mirror of
https://github.com/microsoft/PowerToys.git
synced 2026-07-06 02:19:51 +02:00
Compare commits
22 Commits
copilot/cl
...
copilot/im
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ab08e318d8 | ||
|
|
ce0439f580 | ||
|
|
5b7d3c0a82 | ||
|
|
5e12d2476d | ||
|
|
3b6bf9c0ce | ||
|
|
9020b18a9f | ||
|
|
f59f67cb08 | ||
|
|
890ea40f8a | ||
|
|
0089de33bd | ||
|
|
3e2914a0b2 | ||
|
|
3554f0884b | ||
|
|
ad60090096 | ||
|
|
de6a609d16 | ||
|
|
243255ecea | ||
|
|
70dd9db67a | ||
|
|
fb7c945a2c | ||
|
|
c72580c8f2 | ||
|
|
ce6debf68b | ||
|
|
1eca1713e1 | ||
|
|
1254cba088 | ||
|
|
b5373cbb2b | ||
|
|
b82e6c508d |
9
.github/actions/spell-check/allow/code.txt
vendored
9
.github/actions/spell-check/allow/code.txt
vendored
@@ -19,6 +19,7 @@ OLIVEGREEN
|
||||
PALEBLUE
|
||||
PArgb
|
||||
Pbgra
|
||||
SRGBTo
|
||||
WHITEONBLACK
|
||||
|
||||
|
||||
@@ -327,10 +328,14 @@ MRUCMPPROC
|
||||
MRUINFO
|
||||
REGSTR
|
||||
|
||||
# Quick Accent
|
||||
Ene
|
||||
|
||||
# Misc Win32 APIs and PInvokes
|
||||
INVOKEIDLIST
|
||||
MEMORYSTATUSEX
|
||||
ABE
|
||||
Mdt
|
||||
HTCAPTION
|
||||
POSCHANGED
|
||||
QUERYPOS
|
||||
@@ -387,5 +392,9 @@ nostdin
|
||||
engtype
|
||||
Nonpaged
|
||||
|
||||
# Spell-check fragments
|
||||
traies
|
||||
udit
|
||||
|
||||
# XAML
|
||||
Untargeted
|
||||
|
||||
2
.github/actions/spell-check/excludes.txt
vendored
2
.github/actions/spell-check/excludes.txt
vendored
@@ -140,8 +140,6 @@
|
||||
^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
|
||||
^src/modules/powerrename/unittests/testdata/avif_test\.avif$
|
||||
^src/modules/powerrename/unittests/testdata/heif_test\.heic$
|
||||
|
||||
11
.github/actions/spell-check/expect.txt
vendored
11
.github/actions/spell-check/expect.txt
vendored
@@ -791,7 +791,6 @@ lowlevel
|
||||
LOWORD
|
||||
lparam
|
||||
LPBITMAPINFOHEADER
|
||||
LPCFHOOKPROC
|
||||
lpch
|
||||
LPCITEMIDLIST
|
||||
LPCLSID
|
||||
@@ -833,7 +832,6 @@ lstrlen
|
||||
LTEXT
|
||||
LTRREADING
|
||||
luid
|
||||
LUMA
|
||||
lusrmgr
|
||||
LVal
|
||||
LWA
|
||||
@@ -852,7 +850,6 @@ MAPPEDTOSAMEKEY
|
||||
MAPTOSAMESHORTCUT
|
||||
MAPVK
|
||||
MARKDOWNPREVIEWHANDLERCPP
|
||||
MAXDWORD
|
||||
MAXSHORTCUTSIZE
|
||||
maxversiontested
|
||||
MBM
|
||||
@@ -917,7 +914,6 @@ MOUSEINPUT
|
||||
MOVESIZEEND
|
||||
MOVESIZESTART
|
||||
MRM
|
||||
MRT
|
||||
mru
|
||||
msc
|
||||
mscorlib
|
||||
@@ -1037,9 +1033,7 @@ NORMALDISPLAY
|
||||
NORMALUSER
|
||||
NOSEARCH
|
||||
NOSENDCHANGING
|
||||
NOSIZE
|
||||
notdefault
|
||||
Nosize
|
||||
NOTHOUSANDS
|
||||
NOTICKS
|
||||
NOTIFICATIONSDLL
|
||||
@@ -1694,6 +1688,7 @@ UOI
|
||||
UPDATENOW
|
||||
updown
|
||||
UPGRADINGPRODUCTCODE
|
||||
upserts
|
||||
Uptool
|
||||
urld
|
||||
Usb
|
||||
@@ -1778,7 +1773,6 @@ webpage
|
||||
websites
|
||||
wekyb
|
||||
wgpocpl
|
||||
WIC
|
||||
wic
|
||||
wifi
|
||||
winapi
|
||||
@@ -2176,7 +2170,7 @@ nodiscard
|
||||
nologo
|
||||
nomove
|
||||
nosize
|
||||
notopmost
|
||||
NOTOPMOST
|
||||
Notupdated
|
||||
notwindows
|
||||
nowarn
|
||||
@@ -2305,7 +2299,6 @@ virama
|
||||
vnd
|
||||
vredraw
|
||||
VSpeed
|
||||
VSync
|
||||
WASDK
|
||||
WCRAPI
|
||||
wft
|
||||
|
||||
9
.github/actions/spell-check/patterns.txt
vendored
9
.github/actions/spell-check/patterns.txt
vendored
@@ -191,15 +191,6 @@ aka\.ms/[a-zA-Z0-9]+
|
||||
# #pragma lib
|
||||
^\s*#pragma comment\(lib, ".*?"\)
|
||||
|
||||
# UnitTests
|
||||
\[DataRow\(.*\)\]
|
||||
|
||||
# AdditionalDependencies
|
||||
<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`
|
||||
|
||||
13
.github/workflows/spelling2.yml
vendored
13
.github/workflows/spelling2.yml
vendored
@@ -93,7 +93,7 @@ jobs:
|
||||
steps:
|
||||
- name: check-spelling
|
||||
id: spelling
|
||||
uses: check-spelling/check-spelling@c635c2f3f714eec2fcf27b643a1919b9a811ef2e # v0.0.25
|
||||
uses: check-spelling/check-spelling@cfb6f7e75bbfc89c71eaa30366d0c166f1bd9c8c # v0.0.26
|
||||
with:
|
||||
config: .github/actions/spell-check
|
||||
suppress_push_for_open_pull_request: ${{ github.actor != 'dependabot[bot]' && 1 }}
|
||||
@@ -135,6 +135,7 @@ jobs:
|
||||
cspell:cpp/compiler-msvc.txt
|
||||
cspell:python/common/extra.txt
|
||||
cspell:scala/scala.txt
|
||||
ignored: ignored-expect-variant
|
||||
|
||||
comment-push:
|
||||
name: Report (Push)
|
||||
@@ -147,10 +148,8 @@ jobs:
|
||||
if: (success() || failure()) && needs.spelling.outputs.followup && github.event_name == 'push'
|
||||
steps:
|
||||
- name: comment
|
||||
uses: check-spelling/check-spelling@c635c2f3f714eec2fcf27b643a1919b9a811ef2e # v0.0.25
|
||||
uses: check-spelling/check-spelling@cfb6f7e75bbfc89c71eaa30366d0c166f1bd9c8c # v0.0.26
|
||||
with:
|
||||
config: .github/actions/spell-check
|
||||
checkout: true
|
||||
spell_check_this: microsoft/PowerToys@main
|
||||
task: ${{ needs.spelling.outputs.followup }}
|
||||
|
||||
@@ -166,10 +165,8 @@ jobs:
|
||||
if: (success() || failure()) && needs.spelling.outputs.followup && contains(github.event_name, 'pull_request')
|
||||
steps:
|
||||
- name: comment
|
||||
uses: check-spelling/check-spelling@c635c2f3f714eec2fcf27b643a1919b9a811ef2e # v0.0.25
|
||||
uses: check-spelling/check-spelling@cfb6f7e75bbfc89c71eaa30366d0c166f1bd9c8c # v0.0.26
|
||||
with:
|
||||
config: .github/actions/spell-check
|
||||
checkout: true
|
||||
spell_check_this: check-spelling/spell-check-this@prerelease
|
||||
task: ${{ needs.spelling.outputs.followup }}
|
||||
experimental_apply_changes_via_bot: ${{ github.repository_owner != 'microsoft' && 1 }}
|
||||
@@ -193,7 +190,7 @@ jobs:
|
||||
cancel-in-progress: false
|
||||
steps:
|
||||
- name: apply spelling updates
|
||||
uses: check-spelling/check-spelling@c635c2f3f714eec2fcf27b643a1919b9a811ef2e # v0.0.25
|
||||
uses: check-spelling/check-spelling@cfb6f7e75bbfc89c71eaa30366d0c166f1bd9c8c # v0.0.26
|
||||
with:
|
||||
experimental_apply_changes_via_bot: ${{ github.repository_owner != 'microsoft' && 1 }}
|
||||
checkout: true
|
||||
|
||||
@@ -217,7 +217,11 @@
|
||||
"PowerToys.PowerAccentModuleInterface.dll",
|
||||
"PowerToys.PowerAccentKeyboardService.dll",
|
||||
|
||||
"PowerToys.PowerDisplayModuleInterface.dll",
|
||||
"WinUI3Apps\\PowerToys.PowerDisplay.dll",
|
||||
"WinUI3Apps\\PowerToys.PowerDisplay.exe",
|
||||
"PowerDisplay.Lib.dll",
|
||||
"PowerDisplay.Models.dll",
|
||||
|
||||
"WinUI3Apps\\PowerToys.PowerRenameExt.dll",
|
||||
"WinUI3Apps\\PowerToys.PowerRename.exe",
|
||||
|
||||
@@ -85,6 +85,14 @@
|
||||
<ForceImportBeforeCppProps>$(RepoRoot)Cpp.Build.props</ForceImportBeforeCppProps>
|
||||
</PropertyGroup>
|
||||
|
||||
<!-- Force all C# projects to reference these packages from NuGet so their 10.x versions
|
||||
take precedence over the 9.x versions bundled in the .NET 9 runtime. Without this,
|
||||
projects that don't transitively depend on these packages would get the runtime 9.x
|
||||
version, causing deps.json version conflicts with projects that do. -->
|
||||
<ItemGroup Condition="'$(MSBuildProjectExtension)' == '.csproj'">
|
||||
<PackageReference Include="System.Diagnostics.EventLog" />
|
||||
<PackageReference Include="System.Threading.Channels" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup Condition="'$(MSBuildProjectExtension)' == '.csproj' and '$(_IsSkippedTestProject)' != 'true'">
|
||||
<PackageReference Include="StyleCop.Analyzers">
|
||||
|
||||
@@ -42,26 +42,26 @@
|
||||
<PackageVersion Include="Microsoft.CodeAnalysis.NetAnalyzers" Version="9.0.0" />
|
||||
<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.10" />
|
||||
<PackageVersion Include="Microsoft.Bcl.AsyncInterfaces" Version="10.0.5" />
|
||||
<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.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.Extensions.AI" Version="10.4.1" />
|
||||
<PackageVersion Include="Microsoft.Extensions.AI.OpenAI" Version="10.4.1" />
|
||||
<PackageVersion Include="Microsoft.Extensions.Caching.Abstractions" Version="10.0.5" />
|
||||
<PackageVersion Include="Microsoft.Extensions.Caching.Memory" Version="10.0.5" />
|
||||
<PackageVersion Include="Microsoft.Extensions.DependencyInjection" Version="10.0.5" />
|
||||
<PackageVersion Include="Microsoft.Extensions.Logging" Version="10.0.5" />
|
||||
<PackageVersion Include="Microsoft.Extensions.Logging.Abstractions" Version="10.0.5" />
|
||||
<PackageVersion Include="Microsoft.Extensions.Hosting" Version="10.0.5" />
|
||||
<PackageVersion Include="Microsoft.Extensions.Hosting.WindowsServices" Version="10.0.5" />
|
||||
<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.SemanticKernel" Version="1.74.0" />
|
||||
<PackageVersion Include="Microsoft.SemanticKernel.Connectors.OpenAI" Version="1.74.0" />
|
||||
<PackageVersion Include="Microsoft.SemanticKernel.Connectors.AzureAIInference" Version="1.74.0-beta" />
|
||||
<PackageVersion Include="Microsoft.SemanticKernel.Connectors.Google" Version="1.74.0-alpha" />
|
||||
<PackageVersion Include="Microsoft.SemanticKernel.Connectors.MistralAI" Version="1.74.0-alpha" />
|
||||
<PackageVersion Include="Microsoft.SemanticKernel.Connectors.Ollama" Version="1.74.0-alpha" />
|
||||
<PackageVersion Include="Microsoft.Toolkit.Uwp.Notifications" Version="7.1.2" />
|
||||
<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. -->
|
||||
@@ -89,11 +89,11 @@
|
||||
<PackageVersion Include="MSTest" Version="$(MSTestVersion)" />
|
||||
<PackageVersion Include="MSTest.TestFramework" Version="$(MSTestVersion)" />
|
||||
<PackageVersion Include="NJsonSchema" Version="11.4.0" />
|
||||
<PackageVersion Include="Newtonsoft.Json" Version="13.0.3" />
|
||||
<PackageVersion Include="Newtonsoft.Json" Version="13.0.4" />
|
||||
<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.5.0" />
|
||||
<PackageVersion Include="OpenAI" Version="2.9.1" />
|
||||
<PackageVersion Include="Polly.Core" Version="8.6.5" />
|
||||
<PackageVersion Include="ReverseMarkdown" Version="4.1.0" />
|
||||
<PackageVersion Include="RtfPipe" Version="2.0.7677.4303" />
|
||||
@@ -113,23 +113,27 @@
|
||||
<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.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.10" />
|
||||
<!-- Package System.Diagnostics.EventLog force-pinned to 10.x to match other Microsoft.Extensions packages;
|
||||
referenced by all C# projects (via Directory.Build.props) to override the 9.x version from the .NET runtime. -->
|
||||
<PackageVersion Include="System.Diagnostics.EventLog" Version="10.0.5" />
|
||||
<!-- 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.10" />
|
||||
<PackageVersion Include="System.ClientModel" Version="1.7.0" />
|
||||
<PackageVersion Include="System.ClientModel" Version="1.9.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.Numerics.Tensors" Version="10.0.4" />
|
||||
<!-- Including System.Threading.Channels to force version across all projects;
|
||||
referenced by all C# projects (via Directory.Build.props) to override the 9.x version from the .NET runtime. -->
|
||||
<PackageVersion Include="System.Threading.Channels" Version="10.0.4" />
|
||||
<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.10" />
|
||||
<PackageVersion Include="System.ServiceProcess.ServiceController" Version="9.0.10" />
|
||||
<PackageVersion Include="System.ServiceProcess.ServiceController" Version="10.0.5" />
|
||||
<PackageVersion Include="System.Text.Encoding.CodePages" Version="9.0.10" />
|
||||
<PackageVersion Include="System.Text.Json" Version="9.0.10" />
|
||||
<PackageVersion Include="System.Text.Json" Version="10.0.5" />
|
||||
<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" />
|
||||
|
||||
@@ -709,17 +709,19 @@
|
||||
</Project>
|
||||
</Folder>
|
||||
<Folder Name="/modules/PowerDisplay/">
|
||||
<Project Path="src/modules/powerdisplay/PowerDisplay.Models/PowerDisplay.Models.csproj">
|
||||
<Platform Solution="*|ARM64" Project="ARM64" />
|
||||
<Platform Solution="*|x64" Project="x64" />
|
||||
</Project>
|
||||
<Project Path="src/modules/powerdisplay/PowerDisplay.Lib/PowerDisplay.Lib.csproj">
|
||||
<Platform Solution="*|ARM64" Project="ARM64" />
|
||||
<Platform Solution="*|x64" Project="x64" />
|
||||
</Project>
|
||||
<!-- TEMPORARILY_DISABLED: PowerDisplay
|
||||
<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">
|
||||
@@ -789,6 +791,10 @@
|
||||
<Platform Solution="*|x64" Project="x64" />
|
||||
</Project>
|
||||
<Project Path="src/modules/peek/peek/peek.vcxproj" Id="a1425b53-3d61-4679-8623-e64a0d3d0a48" />
|
||||
<Project Path="src/modules/peek/Peek.Common.UnitTests/Peek.Common.UnitTests.csproj">
|
||||
<Platform Solution="*|ARM64" Project="ARM64" />
|
||||
<Platform Solution="*|x64" Project="x64" />
|
||||
</Project>
|
||||
</Folder>
|
||||
<Folder Name="/modules/PowerAccent/">
|
||||
<Project Path="src/modules/poweraccent/PowerAccent.Core/PowerAccent.Core.csproj">
|
||||
@@ -801,6 +807,10 @@
|
||||
</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" />
|
||||
<Project Path="src/modules/poweraccent/PowerAccent.Core.UnitTests/PowerAccent.Core.UnitTests.csproj">
|
||||
<Platform Solution="*|ARM64" Project="ARM64" />
|
||||
<Platform Solution="*|x64" Project="x64" />
|
||||
</Project>
|
||||
</Folder>
|
||||
<Folder Name="/modules/PowerOCR/">
|
||||
<Project Path="src/modules/PowerOCR/PowerOCR/PowerOCR.csproj">
|
||||
|
||||
@@ -1594,6 +1594,7 @@ UINT __stdcall TerminateProcessesCA(MSIHANDLE hInstall)
|
||||
L"PowerToys.PowerRename.exe",
|
||||
L"PowerToys.ImageResizer.exe",
|
||||
L"PowerToys.LightSwitchService.exe",
|
||||
L"PowerToys.PowerDisplay.exe",
|
||||
L"PowerToys.GcodeThumbnailProvider.exe",
|
||||
L"PowerToys.BgcodeThumbnailProvider.exe",
|
||||
L"PowerToys.PdfThumbnailProvider.exe",
|
||||
|
||||
@@ -47,6 +47,7 @@ call powershell.exe -NonInteractive -executionpolicy Unrestricted -File $(MSBuil
|
||||
call move /Y ..\..\..\NewPlus.wxs.bk ..\..\..\NewPlus.wxs
|
||||
call move /Y ..\..\..\Peek.wxs.bk ..\..\..\Peek.wxs
|
||||
call move /Y ..\..\..\PowerRename.wxs.bk ..\..\..\PowerRename.wxs
|
||||
call move /Y ..\..\..\PowerDisplay.wxs.bk ..\..\..\PowerDisplay.wxs
|
||||
call move /Y ..\..\..\Product.wxs.bk ..\..\..\Product.wxs
|
||||
call move /Y ..\..\..\RegistryPreview.wxs.bk ..\..\..\RegistryPreview.wxs
|
||||
call move /Y ..\..\..\Resources.wxs.bk ..\..\..\Resources.wxs
|
||||
@@ -123,6 +124,7 @@ call powershell.exe -NonInteractive -executionpolicy Unrestricted -File $(MSBuil
|
||||
<Compile Include="KeyboardManager.wxs" />
|
||||
<Compile Include="Peek.wxs" />
|
||||
<Compile Include="PowerRename.wxs" />
|
||||
<Compile Include="PowerDisplay.wxs" />
|
||||
<Compile Include="DscResources.wxs" />
|
||||
<Compile Include="RegistryPreview.wxs" />
|
||||
<Compile Include="Run.wxs" />
|
||||
|
||||
@@ -53,6 +53,7 @@
|
||||
<ComponentGroupRef Id="LightSwitchComponentGroup" />
|
||||
<ComponentGroupRef Id="PeekComponentGroup" />
|
||||
<ComponentGroupRef Id="PowerRenameComponentGroup" />
|
||||
<ComponentGroupRef Id="PowerDisplayComponentGroup" />
|
||||
<ComponentGroupRef Id="RegistryPreviewComponentGroup" />
|
||||
<ComponentGroupRef Id="RunComponentGroup" />
|
||||
<ComponentGroupRef Id="SettingsComponentGroup" />
|
||||
|
||||
@@ -212,6 +212,10 @@ Generate-FileComponents -fileListName "PeekAssetsFiles" -wxsFilePath $PSScriptRo
|
||||
Generate-FileList -fileDepsJson "" -fileListName PowerRenameAssetsFiles -wxsFilePath $PSScriptRoot\PowerRename.wxs -depsPath "$PSScriptRoot..\..\..\$platform\Release\WinUI3Apps\Assets\PowerRename\"
|
||||
Generate-FileComponents -fileListName "PowerRenameAssetsFiles" -wxsFilePath $PSScriptRoot\PowerRename.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
|
||||
|
||||
#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
|
||||
|
||||
@@ -150,7 +150,6 @@
|
||||
<decimal value="0" />
|
||||
</disabledValue>
|
||||
</policy>
|
||||
<!-- TEMPORARILY_DISABLED: PowerDisplay
|
||||
<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" />
|
||||
@@ -161,7 +160,6 @@
|
||||
<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" />
|
||||
|
||||
@@ -249,7 +249,7 @@ If you don't configure this policy, the user will be able to control the setting
|
||||
<string id="ConfigureEnabledUtilityCmdPal">CmdPal: Configure enabled state</string>
|
||||
<string id="ConfigureEnabledUtilityCropAndLock">Crop And Lock: Configure enabled state</string>
|
||||
<string id="ConfigureEnabledUtilityLightSwitch">Light Switch: Configure enabled state</string>
|
||||
<!-- <string id="ConfigureEnabledUtilityPowerDisplay">PowerDisplay: Configure enabled state</string> --><!-- TEMPORARILY_DISABLED: PowerDisplay -->
|
||||
<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>
|
||||
|
||||
@@ -17,10 +17,7 @@
|
||||
<PackageReference Include="MSTest" />
|
||||
<PackageReference Include="System.IO.Abstractions" />
|
||||
<PackageReference Include="System.IO.Abstractions.TestingHelpers" />
|
||||
<PackageReference Include="System.Diagnostics.EventLog">
|
||||
<!-- This package is a dependency of Microsoft.Extensions.Logging.EventLog, but we need to set it here so we can exclude the assets, so it doesn't conflict with the 8.0.1 dll coming from .NET SDK. -->
|
||||
<ExcludeAssets>runtime</ExcludeAssets> <!-- Should already be present on .net sdk runtime, so we avoid the conflicting runtime version from nuget -->
|
||||
</PackageReference>
|
||||
<!-- System.Diagnostics.EventLog is now provided to all C# projects via Directory.Build.props -->
|
||||
<!-- HACK: CmdPal uses CommunityToolkit.Common directly. Align the version. -->
|
||||
<PackageReference Include="CommunityToolkit.Common" />
|
||||
</ItemGroup>
|
||||
|
||||
180
src/modules/Hosts/Hosts.Tests/ValidationHelperTest.cs
Normal file
180
src/modules/Hosts/Hosts.Tests/ValidationHelperTest.cs
Normal file
@@ -0,0 +1,180 @@
|
||||
// Copyright (c) Microsoft Corporation
|
||||
// The 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 HostsUILib;
|
||||
using HostsUILib.Helpers;
|
||||
using Microsoft.VisualStudio.TestTools.UnitTesting;
|
||||
|
||||
namespace Hosts.Tests
|
||||
{
|
||||
[TestClass]
|
||||
public class ValidationHelperTest
|
||||
{
|
||||
[DataTestMethod]
|
||||
[DataRow("0.0.0.0")]
|
||||
[DataRow("127.0.0.1")]
|
||||
[DataRow("192.168.1.1")]
|
||||
[DataRow("255.255.255.255")]
|
||||
[DataRow("10.0.0.1")]
|
||||
[DataRow("172.16.0.1")]
|
||||
[DataRow("1.2.3.4")]
|
||||
[DataRow("01.01.01.01")]
|
||||
[DataRow("0.0.0.1")]
|
||||
public void ValidIPv4_ValidAddresses_ReturnsTrue(string address)
|
||||
{
|
||||
Assert.IsTrue(ValidationHelper.ValidIPv4(address));
|
||||
}
|
||||
|
||||
[DataTestMethod]
|
||||
[DataRow("256.0.0.0")]
|
||||
[DataRow("0.256.0.0")]
|
||||
[DataRow("0.0.256.0")]
|
||||
[DataRow("0.0.0.256")]
|
||||
[DataRow("999.999.999.999")]
|
||||
[DataRow("1.2.3")]
|
||||
[DataRow("1.2.3.4.5")]
|
||||
[DataRow("1.2.3.")]
|
||||
[DataRow(".1.2.3")]
|
||||
[DataRow("1..2.3")]
|
||||
[DataRow("abc.def.ghi.jkl")]
|
||||
[DataRow("192.168.1.1/24")]
|
||||
[DataRow("192.168.1.1:80")]
|
||||
[DataRow("192.168.1")]
|
||||
[DataRow("-1.0.0.0")]
|
||||
public void ValidIPv4_InvalidAddresses_ReturnsFalse(string address)
|
||||
{
|
||||
Assert.IsFalse(ValidationHelper.ValidIPv4(address));
|
||||
}
|
||||
|
||||
[DataTestMethod]
|
||||
[DataRow(null)]
|
||||
[DataRow("")]
|
||||
[DataRow(" ")]
|
||||
[DataRow("\t")]
|
||||
[DataRow("\n")]
|
||||
public void ValidIPv4_NullOrWhitespace_ReturnsFalse(string address)
|
||||
{
|
||||
Assert.IsFalse(ValidationHelper.ValidIPv4(address));
|
||||
}
|
||||
|
||||
[DataTestMethod]
|
||||
[DataRow("::1")]
|
||||
[DataRow("::")]
|
||||
[DataRow("2001:0db8:85a3:0000:0000:8a2e:0370:7334")]
|
||||
[DataRow("2001:db8:85a3:0:0:8a2e:370:7334")]
|
||||
[DataRow("2001:db8:85a3::8a2e:370:7334")]
|
||||
[DataRow("fe80::1")]
|
||||
[DataRow("ff02::1")]
|
||||
[DataRow("2001:db8::1")]
|
||||
[DataRow("::ffff:192.168.1.1")]
|
||||
[DataRow("fe80::1%eth0")]
|
||||
public void ValidIPv6_ValidAddresses_ReturnsTrue(string address)
|
||||
{
|
||||
Assert.IsTrue(ValidationHelper.ValidIPv6(address));
|
||||
}
|
||||
|
||||
[DataTestMethod]
|
||||
[DataRow("2001:db8:85a3:0:0:8a2e:370:7334:extra")]
|
||||
[DataRow("gggg::1")]
|
||||
[DataRow("12345::1")]
|
||||
[DataRow("192.168.1.1")]
|
||||
[DataRow("::ffff:999.999.999.999")]
|
||||
[DataRow("hello")]
|
||||
[DataRow("2001:db8:85a3::8a2e:370:7334:1234:5678")]
|
||||
public void ValidIPv6_InvalidAddresses_ReturnsFalse(string address)
|
||||
{
|
||||
Assert.IsFalse(ValidationHelper.ValidIPv6(address));
|
||||
}
|
||||
|
||||
[DataTestMethod]
|
||||
[DataRow(null)]
|
||||
[DataRow("")]
|
||||
[DataRow(" ")]
|
||||
[DataRow("\t")]
|
||||
public void ValidIPv6_NullOrWhitespace_ReturnsFalse(string address)
|
||||
{
|
||||
Assert.IsFalse(ValidationHelper.ValidIPv6(address));
|
||||
}
|
||||
|
||||
[DataTestMethod]
|
||||
[DataRow("localhost")]
|
||||
[DataRow("example.com")]
|
||||
[DataRow("sub.domain.example.com")]
|
||||
[DataRow("my-host")]
|
||||
[DataRow("host1 host2")]
|
||||
[DataRow("host1 host2 host3")]
|
||||
[DataRow("example.com www.example.com")]
|
||||
public void ValidHosts_ValidHostnames_ReturnsTrue(string hosts)
|
||||
{
|
||||
Assert.IsTrue(ValidationHelper.ValidHosts(hosts, validateHostsLength: false));
|
||||
}
|
||||
|
||||
[DataTestMethod]
|
||||
[DataRow(null)]
|
||||
[DataRow("")]
|
||||
[DataRow(" ")]
|
||||
[DataRow("\t")]
|
||||
public void ValidHosts_NullOrWhitespace_ReturnsFalse(string hosts)
|
||||
{
|
||||
Assert.IsFalse(ValidationHelper.ValidHosts(hosts, validateHostsLength: false));
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void ValidHosts_WithLengthValidation_ExceedsMaxCount_ReturnsFalse()
|
||||
{
|
||||
// Create a host string with one more than MaxHostsCount hosts
|
||||
var hosts = string.Join(" ", Enumerable.Range(1, Consts.MaxHostsCount + 1).Select(i => $"h{i}"));
|
||||
Assert.IsFalse(ValidationHelper.ValidHosts(hosts, validateHostsLength: true));
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void ValidHosts_WithLengthValidation_AtMaxCount_ReturnsTrue()
|
||||
{
|
||||
// Create a host string with exactly MaxHostsCount hosts
|
||||
var hosts = string.Join(" ", Enumerable.Range(1, Consts.MaxHostsCount).Select(i => $"h{i}"));
|
||||
Assert.IsTrue(ValidationHelper.ValidHosts(hosts, validateHostsLength: true));
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void ValidHosts_WithLengthValidation_BelowMaxCount_ReturnsTrue()
|
||||
{
|
||||
string hosts = "host1 host2 host3";
|
||||
Assert.IsTrue(ValidationHelper.ValidHosts(hosts, validateHostsLength: true));
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void ValidHosts_WithoutLengthValidation_ExceedsMaxCount_ReturnsTrue()
|
||||
{
|
||||
// When validateHostsLength is false, exceeding max count should still return true
|
||||
var hosts = string.Join(" ", Enumerable.Range(1, Consts.MaxHostsCount + 1).Select(i => $"h{i}"));
|
||||
Assert.IsTrue(ValidationHelper.ValidHosts(hosts, validateHostsLength: false));
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void ValidHosts_SingleHost_ReturnsTrue()
|
||||
{
|
||||
Assert.IsTrue(ValidationHelper.ValidHosts("localhost", validateHostsLength: true));
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void ValidHosts_InvalidHostname_ReturnsFalse()
|
||||
{
|
||||
Assert.IsFalse(ValidationHelper.ValidHosts("host_with!invalid@chars", validateHostsLength: false));
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void ValidHosts_HostWithSubdomains_ReturnsTrue()
|
||||
{
|
||||
Assert.IsTrue(ValidationHelper.ValidHosts("sub.domain.example.com", validateHostsLength: true));
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void ValidHosts_MultipleValidHosts_WithLengthValidation_ReturnsTrue()
|
||||
{
|
||||
Assert.IsTrue(ValidationHelper.ValidHosts("example.com www.example.com api.example.com", validateHostsLength: true));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,459 @@
|
||||
// Copyright (c) Microsoft Corporation
|
||||
// The Microsoft Corporation 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.Globalization;
|
||||
|
||||
using ColorPicker.Helpers;
|
||||
using ManagedCommon;
|
||||
using Microsoft.VisualStudio.TestTools.UnitTesting;
|
||||
|
||||
namespace ColorPicker.UnitTests.Helpers
|
||||
{
|
||||
/// <summary>
|
||||
/// Test class to test <see cref="ColorFormatHelper"/> conversion methods not covered
|
||||
/// by the existing <see cref="ColorConverterTest"/> (which tests HSL and HSV).
|
||||
/// Covers: CMYK, HSB, HSI, HWB, CIE LAB, CIE XYZ, Oklab, Oklch, sRGB-to-linear, NCol.
|
||||
/// </summary>
|
||||
[TestClass]
|
||||
public class ColorFormatConversionTest
|
||||
{
|
||||
[TestMethod]
|
||||
public void ConvertToCMYK_Black_Returns0_0_0_1()
|
||||
{
|
||||
var result = ColorFormatHelper.ConvertToCMYKColor(Color.FromArgb(255, 0, 0, 0));
|
||||
Assert.AreEqual(0d, result.Cyan);
|
||||
Assert.AreEqual(0d, result.Magenta);
|
||||
Assert.AreEqual(0d, result.Yellow);
|
||||
Assert.AreEqual(1d, result.BlackKey);
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void ConvertToCMYK_White_Returns0_0_0_0()
|
||||
{
|
||||
var result = ColorFormatHelper.ConvertToCMYKColor(Color.FromArgb(255, 255, 255, 255));
|
||||
Assert.AreEqual(0d, result.Cyan, 0.01);
|
||||
Assert.AreEqual(0d, result.Magenta, 0.01);
|
||||
Assert.AreEqual(0d, result.Yellow, 0.01);
|
||||
Assert.AreEqual(0d, result.BlackKey, 0.01);
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void ConvertToCMYK_Red_Returns0_1_1_0()
|
||||
{
|
||||
var result = ColorFormatHelper.ConvertToCMYKColor(Color.FromArgb(255, 255, 0, 0));
|
||||
Assert.AreEqual(0d, result.Cyan, 0.01);
|
||||
Assert.AreEqual(1d, result.Magenta, 0.01);
|
||||
Assert.AreEqual(1d, result.Yellow, 0.01);
|
||||
Assert.AreEqual(0d, result.BlackKey, 0.01);
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void ConvertToCMYK_Green_Returns1_0_1_0()
|
||||
{
|
||||
var result = ColorFormatHelper.ConvertToCMYKColor(Color.FromArgb(255, 0, 255, 0));
|
||||
Assert.AreEqual(1d, result.Cyan, 0.01);
|
||||
Assert.AreEqual(0d, result.Magenta, 0.01);
|
||||
Assert.AreEqual(1d, result.Yellow, 0.01);
|
||||
Assert.AreEqual(0d, result.BlackKey, 0.01);
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void ConvertToCMYK_Blue_Returns1_1_0_0()
|
||||
{
|
||||
var result = ColorFormatHelper.ConvertToCMYKColor(Color.FromArgb(255, 0, 0, 255));
|
||||
Assert.AreEqual(1d, result.Cyan, 0.01);
|
||||
Assert.AreEqual(1d, result.Magenta, 0.01);
|
||||
Assert.AreEqual(0d, result.Yellow, 0.01);
|
||||
Assert.AreEqual(0d, result.BlackKey, 0.01);
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void ConvertToCMYK_MidGray_Returns0_0_0_Half()
|
||||
{
|
||||
// RGB(128, 128, 128) should give roughly K ≈ 0.498
|
||||
var result = ColorFormatHelper.ConvertToCMYKColor(Color.FromArgb(255, 128, 128, 128));
|
||||
Assert.AreEqual(0d, result.Cyan, 0.01);
|
||||
Assert.AreEqual(0d, result.Magenta, 0.01);
|
||||
Assert.AreEqual(0d, result.Yellow, 0.01);
|
||||
Assert.AreEqual(128d / 255d, result.BlackKey, 0.01);
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void ConvertToHSB_Black_Returns0_0_0()
|
||||
{
|
||||
var result = ColorFormatHelper.ConvertToHSBColor(Color.FromArgb(255, 0, 0, 0));
|
||||
Assert.AreEqual(0d, result.Hue, 0.5);
|
||||
Assert.AreEqual(0d, result.Saturation, 0.01);
|
||||
Assert.AreEqual(0d, result.Brightness, 0.01);
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void ConvertToHSB_White_Returns0_0_1()
|
||||
{
|
||||
var result = ColorFormatHelper.ConvertToHSBColor(Color.FromArgb(255, 255, 255, 255));
|
||||
Assert.AreEqual(0d, result.Hue, 0.5);
|
||||
Assert.AreEqual(0d, result.Saturation, 0.01);
|
||||
Assert.AreEqual(1d, result.Brightness, 0.01);
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void ConvertToHSB_Red_Returns0_1_1()
|
||||
{
|
||||
var result = ColorFormatHelper.ConvertToHSBColor(Color.FromArgb(255, 255, 0, 0));
|
||||
Assert.AreEqual(0d, result.Hue, 0.5);
|
||||
Assert.AreEqual(1d, result.Saturation, 0.01);
|
||||
Assert.AreEqual(1d, result.Brightness, 0.01);
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void ConvertToHSI_Black_Returns0_0_0()
|
||||
{
|
||||
var result = ColorFormatHelper.ConvertToHSIColor(Color.FromArgb(255, 0, 0, 0));
|
||||
Assert.AreEqual(0d, result.Hue, 0.5);
|
||||
Assert.AreEqual(0d, result.Saturation, 0.01);
|
||||
Assert.AreEqual(0d, result.Intensity, 0.01);
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void ConvertToHSI_White_Returns0_0_1()
|
||||
{
|
||||
var result = ColorFormatHelper.ConvertToHSIColor(Color.FromArgb(255, 255, 255, 255));
|
||||
Assert.AreEqual(0d, result.Hue, 0.5);
|
||||
Assert.AreEqual(0d, result.Saturation, 0.01);
|
||||
Assert.AreEqual(1d, result.Intensity, 0.01);
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void ConvertToHSI_Red_Returns0_1_Third()
|
||||
{
|
||||
// Pure red: intensity = (255+0+0)/(3*255) = 1/3
|
||||
var result = ColorFormatHelper.ConvertToHSIColor(Color.FromArgb(255, 255, 0, 0));
|
||||
Assert.AreEqual(0d, result.Hue, 0.5);
|
||||
Assert.AreEqual(1d, result.Saturation, 0.01);
|
||||
Assert.AreEqual(1d / 3d, result.Intensity, 0.01);
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void ConvertToHWB_Black_Returns0_0_1()
|
||||
{
|
||||
var result = ColorFormatHelper.ConvertToHWBColor(Color.FromArgb(255, 0, 0, 0));
|
||||
Assert.AreEqual(0d, result.Hue, 0.5);
|
||||
Assert.AreEqual(0d, result.Whiteness, 0.01);
|
||||
Assert.AreEqual(1d, result.Blackness, 0.01);
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void ConvertToHWB_White_Returns0_1_0()
|
||||
{
|
||||
var result = ColorFormatHelper.ConvertToHWBColor(Color.FromArgb(255, 255, 255, 255));
|
||||
Assert.AreEqual(0d, result.Hue, 0.5);
|
||||
Assert.AreEqual(1d, result.Whiteness, 0.01);
|
||||
Assert.AreEqual(0d, result.Blackness, 0.01);
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void ConvertToHWB_Red_Returns0_0_0()
|
||||
{
|
||||
var result = ColorFormatHelper.ConvertToHWBColor(Color.FromArgb(255, 255, 0, 0));
|
||||
Assert.AreEqual(0d, result.Hue, 0.5);
|
||||
Assert.AreEqual(0d, result.Whiteness, 0.01);
|
||||
Assert.AreEqual(0d, result.Blackness, 0.01);
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void ConvertToHWB_MidGray_Returns0_Half_Half()
|
||||
{
|
||||
var result = ColorFormatHelper.ConvertToHWBColor(Color.FromArgb(255, 128, 128, 128));
|
||||
Assert.AreEqual(0d, result.Hue, 0.5);
|
||||
Assert.AreEqual(128d / 255d, result.Whiteness, 0.01);
|
||||
Assert.AreEqual(1d - (128d / 255d), result.Blackness, 0.01);
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void ConvertToCIEXYZ_Black_Returns0_0_0()
|
||||
{
|
||||
var result = ColorFormatHelper.ConvertToCIEXYZColor(Color.FromArgb(255, 0, 0, 0));
|
||||
Assert.AreEqual(0d, result.X, 0.001);
|
||||
Assert.AreEqual(0d, result.Y, 0.001);
|
||||
Assert.AreEqual(0d, result.Z, 0.001);
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void ConvertToCIEXYZ_White_ReturnsD65Illuminant()
|
||||
{
|
||||
// White should be close to D65 illuminant: X≈0.9505, Y≈1.0, Z≈1.089
|
||||
var result = ColorFormatHelper.ConvertToCIEXYZColor(Color.FromArgb(255, 255, 255, 255));
|
||||
Assert.AreEqual(0.9505d, result.X, 0.02);
|
||||
Assert.AreEqual(1.0d, result.Y, 0.02);
|
||||
Assert.AreEqual(1.089d, result.Z, 0.02);
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void ConvertToCIEXYZ_Red_HasExpectedValues()
|
||||
{
|
||||
// Pure red: X≈0.4124, Y≈0.2126, Z≈0.0193
|
||||
var result = ColorFormatHelper.ConvertToCIEXYZColor(Color.FromArgb(255, 255, 0, 0));
|
||||
Assert.AreEqual(0.4124d, result.X, 0.02);
|
||||
Assert.AreEqual(0.2126d, result.Y, 0.02);
|
||||
Assert.AreEqual(0.0193d, result.Z, 0.02);
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void ConvertToCIELAB_Black_Returns0_0_0()
|
||||
{
|
||||
var result = ColorFormatHelper.ConvertToCIELABColor(Color.FromArgb(255, 0, 0, 0));
|
||||
Assert.AreEqual(0d, result.Lightness, 0.5);
|
||||
Assert.AreEqual(0d, result.ChromaticityA, 0.5);
|
||||
Assert.AreEqual(0d, result.ChromaticityB, 0.5);
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void ConvertToCIELAB_White_Returns100_0_0()
|
||||
{
|
||||
var result = ColorFormatHelper.ConvertToCIELABColor(Color.FromArgb(255, 255, 255, 255));
|
||||
Assert.AreEqual(100d, result.Lightness, 1.0);
|
||||
Assert.AreEqual(0d, result.ChromaticityA, 1.0);
|
||||
Assert.AreEqual(0d, result.ChromaticityB, 1.0);
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void ConvertToCIELAB_Red_HasPositiveA()
|
||||
{
|
||||
// Red is in the +a* direction
|
||||
var result = ColorFormatHelper.ConvertToCIELABColor(Color.FromArgb(255, 255, 0, 0));
|
||||
Assert.IsTrue(result.ChromaticityA > 0, "Red should have positive a* in CIE LAB");
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void ConvertToCIELAB_Blue_HasNegativeB()
|
||||
{
|
||||
// Blue is in the -b* direction
|
||||
var result = ColorFormatHelper.ConvertToCIELABColor(Color.FromArgb(255, 0, 0, 255));
|
||||
Assert.IsTrue(result.ChromaticityB < 0, "Blue should have negative b* in CIE LAB");
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void ConvertToOklab_Black_Returns0_0_0()
|
||||
{
|
||||
var result = ColorFormatHelper.ConvertToOklabColor(Color.FromArgb(255, 0, 0, 0));
|
||||
Assert.AreEqual(0d, result.Lightness, 0.01);
|
||||
Assert.AreEqual(0d, result.ChromaticityA, 0.01);
|
||||
Assert.AreEqual(0d, result.ChromaticityB, 0.01);
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void ConvertToOklab_White_Returns1_0_0()
|
||||
{
|
||||
var result = ColorFormatHelper.ConvertToOklabColor(Color.FromArgb(255, 255, 255, 255));
|
||||
Assert.AreEqual(1d, result.Lightness, 0.02);
|
||||
Assert.AreEqual(0d, result.ChromaticityA, 0.02);
|
||||
Assert.AreEqual(0d, result.ChromaticityB, 0.02);
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void ConvertToOklch_Black_Returns0_0_0()
|
||||
{
|
||||
var result = ColorFormatHelper.ConvertToOklchColor(Color.FromArgb(255, 0, 0, 0));
|
||||
Assert.AreEqual(0d, result.Lightness, 0.01);
|
||||
Assert.AreEqual(0d, result.Chroma, 0.01);
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void ConvertToOklch_White_Returns1_0_Any()
|
||||
{
|
||||
var result = ColorFormatHelper.ConvertToOklchColor(Color.FromArgb(255, 255, 255, 255));
|
||||
Assert.AreEqual(1d, result.Lightness, 0.02);
|
||||
Assert.AreEqual(0d, result.Chroma, 0.02);
|
||||
|
||||
// Hue is undefined for achromatic colors, so we don't assert it
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void ConvertToOklch_Chroma_IsNonNegative()
|
||||
{
|
||||
// Chroma should always be non-negative
|
||||
var colors = new[] { Color.Red, Color.Green, Color.Blue, Color.Yellow, Color.Cyan, Color.Magenta };
|
||||
foreach (var color in colors)
|
||||
{
|
||||
var result = ColorFormatHelper.ConvertToOklchColor(color);
|
||||
Assert.IsTrue(result.Chroma >= 0, $"Chroma should be non-negative for {color.Name}");
|
||||
}
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void ConvertSRGBToLinear_Zero_ReturnsZero()
|
||||
{
|
||||
var result = ColorFormatHelper.ConvertSRGBToLinearRGB(0, 0, 0);
|
||||
Assert.AreEqual(0d, result.R, 0.001);
|
||||
Assert.AreEqual(0d, result.G, 0.001);
|
||||
Assert.AreEqual(0d, result.B, 0.001);
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void ConvertSRGBToLinear_One_ReturnsOne()
|
||||
{
|
||||
var result = ColorFormatHelper.ConvertSRGBToLinearRGB(1, 1, 1);
|
||||
Assert.AreEqual(1d, result.R, 0.001);
|
||||
Assert.AreEqual(1d, result.G, 0.001);
|
||||
Assert.AreEqual(1d, result.B, 0.001);
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void ConvertSRGBToLinear_SmallValues_UsesLinearPath()
|
||||
{
|
||||
// For small values (≤ 0.04045), the formula is linear: value / 12.92
|
||||
var result = ColorFormatHelper.ConvertSRGBToLinearRGB(0.04, 0.04, 0.04);
|
||||
Assert.AreEqual(0.04 / 12.92, result.R, 0.001);
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void ConvertSRGBToLinear_LargeValues_UsesGammaPath()
|
||||
{
|
||||
// For larger values, the gamma function is applied
|
||||
// sRGB 0.5 should map to ~0.214
|
||||
var result = ColorFormatHelper.ConvertSRGBToLinearRGB(0.5, 0.5, 0.5);
|
||||
Assert.AreEqual(0.214, result.R, 0.01);
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void ConvertToNaturalColor_Red_ReturnsR0()
|
||||
{
|
||||
var result = ColorFormatHelper.ConvertToNaturalColor(Color.FromArgb(255, 255, 0, 0));
|
||||
Assert.AreEqual("R0", result.Hue);
|
||||
Assert.AreEqual(0d, result.Whiteness, 0.01);
|
||||
Assert.AreEqual(0d, result.Blackness, 0.01);
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void ConvertToNaturalColor_Green_HueStartsWithG()
|
||||
{
|
||||
var result = ColorFormatHelper.ConvertToNaturalColor(Color.FromArgb(255, 0, 128, 0));
|
||||
Assert.IsTrue(result.Hue.StartsWith('G'), $"Green should start with G, got {result.Hue}");
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void ConvertToNaturalColor_Blue_HueStartsWithB()
|
||||
{
|
||||
var result = ColorFormatHelper.ConvertToNaturalColor(Color.FromArgb(255, 0, 0, 255));
|
||||
Assert.IsTrue(result.Hue.StartsWith('B'), $"Blue should start with B, got {result.Hue}");
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void ConvertToNaturalColor_Black_Returns0_0_100()
|
||||
{
|
||||
var result = ColorFormatHelper.ConvertToNaturalColor(Color.FromArgb(255, 0, 0, 0));
|
||||
Assert.AreEqual(0d, result.Whiteness, 0.01);
|
||||
Assert.AreEqual(1d, result.Blackness, 0.01);
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void ConvertToNaturalColor_White_Returns0_100_0()
|
||||
{
|
||||
var result = ColorFormatHelper.ConvertToNaturalColor(Color.FromArgb(255, 255, 255, 255));
|
||||
Assert.AreEqual(1d, result.Whiteness, 0.01);
|
||||
Assert.AreEqual(0d, result.Blackness, 0.01);
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
[DataRow("CMYK", "cmyk(0%, 100%, 100%, 0%)")]
|
||||
[DataRow("HEX", "ff0000")]
|
||||
[DataRow("RGB", "rgb(255, 0, 0)")]
|
||||
[DataRow("HSL", "hsl(0, 100%, 50%)")]
|
||||
[DataRow("HSV", "hsv(0, 100%, 100%)")]
|
||||
[DataRow("HSB", "hsb(0, 100%, 100%)")]
|
||||
[DataRow("HSI", "hsi(0, 100%, 33%)")]
|
||||
[DataRow("HWB", "hwb(0, 0%, 0%)")]
|
||||
[DataRow("Decimal", "255")]
|
||||
[DataRow("HEX Int", "0xFFFF0000")]
|
||||
[DataRow("VEC4", "(1f, 0f, 0f, 1f)")]
|
||||
public void GetStringRepresentation_Red(string type, string expected)
|
||||
{
|
||||
var result = ColorRepresentationHelper.GetStringRepresentation(Color.FromArgb(255, 255, 0, 0), type, ColorFormatHelper.GetDefaultFormat(type));
|
||||
Assert.AreEqual(expected, result);
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
[DataRow("CMYK", "cmyk(0%, 0%, 0%, 0%)")]
|
||||
[DataRow("HEX", "ffffff")]
|
||||
[DataRow("RGB", "rgb(255, 255, 255)")]
|
||||
[DataRow("HSL", "hsl(0, 0%, 100%)")]
|
||||
[DataRow("HSV", "hsv(0, 0%, 100%)")]
|
||||
[DataRow("Decimal", "16777215")]
|
||||
[DataRow("HEX Int", "0xFFFFFFFF")]
|
||||
[DataRow("VEC4", "(1f, 1f, 1f, 1f)")]
|
||||
public void GetStringRepresentation_White(string type, string expected)
|
||||
{
|
||||
var result = ColorRepresentationHelper.GetStringRepresentation(Color.FromArgb(255, 255, 255, 255), type, ColorFormatHelper.GetDefaultFormat(type));
|
||||
Assert.AreEqual(expected, result);
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
[DataRow("CMYK", "cmyk(100%, 0%, 100%, 0%)")]
|
||||
[DataRow("HEX", "00ff00")]
|
||||
[DataRow("RGB", "rgb(0, 255, 0)")]
|
||||
[DataRow("HSL", "hsl(120, 100%, 50%)")]
|
||||
[DataRow("HSV", "hsv(120, 100%, 100%)")]
|
||||
[DataRow("Decimal", "65280")]
|
||||
[DataRow("HEX Int", "0xFF00FF00")]
|
||||
public void GetStringRepresentation_Green(string type, string expected)
|
||||
{
|
||||
var result = ColorRepresentationHelper.GetStringRepresentation(Color.FromArgb(255, 0, 255, 0), type, ColorFormatHelper.GetDefaultFormat(type));
|
||||
Assert.AreEqual(expected, result);
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
[DataRow("CMYK", "cmyk(100%, 100%, 0%, 0%)")]
|
||||
[DataRow("HEX", "0000ff")]
|
||||
[DataRow("RGB", "rgb(0, 0, 255)")]
|
||||
[DataRow("HSL", "hsl(240, 100%, 50%)")]
|
||||
[DataRow("HSV", "hsv(240, 100%, 100%)")]
|
||||
[DataRow("Decimal", "16711680")]
|
||||
[DataRow("HEX Int", "0xFF0000FF")]
|
||||
public void GetStringRepresentation_Blue(string type, string expected)
|
||||
{
|
||||
var result = ColorRepresentationHelper.GetStringRepresentation(Color.FromArgb(255, 0, 0, 255), type, ColorFormatHelper.GetDefaultFormat(type));
|
||||
Assert.AreEqual(expected, result);
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void GetStringRepresentation_EmptyFormat_ReturnsHex()
|
||||
{
|
||||
// When colorFormat is null or empty, should return hex
|
||||
var result = ColorRepresentationHelper.GetStringRepresentation(Color.FromArgb(255, 255, 0, 0), "RGB", string.Empty);
|
||||
Assert.AreEqual("ff0000", result);
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void GetStringRepresentation_NullFormat_ReturnsHex()
|
||||
{
|
||||
var result = ColorRepresentationHelper.GetStringRepresentation(Color.FromArgb(255, 255, 0, 0), "RGB", null);
|
||||
Assert.AreEqual("ff0000", result);
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
[DataRow("RGB")]
|
||||
[DataRow("HEX")]
|
||||
[DataRow("CMYK")]
|
||||
[DataRow("HSL")]
|
||||
[DataRow("HSV")]
|
||||
[DataRow("HSB")]
|
||||
[DataRow("HSI")]
|
||||
[DataRow("HWB")]
|
||||
[DataRow("NCol")]
|
||||
[DataRow("CIEXYZ")]
|
||||
[DataRow("CIELAB")]
|
||||
[DataRow("Oklab")]
|
||||
[DataRow("Oklch")]
|
||||
[DataRow("VEC4")]
|
||||
[DataRow("Decimal")]
|
||||
[DataRow("HEX Int")]
|
||||
public void GetDefaultFormat_KnownTypes_ReturnsNonEmptyString(string formatName)
|
||||
{
|
||||
var result = ColorFormatHelper.GetDefaultFormat(formatName);
|
||||
Assert.IsFalse(string.IsNullOrEmpty(result), $"Default format for {formatName} should not be empty");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
namespace KeyboardManagerEditorUI.Controls
|
||||
{
|
||||
public class KeyChangedEventArgs : EventArgs
|
||||
{
|
||||
public string OldKeyName { get; }
|
||||
|
||||
public string NewKeyName { get; }
|
||||
|
||||
public int NewKeyCode { get; }
|
||||
|
||||
public KeyChangedEventArgs(string oldKeyName, string newKeyName, int newKeyCode)
|
||||
{
|
||||
OldKeyName = oldKeyName;
|
||||
NewKeyName = newKeyName;
|
||||
NewKeyCode = newKeyCode;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
<?xml version="1.0" encoding="utf-8" ?>
|
||||
<UserControl
|
||||
x:Class="KeyboardManagerEditorUI.Controls.KeyDropDownButton"
|
||||
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:commoncontrols="using:Microsoft.PowerToys.Common.UI.Controls"
|
||||
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
|
||||
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
|
||||
mc:Ignorable="d">
|
||||
<DropDownButton
|
||||
x:Name="KeyButton"
|
||||
MinWidth="48"
|
||||
MinHeight="36"
|
||||
Padding="8"
|
||||
HorizontalAlignment="Center"
|
||||
HorizontalContentAlignment="Center"
|
||||
VerticalContentAlignment="Center"
|
||||
CornerRadius="{StaticResource OverlayCornerRadius}"
|
||||
FontSize="16"
|
||||
Style="{StaticResource DefaultKeyVisualDropDownButtonStyle}">
|
||||
<DropDownButton.Flyout>
|
||||
<Flyout
|
||||
x:Name="KeyListFlyout"
|
||||
Closed="KeyListFlyout_Closed"
|
||||
Opening="KeyListFlyout_Opening"
|
||||
Placement="Bottom">
|
||||
<ListView
|
||||
x:Name="KeyListView"
|
||||
MinWidth="200"
|
||||
MaxHeight="320"
|
||||
IsItemClickEnabled="True"
|
||||
ItemClick="KeyListView_ItemClick"
|
||||
SelectionMode="Single">
|
||||
<ListView.ItemTemplate>
|
||||
<DataTemplate>
|
||||
<TextBlock Text="{Binding DisplayName}" />
|
||||
</DataTemplate>
|
||||
</ListView.ItemTemplate>
|
||||
</ListView>
|
||||
</Flyout>
|
||||
</DropDownButton.Flyout>
|
||||
|
||||
<commoncontrols:KeyCharPresenter x:Name="KeyNamePresenter" Content="{x:Bind KeyName, Mode=OneWay}" />
|
||||
</DropDownButton>
|
||||
</UserControl>
|
||||
@@ -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.Collections.Generic;
|
||||
using System.Linq;
|
||||
using KeyboardManagerEditorUI.Interop;
|
||||
using Microsoft.UI.Xaml;
|
||||
using Microsoft.UI.Xaml.Controls;
|
||||
|
||||
namespace KeyboardManagerEditorUI.Controls
|
||||
{
|
||||
public sealed partial class KeyDropDownButton : UserControl
|
||||
{
|
||||
private static List<KeyNameEntry>? _cachedKeyList;
|
||||
private static List<KeyNameEntry>? _cachedShortcutKeyList;
|
||||
|
||||
public static readonly DependencyProperty KeyNameProperty =
|
||||
DependencyProperty.Register(
|
||||
nameof(KeyName),
|
||||
typeof(string),
|
||||
typeof(KeyDropDownButton),
|
||||
new PropertyMetadata(string.Empty));
|
||||
|
||||
public static readonly DependencyProperty IsShortcutProperty =
|
||||
DependencyProperty.Register(
|
||||
nameof(IsShortcut),
|
||||
typeof(bool),
|
||||
typeof(KeyDropDownButton),
|
||||
new PropertyMetadata(true));
|
||||
|
||||
public static readonly DependencyProperty UseAccentStyleProperty =
|
||||
DependencyProperty.Register(
|
||||
nameof(UseAccentStyle),
|
||||
typeof(bool),
|
||||
typeof(KeyDropDownButton),
|
||||
new PropertyMetadata(false));
|
||||
|
||||
public string KeyName
|
||||
{
|
||||
get => (string)GetValue(KeyNameProperty);
|
||||
set => SetValue(KeyNameProperty, value);
|
||||
}
|
||||
|
||||
public bool IsShortcut
|
||||
{
|
||||
get => (bool)GetValue(IsShortcutProperty);
|
||||
set => SetValue(IsShortcutProperty, value);
|
||||
}
|
||||
|
||||
public bool UseAccentStyle
|
||||
{
|
||||
get => (bool)GetValue(UseAccentStyleProperty);
|
||||
set => SetValue(UseAccentStyleProperty, value);
|
||||
}
|
||||
|
||||
public event EventHandler<KeyChangedEventArgs>? KeyChanged;
|
||||
|
||||
public KeyDropDownButton()
|
||||
{
|
||||
this.InitializeComponent();
|
||||
this.Loaded += (_, _) =>
|
||||
{
|
||||
if (UseAccentStyle)
|
||||
{
|
||||
KeyButton.Style = (Style)Application.Current.Resources["AccentKeyVisualDropDownButtonStyle"];
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
private void KeyListView_ItemClick(object sender, ItemClickEventArgs e)
|
||||
{
|
||||
if (e.ClickedItem is KeyNameEntry entry)
|
||||
{
|
||||
string oldKeyName = KeyName;
|
||||
KeyListFlyout.Hide();
|
||||
KeyChanged?.Invoke(this, new KeyChangedEventArgs(oldKeyName, entry.DisplayName, entry.KeyCode));
|
||||
}
|
||||
}
|
||||
|
||||
private void KeyListFlyout_Closed(object sender, object e)
|
||||
{
|
||||
// Clear selection when flyout closes
|
||||
KeyListView.SelectedItem = null;
|
||||
}
|
||||
|
||||
private void KeyListFlyout_Opening(object sender, object e)
|
||||
{
|
||||
RefreshKeyList();
|
||||
}
|
||||
|
||||
private List<KeyNameEntry> GetKeyList()
|
||||
{
|
||||
bool isShortcut = IsShortcut;
|
||||
ref var cached = ref (isShortcut ? ref _cachedShortcutKeyList : ref _cachedKeyList);
|
||||
|
||||
if (cached == null)
|
||||
{
|
||||
try
|
||||
{
|
||||
using (var service = new KeyboardMappingService())
|
||||
{
|
||||
var list = service.GetKeyboardKeysList(isShortcut);
|
||||
|
||||
// Filter out the synthetic "None" entry (keycode 0) that the native layer
|
||||
// injects for shortcut lists; selecting it would store an invalid key code.
|
||||
cached = list.Where(e => e.KeyCode != 0).ToList();
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
cached = new List<KeyNameEntry>();
|
||||
}
|
||||
}
|
||||
|
||||
return cached;
|
||||
}
|
||||
|
||||
internal void RefreshKeyList()
|
||||
{
|
||||
KeyListView.ItemsSource = GetKeyList();
|
||||
|
||||
// Scroll to current key if possible
|
||||
var list = GetKeyList();
|
||||
for (int i = 0; i < list.Count; i++)
|
||||
{
|
||||
if (string.Equals(list[i].DisplayName, KeyName, StringComparison.Ordinal))
|
||||
{
|
||||
KeyListView.SelectedIndex = i;
|
||||
KeyListView.ScrollIntoView(list[i]);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -6,6 +6,7 @@
|
||||
xmlns:commoncontrols="using:Microsoft.PowerToys.Common.UI.Controls"
|
||||
xmlns:controls="using:CommunityToolkit.WinUI.Controls"
|
||||
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
|
||||
xmlns:local="using:KeyboardManagerEditorUI.Controls"
|
||||
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
|
||||
xmlns:tkcontrols="using:CommunityToolkit.WinUI.Controls"
|
||||
xmlns:ui="using:CommunityToolkit.WinUI"
|
||||
@@ -80,28 +81,29 @@
|
||||
Style="{StaticResource CustomShortcutToggleButtonStyle}"
|
||||
Unchecked="TriggerKeyToggleBtn_Unchecked">
|
||||
<ToggleButton.Content>
|
||||
<ItemsControl x:Name="TriggerKeys">
|
||||
<ItemsControl.ItemsPanel>
|
||||
<ItemsPanelTemplate>
|
||||
<controls:WrapPanel
|
||||
HorizontalSpacing="4"
|
||||
Orientation="Horizontal"
|
||||
VerticalSpacing="4" />
|
||||
</ItemsPanelTemplate>
|
||||
</ItemsControl.ItemsPanel>
|
||||
<ItemsControl.ItemTemplate>
|
||||
<DataTemplate>
|
||||
<commoncontrols:KeyVisual
|
||||
Padding="8"
|
||||
Background="{ThemeResource ControlFillColorDefaultBrush}"
|
||||
BorderThickness="1"
|
||||
Content="{Binding}"
|
||||
CornerRadius="{StaticResource OverlayCornerRadius}"
|
||||
FontSize="16"
|
||||
Style="{StaticResource DefaultKeyVisualStyle}" />
|
||||
</DataTemplate>
|
||||
</ItemsControl.ItemTemplate>
|
||||
</ItemsControl>
|
||||
<Grid>
|
||||
<TextBlock
|
||||
x:Name="TriggerKeyPlaceholder"
|
||||
x:Uid="TriggerKeyPlaceholder"
|
||||
HorizontalAlignment="Center"
|
||||
VerticalAlignment="Center"
|
||||
Foreground="{ThemeResource TextFillColorDisabledBrush}" />
|
||||
<ItemsControl x:Name="TriggerKeys" IsTabStop="False">
|
||||
<ItemsControl.ItemsPanel>
|
||||
<ItemsPanelTemplate>
|
||||
<controls:WrapPanel
|
||||
HorizontalSpacing="4"
|
||||
Orientation="Horizontal"
|
||||
VerticalSpacing="4" />
|
||||
</ItemsPanelTemplate>
|
||||
</ItemsControl.ItemsPanel>
|
||||
<ItemsControl.ItemTemplate>
|
||||
<DataTemplate>
|
||||
<local:KeyDropDownButton KeyName="{Binding}" Loaded="TriggerKeyDropDown_Loaded" />
|
||||
</DataTemplate>
|
||||
</ItemsControl.ItemTemplate>
|
||||
</ItemsControl>
|
||||
</Grid>
|
||||
</ToggleButton.Content>
|
||||
</ToggleButton>
|
||||
<CheckBox
|
||||
@@ -204,6 +206,12 @@
|
||||
<TextBlock x:Uid="ActionType_OpenApp_Text" />
|
||||
</StackPanel>
|
||||
</ComboBoxItem>
|
||||
<ComboBoxItem x:Uid="ActionType_Disable" Tag="Disable">
|
||||
<StackPanel Orientation="Horizontal" Spacing="8">
|
||||
<FontIcon FontSize="14" Glyph="" />
|
||||
<TextBlock x:Uid="ActionType_Disable_Text" />
|
||||
</StackPanel>
|
||||
</ComboBoxItem>
|
||||
<!--
|
||||
<ComboBoxItem x:Uid="ActionType_MouseClick" Tag="MouseClick">
|
||||
<StackPanel Orientation="Horizontal" Spacing="8">
|
||||
@@ -235,29 +243,32 @@
|
||||
Style="{StaticResource CustomShortcutToggleButtonStyle}"
|
||||
Unchecked="ActionKeyToggleBtn_Unchecked">
|
||||
<ToggleButton.Content>
|
||||
<ItemsControl x:Name="ActionKeys">
|
||||
<ItemsControl.ItemsPanel>
|
||||
<ItemsPanelTemplate>
|
||||
<controls:WrapPanel
|
||||
HorizontalSpacing="4"
|
||||
Orientation="Horizontal"
|
||||
VerticalSpacing="4" />
|
||||
</ItemsPanelTemplate>
|
||||
</ItemsControl.ItemsPanel>
|
||||
<ItemsControl.ItemTemplate>
|
||||
<DataTemplate>
|
||||
<commoncontrols:KeyVisual
|
||||
Padding="8"
|
||||
Background="{ThemeResource CustomAccentBackgroundBrush}"
|
||||
BorderThickness="0"
|
||||
Content="{Binding}"
|
||||
CornerRadius="{StaticResource OverlayCornerRadius}"
|
||||
FontSize="16"
|
||||
Foreground="{ThemeResource AccentTextFillColorPrimaryBrush}"
|
||||
Style="{StaticResource DefaultKeyVisualStyle}" />
|
||||
</DataTemplate>
|
||||
</ItemsControl.ItemTemplate>
|
||||
</ItemsControl>
|
||||
<Grid>
|
||||
<TextBlock
|
||||
x:Name="ActionKeyPlaceholder"
|
||||
x:Uid="ActionKeyPlaceholder"
|
||||
HorizontalAlignment="Center"
|
||||
VerticalAlignment="Center"
|
||||
Foreground="{ThemeResource TextFillColorDisabledBrush}" />
|
||||
<ItemsControl x:Name="ActionKeys" IsTabStop="False">
|
||||
<ItemsControl.ItemsPanel>
|
||||
<ItemsPanelTemplate>
|
||||
<controls:WrapPanel
|
||||
HorizontalSpacing="4"
|
||||
Orientation="Horizontal"
|
||||
VerticalSpacing="4" />
|
||||
</ItemsPanelTemplate>
|
||||
</ItemsControl.ItemsPanel>
|
||||
<ItemsControl.ItemTemplate>
|
||||
<DataTemplate>
|
||||
<local:KeyDropDownButton
|
||||
KeyName="{Binding}"
|
||||
Loaded="ActionKeyDropDown_Loaded"
|
||||
UseAccentStyle="True" />
|
||||
</DataTemplate>
|
||||
</ItemsControl.ItemTemplate>
|
||||
</ItemsControl>
|
||||
</Grid>
|
||||
</ToggleButton.Content>
|
||||
</ToggleButton>
|
||||
</tkcontrols:Case>
|
||||
@@ -288,7 +299,7 @@
|
||||
<!-- Open App Action -->
|
||||
<tkcontrols:Case Value="OpenApp">
|
||||
<StackPanel Orientation="Vertical" Spacing="16">
|
||||
<Grid ColumnSpacing="8">
|
||||
<Grid ColumnSpacing="4">
|
||||
<Grid.ColumnDefinitions>
|
||||
<ColumnDefinition Width="*" />
|
||||
<ColumnDefinition Width="Auto" />
|
||||
@@ -307,13 +318,17 @@
|
||||
Click="ProgramPathSelectButton_Click"
|
||||
Content="{ui:FontIcon Glyph=,
|
||||
FontSize=16}"
|
||||
Style="{StaticResource SubtleButtonStyle}" />
|
||||
Style="{StaticResource SubtleButtonStyle}">
|
||||
<ToolTipService.ToolTip>
|
||||
<TextBlock x:Uid="ProgramPathSelectButtonTooltip" />
|
||||
</ToolTipService.ToolTip>
|
||||
</Button>
|
||||
</Grid>
|
||||
<TextBox
|
||||
x:Name="ProgramArgsInput"
|
||||
x:Uid="ProgramArgsInput"
|
||||
GotFocus="ProgramArgsInput_GotFocus" />
|
||||
<Grid ColumnSpacing="8">
|
||||
<Grid ColumnSpacing="4">
|
||||
<Grid.ColumnDefinitions>
|
||||
<ColumnDefinition Width="*" />
|
||||
<ColumnDefinition Width="Auto" />
|
||||
@@ -331,7 +346,11 @@
|
||||
Click="StartInSelectButton_Click"
|
||||
Content="{ui:FontIcon Glyph=,
|
||||
FontSize=16}"
|
||||
Style="{StaticResource SubtleButtonStyle}" />
|
||||
Style="{StaticResource SubtleButtonStyle}">
|
||||
<ToolTipService.ToolTip>
|
||||
<TextBlock x:Uid="StartInSelectButtonTooltip" />
|
||||
</ToolTipService.ToolTip>
|
||||
</Button>
|
||||
</Grid>
|
||||
<ComboBox
|
||||
x:Name="ElevationComboBox"
|
||||
@@ -372,6 +391,13 @@
|
||||
Foreground="{ThemeResource TextFillColorSecondaryBrush}"
|
||||
TextWrapping="Wrap" />
|
||||
</tkcontrols:Case>
|
||||
<!-- Disable Action -->
|
||||
<tkcontrols:Case Value="Disable">
|
||||
<TextBlock
|
||||
x:Uid="DisableDescription"
|
||||
Foreground="{ThemeResource TextFillColorSecondaryBrush}"
|
||||
TextWrapping="Wrap" />
|
||||
</tkcontrols:Case>
|
||||
</tkcontrols:SwitchPresenter>
|
||||
</StackPanel>
|
||||
<!-- Validation InfoBar spanning all columns -->
|
||||
|
||||
@@ -7,6 +7,7 @@ using System.Collections.Generic;
|
||||
using System.Collections.ObjectModel;
|
||||
using System.Linq;
|
||||
using KeyboardManagerEditorUI.Helpers;
|
||||
using KeyboardManagerEditorUI.Interop;
|
||||
using Microsoft.UI.Xaml;
|
||||
using Microsoft.UI.Xaml.Controls;
|
||||
using Windows.Storage;
|
||||
@@ -77,6 +78,7 @@ namespace KeyboardManagerEditorUI.Controls
|
||||
OpenUrl,
|
||||
OpenApp,
|
||||
MouseClick,
|
||||
Disable,
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -129,6 +131,7 @@ namespace KeyboardManagerEditorUI.Controls
|
||||
"OpenUrl" => ActionType.OpenUrl,
|
||||
"OpenApp" => ActionType.OpenApp,
|
||||
"MouseClick" => ActionType.MouseClick,
|
||||
"Disable" => ActionType.Disable,
|
||||
_ => ActionType.KeyOrShortcut,
|
||||
};
|
||||
}
|
||||
@@ -148,8 +151,17 @@ namespace KeyboardManagerEditorUI.Controls
|
||||
TriggerKeys.ItemsSource = _triggerKeys;
|
||||
ActionKeys.ItemsSource = _actionKeys;
|
||||
|
||||
_triggerKeys.CollectionChanged += (_, _) => RaiseValidationStateChanged();
|
||||
_actionKeys.CollectionChanged += (_, _) => RaiseValidationStateChanged();
|
||||
_triggerKeys.CollectionChanged += (_, _) =>
|
||||
{
|
||||
UpdatePlaceholderVisibility();
|
||||
RaiseValidationStateChanged();
|
||||
};
|
||||
|
||||
_actionKeys.CollectionChanged += (_, _) =>
|
||||
{
|
||||
UpdatePlaceholderVisibility();
|
||||
RaiseValidationStateChanged();
|
||||
};
|
||||
|
||||
this.Unloaded += UnifiedMappingControl_Unloaded;
|
||||
}
|
||||
@@ -209,6 +221,9 @@ namespace KeyboardManagerEditorUI.Controls
|
||||
ActionKeyToggleBtn.IsChecked = false;
|
||||
}
|
||||
|
||||
// Disable dropdowns during recording
|
||||
SetDropDownsEnabled(TriggerKeys, false);
|
||||
|
||||
KeyboardHookHelper.Instance.ActivateHook(this);
|
||||
}
|
||||
}
|
||||
@@ -219,6 +234,8 @@ namespace KeyboardManagerEditorUI.Controls
|
||||
{
|
||||
CleanupKeyboardHook();
|
||||
}
|
||||
|
||||
SetDropDownsEnabled(TriggerKeys, true);
|
||||
}
|
||||
|
||||
#endregion
|
||||
@@ -262,6 +279,9 @@ namespace KeyboardManagerEditorUI.Controls
|
||||
TriggerKeyToggleBtn.IsChecked = false;
|
||||
}
|
||||
|
||||
// Disable dropdowns during recording
|
||||
SetDropDownsEnabled(ActionKeys, false);
|
||||
|
||||
KeyboardHookHelper.Instance.ActivateHook(this);
|
||||
}
|
||||
}
|
||||
@@ -272,6 +292,238 @@ namespace KeyboardManagerEditorUI.Controls
|
||||
{
|
||||
CleanupKeyboardHook();
|
||||
}
|
||||
|
||||
SetDropDownsEnabled(ActionKeys, true);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Key Dropdown Handling
|
||||
|
||||
private void TriggerKeyDropDown_Loaded(object sender, RoutedEventArgs e)
|
||||
{
|
||||
if (sender is KeyDropDownButton dropDown)
|
||||
{
|
||||
// Ensure we do not accumulate multiple subscriptions when Loaded fires repeatedly.
|
||||
dropDown.KeyChanged -= TriggerKeyDropDown_KeyChanged;
|
||||
dropDown.KeyChanged += TriggerKeyDropDown_KeyChanged;
|
||||
|
||||
// Use a named Unloaded handler so we can detach it and avoid accumulating handlers.
|
||||
dropDown.Unloaded -= TriggerKeyDropDown_Unloaded;
|
||||
dropDown.Unloaded += TriggerKeyDropDown_Unloaded;
|
||||
}
|
||||
}
|
||||
|
||||
private void TriggerKeyDropDown_Unloaded(object sender, RoutedEventArgs e)
|
||||
{
|
||||
if (sender is KeyDropDownButton dropDown)
|
||||
{
|
||||
dropDown.KeyChanged -= TriggerKeyDropDown_KeyChanged;
|
||||
dropDown.Unloaded -= TriggerKeyDropDown_Unloaded;
|
||||
}
|
||||
}
|
||||
|
||||
private void ActionKeyDropDown_Loaded(object sender, RoutedEventArgs e)
|
||||
{
|
||||
if (sender is KeyDropDownButton dropDown)
|
||||
{
|
||||
// Ensure we do not accumulate multiple subscriptions when Loaded fires repeatedly.
|
||||
dropDown.KeyChanged -= ActionKeyDropDown_KeyChanged;
|
||||
dropDown.KeyChanged += ActionKeyDropDown_KeyChanged;
|
||||
|
||||
// Use a named Unloaded handler so we can detach it and avoid accumulating handlers.
|
||||
dropDown.Unloaded -= ActionKeyDropDown_Unloaded;
|
||||
dropDown.Unloaded += ActionKeyDropDown_Unloaded;
|
||||
}
|
||||
}
|
||||
|
||||
private void ActionKeyDropDown_Unloaded(object sender, RoutedEventArgs e)
|
||||
{
|
||||
if (sender is KeyDropDownButton dropDown)
|
||||
{
|
||||
dropDown.KeyChanged -= ActionKeyDropDown_KeyChanged;
|
||||
dropDown.Unloaded -= ActionKeyDropDown_Unloaded;
|
||||
}
|
||||
}
|
||||
|
||||
private void TriggerKeyDropDown_KeyChanged(object? sender, KeyChangedEventArgs e)
|
||||
{
|
||||
if (sender is KeyDropDownButton dropDown)
|
||||
{
|
||||
int index = GetDropDownIndex(TriggerKeys, dropDown);
|
||||
if (index >= 0 && index < _triggerKeys.Count)
|
||||
{
|
||||
// KeyCode 0 means "None" — treat as invalid selection and do not update.
|
||||
if (e.NewKeyCode == 0)
|
||||
{
|
||||
RevertKeySelection(_triggerKeys, index);
|
||||
return;
|
||||
}
|
||||
|
||||
string? validationError = ValidateDropDownSelection(_triggerKeys, index, e.NewKeyCode, e.NewKeyName);
|
||||
if (validationError != null)
|
||||
{
|
||||
RevertKeySelection(_triggerKeys, index);
|
||||
ShowNotificationTip(validationError);
|
||||
return;
|
||||
}
|
||||
|
||||
_triggerKeys[index] = e.NewKeyName;
|
||||
HandleAutoGrowShrink(_triggerKeys, index, e.NewKeyCode);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void ActionKeyDropDown_KeyChanged(object? sender, KeyChangedEventArgs e)
|
||||
{
|
||||
if (sender is KeyDropDownButton dropDown)
|
||||
{
|
||||
int index = GetDropDownIndex(ActionKeys, dropDown);
|
||||
if (index >= 0 && index < _actionKeys.Count)
|
||||
{
|
||||
// KeyCode 0 means "None" — treat as invalid selection and do not update.
|
||||
if (e.NewKeyCode == 0)
|
||||
{
|
||||
RevertKeySelection(_actionKeys, index);
|
||||
return;
|
||||
}
|
||||
|
||||
string? validationError = ValidateDropDownSelection(_actionKeys, index, e.NewKeyCode, e.NewKeyName);
|
||||
if (validationError != null)
|
||||
{
|
||||
RevertKeySelection(_actionKeys, index);
|
||||
ShowNotificationTip(validationError);
|
||||
return;
|
||||
}
|
||||
|
||||
_actionKeys[index] = e.NewKeyName;
|
||||
HandleAutoGrowShrink(_actionKeys, index, e.NewKeyCode);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Reverts a key selection by re-inserting the current value via the bound ObservableCollection,
|
||||
/// which forces the binding to refresh without breaking the binding expression.
|
||||
/// </summary>
|
||||
private static void RevertKeySelection(ObservableCollection<string> keys, int index)
|
||||
{
|
||||
string current = keys[index];
|
||||
keys.RemoveAt(index);
|
||||
keys.Insert(index, current);
|
||||
}
|
||||
|
||||
private static int GetDropDownIndex(ItemsControl itemsControl, KeyDropDownButton dropDown)
|
||||
{
|
||||
for (int i = 0; i < itemsControl.Items.Count; i++)
|
||||
{
|
||||
var container = itemsControl.ContainerFromIndex(i) as ContentPresenter;
|
||||
if (container != null)
|
||||
{
|
||||
// Walk the visual tree to find the KeyDropDownButton
|
||||
var child = FindChild<KeyDropDownButton>(container);
|
||||
if (child == dropDown)
|
||||
{
|
||||
return i;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return -1;
|
||||
}
|
||||
|
||||
private static T? FindChild<T>(Microsoft.UI.Xaml.DependencyObject parent)
|
||||
where T : Microsoft.UI.Xaml.DependencyObject
|
||||
{
|
||||
int childCount = Microsoft.UI.Xaml.Media.VisualTreeHelper.GetChildrenCount(parent);
|
||||
for (int i = 0; i < childCount; i++)
|
||||
{
|
||||
var child = Microsoft.UI.Xaml.Media.VisualTreeHelper.GetChild(parent, i);
|
||||
if (child is T result)
|
||||
{
|
||||
return result;
|
||||
}
|
||||
|
||||
var descendant = FindChild<T>(child);
|
||||
if (descendant != null)
|
||||
{
|
||||
return descendant;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Validates a key selection from a dropdown before it is applied.
|
||||
/// Returns null if valid, or an error message string if invalid.
|
||||
/// </summary>
|
||||
private static string? ValidateDropDownSelection(ObservableCollection<string> keys, int changedIndex, int newKeyCode, string newKeyName)
|
||||
{
|
||||
const int maxShortcutSize = 5;
|
||||
|
||||
// KeyType: 0=Win, 1=Ctrl, 2=Alt, 3=Shift, 4=Action
|
||||
int newKeyType = KeyboardManagerInterop.GetKeyType(newKeyCode);
|
||||
bool isModifier = newKeyType < 4;
|
||||
|
||||
// Count only non-empty (real) entries to determine effective shortcut size.
|
||||
int nonEmptyCount = keys.Count(k => !string.IsNullOrEmpty(k));
|
||||
|
||||
// Rule: action key at position 0 in multi-key shortcut (shortcut must start with modifier)
|
||||
if (!isModifier && changedIndex == 0 && nonEmptyCount > 1)
|
||||
{
|
||||
return ResourceHelper.GetString("Warning_ShortcutStartWithModifier");
|
||||
}
|
||||
|
||||
// Rule: no repeated modifier types (skip empty placeholder slots)
|
||||
if (isModifier)
|
||||
{
|
||||
for (int i = 0; i < keys.Count; i++)
|
||||
{
|
||||
if (i == changedIndex || string.IsNullOrEmpty(keys[i]))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
int existingKeyCode = KeyboardManagerInterop.GetKeyCodeFromName(keys[i]);
|
||||
int existingKeyType = KeyboardManagerInterop.GetKeyType(existingKeyCode);
|
||||
|
||||
if (existingKeyType == newKeyType)
|
||||
{
|
||||
return ResourceHelper.GetString("Warning_RepeatedModifier");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Rule: modifier at last position when already at max size
|
||||
if (isModifier && changedIndex == keys.Count - 1 && nonEmptyCount >= maxShortcutSize)
|
||||
{
|
||||
return ResourceHelper.GetString("Warning_MaxShortcutSize");
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private void HandleAutoGrowShrink(ObservableCollection<string> keys, int changedIndex, int newKeyCode)
|
||||
{
|
||||
const int maxShortcutSize = 5;
|
||||
|
||||
int keyType = KeyboardManagerInterop.GetKeyType(newKeyCode);
|
||||
bool isModifier = keyType < 4;
|
||||
|
||||
if (isModifier && changedIndex == keys.Count - 1 && keys.Count < maxShortcutSize)
|
||||
{
|
||||
// Modifier at last position — auto-grow: add placeholder for next key
|
||||
keys.Add(string.Empty);
|
||||
}
|
||||
else if (!isModifier)
|
||||
{
|
||||
// Action key — trim any trailing entries after this one
|
||||
while (keys.Count > changedIndex + 1)
|
||||
{
|
||||
keys.RemoveAt(keys.Count - 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#endregion
|
||||
@@ -453,7 +705,7 @@ namespace KeyboardManagerEditorUI.Controls
|
||||
|
||||
public void OnInputLimitReached()
|
||||
{
|
||||
ShowNotificationTip("Shortcuts can only have up to 4 modifier keys");
|
||||
ShowNotificationTip(ResourceHelper.GetString("Warning_InputLimitReached"));
|
||||
}
|
||||
|
||||
#endregion
|
||||
@@ -463,12 +715,12 @@ namespace KeyboardManagerEditorUI.Controls
|
||||
/// <summary>
|
||||
/// Gets the trigger keys.
|
||||
/// </summary>
|
||||
public List<string> GetTriggerKeys() => _triggerKeys.ToList();
|
||||
public List<string> GetTriggerKeys() => _triggerKeys.Where(k => !string.IsNullOrEmpty(k)).ToList();
|
||||
|
||||
/// <summary>
|
||||
/// Gets the action keys (for Key/Shortcut action type).
|
||||
/// </summary>
|
||||
public List<string> GetActionKeys() => _actionKeys.ToList();
|
||||
public List<string> GetActionKeys() => _actionKeys.Where(k => !string.IsNullOrEmpty(k)).ToList();
|
||||
|
||||
/// <summary>
|
||||
/// Gets the selected mouse trigger.
|
||||
@@ -567,6 +819,7 @@ namespace KeyboardManagerEditorUI.Controls
|
||||
ActionType.Text => !string.IsNullOrEmpty(TextContentBox?.Text),
|
||||
ActionType.OpenUrl => !string.IsNullOrWhiteSpace(UrlPathInput?.Text),
|
||||
ActionType.OpenApp => !string.IsNullOrWhiteSpace(ProgramPathInput?.Text),
|
||||
ActionType.Disable => true,
|
||||
_ => false,
|
||||
};
|
||||
}
|
||||
@@ -612,18 +865,28 @@ namespace KeyboardManagerEditorUI.Controls
|
||||
/// </summary>
|
||||
public void SetActionType(ActionType actionType)
|
||||
{
|
||||
int index = actionType switch
|
||||
if (ActionTypeComboBox == null)
|
||||
{
|
||||
ActionType.Text => 1,
|
||||
ActionType.OpenUrl => 2,
|
||||
ActionType.OpenApp => 3,
|
||||
ActionType.MouseClick => 4,
|
||||
_ => 0,
|
||||
return;
|
||||
}
|
||||
|
||||
string tag = actionType switch
|
||||
{
|
||||
ActionType.Text => "Text",
|
||||
ActionType.OpenUrl => "OpenUrl",
|
||||
ActionType.OpenApp => "OpenApp",
|
||||
ActionType.Disable => "Disable",
|
||||
ActionType.MouseClick => "MouseClick",
|
||||
_ => "KeyOrShortcut",
|
||||
};
|
||||
|
||||
if (ActionTypeComboBox != null)
|
||||
foreach (var item in ActionTypeComboBox.Items)
|
||||
{
|
||||
ActionTypeComboBox.SelectedIndex = index;
|
||||
if (item is ComboBoxItem comboBoxItem && comboBoxItem.Tag is string itemTag && itemTag == tag)
|
||||
{
|
||||
ActionTypeComboBox.SelectedItem = comboBoxItem;
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -748,11 +1011,44 @@ namespace KeyboardManagerEditorUI.Controls
|
||||
}
|
||||
}
|
||||
|
||||
private static void SetDropDownsEnabled(ItemsControl itemsControl, bool enabled)
|
||||
{
|
||||
for (int i = 0; i < itemsControl.Items.Count; i++)
|
||||
{
|
||||
var container = itemsControl.ContainerFromIndex(i) as ContentPresenter;
|
||||
if (container != null)
|
||||
{
|
||||
var dropDown = FindChild<KeyDropDownButton>(container);
|
||||
if (dropDown != null)
|
||||
{
|
||||
dropDown.IsEnabled = enabled;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void CleanupKeyboardHook()
|
||||
{
|
||||
KeyboardHookHelper.Instance.CleanupHook();
|
||||
}
|
||||
|
||||
private void UpdatePlaceholderVisibility()
|
||||
{
|
||||
if (TriggerKeyPlaceholder != null)
|
||||
{
|
||||
TriggerKeyPlaceholder.Visibility = _triggerKeys.Count == 0
|
||||
? Microsoft.UI.Xaml.Visibility.Visible
|
||||
: Microsoft.UI.Xaml.Visibility.Collapsed;
|
||||
}
|
||||
|
||||
if (ActionKeyPlaceholder != null)
|
||||
{
|
||||
ActionKeyPlaceholder.Visibility = _actionKeys.Count == 0
|
||||
? Microsoft.UI.Xaml.Visibility.Visible
|
||||
: Microsoft.UI.Xaml.Visibility.Collapsed;
|
||||
}
|
||||
}
|
||||
|
||||
private void RaiseValidationStateChanged()
|
||||
{
|
||||
UpdateInlineValidation();
|
||||
@@ -910,7 +1206,7 @@ namespace KeyboardManagerEditorUI.Controls
|
||||
/// </summary>
|
||||
public void ShowNotificationTip(string message)
|
||||
{
|
||||
ShowValidationMessage("Warning", message, InfoBarSeverity.Warning);
|
||||
ShowValidationMessage(ResourceHelper.GetString("Warning_Title"), message, InfoBarSeverity.Warning);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -932,7 +1228,7 @@ namespace KeyboardManagerEditorUI.Controls
|
||||
}
|
||||
else
|
||||
{
|
||||
ShowValidationError("Validation Error", "An unknown validation error occurred.");
|
||||
ShowValidationError(ResourceHelper.GetString("Error_UnknownValidation_Title"), ResourceHelper.GetString("Error_UnknownValidation_Message"));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -8,6 +8,7 @@ using System.Linq;
|
||||
using System.Text;
|
||||
using System.Threading.Tasks;
|
||||
using KeyboardManagerEditorUI.Interop;
|
||||
using ManagedCommon;
|
||||
using Microsoft.PowerToys.Settings.UI.Library;
|
||||
using Windows.System;
|
||||
|
||||
@@ -19,7 +20,7 @@ namespace KeyboardManagerEditorUI.Helpers
|
||||
|
||||
public static KeyboardHookHelper Instance => _instance ??= new KeyboardHookHelper();
|
||||
|
||||
private KeyboardMappingService _mappingService;
|
||||
private KeyboardMappingService? _mappingService;
|
||||
|
||||
private HotkeySettingsControlHook? _keyboardHook;
|
||||
|
||||
@@ -34,7 +35,14 @@ namespace KeyboardManagerEditorUI.Helpers
|
||||
// Singleton to make sure only one instance of the hook is active
|
||||
private KeyboardHookHelper()
|
||||
{
|
||||
_mappingService = new KeyboardMappingService();
|
||||
try
|
||||
{
|
||||
_mappingService = new KeyboardMappingService();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Logger.LogWarning($"Native KBM library unavailable for keyboard hook: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
public void ActivateHook(IKeyboardHookTarget target)
|
||||
@@ -46,11 +54,18 @@ namespace KeyboardManagerEditorUI.Helpers
|
||||
_currentlyPressedKeys.Clear();
|
||||
_keyPressOrder.Clear();
|
||||
|
||||
_keyboardHook = new HotkeySettingsControlHook(
|
||||
KeyDown,
|
||||
KeyUp,
|
||||
() => true,
|
||||
(key, extraInfo) => true);
|
||||
try
|
||||
{
|
||||
_keyboardHook = new HotkeySettingsControlHook(
|
||||
KeyDown,
|
||||
KeyUp,
|
||||
() => true,
|
||||
(key, extraInfo) => true);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Logger.LogWarning($"Keyboard hook unavailable: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
public void CleanupHook()
|
||||
@@ -110,6 +125,17 @@ namespace KeyboardManagerEditorUI.Helpers
|
||||
{
|
||||
_keyPressOrder.Add(virtualKey);
|
||||
|
||||
// When building chords, cap at 2 action keys: if a third action key arrives,
|
||||
// remove the oldest (shift behavior matching old editor).
|
||||
if (_activeTarget.AllowChords && !RemappingHelper.IsModifierKey(virtualKey))
|
||||
{
|
||||
var actionKeysInOrder = _keyPressOrder.Where(k => !RemappingHelper.IsModifierKey(k)).ToList();
|
||||
if (actionKeysInOrder.Count > 2)
|
||||
{
|
||||
_keyPressOrder.Remove(actionKeysInOrder[0]);
|
||||
}
|
||||
}
|
||||
|
||||
// Notify the target page
|
||||
_activeTarget.OnKeyDown(virtualKey, GetFormattedKeyList());
|
||||
}
|
||||
@@ -126,7 +152,13 @@ namespace KeyboardManagerEditorUI.Helpers
|
||||
|
||||
if (_currentlyPressedKeys.Remove(virtualKey))
|
||||
{
|
||||
_keyPressOrder.Remove(virtualKey);
|
||||
// When building chords, preserve released action keys in _keyPressOrder
|
||||
// so the next action key press is recognized as the chord's second key.
|
||||
// Only modifier releases clear from _keyPressOrder (matching old editor behavior).
|
||||
if (!_activeTarget.AllowChords || RemappingHelper.IsModifierKey(virtualKey))
|
||||
{
|
||||
_keyPressOrder.Remove(virtualKey);
|
||||
}
|
||||
|
||||
_activeTarget.OnKeyUp(virtualKey, GetFormattedKeyList());
|
||||
}
|
||||
@@ -140,6 +172,11 @@ namespace KeyboardManagerEditorUI.Helpers
|
||||
return new List<string>();
|
||||
}
|
||||
|
||||
if (_mappingService is null)
|
||||
{
|
||||
return new List<string>();
|
||||
}
|
||||
|
||||
List<string> keyList = new List<string>();
|
||||
List<VirtualKey> modifierKeys = new List<VirtualKey>();
|
||||
VirtualKey? actionKey = null;
|
||||
@@ -147,9 +184,15 @@ namespace KeyboardManagerEditorUI.Helpers
|
||||
|
||||
foreach (var key in _keyPressOrder)
|
||||
{
|
||||
// For modifiers, only include if currently pressed.
|
||||
// For action keys when building chords, also include released keys
|
||||
// so the chord's first key stays visible while waiting for the second.
|
||||
if (!_currentlyPressedKeys.Contains(key))
|
||||
{
|
||||
continue;
|
||||
if (RemappingHelper.IsModifierKey(key) || !_activeTarget.AllowChords)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
if (RemappingHelper.IsModifierKey(key))
|
||||
@@ -189,6 +232,11 @@ namespace KeyboardManagerEditorUI.Helpers
|
||||
|
||||
private void RemoveExistingModifierVariant(VirtualKey key)
|
||||
{
|
||||
if (_mappingService is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
KeyType keyType = (KeyType)KeyboardManagerInterop.GetKeyType((int)key);
|
||||
|
||||
// No need to remove if the key is an action key
|
||||
|
||||
@@ -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 Microsoft.Windows.ApplicationModel.Resources;
|
||||
|
||||
namespace KeyboardManagerEditorUI.Helpers
|
||||
{
|
||||
public static class ResourceHelper
|
||||
{
|
||||
private static ResourceLoader? _resourceLoader;
|
||||
|
||||
public static string GetString(string resourceKey)
|
||||
{
|
||||
_resourceLoader ??= new ResourceLoader();
|
||||
return _resourceLoader.GetString(resourceKey);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
namespace KeyboardManagerEditorUI.Helpers
|
||||
{
|
||||
public static class ServiceStatusHelper
|
||||
{
|
||||
private const string KeyboardManagerEngineProcessName = "PowerToys.KeyboardManagerEngine";
|
||||
|
||||
public static bool IsKeyboardManagerServiceRunning()
|
||||
{
|
||||
try
|
||||
{
|
||||
var processes = Process.GetProcessesByName(KeyboardManagerEngineProcessName);
|
||||
bool running = processes.Length > 0;
|
||||
foreach (var process in processes)
|
||||
{
|
||||
process.Dispose();
|
||||
}
|
||||
|
||||
return running;
|
||||
}
|
||||
catch (Exception)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
public static bool IsPowerToysRunning()
|
||||
{
|
||||
try
|
||||
{
|
||||
var processes = Process.GetProcessesByName("PowerToys");
|
||||
bool running = processes.Length > 0;
|
||||
foreach (var process in processes)
|
||||
{
|
||||
process.Dispose();
|
||||
}
|
||||
|
||||
return running;
|
||||
}
|
||||
catch (Exception)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -19,6 +19,7 @@ namespace KeyboardManagerEditorUI.Helpers
|
||||
EmptyAppName,
|
||||
IllegalShortcut,
|
||||
DuplicateMapping,
|
||||
ConflictingModifier,
|
||||
SelfMapping,
|
||||
EmptyTargetText,
|
||||
EmptyUrl,
|
||||
|
||||
@@ -16,17 +16,18 @@ namespace KeyboardManagerEditorUI.Helpers
|
||||
{
|
||||
public static readonly Dictionary<ValidationErrorType, (string Title, string Message)> ValidationMessages = new()
|
||||
{
|
||||
{ ValidationErrorType.EmptyOriginalKeys, ("Missing Original Keys", "Please enter at least one original key to create a remapping.") },
|
||||
{ ValidationErrorType.EmptyRemappedKeys, ("Missing Target Keys", "Please enter at least one target key to create a remapping.") },
|
||||
{ ValidationErrorType.ModifierOnly, ("Invalid Shortcut", "Shortcuts must contain at least one action key in addition to modifier keys (Ctrl, Alt, Shift, Win).") },
|
||||
{ ValidationErrorType.EmptyAppName, ("Missing Application Name", "You've selected app-specific remapping but haven't specified an application name. Please enter the application name.") },
|
||||
{ ValidationErrorType.IllegalShortcut, ("Reserved System Shortcut", "Win+L and Ctrl+Alt+Delete are reserved system shortcuts and cannot be remapped.") },
|
||||
{ ValidationErrorType.DuplicateMapping, ("Duplicate Remapping", "This key or shortcut is already remapped.") },
|
||||
{ ValidationErrorType.SelfMapping, ("Invalid Remapping", "A key or shortcut cannot be remapped to itself. Please choose a different target.") },
|
||||
{ ValidationErrorType.EmptyTargetText, ("Missing Target Text", "Please enter the text to be inserted when the shortcut is pressed.") },
|
||||
{ ValidationErrorType.EmptyUrl, ("Missing URL", "Please enter the URL to open when the shortcut is pressed.") },
|
||||
{ ValidationErrorType.EmptyProgramPath, ("Missing Program Path", "Please enter the program path to launch when the shortcut is pressed.") },
|
||||
{ ValidationErrorType.OneKeyMapping, ("Invalid Remapping", "A single key cannot be remapped to a Program or URL shortcut. Please choose a combination of keys.") },
|
||||
{ ValidationErrorType.EmptyOriginalKeys, (ResourceHelper.GetString("Validation_EmptyOriginalKeys_Title"), ResourceHelper.GetString("Validation_EmptyOriginalKeys_Message")) },
|
||||
{ ValidationErrorType.EmptyRemappedKeys, (ResourceHelper.GetString("Validation_EmptyRemappedKeys_Title"), ResourceHelper.GetString("Validation_EmptyRemappedKeys_Message")) },
|
||||
{ ValidationErrorType.ModifierOnly, (ResourceHelper.GetString("Validation_ModifierOnly_Title"), ResourceHelper.GetString("Validation_ModifierOnly_Message")) },
|
||||
{ ValidationErrorType.EmptyAppName, (ResourceHelper.GetString("Validation_EmptyAppName_Title"), ResourceHelper.GetString("Validation_EmptyAppName_Message")) },
|
||||
{ ValidationErrorType.IllegalShortcut, (ResourceHelper.GetString("Validation_IllegalShortcut_Title"), ResourceHelper.GetString("Validation_IllegalShortcut_Message")) },
|
||||
{ ValidationErrorType.DuplicateMapping, (ResourceHelper.GetString("Validation_DuplicateMapping_Title"), ResourceHelper.GetString("Validation_DuplicateMapping_Message")) },
|
||||
{ ValidationErrorType.ConflictingModifier, (ResourceHelper.GetString("Validation_ConflictingModifier_Title"), ResourceHelper.GetString("Validation_ConflictingModifier_Message")) },
|
||||
{ ValidationErrorType.SelfMapping, (ResourceHelper.GetString("Validation_SelfMapping_Title"), ResourceHelper.GetString("Validation_SelfMapping_Message")) },
|
||||
{ ValidationErrorType.EmptyTargetText, (ResourceHelper.GetString("Validation_EmptyTargetText_Title"), ResourceHelper.GetString("Validation_EmptyTargetText_Message")) },
|
||||
{ ValidationErrorType.EmptyUrl, (ResourceHelper.GetString("Validation_EmptyUrl_Title"), ResourceHelper.GetString("Validation_EmptyUrl_Message")) },
|
||||
{ ValidationErrorType.EmptyProgramPath, (ResourceHelper.GetString("Validation_EmptyProgramPath_Title"), ResourceHelper.GetString("Validation_EmptyProgramPath_Message")) },
|
||||
{ ValidationErrorType.OneKeyMapping, (ResourceHelper.GetString("Validation_OneKeyMapping_Title"), ResourceHelper.GetString("Validation_OneKeyMapping_Message")) },
|
||||
};
|
||||
|
||||
public static ValidationErrorType ValidateKeyMapping(
|
||||
@@ -69,6 +70,11 @@ namespace KeyboardManagerEditorUI.Helpers
|
||||
return ValidationErrorType.DuplicateMapping;
|
||||
}
|
||||
|
||||
if (originalKeys.Count == 1 && HasConflictingModifierMapping(originalKeys[0], isEditMode, mappingService))
|
||||
{
|
||||
return ValidationErrorType.ConflictingModifier;
|
||||
}
|
||||
|
||||
if (IsSelfMapping(originalKeys, remappedKeys, mappingService))
|
||||
{
|
||||
return ValidationErrorType.SelfMapping;
|
||||
@@ -77,6 +83,47 @@ namespace KeyboardManagerEditorUI.Helpers
|
||||
return ValidationErrorType.NoError;
|
||||
}
|
||||
|
||||
public static ValidationErrorType ValidateDisableMapping(
|
||||
List<string> originalKeys,
|
||||
bool isAppSpecific,
|
||||
string appName,
|
||||
KeyboardMappingService mappingService,
|
||||
bool isEditMode = false,
|
||||
Remapping? editingRemapping = null)
|
||||
{
|
||||
if (originalKeys == null || originalKeys.Count == 0)
|
||||
{
|
||||
return ValidationErrorType.EmptyOriginalKeys;
|
||||
}
|
||||
|
||||
if (originalKeys.Count > 1 && ContainsOnlyModifierKeys(originalKeys))
|
||||
{
|
||||
return ValidationErrorType.ModifierOnly;
|
||||
}
|
||||
|
||||
if (isAppSpecific && string.IsNullOrWhiteSpace(appName))
|
||||
{
|
||||
return ValidationErrorType.EmptyAppName;
|
||||
}
|
||||
|
||||
if (originalKeys.Count > 1 && IsIllegalShortcut(originalKeys, mappingService))
|
||||
{
|
||||
return ValidationErrorType.IllegalShortcut;
|
||||
}
|
||||
|
||||
if (IsDuplicateMapping(originalKeys, isEditMode, mappingService, appName))
|
||||
{
|
||||
return ValidationErrorType.DuplicateMapping;
|
||||
}
|
||||
|
||||
if (originalKeys.Count == 1 && HasConflictingModifierMapping(originalKeys[0], isEditMode, mappingService))
|
||||
{
|
||||
return ValidationErrorType.ConflictingModifier;
|
||||
}
|
||||
|
||||
return ValidationErrorType.NoError;
|
||||
}
|
||||
|
||||
public static ValidationErrorType ValidateTextMapping(
|
||||
List<string> keys,
|
||||
string textContent,
|
||||
@@ -239,6 +286,58 @@ namespace KeyboardManagerEditorUI.Helpers
|
||||
return KeyboardManagerInterop.IsShortcutIllegal(shortcutKeysString);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Checks if a single key conflicts with existing single-key mappings via modifier variants.
|
||||
/// E.g., remapping LCtrl when Ctrl is already mapped, or vice versa.
|
||||
/// </summary>
|
||||
private static bool HasConflictingModifierMapping(string keyName, bool isEditMode, KeyboardMappingService mappingService)
|
||||
{
|
||||
int keyCode = KeyboardManagerInterop.GetKeyCodeFromName(keyName);
|
||||
int keyType = KeyboardManagerInterop.GetKeyType(keyCode);
|
||||
|
||||
// Only modifier keys can conflict with their variants
|
||||
if (keyType >= 4)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
int upperLimit = isEditMode ? 1 : 0;
|
||||
int conflictCount = 0;
|
||||
|
||||
foreach (var settings in SettingsManager.EditorSettings.ShortcutSettingsDictionary.Values)
|
||||
{
|
||||
string existingOriginal = settings.Shortcut.OriginalKeys;
|
||||
|
||||
// Only check single-key mappings (no semicolons)
|
||||
if (string.IsNullOrEmpty(existingOriginal) || existingOriginal.Contains(';'))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (int.TryParse(existingOriginal, out int existingKeyCode))
|
||||
{
|
||||
if (existingKeyCode == keyCode)
|
||||
{
|
||||
continue; // Exact match handled by DuplicateMapping
|
||||
}
|
||||
|
||||
int existingKeyType = KeyboardManagerInterop.GetKeyType(existingKeyCode);
|
||||
|
||||
// Same modifier type (e.g., Ctrl and LCtrl) = conflict
|
||||
if (existingKeyType == keyType)
|
||||
{
|
||||
conflictCount++;
|
||||
if (conflictCount > upperLimit)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private static string BuildKeyCodeString(List<string> keys, KeyboardMappingService mappingService)
|
||||
{
|
||||
return string.Join(";", keys.Select(k => mappingService.GetKeyCodeFromName(k).ToString(CultureInfo.InvariantCulture)));
|
||||
|
||||
@@ -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 KeyboardManagerEditorUI.Interop
|
||||
{
|
||||
public record KeyNameEntry(int KeyCode, string DisplayName);
|
||||
}
|
||||
@@ -98,6 +98,10 @@ namespace KeyboardManagerEditorUI.Interop
|
||||
[return: MarshalAs(UnmanagedType.Bool)]
|
||||
internal static extern bool DeleteShortcutRemap(IntPtr mappingConfiguration, [MarshalAs(UnmanagedType.LPWStr)] string originalKeys, [MarshalAs(UnmanagedType.LPWStr)] string targetApp);
|
||||
|
||||
// Key List Functions
|
||||
[DllImport(DllName, CallingConvention = Convention)]
|
||||
internal static extern int GetKeyboardKeysList([MarshalAs(UnmanagedType.Bool)] bool isShortcut, [Out] KeyNamePair[] keyList, int maxCount);
|
||||
|
||||
// Key Utility Functions
|
||||
[DllImport(DllName, CallingConvention = Convention, CharSet = CharSet.Unicode)]
|
||||
internal static extern int GetKeyCodeFromName([MarshalAs(UnmanagedType.LPWStr)] string keyName);
|
||||
@@ -162,4 +166,13 @@ namespace KeyboardManagerEditorUI.Interop
|
||||
public IntPtr ProgramArgs;
|
||||
public IntPtr UriToOpen;
|
||||
}
|
||||
|
||||
[StructLayout(LayoutKind.Sequential, CharSet = CharSet.Unicode)]
|
||||
public struct KeyNamePair
|
||||
{
|
||||
public int KeyCode;
|
||||
|
||||
[MarshalAs(UnmanagedType.ByValTStr, SizeConst = 64)]
|
||||
public string KeyName;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -145,6 +145,21 @@ namespace KeyboardManagerEditorUI.Interop
|
||||
return keyCode;
|
||||
}
|
||||
|
||||
public List<KeyNameEntry> GetKeyboardKeysList(bool isShortcut)
|
||||
{
|
||||
const int maxKeys = 512;
|
||||
var buffer = new KeyNamePair[maxKeys];
|
||||
int count = KeyboardManagerInterop.GetKeyboardKeysList(isShortcut, buffer, maxKeys);
|
||||
|
||||
var result = new List<KeyNameEntry>(count);
|
||||
for (int i = 0; i < count; i++)
|
||||
{
|
||||
result.Add(new KeyNameEntry(buffer[i].KeyCode, buffer[i].KeyName));
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
public bool AddSingleKeyMapping(int originalKey, int targetKey)
|
||||
{
|
||||
return KeyboardManagerInterop.AddSingleKeyRemap(_configHandle, originalKey, targetKey);
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -15,6 +15,7 @@ using KeyboardManagerEditorUI.Helpers;
|
||||
using KeyboardManagerEditorUI.Interop;
|
||||
using KeyboardManagerEditorUI.Settings;
|
||||
using ManagedCommon;
|
||||
using Microsoft.UI.Dispatching;
|
||||
using Microsoft.UI.Xaml;
|
||||
using Microsoft.UI.Xaml.Controls;
|
||||
using static KeyboardManagerEditorUI.Interop.ShortcutKeyMapping;
|
||||
@@ -27,11 +28,19 @@ namespace KeyboardManagerEditorUI.Pages
|
||||
#pragma warning disable SA1124 // Do not use regions
|
||||
public sealed partial class MainPage : Page, IDisposable, INotifyPropertyChanged
|
||||
{
|
||||
/// <summary>VK_DISABLED sentinel: target key code that tells the engine to suppress the key.</summary>
|
||||
private const int VkDisabled = 0x100;
|
||||
|
||||
/// <summary>String form of <see cref="VkDisabled"/> used in shortcut key mapping serialization.</summary>
|
||||
private const string VkDisabledString = "256";
|
||||
|
||||
private DispatcherTimer? _serviceCheckTimer;
|
||||
private KeyboardMappingService? _mappingService;
|
||||
private bool _disposed;
|
||||
private bool _isEditMode;
|
||||
private EditingItem? _editingItem;
|
||||
private string _mappingState = "Empty";
|
||||
private bool _isServiceRunning = true;
|
||||
|
||||
public event PropertyChangedEventHandler? PropertyChanged;
|
||||
|
||||
@@ -48,8 +57,24 @@ namespace KeyboardManagerEditorUI.Pages
|
||||
}
|
||||
}
|
||||
|
||||
public bool IsServiceRunning
|
||||
{
|
||||
get => _isServiceRunning;
|
||||
private set
|
||||
{
|
||||
if (_isServiceRunning != value)
|
||||
{
|
||||
_isServiceRunning = value;
|
||||
PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(IsServiceRunning)));
|
||||
UpdateServiceBannerVisibility();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public ObservableCollection<Remapping> RemappingList { get; } = new();
|
||||
|
||||
public ObservableCollection<Remapping> DisabledList { get; } = new();
|
||||
|
||||
public ObservableCollection<TextMapping> TextMappings { get; } = new();
|
||||
|
||||
public ObservableCollection<ProgramShortcut> ProgramShortcuts { get; } = new();
|
||||
@@ -83,22 +108,51 @@ namespace KeyboardManagerEditorUI.Pages
|
||||
public MainPage()
|
||||
{
|
||||
this.InitializeComponent();
|
||||
|
||||
try
|
||||
{
|
||||
_mappingService = new KeyboardMappingService();
|
||||
LoadAllMappings();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Logger.LogError("Failed to initialize KeyboardMappingService in MainPage page: " + ex.Message);
|
||||
Logger.LogError("Failed to initialize mapping service: " + ex.Message);
|
||||
IsServiceRunning = false;
|
||||
return;
|
||||
}
|
||||
|
||||
if (_mappingService != null)
|
||||
{
|
||||
LoadAllMappings();
|
||||
}
|
||||
else
|
||||
{
|
||||
MappingState = "Error";
|
||||
}
|
||||
|
||||
Unloaded += All_Unloaded;
|
||||
|
||||
CheckServiceStatus();
|
||||
|
||||
// Set up periodic checks every 3 seconds
|
||||
_serviceCheckTimer = new DispatcherTimer
|
||||
{
|
||||
Interval = TimeSpan.FromSeconds(3),
|
||||
};
|
||||
_serviceCheckTimer.Tick += (s, e) => CheckServiceStatus();
|
||||
_serviceCheckTimer.Start();
|
||||
}
|
||||
|
||||
private void All_Unloaded(object sender, RoutedEventArgs e) => Dispose();
|
||||
|
||||
private void CheckServiceStatus()
|
||||
{
|
||||
IsServiceRunning = ServiceStatusHelper.IsKeyboardManagerServiceRunning();
|
||||
}
|
||||
|
||||
private void UpdateServiceBannerVisibility()
|
||||
{
|
||||
ServiceDownBanner.Visibility = IsServiceRunning ? Visibility.Collapsed : Visibility.Visible;
|
||||
}
|
||||
|
||||
#region Dialog Show Methods
|
||||
|
||||
private async void NewRemappingBtn_Click(object sender, RoutedEventArgs e)
|
||||
@@ -106,7 +160,7 @@ namespace KeyboardManagerEditorUI.Pages
|
||||
_isEditMode = false;
|
||||
_editingItem = null;
|
||||
UnifiedMappingControl.Reset();
|
||||
RemappingDialog.Title = "New remapping";
|
||||
RemappingDialog.Title = ResourceHelper.GetString("RemappingDialog/Title");
|
||||
await ShowRemappingDialog();
|
||||
}
|
||||
|
||||
@@ -132,7 +186,32 @@ namespace KeyboardManagerEditorUI.Pages
|
||||
UnifiedMappingControl.SetActionType(UnifiedMappingControl.ActionType.KeyOrShortcut);
|
||||
UnifiedMappingControl.SetActionKeys(remapping.RemappedKeys.ToList());
|
||||
UnifiedMappingControl.SetAppSpecific(!remapping.IsAllApps, remapping.AppName);
|
||||
RemappingDialog.Title = "Edit remapping";
|
||||
RemappingDialog.Title = ResourceHelper.GetString("RemappingDialog_TitleEdit");
|
||||
await ShowRemappingDialog();
|
||||
}
|
||||
|
||||
private async void DisabledList_ItemClick(object sender, ItemClickEventArgs e)
|
||||
{
|
||||
if (e.ClickedItem is not Remapping disabledMapping)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_isEditMode = true;
|
||||
_editingItem = new EditingItem
|
||||
{
|
||||
Type = EditingItem.ItemType.Remapping,
|
||||
Item = disabledMapping,
|
||||
OriginalTriggerKeys = disabledMapping.Shortcut.ToList(),
|
||||
AppName = disabledMapping.AppName,
|
||||
IsAllApps = disabledMapping.IsAllApps,
|
||||
};
|
||||
|
||||
UnifiedMappingControl.Reset();
|
||||
UnifiedMappingControl.SetTriggerKeys(disabledMapping.Shortcut.ToList());
|
||||
UnifiedMappingControl.SetActionType(UnifiedMappingControl.ActionType.Disable);
|
||||
UnifiedMappingControl.SetAppSpecific(!disabledMapping.IsAllApps, disabledMapping.AppName);
|
||||
RemappingDialog.Title = ResourceHelper.GetString("RemappingDialog_TitleEdit");
|
||||
await ShowRemappingDialog();
|
||||
}
|
||||
|
||||
@@ -158,7 +237,7 @@ namespace KeyboardManagerEditorUI.Pages
|
||||
UnifiedMappingControl.SetActionType(UnifiedMappingControl.ActionType.Text);
|
||||
UnifiedMappingControl.SetTextContent(textMapping.Text);
|
||||
UnifiedMappingControl.SetAppSpecific(!textMapping.IsAllApps, textMapping.AppName);
|
||||
RemappingDialog.Title = "Edit remapping";
|
||||
RemappingDialog.Title = ResourceHelper.GetString("RemappingDialog_TitleEdit");
|
||||
await ShowRemappingDialog();
|
||||
}
|
||||
|
||||
@@ -196,7 +275,7 @@ namespace KeyboardManagerEditorUI.Pages
|
||||
}
|
||||
|
||||
UnifiedMappingControl.SetAppSpecific(!programShortcut.IsAllApps, programShortcut.AppName);
|
||||
RemappingDialog.Title = "Edit remapping";
|
||||
RemappingDialog.Title = ResourceHelper.GetString("RemappingDialog_TitleEdit");
|
||||
await ShowRemappingDialog();
|
||||
}
|
||||
|
||||
@@ -222,7 +301,7 @@ namespace KeyboardManagerEditorUI.Pages
|
||||
UnifiedMappingControl.SetActionType(UnifiedMappingControl.ActionType.OpenUrl);
|
||||
UnifiedMappingControl.SetUrl(urlShortcut.URL);
|
||||
UnifiedMappingControl.SetAppSpecific(!urlShortcut.IsAllApps, urlShortcut.AppName);
|
||||
RemappingDialog.Title = "Edit remapping";
|
||||
RemappingDialog.Title = ResourceHelper.GetString("RemappingDialog_TitleEdit");
|
||||
await ShowRemappingDialog();
|
||||
}
|
||||
|
||||
@@ -279,7 +358,7 @@ namespace KeyboardManagerEditorUI.Pages
|
||||
if (_mappingService == null)
|
||||
{
|
||||
Logger.LogError("Mapping service is null, cannot save mapping");
|
||||
UnifiedMappingControl.ShowValidationError("Error", "Mapping service is not available.");
|
||||
UnifiedMappingControl.ShowValidationError(ResourceHelper.GetString("Error_MappingServiceUnavailable_Title"), ResourceHelper.GetString("Error_MappingServiceUnavailable_Message"));
|
||||
args.Cancel = true;
|
||||
return;
|
||||
}
|
||||
@@ -290,7 +369,7 @@ namespace KeyboardManagerEditorUI.Pages
|
||||
|
||||
if (triggerKeys == null || triggerKeys.Count == 0)
|
||||
{
|
||||
UnifiedMappingControl.ShowValidationError("Missing Original Keys", "Please enter at least one original key to create a remapping.");
|
||||
UnifiedMappingControl.ShowValidationErrorFromType(ValidationErrorType.EmptyOriginalKeys);
|
||||
args.Cancel = true;
|
||||
return;
|
||||
}
|
||||
@@ -314,6 +393,7 @@ namespace KeyboardManagerEditorUI.Pages
|
||||
UnifiedMappingControl.ActionType.Text => SaveTextMapping(triggerKeys),
|
||||
UnifiedMappingControl.ActionType.OpenUrl => SaveUrlMapping(triggerKeys),
|
||||
UnifiedMappingControl.ActionType.OpenApp => SaveProgramMapping(triggerKeys),
|
||||
UnifiedMappingControl.ActionType.Disable => SaveDisableMapping(triggerKeys),
|
||||
UnifiedMappingControl.ActionType.MouseClick => throw new NotImplementedException("Mouse click remapping is not yet supported."),
|
||||
_ => false,
|
||||
};
|
||||
@@ -324,19 +404,19 @@ namespace KeyboardManagerEditorUI.Pages
|
||||
}
|
||||
else
|
||||
{
|
||||
UnifiedMappingControl.ShowValidationError("Save Failed", "Failed to save the remapping. Please try again.");
|
||||
UnifiedMappingControl.ShowValidationError(ResourceHelper.GetString("Error_SaveFailed_Title"), ResourceHelper.GetString("Error_SaveFailed_Message"));
|
||||
args.Cancel = true;
|
||||
}
|
||||
}
|
||||
catch (NotImplementedException ex)
|
||||
{
|
||||
UnifiedMappingControl.ShowValidationError("Not Implemented", ex.Message);
|
||||
UnifiedMappingControl.ShowValidationError(ResourceHelper.GetString("Error_NotImplemented_Title"), ex.Message);
|
||||
args.Cancel = true;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Logger.LogError("Error saving mapping: " + ex.Message);
|
||||
UnifiedMappingControl.ShowValidationError("Error", "An error occurred while saving: " + ex.Message);
|
||||
UnifiedMappingControl.ShowValidationError(ResourceHelper.GetString("Error_Generic_Title"), ResourceHelper.GetString("Error_Generic_Message") + ex.Message);
|
||||
args.Cancel = true;
|
||||
}
|
||||
}
|
||||
@@ -357,6 +437,8 @@ namespace KeyboardManagerEditorUI.Pages
|
||||
triggerKeys, UnifiedMappingControl.GetUrl(), isAppSpecific, appName, _mappingService!, _isEditMode),
|
||||
UnifiedMappingControl.ActionType.OpenApp => ValidationHelper.ValidateAppMapping(
|
||||
triggerKeys, UnifiedMappingControl.GetProgramPath(), isAppSpecific, appName, _mappingService!, _isEditMode),
|
||||
UnifiedMappingControl.ActionType.Disable => ValidationHelper.ValidateDisableMapping(
|
||||
triggerKeys, isAppSpecific, appName, _mappingService!, _isEditMode, editingRemapping),
|
||||
_ => ValidationErrorType.NoError,
|
||||
};
|
||||
}
|
||||
@@ -429,6 +511,46 @@ namespace KeyboardManagerEditorUI.Pages
|
||||
UnifiedMappingControl.GetAppName());
|
||||
}
|
||||
|
||||
private bool SaveDisableMapping(List<string> triggerKeys)
|
||||
{
|
||||
bool isAppSpecific = UnifiedMappingControl.GetIsAppSpecific();
|
||||
string appName = UnifiedMappingControl.GetAppName();
|
||||
|
||||
string originalKeysString = string.Join(
|
||||
";",
|
||||
triggerKeys.Select(k => _mappingService!.GetKeyCodeFromName(k).ToString(System.Globalization.CultureInfo.InvariantCulture)));
|
||||
|
||||
var shortcutKeyMapping = new ShortcutKeyMapping
|
||||
{
|
||||
OperationType = ShortcutOperationType.RemapShortcut,
|
||||
OriginalKeys = originalKeysString,
|
||||
TargetKeys = VkDisabledString,
|
||||
TargetApp = isAppSpecific ? appName : string.Empty,
|
||||
};
|
||||
|
||||
if (triggerKeys.Count == 1)
|
||||
{
|
||||
int originalKey = _mappingService!.GetKeyCodeFromName(triggerKeys[0]);
|
||||
if (originalKey == 0)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
shortcutKeyMapping.OriginalKeys = originalKey.ToString(System.Globalization.CultureInfo.InvariantCulture);
|
||||
_mappingService.AddSingleKeyMapping(originalKey, VkDisabled);
|
||||
}
|
||||
else
|
||||
{
|
||||
_mappingService!.AddShortcutMapping(
|
||||
originalKeysString,
|
||||
VkDisabledString,
|
||||
isAppSpecific ? appName : string.Empty);
|
||||
}
|
||||
|
||||
SettingsManager.AddShortcutKeyMappingToSettings(shortcutKeyMapping);
|
||||
return _mappingService.SaveSettings();
|
||||
}
|
||||
|
||||
private bool SaveTextMapping(List<string> triggerKeys)
|
||||
{
|
||||
string textContent = UnifiedMappingControl.GetTextContent();
|
||||
@@ -667,7 +789,16 @@ namespace KeyboardManagerEditorUI.Pages
|
||||
{
|
||||
if (shortcut is Remapping remapping)
|
||||
{
|
||||
RemappingHelper.SaveMapping(_mappingService!, remapping.Shortcut, remapping.RemappedKeys, !remapping.IsAllApps, remapping.AppName, false);
|
||||
if (remapping.RemappedKeys == null || remapping.RemappedKeys.Count == 0)
|
||||
{
|
||||
// Disabled mapping — re-enable by adding back the VK_DISABLED target
|
||||
EnableDisabledMapping(remapping);
|
||||
}
|
||||
else
|
||||
{
|
||||
RemappingHelper.SaveMapping(_mappingService!, remapping.Shortcut, remapping.RemappedKeys, !remapping.IsAllApps, remapping.AppName, false);
|
||||
}
|
||||
|
||||
shortcut.IsActive = true;
|
||||
SettingsManager.ToggleShortcutKeyMappingActiveState(shortcut.Id);
|
||||
return;
|
||||
@@ -710,6 +841,31 @@ namespace KeyboardManagerEditorUI.Pages
|
||||
}
|
||||
}
|
||||
|
||||
private void EnableDisabledMapping(Remapping remapping)
|
||||
{
|
||||
string originalKeysString = string.Join(
|
||||
";",
|
||||
remapping.Shortcut.Select(k => _mappingService!.GetKeyCodeFromName(k).ToString(System.Globalization.CultureInfo.InvariantCulture)));
|
||||
|
||||
if (remapping.Shortcut.Count == 1)
|
||||
{
|
||||
int originalKey = _mappingService!.GetKeyCodeFromName(remapping.Shortcut[0]);
|
||||
if (originalKey != 0)
|
||||
{
|
||||
_mappingService.AddSingleKeyMapping(originalKey, VkDisabled);
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
_mappingService!.AddShortcutMapping(
|
||||
originalKeysString,
|
||||
VkDisabledString,
|
||||
!remapping.IsAllApps ? remapping.AppName : string.Empty);
|
||||
}
|
||||
|
||||
_mappingService!.SaveSettings();
|
||||
}
|
||||
|
||||
private bool DeleteSingleKeyToTextMapping(string keyName)
|
||||
{
|
||||
int originalKey = _mappingService!.GetKeyCodeFromName(keyName);
|
||||
@@ -731,7 +887,7 @@ namespace KeyboardManagerEditorUI.Pages
|
||||
|
||||
private void UpdateHasAnyMappings()
|
||||
{
|
||||
bool hasAny = RemappingList.Count > 0 || TextMappings.Count > 0 || ProgramShortcuts.Count > 0 || UrlShortcuts.Count > 0;
|
||||
bool hasAny = RemappingList.Count > 0 || DisabledList.Count > 0 || TextMappings.Count > 0 || ProgramShortcuts.Count > 0 || UrlShortcuts.Count > 0;
|
||||
MappingState = hasAny ? "HasMappings" : "Empty";
|
||||
}
|
||||
|
||||
@@ -739,29 +895,40 @@ namespace KeyboardManagerEditorUI.Pages
|
||||
{
|
||||
SettingsManager.EditorSettings.ShortcutsByOperationType.TryGetValue(ShortcutOperationType.RemapShortcut, out var remapShortcutIds);
|
||||
|
||||
if (_mappingService == null || remapShortcutIds == null)
|
||||
if (remapShortcutIds == null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
RemappingList.Clear();
|
||||
DisabledList.Clear();
|
||||
|
||||
foreach (var id in remapShortcutIds)
|
||||
{
|
||||
ShortcutSettings shortcutSettings = SettingsManager.EditorSettings.ShortcutSettingsDictionary[id];
|
||||
ShortcutKeyMapping mapping = shortcutSettings.Shortcut;
|
||||
var originalKeyNames = ParseKeyCodes(mapping.OriginalKeys);
|
||||
var remappedKeyNames = ParseKeyCodes(mapping.TargetKeys);
|
||||
|
||||
RemappingList.Add(new Remapping
|
||||
bool isDisabled = mapping.TargetKeys == VkDisabledString;
|
||||
|
||||
var remapping = new Remapping
|
||||
{
|
||||
Shortcut = originalKeyNames,
|
||||
RemappedKeys = remappedKeyNames,
|
||||
RemappedKeys = isDisabled ? new List<string>() : ParseKeyCodes(mapping.TargetKeys),
|
||||
IsAllApps = string.IsNullOrEmpty(mapping.TargetApp),
|
||||
AppName = mapping.TargetApp ?? string.Empty,
|
||||
Id = shortcutSettings.Id,
|
||||
IsActive = shortcutSettings.IsActive,
|
||||
});
|
||||
};
|
||||
|
||||
if (isDisabled)
|
||||
{
|
||||
DisabledList.Add(remapping);
|
||||
}
|
||||
else
|
||||
{
|
||||
RemappingList.Add(remapping);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -769,7 +936,7 @@ namespace KeyboardManagerEditorUI.Pages
|
||||
{
|
||||
SettingsManager.EditorSettings.ShortcutsByOperationType.TryGetValue(ShortcutOperationType.RemapText, out var remapShortcutIds);
|
||||
|
||||
if (_mappingService == null || remapShortcutIds == null)
|
||||
if (remapShortcutIds == null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
@@ -798,7 +965,7 @@ namespace KeyboardManagerEditorUI.Pages
|
||||
{
|
||||
SettingsManager.EditorSettings.ShortcutsByOperationType.TryGetValue(ShortcutOperationType.RunProgram, out var remapShortcutIds);
|
||||
|
||||
if (_mappingService == null || remapShortcutIds == null)
|
||||
if (remapShortcutIds == null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
@@ -832,7 +999,7 @@ namespace KeyboardManagerEditorUI.Pages
|
||||
{
|
||||
SettingsManager.EditorSettings.ShortcutsByOperationType.TryGetValue(ShortcutOperationType.OpenUri, out var remapShortcutIds);
|
||||
|
||||
if (_mappingService == null || remapShortcutIds == null)
|
||||
if (remapShortcutIds == null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
@@ -860,8 +1027,12 @@ namespace KeyboardManagerEditorUI.Pages
|
||||
private List<string> ParseKeyCodes(string keyCodesString)
|
||||
{
|
||||
return keyCodesString.Split(';')
|
||||
.Where(keyCode => int.TryParse(keyCode, out int code))
|
||||
.Select(keyCode => _mappingService!.GetKeyDisplayName(int.Parse(keyCode, CultureInfo.InvariantCulture)))
|
||||
.Where(keyCode => int.TryParse(keyCode, out _))
|
||||
.Select(keyCode =>
|
||||
{
|
||||
int code = int.Parse(keyCode, CultureInfo.InvariantCulture);
|
||||
return _mappingService?.GetKeyDisplayName(code) ?? $"VK {code}";
|
||||
})
|
||||
.ToList();
|
||||
}
|
||||
|
||||
@@ -884,6 +1055,8 @@ namespace KeyboardManagerEditorUI.Pages
|
||||
|
||||
if (disposing)
|
||||
{
|
||||
_serviceCheckTimer?.Stop();
|
||||
_serviceCheckTimer = null;
|
||||
_mappingService?.Dispose();
|
||||
_mappingService = null;
|
||||
}
|
||||
|
||||
@@ -24,12 +24,28 @@ namespace KeyboardManagerEditorUI.Settings
|
||||
|
||||
private static readonly JsonSerializerOptions _jsonOptions = new JsonSerializerOptions { WriteIndented = true };
|
||||
|
||||
private static readonly KeyboardMappingService _mappingService = new KeyboardMappingService();
|
||||
private static readonly KeyboardMappingService? _mappingService;
|
||||
|
||||
/// <summary>
|
||||
/// Gets a value indicating whether the native C++ wrapper DLL is available.
|
||||
/// When false the editor runs in read-only / XAML-preview mode using JSON settings only.
|
||||
/// </summary>
|
||||
internal static bool IsNativeServiceAvailable => _mappingService is not null;
|
||||
|
||||
public static EditorSettings EditorSettings { get; set; }
|
||||
|
||||
static SettingsManager()
|
||||
{
|
||||
try
|
||||
{
|
||||
_mappingService = new KeyboardMappingService();
|
||||
}
|
||||
catch (Exception ex) when (ex is DllNotFoundException or InvalidOperationException)
|
||||
{
|
||||
ManagedCommon.Logger.LogWarning($"Native KBM library unavailable, running in standalone mode: {ex.Message}");
|
||||
_mappingService = null;
|
||||
}
|
||||
|
||||
EditorSettings = LoadSettings();
|
||||
}
|
||||
|
||||
@@ -39,9 +55,14 @@ namespace KeyboardManagerEditorUI.Settings
|
||||
{
|
||||
if (!File.Exists(_settingsFilePath))
|
||||
{
|
||||
EditorSettings createdSettings = CreateSettingsFromKeyboardManagerService();
|
||||
WriteSettings(createdSettings);
|
||||
return createdSettings;
|
||||
if (_mappingService is not null)
|
||||
{
|
||||
EditorSettings createdSettings = CreateSettingsFromKeyboardManagerService();
|
||||
WriteSettings(createdSettings);
|
||||
return createdSettings;
|
||||
}
|
||||
|
||||
return new EditorSettings();
|
||||
}
|
||||
|
||||
string json = File.ReadAllText(_settingsFilePath);
|
||||
@@ -75,13 +96,13 @@ namespace KeyboardManagerEditorUI.Settings
|
||||
EditorSettings settings = new EditorSettings();
|
||||
|
||||
// Process all shortcut mappings (RunProgram, OpenUri, RemapShortcut, RemapText)
|
||||
foreach (ShortcutKeyMapping mapping in _mappingService.GetShortcutMappings())
|
||||
foreach (ShortcutKeyMapping mapping in _mappingService!.GetShortcutMappings())
|
||||
{
|
||||
AddShortcutMapping(settings, mapping);
|
||||
}
|
||||
|
||||
// Process single key to key mappings
|
||||
foreach (var mapping in _mappingService.GetSingleKeyMappings())
|
||||
foreach (var mapping in _mappingService!.GetSingleKeyMappings())
|
||||
{
|
||||
var shortcutMapping = new ShortcutKeyMapping
|
||||
{
|
||||
@@ -93,7 +114,7 @@ namespace KeyboardManagerEditorUI.Settings
|
||||
}
|
||||
|
||||
// Process single key to text mappings
|
||||
foreach (var mapping in _mappingService.GetKeyToTextMappings())
|
||||
foreach (var mapping in _mappingService!.GetKeyToTextMappings())
|
||||
{
|
||||
var shortcutMapping = new ShortcutKeyMapping
|
||||
{
|
||||
@@ -110,10 +131,15 @@ namespace KeyboardManagerEditorUI.Settings
|
||||
|
||||
public static void CorrelateServiceAndEditorMappings()
|
||||
{
|
||||
if (_mappingService is not { } service)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
bool shortcutSettingsChanged = false;
|
||||
|
||||
// Process all shortcut mappings
|
||||
foreach (ShortcutKeyMapping mapping in _mappingService.GetShortcutMappings())
|
||||
foreach (ShortcutKeyMapping mapping in service.GetShortcutMappings())
|
||||
{
|
||||
if (!EditorSettings.ShortcutSettingsDictionary.Values.Any(s => s.Shortcut.OriginalKeys == mapping.OriginalKeys))
|
||||
{
|
||||
@@ -123,7 +149,7 @@ namespace KeyboardManagerEditorUI.Settings
|
||||
}
|
||||
|
||||
// Process single key to key mappings
|
||||
foreach (var mapping in _mappingService.GetSingleKeyMappings())
|
||||
foreach (var mapping in service.GetSingleKeyMappings())
|
||||
{
|
||||
var shortcutMapping = new ShortcutKeyMapping
|
||||
{
|
||||
@@ -140,7 +166,7 @@ namespace KeyboardManagerEditorUI.Settings
|
||||
}
|
||||
|
||||
// Process single key to text mappings
|
||||
foreach (var mapping in _mappingService.GetKeyToTextMappings())
|
||||
foreach (var mapping in service.GetKeyToTextMappings())
|
||||
{
|
||||
var shortcutMapping = new ShortcutKeyMapping
|
||||
{
|
||||
@@ -158,9 +184,9 @@ namespace KeyboardManagerEditorUI.Settings
|
||||
}
|
||||
|
||||
// Mark inactive mappings
|
||||
var singleKeyMappings = _mappingService.GetSingleKeyMappings();
|
||||
var keyToTextMappings = _mappingService.GetKeyToTextMappings();
|
||||
var shortcutKeyMappings = _mappingService.GetShortcutMappings();
|
||||
var singleKeyMappings = service.GetSingleKeyMappings();
|
||||
var keyToTextMappings = service.GetKeyToTextMappings();
|
||||
var shortcutKeyMappings = service.GetShortcutMappings();
|
||||
|
||||
foreach (ShortcutSettings shortcutSettings in EditorSettings.ShortcutSettingsDictionary.Values.ToList())
|
||||
{
|
||||
|
||||
@@ -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">
|
||||
@@ -112,6 +171,9 @@
|
||||
<data name="RemappingDialog.Title" xml:space="preserve">
|
||||
<value>Add new remapping</value>
|
||||
</data>
|
||||
<data name="RemappingDialog_TitleEdit" xml:space="preserve">
|
||||
<value>Edit remapping</value>
|
||||
</data>
|
||||
<data name="RemappingDialog.PrimaryButtonText" xml:space="preserve">
|
||||
<value>Save</value>
|
||||
</data>
|
||||
@@ -151,6 +213,12 @@
|
||||
<data name="AllowChordsCheckBox.Content" xml:space="preserve">
|
||||
<value>Allow chords</value>
|
||||
</data>
|
||||
<data name="TriggerKeyPlaceholder.Text" xml:space="preserve">
|
||||
<value>Press to start recording keys</value>
|
||||
</data>
|
||||
<data name="ActionKeyPlaceholder.Text" xml:space="preserve">
|
||||
<value>Press to start recording keys</value>
|
||||
</data>
|
||||
<data name="ExactMatchCheckBox.Content" xml:space="preserve">
|
||||
<value>Exact match</value>
|
||||
</data>
|
||||
@@ -208,6 +276,18 @@
|
||||
<data name="ActionType_MouseClick_Text.Text" xml:space="preserve">
|
||||
<value>Remap to mouse click</value>
|
||||
</data>
|
||||
<data name="ActionType_Disable.AutomationProperties.Name" xml:space="preserve">
|
||||
<value>Disable key or shortcut</value>
|
||||
</data>
|
||||
<data name="ActionType_Disable_Text.Text" xml:space="preserve">
|
||||
<value>Disable key or shortcut</value>
|
||||
</data>
|
||||
<data name="DisableDescription.Text" xml:space="preserve">
|
||||
<value>This key or shortcut will be disabled.</value>
|
||||
</data>
|
||||
<data name="DisabledHeader.Text" xml:space="preserve">
|
||||
<value>Disabled</value>
|
||||
</data>
|
||||
<data name="TextContentBox.Header" xml:space="preserve">
|
||||
<value>Text to type</value>
|
||||
</data>
|
||||
@@ -229,6 +309,9 @@
|
||||
<data name="ProgramPathSelectButton.AutomationProperties.Name" xml:space="preserve">
|
||||
<value>Select program path</value>
|
||||
</data>
|
||||
<data name="ProgramPathSelectButtonTooltip.Text" xml:space="preserve">
|
||||
<value>Select program path</value>
|
||||
</data>
|
||||
<data name="ProgramArgsInput.Header" xml:space="preserve">
|
||||
<value>Arguments (optional)</value>
|
||||
</data>
|
||||
@@ -243,6 +326,9 @@
|
||||
</data>
|
||||
<data name="StartInSelectButton.AutomationProperties.Name" xml:space="preserve">
|
||||
<value>Select start directory</value>
|
||||
</data>
|
||||
<data name="StartInSelectButtonTooltip.Text" xml:space="preserve">
|
||||
<value>Select start directory</value>
|
||||
</data>
|
||||
<data name="ElevationComboBox.Header" xml:space="preserve">
|
||||
<value>Run as</value>
|
||||
@@ -292,4 +378,127 @@
|
||||
<data name="MouseClickPlaceholder.Text" xml:space="preserve">
|
||||
<value>Mouse click action - coming soon</value>
|
||||
</data>
|
||||
<data name="Validation_EmptyOriginalKeys_Title" xml:space="preserve">
|
||||
<value>Missing original keys</value>
|
||||
</data>
|
||||
<data name="Validation_EmptyOriginalKeys_Message" xml:space="preserve">
|
||||
<value>Please enter at least one original key to create a remapping.</value>
|
||||
</data>
|
||||
<data name="Validation_EmptyRemappedKeys_Title" xml:space="preserve">
|
||||
<value>Missing target keys</value>
|
||||
</data>
|
||||
<data name="Validation_EmptyRemappedKeys_Message" xml:space="preserve">
|
||||
<value>Please enter at least one target key to create a remapping.</value>
|
||||
</data>
|
||||
<data name="Validation_ModifierOnly_Title" xml:space="preserve">
|
||||
<value>Invalid shortcut</value>
|
||||
</data>
|
||||
<data name="Validation_ModifierOnly_Message" xml:space="preserve">
|
||||
<value>Shortcuts must contain at least one action key in addition to modifier keys (Ctrl, Alt, Shift, Win).</value>
|
||||
</data>
|
||||
<data name="Validation_EmptyAppName_Title" xml:space="preserve">
|
||||
<value>Missing application name</value>
|
||||
</data>
|
||||
<data name="Validation_EmptyAppName_Message" xml:space="preserve">
|
||||
<value>You've selected app-specific remapping but haven't specified an application name. Please enter the application name.</value>
|
||||
</data>
|
||||
<data name="Validation_IllegalShortcut_Title" xml:space="preserve">
|
||||
<value>Reserved system shortcut</value>
|
||||
</data>
|
||||
<data name="Validation_IllegalShortcut_Message" xml:space="preserve">
|
||||
<value>Win+L and Ctrl+Alt+Delete are reserved system shortcuts and cannot be remapped.</value>
|
||||
</data>
|
||||
<data name="Validation_DuplicateMapping_Title" xml:space="preserve">
|
||||
<value>Duplicate remapping</value>
|
||||
</data>
|
||||
<data name="Validation_DuplicateMapping_Message" xml:space="preserve">
|
||||
<value>This key or shortcut is already remapped.</value>
|
||||
</data>
|
||||
<data name="Validation_ConflictingModifier_Title" xml:space="preserve">
|
||||
<value>Conflicting modifier</value>
|
||||
</data>
|
||||
<data name="Validation_ConflictingModifier_Message" xml:space="preserve">
|
||||
<value>This key conflicts with an existing remapping that uses a related modifier variant (e.g., Ctrl vs Left Ctrl).</value>
|
||||
</data>
|
||||
<data name="Validation_SelfMapping_Title" xml:space="preserve">
|
||||
<value>Invalid remapping</value>
|
||||
</data>
|
||||
<data name="Validation_SelfMapping_Message" xml:space="preserve">
|
||||
<value>A key or shortcut cannot be remapped to itself. Please choose a different target.</value>
|
||||
</data>
|
||||
<data name="Validation_EmptyTargetText_Title" xml:space="preserve">
|
||||
<value>Missing target text</value>
|
||||
</data>
|
||||
<data name="Validation_EmptyTargetText_Message" xml:space="preserve">
|
||||
<value>Please enter the text to be inserted when the shortcut is pressed.</value>
|
||||
</data>
|
||||
<data name="Validation_EmptyUrl_Title" xml:space="preserve">
|
||||
<value>Missing URL</value>
|
||||
</data>
|
||||
<data name="Validation_EmptyUrl_Message" xml:space="preserve">
|
||||
<value>Please enter the URL to open when the shortcut is pressed.</value>
|
||||
</data>
|
||||
<data name="Validation_EmptyProgramPath_Title" xml:space="preserve">
|
||||
<value>Missing program path</value>
|
||||
</data>
|
||||
<data name="Validation_EmptyProgramPath_Message" xml:space="preserve">
|
||||
<value>Please enter the program path to launch when the shortcut is pressed.</value>
|
||||
</data>
|
||||
<data name="Validation_OneKeyMapping_Title" xml:space="preserve">
|
||||
<value>Invalid remapping</value>
|
||||
</data>
|
||||
<data name="Validation_OneKeyMapping_Message" xml:space="preserve">
|
||||
<value>A single key cannot be remapped to a Program or URL shortcut. Please choose a combination of keys.</value>
|
||||
</data>
|
||||
<data name="Warning_ShortcutStartWithModifier" xml:space="preserve">
|
||||
<value>Shortcuts must start with a modifier key (Ctrl, Alt, Shift, or Win).</value>
|
||||
</data>
|
||||
<data name="Warning_RepeatedModifier" xml:space="preserve">
|
||||
<value>This shortcut already contains a modifier of the same type.</value>
|
||||
</data>
|
||||
<data name="Warning_MaxShortcutSize" xml:space="preserve">
|
||||
<value>Maximum shortcut size reached (4 modifiers + 1 action key).</value>
|
||||
</data>
|
||||
<data name="Warning_InputLimitReached" xml:space="preserve">
|
||||
<value>Shortcuts can only have up to 4 modifier keys.</value>
|
||||
</data>
|
||||
<data name="Error_MappingServiceUnavailable_Title" xml:space="preserve">
|
||||
<value>Error</value>
|
||||
</data>
|
||||
<data name="Error_MappingServiceUnavailable_Message" xml:space="preserve">
|
||||
<value>Mapping service is not available.</value>
|
||||
</data>
|
||||
<data name="Error_SaveFailed_Title" xml:space="preserve">
|
||||
<value>Save failed</value>
|
||||
</data>
|
||||
<data name="Error_SaveFailed_Message" xml:space="preserve">
|
||||
<value>Failed to save the remapping. Please try again.</value>
|
||||
</data>
|
||||
<data name="Error_NotImplemented_Title" xml:space="preserve">
|
||||
<value>Not implemented</value>
|
||||
</data>
|
||||
<data name="Error_Generic_Title" xml:space="preserve">
|
||||
<value>Error</value>
|
||||
</data>
|
||||
<data name="Error_Generic_Message" xml:space="preserve">
|
||||
<value>An error occurred while saving: </value>
|
||||
</data>
|
||||
<data name="Warning_Title" xml:space="preserve">
|
||||
<value>Warning</value>
|
||||
</data>
|
||||
<data name="Error_UnknownValidation_Title" xml:space="preserve">
|
||||
<value>Validation error</value>
|
||||
</data>
|
||||
<data name="Error_UnknownValidation_Message" xml:space="preserve">
|
||||
<value>An unknown validation error occurred.</value>
|
||||
</data>
|
||||
<data name="ServiceDownBanner.Title" xml:space="preserve">
|
||||
<value>Keyboard Manager service is down</value>
|
||||
</data>
|
||||
<data name="ServiceDownBanner.Message" xml:space="preserve">
|
||||
<value>Remappings will not function and you will be unable to edit any entries until the service is restored.</value>
|
||||
</data>
|
||||
<data name="CheckServiceBtn.Content" xml:space="preserve">
|
||||
<value>Check service status</value>
|
||||
</data>
|
||||
</root>
|
||||
@@ -1,5 +1,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
|
||||
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="CustomShortcutToggleButtonStyle" TargetType="ToggleButton">
|
||||
<Setter Property="Background" Value="{ThemeResource ToggleButtonBackground}" />
|
||||
@@ -135,58 +138,7 @@
|
||||
</ObjectAnimationUsingKeyFrames>
|
||||
</Storyboard>
|
||||
</VisualState>
|
||||
<VisualState x:Name="Indeterminate">
|
||||
<Storyboard>
|
||||
<ObjectAnimationUsingKeyFrames Storyboard.TargetName="ContentPresenter" Storyboard.TargetProperty="Background">
|
||||
<DiscreteObjectKeyFrame KeyTime="0" Value="{ThemeResource ToggleButtonBackgroundIndeterminate}" />
|
||||
</ObjectAnimationUsingKeyFrames>
|
||||
<ObjectAnimationUsingKeyFrames Storyboard.TargetName="ContentPresenter" Storyboard.TargetProperty="Foreground">
|
||||
<DiscreteObjectKeyFrame KeyTime="0" Value="{ThemeResource ToggleButtonForegroundIndeterminate}" />
|
||||
</ObjectAnimationUsingKeyFrames>
|
||||
<ObjectAnimationUsingKeyFrames Storyboard.TargetName="ContentPresenter" Storyboard.TargetProperty="BorderBrush">
|
||||
<DiscreteObjectKeyFrame KeyTime="0" Value="{ThemeResource ToggleButtonBorderBrushIndeterminate}" />
|
||||
</ObjectAnimationUsingKeyFrames>
|
||||
</Storyboard>
|
||||
</VisualState>
|
||||
<VisualState x:Name="IndeterminatePointerOver">
|
||||
<Storyboard>
|
||||
<ObjectAnimationUsingKeyFrames Storyboard.TargetName="ContentPresenter" Storyboard.TargetProperty="Background">
|
||||
<DiscreteObjectKeyFrame KeyTime="0" Value="{ThemeResource ToggleButtonBackgroundIndeterminatePointerOver}" />
|
||||
</ObjectAnimationUsingKeyFrames>
|
||||
<ObjectAnimationUsingKeyFrames Storyboard.TargetName="ContentPresenter" Storyboard.TargetProperty="BorderBrush">
|
||||
<DiscreteObjectKeyFrame KeyTime="0" Value="{ThemeResource ToggleButtonBorderBrushIndeterminatePointerOver}" />
|
||||
</ObjectAnimationUsingKeyFrames>
|
||||
<ObjectAnimationUsingKeyFrames Storyboard.TargetName="ContentPresenter" Storyboard.TargetProperty="Foreground">
|
||||
<DiscreteObjectKeyFrame KeyTime="0" Value="{ThemeResource ToggleButtonForegroundIndeterminatePointerOver}" />
|
||||
</ObjectAnimationUsingKeyFrames>
|
||||
</Storyboard>
|
||||
</VisualState>
|
||||
<VisualState x:Name="IndeterminatePressed">
|
||||
<Storyboard>
|
||||
<ObjectAnimationUsingKeyFrames Storyboard.TargetName="ContentPresenter" Storyboard.TargetProperty="Background">
|
||||
<DiscreteObjectKeyFrame KeyTime="0" Value="{ThemeResource ToggleButtonBackgroundIndeterminatePressed}" />
|
||||
</ObjectAnimationUsingKeyFrames>
|
||||
<ObjectAnimationUsingKeyFrames Storyboard.TargetName="ContentPresenter" Storyboard.TargetProperty="BorderBrush">
|
||||
<DiscreteObjectKeyFrame KeyTime="0" Value="{ThemeResource ToggleButtonBorderBrushIndeterminatePressed}" />
|
||||
</ObjectAnimationUsingKeyFrames>
|
||||
<ObjectAnimationUsingKeyFrames Storyboard.TargetName="ContentPresenter" Storyboard.TargetProperty="Foreground">
|
||||
<DiscreteObjectKeyFrame KeyTime="0" Value="{ThemeResource ToggleButtonForegroundIndeterminatePressed}" />
|
||||
</ObjectAnimationUsingKeyFrames>
|
||||
</Storyboard>
|
||||
</VisualState>
|
||||
<VisualState x:Name="IndeterminateDisabled">
|
||||
<Storyboard>
|
||||
<ObjectAnimationUsingKeyFrames Storyboard.TargetName="ContentPresenter" Storyboard.TargetProperty="Background">
|
||||
<DiscreteObjectKeyFrame KeyTime="0" Value="{ThemeResource ToggleButtonBackgroundIndeterminateDisabled}" />
|
||||
</ObjectAnimationUsingKeyFrames>
|
||||
<ObjectAnimationUsingKeyFrames Storyboard.TargetName="ContentPresenter" Storyboard.TargetProperty="Foreground">
|
||||
<DiscreteObjectKeyFrame KeyTime="0" Value="{ThemeResource ToggleButtonForegroundIndeterminateDisabled}" />
|
||||
</ObjectAnimationUsingKeyFrames>
|
||||
<ObjectAnimationUsingKeyFrames Storyboard.TargetName="ContentPresenter" Storyboard.TargetProperty="BorderBrush">
|
||||
<DiscreteObjectKeyFrame KeyTime="0" Value="{ThemeResource ToggleButtonBorderBrushIndeterminateDisabled}" />
|
||||
</ObjectAnimationUsingKeyFrames>
|
||||
</Storyboard>
|
||||
</VisualState>
|
||||
|
||||
</VisualStateGroup>
|
||||
</VisualStateManager.VisualStateGroups>
|
||||
</ContentPresenter>
|
||||
@@ -756,4 +708,232 @@
|
||||
</Setter>
|
||||
</Style.Setters>
|
||||
</Style>
|
||||
|
||||
<Style x:Key="DefaultKeyVisualDropDownButtonStyle" TargetType="DropDownButton">
|
||||
<Setter Property="Background" Value="{ThemeResource ControlFillColorDefaultBrush}" />
|
||||
<Setter Property="Foreground" Value="{ThemeResource TextFillColorPrimaryBrush}" />
|
||||
<Setter Property="BorderBrush" Value="{ThemeResource CardStrokeColorDefaultBrush}" />
|
||||
<Setter Property="BorderThickness" Value="1" />
|
||||
<Setter Property="Padding" Value="8" />
|
||||
<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="16" />
|
||||
<Setter Property="UseSystemFocusVisuals" Value="{StaticResource UseSystemFocusVisuals}" />
|
||||
<Setter Property="FocusVisualMargin" Value="-3" />
|
||||
<Setter Property="CornerRadius" Value="{ThemeResource OverlayCornerRadius}" />
|
||||
<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="8,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 ButtonBackgroundPointerOver}" />
|
||||
</ObjectAnimationUsingKeyFrames>
|
||||
<ObjectAnimationUsingKeyFrames Storyboard.TargetName="RootGrid" Storyboard.TargetProperty="BorderBrush">
|
||||
<DiscreteObjectKeyFrame KeyTime="0" Value="{ThemeResource ButtonBorderBrushPointerOver}" />
|
||||
</ObjectAnimationUsingKeyFrames>
|
||||
<ObjectAnimationUsingKeyFrames Storyboard.TargetName="ContentPresenter" Storyboard.TargetProperty="Foreground">
|
||||
<DiscreteObjectKeyFrame KeyTime="0" Value="{ThemeResource ButtonForegroundPointerOver}" />
|
||||
</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 ButtonBackgroundPressed}" />
|
||||
</ObjectAnimationUsingKeyFrames>
|
||||
<ObjectAnimationUsingKeyFrames Storyboard.TargetName="RootGrid" Storyboard.TargetProperty="BorderBrush">
|
||||
<DiscreteObjectKeyFrame KeyTime="0" Value="{ThemeResource ButtonBorderBrushPressed}" />
|
||||
</ObjectAnimationUsingKeyFrames>
|
||||
<ObjectAnimationUsingKeyFrames Storyboard.TargetName="ContentPresenter" Storyboard.TargetProperty="Foreground">
|
||||
<DiscreteObjectKeyFrame KeyTime="0" Value="{ThemeResource ButtonForegroundPressed}" />
|
||||
</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 ButtonBackgroundDisabled}" />
|
||||
</ObjectAnimationUsingKeyFrames>
|
||||
<ObjectAnimationUsingKeyFrames Storyboard.TargetName="RootGrid" Storyboard.TargetProperty="BorderBrush">
|
||||
<DiscreteObjectKeyFrame KeyTime="0" Value="{ThemeResource ButtonBorderBrushDisabled}" />
|
||||
</ObjectAnimationUsingKeyFrames>
|
||||
<ObjectAnimationUsingKeyFrames Storyboard.TargetName="ContentPresenter" Storyboard.TargetProperty="Foreground">
|
||||
<DiscreteObjectKeyFrame KeyTime="0" Value="{ThemeResource ButtonForegroundDisabled}" />
|
||||
</ObjectAnimationUsingKeyFrames>
|
||||
<ObjectAnimationUsingKeyFrames Storyboard.TargetName="ChevronIcon" Storyboard.TargetProperty="Foreground">
|
||||
<DiscreteObjectKeyFrame KeyTime="0" Value="{ThemeResource ButtonForegroundDisabled}" />
|
||||
</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>
|
||||
|
||||
<Style x:Key="AccentKeyVisualDropDownButtonStyle" TargetType="DropDownButton">
|
||||
<Setter Property="Background" Value="{ThemeResource CustomAccentBackgroundBrush}" />
|
||||
<Setter Property="Foreground" Value="{ThemeResource AccentTextFillColorPrimaryBrush}" />
|
||||
<Setter Property="BorderThickness" Value="0" />
|
||||
<Setter Property="Padding" Value="8" />
|
||||
<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="16" />
|
||||
<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}"
|
||||
Foreground="{TemplateBinding Foreground}" />
|
||||
<FontIcon
|
||||
x:Name="ChevronIcon"
|
||||
Grid.Column="1"
|
||||
Margin="8,0,0,0"
|
||||
FontFamily="{ThemeResource SymbolThemeFontFamily}"
|
||||
FontSize="8"
|
||||
Foreground="{TemplateBinding Foreground}"
|
||||
Glyph="" />
|
||||
<VisualStateManager.VisualStateGroups>
|
||||
<VisualStateGroup x:Name="CommonStates">
|
||||
<VisualState x:Name="Normal" />
|
||||
<VisualState x:Name="PointerOver">
|
||||
<Storyboard>
|
||||
<ObjectAnimationUsingKeyFrames Storyboard.TargetName="ContentPresenter" Storyboard.TargetProperty="Foreground">
|
||||
<DiscreteObjectKeyFrame KeyTime="0" Value="{ThemeResource AccentTextFillColorSecondaryBrush}" />
|
||||
</ObjectAnimationUsingKeyFrames>
|
||||
<ObjectAnimationUsingKeyFrames Storyboard.TargetName="ChevronIcon" Storyboard.TargetProperty="Foreground">
|
||||
<DiscreteObjectKeyFrame KeyTime="0" Value="{ThemeResource AccentTextFillColorSecondaryBrush}" />
|
||||
</ObjectAnimationUsingKeyFrames>
|
||||
</Storyboard>
|
||||
<VisualState.Setters />
|
||||
</VisualState>
|
||||
<VisualState x:Name="Pressed">
|
||||
<Storyboard>
|
||||
<ObjectAnimationUsingKeyFrames Storyboard.TargetName="ContentPresenter" Storyboard.TargetProperty="Foreground">
|
||||
<DiscreteObjectKeyFrame KeyTime="0" Value="{ThemeResource AccentTextFillColorTertiaryBrush}" />
|
||||
</ObjectAnimationUsingKeyFrames>
|
||||
<ObjectAnimationUsingKeyFrames Storyboard.TargetName="ChevronIcon" Storyboard.TargetProperty="Foreground">
|
||||
<DiscreteObjectKeyFrame KeyTime="0" Value="{ThemeResource AccentTextFillColorTertiaryBrush}" />
|
||||
</ObjectAnimationUsingKeyFrames>
|
||||
</Storyboard>
|
||||
<VisualState.Setters />
|
||||
</VisualState>
|
||||
<VisualState x:Name="Disabled">
|
||||
<Storyboard>
|
||||
<ObjectAnimationUsingKeyFrames Storyboard.TargetName="RootGrid" Storyboard.TargetProperty="Background">
|
||||
<DiscreteObjectKeyFrame KeyTime="0" Value="{ThemeResource AccentButtonBackgroundDisabled}" />
|
||||
</ObjectAnimationUsingKeyFrames>
|
||||
<ObjectAnimationUsingKeyFrames Storyboard.TargetName="RootGrid" Storyboard.TargetProperty="BorderBrush">
|
||||
<DiscreteObjectKeyFrame KeyTime="0" Value="{ThemeResource AccentButtonBorderBrushDisabled}" />
|
||||
</ObjectAnimationUsingKeyFrames>
|
||||
<ObjectAnimationUsingKeyFrames Storyboard.TargetName="ContentPresenter" Storyboard.TargetProperty="Foreground">
|
||||
<DiscreteObjectKeyFrame KeyTime="0" Value="{ThemeResource AccentButtonForegroundDisabled}" />
|
||||
</ObjectAnimationUsingKeyFrames>
|
||||
<ObjectAnimationUsingKeyFrames Storyboard.TargetName="ChevronIcon" Storyboard.TargetProperty="Foreground">
|
||||
<DiscreteObjectKeyFrame KeyTime="0" Value="{ThemeResource AccentButtonForegroundDisabled}" />
|
||||
</ObjectAnimationUsingKeyFrames>
|
||||
</Storyboard>
|
||||
<VisualState.Setters />
|
||||
</VisualState>
|
||||
</VisualStateGroup>
|
||||
</VisualStateManager.VisualStateGroups>
|
||||
</Grid>
|
||||
</ControlTemplate>
|
||||
</Setter.Value>
|
||||
</Setter>
|
||||
</Style>
|
||||
</ResourceDictionary>
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
<?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="Default">
|
||||
<SolidColorBrush
|
||||
|
||||
@@ -538,10 +538,11 @@ namespace KeyboardEventHandlers
|
||||
// Release original shortcut state (release in reverse order of shortcut to be accurate)
|
||||
Helpers::SetModifierKeyEvents(it->first, it->second.modifierKeysInvoked, keyEventList, false, KeyboardManagerConstants::KEYBOARDMANAGER_SHORTCUT_FLAG);
|
||||
|
||||
// Send modifier release events first, then paste text via clipboard
|
||||
// Send modifier release events first, then send text directly
|
||||
// (SendTextInput handles multiline by flushing between chunks)
|
||||
ii.SendVirtualInput(keyEventList);
|
||||
keyEventList.clear();
|
||||
Helpers::SendTextViaClipboard(remapping);
|
||||
Helpers::SendTextInput(remapping);
|
||||
}
|
||||
|
||||
it->second.isShortcutInvoked = true;
|
||||
@@ -722,7 +723,8 @@ namespace KeyboardEventHandlers
|
||||
else if (remapToText)
|
||||
{
|
||||
auto& remapping = std::get<std::wstring>(it->second.targetShortcut);
|
||||
Helpers::SendTextViaClipboard(remapping);
|
||||
ii.SendVirtualInput(keyEventList);
|
||||
Helpers::SendTextInput(remapping);
|
||||
return 1;
|
||||
}
|
||||
|
||||
@@ -1797,7 +1799,7 @@ namespace KeyboardEventHandlers
|
||||
return 0;
|
||||
}
|
||||
|
||||
Helpers::SendTextViaClipboard(*remapping);
|
||||
Helpers::SendTextInput(*remapping);
|
||||
|
||||
return 1;
|
||||
}
|
||||
|
||||
@@ -60,85 +60,4 @@ namespace RemappingLogicTests
|
||||
Assert::AreEqual<unsigned int>(0, inputs[1].ki.wScan);
|
||||
}
|
||||
};
|
||||
|
||||
// Tests for the SetTextKeyEvents method
|
||||
TEST_CLASS (SetTextKeyEventsTests)
|
||||
{
|
||||
public:
|
||||
// Test that plain ASCII text produces KEYEVENTF_UNICODE events with correct scan codes
|
||||
TEST_METHOD (SetTextKeyEvents_ShouldUseUnicodeFlag_WhenTextIsPlainAscii)
|
||||
{
|
||||
std::vector<INPUT> inputs;
|
||||
std::wstring text = L"abc";
|
||||
|
||||
Helpers::SetTextKeyEvents(inputs, text);
|
||||
|
||||
// 3 characters × 2 events (down+up) = 6 events
|
||||
Assert::AreEqual<size_t>(6, inputs.size());
|
||||
for (size_t i = 0; i < inputs.size(); i++)
|
||||
{
|
||||
Assert::AreEqual(true, bool(inputs[i].ki.dwFlags & KEYEVENTF_UNICODE));
|
||||
}
|
||||
Assert::AreEqual<unsigned short>(L'a', inputs[0].ki.wScan);
|
||||
Assert::AreEqual<unsigned short>(L'b', inputs[2].ki.wScan);
|
||||
Assert::AreEqual<unsigned short>(L'c', inputs[4].ki.wScan);
|
||||
}
|
||||
|
||||
// Test that each character generates a keydown and keyup event pair
|
||||
TEST_METHOD (SetTextKeyEvents_ShouldGenerateDownUpPairs_WhenTextHasMultipleChars)
|
||||
{
|
||||
std::vector<INPUT> inputs;
|
||||
std::wstring text = L"xy";
|
||||
|
||||
Helpers::SetTextKeyEvents(inputs, text);
|
||||
|
||||
Assert::AreEqual<size_t>(4, inputs.size());
|
||||
// First event: 'x' keydown (no KEYEVENTF_KEYUP flag)
|
||||
Assert::AreEqual<unsigned short>(L'x', inputs[0].ki.wScan);
|
||||
Assert::IsFalse(bool(inputs[0].ki.dwFlags & KEYEVENTF_KEYUP));
|
||||
// Second event: 'x' keyup
|
||||
Assert::AreEqual<unsigned short>(L'x', inputs[1].ki.wScan);
|
||||
Assert::AreEqual(true, bool(inputs[1].ki.dwFlags & KEYEVENTF_KEYUP));
|
||||
}
|
||||
|
||||
// Test that newline characters are passed through as Unicode events (actual newline handling is done via clipboard)
|
||||
TEST_METHOD (SetTextKeyEvents_ShouldPassNewlinesAsUnicode_WhenTextContainsNewlines)
|
||||
{
|
||||
std::vector<INPUT> inputs;
|
||||
std::wstring text = L"a\r\nb";
|
||||
|
||||
Helpers::SetTextKeyEvents(inputs, text);
|
||||
|
||||
// All 4 characters (a, \r, \n, b) × 2 events = 8 events
|
||||
Assert::AreEqual<size_t>(8, inputs.size());
|
||||
Assert::AreEqual<unsigned short>(L'a', inputs[0].ki.wScan);
|
||||
Assert::AreEqual<unsigned short>(L'\r', inputs[2].ki.wScan);
|
||||
Assert::AreEqual<unsigned short>(L'\n', inputs[4].ki.wScan);
|
||||
Assert::AreEqual<unsigned short>(L'b', inputs[6].ki.wScan);
|
||||
}
|
||||
|
||||
// Test empty string produces no events
|
||||
TEST_METHOD (SetTextKeyEvents_ShouldProduceNoEvents_WhenTextIsEmpty)
|
||||
{
|
||||
std::vector<INPUT> inputs;
|
||||
std::wstring text = L"";
|
||||
|
||||
Helpers::SetTextKeyEvents(inputs, text);
|
||||
|
||||
Assert::AreEqual<size_t>(0, inputs.size());
|
||||
}
|
||||
|
||||
// Test that extraInfo flag is set correctly for KBM identification
|
||||
TEST_METHOD (SetTextKeyEvents_ShouldSetExtraInfoFlag_WhenTextIsProvided)
|
||||
{
|
||||
std::vector<INPUT> inputs;
|
||||
std::wstring text = L"a";
|
||||
|
||||
Helpers::SetTextKeyEvents(inputs, text);
|
||||
|
||||
Assert::AreEqual<size_t>(2, inputs.size());
|
||||
Assert::AreEqual(KeyboardManagerConstants::KEYBOARDMANAGER_SHORTCUT_FLAG, inputs[0].ki.dwExtraInfo);
|
||||
Assert::AreEqual(KeyboardManagerConstants::KEYBOARDMANAGER_SHORTCUT_FLAG, inputs[1].ki.dwExtraInfo);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,11 +1,6 @@
|
||||
#include "pch.h"
|
||||
#include "Helpers.h"
|
||||
#include <atomic>
|
||||
#include <condition_variable>
|
||||
#include <queue>
|
||||
#include <sstream>
|
||||
#include <mutex>
|
||||
#include <thread>
|
||||
|
||||
#include <common/interop/shared_constants.h>
|
||||
#include <common/utils/process_path.h>
|
||||
@@ -314,238 +309,72 @@ namespace Helpers
|
||||
}
|
||||
}
|
||||
|
||||
void SetTextKeyEvents(std::vector<INPUT>& keyEventArray, const std::wstring& remapping)
|
||||
// Sends text input directly via SendInput, handling newlines by sending
|
||||
// Shift+Enter. Each character is sent individually to avoid a synchronization
|
||||
// error across key-down and key-up events that causes repeated or dropped characters
|
||||
// when large batches of KEYEVENTF_UNICODE events are sent at once.
|
||||
void SendTextInput(const std::wstring& text)
|
||||
{
|
||||
for (wchar_t c : remapping)
|
||||
for (size_t i = 0; i < text.size(); ++i)
|
||||
{
|
||||
for (DWORD flag : { 0, KEYEVENTF_KEYUP })
|
||||
wchar_t c = text[i];
|
||||
|
||||
// Handle \r\n as a single newline
|
||||
if (c == L'\r' && i + 1 < text.size() && text[i + 1] == L'\n')
|
||||
{
|
||||
INPUT input{};
|
||||
input.type = INPUT_KEYBOARD;
|
||||
input.ki.dwFlags = KEYEVENTF_UNICODE | flag;
|
||||
input.ki.dwExtraInfo = KeyboardManagerConstants::KEYBOARDMANAGER_SHORTCUT_FLAG;
|
||||
input.ki.wScan = c;
|
||||
keyEventArray.push_back(input);
|
||||
++i;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Helper to set clipboard text. Returns true on success.
|
||||
static bool SetClipboardText(const std::wstring& text)
|
||||
{
|
||||
if (!OpenClipboard(nullptr))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
EmptyClipboard();
|
||||
|
||||
size_t byteSize = (text.size() + 1) * sizeof(wchar_t);
|
||||
HGLOBAL hMem = GlobalAlloc(GMEM_MOVEABLE, byteSize);
|
||||
if (!hMem)
|
||||
{
|
||||
CloseClipboard();
|
||||
return false;
|
||||
}
|
||||
|
||||
wchar_t* pMem = static_cast<wchar_t*>(GlobalLock(hMem));
|
||||
if (!pMem)
|
||||
{
|
||||
GlobalFree(hMem);
|
||||
CloseClipboard();
|
||||
return false;
|
||||
}
|
||||
|
||||
wcscpy_s(pMem, text.size() + 1, text.c_str());
|
||||
GlobalUnlock(hMem);
|
||||
|
||||
if (!SetClipboardData(CF_UNICODETEXT, hMem))
|
||||
{
|
||||
GlobalFree(hMem);
|
||||
CloseClipboard();
|
||||
return false;
|
||||
}
|
||||
|
||||
// Exclude this entry from clipboard history and cloud clipboard so the
|
||||
// temporary paste text does not pollute the user's clipboard history.
|
||||
static const UINT excludeFromHistory = RegisterClipboardFormat(L"ExcludeClipboardContentFromMonitorProcessing");
|
||||
if (excludeFromHistory != 0)
|
||||
{
|
||||
HGLOBAL hExclude = GlobalAlloc(GMEM_MOVEABLE, sizeof(DWORD));
|
||||
if (hExclude)
|
||||
if (c == L'\r' || c == L'\n')
|
||||
{
|
||||
SetClipboardData(excludeFromHistory, hExclude);
|
||||
// Send Shift+Enter instead of bare Enter so that chat apps
|
||||
// (Teams, Slack, Discord, etc.) insert a new line rather than
|
||||
// submitting the message. In plain text editors both behave
|
||||
// the same.
|
||||
INPUT returnInputs[4]{};
|
||||
|
||||
// Shift down
|
||||
returnInputs[0].type = INPUT_KEYBOARD;
|
||||
returnInputs[0].ki.wVk = VK_SHIFT;
|
||||
returnInputs[0].ki.wScan = static_cast<WORD>(MapVirtualKey(VK_SHIFT, MAPVK_VK_TO_VSC));
|
||||
returnInputs[0].ki.dwExtraInfo = KeyboardManagerConstants::KEYBOARDMANAGER_SHORTCUT_FLAG;
|
||||
|
||||
// Return down
|
||||
returnInputs[1].type = INPUT_KEYBOARD;
|
||||
returnInputs[1].ki.wVk = VK_RETURN;
|
||||
returnInputs[1].ki.wScan = static_cast<WORD>(MapVirtualKey(VK_RETURN, MAPVK_VK_TO_VSC));
|
||||
returnInputs[1].ki.dwExtraInfo = KeyboardManagerConstants::KEYBOARDMANAGER_SHORTCUT_FLAG;
|
||||
|
||||
// Return up
|
||||
returnInputs[2].type = INPUT_KEYBOARD;
|
||||
returnInputs[2].ki.wVk = VK_RETURN;
|
||||
returnInputs[2].ki.dwFlags = KEYEVENTF_KEYUP;
|
||||
returnInputs[2].ki.wScan = static_cast<WORD>(MapVirtualKey(VK_RETURN, MAPVK_VK_TO_VSC));
|
||||
returnInputs[2].ki.dwExtraInfo = KeyboardManagerConstants::KEYBOARDMANAGER_SHORTCUT_FLAG;
|
||||
|
||||
// Shift up
|
||||
returnInputs[3].type = INPUT_KEYBOARD;
|
||||
returnInputs[3].ki.wVk = VK_SHIFT;
|
||||
returnInputs[3].ki.dwFlags = KEYEVENTF_KEYUP;
|
||||
returnInputs[3].ki.wScan = static_cast<WORD>(MapVirtualKey(VK_SHIFT, MAPVK_VK_TO_VSC));
|
||||
returnInputs[3].ki.dwExtraInfo = KeyboardManagerConstants::KEYBOARDMANAGER_SHORTCUT_FLAG;
|
||||
|
||||
SendInput(ARRAYSIZE(returnInputs), returnInputs, sizeof(INPUT));
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
CloseClipboard();
|
||||
return true;
|
||||
}
|
||||
INPUT charInputs[2]{};
|
||||
charInputs[0].type = INPUT_KEYBOARD;
|
||||
charInputs[0].ki.dwFlags = KEYEVENTF_UNICODE;
|
||||
charInputs[0].ki.dwExtraInfo = KeyboardManagerConstants::KEYBOARDMANAGER_SHORTCUT_FLAG;
|
||||
charInputs[0].ki.wScan = c;
|
||||
|
||||
// Simulate Ctrl+V paste keystroke, tagged with KBM flag so our own hook
|
||||
// passes it through without re-intercepting.
|
||||
static void SendPasteKeystroke()
|
||||
{
|
||||
INPUT pasteInputs[4]{};
|
||||
charInputs[1].type = INPUT_KEYBOARD;
|
||||
charInputs[1].ki.dwFlags = KEYEVENTF_UNICODE | KEYEVENTF_KEYUP;
|
||||
charInputs[1].ki.dwExtraInfo = KeyboardManagerConstants::KEYBOARDMANAGER_SHORTCUT_FLAG;
|
||||
charInputs[1].ki.wScan = c;
|
||||
|
||||
pasteInputs[0].type = INPUT_KEYBOARD;
|
||||
pasteInputs[0].ki.wVk = VK_CONTROL;
|
||||
pasteInputs[0].ki.wScan = static_cast<WORD>(MapVirtualKey(VK_CONTROL, MAPVK_VK_TO_VSC));
|
||||
pasteInputs[0].ki.dwExtraInfo = KeyboardManagerConstants::KEYBOARDMANAGER_SHORTCUT_FLAG;
|
||||
|
||||
pasteInputs[1].type = INPUT_KEYBOARD;
|
||||
pasteInputs[1].ki.wVk = 'V';
|
||||
pasteInputs[1].ki.wScan = static_cast<WORD>(MapVirtualKey('V', MAPVK_VK_TO_VSC));
|
||||
pasteInputs[1].ki.dwExtraInfo = KeyboardManagerConstants::KEYBOARDMANAGER_SHORTCUT_FLAG;
|
||||
|
||||
pasteInputs[2].type = INPUT_KEYBOARD;
|
||||
pasteInputs[2].ki.wVk = 'V';
|
||||
pasteInputs[2].ki.dwFlags = KEYEVENTF_KEYUP;
|
||||
pasteInputs[2].ki.wScan = static_cast<WORD>(MapVirtualKey('V', MAPVK_VK_TO_VSC));
|
||||
pasteInputs[2].ki.dwExtraInfo = KeyboardManagerConstants::KEYBOARDMANAGER_SHORTCUT_FLAG;
|
||||
|
||||
pasteInputs[3].type = INPUT_KEYBOARD;
|
||||
pasteInputs[3].ki.wVk = VK_CONTROL;
|
||||
pasteInputs[3].ki.dwFlags = KEYEVENTF_KEYUP;
|
||||
pasteInputs[3].ki.wScan = static_cast<WORD>(MapVirtualKey(VK_CONTROL, MAPVK_VK_TO_VSC));
|
||||
pasteInputs[3].ki.dwExtraInfo = KeyboardManagerConstants::KEYBOARDMANAGER_SHORTCUT_FLAG;
|
||||
|
||||
SendInput(ARRAYSIZE(pasteInputs), pasteInputs, sizeof(INPUT));
|
||||
}
|
||||
|
||||
// Single background worker that processes clipboard-paste requests
|
||||
// sequentially. Keeping one thread avoids creating/tearing-down a
|
||||
// thread on every keypress and naturally serializes clipboard access
|
||||
// without a separate mutex.
|
||||
static std::queue<std::wstring> s_clipboardQueue;
|
||||
static std::mutex s_queueMutex;
|
||||
static std::condition_variable s_queueCV;
|
||||
static std::once_flag s_workerInitFlag;
|
||||
static std::atomic<bool> s_shutdown{ false };
|
||||
static std::thread s_workerThread;
|
||||
|
||||
static void ClipboardWorkerLoop()
|
||||
{
|
||||
while (true)
|
||||
{
|
||||
try
|
||||
{
|
||||
std::wstring text;
|
||||
{
|
||||
std::unique_lock<std::mutex> lock(s_queueMutex);
|
||||
s_queueCV.wait(lock, [] { return !s_clipboardQueue.empty() || s_shutdown.load(); });
|
||||
if (s_shutdown.load())
|
||||
{
|
||||
break;
|
||||
}
|
||||
text = std::move(s_clipboardQueue.front());
|
||||
s_clipboardQueue.pop();
|
||||
}
|
||||
|
||||
// Snapshot current clipboard state
|
||||
bool hadOriginalText = false;
|
||||
std::wstring originalClipboardText;
|
||||
if (OpenClipboard(nullptr))
|
||||
{
|
||||
if (IsClipboardFormatAvailable(CF_UNICODETEXT))
|
||||
{
|
||||
HANDLE hData = GetClipboardData(CF_UNICODETEXT);
|
||||
if (hData)
|
||||
{
|
||||
wchar_t* pText = static_cast<wchar_t*>(GlobalLock(hData));
|
||||
if (pText)
|
||||
{
|
||||
originalClipboardText = pText;
|
||||
hadOriginalText = true;
|
||||
GlobalUnlock(hData);
|
||||
}
|
||||
}
|
||||
}
|
||||
CloseClipboard();
|
||||
}
|
||||
|
||||
// Place our text on the clipboard (with history exclusion)
|
||||
if (!SetClipboardText(text))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
SendPasteKeystroke();
|
||||
|
||||
// Ctrl+V is asynchronous (SendInput queues the input), so the
|
||||
// target app needs time to process the keystroke and read the
|
||||
// clipboard before we restore the original content.
|
||||
Sleep(100);
|
||||
|
||||
if (hadOriginalText)
|
||||
{
|
||||
SetClipboardText(originalClipboardText);
|
||||
}
|
||||
else
|
||||
{
|
||||
if (OpenClipboard(nullptr))
|
||||
{
|
||||
EmptyClipboard();
|
||||
CloseClipboard();
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (...)
|
||||
{
|
||||
OutputDebugStringA("KBM ClipboardWorker exception caught, continuing\n");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Inner implementation that may throw C++ exceptions.
|
||||
static bool SendTextViaClipboardImpl(const std::wstring& text)
|
||||
{
|
||||
// Lazily start the worker on first use.
|
||||
std::call_once(s_workerInitFlag, [] {
|
||||
s_workerThread = std::thread(ClipboardWorkerLoop);
|
||||
std::atexit([] {
|
||||
{
|
||||
std::lock_guard<std::mutex> lock(s_queueMutex);
|
||||
s_shutdown.store(true);
|
||||
}
|
||||
s_queueCV.notify_one();
|
||||
if (s_workerThread.joinable())
|
||||
{
|
||||
s_workerThread.join();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// Enqueue the text and return immediately so we never block the
|
||||
// low-level keyboard hook (WH_KEYBOARD_LL).
|
||||
{
|
||||
std::lock_guard<std::mutex> lock(s_queueMutex);
|
||||
if (!s_clipboardQueue.empty())
|
||||
{
|
||||
return true;
|
||||
}
|
||||
s_clipboardQueue.push(text);
|
||||
}
|
||||
s_queueCV.notify_one();
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
// Function to send text via clipboard paste (Ctrl+V).
|
||||
// Saves the previous clipboard content and restores it asynchronously.
|
||||
// This function MUST NOT throw — it's called from noexcept hook handlers.
|
||||
// We use __try/__except (SEH) because C++ try/catch may be optimized away
|
||||
// in Release builds when the caller is noexcept.
|
||||
bool SendTextViaClipboard(const std::wstring& text) noexcept
|
||||
{
|
||||
__try
|
||||
{
|
||||
return SendTextViaClipboardImpl(text);
|
||||
}
|
||||
__except (EXCEPTION_EXECUTE_HANDLER)
|
||||
{
|
||||
OutputDebugStringA("KBM SendTextViaClipboard SEH exception caught\n");
|
||||
return false;
|
||||
SendInput(ARRAYSIZE(charInputs), charInputs, sizeof(INPUT));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -38,11 +38,10 @@ namespace Helpers
|
||||
// Function to set the dummy key events used for remapping shortcuts, required to ensure releasing a modifier doesn't trigger another action (For example, Win->Start Menu or Alt->Menu bar)
|
||||
void SetDummyKeyEvent(std::vector<INPUT>& keyEventArray, ULONG_PTR extraInfo);
|
||||
|
||||
// Function to set key events for remapping text.
|
||||
void SetTextKeyEvents(std::vector<INPUT>& keyEventArray, const std::wstring& remapping);
|
||||
|
||||
// Function to send text via clipboard paste (Ctrl+V). Saves and restores previous clipboard content.
|
||||
bool SendTextViaClipboard(const std::wstring& text) noexcept;
|
||||
// Function to send text input directly, with multiline support.
|
||||
// Sends each line via KEYEVENTF_UNICODE and newlines via VK_RETURN
|
||||
// as separate SendInput calls to avoid mixing event types.
|
||||
void SendTextInput(const std::wstring& text);
|
||||
|
||||
// Function to return window handle for a full screen UWP app
|
||||
HWND GetFullscreenUWPWindowHandle();
|
||||
|
||||
@@ -20,9 +20,7 @@
|
||||
<PackageReference Include="System.Configuration.ConfigurationManager">
|
||||
<ExcludeAssets>runtime</ExcludeAssets> <!-- Should already be present on .net sdk runtime, so we avoid the conflicting runtime version from nuget -->
|
||||
</PackageReference>
|
||||
<PackageReference Include="System.Diagnostics.EventLog">
|
||||
<ExcludeAssets>runtime</ExcludeAssets> <!-- Should already be present on .net sdk runtime, so we avoid the conflicting runtime version from nuget -->
|
||||
</PackageReference>
|
||||
<!-- System.Diagnostics.EventLog is now provided to all C# projects via Directory.Build.props -->
|
||||
<PackageReference Include="System.Diagnostics.PerformanceCounter">
|
||||
<ExcludeAssets>runtime</ExcludeAssets> <!-- Should already be present on .net sdk runtime, so we avoid the conflicting runtime version from nuget -->
|
||||
</PackageReference>
|
||||
|
||||
@@ -188,10 +188,29 @@ namespace Microsoft.Plugin.Shell
|
||||
return history.ToList();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Escapes embedded double-quotes in a string for cmd.exe.
|
||||
/// cmd.exe uses doubled double-quotes ("") for escaping inside a double-quoted string.
|
||||
/// </summary>
|
||||
private static string EscapeCmdArgument(string arg)
|
||||
{
|
||||
return string.IsNullOrEmpty(arg) ? string.Empty : arg.Replace("\"", "\"\"");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Escapes embedded double-quotes in a string for PowerShell.
|
||||
/// PowerShell honors backslash-escaping (\") when receiving a command string via the -Command/-C parameter.
|
||||
/// </summary>
|
||||
private static string EscapePowerShellArgument(string arg)
|
||||
{
|
||||
return string.IsNullOrEmpty(arg) ? string.Empty : arg.Replace("\"", "\\\"");
|
||||
}
|
||||
|
||||
private ProcessStartInfo PrepareProcessStartInfo(string command, RunAsType runAs = RunAsType.None)
|
||||
{
|
||||
string trimmedCommand = command.Trim();
|
||||
command = Environment.ExpandEnvironmentVariables(trimmedCommand);
|
||||
|
||||
var workingDirectory = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile);
|
||||
|
||||
// Set runAsArg
|
||||
@@ -208,34 +227,36 @@ namespace Microsoft.Plugin.Shell
|
||||
ProcessStartInfo info;
|
||||
if (_settings.Shell == ExecutionShell.Cmd)
|
||||
{
|
||||
var arguments = _settings.LeaveShellOpen ? $"/k \"{command}\"" : $"/c \"{command}\" & pause";
|
||||
var arguments = _settings.LeaveShellOpen ? $"/k {command}" : $"/c {command} & pause";
|
||||
|
||||
info = ShellCommand.SetProcessStartInfo("cmd.exe", workingDirectory, arguments, runAsVerbArg);
|
||||
}
|
||||
else if (_settings.Shell == ExecutionShell.Powershell)
|
||||
{
|
||||
string escapedPS = EscapePowerShellArgument(command);
|
||||
string arguments;
|
||||
if (_settings.LeaveShellOpen)
|
||||
{
|
||||
arguments = $"-NoExit \"{command}\"";
|
||||
arguments = $"-NoExit -C \"{escapedPS}\"";
|
||||
}
|
||||
else
|
||||
{
|
||||
arguments = $"\"{command} ; Read-Host -Prompt \\\"{Resources.run_plugin_cmd_wait_message}\\\"\"";
|
||||
arguments = $"-C \"{escapedPS} ; Read-Host -Prompt \\\"{Resources.run_plugin_cmd_wait_message}\\\"\"";
|
||||
}
|
||||
|
||||
info = ShellCommand.SetProcessStartInfo("powershell.exe", workingDirectory, arguments, runAsVerbArg);
|
||||
}
|
||||
else if (_settings.Shell == ExecutionShell.PowerShellSeven)
|
||||
{
|
||||
string escapedPS = EscapePowerShellArgument(command);
|
||||
string arguments;
|
||||
if (_settings.LeaveShellOpen)
|
||||
{
|
||||
arguments = $"-NoExit -C \"{command}\"";
|
||||
arguments = $"-NoExit -C \"{escapedPS}\"";
|
||||
}
|
||||
else
|
||||
{
|
||||
arguments = $"-C \"{command} ; Read-Host -Prompt \\\"{Resources.run_plugin_cmd_wait_message}\\\"\"";
|
||||
arguments = $"-C \"{escapedPS} ; Read-Host -Prompt \\\"{Resources.run_plugin_cmd_wait_message}\\\"\"";
|
||||
}
|
||||
|
||||
info = ShellCommand.SetProcessStartInfo("pwsh.exe", workingDirectory, arguments, runAsVerbArg);
|
||||
@@ -245,39 +266,41 @@ namespace Microsoft.Plugin.Shell
|
||||
string arguments;
|
||||
if (_settings.LeaveShellOpen)
|
||||
{
|
||||
arguments = $"cmd.exe /k \"{command}\"";
|
||||
arguments = $"cmd.exe /k {command}";
|
||||
}
|
||||
else
|
||||
{
|
||||
arguments = $"cmd.exe /c \"{command}\" & pause";
|
||||
arguments = $"cmd.exe /c {command} & pause";
|
||||
}
|
||||
|
||||
info = ShellCommand.SetProcessStartInfo("wt.exe", workingDirectory, arguments, runAsVerbArg);
|
||||
}
|
||||
else if (_settings.Shell == ExecutionShell.WindowsTerminalPowerShell)
|
||||
{
|
||||
string escapedPS = EscapePowerShellArgument(command);
|
||||
string arguments;
|
||||
if (_settings.LeaveShellOpen)
|
||||
{
|
||||
arguments = $"powershell -NoExit -C \"{command}\"";
|
||||
arguments = $"powershell -NoExit -C \"{escapedPS}\"";
|
||||
}
|
||||
else
|
||||
{
|
||||
arguments = $"powershell -C \"{command}\"";
|
||||
arguments = $"powershell -C \"{escapedPS}\"";
|
||||
}
|
||||
|
||||
info = ShellCommand.SetProcessStartInfo("wt.exe", workingDirectory, arguments, runAsVerbArg);
|
||||
}
|
||||
else if (_settings.Shell == ExecutionShell.WindowsTerminalPowerShellSeven)
|
||||
{
|
||||
string escapedPS = EscapePowerShellArgument(command);
|
||||
string arguments;
|
||||
if (_settings.LeaveShellOpen)
|
||||
{
|
||||
arguments = $"pwsh.exe -NoExit -C \"{command}\"";
|
||||
arguments = $"pwsh.exe -NoExit -C \"{escapedPS}\"";
|
||||
}
|
||||
else
|
||||
{
|
||||
arguments = $"pwsh.exe -C \"{command}\"";
|
||||
arguments = $"pwsh.exe -C \"{escapedPS}\"";
|
||||
}
|
||||
|
||||
info = ShellCommand.SetProcessStartInfo("wt.exe", workingDirectory, arguments, runAsVerbArg);
|
||||
@@ -301,7 +324,7 @@ namespace Microsoft.Plugin.Shell
|
||||
if (_settings.LeaveShellOpen)
|
||||
{
|
||||
// Wrap the command in a cmd.exe process
|
||||
info = ShellCommand.SetProcessStartInfo("cmd.exe", workingDirectory, $"/k \"{filename} {arguments}\"", runAsVerbArg);
|
||||
info = ShellCommand.SetProcessStartInfo("cmd.exe", workingDirectory, $"/k {filename} {arguments}", runAsVerbArg);
|
||||
}
|
||||
else
|
||||
{
|
||||
@@ -310,10 +333,11 @@ namespace Microsoft.Plugin.Shell
|
||||
}
|
||||
else
|
||||
{
|
||||
string escapedCmd = EscapeCmdArgument(command);
|
||||
if (_settings.LeaveShellOpen)
|
||||
{
|
||||
// Wrap the command in a cmd.exe process
|
||||
info = ShellCommand.SetProcessStartInfo("cmd.exe", workingDirectory, $"/k \"{command}\"", runAsVerbArg);
|
||||
info = ShellCommand.SetProcessStartInfo("cmd.exe", workingDirectory, $"/k {escapedCmd}", runAsVerbArg);
|
||||
}
|
||||
else
|
||||
{
|
||||
@@ -323,10 +347,11 @@ namespace Microsoft.Plugin.Shell
|
||||
}
|
||||
else
|
||||
{
|
||||
string escapedCmd = EscapeCmdArgument(command);
|
||||
if (_settings.LeaveShellOpen)
|
||||
{
|
||||
// Wrap the command in a cmd.exe process
|
||||
info = ShellCommand.SetProcessStartInfo("cmd.exe", workingDirectory, $"/k \"{command}\"", runAsVerbArg);
|
||||
info = ShellCommand.SetProcessStartInfo("cmd.exe", workingDirectory, $"/k {escapedCmd}", runAsVerbArg);
|
||||
}
|
||||
else
|
||||
{
|
||||
@@ -452,7 +477,7 @@ namespace Microsoft.Plugin.Shell
|
||||
{
|
||||
PluginName = Assembly.GetExecutingAssembly().GetName().Name,
|
||||
Title = Properties.Resources.wox_plugin_cmd_run_as_administrator,
|
||||
Glyph = "\xE7EF",
|
||||
Glyph = "\uE7EF",
|
||||
FontFamily = "Segoe Fluent Icons,Segoe MDL2 Assets",
|
||||
AcceleratorKey = Key.Enter,
|
||||
AcceleratorModifiers = ModifierKeys.Control | ModifierKeys.Shift,
|
||||
@@ -466,7 +491,7 @@ namespace Microsoft.Plugin.Shell
|
||||
{
|
||||
PluginName = Assembly.GetExecutingAssembly().GetName().Name,
|
||||
Title = Properties.Resources.wox_plugin_cmd_run_as_user,
|
||||
Glyph = "\xE7EE",
|
||||
Glyph = "\uE7EE",
|
||||
FontFamily = "Segoe Fluent Icons,Segoe MDL2 Assets",
|
||||
AcceleratorKey = Key.U,
|
||||
AcceleratorModifiers = ModifierKeys.Control | ModifierKeys.Shift,
|
||||
|
||||
@@ -42,9 +42,7 @@
|
||||
<PackageReference Include="System.Configuration.ConfigurationManager">
|
||||
<ExcludeAssets>runtime</ExcludeAssets> <!-- Should already be present on .net sdk runtime, so we avoid the conflicting runtime version from nuget -->
|
||||
</PackageReference>
|
||||
<PackageReference Include="System.Diagnostics.EventLog">
|
||||
<ExcludeAssets>runtime</ExcludeAssets> <!-- Should already be present on .net sdk runtime, so we avoid the conflicting runtime version from nuget -->
|
||||
</PackageReference>
|
||||
<!-- System.Diagnostics.EventLog is now provided to all C# projects via Directory.Build.props -->
|
||||
<PackageReference Include="System.Diagnostics.PerformanceCounter">
|
||||
<ExcludeAssets>runtime</ExcludeAssets> <!-- Should already be present on .net sdk runtime, so we avoid the conflicting runtime version from nuget -->
|
||||
</PackageReference>
|
||||
|
||||
@@ -20,9 +20,7 @@
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="System.ServiceProcess.ServiceController" />
|
||||
<PackageReference Include="System.Diagnostics.EventLog">
|
||||
<ExcludeAssets>runtime</ExcludeAssets> <!-- Should already be present on .net sdk runtime, so we avoid the conflicting runtime version from nuget -->
|
||||
</PackageReference>
|
||||
<!-- System.Diagnostics.EventLog is now provided to all C# projects via Directory.Build.props -->
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
|
||||
@@ -35,9 +35,7 @@
|
||||
<PackageReference Include="System.Configuration.ConfigurationManager">
|
||||
<ExcludeAssets>runtime</ExcludeAssets> <!-- Should already be present on .net sdk runtime, so we avoid the conflicting runtime version from nuget -->
|
||||
</PackageReference>
|
||||
<PackageReference Include="System.Diagnostics.EventLog">
|
||||
<ExcludeAssets>runtime</ExcludeAssets> <!-- Should already be present on .net sdk runtime, so we avoid the conflicting runtime version from nuget -->
|
||||
</PackageReference>
|
||||
<!-- System.Diagnostics.EventLog is now provided to all C# projects via Directory.Build.props -->
|
||||
<PackageReference Include="System.Diagnostics.PerformanceCounter">
|
||||
<ExcludeAssets>runtime</ExcludeAssets> <!-- Should already be present on .net sdk runtime, so we avoid the conflicting runtime version from nuget -->
|
||||
</PackageReference>
|
||||
|
||||
283
src/modules/peek/Peek.Common.UnitTests/MathHelperTests.cs
Normal file
283
src/modules/peek/Peek.Common.UnitTests/MathHelperTests.cs
Normal file
@@ -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 Microsoft.VisualStudio.TestTools.UnitTesting;
|
||||
using Peek.Common.Helpers;
|
||||
|
||||
namespace Peek.Common.UnitTests
|
||||
{
|
||||
[TestClass]
|
||||
public class MathHelperTests
|
||||
{
|
||||
/// <summary>
|
||||
/// Product code: MathHelper.Modulo(int, int)
|
||||
/// What: Verifies standard modulo for positive numbers (7 mod 3 = 1)
|
||||
/// Why: Baseline correctness — ensures the positive path matches C# % operator
|
||||
/// </summary>
|
||||
[TestMethod]
|
||||
public void Modulo_PositiveNumbers_ShouldReturnStandardModulo()
|
||||
{
|
||||
Assert.AreEqual(1, MathHelper.Modulo(7, 3));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Product code: MathHelper.Modulo(int, int)
|
||||
/// What: Verifies that 0 mod n = 0
|
||||
/// Why: Zero dividend is a common boundary — must not throw or return non-zero
|
||||
/// </summary>
|
||||
[TestMethod]
|
||||
public void Modulo_ZeroDividend_ShouldReturnZero()
|
||||
{
|
||||
Assert.AreEqual(0, MathHelper.Modulo(0, 5));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Product code: MathHelper.Modulo(int, int)
|
||||
/// What: Verifies that exact division yields zero remainder
|
||||
/// Why: Guards against off-by-one in the modulo formula
|
||||
/// </summary>
|
||||
[TestMethod]
|
||||
public void Modulo_ExactDivision_ShouldReturnZero()
|
||||
{
|
||||
Assert.AreEqual(0, MathHelper.Modulo(6, 3));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Product code: MathHelper.Modulo(int, int) — negative dividend path
|
||||
/// What: Verifies that -1 mod 3 = 2 (mathematical modulo, not C# remainder)
|
||||
/// Why: C# % returns -1 for negative dividends; Modulo wraps to positive range
|
||||
/// </summary>
|
||||
[TestMethod]
|
||||
public void Modulo_NegativeDividend_ShouldReturnPositiveResult()
|
||||
{
|
||||
Assert.AreEqual(2, MathHelper.Modulo(-1, 3));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Product code: MathHelper.Modulo(int, int) — negative dividend with larger magnitude
|
||||
/// What: Verifies -7 mod 3 = 2 (wraps correctly for larger negative values)
|
||||
/// Why: Tests the full formula: ((-7 % 3) + 3) % 3 = (-1 + 3) % 3 = 2
|
||||
/// </summary>
|
||||
[TestMethod]
|
||||
public void Modulo_NegativeDividend_LargerMagnitude_ShouldWrapCorrectly()
|
||||
{
|
||||
Assert.AreEqual(2, MathHelper.Modulo(-7, 3));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Product code: MathHelper.Modulo(int, int)
|
||||
/// What: Verifies -6 mod 3 = 0 (exact negative multiple)
|
||||
/// Why: Edge case where negative dividend is an exact multiple of divisor
|
||||
/// </summary>
|
||||
[TestMethod]
|
||||
public void Modulo_NegativeDividend_ExactMultiple_ShouldReturnZero()
|
||||
{
|
||||
Assert.AreEqual(0, MathHelper.Modulo(-6, 3));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Product code: MathHelper.Modulo(int, int)
|
||||
/// What: Verifies n mod 1 = 0 for positive n
|
||||
/// Why: Divisor of 1 always yields 0 — guards against divide-by-zero edge case
|
||||
/// </summary>
|
||||
[TestMethod]
|
||||
public void Modulo_PositiveDividend_DivisorOne_ShouldReturnZero()
|
||||
{
|
||||
Assert.AreEqual(0, MathHelper.Modulo(5, 1));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Product code: MathHelper.Modulo(int, int)
|
||||
/// What: Verifies -n mod 1 = 0 for negative n
|
||||
/// Why: Tests the negative path with divisor=1 (simplest negative case)
|
||||
/// </summary>
|
||||
[TestMethod]
|
||||
public void Modulo_NegativeDividend_DivisorOne_ShouldReturnZero()
|
||||
{
|
||||
Assert.AreEqual(0, MathHelper.Modulo(-5, 1));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Product code: MathHelper.Modulo(int, int)
|
||||
/// What: Verifies modulo works with large numbers (1000001 mod 1000000 = 1)
|
||||
/// Why: Tests that no integer overflow occurs in the formula for large operands
|
||||
/// </summary>
|
||||
[TestMethod]
|
||||
public void Modulo_LargePositiveNumbers_ShouldWork()
|
||||
{
|
||||
Assert.AreEqual(1, MathHelper.Modulo(1000001, 1000000));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Product code: MathHelper.Modulo(int, int)
|
||||
/// What: Verifies that dividend less than divisor returns the dividend itself
|
||||
/// Why: 2 mod 5 = 2 — no division happens, just returns the dividend
|
||||
/// </summary>
|
||||
[TestMethod]
|
||||
public void Modulo_DividendLessThanDivisor_ShouldReturnDividend()
|
||||
{
|
||||
Assert.AreEqual(2, MathHelper.Modulo(2, 5));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Product code: MathHelper.Modulo(int, int) — negative dividend
|
||||
/// What: Verifies -2 mod 3 = 1 (mathematical modulo)
|
||||
/// Why: Tests another negative wrap case: ((-2 % 3) + 3) % 3 = (-2 + 3) % 3 = 1
|
||||
/// </summary>
|
||||
[TestMethod]
|
||||
public void Modulo_NegativeDividend_MinusTwo_ModThree_ShouldReturnOne()
|
||||
{
|
||||
Assert.AreEqual(1, MathHelper.Modulo(-2, 3));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Product code: MathHelper.Modulo(int, int) — zero divisor
|
||||
/// What: Documents that dividing by zero throws DivideByZeroException
|
||||
/// Why: Edge case — callers must handle zero divisor; the method does not guard against it
|
||||
/// </summary>
|
||||
[TestMethod]
|
||||
[ExpectedException(typeof(ArgumentOutOfRangeException))]
|
||||
public void Modulo_ZeroDivisor_ShouldThrow()
|
||||
{
|
||||
MathHelper.Modulo(5, 0);
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
[ExpectedException(typeof(ArgumentOutOfRangeException))]
|
||||
public void Modulo_NegativeDivisor_ShouldThrow()
|
||||
{
|
||||
MathHelper.Modulo(-1, -3);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Product code: MathHelper.NumberOfDigits(int)
|
||||
/// What: Verifies that 0 has 1 digit
|
||||
/// Why: Zero is a special case — Math.Abs(0).ToString() = "0" which has length 1
|
||||
/// </summary>
|
||||
[TestMethod]
|
||||
public void NumberOfDigits_Zero_ShouldReturnOne()
|
||||
{
|
||||
Assert.AreEqual(1, MathHelper.NumberOfDigits(0));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Product code: MathHelper.NumberOfDigits(int)
|
||||
/// What: Verifies single-digit number returns 1
|
||||
/// Why: Baseline case for the simplest positive input
|
||||
/// </summary>
|
||||
[TestMethod]
|
||||
public void NumberOfDigits_SingleDigit_ShouldReturnOne()
|
||||
{
|
||||
Assert.AreEqual(1, MathHelper.NumberOfDigits(5));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Product code: MathHelper.NumberOfDigits(int)
|
||||
/// What: Verifies two-digit number returns 2
|
||||
/// Why: Tests the transition from single to multi-digit
|
||||
/// </summary>
|
||||
[TestMethod]
|
||||
public void NumberOfDigits_TwoDigits_ShouldReturnTwo()
|
||||
{
|
||||
Assert.AreEqual(2, MathHelper.NumberOfDigits(42));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Product code: MathHelper.NumberOfDigits(int)
|
||||
/// What: Verifies three-digit number (100) returns 3
|
||||
/// Why: Tests exact power of 10 boundary (100 vs 99)
|
||||
/// </summary>
|
||||
[TestMethod]
|
||||
public void NumberOfDigits_ThreeDigits_ShouldReturnThree()
|
||||
{
|
||||
Assert.AreEqual(3, MathHelper.NumberOfDigits(100));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Product code: MathHelper.NumberOfDigits(int) — negative number
|
||||
/// What: Verifies that sign is ignored via Math.Abs
|
||||
/// Why: Negative sign is not a digit — only magnitude matters
|
||||
/// </summary>
|
||||
[TestMethod]
|
||||
public void NumberOfDigits_NegativeNumber_ShouldIgnoreSign()
|
||||
{
|
||||
Assert.AreEqual(3, MathHelper.NumberOfDigits(-123));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Product code: MathHelper.NumberOfDigits(int) — int.MaxValue
|
||||
/// What: Verifies int.MaxValue (2147483647) returns 10 digits
|
||||
/// Why: Upper boundary of int range — ensures no overflow in Math.Abs path
|
||||
/// </summary>
|
||||
[TestMethod]
|
||||
public void NumberOfDigits_MaxValue_ShouldReturnTenDigits()
|
||||
{
|
||||
Assert.AreEqual(10, MathHelper.NumberOfDigits(int.MaxValue));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Product code: MathHelper.NumberOfDigits(int) — near int.MinValue
|
||||
/// What: Verifies int.MinValue + 1 (-2147483647) returns 10 digits
|
||||
/// Why: int.MinValue itself overflows Math.Abs — this tests the safe boundary
|
||||
/// </summary>
|
||||
[TestMethod]
|
||||
public void NumberOfDigits_MinValue_ShouldHandleAbsoluteValue()
|
||||
{
|
||||
Assert.AreEqual(10, MathHelper.NumberOfDigits(int.MinValue + 1));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Product code: MathHelper.NumberOfDigits(int) — int.MinValue
|
||||
/// What: Documents that int.MinValue causes OverflowException due to Math.Abs
|
||||
/// Why: Edge case — Math.Abs(-2147483648) has no positive int representation
|
||||
/// </summary>
|
||||
[TestMethod]
|
||||
[ExpectedException(typeof(OverflowException))]
|
||||
public void NumberOfDigits_MinValue_ShouldThrowOverflow()
|
||||
{
|
||||
MathHelper.NumberOfDigits(int.MinValue);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Product code: MathHelper.NumberOfDigits(int) — powers of 10
|
||||
/// What: Verifies digit count at each power-of-10 boundary (1, 10, 100, 1000, 10000)
|
||||
/// Why: Powers of 10 are the exact transition points — off-by-one bugs surface here
|
||||
/// </summary>
|
||||
[TestMethod]
|
||||
public void NumberOfDigits_PowerOfTen_ShouldReturnCorrectCount()
|
||||
{
|
||||
Assert.AreEqual(1, MathHelper.NumberOfDigits(1));
|
||||
Assert.AreEqual(2, MathHelper.NumberOfDigits(10));
|
||||
Assert.AreEqual(3, MathHelper.NumberOfDigits(100));
|
||||
Assert.AreEqual(4, MathHelper.NumberOfDigits(1000));
|
||||
Assert.AreEqual(5, MathHelper.NumberOfDigits(10000));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Product code: MathHelper.NumberOfDigits(int) — 9→10 boundary
|
||||
/// What: Verifies that 9 returns 1 digit and 10 returns 2 digits
|
||||
/// Why: Tests the first single-to-double digit transition
|
||||
/// </summary>
|
||||
[TestMethod]
|
||||
public void NumberOfDigits_BoundaryValues_NineAndTen()
|
||||
{
|
||||
Assert.AreEqual(1, MathHelper.NumberOfDigits(9));
|
||||
Assert.AreEqual(2, MathHelper.NumberOfDigits(10));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Product code: MathHelper.NumberOfDigits(int) — 99→100 boundary
|
||||
/// What: Verifies that 99 returns 2 digits and 100 returns 3 digits
|
||||
/// Why: Tests the double-to-triple digit transition
|
||||
/// </summary>
|
||||
[TestMethod]
|
||||
public void NumberOfDigits_BoundaryValues_NinetyNineAndHundred()
|
||||
{
|
||||
Assert.AreEqual(2, MathHelper.NumberOfDigits(99));
|
||||
Assert.AreEqual(3, MathHelper.NumberOfDigits(100));
|
||||
}
|
||||
}
|
||||
}
|
||||
156
src/modules/peek/Peek.Common.UnitTests/PathHelperTests.cs
Normal file
156
src/modules/peek/Peek.Common.UnitTests/PathHelperTests.cs
Normal file
@@ -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 Microsoft.VisualStudio.TestTools.UnitTesting;
|
||||
using Peek.Common.Helpers;
|
||||
|
||||
namespace Peek.Common.UnitTests
|
||||
{
|
||||
[TestClass]
|
||||
public class PathHelperTests
|
||||
{
|
||||
/// <summary>
|
||||
/// Product code: PathHelper.IsUncPath(string)
|
||||
/// What: Verifies a standard UNC path (\\server\share) is recognized
|
||||
/// Why: Baseline positive case — the most common UNC format
|
||||
/// </summary>
|
||||
[TestMethod]
|
||||
public void IsUncPath_StandardUncPath_ShouldReturnTrue()
|
||||
{
|
||||
Assert.IsTrue(PathHelper.IsUncPath(@"\\server\share"));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Product code: PathHelper.IsUncPath(string)
|
||||
/// What: Verifies a UNC path with nested subfolders and file is recognized
|
||||
/// Why: Real-world UNC paths include subfolders — must not fail on deeper paths
|
||||
/// </summary>
|
||||
[TestMethod]
|
||||
public void IsUncPath_UncPathWithSubfolder_ShouldReturnTrue()
|
||||
{
|
||||
Assert.IsTrue(PathHelper.IsUncPath(@"\\server\share\folder\file.txt"));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Product code: PathHelper.IsUncPath(string)
|
||||
/// What: Verifies a UNC path with dotted server name (FQDN) is recognized
|
||||
/// Why: Enterprise environments use FQDN server names (e.g., server.corp.com)
|
||||
/// </summary>
|
||||
[TestMethod]
|
||||
public void IsUncPath_UncPathWithDottedServer_ShouldReturnTrue()
|
||||
{
|
||||
Assert.IsTrue(PathHelper.IsUncPath(@"\\server.domain.com\share"));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Product code: PathHelper.IsUncPath(string)
|
||||
/// What: Verifies a UNC path with IP address is recognized
|
||||
/// Why: Some network shares use IP addresses instead of hostnames
|
||||
/// </summary>
|
||||
[TestMethod]
|
||||
public void IsUncPath_UncPathWithIPAddress_ShouldReturnTrue()
|
||||
{
|
||||
Assert.IsTrue(PathHelper.IsUncPath(@"\\192.168.1.1\share"));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Product code: PathHelper.IsUncPath(string)
|
||||
/// What: Verifies a local drive path (C:\...) is not classified as UNC
|
||||
/// Why: Drive-letter paths are local — confusing them with UNC would break file access
|
||||
/// </summary>
|
||||
[TestMethod]
|
||||
public void IsUncPath_LocalDrivePath_ShouldReturnFalse()
|
||||
{
|
||||
Assert.IsFalse(PathHelper.IsUncPath(@"C:\Users\test\file.txt"));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Product code: PathHelper.IsUncPath(string)
|
||||
/// What: Verifies a bare drive root (D:\) is not classified as UNC
|
||||
/// Why: Drive roots are local paths — shortest possible local absolute path
|
||||
/// </summary>
|
||||
[TestMethod]
|
||||
public void IsUncPath_LocalRootPath_ShouldReturnFalse()
|
||||
{
|
||||
Assert.IsFalse(PathHelper.IsUncPath(@"D:\"));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Product code: PathHelper.IsUncPath(string)
|
||||
/// What: Verifies a relative path is not classified as UNC
|
||||
/// Why: Relative paths have no server component — Uri.TryCreate fails for them
|
||||
/// </summary>
|
||||
[TestMethod]
|
||||
public void IsUncPath_RelativePath_ShouldReturnFalse()
|
||||
{
|
||||
Assert.IsFalse(PathHelper.IsUncPath(@"folder\file.txt"));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Product code: PathHelper.IsUncPath(string)
|
||||
/// What: Verifies an empty string returns false without throwing
|
||||
/// Why: Empty input is a common edge case — must not crash
|
||||
/// </summary>
|
||||
[TestMethod]
|
||||
public void IsUncPath_EmptyString_ShouldReturnFalse()
|
||||
{
|
||||
Assert.IsFalse(PathHelper.IsUncPath(string.Empty));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Product code: PathHelper.IsUncPath(string)
|
||||
/// What: Verifies an HTTP URL is not classified as UNC
|
||||
/// Why: HTTP URLs are not file paths — Uri.IsUnc must return false for them
|
||||
/// </summary>
|
||||
[TestMethod]
|
||||
public void IsUncPath_HttpUrl_ShouldReturnFalse()
|
||||
{
|
||||
Assert.IsFalse(PathHelper.IsUncPath("http://example.com/path"));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Product code: PathHelper.IsUncPath(string)
|
||||
/// What: Verifies a file:/// URI (local file) is not classified as UNC
|
||||
/// Why: file:///C:/... is a local file URI, not a network UNC path
|
||||
/// </summary>
|
||||
[TestMethod]
|
||||
public void IsUncPath_FileUri_ShouldReturnFalse()
|
||||
{
|
||||
Assert.IsFalse(PathHelper.IsUncPath("file:///C:/Users/test/file.txt"));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Product code: PathHelper.IsUncPath(string)
|
||||
/// What: Verifies a standard UNC path with a file component (\\server\share\file.txt)
|
||||
/// Why: Tests UNC with a trailing file name — distinct from share-only paths
|
||||
/// </summary>
|
||||
[TestMethod]
|
||||
public void IsUncPath_StandardUncWithFile_ShouldReturnTrue()
|
||||
{
|
||||
Assert.IsTrue(PathHelper.IsUncPath(@"\\server\share\file.txt"));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Product code: PathHelper.IsUncPath(string)
|
||||
/// What: Verifies a single-backslash prefix is not classified as UNC
|
||||
/// Why: UNC requires exactly two leading backslashes — one backslash is not UNC
|
||||
/// </summary>
|
||||
[TestMethod]
|
||||
public void IsUncPath_SingleBackslash_ShouldReturnFalse()
|
||||
{
|
||||
Assert.IsFalse(PathHelper.IsUncPath(@"\server\share"));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Product code: PathHelper.IsUncPath(string)
|
||||
/// What: Verifies null input returns false without throwing
|
||||
/// Why: Null is a common edge case — Uri.TryCreate handles null gracefully
|
||||
/// </summary>
|
||||
[TestMethod]
|
||||
public void IsUncPath_NullInput_ShouldReturnFalse()
|
||||
{
|
||||
Assert.IsFalse(PathHelper.IsUncPath(null));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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" />
|
||||
|
||||
<PropertyGroup>
|
||||
<IsPackable>false</IsPackable>
|
||||
<AppendTargetFrameworkToOutputPath>false</AppendTargetFrameworkToOutputPath>
|
||||
<AppendRuntimeIdentifierToOutputPath>false</AppendRuntimeIdentifierToOutputPath>
|
||||
<OutputPath>$(SolutionDir)$(Platform)\$(Configuration)\tests\Peek.Common.Tests\</OutputPath>
|
||||
<RootNamespace>Peek.Common.UnitTests</RootNamespace>
|
||||
<AssemblyName>PowerToys.Peek.Common.UnitTests</AssemblyName>
|
||||
<OutputType>Exe</OutputType>
|
||||
<!-- Pull in the WindowsDesktop shared framework so that runtime DLLs (System.CodeDom,
|
||||
Microsoft.VisualBasic, WindowsBase, etc.) resolve to the same version as all other
|
||||
projects in the solution. Without this, transitive NuGet packages provide older
|
||||
versions that fail the verifyDepsJsonLibraryVersions CI check. -->
|
||||
<UseWindowsForms>true</UseWindowsForms>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="MSTest" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\Peek.Common\Peek.Common.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -11,6 +11,11 @@ namespace Peek.Common.Helpers
|
||||
{
|
||||
public static int Modulo(int a, int b)
|
||||
{
|
||||
if (b <= 0)
|
||||
{
|
||||
throw new ArgumentOutOfRangeException(nameof(b), b, "Divisor must be positive.");
|
||||
}
|
||||
|
||||
return a < 0 ? ((a % b) + b) % b : a % b;
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
using Microsoft.VisualStudio.TestTools.UnitTesting;
|
||||
using PowerAccent.Core.Services;
|
||||
using PowerAccent.Core.Tools;
|
||||
|
||||
namespace PowerAccent.Core.UnitTests
|
||||
{
|
||||
[TestClass]
|
||||
public class CalculationTests
|
||||
{
|
||||
// Screen representing a standard 1920x1080 monitor at position (0,0)
|
||||
private static readonly Rect StandardScreen = new Rect(0, 0, 1920, 1080);
|
||||
|
||||
// A typical toolbar window size (300x50 in WPF DIPs)
|
||||
private static readonly Size ToolbarWindow = new Size(300, 50);
|
||||
|
||||
/// <summary>
|
||||
/// Product code: Calculation.GetRawCoordinatesFromCaret
|
||||
/// What: Verifies toolbar is centered horizontally above the caret at screen center
|
||||
/// Why: The most common placement scenario — regression here affects every user
|
||||
/// </summary>
|
||||
[TestMethod]
|
||||
public void GetRawCoordinatesFromCaret_CenterOfScreen_ShouldCenterToolbar()
|
||||
{
|
||||
var caret = new Point(960, 540);
|
||||
var result = Calculation.GetRawCoordinatesFromCaret(caret, StandardScreen, ToolbarWindow);
|
||||
|
||||
// X should be caret.X - window.Width/2 = 960 - 150 = 810
|
||||
Assert.AreEqual(810.0, result.X);
|
||||
|
||||
// Y should be caret.Y - window.Height - 20 = 540 - 50 - 20 = 470
|
||||
Assert.AreEqual(470.0, result.Y);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Product code: Calculation.GetRawCoordinatesFromCaret — left edge clamping
|
||||
/// What: Verifies toolbar is clamped to screen left when caret is near left edge
|
||||
/// Why: Without clamping, toolbar would extend off-screen and be unusable
|
||||
/// </summary>
|
||||
[TestMethod]
|
||||
public void GetRawCoordinatesFromCaret_NearLeftEdge_ShouldClampToScreenLeft()
|
||||
{
|
||||
var caret = new Point(50, 540);
|
||||
var result = Calculation.GetRawCoordinatesFromCaret(caret, StandardScreen, ToolbarWindow);
|
||||
|
||||
// left = 50 - 150 = -100, which is < screen.X (0), so X should be clamped to 0
|
||||
Assert.AreEqual(0.0, result.X);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Product code: Calculation.GetRawCoordinatesFromCaret — right edge clamping
|
||||
/// What: Verifies toolbar is clamped to screen right when caret is near right edge
|
||||
/// Why: Toolbar extending past screen right would be clipped by the display
|
||||
/// </summary>
|
||||
[TestMethod]
|
||||
public void GetRawCoordinatesFromCaret_NearRightEdge_ShouldClampToScreenRight()
|
||||
{
|
||||
var caret = new Point(1900, 540);
|
||||
var result = Calculation.GetRawCoordinatesFromCaret(caret, StandardScreen, ToolbarWindow);
|
||||
|
||||
// left = 1900 - 150 = 1750, left + window.Width = 1750 + 300 = 2050 > 1920
|
||||
// So X should be clamped to screen.X + screen.Width - window.Width = 1920 - 300 = 1620
|
||||
Assert.AreEqual(1620.0, result.X);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Product code: Calculation.GetRawCoordinatesFromCaret — top edge flip
|
||||
/// What: Verifies toolbar is placed below the caret when there is no room above
|
||||
/// Why: When caret is near top of screen, toolbar must flip below to remain visible
|
||||
/// </summary>
|
||||
[TestMethod]
|
||||
public void GetRawCoordinatesFromCaret_NearTopEdge_ShouldPlaceBelow()
|
||||
{
|
||||
var caret = new Point(960, 30);
|
||||
var result = Calculation.GetRawCoordinatesFromCaret(caret, StandardScreen, ToolbarWindow);
|
||||
|
||||
// top = 30 - 50 - 20 = -40, which is < screen.Y (0)
|
||||
// So Y should be caret.Y + 20 = 50 (placed below caret)
|
||||
Assert.AreEqual(50.0, result.Y);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Product code: Calculation.GetRawCoordinatesFromCaret — normal above placement
|
||||
/// What: Verifies toolbar is placed above the caret when there is sufficient space
|
||||
/// Why: Default behavior — toolbar should appear above to avoid occluding text below
|
||||
/// </summary>
|
||||
[TestMethod]
|
||||
public void GetRawCoordinatesFromCaret_SufficientSpaceAbove_ShouldPlaceAbove()
|
||||
{
|
||||
var caret = new Point(960, 200);
|
||||
var result = Calculation.GetRawCoordinatesFromCaret(caret, StandardScreen, ToolbarWindow);
|
||||
|
||||
// top = 200 - 50 - 20 = 130, which is >= screen.Y (0)
|
||||
Assert.AreEqual(130.0, result.Y);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Product code: Calculation.GetRawCoordinatesFromCaret — multi-monitor offset
|
||||
/// What: Verifies toolbar respects non-zero screen origin (second monitor at X=1920)
|
||||
/// Why: Multi-monitor setups have offset screen coordinates — clamping must use screen.X
|
||||
/// </summary>
|
||||
[TestMethod]
|
||||
public void GetRawCoordinatesFromCaret_OffsetScreen_ShouldRespectScreenOrigin()
|
||||
{
|
||||
var screen = new Rect(1920, 0, 1920, 1080);
|
||||
var caret = new Point(1950, 540);
|
||||
var result = Calculation.GetRawCoordinatesFromCaret(caret, screen, ToolbarWindow);
|
||||
|
||||
// left = 1950 - 150 = 1800, which is < screen.X (1920)
|
||||
// So X should be clamped to 1920
|
||||
Assert.AreEqual(1920.0, result.X);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Product code: Calculation.GetRawCoordinatesFromCaret — exact boundary case
|
||||
/// What: Verifies no clamping occurs when toolbar fits exactly at the boundary
|
||||
/// Why: Off-by-one errors in boundary checks are common — this catches ≤ vs < bugs
|
||||
/// </summary>
|
||||
[TestMethod]
|
||||
public void GetRawCoordinatesFromCaret_ExactBoundary_ShouldNotClamp()
|
||||
{
|
||||
var caret = new Point(150, 100);
|
||||
var result = Calculation.GetRawCoordinatesFromCaret(caret, StandardScreen, ToolbarWindow);
|
||||
|
||||
// left = 150 - 150 = 0, which == screen.X, so no left clamp needed
|
||||
Assert.AreEqual(0.0, result.X);
|
||||
|
||||
// top = 100 - 50 - 20 = 30, which >= 0
|
||||
Assert.AreEqual(30.0, result.Y);
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
[ExpectedException(typeof(NotImplementedException))]
|
||||
public void GetRawCoordinatesFromPosition_InvalidPosition_ShouldThrow()
|
||||
{
|
||||
Calculation.GetRawCoordinatesFromPosition((Position)999, StandardScreen, ToolbarWindow, 1.0);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Product code: Calculation.GetRawCoordinatesFromPosition with Position.Top
|
||||
/// What: Verifies toolbar is horizontally centered at the top of the screen with offset
|
||||
/// Why: Exact value test — catches formula regressions in the Top positioning branch
|
||||
/// </summary>
|
||||
[TestMethod]
|
||||
public void GetRawCoordinatesFromPosition_TopCenter_ShouldBeCenteredAtTop()
|
||||
{
|
||||
var result = Calculation.GetRawCoordinatesFromPosition(Position.Top, StandardScreen, ToolbarWindow, 1.0);
|
||||
|
||||
// X: screen.X + screen.Width/2 - window.Width*dpi/2 = 0 + 960 - 150 = 810
|
||||
Assert.AreEqual(810.0, result.X);
|
||||
|
||||
// Y: screen.Y + offset = 0 + 24 = 24
|
||||
Assert.AreEqual(24.0, result.Y);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Product code: Calculation.GetRawCoordinatesFromPosition with Position.Bottom
|
||||
/// What: Verifies toolbar is horizontally centered at the bottom of the screen
|
||||
/// Why: Bottom placement subtracts window height + offset — easy to get wrong
|
||||
/// </summary>
|
||||
[TestMethod]
|
||||
public void GetRawCoordinatesFromPosition_BottomCenter_ShouldBeCenteredAtBottom()
|
||||
{
|
||||
var result = Calculation.GetRawCoordinatesFromPosition(Position.Bottom, StandardScreen, ToolbarWindow, 1.0);
|
||||
|
||||
// X: centered = 810
|
||||
Assert.AreEqual(810.0, result.X);
|
||||
|
||||
// Y: screen.Y + screen.Height - (window.Height*dpi + offset) = 0 + 1080 - (50 + 24) = 1006
|
||||
Assert.AreEqual(1006.0, result.Y);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Product code: Calculation.GetRawCoordinatesFromPosition with Position.Center
|
||||
/// What: Verifies toolbar is centered both horizontally and vertically
|
||||
/// Why: Center is the simplest case — validates the baseline formula for both axes
|
||||
/// </summary>
|
||||
[TestMethod]
|
||||
public void GetRawCoordinatesFromPosition_Center_ShouldBeTrulyCentered()
|
||||
{
|
||||
var result = Calculation.GetRawCoordinatesFromPosition(Position.Center, StandardScreen, ToolbarWindow, 1.0);
|
||||
|
||||
// X: screen.X + screen.Width/2 - window.Width*dpi/2 = 0 + 960 - 150 = 810
|
||||
Assert.AreEqual(810.0, result.X);
|
||||
|
||||
// Y: screen.Y + screen.Height/2 - window.Height*dpi/2 = 0 + 540 - 25 = 515
|
||||
Assert.AreEqual(515.0, result.Y);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Product code: Calculation.GetRawCoordinatesFromPosition with Position.TopLeft
|
||||
/// What: Verifies toolbar is placed at top-left corner with offset margin
|
||||
/// Why: Corner placements use raw offset — validates the simplest X/Y path
|
||||
/// </summary>
|
||||
[TestMethod]
|
||||
public void GetRawCoordinatesFromPosition_TopLeft_ShouldBeAtTopLeftCorner()
|
||||
{
|
||||
var result = Calculation.GetRawCoordinatesFromPosition(Position.TopLeft, StandardScreen, ToolbarWindow, 1.0);
|
||||
|
||||
// X: screen.X + offset = 0 + 24 = 24
|
||||
Assert.AreEqual(24.0, result.X);
|
||||
|
||||
// Y: screen.Y + offset = 0 + 24 = 24
|
||||
Assert.AreEqual(24.0, result.Y);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Product code: Calculation.GetRawCoordinatesFromPosition with Position.TopRight
|
||||
/// What: Verifies exact coordinates for top-right corner placement
|
||||
/// Why: TopRight combines right-aligned X with top Y — validates both formula branches
|
||||
/// </summary>
|
||||
[TestMethod]
|
||||
public void GetRawCoordinatesFromPosition_TopRight_ShouldBeAtTopRightCorner()
|
||||
{
|
||||
var result = Calculation.GetRawCoordinatesFromPosition(Position.TopRight, StandardScreen, ToolbarWindow, 1.0);
|
||||
|
||||
// X: screen.X + screen.Width - (window.Width*dpi + offset) = 0 + 1920 - (300 + 24) = 1596
|
||||
Assert.AreEqual(1596.0, result.X);
|
||||
|
||||
// Y: screen.Y + offset = 0 + 24 = 24
|
||||
Assert.AreEqual(24.0, result.Y);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Product code: Calculation.GetRawCoordinatesFromPosition with Position.BottomLeft
|
||||
/// What: Verifies exact coordinates for bottom-left corner placement
|
||||
/// Why: BottomLeft combines left-aligned X with bottom Y — validates both formula branches
|
||||
/// </summary>
|
||||
[TestMethod]
|
||||
public void GetRawCoordinatesFromPosition_BottomLeft_ShouldBeAtBottomLeftCorner()
|
||||
{
|
||||
var result = Calculation.GetRawCoordinatesFromPosition(Position.BottomLeft, StandardScreen, ToolbarWindow, 1.0);
|
||||
|
||||
// X: screen.X + offset = 0 + 24 = 24
|
||||
Assert.AreEqual(24.0, result.X);
|
||||
|
||||
// Y: screen.Y + screen.Height - (window.Height*dpi + offset) = 0 + 1080 - (50 + 24) = 1006
|
||||
Assert.AreEqual(1006.0, result.Y);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Product code: Calculation.GetRawCoordinatesFromPosition with Position.BottomRight
|
||||
/// What: Verifies toolbar is placed at bottom-right corner with offset margin
|
||||
/// Why: BottomRight uses the most complex formula (subtracts from both edges)
|
||||
/// </summary>
|
||||
[TestMethod]
|
||||
public void GetRawCoordinatesFromPosition_BottomRight_ShouldBeAtBottomRightCorner()
|
||||
{
|
||||
var result = Calculation.GetRawCoordinatesFromPosition(Position.BottomRight, StandardScreen, ToolbarWindow, 1.0);
|
||||
|
||||
// X: screen.X + screen.Width - (window.Width*dpi + offset) = 0 + 1920 - (300 + 24) = 1596
|
||||
Assert.AreEqual(1596.0, result.X);
|
||||
|
||||
// Y: screen.Y + screen.Height - (window.Height*dpi + offset) = 0 + 1080 - (50 + 24) = 1006
|
||||
Assert.AreEqual(1006.0, result.Y);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Product code: Calculation.GetRawCoordinatesFromPosition with Position.Left
|
||||
/// What: Verifies toolbar is placed at left edge, vertically centered
|
||||
/// Why: Left position uses offset for X and centering for Y — tests the mix
|
||||
/// </summary>
|
||||
[TestMethod]
|
||||
public void GetRawCoordinatesFromPosition_Left_ShouldBeAtLeftMiddle()
|
||||
{
|
||||
var result = Calculation.GetRawCoordinatesFromPosition(Position.Left, StandardScreen, ToolbarWindow, 1.0);
|
||||
|
||||
// X: screen.X + offset = 0 + 24 = 24
|
||||
Assert.AreEqual(24.0, result.X);
|
||||
|
||||
// Y: centered vertically = 0 + 540 - 25 = 515
|
||||
Assert.AreEqual(515.0, result.Y);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Product code: Calculation.GetRawCoordinatesFromPosition with Position.Right
|
||||
/// What: Verifies toolbar is placed at right edge, vertically centered
|
||||
/// Why: Right position subtracts window width from screen edge — tests the right-align formula
|
||||
/// </summary>
|
||||
[TestMethod]
|
||||
public void GetRawCoordinatesFromPosition_Right_ShouldBeAtRightMiddle()
|
||||
{
|
||||
var result = Calculation.GetRawCoordinatesFromPosition(Position.Right, StandardScreen, ToolbarWindow, 1.0);
|
||||
|
||||
// X: screen.X + screen.Width - (window.Width*dpi + offset) = 1920 - 324 = 1596
|
||||
Assert.AreEqual(1596.0, result.X);
|
||||
|
||||
// Y: centered vertically = 515
|
||||
Assert.AreEqual(515.0, result.Y);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Product code: Calculation.GetRawCoordinatesFromPosition with DPI=1.5
|
||||
/// What: Verifies that DPI scaling is applied to window size in centering formula
|
||||
/// Why: High-DPI monitors are common — incorrect scaling makes toolbar appear off-center
|
||||
/// </summary>
|
||||
[TestMethod]
|
||||
public void GetRawCoordinatesFromPosition_WithHighDpi_ShouldScaleWindow()
|
||||
{
|
||||
double dpi = 1.5;
|
||||
var result = Calculation.GetRawCoordinatesFromPosition(Position.Center, StandardScreen, ToolbarWindow, dpi);
|
||||
|
||||
// X: screen.X + screen.Width/2 - window.Width*dpi/2 = 0 + 960 - (300*1.5)/2 = 960 - 225 = 735
|
||||
Assert.AreEqual(735.0, result.X);
|
||||
|
||||
// Y: screen.Y + screen.Height/2 - window.Height*dpi/2 = 0 + 540 - (50*1.5)/2 = 540 - 37.5 = 502.5
|
||||
Assert.AreEqual(502.5, result.Y);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Product code: Calculation.GetRawCoordinatesFromPosition with DPI=2.0, TopLeft
|
||||
/// What: Verifies that offset is NOT DPI-scaled for corner positions
|
||||
/// Why: The 24px offset is a fixed margin — DPI scaling only affects window size
|
||||
/// </summary>
|
||||
[TestMethod]
|
||||
public void GetRawCoordinatesFromPosition_WithDpi2_TopLeft_ShouldUseOffset()
|
||||
{
|
||||
double dpi = 2.0;
|
||||
var result = Calculation.GetRawCoordinatesFromPosition(Position.TopLeft, StandardScreen, ToolbarWindow, dpi);
|
||||
|
||||
// X: screen.X + offset = 0 + 24 = 24 (offset is not DPI-scaled)
|
||||
Assert.AreEqual(24.0, result.X);
|
||||
|
||||
// Y: screen.Y + offset = 0 + 24 = 24
|
||||
Assert.AreEqual(24.0, result.Y);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Product code: Calculation.GetRawCoordinatesFromPosition with DPI=2.0, BottomRight
|
||||
/// What: Verifies DPI scaling of window size in bottom-right corner formula
|
||||
/// Why: At 2x DPI, the window occupies 600×100 pixels — offset from edge must account for this
|
||||
/// </summary>
|
||||
[TestMethod]
|
||||
public void GetRawCoordinatesFromPosition_WithDpi2_BottomRight_ShouldScaleWindowSize()
|
||||
{
|
||||
double dpi = 2.0;
|
||||
var result = Calculation.GetRawCoordinatesFromPosition(Position.BottomRight, StandardScreen, ToolbarWindow, dpi);
|
||||
|
||||
// X: screen.X + screen.Width - (window.Width*dpi + offset) = 0 + 1920 - (300*2 + 24) = 1920 - 624 = 1296
|
||||
Assert.AreEqual(1296.0, result.X);
|
||||
|
||||
// Y: screen.Y + screen.Height - (window.Height*dpi + offset) = 0 + 1080 - (50*2 + 24) = 1080 - 124 = 956
|
||||
Assert.AreEqual(956.0, result.Y);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Product code: Calculation.GetRawCoordinatesFromPosition with offset screen origin
|
||||
/// What: Verifies screen.X is added as the base for TopLeft on a secondary monitor
|
||||
/// Why: Without adding screen origin, toolbar appears on the wrong monitor
|
||||
/// </summary>
|
||||
[TestMethod]
|
||||
public void GetRawCoordinatesFromPosition_OffsetScreen_ShouldRespectScreenOrigin()
|
||||
{
|
||||
var screen = new Rect(1920, 0, 1920, 1080);
|
||||
var result = Calculation.GetRawCoordinatesFromPosition(Position.TopLeft, screen, ToolbarWindow, 1.0);
|
||||
|
||||
// X: screen.X + offset = 1920 + 24 = 1944
|
||||
Assert.AreEqual(1944.0, result.X);
|
||||
|
||||
// Y: screen.Y + offset = 0 + 24 = 24
|
||||
Assert.AreEqual(24.0, result.Y);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,265 @@
|
||||
// Copyright (c) Microsoft Corporation
|
||||
// The Microsoft Corporation 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;
|
||||
using PowerToys.PowerAccentKeyboardService;
|
||||
|
||||
namespace PowerAccent.Core.UnitTests
|
||||
{
|
||||
[TestClass]
|
||||
public class LanguagesTests
|
||||
{
|
||||
/// <summary>
|
||||
/// Product code: Languages.GetDefaultLetterKey
|
||||
/// What: Verifies that passing an empty language array returns an empty result
|
||||
/// Why: Guards against NullReferenceException or incorrect fallback when no languages are configured
|
||||
/// </summary>
|
||||
[TestMethod]
|
||||
public void GetDefaultLetterKey_EmptyLanguageArray_ShouldReturnEmpty()
|
||||
{
|
||||
var result = Languages.GetDefaultLetterKey(LetterKey.VK_A, Array.Empty<Language>());
|
||||
Assert.AreEqual(0, result.Length);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Product code: Languages.GetDefaultLetterKey with Language.FR
|
||||
/// What: Verifies that French returns at least one accented character for the letter A
|
||||
/// Why: Regression guard — ensures the French language mapping is wired up and non-empty
|
||||
/// </summary>
|
||||
[TestMethod]
|
||||
public void GetDefaultLetterKey_SingleLanguage_ShouldReturnNonEmpty()
|
||||
{
|
||||
var result = Languages.GetDefaultLetterKey(LetterKey.VK_A, new[] { Language.FR });
|
||||
Assert.IsTrue(result.Length > 0, "French should have accented characters for A");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Product code: Languages.GetDefaultLetterKey with multiple languages
|
||||
/// What: Verifies that combining French and Spanish merges characters and removes duplicates
|
||||
/// Why: Guards against duplicate entries when languages share accent characters (e.g., à)
|
||||
/// </summary>
|
||||
[TestMethod]
|
||||
public void GetDefaultLetterKey_MultipleLanguages_ShouldMergeAndDeduplicate()
|
||||
{
|
||||
var frResult = Languages.GetDefaultLetterKey(LetterKey.VK_A, new[] { Language.FR });
|
||||
var spResult = Languages.GetDefaultLetterKey(LetterKey.VK_A, new[] { Language.SP });
|
||||
var combinedResult = Languages.GetDefaultLetterKey(LetterKey.VK_A, new[] { Language.FR, Language.SP });
|
||||
|
||||
Assert.AreEqual(combinedResult.Length, combinedResult.Distinct().Count(), "Combined result should contain no duplicates");
|
||||
|
||||
foreach (var ch in frResult)
|
||||
{
|
||||
CollectionAssert.Contains(combinedResult, ch, $"Combined result should contain French character '{ch}'");
|
||||
}
|
||||
|
||||
foreach (var ch in spResult)
|
||||
{
|
||||
CollectionAssert.Contains(combinedResult, ch, $"Combined result should contain Spanish character '{ch}'");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Product code: Languages.GetDefaultLetterKey with Language.FR for VK_A
|
||||
/// What: Verifies that French returns specific expected accent characters (à, â, æ)
|
||||
/// Why: Pinpoints exact character expectations — catches silent data regressions in language tables
|
||||
/// </summary>
|
||||
[TestMethod]
|
||||
public void GetDefaultLetterKey_French_A_ShouldContainExpectedAccents()
|
||||
{
|
||||
var result = Languages.GetDefaultLetterKey(LetterKey.VK_A, new[] { Language.FR });
|
||||
|
||||
CollectionAssert.Contains(result, "à", "French should contain à");
|
||||
CollectionAssert.Contains(result, "â", "French should contain â");
|
||||
CollectionAssert.Contains(result, "æ", "French should contain æ");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Product code: Languages.GetDefaultLetterKey with Language.DE for VK_O
|
||||
/// What: Verifies German returns the ö umlaut for the O key
|
||||
/// Why: Core German character — regression would break German users' workflow
|
||||
/// </summary>
|
||||
[TestMethod]
|
||||
public void GetDefaultLetterKey_German_O_ShouldContainUmlaut()
|
||||
{
|
||||
var result = Languages.GetDefaultLetterKey(LetterKey.VK_O, new[] { Language.DE });
|
||||
CollectionAssert.Contains(result, "ö", "German should contain ö for O");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Product code: Languages.GetDefaultLetterKey with Language.DE for VK_U
|
||||
/// What: Verifies German returns the ü umlaut for the U key
|
||||
/// Why: Core German character — regression would break German users' workflow
|
||||
/// </summary>
|
||||
[TestMethod]
|
||||
public void GetDefaultLetterKey_German_U_ShouldContainUmlaut()
|
||||
{
|
||||
var result = Languages.GetDefaultLetterKey(LetterKey.VK_U, new[] { Language.DE });
|
||||
CollectionAssert.Contains(result, "ü", "German should contain ü for U");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Product code: Languages.GetDefaultLetterKey with Language.DE for VK_S
|
||||
/// What: Verifies German returns the ß (Eszett) for the S key
|
||||
/// Why: ß is unique to German — its absence would be a critical language regression
|
||||
/// </summary>
|
||||
[TestMethod]
|
||||
public void GetDefaultLetterKey_German_S_ShouldContainEszett()
|
||||
{
|
||||
var result = Languages.GetDefaultLetterKey(LetterKey.VK_S, new[] { Language.DE });
|
||||
CollectionAssert.Contains(result, "ß", "German should contain ß for S");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Product code: Languages.GetDefaultLetterKey with Language.SP for VK_N
|
||||
/// What: Verifies Spanish returns the ñ character for the N key
|
||||
/// Why: ñ is the most recognizable Spanish-specific character
|
||||
/// </summary>
|
||||
[TestMethod]
|
||||
public void GetDefaultLetterKey_Spanish_N_ShouldContainEne()
|
||||
{
|
||||
var result = Languages.GetDefaultLetterKey(LetterKey.VK_N, new[] { Language.SP });
|
||||
CollectionAssert.Contains(result, "ñ", "Spanish should contain ñ for N");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Product code: Languages.GetDefaultLetterKey with Language.CZ for VK_C
|
||||
/// What: Verifies Czech returns the č (c-caron) for the C key
|
||||
/// Why: č is essential for Czech — its absence would break Czech language support
|
||||
/// </summary>
|
||||
[TestMethod]
|
||||
public void GetDefaultLetterKey_Czech_C_ShouldContainCaron()
|
||||
{
|
||||
var result = Languages.GetDefaultLetterKey(LetterKey.VK_C, new[] { Language.CZ });
|
||||
CollectionAssert.Contains(result, "č", "Czech should contain č for C");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Product code: Languages.GetDefaultLetterKey with Language.PL for VK_L
|
||||
/// What: Verifies Polish returns the ł (L with stroke) for the L key
|
||||
/// Why: ł is fundamental to Polish — its absence would break Polish language support
|
||||
/// </summary>
|
||||
[TestMethod]
|
||||
public void GetDefaultLetterKey_Polish_L_ShouldContainStroke()
|
||||
{
|
||||
var result = Languages.GetDefaultLetterKey(LetterKey.VK_L, new[] { Language.PL });
|
||||
CollectionAssert.Contains(result, "ł", "Polish should contain ł for L");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Product code: Languages.GetDefaultLetterKey with Language.VI for VK_A
|
||||
/// What: Verifies Vietnamese returns a rich set of A variants (≥10 accented forms)
|
||||
/// Why: Vietnamese has extensive diacritics — guards against truncation of the character table
|
||||
/// </summary>
|
||||
[TestMethod]
|
||||
public void GetDefaultLetterKey_Vietnamese_A_ShouldContainMultipleAccents()
|
||||
{
|
||||
var result = Languages.GetDefaultLetterKey(LetterKey.VK_A, new[] { Language.VI });
|
||||
|
||||
Assert.IsTrue(result.Length >= 10, "Vietnamese should have many accented A characters");
|
||||
CollectionAssert.Contains(result, "à", "Vietnamese should contain à");
|
||||
CollectionAssert.Contains(result, "ă", "Vietnamese should contain ă");
|
||||
CollectionAssert.Contains(result, "â", "Vietnamese should contain â");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Product code: Languages.GetDefaultLetterKey with Language.SPECIAL for VK_0
|
||||
/// What: Verifies that digit 0 returns superscript (⁰) and subscript (₀) variants
|
||||
/// Why: Special characters for digits are used in mathematical/scientific input
|
||||
/// </summary>
|
||||
[TestMethod]
|
||||
public void GetDefaultLetterKey_Special_Digits_ShouldReturnSubscriptsSuperscripts()
|
||||
{
|
||||
var result = Languages.GetDefaultLetterKey(LetterKey.VK_0, new[] { Language.SPECIAL });
|
||||
Assert.IsTrue(result.Length > 0, "Special characters for 0 should exist");
|
||||
CollectionAssert.Contains(result, "⁰", "Special 0 should contain superscript 0");
|
||||
CollectionAssert.Contains(result, "₀", "Special 0 should contain subscript 0");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Product code: Languages.GetDefaultLetterKey with Language.SPECIAL for VK_1
|
||||
/// What: Verifies that digit 1 returns fraction (½) and superscript (¹) variants
|
||||
/// Why: Fraction characters are commonly needed for recipes, measurements, etc.
|
||||
/// </summary>
|
||||
[TestMethod]
|
||||
public void GetDefaultLetterKey_Special_1_ShouldContainFractions()
|
||||
{
|
||||
var result = Languages.GetDefaultLetterKey(LetterKey.VK_1, new[] { Language.SPECIAL });
|
||||
CollectionAssert.Contains(result, "½", "Special 1 should contain ½");
|
||||
CollectionAssert.Contains(result, "¹", "Special 1 should contain ¹");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Product code: Languages.GetDefaultLetterKey with Language.CUR
|
||||
/// What: Verifies that Currency language returns well-known currency symbols (€, $, £)
|
||||
/// Why: Currency symbols are the primary use case — tests exact symbol presence, not just non-null
|
||||
/// </summary>
|
||||
[TestMethod]
|
||||
public void GetDefaultLetterKey_Currency_ShouldReturnCurrencySymbols()
|
||||
{
|
||||
var euroResult = Languages.GetDefaultLetterKey(LetterKey.VK_E, new[] { Language.CUR });
|
||||
CollectionAssert.Contains(euroResult, "€", "Currency VK_E should contain Euro symbol");
|
||||
|
||||
var dollarResult = Languages.GetDefaultLetterKey(LetterKey.VK_S, new[] { Language.CUR });
|
||||
CollectionAssert.Contains(dollarResult, "$", "Currency VK_S should contain Dollar symbol");
|
||||
|
||||
var poundResult = Languages.GetDefaultLetterKey(LetterKey.VK_P, new[] { Language.CUR });
|
||||
CollectionAssert.Contains(poundResult, "£", "Currency VK_P should contain Pound symbol");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Product code: Languages.GetDefaultLetterKey with Language.DE for VK_Q
|
||||
/// What: Verifies that a key with no accented characters for a language returns empty
|
||||
/// Why: Guards against spurious characters appearing for keys that should have no accents
|
||||
/// </summary>
|
||||
[TestMethod]
|
||||
public void GetDefaultLetterKey_UnusedKeyForLanguage_ShouldReturnEmpty()
|
||||
{
|
||||
var result = Languages.GetDefaultLetterKey(LetterKey.VK_Q, new[] { Language.DE });
|
||||
Assert.AreEqual(0, result.Length, "German should have no accented characters for Q");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Product code: Languages.GetDefaultLetterKeyALL (cache path via ConcurrentDictionary)
|
||||
/// What: Verifies that calling with all languages twice returns the same cached array instance
|
||||
/// Why: Cache hit should return the identical object reference, not a recomputed copy
|
||||
/// </summary>
|
||||
[TestMethod]
|
||||
public void GetDefaultLetterKey_AllLanguages_ShouldReturnCachedResults()
|
||||
{
|
||||
var allLanguages = Enum.GetValues<Language>();
|
||||
|
||||
var result1 = Languages.GetDefaultLetterKey(LetterKey.VK_A, allLanguages);
|
||||
var result2 = Languages.GetDefaultLetterKey(LetterKey.VK_A, allLanguages);
|
||||
|
||||
Assert.IsTrue(result1.Length > 0, "All languages combined should have accented A characters");
|
||||
CollectionAssert.AreEqual(result1, result2, "Cached results should be identical");
|
||||
|
||||
// Verify cache hit returns same object instance, not just equal contents
|
||||
Assert.AreSame(result1, result2, "Second call should return cached reference");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Product code: Languages.GetDefaultLetterKey across all LetterKey × Language combinations
|
||||
/// What: Smoke-tests every letter key with all languages to ensure no exceptions are thrown
|
||||
/// Why: Catches missing switch arms or null returns that would crash the toolbar at runtime
|
||||
/// </summary>
|
||||
[TestMethod]
|
||||
public void GetDefaultLetterKey_AllLanguages_EachLetterKey_ShouldNotThrow()
|
||||
{
|
||||
var allLanguages = Enum.GetValues<Language>();
|
||||
var allLetterKeys = Enum.GetValues<LetterKey>();
|
||||
|
||||
foreach (var letterKey in allLetterKeys)
|
||||
{
|
||||
var result = Languages.GetDefaultLetterKey(letterKey, allLanguages);
|
||||
Assert.IsNotNull(result, $"Result for {letterKey} should not be null");
|
||||
|
||||
Assert.AreEqual(result.Length, result.Distinct().Count(), $"Result for {letterKey} should have no duplicates");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
205
src/modules/poweraccent/PowerAccent.Core.UnitTests/PointTests.cs
Normal file
205
src/modules/poweraccent/PowerAccent.Core.UnitTests/PointTests.cs
Normal file
@@ -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 Microsoft.VisualStudio.TestTools.UnitTesting;
|
||||
|
||||
namespace PowerAccent.Core.UnitTests
|
||||
{
|
||||
[TestClass]
|
||||
public class PointTests
|
||||
{
|
||||
/// <summary>
|
||||
/// Product code: Point() default constructor
|
||||
/// What: Verifies X and Y initialize to zero
|
||||
/// Why: Default state must be deterministic — uninitialized coordinates cause misplacement
|
||||
/// </summary>
|
||||
[TestMethod]
|
||||
public void DefaultConstructor_ShouldInitializeToZero()
|
||||
{
|
||||
var point = new Point();
|
||||
Assert.AreEqual(0, point.X);
|
||||
Assert.AreEqual(0, point.Y);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Product code: Point(double, double) constructor
|
||||
/// What: Verifies that fractional coordinates are preserved exactly
|
||||
/// Why: Sub-pixel precision is needed for DPI-aware positioning
|
||||
/// </summary>
|
||||
[TestMethod]
|
||||
public void DoubleConstructor_ShouldSetCoordinates()
|
||||
{
|
||||
var point = new Point(3.5, 7.2);
|
||||
Assert.AreEqual(3.5, point.X);
|
||||
Assert.AreEqual(7.2, point.Y);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Product code: Point(int, int) constructor
|
||||
/// What: Verifies integer coordinates are stored as doubles without loss
|
||||
/// Why: Implicit int→double conversion must be exact for pixel-aligned positioning
|
||||
/// </summary>
|
||||
[TestMethod]
|
||||
public void IntConstructor_ShouldSetCoordinates()
|
||||
{
|
||||
var point = new Point(10, 20);
|
||||
Assert.AreEqual(10.0, point.X);
|
||||
Assert.AreEqual(20.0, point.Y);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Product code: Point(System.Drawing.Point) constructor
|
||||
/// What: Verifies conversion from System.Drawing.Point preserves coordinates
|
||||
/// Why: Win32 API interop uses System.Drawing.Point — conversion must be lossless
|
||||
/// </summary>
|
||||
[TestMethod]
|
||||
public void DrawingPointConstructor_ShouldConvertCoordinates()
|
||||
{
|
||||
var drawingPoint = new System.Drawing.Point(15, 25);
|
||||
var point = new Point(drawingPoint);
|
||||
Assert.AreEqual(15.0, point.X);
|
||||
Assert.AreEqual(25.0, point.Y);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Product code: Point implicit operator from System.Drawing.Point
|
||||
/// What: Verifies implicit conversion works at assignment sites
|
||||
/// Why: Enables seamless interop without explicit casts in calling code
|
||||
/// </summary>
|
||||
[TestMethod]
|
||||
public void ImplicitConversion_FromDrawingPoint_ShouldWork()
|
||||
{
|
||||
Point point = new System.Drawing.Point(100, 200);
|
||||
Assert.AreEqual(100.0, point.X);
|
||||
Assert.AreEqual(200.0, point.Y);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Product code: Point operator /(Point, double)
|
||||
/// What: Verifies scalar division halves both coordinates
|
||||
/// Why: Core arithmetic for DPI normalization
|
||||
/// </summary>
|
||||
[TestMethod]
|
||||
public void DivisionByScalar_ShouldDivideCoordinates()
|
||||
{
|
||||
var point = new Point(10.0, 20.0);
|
||||
var result = point / 2.0;
|
||||
Assert.AreEqual(5.0, result.X);
|
||||
Assert.AreEqual(10.0, result.Y);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Product code: Point operator /(Point, double) with negative divisor
|
||||
/// What: Verifies negative divisor negates both coordinates
|
||||
/// Why: Ensures correct sign propagation in coordinate math
|
||||
/// </summary>
|
||||
[TestMethod]
|
||||
public void DivisionByScalar_WithNegativeDivider_ShouldNegateCoordinates()
|
||||
{
|
||||
var point = new Point(10.0, 20.0);
|
||||
var result = point / -2.0;
|
||||
Assert.AreEqual(-5.0, result.X);
|
||||
Assert.AreEqual(-10.0, result.Y);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Product code: Point operator /(Point, double) zero guard
|
||||
/// What: Verifies that dividing by zero throws DivideByZeroException
|
||||
/// Why: Prevents Infinity coordinates from corrupting toolbar placement
|
||||
/// </summary>
|
||||
[TestMethod]
|
||||
[ExpectedException(typeof(DivideByZeroException))]
|
||||
public void DivisionByScalar_WithZero_ShouldThrow()
|
||||
{
|
||||
var point = new Point(10.0, 20.0);
|
||||
_ = point / 0.0;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Product code: Point operator /(Point, Point)
|
||||
/// What: Verifies component-wise division (X/X, Y/Y)
|
||||
/// Why: Used for coordinate space transformations
|
||||
/// </summary>
|
||||
[TestMethod]
|
||||
public void DivisionByPoint_ShouldDivideComponentwise()
|
||||
{
|
||||
var point = new Point(10.0, 20.0);
|
||||
var divider = new Point(2.0, 5.0);
|
||||
var result = point / divider;
|
||||
Assert.AreEqual(5.0, result.X);
|
||||
Assert.AreEqual(4.0, result.Y);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Product code: Point operator /(Point, Point) zero guard for X
|
||||
/// What: Verifies that a divider with X=0 throws DivideByZeroException
|
||||
/// Why: X=0 in a divider would produce Infinity for the X coordinate
|
||||
/// </summary>
|
||||
[TestMethod]
|
||||
[ExpectedException(typeof(DivideByZeroException))]
|
||||
public void DivisionByPoint_WithZeroX_ShouldThrow()
|
||||
{
|
||||
var point = new Point(10.0, 20.0);
|
||||
var divider = new Point(0.0, 5.0);
|
||||
_ = point / divider;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Product code: Point operator /(Point, Point) zero guard for Y
|
||||
/// What: Verifies that a divider with Y=0 throws DivideByZeroException
|
||||
/// Why: Y=0 in a divider would produce Infinity for the Y coordinate
|
||||
/// </summary>
|
||||
[TestMethod]
|
||||
[ExpectedException(typeof(DivideByZeroException))]
|
||||
public void DivisionByPoint_WithZeroY_ShouldThrow()
|
||||
{
|
||||
var point = new Point(10.0, 20.0);
|
||||
var divider = new Point(5.0, 0.0);
|
||||
_ = point / divider;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Product code: Point(double, double) constructor with negatives
|
||||
/// What: Verifies that negative coordinates are stored correctly
|
||||
/// Why: Negative coordinates are valid in multi-monitor setups (left/above primary)
|
||||
/// </summary>
|
||||
[TestMethod]
|
||||
public void NegativeCoordinates_ShouldBeAllowed()
|
||||
{
|
||||
var point = new Point(-5.5, -10.3);
|
||||
Assert.AreEqual(-5.5, point.X);
|
||||
Assert.AreEqual(-10.3, point.Y);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Product code: Point operator /(Point, double) with fractional divisor
|
||||
/// What: Verifies dividing by 0.5 effectively doubles coordinates
|
||||
/// Why: Fractional DPI values (1.25x, 1.5x) are common in production
|
||||
/// </summary>
|
||||
[TestMethod]
|
||||
public void DivisionByScalar_WithFractionalDivider_ShouldWork()
|
||||
{
|
||||
var point = new Point(10.0, 20.0);
|
||||
var result = point / 0.5;
|
||||
Assert.AreEqual(20.0, result.X);
|
||||
Assert.AreEqual(40.0, result.Y);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Product code: Point operator /(Point, Point) zero guard for both X and Y
|
||||
/// What: Verifies that a divider with both X=0 and Y=0 throws
|
||||
/// Why: Degenerate origin-point divider must be rejected — tests the first guard hit
|
||||
/// </summary>
|
||||
[TestMethod]
|
||||
[ExpectedException(typeof(DivideByZeroException))]
|
||||
public void DivisionByPoint_WithBothZero_ShouldThrow()
|
||||
{
|
||||
var point = new Point(10.0, 20.0);
|
||||
var divider = new Point(0.0, 0.0);
|
||||
_ = point / divider;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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" />
|
||||
|
||||
<Import Project="$(RepoRoot)src\Common.SelfContained.props" />
|
||||
|
||||
<PropertyGroup>
|
||||
<IsPackable>false</IsPackable>
|
||||
<AppendTargetFrameworkToOutputPath>false</AppendTargetFrameworkToOutputPath>
|
||||
<AppendRuntimeIdentifierToOutputPath>false</AppendRuntimeIdentifierToOutputPath>
|
||||
<OutputPath>$(SolutionDir)$(Platform)\$(Configuration)\tests\PowerAccent.Core.Tests\</OutputPath>
|
||||
<RootNamespace>PowerAccent.Core.UnitTests</RootNamespace>
|
||||
<AssemblyName>PowerToys.PowerAccent.Core.UnitTests</AssemblyName>
|
||||
<OutputType>Exe</OutputType>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="MSTest" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\PowerAccent.Core\PowerAccent.Core.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
223
src/modules/poweraccent/PowerAccent.Core.UnitTests/RectTests.cs
Normal file
223
src/modules/poweraccent/PowerAccent.Core.UnitTests/RectTests.cs
Normal file
@@ -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 System;
|
||||
|
||||
using Microsoft.VisualStudio.TestTools.UnitTesting;
|
||||
|
||||
namespace PowerAccent.Core.UnitTests
|
||||
{
|
||||
[TestClass]
|
||||
public class RectTests
|
||||
{
|
||||
/// <summary>
|
||||
/// Product code: Rect() default constructor
|
||||
/// What: Verifies all properties initialize to zero
|
||||
/// Why: Default state must be well-defined to avoid uninitialized geometry bugs
|
||||
/// </summary>
|
||||
[TestMethod]
|
||||
public void DefaultConstructor_ShouldInitializeToZero()
|
||||
{
|
||||
var rect = new Rect();
|
||||
Assert.AreEqual(0, rect.X);
|
||||
Assert.AreEqual(0, rect.Y);
|
||||
Assert.AreEqual(0, rect.Width);
|
||||
Assert.AreEqual(0, rect.Height);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Product code: Rect(int, int, int, int) constructor
|
||||
/// What: Verifies that integer arguments are correctly stored as doubles
|
||||
/// Why: Implicit int→double conversion must preserve exact values
|
||||
/// </summary>
|
||||
[TestMethod]
|
||||
public void IntConstructor_ShouldSetAllProperties()
|
||||
{
|
||||
var rect = new Rect(10, 20, 800, 600);
|
||||
Assert.AreEqual(10.0, rect.X);
|
||||
Assert.AreEqual(20.0, rect.Y);
|
||||
Assert.AreEqual(800.0, rect.Width);
|
||||
Assert.AreEqual(600.0, rect.Height);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Product code: Rect(double, double, double, double) constructor
|
||||
/// What: Verifies that fractional double values are stored exactly
|
||||
/// Why: Ensures no rounding or truncation occurs during construction
|
||||
/// </summary>
|
||||
[TestMethod]
|
||||
public void DoubleConstructor_ShouldSetAllProperties()
|
||||
{
|
||||
var rect = new Rect(1.5, 2.5, 100.3, 200.7);
|
||||
Assert.AreEqual(1.5, rect.X);
|
||||
Assert.AreEqual(2.5, rect.Y);
|
||||
Assert.AreEqual(100.3, rect.Width);
|
||||
Assert.AreEqual(200.7, rect.Height);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Product code: Rect(Point, Size) constructor
|
||||
/// What: Verifies that Point and Size components map to correct Rect properties
|
||||
/// Why: X/Y come from Point, Width/Height from Size — a swap would silently corrupt layout
|
||||
/// </summary>
|
||||
[TestMethod]
|
||||
public void PointSizeConstructor_ShouldSetFromComponents()
|
||||
{
|
||||
var point = new Point(50.0, 100.0);
|
||||
var size = new Size(400.0, 300.0);
|
||||
var rect = new Rect(point, size);
|
||||
Assert.AreEqual(50.0, rect.X);
|
||||
Assert.AreEqual(100.0, rect.Y);
|
||||
Assert.AreEqual(400.0, rect.Width);
|
||||
Assert.AreEqual(300.0, rect.Height);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Product code: Rect operator /(Rect, double)
|
||||
/// What: Verifies component-wise division by a scalar
|
||||
/// Why: Used for DPI scaling — incorrect division would misposition the toolbar
|
||||
/// </summary>
|
||||
[TestMethod]
|
||||
public void DivisionByScalar_ShouldDivideAllComponents()
|
||||
{
|
||||
var rect = new Rect(10.0, 20.0, 100.0, 200.0);
|
||||
var result = rect / 2.0;
|
||||
Assert.AreEqual(5.0, result.X);
|
||||
Assert.AreEqual(10.0, result.Y);
|
||||
Assert.AreEqual(50.0, result.Width);
|
||||
Assert.AreEqual(100.0, result.Height);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Product code: Rect operator /(Rect, double) zero guard
|
||||
/// What: Verifies that dividing by zero throws DivideByZeroException
|
||||
/// Why: Prevents Infinity values from propagating through layout calculations
|
||||
/// </summary>
|
||||
[TestMethod]
|
||||
[ExpectedException(typeof(DivideByZeroException))]
|
||||
public void DivisionByScalar_WithZero_ShouldThrow()
|
||||
{
|
||||
var rect = new Rect(10.0, 20.0, 100.0, 200.0);
|
||||
_ = rect / 0.0;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Product code: Rect operator /(Rect, Rect)
|
||||
/// What: Verifies component-wise division (X/X, Y/Y, Width/Width, Height/Height)
|
||||
/// Why: Used for coordinate system transformations between screen and window space
|
||||
/// </summary>
|
||||
[TestMethod]
|
||||
public void DivisionByRect_ShouldDivideComponentwise()
|
||||
{
|
||||
var rect = new Rect(10.0, 20.0, 100.0, 200.0);
|
||||
var divider = new Rect(2.0, 5.0, 10.0, 20.0);
|
||||
var result = rect / divider;
|
||||
Assert.AreEqual(5.0, result.X);
|
||||
Assert.AreEqual(4.0, result.Y);
|
||||
Assert.AreEqual(10.0, result.Width);
|
||||
Assert.AreEqual(10.0, result.Height);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Product code: Rect operator /(Rect, Rect) zero guard for X component
|
||||
/// What: Verifies that a divider with X=0 throws DivideByZeroException
|
||||
/// Why: X=0 in a divider Rect would produce Infinity for the X coordinate
|
||||
/// </summary>
|
||||
[TestMethod]
|
||||
[ExpectedException(typeof(DivideByZeroException))]
|
||||
public void DivisionByRect_WithZeroX_ShouldThrow()
|
||||
{
|
||||
var rect = new Rect(10.0, 20.0, 100.0, 200.0);
|
||||
var divider = new Rect(0.0, 5.0, 10.0, 20.0);
|
||||
_ = rect / divider;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Product code: Rect operator /(Rect, Rect) zero guard for Y component
|
||||
/// What: Verifies that a divider with Y=0 throws DivideByZeroException
|
||||
/// Why: Y=0 in a divider Rect would produce Infinity for the Y coordinate
|
||||
/// </summary>
|
||||
[TestMethod]
|
||||
[ExpectedException(typeof(DivideByZeroException))]
|
||||
public void DivisionByRect_WithZeroY_ShouldThrow()
|
||||
{
|
||||
var rect = new Rect(10.0, 20.0, 100.0, 200.0);
|
||||
var divider = new Rect(5.0, 0.0, 10.0, 20.0);
|
||||
_ = rect / divider;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Product code: Rect operator /(Rect, Rect) zero guard for Width component
|
||||
/// What: Verifies that a divider with Width=0 throws DivideByZeroException
|
||||
/// Why: Guards against producing Infinity values that propagate through calculations
|
||||
/// </summary>
|
||||
[TestMethod]
|
||||
[ExpectedException(typeof(DivideByZeroException))]
|
||||
public void DivisionByRect_WithZeroWidth_ShouldThrow()
|
||||
{
|
||||
var rect = new Rect(10, 20, 100, 200);
|
||||
var divider = new Rect(1, 1, 0, 1);
|
||||
_ = rect / divider;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Product code: Rect operator /(Rect, Rect) zero guard for Height component
|
||||
/// What: Verifies that a divider with Height=0 throws DivideByZeroException
|
||||
/// Why: Guards against producing Infinity values that propagate through calculations
|
||||
/// </summary>
|
||||
[TestMethod]
|
||||
[ExpectedException(typeof(DivideByZeroException))]
|
||||
public void DivisionByRect_WithZeroHeight_ShouldThrow()
|
||||
{
|
||||
var rect = new Rect(10, 20, 100, 200);
|
||||
var divider = new Rect(1, 1, 1, 0);
|
||||
_ = rect / divider;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Product code: Rect operator /(Rect, Rect) zero guard for all components
|
||||
/// What: Verifies that a divider with all zero components throws DivideByZeroException
|
||||
/// Why: Degenerate all-zero divider must be caught — tests the first guard hit (X=0)
|
||||
/// </summary>
|
||||
[TestMethod]
|
||||
[ExpectedException(typeof(DivideByZeroException))]
|
||||
public void DivisionByRect_WithAllZeroComponents_ShouldThrow()
|
||||
{
|
||||
var rect = new Rect(10, 20, 100, 200);
|
||||
var divider = new Rect(0, 0, 0, 0);
|
||||
_ = rect / divider;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Product code: Rect(double, double, double, double) constructor
|
||||
/// What: Verifies that negative X/Y coordinates are allowed
|
||||
/// Why: Multi-monitor setups can have negative screen coordinates (left/above primary)
|
||||
/// </summary>
|
||||
[TestMethod]
|
||||
public void NegativeCoordinates_ShouldBeAllowed()
|
||||
{
|
||||
var rect = new Rect(-100.0, -200.0, 400.0, 300.0);
|
||||
Assert.AreEqual(-100.0, rect.X);
|
||||
Assert.AreEqual(-200.0, rect.Y);
|
||||
Assert.AreEqual(400.0, rect.Width);
|
||||
Assert.AreEqual(300.0, rect.Height);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Product code: Rect operator /(Rect, double) with negative divisor
|
||||
/// What: Verifies that negative divisor negates all components
|
||||
/// Why: Ensures sign propagation is correct — used in coordinate flipping scenarios
|
||||
/// </summary>
|
||||
[TestMethod]
|
||||
public void DivisionByScalar_WithNegativeDivider_ShouldNegateComponents()
|
||||
{
|
||||
var rect = new Rect(10.0, 20.0, 100.0, 200.0);
|
||||
var result = rect / -1.0;
|
||||
Assert.AreEqual(-10.0, result.X);
|
||||
Assert.AreEqual(-20.0, result.Y);
|
||||
Assert.AreEqual(-100.0, result.Width);
|
||||
Assert.AreEqual(-200.0, result.Height);
|
||||
}
|
||||
}
|
||||
}
|
||||
192
src/modules/poweraccent/PowerAccent.Core.UnitTests/SizeTests.cs
Normal file
192
src/modules/poweraccent/PowerAccent.Core.UnitTests/SizeTests.cs
Normal file
@@ -0,0 +1,192 @@
|
||||
// Copyright (c) Microsoft Corporation
|
||||
// The Microsoft 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 PowerAccent.Core.UnitTests
|
||||
{
|
||||
[TestClass]
|
||||
public class SizeTests
|
||||
{
|
||||
/// <summary>
|
||||
/// Product code: Size() default constructor
|
||||
/// What: Verifies Width and Height initialize to zero
|
||||
/// Why: Default state must be deterministic to avoid layout corruption
|
||||
/// </summary>
|
||||
[TestMethod]
|
||||
public void DefaultConstructor_ShouldInitializeToZero()
|
||||
{
|
||||
var size = new Size();
|
||||
Assert.AreEqual(0, size.Width);
|
||||
Assert.AreEqual(0, size.Height);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Product code: Size(double, double) constructor
|
||||
/// What: Verifies fractional values are preserved exactly
|
||||
/// Why: Ensures no rounding during construction for sub-pixel precision
|
||||
/// </summary>
|
||||
[TestMethod]
|
||||
public void DoubleConstructor_ShouldSetDimensions()
|
||||
{
|
||||
var size = new Size(100.5, 200.3);
|
||||
Assert.AreEqual(100.5, size.Width);
|
||||
Assert.AreEqual(200.3, size.Height);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Product code: Size(int, int) constructor
|
||||
/// What: Verifies integer dimensions are stored as doubles without loss
|
||||
/// Why: Implicit int→double conversion must be exact
|
||||
/// </summary>
|
||||
[TestMethod]
|
||||
public void IntConstructor_ShouldSetDimensions()
|
||||
{
|
||||
var size = new Size(800, 600);
|
||||
Assert.AreEqual(800.0, size.Width);
|
||||
Assert.AreEqual(600.0, size.Height);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Product code: Size implicit operator from System.Drawing.Size
|
||||
/// What: Verifies implicit conversion from System.Drawing.Size works
|
||||
/// Why: Interop boundary — System.Drawing types are used by Win32 APIs
|
||||
/// </summary>
|
||||
[TestMethod]
|
||||
public void ImplicitConversion_FromDrawingSize_ShouldWork()
|
||||
{
|
||||
Size size = new System.Drawing.Size(1920, 1080);
|
||||
Assert.AreEqual(1920.0, size.Width);
|
||||
Assert.AreEqual(1080.0, size.Height);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Product code: Size operator /(Size, double)
|
||||
/// What: Verifies scalar division halves both dimensions
|
||||
/// Why: Core arithmetic used for DPI scaling — must produce exact results
|
||||
/// </summary>
|
||||
[TestMethod]
|
||||
public void DivisionByScalar_ShouldDivideDimensions()
|
||||
{
|
||||
var size = new Size(100.0, 200.0);
|
||||
var result = size / 2.0;
|
||||
Assert.AreEqual(50.0, result.Width);
|
||||
Assert.AreEqual(100.0, result.Height);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Product code: Size operator /(Size, double) with fractional divisor
|
||||
/// What: Verifies that dividing by 0.5 effectively doubles dimensions
|
||||
/// Why: Fractional DPI values are real (e.g., 1.25x, 1.5x scaling)
|
||||
/// </summary>
|
||||
[TestMethod]
|
||||
public void DivisionByScalar_WithFractionalDivider_ShouldWork()
|
||||
{
|
||||
var size = new Size(100.0, 200.0);
|
||||
var result = size / 0.5;
|
||||
Assert.AreEqual(200.0, result.Width);
|
||||
Assert.AreEqual(400.0, result.Height);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Product code: Size operator /(Size, double) zero guard
|
||||
/// What: Verifies that dividing by zero throws DivideByZeroException
|
||||
/// Why: Prevents Infinity dimensions that would corrupt window layout
|
||||
/// </summary>
|
||||
[TestMethod]
|
||||
[ExpectedException(typeof(DivideByZeroException))]
|
||||
public void DivisionByScalar_WithZero_ShouldThrow()
|
||||
{
|
||||
var size = new Size(100.0, 200.0);
|
||||
_ = size / 0.0;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Product code: Size operator /(Size, Size)
|
||||
/// What: Verifies component-wise division (Width/Width, Height/Height)
|
||||
/// Why: Used for computing scaling ratios between two rectangles
|
||||
/// </summary>
|
||||
[TestMethod]
|
||||
public void DivisionBySize_ShouldDivideComponentwise()
|
||||
{
|
||||
var size = new Size(100.0, 200.0);
|
||||
var divider = new Size(10.0, 20.0);
|
||||
var result = size / divider;
|
||||
Assert.AreEqual(10.0, result.Width);
|
||||
Assert.AreEqual(10.0, result.Height);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Product code: Size operator /(Size, Size) zero Width guard
|
||||
/// What: Verifies that a divider with Width=0 throws DivideByZeroException
|
||||
/// Why: Width=0 divider would produce Infinity — must be caught early
|
||||
/// </summary>
|
||||
[TestMethod]
|
||||
[ExpectedException(typeof(DivideByZeroException))]
|
||||
public void DivisionBySize_WithZeroWidth_ShouldThrow()
|
||||
{
|
||||
var size = new Size(100.0, 200.0);
|
||||
var divider = new Size(0.0, 20.0);
|
||||
_ = size / divider;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Product code: Size operator /(Size, Size) zero Height guard
|
||||
/// What: Verifies that a divider with Height=0 throws DivideByZeroException
|
||||
/// Why: Height=0 divider would produce Infinity — must be caught early
|
||||
/// </summary>
|
||||
[TestMethod]
|
||||
[ExpectedException(typeof(DivideByZeroException))]
|
||||
public void DivisionBySize_WithZeroHeight_ShouldThrow()
|
||||
{
|
||||
var size = new Size(100.0, 200.0);
|
||||
var divider = new Size(10.0, 0.0);
|
||||
_ = size / divider;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Product code: Size operator /(Size, double) with negative divisor
|
||||
/// What: Verifies that negative divisor correctly negates both dimensions
|
||||
/// Why: Ensures sign propagation is correct in arithmetic
|
||||
/// </summary>
|
||||
[TestMethod]
|
||||
public void DivisionByScalar_WithNegativeDivider_ShouldWork()
|
||||
{
|
||||
var size = new Size(100.0, 200.0);
|
||||
var result = size / -2.0;
|
||||
Assert.AreEqual(-50.0, result.Width);
|
||||
Assert.AreEqual(-100.0, result.Height);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Product code: Size operator /(Size, double) with very small divisor
|
||||
/// What: Verifies division by a small number produces expected large result
|
||||
/// Why: Near-zero divisors (e.g., very low DPI) must not crash or produce NaN
|
||||
/// </summary>
|
||||
[TestMethod]
|
||||
public void DivisionByScalar_WithVerySmallDivider_ShouldYieldLargeResult()
|
||||
{
|
||||
var size = new Size(1.0, 1.0);
|
||||
var result = size / 0.001;
|
||||
Assert.AreEqual(1000.0, result.Width, 0.01);
|
||||
Assert.AreEqual(1000.0, result.Height, 0.01);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Product code: Size operator /(Size, Size) zero guard for both dimensions
|
||||
/// What: Verifies that a divider with both Width=0 and Height=0 throws
|
||||
/// Why: Degenerate zero-size divider must be rejected — tests the first guard hit
|
||||
/// </summary>
|
||||
[TestMethod]
|
||||
[ExpectedException(typeof(DivideByZeroException))]
|
||||
public void DivisionBySize_WithBothZeroDimensions_ShouldThrow()
|
||||
{
|
||||
var size = new Size(100.0, 200.0);
|
||||
var divider = new Size(0.0, 0.0);
|
||||
_ = size / divider;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -58,7 +58,7 @@ public struct Rect
|
||||
|
||||
public static Rect operator /(Rect rect, Rect divider)
|
||||
{
|
||||
if (divider.X == 0 || divider.Y == 0)
|
||||
if (divider.X == 0 || divider.Y == 0 || divider.Width == 0 || divider.Height == 0)
|
||||
{
|
||||
throw new DivideByZeroException();
|
||||
}
|
||||
|
||||
@@ -42,7 +42,7 @@ public struct Size
|
||||
|
||||
public static Size operator /(Size size, Size divider)
|
||||
{
|
||||
if (divider.Width == 0 || divider.Height == 0 || divider.Width == 0 || divider.Height == 0)
|
||||
if (divider.Width == 0 || divider.Height == 0)
|
||||
{
|
||||
throw new DivideByZeroException();
|
||||
}
|
||||
|
||||
@@ -33,4 +33,10 @@
|
||||
<ProjectReference Include="..\..\..\settings-ui\Settings.UI.Library\Settings.UI.Library.csproj" />
|
||||
<ProjectReference Include="..\PowerAccentKeyboardService\PowerAccentKeyboardService.vcxproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<AssemblyAttribute Include="System.Runtime.CompilerServices.InternalsVisibleToAttribute">
|
||||
<_Parameter1>PowerToys.PowerAccent.Core.UnitTests</_Parameter1>
|
||||
</AssemblyAttribute>
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
|
||||
@@ -28,11 +28,7 @@
|
||||
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>
|
||||
<!-- System.Diagnostics.EventLog is now provided to all C# projects via Directory.Build.props -->
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
|
||||
@@ -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 PowerDisplay.Common.Utils;
|
||||
using PowerDisplay.Models;
|
||||
|
||||
namespace PowerDisplay.Common.Extensions
|
||||
{
|
||||
/// <summary>
|
||||
/// Extension methods for <see cref="CustomVcpValueMapping"/> that provide display-related properties.
|
||||
/// These depend on <see cref="VcpNames"/> and are therefore kept in PowerDisplay.Lib
|
||||
/// rather than in the shared PowerDisplay.Models project.
|
||||
/// </summary>
|
||||
public static class CustomVcpValueMappingExtensions
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets the display name for the VCP code (e.g., "Select Color Preset").
|
||||
/// </summary>
|
||||
public static string GetVcpCodeDisplayName(this CustomVcpValueMapping mapping)
|
||||
{
|
||||
return VcpNames.GetCodeName(mapping.VcpCode);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the formatted display name for the VCP value (e.g., "6500K (0x05)").
|
||||
/// </summary>
|
||||
public static string GetValueDisplayName(this CustomVcpValueMapping mapping)
|
||||
{
|
||||
return VcpNames.GetFormattedValueName(mapping.VcpCode, mapping.Value);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets a summary string for display in the UI list.
|
||||
/// Format: "OriginalValue → CustomName" or "OriginalValue → CustomName (MonitorName)"
|
||||
/// </summary>
|
||||
public static string GetDisplaySummary(this CustomVcpValueMapping mapping)
|
||||
{
|
||||
var baseSummary = $"{VcpNames.GetValueName(mapping.VcpCode, mapping.Value) ?? $"0x{mapping.Value:X2}"} \u2192 {mapping.CustomName}";
|
||||
if (!mapping.ApplyToAll && !string.IsNullOrEmpty(mapping.TargetMonitorName))
|
||||
{
|
||||
return $"{baseSummary} ({mapping.TargetMonitorName})";
|
||||
}
|
||||
|
||||
return baseSummary;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2,13 +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 PowerDisplay.Common.Models;
|
||||
using PowerDisplay.Models;
|
||||
|
||||
namespace PowerDisplay.Common.Interfaces
|
||||
{
|
||||
/// <summary>
|
||||
/// Interface for profile management service.
|
||||
/// Provides abstraction for loading, saving, and managing PowerDisplay profiles.
|
||||
/// Provides abstraction for loading and saving PowerDisplay profiles.
|
||||
/// Enables dependency injection and unit testing.
|
||||
/// </summary>
|
||||
public interface IProfileService
|
||||
@@ -25,38 +25,5 @@ namespace PowerDisplay.Common.Interfaces
|
||||
/// <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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,88 +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;
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -39,5 +39,6 @@
|
||||
<ProjectReference Include="..\..\..\common\ManagedCommon\ManagedCommon.csproj" />
|
||||
<ProjectReference Include="..\..\..\common\ManagedCsWin32\ManagedCsWin32.csproj" />
|
||||
<ProjectReference Include="..\..\..\common\interop\PowerToys.Interop.vcxproj" />
|
||||
<ProjectReference Include="..\PowerDisplay.Models\PowerDisplay.Models.csproj" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
|
||||
@@ -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 System.Text.Json.Serialization;
|
||||
using PowerDisplay.Common.Models;
|
||||
|
||||
namespace PowerDisplay.Common.Serialization
|
||||
{
|
||||
/// <summary>
|
||||
/// JSON serialization context for MonitorState types.
|
||||
/// Provides source-generated serialization for Native AOT compatibility.
|
||||
/// Separated from ProfileSerializationContext which moved to PowerDisplay.Models.
|
||||
/// </summary>
|
||||
[JsonSourceGenerationOptions(
|
||||
WriteIndented = false,
|
||||
DefaultIgnoreCondition = JsonIgnoreCondition.Never,
|
||||
IncludeFields = true)]
|
||||
[JsonSerializable(typeof(MonitorStateFile))]
|
||||
[JsonSerializable(typeof(MonitorStateEntry))]
|
||||
[JsonSerializable(typeof(Dictionary<string, MonitorStateEntry>))]
|
||||
public partial class MonitorStateSerializationContext : JsonSerializerContext
|
||||
{
|
||||
}
|
||||
}
|
||||
@@ -156,7 +156,7 @@ namespace PowerDisplay.Common.Services
|
||||
}
|
||||
|
||||
var json = File.ReadAllText(_stateFilePath);
|
||||
var stateFile = JsonSerializer.Deserialize(json, ProfileSerializationContext.Default.MonitorStateFile);
|
||||
var stateFile = JsonSerializer.Deserialize(json, MonitorStateSerializationContext.Default.MonitorStateFile);
|
||||
|
||||
if (stateFile?.Monitors != null)
|
||||
{
|
||||
@@ -257,7 +257,7 @@ namespace PowerDisplay.Common.Services
|
||||
};
|
||||
}
|
||||
|
||||
return JsonSerializer.Serialize(stateFile, ProfileSerializationContext.Default.MonitorStateFile);
|
||||
return JsonSerializer.Serialize(stateFile, MonitorStateSerializationContext.Default.MonitorStateFile);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
||||
@@ -2,249 +2,52 @@
|
||||
// The Microsoft Corporation 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;
|
||||
using PowerDisplay.Models;
|
||||
using ModelsProfileHelper = PowerDisplay.Models.ProfileHelper;
|
||||
|
||||
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.
|
||||
/// Thin facade over <see cref="ProfileHelper"/> that satisfies <see cref="IProfileService"/>
|
||||
/// and provides named static entry points for PowerDisplay.exe callers.
|
||||
/// All locking and compound-operation atomicity is handled by <see cref="PowerDisplay.Models.ProfileHelper"/>.
|
||||
/// </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;
|
||||
}
|
||||
}
|
||||
/// <inheritdoc cref="ModelsProfileHelper.LoadProfiles"/>
|
||||
public static PowerDisplayProfiles LoadProfiles() => ModelsProfileHelper.LoadProfiles();
|
||||
|
||||
/// <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;
|
||||
}
|
||||
/// <inheritdoc cref="ModelsProfileHelper.SaveProfiles"/>
|
||||
public static bool SaveProfiles(PowerDisplayProfiles profiles) => ModelsProfileHelper.SaveProfiles(profiles);
|
||||
|
||||
var (success, _) = SaveProfilesInternal(profiles);
|
||||
return success;
|
||||
}
|
||||
}
|
||||
/// <inheritdoc cref="ModelsProfileHelper.AddOrUpdateProfile"/>
|
||||
public static bool AddOrUpdateProfile(PowerDisplayProfile profile) => ModelsProfileHelper.AddOrUpdateProfile(profile);
|
||||
|
||||
/// <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;
|
||||
}
|
||||
/// <inheritdoc cref="ModelsProfileHelper.RenameAndUpdateProfile"/>
|
||||
public static bool RenameAndUpdateProfile(string oldName, PowerDisplayProfile newProfile) => ModelsProfileHelper.RenameAndUpdateProfile(oldName, newProfile);
|
||||
|
||||
var (profiles, _) = LoadProfilesInternal();
|
||||
profiles.SetProfile(profile);
|
||||
/// <inheritdoc cref="ModelsProfileHelper.RemoveProfile"/>
|
||||
public static bool RemoveProfile(string profileName) => ModelsProfileHelper.RemoveProfile(profileName);
|
||||
|
||||
var (success, _) = SaveProfilesInternal(profiles);
|
||||
return success;
|
||||
}
|
||||
}
|
||||
/// <inheritdoc cref="ModelsProfileHelper.GetProfile"/>
|
||||
public static PowerDisplayProfile? GetProfile(string profileName) => ModelsProfileHelper.GetProfile(profileName);
|
||||
|
||||
/// <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
|
||||
// IProfileService explicit implementation
|
||||
|
||||
/// <inheritdoc/>
|
||||
PowerDisplayProfiles IProfileService.LoadProfiles() => LoadProfiles();
|
||||
PowerDisplayProfiles IProfileService.LoadProfiles() => ModelsProfileHelper.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();
|
||||
bool IProfileService.SaveProfiles(PowerDisplayProfiles profiles) => ModelsProfileHelper.SaveProfiles(profiles);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using PowerDisplay.Common.Drivers;
|
||||
using PowerDisplay.Common.Models;
|
||||
using PowerDisplay.Models;
|
||||
|
||||
namespace PowerDisplay.Common.Utils
|
||||
{
|
||||
|
||||
@@ -5,14 +5,13 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using PowerDisplay.Common.Models;
|
||||
using PowerDisplay.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
|
||||
{
|
||||
@@ -249,20 +248,11 @@ namespace PowerDisplay.Common.Utils
|
||||
|
||||
/// <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})";
|
||||
}
|
||||
|
||||
@@ -410,17 +400,7 @@ namespace PowerDisplay.Common.Utils
|
||||
};
|
||||
|
||||
/// <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
|
||||
/// 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>
|
||||
@@ -439,7 +419,7 @@ namespace PowerDisplay.Common.Utils
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Get formatted display name for a VCP value (with hex value in parentheses)
|
||||
/// 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>
|
||||
@@ -458,21 +438,17 @@ namespace PowerDisplay.Common.Utils
|
||||
/// <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>
|
||||
/// <param name="customMappings">Custom mappings that take priority over built-in names</param>
|
||||
/// <param name="monitorId">Monitor ID to filter monitor-specific 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 &&
|
||||
@@ -487,26 +463,5 @@ namespace PowerDisplay.Common.Utils
|
||||
// 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}";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,7 +6,7 @@ using System.ComponentModel;
|
||||
using System.Runtime.CompilerServices;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace PowerDisplay.Common.Models
|
||||
namespace PowerDisplay.Models
|
||||
{
|
||||
/// <summary>
|
||||
/// Represents a color temperature preset item for VCP code 0x14.
|
||||
@@ -4,12 +4,12 @@
|
||||
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace Microsoft.PowerToys.Settings.UI.Library
|
||||
namespace PowerDisplay.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 is a local copy maintained in Settings.UI.Library to avoid a dependency on PowerDisplay.Lib.
|
||||
/// This class is shared between PowerDisplay app and Settings UI.
|
||||
/// </summary>
|
||||
public class CustomVcpValueMapping
|
||||
{
|
||||
@@ -53,16 +53,11 @@ namespace Microsoft.PowerToys.Settings.UI.Library
|
||||
|
||||
/// <summary>
|
||||
/// Gets the display name for the VCP code (for UI display).
|
||||
/// Uses simple hex formatting. For richer names, use extension methods in PowerDisplay.Lib.
|
||||
/// </summary>
|
||||
[JsonIgnore]
|
||||
public string VcpCodeDisplayName => $"VCP 0x{VcpCode:X2}";
|
||||
|
||||
/// <summary>
|
||||
/// Gets the display name for the VCP value.
|
||||
/// </summary>
|
||||
[JsonIgnore]
|
||||
public string ValueDisplayName => $"0x{Value:X2}";
|
||||
|
||||
/// <summary>
|
||||
/// Gets a summary string for display in the UI list.
|
||||
/// Format: "0xVV → CustomName" or "0xVV → CustomName (MonitorName)"
|
||||
@@ -0,0 +1,23 @@
|
||||
<!-- 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.Models</RootNamespace>
|
||||
<Platforms>x64;ARM64</Platforms>
|
||||
<AppendTargetFrameworkToOutputPath>false</AppendTargetFrameworkToOutputPath>
|
||||
<AppendRuntimeIdentifierToOutputPath>false</AppendRuntimeIdentifierToOutputPath>
|
||||
<Nullable>enable</Nullable>
|
||||
<AssemblyName>PowerDisplay.Models</AssemblyName>
|
||||
</PropertyGroup>
|
||||
<!-- Native AOT Configuration -->
|
||||
<PropertyGroup>
|
||||
<IsAotCompatible>true</IsAotCompatible>
|
||||
</PropertyGroup>
|
||||
<ItemGroup>
|
||||
<PackageReference Include="System.Text.Json" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
@@ -6,7 +6,7 @@ using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace PowerDisplay.Common.Models
|
||||
namespace PowerDisplay.Models
|
||||
{
|
||||
/// <summary>
|
||||
/// Represents a PowerDisplay profile containing monitor settings
|
||||
@@ -9,17 +9,13 @@ using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace PowerDisplay.Common.Models
|
||||
namespace PowerDisplay.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; }
|
||||
|
||||
196
src/modules/powerdisplay/PowerDisplay.Models/ProfileHelper.cs
Normal file
196
src/modules/powerdisplay/PowerDisplay.Models/ProfileHelper.cs
Normal file
@@ -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.IO;
|
||||
using System.Text.Json;
|
||||
|
||||
namespace PowerDisplay.Models
|
||||
{
|
||||
/// <summary>
|
||||
/// Helper for loading and saving PowerDisplay profiles from/to disk.
|
||||
/// Provides shared file I/O logic used by both Settings UI and PowerDisplay module.
|
||||
/// Thread-safe and AOT-compatible.
|
||||
/// All compound operations (load → modify → save) are atomic within a single process.
|
||||
/// </summary>
|
||||
public static class ProfileHelper
|
||||
{
|
||||
private static readonly object _lock = new object();
|
||||
|
||||
private static readonly Lazy<string> _profilesFilePath = new Lazy<string>(() =>
|
||||
Path.Combine(
|
||||
Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
|
||||
"Microsoft",
|
||||
"PowerToys",
|
||||
"PowerDisplay",
|
||||
"profiles.json"));
|
||||
|
||||
/// <summary>
|
||||
/// Gets the full path to the profiles JSON file.
|
||||
/// </summary>
|
||||
public static string ProfilesFilePath => _profilesFilePath.Value;
|
||||
|
||||
/// <summary>
|
||||
/// Loads PowerDisplay profiles from disk.
|
||||
/// Thread-safe operation.
|
||||
/// </summary>
|
||||
/// <returns>PowerDisplayProfiles object, or a new empty instance if file doesn't exist or load fails.</returns>
|
||||
public static PowerDisplayProfiles LoadProfiles()
|
||||
{
|
||||
lock (_lock)
|
||||
{
|
||||
return LoadProfilesCore();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Saves PowerDisplay profiles to disk.
|
||||
/// Thread-safe operation with automatic timestamp update.
|
||||
/// </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)
|
||||
{
|
||||
return SaveProfilesCore(profiles);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Adds or updates a profile and persists to disk atomically.
|
||||
/// </summary>
|
||||
/// <param name="profile">The profile to add or update.</param>
|
||||
/// <returns>True if the operation was successful, false otherwise.</returns>
|
||||
public static bool AddOrUpdateProfile(PowerDisplayProfile profile)
|
||||
{
|
||||
if (profile == null || !profile.IsValid())
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
lock (_lock)
|
||||
{
|
||||
var profiles = LoadProfilesCore();
|
||||
profiles.SetProfile(profile);
|
||||
return SaveProfilesCore(profiles);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Renames and updates a profile atomically (for rename or edit operations).
|
||||
/// Removes the old entry by <paramref name="oldName"/> and upserts the updated profile.
|
||||
/// </summary>
|
||||
/// <param name="oldName">The current name of the profile to replace.</param>
|
||||
/// <param name="newProfile">The updated profile.</param>
|
||||
/// <returns>True if the operation was successful, false otherwise.</returns>
|
||||
public static bool RenameAndUpdateProfile(string oldName, PowerDisplayProfile newProfile)
|
||||
{
|
||||
if (newProfile == null || !newProfile.IsValid())
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
lock (_lock)
|
||||
{
|
||||
var profiles = LoadProfilesCore();
|
||||
profiles.RemoveProfile(oldName);
|
||||
profiles.SetProfile(newProfile);
|
||||
return SaveProfilesCore(profiles);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Removes a profile by name and persists to disk atomically.
|
||||
/// </summary>
|
||||
/// <param name="profileName">The name of the profile to remove.</param>
|
||||
/// <returns>True if the profile was found and removed, false otherwise.</returns>
|
||||
public static bool RemoveProfile(string profileName)
|
||||
{
|
||||
lock (_lock)
|
||||
{
|
||||
var profiles = LoadProfilesCore();
|
||||
bool removed = profiles.RemoveProfile(profileName);
|
||||
if (removed)
|
||||
{
|
||||
SaveProfilesCore(profiles);
|
||||
}
|
||||
|
||||
return removed;
|
||||
}
|
||||
}
|
||||
|
||||
/// <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>
|
||||
public static PowerDisplayProfile? GetProfile(string profileName)
|
||||
{
|
||||
lock (_lock)
|
||||
{
|
||||
return LoadProfilesCore().GetProfile(profileName);
|
||||
}
|
||||
}
|
||||
|
||||
// Lock-free core methods — only call from within a lock (_lock) block.
|
||||
private static PowerDisplayProfiles LoadProfilesCore()
|
||||
{
|
||||
try
|
||||
{
|
||||
EnsureFolderExists();
|
||||
|
||||
if (File.Exists(ProfilesFilePath))
|
||||
{
|
||||
var json = File.ReadAllText(ProfilesFilePath);
|
||||
var profiles = JsonSerializer.Deserialize(json, ProfileSerializationContext.Default.PowerDisplayProfiles);
|
||||
|
||||
if (profiles != null)
|
||||
{
|
||||
return profiles;
|
||||
}
|
||||
}
|
||||
|
||||
return new PowerDisplayProfiles();
|
||||
}
|
||||
catch (Exception)
|
||||
{
|
||||
return new PowerDisplayProfiles();
|
||||
}
|
||||
}
|
||||
|
||||
private static bool SaveProfilesCore(PowerDisplayProfiles profiles)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (profiles == null)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
EnsureFolderExists();
|
||||
|
||||
profiles.LastUpdated = DateTime.UtcNow;
|
||||
|
||||
var json = JsonSerializer.Serialize(profiles, ProfileSerializationContext.Default.PowerDisplayProfiles);
|
||||
File.WriteAllText(ProfilesFilePath, json);
|
||||
|
||||
return true;
|
||||
}
|
||||
catch (Exception)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private static void EnsureFolderExists()
|
||||
{
|
||||
var folder = Path.GetDirectoryName(ProfilesFilePath);
|
||||
if (folder != null && !Directory.Exists(folder))
|
||||
{
|
||||
Directory.CreateDirectory(folder);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -4,7 +4,7 @@
|
||||
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace PowerDisplay.Common.Models
|
||||
namespace PowerDisplay.Models
|
||||
{
|
||||
/// <summary>
|
||||
/// Monitor settings for a specific profile
|
||||
@@ -4,9 +4,8 @@
|
||||
|
||||
using System.Collections.Generic;
|
||||
using System.Text.Json.Serialization;
|
||||
using PowerDisplay.Common.Models;
|
||||
|
||||
namespace PowerDisplay.Common.Serialization
|
||||
namespace PowerDisplay.Models
|
||||
{
|
||||
/// <summary>
|
||||
/// JSON serialization context for PowerDisplay Profile types.
|
||||
@@ -23,11 +22,6 @@ namespace PowerDisplay.Common.Serialization
|
||||
[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
|
||||
{
|
||||
}
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 468 KiB After Width: | Height: | Size: 191 KiB |
@@ -14,11 +14,25 @@ namespace PowerDisplay.Configuration
|
||||
/// </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;
|
||||
// Main flyout dimensions in device-independent pixels (DIP)
|
||||
public const int WindowWidthDip = 362;
|
||||
public const int WindowMinHeightDip = 100;
|
||||
public const int WindowMaxHeightDip = 650;
|
||||
public const int WindowRightMarginDip = 12;
|
||||
public const int WindowBottomMarginDip = WindowRightMarginDip;
|
||||
public const double WindowMaxWorkAreaHeightRatio = 0.75;
|
||||
|
||||
// Adaptive flyout bounds in device-independent pixels (DIP)
|
||||
public const int FlyoutContextMenuMaxWidthDip = 320;
|
||||
public const int FlyoutContextMenuAdaptiveMaxWidthDip = 420;
|
||||
public const double FlyoutContextMenuMaxWorkAreaWidthRatio = 0.35;
|
||||
|
||||
// Identify overlay bounds in device-independent pixels (DIP)
|
||||
public const int IdentifyWindowPreferredWidthDip = 300;
|
||||
public const int IdentifyWindowPreferredHeightDip = 280;
|
||||
public const int IdentifyWindowMinWidthDip = 160;
|
||||
public const int IdentifyWindowMinHeightDip = 160;
|
||||
public const double IdentifyWindowMaxWorkAreaRatio = 0.28;
|
||||
|
||||
/// <summary>
|
||||
/// Icon glyph for internal/laptop displays (WMI)
|
||||
|
||||
116
src/modules/powerdisplay/PowerDisplay/Helpers/DpiSuppressor.cs
Normal file
116
src/modules/powerdisplay/PowerDisplay/Helpers/DpiSuppressor.cs
Normal file
@@ -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.Runtime.InteropServices;
|
||||
using WinUIEx;
|
||||
|
||||
namespace PowerDisplay.Helpers
|
||||
{
|
||||
/// <summary>
|
||||
/// Subclasses a window's WndProc to suppress WM_DPICHANGED messages during
|
||||
/// cross-monitor MoveAndResize calls. Without suppression, the framework
|
||||
/// auto-scales it a second time, causing double-scaling artifacts.
|
||||
///
|
||||
/// Usage:
|
||||
/// var suppressor = new DpiSuppressor(window);
|
||||
/// using (suppressor.Suppress())
|
||||
/// {
|
||||
/// window.AppWindow.MoveAndResize(rect, displayArea);
|
||||
/// }
|
||||
/// </summary>
|
||||
internal sealed partial class DpiSuppressor : IDisposable
|
||||
{
|
||||
// Optional external WndProc handler (e.g., HotkeyService) called before default processing.
|
||||
// Return true to indicate the message was handled.
|
||||
private readonly Func<uint, nuint, nint, bool>? _preProcessor;
|
||||
|
||||
private const int GwlWndProc = -4;
|
||||
private const uint WmDpiChanged = 0x02E0;
|
||||
|
||||
private readonly nint _hwnd;
|
||||
private nint _originalWndProc;
|
||||
private WndProcDelegate? _wndProcDelegate;
|
||||
private bool _suppressing;
|
||||
private bool _disposed;
|
||||
|
||||
[LibraryImport("user32.dll", EntryPoint = "SetWindowLongPtrW")]
|
||||
private static partial nint SetWindowLongPtr(nint hWnd, int nIndex, nint dwNewLong);
|
||||
|
||||
[LibraryImport("user32.dll", EntryPoint = "CallWindowProcW")]
|
||||
private static partial nint CallWindowProc(nint lpPrevWndFunc, nint hWnd, uint msg, nuint wParam, nint lParam);
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="DpiSuppressor"/> class.
|
||||
/// Subclass the window's WndProc to enable DPI suppression.
|
||||
/// </summary>
|
||||
/// <param name="window">Window to subclass.</param>
|
||||
/// <param name="preProcessor">Optional callback invoked for every message before default processing.
|
||||
/// Receives (uMsg, wParam, lParam). Return true to swallow the message.</param>
|
||||
public DpiSuppressor(WindowEx window, Func<uint, nuint, nint, bool>? preProcessor = null)
|
||||
{
|
||||
_hwnd = window.GetWindowHandle();
|
||||
_preProcessor = preProcessor;
|
||||
_wndProcDelegate = WndProc;
|
||||
var ptr = Marshal.GetFunctionPointerForDelegate(_wndProcDelegate);
|
||||
_originalWndProc = SetWindowLongPtr(_hwnd, GwlWndProc, ptr);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns a disposable scope during which WM_DPICHANGED is suppressed.
|
||||
/// </summary>
|
||||
public SuppressScope Suppress() => new(this);
|
||||
|
||||
private nint WndProc(nint hwnd, uint uMsg, nuint wParam, nint lParam)
|
||||
{
|
||||
// Let external handler process first (e.g., hotkey messages)
|
||||
if (_preProcessor?.Invoke(uMsg, wParam, lParam) == true)
|
||||
{
|
||||
return 0;
|
||||
}
|
||||
|
||||
if (uMsg == WmDpiChanged && _suppressing)
|
||||
{
|
||||
return 0;
|
||||
}
|
||||
|
||||
return CallWindowProc(_originalWndProc, hwnd, uMsg, wParam, lParam);
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
if (_disposed)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_disposed = true;
|
||||
|
||||
// Restore original WndProc
|
||||
if (_originalWndProc != 0)
|
||||
{
|
||||
SetWindowLongPtr(_hwnd, GwlWndProc, _originalWndProc);
|
||||
_originalWndProc = 0;
|
||||
}
|
||||
|
||||
_wndProcDelegate = null;
|
||||
}
|
||||
|
||||
internal readonly struct SuppressScope : IDisposable
|
||||
{
|
||||
private readonly DpiSuppressor _owner;
|
||||
|
||||
internal SuppressScope(DpiSuppressor owner)
|
||||
{
|
||||
_owner = owner;
|
||||
_owner._suppressing = true;
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
_owner._suppressing = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -6,7 +6,6 @@ using System;
|
||||
using System.Runtime.InteropServices;
|
||||
using ManagedCommon;
|
||||
using Microsoft.PowerToys.Settings.UI.Library;
|
||||
using WinRT.Interop;
|
||||
|
||||
namespace PowerDisplay.Helpers
|
||||
{
|
||||
@@ -23,10 +22,6 @@ namespace PowerDisplay.Helpers
|
||||
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;
|
||||
|
||||
@@ -38,25 +33,37 @@ namespace PowerDisplay.Helpers
|
||||
|
||||
/// <summary>
|
||||
/// Initialize the hotkey service with a window handle.
|
||||
/// Must be called after window is created.
|
||||
/// Must be called after window is created and WndProcService is attached.
|
||||
/// </summary>
|
||||
/// <param name="window">The WinUI window to attach to.</param>
|
||||
public void Initialize(Microsoft.UI.Xaml.Window window)
|
||||
public void Initialize(nint hwnd)
|
||||
{
|
||||
_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
|
||||
_hwnd = hwnd;
|
||||
ReloadSettings();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Handle WM_HOTKEY messages. Called by WndProcService.
|
||||
/// </summary>
|
||||
/// <returns>True if the message was handled.</returns>
|
||||
public bool HandleMessage(uint uMsg, nuint wParam)
|
||||
{
|
||||
if (uMsg == WmHotkey && (int)wParam == HotkeyId)
|
||||
{
|
||||
try
|
||||
{
|
||||
_hotkeyAction?.Invoke();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Logger.LogError($"[HotkeyService] Hotkey action failed: {ex.Message}");
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Reload settings and re-register hotkey.
|
||||
/// Call this when settings change.
|
||||
@@ -119,25 +126,6 @@ namespace PowerDisplay.Helpers
|
||||
_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)
|
||||
@@ -150,7 +138,6 @@ namespace PowerDisplay.Helpers
|
||||
}
|
||||
|
||||
// P/Invoke constants
|
||||
private const int GwlWndProc = -4;
|
||||
private const uint WmHotkey = 0x0312;
|
||||
|
||||
// HOT_KEY_MODIFIERS flags
|
||||
@@ -160,12 +147,6 @@ namespace PowerDisplay.Helpers
|
||||
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);
|
||||
|
||||
@@ -144,44 +144,6 @@ namespace PowerDisplay.Helpers
|
||||
}
|
||||
}
|
||||
|
||||
/// <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>
|
||||
@@ -215,33 +177,6 @@ namespace PowerDisplay.Helpers
|
||||
(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>
|
||||
@@ -253,33 +188,6 @@ namespace PowerDisplay.Helpers
|
||||
(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>
|
||||
|
||||
@@ -250,7 +250,6 @@ namespace PowerDisplay.Helpers
|
||||
|
||||
break;
|
||||
case PInvoke.WM_LBUTTONUP:
|
||||
case PInvoke.WM_LBUTTONDBLCLK:
|
||||
_toggleWindowAction?.Invoke();
|
||||
break;
|
||||
}
|
||||
|
||||
@@ -3,9 +3,7 @@
|
||||
// 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;
|
||||
|
||||
@@ -26,6 +24,9 @@ namespace PowerDisplay.Helpers
|
||||
[return: MarshalAs(UnmanagedType.Bool)]
|
||||
private static partial bool GetCursorPos(out POINT lpPoint);
|
||||
|
||||
[LibraryImport("shcore.dll")]
|
||||
private static partial int GetDpiForMonitor(nint hMonitor, uint dpiType, out uint dpiX, out uint dpiY);
|
||||
|
||||
// Window Styles
|
||||
private const int GwlStyle = -16;
|
||||
private const int WsCaption = 0x00C00000;
|
||||
@@ -40,17 +41,11 @@ namespace PowerDisplay.Helpers
|
||||
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;
|
||||
private const uint MdtEffectiveDpi = 0;
|
||||
private const int DefaultDpi = 96;
|
||||
|
||||
// P/Invoke declarations (64-bit only - PowerToys only builds for x64/ARM64)
|
||||
[LibraryImport("user32.dll", EntryPoint = "GetWindowLongPtrW")]
|
||||
@@ -70,22 +65,6 @@ namespace PowerDisplay.Helpers
|
||||
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>
|
||||
@@ -123,54 +102,6 @@ namespace PowerDisplay.Helpers
|
||||
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>
|
||||
@@ -178,18 +109,39 @@ namespace PowerDisplay.Helpers
|
||||
/// <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;
|
||||
return (double)window.GetDpiForWindow() / DefaultDpi;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Convert device-independent units (DIU) to physical pixels
|
||||
/// Get the DPI scale factor for a display area (relative to standard 96 DPI)
|
||||
/// </summary>
|
||||
/// <param name="diu">Device-independent unit value</param>
|
||||
/// <param name="displayArea">Target display area</param>
|
||||
/// <returns>DPI scale factor (1.0 = 100%, 1.25 = 125%, 1.5 = 150%, 2.0 = 200%)</returns>
|
||||
public static double GetDpiScale(DisplayArea displayArea)
|
||||
{
|
||||
return (double)GetEffectiveDpi(global::Microsoft.UI.Win32Interop.GetMonitorFromDisplayId(displayArea.DisplayId)) / DefaultDpi;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Convert device-independent pixels (DIP) to physical pixels.
|
||||
/// </summary>
|
||||
/// <param name="dip">Device-independent pixel value</param>
|
||||
/// <param name="dpiScale">DPI scale factor</param>
|
||||
/// <returns>Physical pixel value</returns>
|
||||
public static int ScaleToPhysicalPixels(int diu, double dpiScale)
|
||||
public static int ScaleToPhysicalPixels(int dip, double dpiScale)
|
||||
{
|
||||
return (int)Math.Ceiling(diu * dpiScale);
|
||||
return (int)Math.Ceiling(dip * dpiScale);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Convert physical pixels to device-independent pixels (DIP).
|
||||
/// </summary>
|
||||
/// <param name="physicalPixels">Physical pixel value</param>
|
||||
/// <param name="dpiScale">DPI scale factor</param>
|
||||
/// <returns>Device-independent pixel value</returns>
|
||||
public static int ScaleToDip(int physicalPixels, double dpiScale)
|
||||
{
|
||||
return (int)Math.Floor(physicalPixels / dpiScale);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -200,62 +152,108 @@ namespace PowerDisplay.Helpers
|
||||
/// - 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>
|
||||
/// <param name="widthDip">Window width in device-independent pixels (DIP)</param>
|
||||
/// <param name="heightDip">Window height in device-independent pixels (DIP)</param>
|
||||
/// <param name="rightMarginDip">Right margin in device-independent pixels (DIP)</param>
|
||||
/// <param name="bottomMarginDip">Bottom margin in device-independent pixels (DIP)</param>
|
||||
public static void PositionWindowBottomRight(
|
||||
WindowEx window,
|
||||
int width,
|
||||
int height,
|
||||
int rightMargin = 0)
|
||||
int widthDip,
|
||||
int heightDip,
|
||||
int rightMarginDip = 0,
|
||||
int bottomMarginDip = 0)
|
||||
{
|
||||
// RectWork already includes correct offsets for taskbar position
|
||||
var monitors = MonitorInfo.GetDisplayMonitors();
|
||||
if (monitors == null || monitors.Count == 0)
|
||||
if (!TryGetDisplayAreaAtCursor(out var displayArea) || displayArea is null)
|
||||
{
|
||||
ManagedCommon.Logger.LogWarning("PositionWindowBottomRight: No monitors found, skipping positioning");
|
||||
ManagedCommon.Logger.LogWarning("PositionWindowBottomRight: Unable to determine target display from cursor, 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);
|
||||
MoveWindowBottomRight(window, displayArea, widthDip, heightDip, rightMarginDip, bottomMarginDip);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Get the monitor where the mouse cursor is currently located.
|
||||
/// Falls back to primary monitor if cursor position cannot be determined.
|
||||
/// Center a window within the specified display area's work area.
|
||||
/// </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)
|
||||
public static void CenterWindowOnDisplay(
|
||||
WindowEx window,
|
||||
DisplayArea displayArea,
|
||||
int widthDip,
|
||||
int heightDip)
|
||||
{
|
||||
// Try to get cursor position using Win32 API
|
||||
if (GetCursorPos(out var cursorPos))
|
||||
double dpiScale = GetDpiScale(displayArea);
|
||||
int w = ScaleToPhysicalPixels(widthDip, dpiScale);
|
||||
int h = ScaleToPhysicalPixels(heightDip, dpiScale);
|
||||
|
||||
// WorkArea relative to DisplayArea (accounts for taskbar position)
|
||||
var rel = GetWorkAreaRelativeToDisplay(displayArea);
|
||||
int x = rel.X + ((rel.Width - w) / 2);
|
||||
int y = rel.Y + ((rel.Height - h) / 2);
|
||||
|
||||
window.AppWindow.MoveAndResize(new Windows.Graphics.RectInt32(x, y, w, h), displayArea);
|
||||
}
|
||||
|
||||
private static void MoveWindowBottomRight(
|
||||
WindowEx window,
|
||||
DisplayArea displayArea,
|
||||
int widthDip,
|
||||
int heightDip,
|
||||
int rightMarginDip,
|
||||
int bottomMarginDip)
|
||||
{
|
||||
double dpiScale = GetDpiScale(displayArea);
|
||||
int w = ScaleToPhysicalPixels(widthDip, dpiScale);
|
||||
int h = ScaleToPhysicalPixels(heightDip, dpiScale);
|
||||
int marginRight = ScaleToPhysicalPixels(rightMarginDip, dpiScale);
|
||||
int marginBottom = ScaleToPhysicalPixels(bottomMarginDip, dpiScale);
|
||||
|
||||
// WorkArea relative to DisplayArea (accounts for taskbar position)
|
||||
var rel = GetWorkAreaRelativeToDisplay(displayArea);
|
||||
int x = rel.X + rel.Width - w - marginRight;
|
||||
int y = rel.Y + rel.Height - h - marginBottom;
|
||||
|
||||
window.AppWindow.MoveAndResize(new Windows.Graphics.RectInt32(x, y, w, h), displayArea);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Get the work area rectangle relative to the display area's origin.
|
||||
/// MoveAndResize(rect, displayArea) expects coordinates relative to the DisplayArea,
|
||||
/// but WorkArea.X/Y are in absolute screen coordinates, so we subtract the DisplayArea origin.
|
||||
/// The resulting rect describes where the usable area is within the display (e.g., offset by taskbar).
|
||||
/// </summary>
|
||||
private static Windows.Graphics.RectInt32 GetWorkAreaRelativeToDisplay(DisplayArea displayArea)
|
||||
{
|
||||
var outer = displayArea.OuterBounds;
|
||||
var work = displayArea.WorkArea;
|
||||
return new Windows.Graphics.RectInt32(
|
||||
work.X - outer.X,
|
||||
work.Y - outer.Y,
|
||||
work.Width,
|
||||
work.Height);
|
||||
}
|
||||
|
||||
internal static bool TryGetDisplayAreaAtCursor(out DisplayArea? displayArea)
|
||||
{
|
||||
displayArea = null;
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
// Fallback to first monitor (typically primary)
|
||||
return monitors[0];
|
||||
displayArea = DisplayArea.GetFromPoint(new Windows.Graphics.PointInt32(cursorPos.X, cursorPos.Y), DisplayAreaFallback.None);
|
||||
return displayArea is not null;
|
||||
}
|
||||
|
||||
private static int GetEffectiveDpi(nint hMonitor)
|
||||
{
|
||||
if (hMonitor == 0)
|
||||
{
|
||||
return DefaultDpi;
|
||||
}
|
||||
|
||||
var hr = GetDpiForMonitor(hMonitor, MdtEffectiveDpi, out var dpiX, out _);
|
||||
return hr >= 0 && dpiX > 0 ? (int)dpiX : DefaultDpi;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -22,6 +22,7 @@
|
||||
<ProjectPriFileName>PowerToys.PowerDisplay.pri</ProjectPriFileName>
|
||||
<AllowUnsafeBlocks>true</AllowUnsafeBlocks>
|
||||
<Nullable>enable</Nullable>
|
||||
<LangVersion>preview</LangVersion>
|
||||
<!-- Disable XAML-generated Main method, use custom Program.cs -->
|
||||
<DefineConstants>DISABLE_XAML_GENERATED_MAIN</DefineConstants>
|
||||
</PropertyGroup>
|
||||
@@ -33,6 +34,12 @@
|
||||
<TrimmerSingleWarn>false</TrimmerSingleWarn>
|
||||
<SuppressTrimAnalysisWarnings>false</SuppressTrimAnalysisWarnings>
|
||||
</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>
|
||||
<ItemGroup>
|
||||
<!-- Hide build log files from Solution Explorer -->
|
||||
<None Remove="*.log" />
|
||||
@@ -81,11 +88,13 @@
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<!-- Removed Common.UI dependency - SettingsDeepLink is now implemented locally -->
|
||||
<ProjectReference Include="..\..\..\common\GPOWrapper\GPOWrapper.vcxproj" />
|
||||
<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" />
|
||||
<ProjectReference Include="..\PowerDisplay.Models\PowerDisplay.Models.csproj" />
|
||||
</ItemGroup>
|
||||
<!-- Copy Assets folder to output directory -->
|
||||
<ItemGroup>
|
||||
|
||||
@@ -10,15 +10,20 @@
|
||||
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 Background="{ThemeResource SolidBackgroundFillColorBaseBrush}">
|
||||
<Grid.Resources>
|
||||
<Thickness x:Key="IdentifyWindowContentMargin">24</Thickness>
|
||||
</Grid.Resources>
|
||||
|
||||
<Viewbox Margin="{StaticResource IdentifyWindowContentMargin}" Stretch="Uniform">
|
||||
<TextBlock
|
||||
x:Name="NumberText"
|
||||
HorizontalAlignment="Center"
|
||||
VerticalAlignment="Center"
|
||||
FontWeight="Bold"
|
||||
Foreground="{ThemeResource TextFillColorPrimaryBrush}"
|
||||
Text="1"
|
||||
TextAlignment="Center" />
|
||||
</Viewbox>
|
||||
</Grid>
|
||||
</winuiex:WindowEx>
|
||||
|
||||
@@ -5,7 +5,8 @@
|
||||
using System;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.UI.Windowing;
|
||||
using Windows.Graphics;
|
||||
using PowerDisplay.Configuration;
|
||||
using PowerDisplay.Helpers;
|
||||
using WinUIEx;
|
||||
|
||||
namespace PowerDisplay.PowerDisplayXAML
|
||||
@@ -13,13 +14,9 @@ namespace PowerDisplay.PowerDisplayXAML
|
||||
/// <summary>
|
||||
/// Interaction logic for IdentifyWindow.xaml
|
||||
/// </summary>
|
||||
public sealed partial class IdentifyWindow : WindowEx
|
||||
public sealed partial class IdentifyWindow : WindowEx, IDisposable
|
||||
{
|
||||
// Window size in device-independent units (DIU)
|
||||
private const int WindowWidthDiu = 300;
|
||||
private const int WindowHeightDiu = 280;
|
||||
|
||||
private double _dpiScale = 1.0;
|
||||
private DpiSuppressor? _dpiSuppressor;
|
||||
|
||||
public IdentifyWindow(string displayText)
|
||||
{
|
||||
@@ -33,13 +30,13 @@ namespace PowerDisplay.PowerDisplayXAML
|
||||
{
|
||||
// WinUI will throw if explorer is not running, safely ignore
|
||||
}
|
||||
catch (Exception)
|
||||
{
|
||||
}
|
||||
|
||||
// Configure window style
|
||||
ConfigureWindow();
|
||||
|
||||
// Subclass WndProc to suppress WM_DPICHANGED during cross-DPI positioning
|
||||
_dpiSuppressor = new DpiSuppressor(this);
|
||||
|
||||
// Auto close after 3 seconds
|
||||
Task.Delay(3000).ContinueWith(_ =>
|
||||
{
|
||||
@@ -52,13 +49,8 @@ namespace PowerDisplay.PowerDisplayXAML
|
||||
|
||||
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 });
|
||||
// Set a preferred size in DIP. PositionOnDisplay will clamp it for the target monitor.
|
||||
this.SetWindowSize(AppConstants.UI.IdentifyWindowPreferredWidthDip, AppConstants.UI.IdentifyWindowPreferredHeightDip);
|
||||
this.IsAlwaysOnTop = true;
|
||||
}
|
||||
|
||||
@@ -66,19 +58,42 @@ namespace PowerDisplay.PowerDisplayXAML
|
||||
/// Position the window at the center of the specified display area
|
||||
/// </summary>
|
||||
public void PositionOnDisplay(DisplayArea displayArea)
|
||||
{
|
||||
var (windowWidthDip, windowHeightDip) = GetAdaptiveWindowSizeDip(displayArea);
|
||||
|
||||
// Suppress WM_DPICHANGED during MoveAndResize to prevent double-scaling
|
||||
// when positioning on a monitor with different DPI than the primary.
|
||||
using (_dpiSuppressor?.Suppress() ?? default)
|
||||
{
|
||||
WindowHelper.CenterWindowOnDisplay(this, displayArea, windowWidthDip, windowHeightDip);
|
||||
}
|
||||
}
|
||||
|
||||
private static (int WidthDip, int HeightDip) GetAdaptiveWindowSizeDip(DisplayArea displayArea)
|
||||
{
|
||||
var workArea = displayArea.WorkArea;
|
||||
double dpiScale = WindowHelper.GetDpiScale(displayArea);
|
||||
|
||||
// Window size in physical pixels (already scaled for DPI)
|
||||
int physicalWidth = (int)(WindowWidthDiu * _dpiScale);
|
||||
int physicalHeight = (int)(WindowHeightDiu * _dpiScale);
|
||||
int maxWidthDip = Math.Max(
|
||||
AppConstants.UI.IdentifyWindowMinWidthDip,
|
||||
WindowHelper.ScaleToDip((int)Math.Floor(workArea.Width * AppConstants.UI.IdentifyWindowMaxWorkAreaRatio), dpiScale));
|
||||
int maxHeightDip = Math.Max(
|
||||
AppConstants.UI.IdentifyWindowMinHeightDip,
|
||||
WindowHelper.ScaleToDip((int)Math.Floor(workArea.Height * AppConstants.UI.IdentifyWindowMaxWorkAreaRatio), 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);
|
||||
int widthDip = Math.Max(
|
||||
AppConstants.UI.IdentifyWindowMinWidthDip,
|
||||
Math.Min(AppConstants.UI.IdentifyWindowPreferredWidthDip, maxWidthDip));
|
||||
int heightDip = Math.Max(
|
||||
AppConstants.UI.IdentifyWindowMinHeightDip,
|
||||
Math.Min(AppConstants.UI.IdentifyWindowPreferredHeightDip, maxHeightDip));
|
||||
|
||||
// Use WindowEx's AppWindow property
|
||||
this.AppWindow.Move(new PointInt32(x, y));
|
||||
return (widthDip, heightDip);
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
_dpiSuppressor?.Dispose();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,8 +5,7 @@
|
||||
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:models="using:PowerDisplay.Models"
|
||||
xmlns:ui="using:CommunityToolkit.WinUI"
|
||||
xmlns:vm="using:PowerDisplay.ViewModels"
|
||||
xmlns:winuiex="using:WinUIEx"
|
||||
@@ -25,13 +24,41 @@
|
||||
IsTabStop="True"
|
||||
TabFocusNavigation="Local">
|
||||
<Grid.Resources>
|
||||
<GridLength x:Key="FlyoutStatusBarHeight">48</GridLength>
|
||||
<x:Double x:Key="FlyoutSeparatorHeight">1</x:Double>
|
||||
<GridLength x:Key="FlyoutSliderIconColumnWidth">20</GridLength>
|
||||
<x:Double x:Key="FlyoutActionGlyphFontSize">16</x:Double>
|
||||
<x:Double x:Key="FlyoutActionGlyphSmallFontSize">14</x:Double>
|
||||
<x:Double x:Key="FlyoutActionGlyphExtraSmallFontSize">12</x:Double>
|
||||
<x:Double x:Key="FlyoutSecondaryTextFontSize">12</x:Double>
|
||||
<x:Double x:Key="FlyoutRotationGlyphFontSize">14</x:Double>
|
||||
<x:Double x:Key="FlyoutButtonSize">32</x:Double>
|
||||
<x:Double x:Key="FlyoutCompactSpacing">4</x:Double>
|
||||
<x:Double x:Key="FlyoutStandardSpacing">8</x:Double>
|
||||
<x:Double x:Key="FlyoutMonitorSectionSpacing">32</x:Double>
|
||||
<x:Double x:Key="FlyoutScanningIndicatorSize">24</x:Double>
|
||||
<x:Double x:Key="FlyoutScanningSpacing">16</x:Double>
|
||||
<Thickness x:Key="FlyoutButtonPadding">6</Thickness>
|
||||
<Thickness x:Key="FlyoutScanningMargin">0,16,0,16</Thickness>
|
||||
<Thickness x:Key="FlyoutContentPadding">16</Thickness>
|
||||
<Thickness x:Key="FlyoutListHeaderMargin">16,0,8,0</Thickness>
|
||||
<Thickness x:Key="FlyoutListItemPadding">0,4</Thickness>
|
||||
<Thickness x:Key="FlyoutSelectionGlyphMargin">8,0,0,0</Thickness>
|
||||
<Thickness x:Key="FlyoutSectionTopMargin">0,4,0,0</Thickness>
|
||||
<Thickness x:Key="FlyoutSectionContainerPadding">8,0,8,8</Thickness>
|
||||
<Thickness x:Key="FlyoutSeparatorMargin">8,8</Thickness>
|
||||
<Thickness x:Key="FlyoutStatusBarMargin">4,0,4,0</Thickness>
|
||||
<Thickness x:Key="MonitorHeaderOffsetMargin">10,0,0,0</Thickness>
|
||||
<Thickness x:Key="MonitorNameBaselineMargin">2,0,0,2</Thickness>
|
||||
<x:Double x:Key="FlyoutContextMenuMinWidth">240</x:Double>
|
||||
<x:Double x:Key="FlyoutContextMenuMaxWidth">320</x:Double>
|
||||
<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="Padding" Value="{StaticResource FlyoutButtonPadding}" />
|
||||
<Setter Property="Width" Value="{StaticResource FlyoutButtonSize}" />
|
||||
<Setter Property="Height" Value="{StaticResource FlyoutButtonSize}" />
|
||||
<Setter Property="FontFamily" Value="{ThemeResource SymbolThemeFontFamily}" />
|
||||
</Style>
|
||||
</Grid.Resources>
|
||||
@@ -39,7 +66,7 @@
|
||||
<Grid>
|
||||
<Grid.RowDefinitions>
|
||||
<RowDefinition Height="Auto" />
|
||||
<RowDefinition Height="48" />
|
||||
<RowDefinition Height="{StaticResource FlyoutStatusBarHeight}" />
|
||||
</Grid.RowDefinitions>
|
||||
|
||||
<!-- Main Content Area with modern design -->
|
||||
@@ -50,22 +77,21 @@
|
||||
BorderThickness="0,0,0,1">
|
||||
<Grid>
|
||||
<StackPanel
|
||||
Margin="0,16,0,16"
|
||||
Margin="{StaticResource FlyoutScanningMargin}"
|
||||
HorizontalAlignment="Center"
|
||||
VerticalAlignment="Center"
|
||||
Orientation="Vertical"
|
||||
Spacing="16"
|
||||
Spacing="{StaticResource FlyoutScanningSpacing}"
|
||||
Visibility="{x:Bind helpers:VisibilityConverter.BoolToVisibility(ViewModel.IsScanning), Mode=OneWay}">
|
||||
<ProgressRing
|
||||
Width="24"
|
||||
Height="24"
|
||||
Width="{StaticResource FlyoutScanningIndicatorSize}"
|
||||
Height="{StaticResource FlyoutScanningIndicatorSize}"
|
||||
Foreground="{ThemeResource AccentFillColorDefaultBrush}"
|
||||
IsActive="True" />
|
||||
<TextBlock
|
||||
x:Name="ScanningMonitorsTextBlock"
|
||||
x:Uid="ScanningMonitorsText"
|
||||
HorizontalAlignment="Center"
|
||||
FontSize="12"
|
||||
FontSize="{StaticResource FlyoutSecondaryTextFontSize}"
|
||||
Foreground="{ThemeResource TextFillColorSecondaryBrush}"
|
||||
TextAlignment="Center" />
|
||||
</StackPanel>
|
||||
@@ -77,94 +103,188 @@
|
||||
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}" />
|
||||
Severity="Informational" />
|
||||
|
||||
<!-- Content Area -->
|
||||
<ScrollViewer
|
||||
x:Name="MainScrollViewer"
|
||||
Padding="16,16,16,16"
|
||||
VerticalAlignment="Stretch"
|
||||
Padding="{StaticResource FlyoutContentPadding}"
|
||||
HorizontalScrollBarVisibility="Disabled"
|
||||
HorizontalScrollMode="Disabled"
|
||||
IsTabStop="False"
|
||||
VerticalScrollBarVisibility="Auto"
|
||||
ZoomMode="Disabled">
|
||||
VerticalScrollBarVisibility="Auto">
|
||||
<!-- 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" />
|
||||
<StackLayout Orientation="Vertical" Spacing="{StaticResource FlyoutMonitorSectionSpacing}" />
|
||||
</ItemsRepeater.Layout>
|
||||
<ItemsRepeater.ItemTemplate>
|
||||
<DataTemplate x:DataType="vm:MonitorViewModel">
|
||||
<StackPanel
|
||||
HorizontalAlignment="Stretch"
|
||||
Spacing="4"
|
||||
TabFocusNavigation="Local">
|
||||
<StackPanel Spacing="{StaticResource FlyoutCompactSpacing}" TabFocusNavigation="Local">
|
||||
<!-- Monitor Name with Icon -->
|
||||
<Grid Margin="2,0,0,0" HorizontalAlignment="Stretch">
|
||||
<Grid
|
||||
Margin="{StaticResource MonitorHeaderOffsetMargin}"
|
||||
HorizontalAlignment="Stretch"
|
||||
ColumnSpacing="{StaticResource FlyoutStandardSpacing}">
|
||||
<Grid.ColumnDefinitions>
|
||||
<ColumnDefinition Width="22" />
|
||||
<ColumnDefinition Width="16" />
|
||||
<!-- Spacing -->
|
||||
<ColumnDefinition Width="Auto" />
|
||||
<ColumnDefinition Width="*" />
|
||||
<ColumnDefinition Width="Auto" />
|
||||
</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"
|
||||
Grid.Column="1"
|
||||
Margin="{StaticResource MonitorNameBaselineMargin}"
|
||||
VerticalAlignment="Center"
|
||||
Text="{x:Bind DisplayName, Mode=OneWay}" />
|
||||
Text="{x:Bind DisplayName, Mode=OneWay}"
|
||||
TextTrimming="CharacterEllipsis"
|
||||
TextWrapping="NoWrap"
|
||||
ToolTipService.ToolTip="{x:Bind DisplayName, Mode=OneWay}" />
|
||||
<!-- Icon buttons for InputSource and ColorTemperature -->
|
||||
<StackPanel
|
||||
Grid.Column="2"
|
||||
HorizontalAlignment="Right"
|
||||
VerticalAlignment="Center"
|
||||
Orientation="Horizontal"
|
||||
Spacing="4">
|
||||
Spacing="{StaticResource FlyoutCompactSpacing}">
|
||||
<!-- Color Temperature Button -->
|
||||
<Button
|
||||
x:Uid="ColorTemperatureTooltip"
|
||||
Content="{ui:FontIcon Glyph=,
|
||||
FontSize=16}"
|
||||
FontSize={StaticResource FlyoutActionGlyphFontSize}}"
|
||||
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="{x:Bind HandleColorTemperatureSelectionChanged}"
|
||||
SelectionMode="Single">
|
||||
<ListView.Header>
|
||||
<TextBlock
|
||||
x:Uid="ColorTemperatureHeader"
|
||||
Margin="{StaticResource FlyoutListHeaderMargin}"
|
||||
FontSize="{StaticResource FlyoutSecondaryTextFontSize}"
|
||||
Foreground="{ThemeResource TextFillColorSecondaryBrush}" />
|
||||
</ListView.Header>
|
||||
<ListView.ItemTemplate>
|
||||
<DataTemplate x:DataType="vm:ColorTemperatureItem">
|
||||
<Grid Padding="{StaticResource FlyoutListItemPadding}">
|
||||
<Grid.ColumnDefinitions>
|
||||
<ColumnDefinition Width="*" />
|
||||
<ColumnDefinition Width="Auto" />
|
||||
</Grid.ColumnDefinitions>
|
||||
<TextBlock
|
||||
VerticalAlignment="Center"
|
||||
Text="{x:Bind DisplayName}"
|
||||
TextTrimming="CharacterEllipsis"
|
||||
TextWrapping="NoWrap" />
|
||||
<FontIcon
|
||||
Grid.Column="1"
|
||||
Margin="{StaticResource FlyoutSelectionGlyphMargin}"
|
||||
FontSize="{StaticResource FlyoutSecondaryTextFontSize}"
|
||||
Glyph=""
|
||||
Visibility="{x:Bind helpers:VisibilityConverter.BoolToVisibility(IsSelected)}" />
|
||||
</Grid>
|
||||
</DataTemplate>
|
||||
</ListView.ItemTemplate>
|
||||
</ListView>
|
||||
</Flyout>
|
||||
</Button.Flyout>
|
||||
</Button>
|
||||
<!-- More Button (Input Source + Power State) -->
|
||||
<Button
|
||||
x:Uid="MoreOptionsTooltip"
|
||||
Content="{ui:FontIcon Glyph=,
|
||||
FontSize={StaticResource FlyoutActionGlyphFontSize}}"
|
||||
Style="{StaticResource SubtleButtonStyle}"
|
||||
Visibility="{x:Bind helpers:VisibilityConverter.BoolToVisibility(ShowMoreButton), Mode=OneWay}">
|
||||
<Button.Flyout>
|
||||
<Flyout
|
||||
Opened="Flyout_Opened"
|
||||
Placement="BottomEdgeAlignedRight"
|
||||
ShouldConstrainToRootBounds="False">
|
||||
<StackPanel MinWidth="{StaticResource FlyoutContextMenuMinWidth}" MaxWidth="{StaticResource FlyoutContextMenuMaxWidth}">
|
||||
<!-- Input Source Section -->
|
||||
<ListView
|
||||
ItemsSource="{x:Bind AvailableColorPresets, Mode=OneWay}"
|
||||
SelectionChanged="ColorTemperatureListView_SelectionChanged"
|
||||
SelectionMode="Single">
|
||||
ItemsSource="{x:Bind AvailableInputSources, Mode=OneWay}"
|
||||
SelectionChanged="{x:Bind HandleInputSourceSelectionChanged}"
|
||||
SelectionMode="Single"
|
||||
Visibility="{x:Bind helpers:VisibilityConverter.BoolToVisibility(ShowInputSource), Mode=OneWay}">
|
||||
<ListView.Header>
|
||||
<TextBlock
|
||||
x:Uid="ColorTemperatureHeader"
|
||||
Margin="16,0,8,0"
|
||||
FontSize="12"
|
||||
x:Uid="InputSourceHeader"
|
||||
Margin="{StaticResource FlyoutListHeaderMargin}"
|
||||
FontSize="{StaticResource FlyoutSecondaryTextFontSize}"
|
||||
Foreground="{ThemeResource TextFillColorSecondaryBrush}" />
|
||||
</ListView.Header>
|
||||
<ListView.ItemTemplate>
|
||||
<DataTemplate x:DataType="vm:ColorTemperatureItem">
|
||||
<Grid Padding="0,4">
|
||||
<DataTemplate x:DataType="vm:InputSourceItem">
|
||||
<Grid Padding="{StaticResource FlyoutListItemPadding}">
|
||||
<Grid.ColumnDefinitions>
|
||||
<ColumnDefinition Width="*" />
|
||||
<ColumnDefinition Width="Auto" />
|
||||
</Grid.ColumnDefinitions>
|
||||
<TextBlock VerticalAlignment="Center" Text="{x:Bind DisplayName}" />
|
||||
<TextBlock
|
||||
VerticalAlignment="Center"
|
||||
Text="{x:Bind Name}"
|
||||
TextTrimming="CharacterEllipsis"
|
||||
TextWrapping="NoWrap" />
|
||||
<FontIcon
|
||||
Grid.Column="1"
|
||||
Margin="8,0,0,0"
|
||||
FontSize="12"
|
||||
Margin="{StaticResource FlyoutSelectionGlyphMargin}"
|
||||
FontSize="{StaticResource FlyoutSecondaryTextFontSize}"
|
||||
Glyph=""
|
||||
Visibility="{x:Bind helpers:VisibilityConverter.BoolToVisibility(IsSelected)}" />
|
||||
Visibility="{x:Bind SelectionVisibility}" />
|
||||
</Grid>
|
||||
</DataTemplate>
|
||||
</ListView.ItemTemplate>
|
||||
</ListView>
|
||||
<!-- Separator between Input Source and Power State -->
|
||||
<Border
|
||||
Height="{StaticResource FlyoutSeparatorHeight}"
|
||||
Margin="{StaticResource FlyoutSeparatorMargin}"
|
||||
Background="{ThemeResource DividerStrokeColorDefaultBrush}"
|
||||
Visibility="{x:Bind helpers:VisibilityConverter.BoolToVisibility(ShowSeparatorAfterInputSource), Mode=OneWay}" />
|
||||
<!-- Power State Section -->
|
||||
<ListView
|
||||
ItemsSource="{x:Bind AvailablePowerStates, Mode=OneWay}"
|
||||
SelectionChanged="{x:Bind HandlePowerStateSelectionChanged}"
|
||||
SelectionMode="Single"
|
||||
Visibility="{x:Bind helpers:VisibilityConverter.BoolToVisibility(ShowPowerState), Mode=OneWay}">
|
||||
<ListView.Header>
|
||||
<TextBlock
|
||||
x:Uid="PowerStateHeader"
|
||||
Margin="{StaticResource FlyoutListHeaderMargin}"
|
||||
FontSize="{StaticResource FlyoutSecondaryTextFontSize}"
|
||||
Foreground="{ThemeResource TextFillColorSecondaryBrush}" />
|
||||
</ListView.Header>
|
||||
<ListView.ItemTemplate>
|
||||
<DataTemplate x:DataType="vm:PowerStateItem">
|
||||
<Grid Padding="{StaticResource FlyoutListItemPadding}">
|
||||
<Grid.ColumnDefinitions>
|
||||
<ColumnDefinition Width="*" />
|
||||
<ColumnDefinition Width="Auto" />
|
||||
</Grid.ColumnDefinitions>
|
||||
<TextBlock
|
||||
VerticalAlignment="Center"
|
||||
Text="{x:Bind Name}"
|
||||
TextTrimming="CharacterEllipsis"
|
||||
TextWrapping="NoWrap" />
|
||||
<FontIcon
|
||||
Grid.Column="1"
|
||||
Margin="{StaticResource FlyoutSelectionGlyphMargin}"
|
||||
FontSize="{StaticResource FlyoutSecondaryTextFontSize}"
|
||||
Glyph=""
|
||||
Visibility="{x:Bind SelectionVisibility}" />
|
||||
</Grid>
|
||||
</DataTemplate>
|
||||
</ListView.ItemTemplate>
|
||||
@@ -173,101 +293,11 @@
|
||||
</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"
|
||||
Margin="{StaticResource FlyoutSectionTopMargin}"
|
||||
Padding="{StaticResource FlyoutSectionContainerPadding}"
|
||||
Background="{ThemeResource LayerOnAcrylicFillColorDefaultBrush}"
|
||||
BorderBrush="{ThemeResource CardStrokeColorDefaultBrush}"
|
||||
BorderThickness="1"
|
||||
@@ -275,190 +305,167 @@
|
||||
TabFocusNavigation="Local"
|
||||
XYFocusKeyboardNavigation="Enabled">
|
||||
<!-- Brightness Control -->
|
||||
<Grid Margin="0,8,0,0" HorizontalAlignment="Stretch">
|
||||
<Grid Margin="{StaticResource FlyoutSectionTopMargin}" ColumnSpacing="{StaticResource FlyoutStandardSpacing}">
|
||||
<Grid.ColumnDefinitions>
|
||||
<ColumnDefinition Width="16" />
|
||||
<ColumnDefinition Width="16" />
|
||||
<!-- Spacing -->
|
||||
<ColumnDefinition Width="{StaticResource FlyoutSliderIconColumnWidth}" />
|
||||
<ColumnDefinition Width="*" />
|
||||
</Grid.ColumnDefinitions>
|
||||
<FontIcon
|
||||
x:Uid="BrightnessTooltip"
|
||||
HorizontalAlignment="Center"
|
||||
VerticalAlignment="Center"
|
||||
AutomationProperties.AccessibilityView="Raw"
|
||||
FontSize="18"
|
||||
FontSize="{StaticResource FlyoutActionGlyphFontSize}"
|
||||
Glyph="" />
|
||||
<Slider
|
||||
x:Uid="BrightnessAutomation"
|
||||
Grid.Column="2"
|
||||
HorizontalAlignment="Stretch"
|
||||
Grid.Column="1"
|
||||
VerticalAlignment="Center"
|
||||
DataContext="{x:Bind}"
|
||||
IsEnabled="{x:Bind IsInteractionEnabled, Mode=OneWay}"
|
||||
IsTabStop="True"
|
||||
KeyUp="Slider_KeyUp"
|
||||
KeyUp="{x:Bind HandleBrightnessKeyUp}"
|
||||
Maximum="{x:Bind MaxBrightness, Mode=OneWay}"
|
||||
Minimum="{x:Bind MinBrightness, Mode=OneWay}"
|
||||
PointerCaptureLost="Slider_PointerCaptureLost"
|
||||
Tag="Brightness"
|
||||
PointerCaptureLost="{x:Bind HandleBrightnessPointerCaptureLost}"
|
||||
Value="{x:Bind Brightness, Mode=OneWay}" />
|
||||
</Grid>
|
||||
|
||||
<!-- Contrast Control -->
|
||||
<Grid
|
||||
Margin="0,8,0,0"
|
||||
HorizontalAlignment="Stretch"
|
||||
Margin="{StaticResource FlyoutSectionTopMargin}"
|
||||
ColumnSpacing="{StaticResource FlyoutStandardSpacing}"
|
||||
Visibility="{x:Bind helpers:VisibilityConverter.BoolToVisibility(ShowContrast), Mode=OneWay}">
|
||||
<Grid.ColumnDefinitions>
|
||||
<ColumnDefinition Width="16" />
|
||||
<ColumnDefinition Width="16" />
|
||||
<!-- Spacing -->
|
||||
<ColumnDefinition Width="{StaticResource FlyoutSliderIconColumnWidth}" />
|
||||
<ColumnDefinition Width="*" />
|
||||
</Grid.ColumnDefinitions>
|
||||
|
||||
<FontIcon
|
||||
x:Uid="ContrastTooltip"
|
||||
HorizontalAlignment="Center"
|
||||
VerticalAlignment="Center"
|
||||
AutomationProperties.AccessibilityView="Raw"
|
||||
FontSize="12"
|
||||
FontSize="{StaticResource FlyoutActionGlyphExtraSmallFontSize}"
|
||||
Glyph="" />
|
||||
|
||||
<Slider
|
||||
x:Uid="ContrastAutomation"
|
||||
Grid.Column="2"
|
||||
Grid.Column="1"
|
||||
VerticalAlignment="Center"
|
||||
DataContext="{x:Bind}"
|
||||
IsEnabled="{x:Bind IsInteractionEnabled, Mode=OneWay}"
|
||||
IsTabStop="True"
|
||||
KeyUp="Slider_KeyUp"
|
||||
KeyUp="{x:Bind HandleContrastKeyUp}"
|
||||
Maximum="100"
|
||||
Minimum="0"
|
||||
PointerCaptureLost="Slider_PointerCaptureLost"
|
||||
Tag="Contrast"
|
||||
PointerCaptureLost="{x:Bind HandleContrastPointerCaptureLost}"
|
||||
Value="{x:Bind ContrastPercent, Mode=OneWay}" />
|
||||
</Grid>
|
||||
|
||||
<!-- Volume Control -->
|
||||
<Grid
|
||||
Margin="0,8,0,0"
|
||||
HorizontalAlignment="Stretch"
|
||||
Margin="{StaticResource FlyoutSectionTopMargin}"
|
||||
ColumnSpacing="{StaticResource FlyoutStandardSpacing}"
|
||||
Visibility="{x:Bind helpers:VisibilityConverter.BoolToVisibility(ShowVolume), Mode=OneWay}">
|
||||
<Grid.ColumnDefinitions>
|
||||
<ColumnDefinition Width="16" />
|
||||
<ColumnDefinition Width="16" />
|
||||
<!-- Spacing -->
|
||||
<ColumnDefinition Width="{StaticResource FlyoutSliderIconColumnWidth}" />
|
||||
<ColumnDefinition Width="*" />
|
||||
</Grid.ColumnDefinitions>
|
||||
|
||||
<FontIcon
|
||||
x:Uid="VolumeTooltip"
|
||||
Margin="2,0,0,0"
|
||||
Margin="4,0,0,0"
|
||||
HorizontalAlignment="Center"
|
||||
VerticalAlignment="Center"
|
||||
AutomationProperties.AccessibilityView="Raw"
|
||||
FontSize="16"
|
||||
Glyph="" />
|
||||
FontSize="{StaticResource FlyoutActionGlyphFontSize}"
|
||||
Glyph="" />
|
||||
<Slider
|
||||
x:Uid="VolumeAutomation"
|
||||
Grid.Column="2"
|
||||
HorizontalAlignment="Stretch"
|
||||
Grid.Column="1"
|
||||
VerticalAlignment="Center"
|
||||
DataContext="{x:Bind}"
|
||||
IsEnabled="{x:Bind IsInteractionEnabled, Mode=OneWay}"
|
||||
IsTabStop="True"
|
||||
KeyUp="Slider_KeyUp"
|
||||
KeyUp="{x:Bind HandleVolumeKeyUp}"
|
||||
Maximum="{x:Bind MaxVolume, Mode=OneWay}"
|
||||
Minimum="{x:Bind MinVolume, Mode=OneWay}"
|
||||
PointerCaptureLost="Slider_PointerCaptureLost"
|
||||
Tag="Volume"
|
||||
PointerCaptureLost="{x:Bind HandleVolumePointerCaptureLost}"
|
||||
Value="{x:Bind Volume, Mode=OneWay}" />
|
||||
</Grid>
|
||||
|
||||
<!-- Rotation Controls -->
|
||||
<Grid
|
||||
Margin="0,8,0,0"
|
||||
HorizontalAlignment="Stretch"
|
||||
Margin="{StaticResource FlyoutSectionTopMargin}"
|
||||
ColumnSpacing="{StaticResource FlyoutStandardSpacing}"
|
||||
Visibility="{x:Bind helpers:VisibilityConverter.BoolToVisibility(ShowRotation), Mode=OneWay}">
|
||||
<Grid.ColumnDefinitions>
|
||||
<ColumnDefinition Width="16" />
|
||||
<ColumnDefinition Width="16" />
|
||||
<!-- Spacing -->
|
||||
<ColumnDefinition Width="{StaticResource FlyoutSliderIconColumnWidth}" />
|
||||
<ColumnDefinition Width="*" />
|
||||
</Grid.ColumnDefinitions>
|
||||
|
||||
<FontIcon
|
||||
x:Uid="RotationTooltip"
|
||||
HorizontalAlignment="Center"
|
||||
VerticalAlignment="Center"
|
||||
AutomationProperties.AccessibilityView="Raw"
|
||||
FontSize="16"
|
||||
FontSize="{StaticResource FlyoutActionGlyphSmallFontSize}"
|
||||
Glyph="" />
|
||||
|
||||
<Grid
|
||||
Grid.Column="2"
|
||||
<ComboBox
|
||||
Grid.Column="1"
|
||||
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
|
||||
IsEnabled="{x:Bind IsInteractionEnabled, Mode=OneWay}"
|
||||
SelectedIndex="{x:Bind SelectedRotationIndex, Mode=TwoWay}">
|
||||
<ComboBoxItem x:Uid="RotationOptionLandscape" />
|
||||
<ComboBoxItem x:Uid="RotationOptionPortrait" />
|
||||
<ComboBoxItem x:Uid="RotationOptionLandscapeFlipped" />
|
||||
<ComboBoxItem x:Uid="RotationOptionPortraitFlipped" />
|
||||
</ComboBox>
|
||||
<!-- Normal (0°) -->
|
||||
<!--<ToggleButton
|
||||
x:Uid="RotateNormalTooltip"
|
||||
Grid.Column="0"
|
||||
HorizontalAlignment="Stretch"
|
||||
Click="RotationButton_Click"
|
||||
DataContext="{x:Bind}"
|
||||
Click="{x:Bind HandleRotation0Click}"
|
||||
IsChecked="{x:Bind IsRotation0, Mode=OneWay}"
|
||||
IsEnabled="{x:Bind IsInteractionEnabled, Mode=OneWay}"
|
||||
IsTabStop="True"
|
||||
Tag="0">
|
||||
<FontIcon FontSize="14" Glyph="" />
|
||||
IsTabStop="True">
|
||||
<FontIcon FontSize="{StaticResource FlyoutRotationGlyphFontSize}" Glyph="" />
|
||||
</ToggleButton>
|
||||
<!-- Left (270°) -->
|
||||
-->
|
||||
<!-- Left (270°) -->
|
||||
<!--
|
||||
<ToggleButton
|
||||
x:Uid="RotateLeftTooltip"
|
||||
Grid.Column="2"
|
||||
Grid.Column="1"
|
||||
HorizontalAlignment="Stretch"
|
||||
Click="RotationButton_Click"
|
||||
DataContext="{x:Bind}"
|
||||
Click="{x:Bind HandleRotation3Click}"
|
||||
IsChecked="{x:Bind IsRotation3, Mode=OneWay}"
|
||||
IsEnabled="{x:Bind IsInteractionEnabled, Mode=OneWay}"
|
||||
IsTabStop="True"
|
||||
Tag="3">
|
||||
<FontIcon FontSize="14" Glyph="" />
|
||||
IsTabStop="True">
|
||||
<FontIcon FontSize="{StaticResource FlyoutRotationGlyphFontSize}" Glyph="" />
|
||||
</ToggleButton>
|
||||
<!-- Right (90°) -->
|
||||
-->
|
||||
<!-- Right (90°) -->
|
||||
<!--
|
||||
<ToggleButton
|
||||
x:Uid="RotateRightTooltip"
|
||||
Grid.Column="4"
|
||||
Grid.Column="2"
|
||||
HorizontalAlignment="Stretch"
|
||||
Click="RotationButton_Click"
|
||||
DataContext="{x:Bind}"
|
||||
Click="{x:Bind HandleRotation1Click}"
|
||||
IsChecked="{x:Bind IsRotation1, Mode=OneWay}"
|
||||
IsEnabled="{x:Bind IsInteractionEnabled, Mode=OneWay}"
|
||||
IsTabStop="True"
|
||||
Tag="1">
|
||||
<FontIcon FontSize="14" Glyph="" />
|
||||
IsTabStop="True">
|
||||
<FontIcon FontSize="{StaticResource FlyoutRotationGlyphFontSize}" Glyph="" />
|
||||
</ToggleButton>
|
||||
<!-- Inverted (180°) -->
|
||||
-->
|
||||
<!-- Inverted (180°) -->
|
||||
<!--
|
||||
<ToggleButton
|
||||
x:Uid="RotateInvertedTooltip"
|
||||
Grid.Column="6"
|
||||
Grid.Column="3"
|
||||
HorizontalAlignment="Stretch"
|
||||
Click="RotationButton_Click"
|
||||
DataContext="{x:Bind}"
|
||||
Click="{x:Bind HandleRotation2Click}"
|
||||
IsChecked="{x:Bind IsRotation2, Mode=OneWay}"
|
||||
IsEnabled="{x:Bind IsInteractionEnabled, Mode=OneWay}"
|
||||
IsTabStop="True"
|
||||
Tag="2">
|
||||
<FontIcon FontSize="14" Glyph="" />
|
||||
</ToggleButton>
|
||||
</Grid>
|
||||
IsTabStop="True">
|
||||
<FontIcon FontSize="{StaticResource FlyoutRotationGlyphFontSize}" Glyph="" />
|
||||
</ToggleButton>-->
|
||||
</Grid>
|
||||
</StackPanel>
|
||||
</StackPanel>
|
||||
@@ -469,72 +476,86 @@
|
||||
</Grid>
|
||||
</Border>
|
||||
|
||||
<Grid x:Name="StatusBar" Grid.Row="1">
|
||||
<Grid
|
||||
x:Name="StatusBar"
|
||||
Grid.Row="1"
|
||||
Padding="{StaticResource FlyoutStatusBarMargin}">
|
||||
<!-- Action Buttons -->
|
||||
<Grid.ColumnDefinitions>
|
||||
<ColumnDefinition Width="*" />
|
||||
<ColumnDefinition Width="Auto" />
|
||||
</Grid.ColumnDefinitions>
|
||||
<Button
|
||||
x:Name="ProfilesButton"
|
||||
x:Uid="ProfilesTooltip"
|
||||
Content="{ui:FontIcon Glyph=,
|
||||
FontSize={StaticResource FlyoutActionGlyphFontSize}}"
|
||||
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="{StaticResource FlyoutListHeaderMargin}"
|
||||
FontSize="{StaticResource FlyoutSecondaryTextFontSize}"
|
||||
Foreground="{ThemeResource TextFillColorSecondaryBrush}" />
|
||||
</ListView.Header>
|
||||
<ListView.ItemTemplate>
|
||||
<DataTemplate x:DataType="models:PowerDisplayProfile">
|
||||
<TextBlock
|
||||
Padding="{StaticResource FlyoutListItemPadding}"
|
||||
Text="{x:Bind Name}"
|
||||
TextTrimming="CharacterEllipsis"
|
||||
TextWrapping="NoWrap" />
|
||||
</DataTemplate>
|
||||
</ListView.ItemTemplate>
|
||||
</ListView>
|
||||
</Flyout>
|
||||
</Button.Flyout>
|
||||
</Button>
|
||||
<StackPanel
|
||||
Margin="0,0,8,8"
|
||||
Grid.Column="1"
|
||||
HorizontalAlignment="Right"
|
||||
VerticalAlignment="Center"
|
||||
Orientation="Horizontal"
|
||||
Spacing="8">
|
||||
Spacing="{StaticResource FlyoutStandardSpacing}">
|
||||
<Button
|
||||
x:Name="ProfilesButton"
|
||||
x:Uid="ProfilesTooltip"
|
||||
Content="{ui:FontIcon Glyph=,
|
||||
x:Uid="RefreshTooltip"
|
||||
Content="{ui:FontIcon Glyph=,
|
||||
FontSize=16}"
|
||||
Style="{StaticResource SubtleButtonStyle}"
|
||||
Visibility="{x:Bind helpers:VisibilityConverter.BoolToVisibility(ViewModel.ShowProfileSwitcherButton), Mode=OneWay}">
|
||||
Style="{StaticResource SubtleButtonStyle}">
|
||||
<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>
|
||||
<MenuFlyout>
|
||||
<MenuFlyoutItem
|
||||
x:Name="RefreshButton"
|
||||
x:Uid="RefreshText"
|
||||
Click="OnRefreshClick"
|
||||
Icon="{ui:FontIcon Glyph=,
|
||||
FontSize=16}" />
|
||||
<MenuFlyoutItem
|
||||
x:Name="IdentifyButton"
|
||||
x:Uid="IdentifyText"
|
||||
Command="{x:Bind ViewModel.IdentifyMonitorsCommand}"
|
||||
Icon="{ui:FontIcon Glyph=}"
|
||||
Visibility="{x:Bind helpers:VisibilityConverter.BoolToVisibility(ViewModel.ShowIdentifyMonitorsButton), Mode=OneWay}" />
|
||||
</MenuFlyout>
|
||||
</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 x:Name="SettingsAnimatedIcon">
|
||||
<AnimatedIcon.Source>
|
||||
<animatedVisuals:AnimatedSettingsVisualSource />
|
||||
</AnimatedIcon.Source>
|
||||
|
||||
@@ -4,18 +4,16 @@
|
||||
|
||||
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.Models;
|
||||
using PowerDisplay.ViewModels;
|
||||
using Windows.Graphics;
|
||||
using WinUIEx;
|
||||
@@ -32,6 +30,7 @@ namespace PowerDisplay
|
||||
private readonly SettingsUtils _settingsUtils = SettingsUtils.Default;
|
||||
private MainViewModel? _viewModel;
|
||||
private HotkeyService? _hotkeyService;
|
||||
private DpiSuppressor? _dpiSuppressor;
|
||||
|
||||
// Expose ViewModel as property for x:Bind
|
||||
public MainViewModel ViewModel => _viewModel ?? throw new InvalidOperationException("ViewModel not initialized");
|
||||
@@ -65,10 +64,15 @@ namespace PowerDisplay
|
||||
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");
|
||||
var hwnd = this.GetWindowHandle();
|
||||
_hotkeyService = new HotkeyService(_settingsUtils, ToggleWindow);
|
||||
_hotkeyService.Initialize(this);
|
||||
_hotkeyService.Initialize(hwnd);
|
||||
|
||||
// 6. Subclass WndProc for hotkey handling and DPI change suppression
|
||||
Logger.LogTrace("MainWindow constructor: Setting up DpiSuppressor");
|
||||
_dpiSuppressor = new DpiSuppressor(this, (uMsg, wParam, _) =>
|
||||
_hotkeyService?.HandleMessage(uMsg, wParam) == true);
|
||||
Logger.LogTrace("MainWindow constructor: HotkeyService initialized");
|
||||
|
||||
Logger.LogTrace("MainWindow constructor: Setting IsShownInSwitchers property");
|
||||
@@ -118,6 +122,7 @@ namespace PowerDisplay
|
||||
}
|
||||
|
||||
private bool _hasInitialized;
|
||||
private bool _isShowingWindow;
|
||||
|
||||
private void ShowError(string message)
|
||||
{
|
||||
@@ -126,10 +131,12 @@ namespace PowerDisplay
|
||||
|
||||
private void OnWindowActivated(object sender, WindowActivatedEventArgs args)
|
||||
{
|
||||
Logger.LogTrace($"OnWindowActivated: WindowActivationState={args.WindowActivationState}");
|
||||
Logger.LogTrace($"OnWindowActivated: WindowActivationState={args.WindowActivationState}, _isShowingWindow={_isShowingWindow}");
|
||||
|
||||
// Auto-hide window when it loses focus (deactivated)
|
||||
if (args.WindowActivationState == WindowActivationState.Deactivated)
|
||||
// But NOT during ShowWindow() sequence — the first Activate() for positioning
|
||||
// may briefly deactivate before the final Activate()/Show() brings it back.
|
||||
if (args.WindowActivationState == WindowActivationState.Deactivated && !_isShowingWindow)
|
||||
{
|
||||
Logger.LogInfo("OnWindowActivated: Window deactivated, hiding window");
|
||||
HideWindow();
|
||||
@@ -146,6 +153,7 @@ namespace PowerDisplay
|
||||
public void ShowWindow()
|
||||
{
|
||||
Logger.LogInfo($"ShowWindow: Called, _hasInitialized={_hasInitialized}");
|
||||
_isShowingWindow = true;
|
||||
try
|
||||
{
|
||||
// If not initialized, log warning but continue showing
|
||||
@@ -154,55 +162,36 @@ namespace PowerDisplay
|
||||
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");
|
||||
// Adjust size and position on the correct monitor
|
||||
// (DPI change suppression is handled inside AdjustWindowSizeToContent)
|
||||
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
|
||||
// Ensure window gets keyboard focus
|
||||
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");
|
||||
}
|
||||
Logger.LogInfo($"ShowWindow: Window shown successfully, visibility={IsWindowVisible()}");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Logger.LogError($"ShowWindow: Failed to show window: {ex.Message}\n{ex.StackTrace}");
|
||||
throw;
|
||||
}
|
||||
finally
|
||||
{
|
||||
_isShowingWindow = false;
|
||||
}
|
||||
}
|
||||
|
||||
public void HideWindow()
|
||||
@@ -323,9 +312,9 @@ namespace PowerDisplay
|
||||
// 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 });
|
||||
// Set a minimal initial window size in DIP - it will be adjusted before showing.
|
||||
// Using minimal height prevents the "large window shrinking" flicker.
|
||||
this.SetWindowSize(AppConstants.UI.WindowWidthDip, AppConstants.UI.WindowMinHeightDip);
|
||||
|
||||
// Position window at bottom right corner
|
||||
PositionWindowAtBottomRight();
|
||||
@@ -379,14 +368,26 @@ namespace PowerDisplay
|
||||
|
||||
// Force layout update and measure content height
|
||||
RootGrid.UpdateLayout();
|
||||
MainContainer?.Measure(new Windows.Foundation.Size(AppConstants.UI.WindowWidth, double.PositiveInfinity));
|
||||
MainContainer?.Measure(new Windows.Foundation.Size(AppConstants.UI.WindowWidthDip, double.PositiveInfinity));
|
||||
var contentHeight = (int)Math.Ceiling(MainContainer?.DesiredSize.Height ?? 0);
|
||||
var maxHeightDip = GetAdaptiveWindowMaxHeightDip();
|
||||
|
||||
// Apply min/max height limits and reposition (WindowEx handles DPI automatically)
|
||||
// Apply min/max height limits and reposition using DIP values.
|
||||
// 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);
|
||||
var finalHeightDip = Math.Min(contentHeight, maxHeightDip);
|
||||
Logger.LogTrace($"AdjustWindowSizeToContent: contentHeight={contentHeight}, maxHeightDip={maxHeightDip}, finalHeightDip={finalHeightDip}");
|
||||
|
||||
// Suppress WM_DPICHANGED during MoveAndResize to prevent double-scaling
|
||||
// when moving across monitors with different DPI settings.
|
||||
using (_dpiSuppressor?.Suppress() ?? default)
|
||||
{
|
||||
WindowHelper.PositionWindowBottomRight(
|
||||
this,
|
||||
AppConstants.UI.WindowWidthDip,
|
||||
finalHeightDip,
|
||||
AppConstants.UI.WindowRightMarginDip,
|
||||
AppConstants.UI.WindowBottomMarginDip);
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
@@ -394,16 +395,48 @@ namespace PowerDisplay
|
||||
}
|
||||
}
|
||||
|
||||
private static int GetAdaptiveWindowMaxHeightDip()
|
||||
{
|
||||
if (!WindowHelper.TryGetDisplayAreaAtCursor(out var displayArea) || displayArea is null)
|
||||
{
|
||||
return AppConstants.UI.WindowMaxHeightDip;
|
||||
}
|
||||
|
||||
double dpiScale = WindowHelper.GetDpiScale(displayArea);
|
||||
int workAreaHeightDip = WindowHelper.ScaleToDip(displayArea.WorkArea.Height, dpiScale);
|
||||
return (int)Math.Floor(workAreaHeightDip * AppConstants.UI.WindowMaxWorkAreaHeightRatio);
|
||||
}
|
||||
|
||||
private static double GetAdaptiveFlyoutMaxWidthDip()
|
||||
{
|
||||
if (!WindowHelper.TryGetDisplayAreaAtCursor(out var displayArea) || displayArea is null)
|
||||
{
|
||||
return AppConstants.UI.FlyoutContextMenuMaxWidthDip;
|
||||
}
|
||||
|
||||
double dpiScale = WindowHelper.GetDpiScale(displayArea);
|
||||
int workAreaWidthDip = WindowHelper.ScaleToDip(displayArea.WorkArea.Width, dpiScale);
|
||||
double adaptiveMaxWidthDip = Math.Floor(workAreaWidthDip * AppConstants.UI.FlyoutContextMenuMaxWorkAreaWidthRatio);
|
||||
|
||||
return Math.Max(
|
||||
AppConstants.UI.FlyoutContextMenuMaxWidthDip,
|
||||
Math.Min(AppConstants.UI.FlyoutContextMenuAdaptiveMaxWidthDip, adaptiveMaxWidthDip));
|
||||
}
|
||||
|
||||
private void PositionWindowAtBottomRight()
|
||||
{
|
||||
try
|
||||
{
|
||||
var windowSize = this.AppWindow.Size;
|
||||
var windowHeightDip = this.Height > 0
|
||||
? (int)Math.Ceiling(this.Height)
|
||||
: AppConstants.UI.WindowMinHeightDip;
|
||||
|
||||
WindowHelper.PositionWindowBottomRight(
|
||||
this, // MainWindow inherits from WindowEx
|
||||
AppConstants.UI.WindowWidth,
|
||||
windowSize.Height,
|
||||
AppConstants.UI.WindowRightMargin);
|
||||
AppConstants.UI.WindowWidthDip,
|
||||
windowHeightDip,
|
||||
AppConstants.UI.WindowRightMarginDip,
|
||||
AppConstants.UI.WindowBottomMarginDip);
|
||||
}
|
||||
catch (Exception)
|
||||
{
|
||||
@@ -411,207 +444,6 @@ namespace PowerDisplay
|
||||
}
|
||||
}
|
||||
|
||||
/// <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>
|
||||
@@ -643,44 +475,6 @@ namespace PowerDisplay
|
||||
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.
|
||||
@@ -689,6 +483,8 @@ namespace PowerDisplay
|
||||
{
|
||||
if (sender is Flyout flyout && flyout.Content is FrameworkElement content)
|
||||
{
|
||||
content.MaxWidth = GetAdaptiveFlyoutMaxWidthDip();
|
||||
|
||||
// Use DispatcherQueue to ensure the flyout content is fully rendered before setting focus
|
||||
DispatcherQueue.TryEnqueue(() =>
|
||||
{
|
||||
@@ -704,6 +500,7 @@ namespace PowerDisplay
|
||||
public void Dispose()
|
||||
{
|
||||
_hotkeyService?.Dispose();
|
||||
_dpiSuppressor?.Dispose();
|
||||
_viewModel?.Dispose();
|
||||
GC.SuppressFinalize(this);
|
||||
}
|
||||
|
||||
@@ -1,49 +1,55 @@
|
||||
<?xml version="1.0" encoding="utf-8" ?>
|
||||
<?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"
|
||||
AutomationProperties.AccessibilityView="Raw"
|
||||
IsTabStop="False"
|
||||
mc:Ignorable="d">
|
||||
|
||||
<UserControl.Resources>
|
||||
<x:Double x:Key="MonitorGlyphFontSize">22</x:Double>
|
||||
<x:Double x:Key="MonitorNumberFontSize">10</x:Double>
|
||||
<Thickness x:Key="ExternalMonitorNumberMargin">0,0,0,4</Thickness>
|
||||
<Thickness x:Key="BuiltInMonitorNumberMargin">0,0,0,6</Thickness>
|
||||
</UserControl.Resources>
|
||||
|
||||
<Grid>
|
||||
<Viewbox>
|
||||
<!-- Decorative badge: keep fixed text scaling for the glyph overlay and rely on adjacent DisplayName text for accessible monitor numbering. -->
|
||||
<Viewbox Width="16">
|
||||
<Grid>
|
||||
<Grid x:Name="MonitorGrid">
|
||||
<Grid x:Name="MonitorGrid" x:Uid="ExternalMonitorTooltip">
|
||||
<FontIcon
|
||||
x:Uid="MonitorTooltip"
|
||||
VerticalAlignment="Center"
|
||||
AutomationProperties.AccessibilityView="Raw"
|
||||
FontSize="22"
|
||||
FontSize="{StaticResource MonitorGlyphFontSize}"
|
||||
Glyph="" />
|
||||
<TextBlock
|
||||
Margin="0,0,0,4"
|
||||
Margin="{StaticResource ExternalMonitorNumberMargin}"
|
||||
HorizontalAlignment="Center"
|
||||
VerticalAlignment="Center"
|
||||
FontSize="10"
|
||||
FontSize="{StaticResource MonitorNumberFontSize}"
|
||||
FontWeight="SemiBold"
|
||||
IsTextScaleFactorEnabled="False"
|
||||
Text="{x:Bind MonitorNumber, Mode=OneWay}" />
|
||||
</Grid>
|
||||
|
||||
<Grid
|
||||
x:Name="BuiltInDisplayGrid"
|
||||
Padding="0,0,0,-4"
|
||||
x:Uid="BuiltInMonitorTooltip"
|
||||
Visibility="Collapsed">
|
||||
<FontIcon
|
||||
x:Uid="MonitorTooltip"
|
||||
Margin="0,0,0,0"
|
||||
VerticalAlignment="Center"
|
||||
AutomationProperties.AccessibilityView="Raw"
|
||||
FontSize="22"
|
||||
FontSize="{StaticResource MonitorGlyphFontSize}"
|
||||
Glyph="" />
|
||||
<TextBlock
|
||||
Margin="0,0,0,6"
|
||||
Margin="{StaticResource BuiltInMonitorNumberMargin}"
|
||||
HorizontalAlignment="Center"
|
||||
VerticalAlignment="Center"
|
||||
FontSize="10"
|
||||
FontSize="{StaticResource MonitorNumberFontSize}"
|
||||
FontWeight="SemiBold"
|
||||
IsTextScaleFactorEnabled="False"
|
||||
Text="{x:Bind MonitorNumber, Mode=OneWay}" />
|
||||
</Grid>
|
||||
</Grid>
|
||||
|
||||
@@ -34,6 +34,13 @@ namespace PowerDisplay
|
||||
// Initialize COM wrappers first (needed for AppInstance)
|
||||
WinRT.ComWrappersSupport.InitializeComWrappers();
|
||||
|
||||
// Exit before instance registration so a blocked launch cannot redirect
|
||||
// activation to an already-running instance.
|
||||
if (PowerToys.GPOWrapper.GPOWrapper.GetConfiguredPowerDisplayEnabledValue() == PowerToys.GPOWrapper.GpoRuleConfigured.Disabled)
|
||||
{
|
||||
return 0;
|
||||
}
|
||||
|
||||
// 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();
|
||||
|
||||
@@ -7,6 +7,7 @@ using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
using Microsoft.PowerToys.Settings.UI.Library;
|
||||
using PowerDisplay.Common.Models;
|
||||
using PowerDisplay.Models;
|
||||
|
||||
namespace PowerDisplay.Serialization
|
||||
{
|
||||
|
||||
@@ -24,8 +24,11 @@
|
||||
<data name="SettingsTooltip.ToolTipService.ToolTip" xml:space="preserve">
|
||||
<value>Settings</value>
|
||||
</data>
|
||||
<data name="MonitorTooltip.ToolTipService.ToolTip" xml:space="preserve">
|
||||
<data name="ExternalMonitorTooltip.ToolTipService.ToolTip" xml:space="preserve">
|
||||
<value>Monitor</value>
|
||||
</data>
|
||||
<data name="BuiltInMonitorTooltip.ToolTipService.ToolTip" xml:space="preserve">
|
||||
<value>Built-in display</value>
|
||||
</data>
|
||||
<data name="BrightnessTooltip.ToolTipService.ToolTip" xml:space="preserve">
|
||||
<value>Brightness</value>
|
||||
@@ -50,6 +53,18 @@
|
||||
</data>
|
||||
<data name="RotateInvertedTooltip.ToolTipService.ToolTip" xml:space="preserve">
|
||||
<value>Inverted (180°)</value>
|
||||
</data>
|
||||
<data name="RotationOptionLandscape.Content" xml:space="preserve">
|
||||
<value>Landscape</value>
|
||||
</data>
|
||||
<data name="RotationOptionPortrait.Content" xml:space="preserve">
|
||||
<value>Portrait</value>
|
||||
</data>
|
||||
<data name="RotationOptionLandscapeFlipped.Content" xml:space="preserve">
|
||||
<value>Landscape (flipped)</value>
|
||||
</data>
|
||||
<data name="RotationOptionPortraitFlipped.Content" xml:space="preserve">
|
||||
<value>Portrait (flipped)</value>
|
||||
</data>
|
||||
<data name="VolumeAutomation.[using:Microsoft.UI.Xaml.Automation]AutomationProperties.Name" xml:space="preserve">
|
||||
<value>Volume</value>
|
||||
@@ -72,8 +87,11 @@
|
||||
<data name="ProfilesTooltip.ToolTipService.ToolTip" xml:space="preserve">
|
||||
<value>Quick apply profiles</value>
|
||||
</data>
|
||||
<data name="IdentifyTooltip.ToolTipService.ToolTip" xml:space="preserve">
|
||||
<data name="IdentifyText.Text" xml:space="preserve">
|
||||
<value>Identify monitors</value>
|
||||
</data>
|
||||
<data name="RefreshText.Text" xml:space="preserve">
|
||||
<value>Refresh</value>
|
||||
</data>
|
||||
<data name="InputSourceHeader.Text" xml:space="preserve">
|
||||
<value>Input source</value>
|
||||
|
||||
@@ -12,6 +12,7 @@ using Microsoft.PowerToys.Telemetry;
|
||||
using PowerDisplay.Common.Models;
|
||||
using PowerDisplay.Common.Services;
|
||||
using PowerDisplay.Common.Utils;
|
||||
using PowerDisplay.Models;
|
||||
using PowerDisplay.Serialization;
|
||||
using PowerDisplay.Services;
|
||||
using PowerDisplay.Telemetry.Events;
|
||||
|
||||
@@ -5,13 +5,12 @@
|
||||
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.ComponentModel;
|
||||
using CommunityToolkit.Mvvm.Input;
|
||||
using ManagedCommon;
|
||||
using Microsoft.PowerToys.Settings.UI.Library;
|
||||
@@ -23,6 +22,7 @@ using PowerDisplay.Common.Drivers.DDC;
|
||||
using PowerDisplay.Common.Models;
|
||||
using PowerDisplay.Common.Services;
|
||||
using PowerDisplay.Helpers;
|
||||
using PowerDisplay.Models;
|
||||
using PowerDisplay.PowerDisplayXAML;
|
||||
|
||||
namespace PowerDisplay.ViewModels;
|
||||
@@ -35,7 +35,7 @@ namespace PowerDisplay.ViewModels;
|
||||
/// - MainViewModel.Settings.cs: Settings UI synchronization and profiles
|
||||
/// </summary>
|
||||
[DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicProperties | DynamicallyAccessedMemberTypes.PublicMethods)]
|
||||
public partial class MainViewModel : INotifyPropertyChanged, IDisposable
|
||||
public partial class MainViewModel : ObservableObject, IDisposable
|
||||
{
|
||||
[LibraryImport("user32.dll", EntryPoint = "GetMonitorInfoW", StringMarshalling = StringMarshalling.Utf16)]
|
||||
[return: MarshalAs(UnmanagedType.Bool)]
|
||||
@@ -48,12 +48,23 @@ public partial class MainViewModel : INotifyPropertyChanged, IDisposable
|
||||
private readonly MonitorStateManager _stateManager;
|
||||
private readonly DisplayChangeWatcher _displayChangeWatcher;
|
||||
|
||||
private ObservableCollection<MonitorViewModel> _monitors;
|
||||
private ObservableCollection<PowerDisplayProfile> _profiles;
|
||||
private bool _isScanning;
|
||||
[ObservableProperty]
|
||||
[NotifyPropertyChangedFor(nameof(HasMonitors))]
|
||||
[NotifyPropertyChangedFor(nameof(ShowNoMonitorsMessage))]
|
||||
[NotifyPropertyChangedFor(nameof(IsInteractionEnabled))]
|
||||
public partial bool IsScanning { get; set; }
|
||||
|
||||
private bool _isInitialized;
|
||||
private bool _isLoading;
|
||||
|
||||
[ObservableProperty]
|
||||
public partial ObservableCollection<MonitorViewModel> Monitors { get; set; }
|
||||
|
||||
[ObservableProperty]
|
||||
[NotifyPropertyChangedFor(nameof(HasProfiles))]
|
||||
[NotifyPropertyChangedFor(nameof(ShowProfileSwitcherButton))]
|
||||
public partial ObservableCollection<PowerDisplayProfile> Profiles { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Event triggered when UI refresh is requested due to settings changes
|
||||
/// </summary>
|
||||
@@ -69,9 +80,11 @@ public partial class MainViewModel : INotifyPropertyChanged, IDisposable
|
||||
{
|
||||
_dispatcherQueue = DispatcherQueue.GetForCurrentThread();
|
||||
_cancellationTokenSource = new CancellationTokenSource();
|
||||
_monitors = new ObservableCollection<MonitorViewModel>();
|
||||
_profiles = new ObservableCollection<PowerDisplayProfile>();
|
||||
_isScanning = true;
|
||||
Monitors = new ObservableCollection<MonitorViewModel>();
|
||||
Profiles = new ObservableCollection<PowerDisplayProfile>();
|
||||
IsScanning = true;
|
||||
ShowProfileSwitcher = true;
|
||||
ShowIdentifyMonitorsButton = true;
|
||||
|
||||
// Initialize settings utils
|
||||
_settingsUtils = SettingsUtils.Default;
|
||||
@@ -97,71 +110,21 @@ public partial class MainViewModel : INotifyPropertyChanged, IDisposable
|
||||
_ = 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;
|
||||
[ObservableProperty]
|
||||
[NotifyPropertyChangedFor(nameof(ShowProfileSwitcherButton))]
|
||||
public partial bool ShowProfileSwitcher { get; set; }
|
||||
|
||||
[ObservableProperty]
|
||||
public partial bool ShowIdentifyMonitorsButton { get; set; }
|
||||
|
||||
/// <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();
|
||||
}
|
||||
}
|
||||
}
|
||||
public bool ShowProfileSwitcherButton => ShowProfileSwitcher && HasProfiles;
|
||||
|
||||
// Custom VCP mappings - loaded from settings
|
||||
private List<CustomVcpValueMapping> _customVcpMappings = new();
|
||||
@@ -180,24 +143,6 @@ public partial class MainViewModel : INotifyPropertyChanged, IDisposable
|
||||
}
|
||||
}
|
||||
|
||||
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;
|
||||
@@ -285,8 +230,8 @@ public partial class MainViewModel : INotifyPropertyChanged, IDisposable
|
||||
|
||||
// Create and position identify window
|
||||
var identifyWindow = new IdentifyWindow(displayText);
|
||||
identifyWindow.PositionOnDisplay(displayArea);
|
||||
identifyWindow.Activate();
|
||||
identifyWindow.PositionOnDisplay(displayArea);
|
||||
windowsCreated++;
|
||||
}
|
||||
}
|
||||
@@ -305,13 +250,6 @@ public partial class MainViewModel : INotifyPropertyChanged, IDisposable
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
@@ -381,10 +319,10 @@ public partial class MainViewModel : INotifyPropertyChanged, IDisposable
|
||||
try
|
||||
{
|
||||
var profilesData = ProfileService.LoadProfiles();
|
||||
_profiles.Clear();
|
||||
Profiles.Clear();
|
||||
foreach (var profile in profilesData.Profiles)
|
||||
{
|
||||
_profiles.Add(profile);
|
||||
Profiles.Add(profile);
|
||||
}
|
||||
|
||||
OnPropertyChanged(nameof(HasProfiles));
|
||||
|
||||
@@ -6,16 +6,20 @@ 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.ComponentModel;
|
||||
using CommunityToolkit.Mvvm.Input;
|
||||
using ManagedCommon;
|
||||
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.Models;
|
||||
using Windows.System;
|
||||
using Monitor = PowerDisplay.Common.Models.Monitor;
|
||||
|
||||
namespace PowerDisplay.ViewModels;
|
||||
@@ -23,7 +27,7 @@ namespace PowerDisplay.ViewModels;
|
||||
/// <summary>
|
||||
/// ViewModel for individual monitor
|
||||
/// </summary>
|
||||
public partial class MonitorViewModel : INotifyPropertyChanged, IDisposable
|
||||
public partial class MonitorViewModel : ObservableObject, IDisposable
|
||||
{
|
||||
private readonly Monitor _monitor;
|
||||
private readonly MonitorManager _monitorManager;
|
||||
@@ -32,13 +36,27 @@ public partial class MonitorViewModel : INotifyPropertyChanged, IDisposable
|
||||
private int _brightness;
|
||||
private int _contrast;
|
||||
private int _volume;
|
||||
private bool _isAvailable;
|
||||
|
||||
[ObservableProperty]
|
||||
public partial bool IsAvailable { get; set; }
|
||||
|
||||
// Visibility settings (controlled by Settings UI)
|
||||
private bool _showContrast;
|
||||
private bool _showVolume;
|
||||
private bool _showInputSource;
|
||||
private bool _showRotation;
|
||||
[ObservableProperty]
|
||||
[NotifyPropertyChangedFor(nameof(HasAdvancedControls))]
|
||||
public partial bool ShowContrast { get; set; }
|
||||
|
||||
[ObservableProperty]
|
||||
[NotifyPropertyChangedFor(nameof(HasAdvancedControls))]
|
||||
public partial bool ShowVolume { get; set; }
|
||||
|
||||
[ObservableProperty]
|
||||
[NotifyPropertyChangedFor(nameof(ShowMoreButton))]
|
||||
[NotifyPropertyChangedFor(nameof(ShowSeparatorAfterInputSource))]
|
||||
public partial bool ShowInputSource { get; set; }
|
||||
|
||||
[ObservableProperty]
|
||||
public partial bool ShowRotation { get; set; }
|
||||
|
||||
private bool _showPowerState;
|
||||
|
||||
/// <summary>
|
||||
@@ -205,9 +223,9 @@ public partial class MonitorViewModel : INotifyPropertyChanged, IDisposable
|
||||
_monitor.PropertyChanged += OnMonitorPropertyChanged;
|
||||
|
||||
// Initialize Show properties based on hardware capabilities
|
||||
_showContrast = monitor.SupportsContrast;
|
||||
_showVolume = monitor.SupportsVolume;
|
||||
_showInputSource = monitor.SupportsInputSource;
|
||||
ShowContrast = monitor.SupportsContrast;
|
||||
ShowVolume = monitor.SupportsVolume;
|
||||
ShowInputSource = monitor.SupportsInputSource;
|
||||
_showPowerState = monitor.SupportsPowerState;
|
||||
_showColorTemperature = monitor.SupportsColorTemperature;
|
||||
|
||||
@@ -215,7 +233,7 @@ public partial class MonitorViewModel : INotifyPropertyChanged, IDisposable
|
||||
_brightness = monitor.CurrentBrightness;
|
||||
_contrast = monitor.CurrentContrast;
|
||||
_volume = monitor.CurrentVolume;
|
||||
_isAvailable = monitor.IsAvailable;
|
||||
IsAvailable = monitor.IsAvailable;
|
||||
}
|
||||
|
||||
public string Id => _monitor.Id;
|
||||
@@ -289,48 +307,6 @@ public partial class MonitorViewModel : INotifyPropertyChanged, IDisposable
|
||||
/// </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>
|
||||
@@ -369,22 +345,6 @@ public partial class MonitorViewModel : INotifyPropertyChanged, IDisposable
|
||||
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>
|
||||
@@ -410,6 +370,16 @@ public partial class MonitorViewModel : INotifyPropertyChanged, IDisposable
|
||||
/// </summary>
|
||||
public bool IsRotation3 => CurrentRotation == 3;
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the selected rotation index for binding to a ComboBox.
|
||||
/// Maps directly to <see cref="CurrentRotation"/>: 0=Landscape, 1=Portrait, 2=Landscape (flipped), 3=Portrait (flipped).
|
||||
/// </summary>
|
||||
public int SelectedRotationIndex
|
||||
{
|
||||
get => CurrentRotation;
|
||||
set => _ = SetRotationAsync(value);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Set rotation/orientation for this monitor.
|
||||
/// Note: MonitorManager.SetRotationAsync will refresh all monitors' orientations after success,
|
||||
@@ -468,8 +438,14 @@ public partial class MonitorViewModel : INotifyPropertyChanged, IDisposable
|
||||
/// 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);
|
||||
public string ColorTemperaturePresetName
|
||||
{
|
||||
get
|
||||
{
|
||||
var name = Common.Utils.VcpNames.GetValueName(0x14, _monitor.CurrentColorTemperature, _mainViewModel?.CustomVcpMappings, _monitor.Id);
|
||||
return name != null ? $"{name} (0x{_monitor.CurrentColorTemperature:X2})" : $"0x{_monitor.CurrentColorTemperature:X2}";
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets a value indicating whether this monitor supports color temperature via VCP 0x14
|
||||
@@ -549,7 +525,7 @@ public partial class MonitorViewModel : INotifyPropertyChanged, IDisposable
|
||||
_availableColorPresets = presetValues.Select(value => new ColorTemperatureItem
|
||||
{
|
||||
VcpValue = value,
|
||||
DisplayName = Common.Utils.VcpNames.GetFormattedValueName(0x14, value, _mainViewModel?.CustomVcpMappings, _monitor.Id),
|
||||
DisplayName = Common.Utils.VcpNames.GetValueName(0x14, value, _mainViewModel?.CustomVcpMappings, _monitor.Id) is string n ? $"{n} (0x{value:X2})" : $"0x{value:X2}",
|
||||
IsSelected = value == _monitor.CurrentColorTemperature,
|
||||
MonitorId = _monitor.Id,
|
||||
}).ToList();
|
||||
@@ -781,16 +757,6 @@ public partial class MonitorViewModel : INotifyPropertyChanged, IDisposable
|
||||
}
|
||||
}
|
||||
|
||||
public bool IsAvailable
|
||||
{
|
||||
get => _isAvailable;
|
||||
set
|
||||
{
|
||||
_isAvailable = value;
|
||||
OnPropertyChanged();
|
||||
}
|
||||
}
|
||||
|
||||
[RelayCommand]
|
||||
private void SetBrightness(int? brightness)
|
||||
{
|
||||
@@ -850,13 +816,6 @@ public partial class MonitorViewModel : INotifyPropertyChanged, IDisposable
|
||||
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))
|
||||
@@ -882,9 +841,125 @@ public partial class MonitorViewModel : INotifyPropertyChanged, IDisposable
|
||||
OnPropertyChanged(nameof(IsRotation1));
|
||||
OnPropertyChanged(nameof(IsRotation2));
|
||||
OnPropertyChanged(nameof(IsRotation3));
|
||||
OnPropertyChanged(nameof(SelectedRotationIndex));
|
||||
}
|
||||
}
|
||||
|
||||
// Slider commit handlers — fire hardware update only on pointer release or arrow key up
|
||||
public void HandleBrightnessPointerCaptureLost(object sender, PointerRoutedEventArgs e)
|
||||
{
|
||||
if (sender is Slider slider)
|
||||
{
|
||||
Brightness = (int)slider.Value;
|
||||
}
|
||||
}
|
||||
|
||||
public void HandleBrightnessKeyUp(object sender, KeyRoutedEventArgs e)
|
||||
{
|
||||
if (IsArrowKey(e.Key) && sender is Slider slider)
|
||||
{
|
||||
Brightness = (int)slider.Value;
|
||||
}
|
||||
}
|
||||
|
||||
public void HandleContrastPointerCaptureLost(object sender, PointerRoutedEventArgs e)
|
||||
{
|
||||
if (sender is Slider slider)
|
||||
{
|
||||
ContrastPercent = (int)slider.Value;
|
||||
}
|
||||
}
|
||||
|
||||
public void HandleContrastKeyUp(object sender, KeyRoutedEventArgs e)
|
||||
{
|
||||
if (IsArrowKey(e.Key) && sender is Slider slider)
|
||||
{
|
||||
ContrastPercent = (int)slider.Value;
|
||||
}
|
||||
}
|
||||
|
||||
public void HandleVolumePointerCaptureLost(object sender, PointerRoutedEventArgs e)
|
||||
{
|
||||
if (sender is Slider slider)
|
||||
{
|
||||
Volume = (int)slider.Value;
|
||||
}
|
||||
}
|
||||
|
||||
public void HandleVolumeKeyUp(object sender, KeyRoutedEventArgs e)
|
||||
{
|
||||
if (IsArrowKey(e.Key) && sender is Slider slider)
|
||||
{
|
||||
Volume = (int)slider.Value;
|
||||
}
|
||||
}
|
||||
|
||||
private static bool IsArrowKey(VirtualKey key) =>
|
||||
key == VirtualKey.Left || key == VirtualKey.Right ||
|
||||
key == VirtualKey.Up || key == VirtualKey.Down;
|
||||
|
||||
// Rotation button handlers — one per orientation to avoid Tag string parsing
|
||||
public async void HandleRotation0Click(object sender, RoutedEventArgs e) => await CommitRotationClickAsync(0);
|
||||
|
||||
public async void HandleRotation1Click(object sender, RoutedEventArgs e) => await CommitRotationClickAsync(1);
|
||||
|
||||
public async void HandleRotation2Click(object sender, RoutedEventArgs e) => await CommitRotationClickAsync(2);
|
||||
|
||||
public async void HandleRotation3Click(object sender, RoutedEventArgs e) => await CommitRotationClickAsync(3);
|
||||
|
||||
private async Task CommitRotationClickAsync(int orientation)
|
||||
{
|
||||
if (CurrentRotation == orientation)
|
||||
{
|
||||
// Force-notify to restore the ToggleButton checked state
|
||||
// (ToggleButton auto-unchecks on click; OneWay binding only re-pushes on change)
|
||||
OnPropertyChanged(nameof(IsRotation0));
|
||||
OnPropertyChanged(nameof(IsRotation1));
|
||||
OnPropertyChanged(nameof(IsRotation2));
|
||||
OnPropertyChanged(nameof(IsRotation3));
|
||||
return;
|
||||
}
|
||||
|
||||
await SetRotationAsync(orientation);
|
||||
}
|
||||
|
||||
// ListView selection handlers
|
||||
public async void HandleColorTemperatureSelectionChanged(object sender, SelectionChangedEventArgs e)
|
||||
{
|
||||
if (sender is not ListView listView || listView.SelectedItem is not ColorTemperatureItem item)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
await SetColorTemperatureAsync(item.VcpValue);
|
||||
listView.SelectedItem = null;
|
||||
}
|
||||
|
||||
public async void HandleInputSourceSelectionChanged(object sender, SelectionChangedEventArgs e)
|
||||
{
|
||||
if (sender is not ListView listView || listView.SelectedItem is not InputSourceItem item)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
await SetInputSourceAsync(item.Value);
|
||||
}
|
||||
|
||||
public async void HandlePowerStateSelectionChanged(object sender, SelectionChangedEventArgs e)
|
||||
{
|
||||
if (sender is not ListView listView || listView.SelectedItem is not PowerStateItem item)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (item.Value == PowerStateItem.PowerStateOn)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
await SetPowerStateAsync(item.Value);
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
// Unsubscribe from MainViewModel events
|
||||
|
||||
@@ -51,14 +51,25 @@ void PowerDisplayProcessManager::stop()
|
||||
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 (!m_enabled)
|
||||
{
|
||||
Logger::warn(L"Ignoring '{}' message because PowerDisplay is disabled", message_type);
|
||||
return;
|
||||
}
|
||||
|
||||
// If the process exited unexpectedly while the module is still enabled,
|
||||
// bring it back before delivering the message.
|
||||
if (!is_process_running())
|
||||
{
|
||||
m_enabled = true;
|
||||
refresh();
|
||||
|
||||
if (!is_process_running())
|
||||
{
|
||||
Logger::warn(L"PowerDisplay process is not running; '{}' message was not delivered", message_type);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
send_named_pipe_message(message_type, message_arg);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -70,7 +70,8 @@ public:
|
||||
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
|
||||
// Listener registration is tied to enable()/disable() so activation still
|
||||
// flows through the module's runtime enable/GPO checks.
|
||||
m_hToggleEvent = CreateDefaultEvent(CommonSharedConstants::TOGGLE_POWER_DISPLAY_EVENT);
|
||||
Logger::trace(L"Created TOGGLE_EVENT: handle={}", reinterpret_cast<void*>(m_hToggleEvent));
|
||||
|
||||
@@ -90,8 +91,6 @@ public:
|
||||
Logger::info(L"All Windows Events created successfully");
|
||||
}
|
||||
|
||||
// Start toggle event listener thread for Quick Access support
|
||||
StartToggleEventListener();
|
||||
}
|
||||
|
||||
~PowerDisplayModule()
|
||||
@@ -129,7 +128,7 @@ public:
|
||||
|
||||
void StartToggleEventListener()
|
||||
{
|
||||
if (!m_hToggleEvent || !m_hStopEvent)
|
||||
if (!m_hToggleEvent || !m_hStopEvent || m_toggleEventThread.joinable())
|
||||
{
|
||||
return;
|
||||
}
|
||||
@@ -152,7 +151,7 @@ public:
|
||||
if (result == WAIT_OBJECT_0 + TOGGLE_EVENT_INDEX)
|
||||
{
|
||||
Logger::trace(L"Toggle event received");
|
||||
TogglePowerDisplay();
|
||||
TryTogglePowerDisplay(L"Toggle event");
|
||||
}
|
||||
else if (result == WAIT_OBJECT_0 + STOP_EVENT_INDEX)
|
||||
{
|
||||
@@ -172,6 +171,34 @@ public:
|
||||
});
|
||||
}
|
||||
|
||||
bool IsActivationAllowed(const wchar_t* source)
|
||||
{
|
||||
if (gpo_policy_enabled_configuration() == powertoys_gpo::gpo_rule_configured_t::gpo_rule_configured_disabled)
|
||||
{
|
||||
Logger::warn(L"{} ignored because PowerDisplay is disabled by GPO", source);
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!m_enabled)
|
||||
{
|
||||
Logger::info(L"{} ignored because PowerDisplay module is disabled", source);
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
bool TrySendMessage(const std::wstring& message_type, const std::wstring& message_arg, const wchar_t* source)
|
||||
{
|
||||
if (!IsActivationAllowed(source))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
m_processManager.send_message(message_type, message_arg);
|
||||
return true;
|
||||
}
|
||||
|
||||
void StopToggleEventListener()
|
||||
{
|
||||
if (m_hStopEvent)
|
||||
@@ -191,8 +218,13 @@ public:
|
||||
/// 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()
|
||||
bool TryTogglePowerDisplay(const wchar_t* source)
|
||||
{
|
||||
if (!IsActivationAllowed(source))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (m_processManager.is_running())
|
||||
{
|
||||
// Process running - launch to trigger single instance redirect, OnActivated will toggle
|
||||
@@ -208,6 +240,7 @@ public:
|
||||
m_processManager.send_message(CommonSharedConstants::POWER_DISPLAY_TOGGLE_MESSAGE);
|
||||
}
|
||||
Trace::ActivatePowerDisplay();
|
||||
return true;
|
||||
}
|
||||
|
||||
virtual void destroy() override
|
||||
@@ -251,7 +284,7 @@ public:
|
||||
if (action_object.get_name() == L"Launch")
|
||||
{
|
||||
Logger::trace(L"Launch action received");
|
||||
TogglePowerDisplay();
|
||||
TryTogglePowerDisplay(L"Launch action");
|
||||
}
|
||||
else if (action_object.get_name() == L"RefreshMonitors")
|
||||
{
|
||||
@@ -274,7 +307,7 @@ public:
|
||||
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);
|
||||
TrySendMessage(CommonSharedConstants::POWER_DISPLAY_APPLY_PROFILE_MESSAGE, profileName, L"ApplyProfile action");
|
||||
}
|
||||
}
|
||||
catch (std::exception&)
|
||||
@@ -297,6 +330,8 @@ public:
|
||||
m_enabled = true;
|
||||
Trace::EnablePowerDisplay(true);
|
||||
|
||||
StartToggleEventListener();
|
||||
|
||||
// Start the process manager (launches PowerDisplay.exe with Named Pipe)
|
||||
m_processManager.start();
|
||||
|
||||
@@ -307,13 +342,12 @@ public:
|
||||
{
|
||||
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;
|
||||
|
||||
StopToggleEventListener();
|
||||
|
||||
// Stop the process manager (sends terminate message and waits for exit)
|
||||
m_processManager.stop();
|
||||
Trace::EnablePowerDisplay(false);
|
||||
}
|
||||
|
||||
|
||||
@@ -286,7 +286,7 @@ int runner(bool isProcessElevated, bool openSettings, std::string settingsWindow
|
||||
L"PowerToys.CmdPalModuleInterface.dll",
|
||||
L"PowerToys.ZoomItModuleInterface.dll",
|
||||
L"PowerToys.LightSwitchModuleInterface.dll",
|
||||
// L"PowerToys.PowerDisplayModuleInterface.dll", // TEMPORARILY_DISABLED: PowerDisplay
|
||||
L"PowerToys.PowerDisplayModuleInterface.dll",
|
||||
};
|
||||
|
||||
for (auto moduleSubdir : knownModules)
|
||||
|
||||
@@ -77,12 +77,6 @@ public sealed class AllAppsViewModel : Observable
|
||||
continue;
|
||||
}
|
||||
|
||||
// TEMPORARILY_DISABLED: PowerDisplay
|
||||
if (moduleType == ModuleType.PowerDisplay)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
_allFlyoutMenuItems.Add(new FlyoutMenuItem
|
||||
{
|
||||
Tag = moduleType,
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user