Compare commits

..

7 Commits

Author SHA1 Message Date
vanzue
e52f85faec Tidy up install logic to block cases that may introduce problems 2026-02-10 17:30:45 +08:00
Jiří Polášek
521d34f1eb CmdPal: Add a button to toggle the visibility of the dev ribbon (#45379)
## Summary of the Pull Request

This PR adds an option to toggle visibility of the dev ribbon. The state
is only temporary and does not persist across app runs -- that is
intentional to prevent the dev from turning it off permanently.

<img width="648" height="519" alt="image"
src="https://github.com/user-attachments/assets/c5b348f6-8f3c-4ec2-a250-e051fd003e09"
/>

<img width="1313" height="751" alt="image"
src="https://github.com/user-attachments/assets/89928e30-b0dc-4a14-b5fb-bf68357a145b"
/>


<!-- Please review the items on the PR checklist before submitting-->
## PR Checklist

- [x] Closes: #45371
<!-- - [ ] Closes: #yyy (add separate lines for additional resolved
issues) -->
- [ ] **Communication:** I've discussed this with core contributors
already. If the work hasn't been agreed, this work might be rejected
- [ ] **Tests:** Added/updated and all pass
- [ ] **Localization:** All end-user-facing strings can be localized
- [ ] **Dev docs:** Added/updated
- [ ] **New binaries:** Added on the required places
- [ ] [JSON for
signing](https://github.com/microsoft/PowerToys/blob/main/.pipelines/ESRPSigning_core.json)
for new binaries
- [ ] [WXS for
installer](https://github.com/microsoft/PowerToys/blob/main/installer/PowerToysSetup/Product.wxs)
for new binaries and localization folder
- [ ] [YML for CI
pipeline](https://github.com/microsoft/PowerToys/blob/main/.pipelines/ci/templates/build-powertoys-steps.yml)
for new test projects
- [ ] [YML for signed
pipeline](https://github.com/microsoft/PowerToys/blob/main/.pipelines/release.yml)
- [ ] **Documentation updated:** If checked, please file a pull request
on [our docs
repo](https://github.com/MicrosoftDocs/windows-uwp/tree/docs/hub/powertoys)
and link it here: #xxx

<!-- Provide a more detailed description of the PR, other things fixed,
or any additional comments/features here -->
## Detailed Description of the Pull Request / Additional comments

<!-- Describe how you validated the behavior. Add automated tests
wherever possible, but list manual validation steps taken as well -->
## Validation Steps Performed
2026-02-09 18:15:22 -06:00
Jiří Polášek
a02a5a9736 CmdPal: Improvements and fixes for icon loading (#45460)
## Summary of the Pull Request

This PR is a follow-up for Icon cache:

- Adds decoding and rasterization limit (by width) to reduce memory
usage and improves throughput (noticeably)
- Fixes timing issue when setting padding for font icons
- Resolves race condition in IconBox caused by incorrect guard

<!-- Please review the items on the PR checklist before submitting-->
## PR Checklist

- [x] Closes: #45460
<!-- - [ ] Closes: #yyy (add separate lines for additional resolved
issues) -->
- [ ] **Communication:** I've discussed this with core contributors
already. If the work hasn't been agreed, this work might be rejected
- [ ] **Tests:** Added/updated and all pass
- [ ] **Localization:** All end-user-facing strings can be localized
- [ ] **Dev docs:** Added/updated
- [ ] **New binaries:** Added on the required places
- [ ] [JSON for
signing](https://github.com/microsoft/PowerToys/blob/main/.pipelines/ESRPSigning_core.json)
for new binaries
- [ ] [WXS for
installer](https://github.com/microsoft/PowerToys/blob/main/installer/PowerToysSetup/Product.wxs)
for new binaries and localization folder
- [ ] [YML for CI
pipeline](https://github.com/microsoft/PowerToys/blob/main/.pipelines/ci/templates/build-powertoys-steps.yml)
for new test projects
- [ ] [YML for signed
pipeline](https://github.com/microsoft/PowerToys/blob/main/.pipelines/release.yml)
- [ ] **Documentation updated:** If checked, please file a pull request
on [our docs
repo](https://github.com/MicrosoftDocs/windows-uwp/tree/docs/hub/powertoys)
and link it here: #xxx

<!-- Provide a more detailed description of the PR, other things fixed,
or any additional comments/features here -->
## Detailed Description of the Pull Request / Additional comments

<!-- Describe how you validated the behavior. Add automated tests
wherever possible, but list manual validation steps taken as well -->
## Validation Steps Performed
2026-02-09 14:06:39 -06:00
Jiří Polášek
1e25d17920 CmdPal: ApplicationInfoService + fix version in the error report builder (#45374)
## Summary of the Pull Request

This PR introduces a new service, ApplicationInfoService, that
encapsulates host app state and infrastructure and moves the logic for
obtaining version information there. It then uses this information to
fix an issue with incorrect version reporting for unpackaged CmdPal in
ErrorReportBuilder.

- Adds ApplicationInfoService.
- Fixes an error in ErrorReportBuilder when the app runs unpackaged.
- Adds logging of the app version and environmental info at startup.

<!-- Please review the items on the PR checklist before submitting-->
## PR Checklist

- [x] Closes: #45368
- [x] Closes: #45370
<!-- - [ ] Closes: #yyy (add separate lines for additional resolved
issues) -->
- [ ] **Communication:** I've discussed this with core contributors
already. If the work hasn't been agreed, this work might be rejected
- [ ] **Tests:** Added/updated and all pass
- [ ] **Localization:** All end-user-facing strings can be localized
- [ ] **Dev docs:** Added/updated
- [ ] **New binaries:** Added on the required places
- [ ] [JSON for
signing](https://github.com/microsoft/PowerToys/blob/main/.pipelines/ESRPSigning_core.json)
for new binaries
- [ ] [WXS for
installer](https://github.com/microsoft/PowerToys/blob/main/installer/PowerToysSetup/Product.wxs)
for new binaries and localization folder
- [ ] [YML for CI
pipeline](https://github.com/microsoft/PowerToys/blob/main/.pipelines/ci/templates/build-powertoys-steps.yml)
for new test projects
- [ ] [YML for signed
pipeline](https://github.com/microsoft/PowerToys/blob/main/.pipelines/release.yml)
- [ ] **Documentation updated:** If checked, please file a pull request
on [our docs
repo](https://github.com/MicrosoftDocs/windows-uwp/tree/docs/hub/powertoys)
and link it here: #xxx

<!-- Provide a more detailed description of the PR, other things fixed,
or any additional comments/features here -->
## Detailed Description of the Pull Request / Additional comments

<!-- Describe how you validated the behavior. Add automated tests
wherever possible, but list manual validation steps taken as well -->
## Validation Steps Performed
2026-02-09 14:00:01 -06:00
Jiří Polášek
4959273875 CmdPal: Prevent the main window from stealing focus (#45309)
## Summary of the Pull Request

This PR adds two fixes to prevent Command Palette’s main window from
stealing focus while or after it is being hidden.

1) GoBack was not forwarding the withAnimation and focusSearch
parameters when chaining to GoHome.

2) Page focus after load can be triggered too late—when the window is
already hidden—causing CmdPal to regain focus unexpectedly.

<!-- Please review the items on the PR checklist before submitting-->
## PR Checklist

- [x] Closes: #45301
<!-- - [ ] Closes: #yyy (add separate lines for additional resolved
issues) -->
- [ ] **Communication:** I've discussed this with core contributors
already. If the work hasn't been agreed, this work might be rejected
- [ ] **Tests:** Added/updated and all pass
- [ ] **Localization:** All end-user-facing strings can be localized
- [ ] **Dev docs:** Added/updated
- [ ] **New binaries:** Added on the required places
- [ ] [JSON for
signing](https://github.com/microsoft/PowerToys/blob/main/.pipelines/ESRPSigning_core.json)
for new binaries
- [ ] [WXS for
installer](https://github.com/microsoft/PowerToys/blob/main/installer/PowerToysSetup/Product.wxs)
for new binaries and localization folder
- [ ] [YML for CI
pipeline](https://github.com/microsoft/PowerToys/blob/main/.pipelines/ci/templates/build-powertoys-steps.yml)
for new test projects
- [ ] [YML for signed
pipeline](https://github.com/microsoft/PowerToys/blob/main/.pipelines/release.yml)
- [ ] **Documentation updated:** If checked, please file a pull request
on [our docs
repo](https://github.com/MicrosoftDocs/windows-uwp/tree/docs/hub/powertoys)
and link it here: #xxx

<!-- Provide a more detailed description of the PR, other things fixed,
or any additional comments/features here -->
## Detailed Description of the Pull Request / Additional comments

<!-- Describe how you validated the behavior. Add automated tests
wherever possible, but list manual validation steps taken as well -->
## Validation Steps Performed
2026-02-09 13:56:30 -06:00
Jiří Polášek
095961402b CmdPal: Transparent window (#45159)
## Summary

This PR adds:
- Backdrop material customization
  - Alongside acrylic, the following options are now available:
  - Transparent background
  - Mica background
- Background material opacity
  - Lets you control how transparent the background is

## Pictures? Pictures!

<img width="1491" height="928" alt="image"
src="https://github.com/user-attachments/assets/ff4e9e06-fcf1-4f05-bc0a-fb70dc4f39be"
/>



https://github.com/user-attachments/assets/84e83279-afab-481e-b904-f054318c5d2f

<img width="977" height="628" alt="image"
src="https://github.com/user-attachments/assets/241a228d-af3f-448a-94a6-0a282218bd8c"
/>


## PR Checklist

- [x] Closes: #44197
<!-- - [ ] Closes: #yyy (add separate lines for additional resolved
issues) -->
- [ ] **Communication:** I've discussed this with core contributors
already. If the work hasn't been agreed, this work might be rejected
- [ ] **Tests:** Added/updated and all pass
- [ ] **Localization:** All end-user-facing strings can be localized
- [ ] **Dev docs:** Added/updated
- [ ] **New binaries:** Added on the required places
- [ ] [JSON for
signing](https://github.com/microsoft/PowerToys/blob/main/.pipelines/ESRPSigning_core.json)
for new binaries
- [ ] [WXS for
installer](https://github.com/microsoft/PowerToys/blob/main/installer/PowerToysSetup/Product.wxs)
for new binaries and localization folder
- [ ] [YML for CI
pipeline](https://github.com/microsoft/PowerToys/blob/main/.pipelines/ci/templates/build-powertoys-steps.yml)
for new test projects
- [ ] [YML for signed
pipeline](https://github.com/microsoft/PowerToys/blob/main/.pipelines/release.yml)
- [ ] **Documentation updated:** If checked, please file a pull request
on [our docs
repo](https://github.com/MicrosoftDocs/windows-uwp/tree/docs/hub/powertoys)
and link it here: #xxx

<!-- Provide a more detailed description of the PR, other things fixed,
or any additional comments/features here -->
## Detailed Description of the Pull Request / Additional comments

<!-- Describe how you validated the behavior. Add automated tests
wherever possible, but list manual validation steps taken as well -->
## Validation Steps Performed
2026-02-09 13:42:01 -06:00
Jiří Polášek
7477b561a1 CmdPal: Add precomputed fuzzy string matching to Command Palette (#44090)
## Summary of the Pull Request

This PR improves fuzzy matching in Command Palette by:
- Precomputing normalized strings to enable faster comparisons
- Reducing memory allocations during matching, effectively down to zero

It also introduces several behavioral improvements:
- Strips diacritics from the normalized search string to improve
matching across languages
- Suppresses the same-case bonus when the query consists entirely of
lowercase characters -- reflecting typical user input patterns
- Allows skipping word separators -- enabling queries like Power Point
to match PowerPoint

This implementation is currently kept internal and is used only on the
home page. For other scenarios, the `FuzzyStringMatcher` from
`Microsoft.CommandPalette.Extensions.Toolkit` is being improved instead.

`PrecomputedFuzzyMatcher` offers up to a 100× performance improvement
over the current `FuzzyStringMatcher`, and approximately 2–5× better
performance compared to the improved version.

The improvement might seem small, but it adds up and becomes quite
noticeable when filtering the entire home page—whether the user starts a
new search or changes the query non-incrementally (e.g., using
backspace).


<!-- Please review the items on the PR checklist before submitting-->
## PR Checklist

- [x] Closes: #45226
- [x] Closes: #44066
- [ ] **Communication:** I've discussed this with core contributors
already. If the work hasn't been agreed, this work might be rejected
- [ ] **Tests:** Added/updated and all pass
- [ ] **Localization:** All end-user-facing strings can be localized
- [ ] **Dev docs:** Added/updated
- [ ] **New binaries:** Added on the required places
- [ ] [JSON for
signing](https://github.com/microsoft/PowerToys/blob/main/.pipelines/ESRPSigning_core.json)
for new binaries
- [ ] [WXS for
installer](https://github.com/microsoft/PowerToys/blob/main/installer/PowerToysSetup/Product.wxs)
for new binaries and localization folder
- [ ] [YML for CI
pipeline](https://github.com/microsoft/PowerToys/blob/main/.pipelines/ci/templates/build-powertoys-steps.yml)
for new test projects
- [ ] [YML for signed
pipeline](https://github.com/microsoft/PowerToys/blob/main/.pipelines/release.yml)
- [ ] **Documentation updated:** If checked, please file a pull request
on [our docs
repo](https://github.com/MicrosoftDocs/windows-uwp/tree/docs/hub/powertoys)
and link it here: #xxx

<!-- Provide a more detailed description of the PR, other things fixed,
or any additional comments/features here -->
## Detailed Description of the Pull Request / Additional comments

<!-- Describe how you validated the behavior. Add automated tests
wherever possible, but list manual validation steps taken as well -->
## Validation Steps Performed
2026-02-09 13:37:59 -06:00
104 changed files with 4401 additions and 601 deletions

View File

@@ -111,6 +111,7 @@
^src/modules/cmdpal/ext/SamplePagesExtension/Pages/SampleMarkdownImagesPage\.cs$
^src/modules/cmdpal/Microsoft\.CmdPal\.UI/Settings/InternalPage\.SampleData\.cs$
^src/modules/cmdpal/Tests/Microsoft\.CmdPal\.Core\.Common\.UnitTests/.*\.TestData\.cs$
^src/modules/cmdpal/Tests/Microsoft\.CmdPal\.Core\.Common\.UnitTests/Text/.*\.cs$
^src/modules/colorPicker/ColorPickerUI/Shaders/GridShader\.cso$
^src/modules/launcher/Plugins/Microsoft\.PowerToys\.Run\.Plugin\.TimeDate/Properties/
^src/modules/MouseUtils/MouseJumpUI/MainForm\.resx$

View File

@@ -1527,6 +1527,7 @@ randi
RAquadrant
rasterization
Rasterize
rasterizing
RAWINPUTDEVICE
RAWINPUTHEADER
RAWMODE

View File

@@ -64,6 +64,7 @@
<TreatWarningAsError>true</TreatWarningAsError>
<LanguageStandard>stdcpplatest</LanguageStandard>
<BuildStlModules>false</BuildStlModules>
<AdditionalOptions>/await %(AdditionalOptions)</AdditionalOptions>
<!-- TODO: _SILENCE_STDEXT_ARR_ITERS_DEPRECATION_WARNING for compatibility with VS 17.8. Check if we can remove. -->
<PreprocessorDefinitions>_SILENCE_STDEXT_ARR_ITERS_DEPRECATION_WARNING;_UNICODE;UNICODE;%(PreprocessorDefinitions)</PreprocessorDefinitions>
<!-- CLR + CFG are not compatible >:{ -->

View File

@@ -5,6 +5,8 @@
#include <spdlog/sinks/base_sink.h>
#include <filesystem>
#include <string_view>
#include <tuple>
#include <vector>
#include "../../src/common/logger/logger.h"
#include "../../src/common/utils/gpo.h"
@@ -57,6 +59,135 @@ constexpr inline const wchar_t *DataDiagnosticsRegValueName = L"AllowDataDiagnos
static Shared::Trace::ETWTrace trace{L"PowerToys_Installer"};
namespace
{
struct VersionQuad
{
uint16_t major = 0;
uint16_t minor = 0;
uint16_t patch = 0;
uint16_t revision = 0;
bool operator>(const VersionQuad& other) const
{
return std::tie(major, minor, patch, revision) > std::tie(other.major, other.minor, other.patch, other.revision);
}
};
std::wstring VersionToWString(const VersionQuad& v)
{
return std::to_wstring(v.major) + L"." + std::to_wstring(v.minor) + L"." + std::to_wstring(v.patch) + L"." + std::to_wstring(v.revision);
}
bool TryGetFileVersion(const std::wstring& filePath, VersionQuad& version)
{
DWORD dummyHandle = 0;
DWORD verSize = GetFileVersionInfoSizeW(filePath.c_str(), &dummyHandle);
if (verSize == 0)
{
return false;
}
std::vector<BYTE> verData(verSize);
if (!GetFileVersionInfoW(filePath.c_str(), 0, verSize, verData.data()))
{
return false;
}
VS_FIXEDFILEINFO* verInfo = nullptr;
UINT verInfoSize = 0;
if (!VerQueryValueW(verData.data(), L"\\", reinterpret_cast<LPVOID*>(&verInfo), &verInfoSize) || verInfo == nullptr)
{
return false;
}
version.major = HIWORD(verInfo->dwFileVersionMS);
version.minor = LOWORD(verInfo->dwFileVersionMS);
version.patch = HIWORD(verInfo->dwFileVersionLS);
version.revision = LOWORD(verInfo->dwFileVersionLS);
return true;
}
bool IsPowerToysPerUserProduct(const wchar_t* productCode, const wchar_t* userSid, MSIINSTALLCONTEXT context)
{
if ((context != MSIINSTALLCONTEXT_USERMANAGED) && (context != MSIINSTALLCONTEXT_USERUNMANAGED))
{
return false;
}
wchar_t componentPath[MAX_PATH]{};
DWORD pathLength = MAX_PATH;
INSTALLSTATE state = MsiGetComponentPathExW(productCode, POWERTOYS_EXE_COMPONENT, userSid, context, componentPath, &pathLength);
return state == INSTALLSTATE_LOCAL || state == INSTALLSTATE_SOURCE || state == INSTALLSTATE_DEFAULT;
}
bool IsAnyPowerToysPerUserInstallPresent()
{
static constexpr wchar_t sidAllUsers[] = L"S-1-1-0";
const DWORD contexts = MSIINSTALLCONTEXT_USERMANAGED | MSIINSTALLCONTEXT_USERUNMANAGED;
for (DWORD index = 0;; ++index)
{
WCHAR productCode[39]{};
WCHAR sidBuffer[256]{};
DWORD sidLength = static_cast<DWORD>(std::size(sidBuffer));
MSIINSTALLCONTEXT installedContext = MSIINSTALLCONTEXT_NONE;
UINT enumResult = MsiEnumProductsExW(
nullptr,
sidAllUsers,
contexts,
index,
productCode,
&installedContext,
sidBuffer,
&sidLength);
if (enumResult == ERROR_NO_MORE_ITEMS)
{
break;
}
if (enumResult != ERROR_SUCCESS && enumResult != ERROR_MORE_DATA)
{
continue;
}
std::wstring dynamicSid;
const wchar_t* sidPtr = sidBuffer[0] ? sidBuffer : nullptr;
if (enumResult == ERROR_MORE_DATA)
{
dynamicSid.resize(sidLength + 1);
DWORD retrySidLength = static_cast<DWORD>(dynamicSid.size());
enumResult = MsiEnumProductsExW(
nullptr,
sidAllUsers,
contexts,
index,
productCode,
&installedContext,
dynamicSid.data(),
&retrySidLength);
if (enumResult != ERROR_SUCCESS)
{
continue;
}
dynamicSid.resize(retrySidLength);
sidPtr = dynamicSid.empty() || dynamicSid[0] == L'\0' ? nullptr : dynamicSid.c_str();
}
if (IsPowerToysPerUserProduct(productCode, sidPtr, installedContext))
{
return true;
}
}
return false;
}
}
inline bool isDataDiagnosticEnabled()
{
HKEY key{};
@@ -337,6 +468,69 @@ LExit:
return WcaFinalize(er);
}
UINT __stdcall CheckInstallGuardsCA(MSIHANDLE hInstall)
{
HRESULT hr = S_OK;
UINT er = ERROR_SUCCESS;
LPWSTR currentScope = nullptr;
LPWSTR installFolder = nullptr;
hr = WcaInitialize(hInstall, "CheckInstallGuardsCA");
ExitOnFailure(hr, "Failed to initialize");
hr = WcaGetProperty(L"InstallScope", &currentScope);
ExitOnFailure(hr, "Failed to get InstallScope property");
if (currentScope != nullptr && std::wstring{ currentScope } == L"perMachine" && IsAnyPowerToysPerUserInstallPresent())
{
PMSIHANDLE hRecord = MsiCreateRecord(0);
MsiRecordSetStringW(hRecord, 0, L"PowerToys is already installed per-user for at least one account. Please uninstall all per-user PowerToys installations before installing machine-wide.");
MsiProcessMessage(hInstall, static_cast<INSTALLMESSAGE>(INSTALLMESSAGE_ERROR + MB_OK), hRecord);
hr = E_ABORT;
ExitOnFailure(hr, "Per-user installation detected while attempting machine-wide install");
}
hr = WcaGetProperty(L"INSTALLFOLDER", &installFolder);
ExitOnFailure(hr, "Failed to get INSTALLFOLDER property");
if (installFolder && *installFolder != L'\0')
{
const auto installedExePath = std::filesystem::path(installFolder) / L"PowerToys.exe";
if (std::filesystem::exists(installedExePath))
{
VersionQuad existingVersion;
if (TryGetFileVersion(installedExePath.wstring(), existingVersion))
{
const VersionQuad targetVersion{
static_cast<uint16_t>(VERSION_MAJOR),
static_cast<uint16_t>(VERSION_MINOR),
static_cast<uint16_t>(VERSION_REVISION),
0
};
if (existingVersion > targetVersion)
{
const auto existingVersionText = VersionToWString(existingVersion);
const auto targetVersionText = VersionToWString(targetVersion);
const auto message = L"A newer PowerToys version (" + existingVersionText + L") already exists in the installation folder. The requested installer version (" + targetVersionText + L") is older. Uninstall the newer version first.";
PMSIHANDLE hRecord = MsiCreateRecord(0);
MsiRecordSetStringW(hRecord, 0, message.c_str());
MsiProcessMessage(hInstall, static_cast<INSTALLMESSAGE>(INSTALLMESSAGE_ERROR + MB_OK), hRecord);
hr = E_ABORT;
ExitOnFailure(hr, "A higher PowerToys.exe version already exists");
}
}
}
}
LExit:
ReleaseStr(currentScope);
ReleaseStr(installFolder);
er = SUCCEEDED(hr) ? ERROR_SUCCESS : ERROR_INSTALL_FAILURE;
return WcaFinalize(er);
}
// We've deprecated Video Conference Mute. This Custom Action cleans up any stray registry entry for the driver dll.
UINT __stdcall CleanVideoConferenceRegistryCA(MSIHANDLE hInstall)
{

View File

@@ -3,6 +3,7 @@ LIBRARY "PowerToysSetupCustomActionsVNext"
EXPORTS
LaunchPowerToysCA
CheckGPOCA
CheckInstallGuardsCA
CleanVideoConferenceRegistryCA
ApplyModulesRegistryChangeSetsCA
DetectPrevInstallPathCA

View File

@@ -88,7 +88,7 @@
<ItemDefinitionGroup>
<ClCompile>
<AdditionalIncludeDirectories>inc;..\..\src\;..\..\src\common\Telemetry;telemetry;%(AdditionalIncludeDirectories)</AdditionalIncludeDirectories>
<AdditionalOptions>/Zc:twoPhase- /Wv:18 %(AdditionalOptions)</AdditionalOptions>
<AdditionalOptions>/await /Zc:twoPhase- /Wv:18 %(AdditionalOptions)</AdditionalOptions>
<WarningLevel>Level4</WarningLevel>
<DebugInformationFormat>ProgramDatabase</DebugInformationFormat>
</ClCompile>

View File

@@ -121,6 +121,7 @@
<Custom Action="SetUnApplyModulesRegistryChangeSetsParam" Before="UnApplyModulesRegistryChangeSets" />
<Custom Action="CheckGPO" After="InstallInitialize" Condition="NOT Installed" />
<Custom Action="CheckInstallGuards" After="CheckGPO" Condition="NOT Installed" />
<Custom Action="SetBundleInstallLocationData" Before="SetBundleInstallLocation" Condition="NOT Installed OR WIX_UPGRADE_DETECTED" />
<Custom Action="SetBundleInstallLocation" After="InstallFiles" Condition="NOT Installed OR WIX_UPGRADE_DETECTED" />
<Custom Action="ApplyModulesRegistryChangeSets" After="InstallFiles" Condition="NOT Installed" />
@@ -258,6 +259,7 @@
<CustomAction Id="UnRegisterCmdPalPackage" Return="ignore" Impersonate="yes" Execute="deferred" DllEntry="UnRegisterCmdPalPackageCA" BinaryRef="PTCustomActions" />
<CustomAction Id="CheckGPO" Return="check" Impersonate="yes" DllEntry="CheckGPOCA" BinaryRef="PTCustomActions" />
<CustomAction Id="CheckInstallGuards" Return="check" Impersonate="yes" DllEntry="CheckInstallGuardsCA" BinaryRef="PTCustomActions" />
<CustomAction Id="InstallCmdPalPackage" Return="ignore" Impersonate="yes" Execute="deferred" DllEntry="InstallCmdPalPackageCA" BinaryRef="PTCustomActions" />

View File

@@ -58,11 +58,6 @@ std::optional<fs::path> ObtainInstaller(bool& isUpToDate)
auto state = UpdateState::read();
const auto new_version_info = get_github_version_info_async().get();
if (!new_version_info)
{
Logger::error(L"Couldn't obtain github version info: {}", new_version_info.error());
return std::nullopt;
}
if (std::holds_alternative<version_up_to_date>(*new_version_info))
{
isUpToDate = true;
@@ -72,11 +67,16 @@ std::optional<fs::path> ObtainInstaller(bool& isUpToDate)
if (state.state == UpdateState::readyToDownload || state.state == UpdateState::errorDownloading)
{
if (!new_version_info)
{
Logger::error(L"Couldn't obtain github version info: {}", new_version_info.error());
return std::nullopt;
}
// Cleanup old updates before downloading the latest
updating::cleanup_updates();
auto downloaded_installer = download_new_version_async(std::get<new_version_download_info>(*new_version_info)).get();
auto downloaded_installer = download_new_version(std::get<new_version_download_info>(*new_version_info)).get();
if (!downloaded_installer)
{
Logger::error("Couldn't download new installer");

View File

@@ -18,7 +18,7 @@ namespace // Strings in this namespace should not be localized
namespace updating
{
winrt::Windows::Foundation::IAsyncOperation<bool> uninstall_previous_msix_version_async()
std::future<bool> uninstall_previous_msix_version_async()
{
winrt::Windows::Management::Deployment::PackageManager package_manager;

View File

@@ -2,11 +2,11 @@
#include <string>
#include <optional>
#include <future>
#include <winrt/Windows.Foundation.h>
#include <common/version/helper.h>
namespace updating
{
winrt::Windows::Foundation::IAsyncOperation<bool> uninstall_previous_msix_version_async();
}
std::future<bool> uninstall_previous_msix_version_async();
}

View File

@@ -82,7 +82,11 @@ namespace updating
// prevent the warning that may show up depend on the value of the constants (#defines)
#pragma warning(push)
#pragma warning(disable : 4702)
utils::async_task<github_version_result> get_github_version_info_async(const bool prerelease)
#if USE_STD_EXPECTED
std::future<std::expected<github_version_info, std::wstring>> get_github_version_info_async(const bool prerelease)
#else
std::future<nonstd::expected<github_version_info, std::wstring>> get_github_version_info_async(const bool prerelease)
#endif
{
// If the current version starts with 0.0.*, it means we're on a local build from a farm and shouldn't check for updates.
if constexpr (VERSION_MAJOR == 0 && VERSION_MINOR == 0)
@@ -166,7 +170,7 @@ namespace updating
return !ec ? std::optional{ installer_download_path } : std::nullopt;
}
utils::async_task<std::optional<std::filesystem::path>> download_new_version_async(new_version_download_info new_version)
std::future<std::optional<std::filesystem::path>> download_new_version(const new_version_download_info& new_version)
{
auto installer_download_path = create_download_path();
if (!installer_download_path)

View File

@@ -2,6 +2,7 @@
#include <optional>
#include <string>
#include <future>
#include <filesystem>
#include <variant>
#include <winrt/Windows.Foundation.h>
@@ -15,7 +16,6 @@
#endif
#include <common/version/helper.h>
#include <common/utils/async_task.h>
namespace updating
{
@@ -32,18 +32,16 @@ namespace updating
};
using github_version_info = std::variant<new_version_download_info, version_up_to_date>;
#if USE_STD_EXPECTED
using github_version_result = std::expected<github_version_info, std::wstring>;
#else
using github_version_result = nonstd::expected<github_version_info, std::wstring>;
#endif
utils::async_task<github_version_result> get_github_version_info_async(bool prerelease = false);
utils::async_task<std::optional<std::filesystem::path>> download_new_version_async(new_version_download_info new_version);
std::future<std::optional<std::filesystem::path>> download_new_version(const new_version_download_info& new_version);
std::filesystem::path get_pending_updates_path();
#if USE_STD_EXPECTED
std::future<std::expected<github_version_info, std::wstring>> get_github_version_info_async(const bool prerelease = false);
#else
std::future<nonstd::expected<github_version_info, std::wstring>> get_github_version_info_async(const bool prerelease = false);
#endif
void cleanup_updates();
// non-localized
constexpr inline std::wstring_view INSTALLER_FILENAME_PATTERN = L"powertoyssetup";
constexpr inline std::wstring_view INSTALLER_FILENAME_PATTERN_USER = L"powertoyssetup-user";
}
constexpr inline std::wstring_view INSTALLER_FILENAME_PATTERN_USER = L"powertoysusersetup";
}

View File

@@ -1,7 +1,6 @@
#pragma once
#include <functional>
#include <string>
#include <future>
#include <winrt/Windows.Foundation.h>
#include <winrt/Windows.Storage.Streams.h>
#include <winrt/Windows.Web.Http.h>
@@ -22,15 +21,15 @@ namespace http
headers.UserAgent().TryParseAdd(USER_AGENT);
}
winrt::Windows::Foundation::IAsyncOperation<winrt::hstring> request(const winrt::Windows::Foundation::Uri& url)
std::future<std::wstring> request(const winrt::Windows::Foundation::Uri& url)
{
auto response = co_await m_client.GetAsync(url);
(void)response.EnsureSuccessStatusCode();
auto body = co_await response.Content().ReadAsStringAsync();
co_return body;
co_return std::wstring(body);
}
winrt::Windows::Foundation::IAsyncAction download(winrt::Windows::Foundation::Uri url, std::wstring dstFilePath)
std::future<void> download(const winrt::Windows::Foundation::Uri& url, const std::wstring& dstFilePath)
{
auto response = co_await m_client.GetAsync(url);
(void)response.EnsureSuccessStatusCode();
@@ -39,7 +38,7 @@ namespace http
file_stream.Close();
}
winrt::Windows::Foundation::IAsyncAction download(winrt::Windows::Foundation::Uri url, std::wstring dstFilePath, std::function<void(float)> progressUpdateCallback)
std::future<void> download(const winrt::Windows::Foundation::Uri& url, const std::wstring& dstFilePath, const std::function<void(float)>& progressUpdateCallback)
{
auto response = co_await m_client.GetAsync(url, HttpCompletionOption::ResponseHeadersRead);
response.EnsureSuccessStatusCode();

View File

@@ -1,107 +0,0 @@
#pragma once
#include <atomic>
#include <coroutine>
#include <exception>
#include <optional>
#include <thread>
#include <utility>
namespace utils
{
// A minimal C++20 coroutine return type that can carry any value type.
// Use this instead of IAsyncOperation<T> when T is not a WinRT-projected type.
// Supports co_return of arbitrary values and synchronous .get() for the caller.
//
// Example:
// utils::async_task<int> compute_async() { co_return 42; }
// int result = compute_async().get();
template<typename T>
class async_task
{
public:
struct promise_type
{
std::optional<T> result;
std::exception_ptr exception;
std::atomic<bool> completed{ false };
async_task get_return_object()
{
return async_task{ std::coroutine_handle<promise_type>::from_promise(*this) };
}
std::suspend_never initial_suspend() noexcept { return {}; }
// Custom final awaiter that signals completion AFTER the coroutine
// is fully suspended, ensuring safe destruction from another thread.
struct final_awaiter
{
bool await_ready() noexcept { return false; }
void await_suspend(std::coroutine_handle<promise_type> h) noexcept
{
h.promise().completed.store(true, std::memory_order_release);
}
void await_resume() noexcept {}
};
final_awaiter final_suspend() noexcept { return {}; }
template<typename U>
void return_value(U&& value)
{
result.emplace(std::forward<U>(value));
}
void unhandled_exception()
{
exception = std::current_exception();
}
};
explicit async_task(std::coroutine_handle<promise_type> h) : handle_(h) {}
async_task(async_task&& other) noexcept : handle_(std::exchange(other.handle_, {})) {}
async_task& operator=(async_task&& other) noexcept
{
if (this != &other)
{
if (handle_)
handle_.destroy();
handle_ = std::exchange(other.handle_, {});
}
return *this;
}
~async_task()
{
if (handle_)
handle_.destroy();
}
async_task(const async_task&) = delete;
async_task& operator=(const async_task&) = delete;
// Block until the coroutine completes and return the result.
// Throws if the coroutine encountered an unhandled exception.
T get()
{
while (!handle_.promise().completed.load(std::memory_order_acquire))
{
std::this_thread::yield();
}
auto& promise = handle_.promise();
if (promise.exception)
{
std::rethrow_exception(promise.exception);
}
return std::move(*promise.result);
}
private:
std::coroutine_handle<promise_type> handle_;
};
} // namespace utils

View File

@@ -13,6 +13,7 @@
<ConformanceMode>false</ConformanceMode>
<TreatWarningAsError>true</TreatWarningAsError>
<LanguageStandard>stdcpplatest</LanguageStandard>
<AdditionalOptions>/await %(AdditionalOptions)</AdditionalOptions>
<PreprocessorDefinitions>_UNICODE;UNICODE;%(PreprocessorDefinitions)</PreprocessorDefinitions>
</ClCompile>
<Link>

View File

@@ -13,6 +13,7 @@
<ConformanceMode>false</ConformanceMode>
<TreatWarningAsError>true</TreatWarningAsError>
<LanguageStandard>stdcpplatest</LanguageStandard>
<AdditionalOptions>/await %(AdditionalOptions)</AdditionalOptions>
<PreprocessorDefinitions>_UNICODE;UNICODE;%(PreprocessorDefinitions)</PreprocessorDefinitions>
</ClCompile>
<Link>

View File

@@ -13,6 +13,7 @@
<ConformanceMode>false</ConformanceMode>
<TreatWarningAsError>true</TreatWarningAsError>
<LanguageStandard>stdcpplatest</LanguageStandard>
<AdditionalOptions>/await %(AdditionalOptions)</AdditionalOptions>
<PreprocessorDefinitions>_UNICODE;UNICODE;%(PreprocessorDefinitions)</PreprocessorDefinitions>
</ClCompile>
<Link>

View File

@@ -28,7 +28,6 @@
#include <common/utils/logger_helper.h>
#include <common/utils/winapi_error.h>
#include <common/utils/gpo.h>
#include <common/utils/async_task.h>
#include <array>
#include <vector>
#endif // __ZOOMIT_POWERTOYS__
@@ -5141,7 +5140,7 @@ bool IsPenInverted( WPARAM wParam )
// Captures the specified screen using the capture APIs
//
//----------------------------------------------------------------------------
utils::async_task<winrt::com_ptr<ID3D11Texture2D>> CaptureScreenshotAsync(winrt::IDirect3DDevice const& device, winrt::GraphicsCaptureItem const& item, winrt::DirectXPixelFormat const& pixelFormat)
std::future<winrt::com_ptr<ID3D11Texture2D>> CaptureScreenshotAsync(winrt::IDirect3DDevice const& device, winrt::GraphicsCaptureItem const& item, winrt::DirectXPixelFormat const& pixelFormat)
{
auto d3dDevice = GetDXGIInterfaceFromObject<ID3D11Device>(device);
winrt::com_ptr<ID3D11DeviceContext> d3dContext;
@@ -5177,7 +5176,9 @@ utils::async_task<winrt::com_ptr<ID3D11Texture2D>> CaptureScreenshotAsync(winrt:
framePool.Close();
auto texture = GetDXGIInterfaceFromObject<ID3D11Texture2D>(frame.Surface());
co_return util::CopyD3DTexture(d3dDevice, texture, true);
auto result = util::CopyD3DTexture(d3dDevice, texture, true);
co_return result;
}
//----------------------------------------------------------------------------
@@ -5204,7 +5205,10 @@ winrt::com_ptr<ID3D11Texture2D>CaptureScreenshot(winrt::DirectXPixelFormat const
auto item = util::CreateCaptureItemForMonitor(hMon);
return CaptureScreenshotAsync(device, item, pixelFormat).get();
auto capture = CaptureScreenshotAsync(device, item, pixelFormat);
capture.wait();
return capture.get();
}

View File

@@ -0,0 +1,26 @@
// Copyright (c) Microsoft Corporation
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
namespace Microsoft.CmdPal.Core.Common;
/// <summary>
/// Represents the packaging flavor of the application.
/// </summary>
public enum AppPackagingFlavor
{
/// <summary>
/// Application is packaged as a Windows MSIX package.
/// </summary>
Packaged,
/// <summary>
/// Application is running unpackaged (native executable).
/// </summary>
Unpackaged,
/// <summary>
/// Application is running as unpackaged portable (self-contained distribution).
/// </summary>
UnpackagedPortable,
}

View File

@@ -0,0 +1,27 @@
// Copyright (c) Microsoft Corporation
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
using Microsoft.CmdPal.Core.Common.Text;
namespace Microsoft.CmdPal.Core.Common.Helpers;
/// <summary>
/// Represents an item that can provide precomputed fuzzy matching targets for its title and subtitle.
/// </summary>
public interface IPrecomputedListItem
{
/// <summary>
/// Gets the fuzzy matching target for the item's title.
/// </summary>
/// <param name="matcher">The precomputed fuzzy matcher used to build the target.</param>
/// <returns>The fuzzy target for the title.</returns>
FuzzyTarget GetTitleTarget(IPrecomputedFuzzyMatcher matcher);
/// <summary>
/// Gets the fuzzy matching target for the item's subtitle.
/// </summary>
/// <param name="matcher">The precomputed fuzzy matcher used to build the target.</param>
/// <returns>The fuzzy target for the subtitle.</returns>
FuzzyTarget GetSubtitleTarget(IPrecomputedFuzzyMatcher matcher);
}

View File

@@ -0,0 +1,142 @@
// Copyright (c) Microsoft Corporation
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
using System.Buffers;
using System.Diagnostics;
using Microsoft.CmdPal.Core.Common.Text;
namespace Microsoft.CmdPal.Core.Common.Helpers;
public static partial class InternalListHelpers
{
public static RoScored<T>[] FilterListWithScores<T>(
IEnumerable<T>? items,
in FuzzyQuery query,
in ScoringFunction<T> scoreFunction)
{
if (items == null)
{
return [];
}
// Try to get initial capacity hint
var initialCapacity = items switch
{
ICollection<T> col => col.Count,
IReadOnlyCollection<T> rc => rc.Count,
_ => 64,
};
var buffer = ArrayPool<RoScored<T>>.Shared.Rent(initialCapacity);
var count = 0;
try
{
foreach (var item in items)
{
var score = scoreFunction(in query, item);
if (score <= 0)
{
continue;
}
if (count == buffer.Length)
{
GrowBuffer(ref buffer, count);
}
buffer[count++] = new RoScored<T>(item, score);
}
Array.Sort(buffer, 0, count, default(RoScoredDescendingComparer<T>));
var result = GC.AllocateUninitializedArray<RoScored<T>>(count);
buffer.AsSpan(0, count).CopyTo(result);
return result;
}
finally
{
ArrayPool<RoScored<T>>.Shared.Return(buffer);
}
}
private static void GrowBuffer<T>(ref RoScored<T>[] buffer, int count)
{
var newBuffer = ArrayPool<RoScored<T>>.Shared.Rent(buffer.Length * 2);
buffer.AsSpan(0, count).CopyTo(newBuffer);
ArrayPool<RoScored<T>>.Shared.Return(buffer);
buffer = newBuffer;
}
public static T[] FilterList<T>(IEnumerable<T> items, in FuzzyQuery query, ScoringFunction<T> scoreFunction)
{
// Try to get initial capacity hint
var initialCapacity = items switch
{
ICollection<T> col => col.Count,
IReadOnlyCollection<T> rc => rc.Count,
_ => 64,
};
var buffer = ArrayPool<RoScored<T>>.Shared.Rent(initialCapacity);
var count = 0;
try
{
foreach (var item in items)
{
var score = scoreFunction(in query, item);
if (score <= 0)
{
continue;
}
if (count == buffer.Length)
{
GrowBuffer(ref buffer, count);
}
buffer[count++] = new RoScored<T>(item, score);
}
Array.Sort(buffer, 0, count, default(RoScoredDescendingComparer<T>));
var result = GC.AllocateUninitializedArray<T>(count);
for (var i = 0; i < count; i++)
{
result[i] = buffer[i].Item;
}
return result;
}
finally
{
ArrayPool<RoScored<T>>.Shared.Return(buffer);
}
}
private readonly struct RoScoredDescendingComparer<T> : IComparer<RoScored<T>>
{
public int Compare(RoScored<T> x, RoScored<T> y) => y.Score.CompareTo(x.Score);
}
}
public delegate int ScoringFunction<in T>(in FuzzyQuery query, T item);
[DebuggerDisplay($"{{{nameof(GetDebuggerDisplay)}(),nq}}")]
public readonly struct RoScored<T>
{
public readonly int Score;
public readonly T Item;
public RoScored(T item, int score)
{
Score = score;
Item = item;
}
private string GetDebuggerDisplay()
{
return "Score = " + Score + ", Item = " + Item;
}
}

View File

@@ -0,0 +1,87 @@
// Copyright (c) Microsoft Corporation
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
using System.Diagnostics;
using Windows.ApplicationModel;
namespace Microsoft.CmdPal.Core.Common.Helpers;
/// <summary>
/// Helper class for retrieving application version information safely.
/// </summary>
internal static class VersionHelper
{
/// <summary>
/// Gets the application version as a string in the format "Major.Minor.Build.Revision".
/// Falls back to assembly version if packaged version is unavailable, and returns a default value if both fail.
/// </summary>
/// <returns>The application version string, or a fallback value if retrieval fails.</returns>
public static string GetAppVersionSafe()
{
if (TryGetPackagedVersion(out var version))
{
return version;
}
if (TryGetAssemblyVersion(out version))
{
return version;
}
return "?";
}
/// <summary>
/// Attempts to retrieve the application version from the package manifest.
/// </summary>
/// <param name="version">The version string if successful, or an empty string if unsuccessful.</param>
/// <returns>True if the version was retrieved successfully; otherwise, false.</returns>
private static bool TryGetPackagedVersion(out string version)
{
version = string.Empty;
try
{
// Package.Current throws InvalidOperationException if the app is not packaged
var v = Package.Current.Id.Version;
version = $"{v.Major}.{v.Minor}.{v.Build}.{v.Revision}";
return true;
}
catch (InvalidOperationException)
{
return false;
}
catch (Exception ex)
{
CoreLogger.LogError("Failed to get version from the package", ex);
return false;
}
}
/// <summary>
/// Attempts to retrieve the application version from the executable file.
/// </summary>
/// <param name="version">The version string if successful, or an empty string if unsuccessful.</param>
/// <returns>True if the version was retrieved successfully; otherwise, false.</returns>
private static bool TryGetAssemblyVersion(out string version)
{
version = string.Empty;
try
{
var processPath = Environment.ProcessPath;
if (string.IsNullOrEmpty(processPath))
{
return false;
}
var info = FileVersionInfo.GetVersionInfo(processPath);
version = $"{info.FileMajorPart}.{info.FileMinorPart}.{info.FileBuildPart}.{info.FilePrivatePart}";
return true;
}
catch (Exception ex)
{
CoreLogger.LogError("Failed to get version from the executable", ex);
return false;
}
}
}

View File

@@ -0,0 +1,125 @@
// Copyright (c) Microsoft Corporation
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
using System.Globalization;
using System.Runtime.InteropServices;
using System.Security.Principal;
using Microsoft.CmdPal.Core.Common.Helpers;
using Microsoft.CommandPalette.Extensions.Toolkit;
using Windows.ApplicationModel;
namespace Microsoft.CmdPal.Core.Common.Services;
/// <summary>
/// Implementation of IApplicationInfoService providing application-wide information.
/// </summary>
public sealed class ApplicationInfoService : IApplicationInfoService
{
private readonly Lazy<string> _configDirectory = new(() => Utilities.BaseSettingsPath("Microsoft.CmdPal"));
private readonly Lazy<bool> _isElevated;
private readonly Lazy<string> _logDirectory;
private readonly Lazy<AppPackagingFlavor> _packagingFlavor;
private Func<string>? _getLogDirectory;
/// <summary>
/// Initializes a new instance of the <see cref="ApplicationInfoService"/> class.
/// The log directory delegate can be set later via <see cref="SetLogDirectory(Func{string})"/>.
/// </summary>
public ApplicationInfoService()
{
_packagingFlavor = new Lazy<AppPackagingFlavor>(DeterminePackagingFlavor);
_isElevated = new Lazy<bool>(DetermineElevationStatus);
_logDirectory = new Lazy<string>(() => _getLogDirectory?.Invoke() ?? "Not available");
}
/// <summary>
/// Initializes a new instance of the <see cref="ApplicationInfoService"/> class with an optional log directory provider.
/// </summary>
/// <param name="getLogDirectory">Optional delegate to retrieve the log directory path. If not provided, the log directory will be unavailable.</param>
public ApplicationInfoService(Func<string>? getLogDirectory)
: this()
{
_getLogDirectory = getLogDirectory;
}
/// <summary>
/// Sets the log directory delegate to be used for retrieving the log directory path.
/// This allows deferred initialization of the logger path.
/// </summary>
/// <param name="getLogDirectory">Delegate to retrieve the log directory path.</param>
public void SetLogDirectory(Func<string> getLogDirectory)
{
ArgumentNullException.ThrowIfNull(getLogDirectory);
_getLogDirectory = getLogDirectory;
}
public string AppVersion => VersionHelper.GetAppVersionSafe();
public AppPackagingFlavor PackagingFlavor => _packagingFlavor.Value;
public string LogDirectory => _logDirectory.Value;
public string ConfigDirectory => _configDirectory.Value;
public bool IsElevated => _isElevated.Value;
public string GetApplicationInfoSummary()
{
return $"""
Application:
App version: {AppVersion}
Packaging flavor: {PackagingFlavor}
Is elevated: {(IsElevated ? "yes" : "no")}
Environment:
OS version: {RuntimeInformation.OSDescription}
OS architecture: {RuntimeInformation.OSArchitecture}
Runtime identifier: {RuntimeInformation.RuntimeIdentifier}
Framework: {RuntimeInformation.FrameworkDescription}
Process architecture: {RuntimeInformation.ProcessArchitecture}
Culture: {CultureInfo.CurrentCulture.Name}
UI culture: {CultureInfo.CurrentUICulture.Name}
Paths:
Log directory: {LogDirectory}
Config directory: {ConfigDirectory}
""";
}
private static AppPackagingFlavor DeterminePackagingFlavor()
{
// Try to determine if running as packaged
try
{
// If this doesn't throw, we're packaged
_ = Package.Current.Id.Version;
return AppPackagingFlavor.Packaged;
}
catch (InvalidOperationException)
{
// Not packaged, check if portable
// For now, we don't support portable yet, so return Unpackaged
// In the future, check for a marker file or environment variable
return AppPackagingFlavor.Unpackaged;
}
catch (Exception ex)
{
CoreLogger.LogError("Failed to determine packaging flavor", ex);
return AppPackagingFlavor.Unpackaged;
}
}
private static bool DetermineElevationStatus()
{
try
{
var isElevated = new WindowsPrincipal(WindowsIdentity.GetCurrent()).IsInRole(WindowsBuiltInRole.Administrator);
return isElevated;
}
catch (Exception)
{
return false;
}
}
}

View File

@@ -0,0 +1,49 @@
// Copyright (c) Microsoft Corporation
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
namespace Microsoft.CmdPal.Core.Common.Services;
/// <summary>
/// Provides access to application-wide information such as version, packaging flavor, and directory paths.
/// </summary>
public interface IApplicationInfoService
{
/// <summary>
/// Gets the application version as a string in the format "Major.Minor.Build.Revision".
/// </summary>
string AppVersion { get; }
/// <summary>
/// Gets the packaging flavor of the application.
/// </summary>
AppPackagingFlavor PackagingFlavor { get; }
/// <summary>
/// Gets the directory path where application logs are stored.
/// </summary>
string LogDirectory { get; }
/// <summary>
/// Gets the directory path where application configuration files are stored.
/// </summary>
string ConfigDirectory { get; }
/// <summary>
/// Gets a value indicating whether the application is running with administrator privileges.
/// </summary>
bool IsElevated { get; }
/// <summary>
/// Gets a formatted summary of application information suitable for logging.
/// </summary>
/// <returns>A formatted string containing application information.</returns>
string GetApplicationInfoSummary();
/// <summary>
/// Sets the log directory delegate to be used for retrieving the log directory path.
/// This allows deferred initialization of the logger path.
/// </summary>
/// <param name="getLogDirectory">Delegate to retrieve the log directory path.</param>
void SetLogDirectory(Func<string> getLogDirectory);
}

View File

@@ -2,20 +2,27 @@
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
using System.Globalization;
using System.Runtime.InteropServices;
using System.Security.Principal;
using Microsoft.CmdPal.Core.Common.Services.Sanitizer;
using Windows.ApplicationModel;
namespace Microsoft.CmdPal.Core.Common.Services.Reports;
public sealed class ErrorReportBuilder : IErrorReportBuilder
{
private readonly ErrorReportSanitizer _sanitizer = new();
private readonly IApplicationInfoService _appInfoService;
private static string Preamble => Properties.Resources.ErrorReport_Global_Preamble;
/// <summary>
/// Initializes a new instance of the <see cref="ErrorReportBuilder"/> class.
/// </summary>
/// <param name="appInfoService">Optional application info service. If not provided, a default instance is created.</param>
public ErrorReportBuilder(IApplicationInfoService? appInfoService = null)
{
_appInfoService = appInfoService ?? new ApplicationInfoService(null);
}
public string BuildReport(Exception exception, string context, bool redactPii = true)
{
ArgumentNullException.ThrowIfNull(exception);
@@ -24,6 +31,9 @@ public sealed class ErrorReportBuilder : IErrorReportBuilder
var sanitizedMessage = redactPii ? _sanitizer.Sanitize(exceptionMessage) : exceptionMessage;
var sanitizedFormattedException = redactPii ? _sanitizer.Sanitize(exception.ToString()) : exception.ToString();
var applicationInfoSummary = GetAppInfoSafe();
var applicationInfoSummarySanitized = redactPii ? _sanitizer.Sanitize(applicationInfoSummary) : applicationInfoSummary;
// Note:
// - do not localize technical part of the report, we need to ensure it can be read by developers
// - keep timestamp format should be consistent with the log (makes it easier to search)
@@ -38,18 +48,7 @@ public sealed class ErrorReportBuilder : IErrorReportBuilder
HRESULT: 0x{exception.HResult:X8} ({exception.HResult})
Context: {context ?? "N/A"}
Application:
App version: {GetAppVersionSafe()}
Is elevated: {GetElevationStatus()}
Environment:
OS version: {RuntimeInformation.OSDescription}
OS architecture: {RuntimeInformation.OSArchitecture}
Runtime identifier: {RuntimeInformation.RuntimeIdentifier}
Framework: {RuntimeInformation.FrameworkDescription}
Process architecture: {RuntimeInformation.ProcessArchitecture}
Culture: {CultureInfo.CurrentCulture.Name}
UI culture: {CultureInfo.CurrentUICulture.Name}
{applicationInfoSummarySanitized}
Stack Trace:
{exception.StackTrace}
@@ -66,31 +65,17 @@ public sealed class ErrorReportBuilder : IErrorReportBuilder
""";
}
private static string GetElevationStatus()
private string? GetAppInfoSafe()
{
// Note: do not localize technical part of the report, we need to ensure it can be read by developers
try
{
var isElevated = new WindowsPrincipal(WindowsIdentity.GetCurrent()).IsInRole(WindowsBuiltInRole.Administrator);
return isElevated ? "yes" : "no";
return _appInfoService.GetApplicationInfoSummary();
}
catch (Exception)
catch (Exception ex)
{
return "Failed to determine elevation status";
}
}
private static string GetAppVersionSafe()
{
// Note: do not localize technical part of the report, we need to ensure it can be read by developers
try
{
var version = Package.Current.Id.Version;
return $"{version.Major}.{version.Minor}.{version.Build}.{version.Revision}";
}
catch (Exception)
{
return "Failed to retrieve app version";
// Getting application info should never throw, but if it does, we don't want it to prevent the report from being generated
var message = CoalesceExceptionMessage(ex);
return $"Failed to get application info summary: {message}";
}
}

View File

@@ -0,0 +1,40 @@
// Copyright (c) Microsoft Corporation
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
using System.Runtime.CompilerServices;
namespace Microsoft.CmdPal.Core.Common.Text;
public sealed class BloomFilter : IBloomFilter
{
public ulong Compute(string input)
{
ulong bloom = 0;
foreach (var ch in input)
{
if (SymbolClassifier.Classify(ch) == SymbolKind.WordSeparator)
{
continue;
}
var h = (uint)ch * 0x45d9f3b;
bloom |= 1UL << (int)(h & 31);
bloom |= 1UL << (int)(((h >> 16) & 31) + 32);
if (bloom == ulong.MaxValue)
{
break;
}
}
return bloom;
}
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public bool MightContain(ulong candidateBloom, ulong queryBloom)
{
return (candidateBloom & queryBloom) == queryBloom;
}
}

View File

@@ -0,0 +1,52 @@
// Copyright (c) Microsoft Corporation
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
using System.Globalization;
namespace Microsoft.CmdPal.Core.Common.Text;
public sealed class FuzzyMatcherProvider : IFuzzyMatcherProvider
{
private readonly IBloomFilter _bloomCalculator = new BloomFilter();
private readonly IStringFolder _normalizer = new StringFolder();
private IPrecomputedFuzzyMatcher _current;
public FuzzyMatcherProvider(PrecomputedFuzzyMatcherOptions core, PinyinFuzzyMatcherOptions? pinyin = null)
{
_current = CreateMatcher(core, pinyin);
}
public IPrecomputedFuzzyMatcher Current => Volatile.Read(ref _current);
public void UpdateSettings(PrecomputedFuzzyMatcherOptions core, PinyinFuzzyMatcherOptions? pinyin = null)
{
Volatile.Write(ref _current, CreateMatcher(core, pinyin));
}
private IPrecomputedFuzzyMatcher CreateMatcher(PrecomputedFuzzyMatcherOptions core, PinyinFuzzyMatcherOptions? pinyin)
{
return pinyin is null || !IsPinyinEnabled(pinyin)
? new PrecomputedFuzzyMatcher(core, _normalizer, _bloomCalculator)
: new PrecomputedFuzzyMatcherWithPinyin(core, pinyin, _normalizer, _bloomCalculator);
}
private static bool IsPinyinEnabled(PinyinFuzzyMatcherOptions o)
{
return o.Mode switch
{
PinyinMode.Off => false,
PinyinMode.On => true,
PinyinMode.AutoSimplifiedChineseUi => IsSimplifiedChineseUi(),
_ => false,
};
}
private static bool IsSimplifiedChineseUi()
{
var culture = CultureInfo.CurrentUICulture;
return culture.Name.StartsWith("zh-CN", StringComparison.OrdinalIgnoreCase)
|| culture.Name.StartsWith("zh-Hans", StringComparison.OrdinalIgnoreCase);
}
}

View File

@@ -0,0 +1,65 @@
// Copyright (c) Microsoft Corporation
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
namespace Microsoft.CmdPal.Core.Common.Text;
public readonly struct FuzzyQuery
{
public readonly string Original;
public readonly string Folded;
public readonly ulong Bloom;
public readonly int EffectiveLength;
public readonly bool IsAllLowercaseAsciiOrNonLetter;
public readonly string? SecondaryOriginal;
public readonly string? SecondaryFolded;
public readonly ulong SecondaryBloom;
public readonly int SecondaryEffectiveLength;
public readonly bool SecondaryIsAllLowercaseAsciiOrNonLetter;
public int Length => Folded.Length;
public bool HasSecondary => SecondaryFolded is not null;
public ReadOnlySpan<char> OriginalSpan => Original.AsSpan();
public ReadOnlySpan<char> FoldedSpan => Folded.AsSpan();
public ReadOnlySpan<char> SecondaryOriginalSpan => SecondaryOriginal.AsSpan();
public ReadOnlySpan<char> SecondaryFoldedSpan => SecondaryFolded.AsSpan();
public FuzzyQuery(
string original,
string folded,
ulong bloom,
int effectiveLength,
bool isAllLowercaseAsciiOrNonLetter,
string? secondaryOriginal = null,
string? secondaryFolded = null,
ulong secondaryBloom = 0,
int secondaryEffectiveLength = 0,
bool secondaryIsAllLowercaseAsciiOrNonLetter = true)
{
Original = original;
Folded = folded;
Bloom = bloom;
EffectiveLength = effectiveLength;
IsAllLowercaseAsciiOrNonLetter = isAllLowercaseAsciiOrNonLetter;
SecondaryOriginal = secondaryOriginal;
SecondaryFolded = secondaryFolded;
SecondaryBloom = secondaryBloom;
SecondaryEffectiveLength = secondaryEffectiveLength;
SecondaryIsAllLowercaseAsciiOrNonLetter = secondaryIsAllLowercaseAsciiOrNonLetter;
}
}

View File

@@ -0,0 +1,46 @@
// Copyright (c) Microsoft Corporation
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
namespace Microsoft.CmdPal.Core.Common.Text;
public readonly struct FuzzyTarget
{
public readonly string Original;
public readonly string Folded;
public readonly ulong Bloom;
public readonly string? SecondaryOriginal;
public readonly string? SecondaryFolded;
public readonly ulong SecondaryBloom;
public int Length => Folded.Length;
public bool HasSecondary => SecondaryFolded is not null;
public int SecondaryLength => SecondaryFolded?.Length ?? 0;
public ReadOnlySpan<char> OriginalSpan => Original.AsSpan();
public ReadOnlySpan<char> FoldedSpan => Folded.AsSpan();
public ReadOnlySpan<char> SecondaryOriginalSpan => SecondaryOriginal.AsSpan();
public ReadOnlySpan<char> SecondaryFoldedSpan => SecondaryFolded.AsSpan();
public FuzzyTarget(
string original,
string folded,
ulong bloom,
string? secondaryOriginal = null,
string? secondaryFolded = null,
ulong secondaryBloom = 0)
{
Original = original;
Folded = folded;
Bloom = bloom;
SecondaryOriginal = secondaryOriginal;
SecondaryFolded = secondaryFolded;
SecondaryBloom = secondaryBloom;
}
}

View File

@@ -0,0 +1,34 @@
// Copyright (c) Microsoft Corporation
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
namespace Microsoft.CmdPal.Core.Common.Text;
public struct FuzzyTargetCache
{
private string? _lastRaw;
private uint _schemaId;
private FuzzyTarget _target;
public FuzzyTarget GetOrUpdate(IPrecomputedFuzzyMatcher matcher, string? raw)
{
raw ??= string.Empty;
if (_schemaId == matcher.SchemaId && string.Equals(_lastRaw, raw, StringComparison.Ordinal))
{
return _target;
}
_target = matcher.PrecomputeTarget(raw);
_schemaId = matcher.SchemaId;
_lastRaw = raw;
return _target;
}
public void Invalidate()
{
_lastRaw = null;
_target = default;
_schemaId = 0;
}
}

View File

@@ -0,0 +1,12 @@
// Copyright (c) Microsoft Corporation
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
namespace Microsoft.CmdPal.Core.Common.Text;
public interface IBloomFilter
{
ulong Compute(string input);
bool MightContain(ulong candidateBloom, ulong queryBloom);
}

View File

@@ -0,0 +1,12 @@
// Copyright (c) Microsoft Corporation
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
namespace Microsoft.CmdPal.Core.Common.Text;
public interface IFuzzyMatcherProvider
{
IPrecomputedFuzzyMatcher Current { get; }
void UpdateSettings(PrecomputedFuzzyMatcherOptions core, PinyinFuzzyMatcherOptions? pinyin = null);
}

View File

@@ -0,0 +1,16 @@
// Copyright (c) Microsoft Corporation
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
namespace Microsoft.CmdPal.Core.Common.Text;
public interface IPrecomputedFuzzyMatcher
{
uint SchemaId { get; }
FuzzyQuery PrecomputeQuery(string? input);
FuzzyTarget PrecomputeTarget(string? input);
int Score(scoped in FuzzyQuery query, scoped in FuzzyTarget target);
}

View File

@@ -0,0 +1,10 @@
// Copyright (c) Microsoft Corporation
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
namespace Microsoft.CmdPal.Core.Common.Text;
public interface IStringFolder
{
string Fold(string input, bool removeDiacritics);
}

View File

@@ -0,0 +1,13 @@
// Copyright (c) Microsoft Corporation
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
namespace Microsoft.CmdPal.Core.Common.Text;
public sealed class PinyinFuzzyMatcherOptions
{
public PinyinMode Mode { get; init; } = PinyinMode.AutoSimplifiedChineseUi;
/// <summary>Remove IME syllable separators (') for query secondary variant.</summary>
public bool RemoveApostrophesForQuery { get; init; } = true;
}

View File

@@ -0,0 +1,12 @@
// Copyright (c) Microsoft Corporation
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
namespace Microsoft.CmdPal.Core.Common.Text;
public enum PinyinMode
{
Off = 0,
AutoSimplifiedChineseUi = 1,
On = 2,
}

View File

@@ -0,0 +1,575 @@
// Copyright (c) Microsoft Corporation
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
using System.Buffers;
using System.Diagnostics;
using System.Runtime.CompilerServices;
using System.Runtime.InteropServices;
namespace Microsoft.CmdPal.Core.Common.Text;
public sealed class PrecomputedFuzzyMatcher : IPrecomputedFuzzyMatcher
{
private const int NoMatchScore = 0;
private const int StackallocThresholdChars = 512;
private const int FolderSchemaVersion = 1;
private const int BloomSchemaVersion = 1;
private readonly PrecomputedFuzzyMatcherOptions _options;
private readonly IStringFolder _stringFolder;
private readonly IBloomFilter _bloom;
public PrecomputedFuzzyMatcher(
PrecomputedFuzzyMatcherOptions? options = null,
IStringFolder? normalization = null,
IBloomFilter? bloomCalculator = null)
{
_options = options ?? PrecomputedFuzzyMatcherOptions.Default;
_bloom = bloomCalculator ?? new BloomFilter();
_stringFolder = normalization ?? new StringFolder();
SchemaId = ComputeSchemaId(_options);
}
public uint SchemaId { get; }
public FuzzyQuery PrecomputeQuery(string? input) => PrecomputeQuery(input, null);
public FuzzyTarget PrecomputeTarget(string? input) => PrecomputeTarget(input, null);
public int Score(in FuzzyQuery query, in FuzzyTarget target)
{
var qFold = query.FoldedSpan;
var tLen = target.Length;
if (query.EffectiveLength == 0 || tLen == 0)
{
return NoMatchScore;
}
var skipWordSeparators = _options.SkipWordSeparators;
var bestScore = 0;
// 1. Primary → Primary
if (tLen >= query.EffectiveLength && _bloom.MightContain(target.Bloom, query.Bloom))
{
if (CanMatchSubsequence(qFold, target.FoldedSpan, skipWordSeparators))
{
bestScore = ScoreNonContiguous(
qRaw: query.OriginalSpan,
qFold: qFold,
qEffectiveLen: query.EffectiveLength,
tRaw: target.OriginalSpan,
tFold: target.FoldedSpan,
ignoreSameCaseBonusForThisQuery: _options.IgnoreSameCaseBonusIfQueryIsAllLowercase && query.IsAllLowercaseAsciiOrNonLetter);
}
}
// 2. Secondary → Secondary
if (query.HasSecondary && target.HasSecondary)
{
var qSecFold = query.SecondaryFoldedSpan;
if (target.SecondaryLength >= query.SecondaryEffectiveLength &&
_bloom.MightContain(target.SecondaryBloom, query.SecondaryBloom) &&
CanMatchSubsequence(qSecFold, target.SecondaryFoldedSpan, skipWordSeparators))
{
var score = ScoreNonContiguous(
qRaw: query.SecondaryOriginalSpan,
qFold: qSecFold,
qEffectiveLen: query.SecondaryEffectiveLength,
tRaw: target.SecondaryOriginalSpan,
tFold: target.SecondaryFoldedSpan,
ignoreSameCaseBonusForThisQuery: _options.IgnoreSameCaseBonusIfQueryIsAllLowercase && query.SecondaryIsAllLowercaseAsciiOrNonLetter);
if (score > bestScore)
{
bestScore = score;
}
}
}
// 3. Primary query → Secondary target
if (target.HasSecondary &&
target.SecondaryLength >= query.EffectiveLength &&
_bloom.MightContain(target.SecondaryBloom, query.Bloom))
{
if (CanMatchSubsequence(qFold, target.SecondaryFoldedSpan, skipWordSeparators))
{
var score = ScoreNonContiguous(
qRaw: query.OriginalSpan,
qFold: qFold,
qEffectiveLen: query.EffectiveLength,
tRaw: target.SecondaryOriginalSpan,
tFold: target.SecondaryFoldedSpan,
ignoreSameCaseBonusForThisQuery: _options.IgnoreSameCaseBonusIfQueryIsAllLowercase && query.IsAllLowercaseAsciiOrNonLetter);
if (score > bestScore)
{
bestScore = score;
}
}
}
// 4. Secondary query → Primary target
if (query.HasSecondary &&
tLen >= query.SecondaryEffectiveLength &&
_bloom.MightContain(target.Bloom, query.SecondaryBloom))
{
var qSecFold = query.SecondaryFoldedSpan;
if (CanMatchSubsequence(qSecFold, target.FoldedSpan, skipWordSeparators))
{
var score = ScoreNonContiguous(
qRaw: query.SecondaryOriginalSpan,
qFold: qSecFold,
qEffectiveLen: query.SecondaryEffectiveLength,
tRaw: target.OriginalSpan,
tFold: target.FoldedSpan,
ignoreSameCaseBonusForThisQuery: _options.IgnoreSameCaseBonusIfQueryIsAllLowercase && query.SecondaryIsAllLowercaseAsciiOrNonLetter);
if (score > bestScore)
{
bestScore = score;
}
}
}
return bestScore;
}
private FuzzyQuery PrecomputeQuery(string? input, string? secondaryInput)
{
input ??= string.Empty;
var folded = _stringFolder.Fold(input, _options.RemoveDiacritics);
var bloom = _bloom.Compute(folded);
var effectiveLength = _options.SkipWordSeparators
? folded.Length - CountWordSeparators(folded)
: folded.Length;
var isAllLowercase = IsAllLowercaseAsciiOrNonLetter(input);
string? secondaryOriginal = null;
string? secondaryFolded = null;
ulong secondaryBloom = 0;
var secondaryEffectiveLength = 0;
var secondaryIsAllLowercase = true;
if (!string.IsNullOrEmpty(secondaryInput))
{
secondaryOriginal = secondaryInput;
secondaryFolded = _stringFolder.Fold(secondaryInput, _options.RemoveDiacritics);
secondaryBloom = _bloom.Compute(secondaryFolded);
secondaryEffectiveLength = _options.SkipWordSeparators
? secondaryFolded.Length - CountWordSeparators(secondaryFolded)
: secondaryFolded.Length;
secondaryIsAllLowercase = IsAllLowercaseAsciiOrNonLetter(secondaryInput);
}
return new FuzzyQuery(
original: input,
folded: folded,
bloom: bloom,
effectiveLength: effectiveLength,
isAllLowercaseAsciiOrNonLetter: isAllLowercase,
secondaryOriginal: secondaryOriginal,
secondaryFolded: secondaryFolded,
secondaryBloom: secondaryBloom,
secondaryEffectiveLength: secondaryEffectiveLength,
secondaryIsAllLowercaseAsciiOrNonLetter: secondaryIsAllLowercase);
[MethodImpl(MethodImplOptions.AggressiveInlining)]
static int CountWordSeparators(string s)
{
var count = 0;
foreach (var c in s)
{
if (SymbolClassifier.Classify(c) == SymbolKind.WordSeparator)
{
count++;
}
}
return count;
}
}
internal FuzzyTarget PrecomputeTarget(string? input, string? secondaryInput)
{
input ??= string.Empty;
var folded = _stringFolder.Fold(input, _options.RemoveDiacritics);
var bloom = _bloom.Compute(folded);
string? secondaryFolded = null;
ulong secondaryBloom = 0;
if (!string.IsNullOrEmpty(secondaryInput))
{
secondaryFolded = _stringFolder.Fold(secondaryInput, _options.RemoveDiacritics);
secondaryBloom = _bloom.Compute(secondaryFolded);
}
return new FuzzyTarget(
input,
folded,
bloom,
secondaryInput,
secondaryFolded,
secondaryBloom);
}
[MethodImpl(MethodImplOptions.AggressiveInlining)]
private static bool IsAllLowercaseAsciiOrNonLetter(string s)
{
foreach (var c in s)
{
if ((uint)(c - 'A') <= ('Z' - 'A'))
{
return false;
}
}
return true;
}
[MethodImpl(MethodImplOptions.AggressiveInlining)]
private static bool CanMatchSubsequence(
ReadOnlySpan<char> qFold,
ReadOnlySpan<char> tFold,
bool skipWordSeparators)
{
var qi = 0;
var ti = 0;
while (qi < qFold.Length && ti < tFold.Length)
{
var qChar = qFold[qi];
if (skipWordSeparators && SymbolClassifier.Classify(qChar) == SymbolKind.WordSeparator)
{
qi++;
continue;
}
if (qChar == tFold[ti])
{
qi++;
}
ti++;
}
// Skip trailing word separators in query
if (skipWordSeparators)
{
while (qi < qFold.Length && SymbolClassifier.Classify(qFold[qi]) == SymbolKind.WordSeparator)
{
qi++;
}
}
return qi == qFold.Length;
}
[MethodImpl(MethodImplOptions.AggressiveOptimization)]
[SkipLocalsInit]
private int ScoreNonContiguous(
scoped in ReadOnlySpan<char> qRaw,
scoped in ReadOnlySpan<char> qFold,
int qEffectiveLen,
scoped in ReadOnlySpan<char> tRaw,
scoped in ReadOnlySpan<char> tFold,
bool ignoreSameCaseBonusForThisQuery)
{
Debug.Assert(qRaw.Length == qFold.Length, "Original and folded spans are traversed in lockstep: requires qRaw.Length == qFold.Length");
Debug.Assert(tRaw.Length == tFold.Length, "Original and folded spans are traversed in lockstep: requires tRaw.Length == tFold.Length");
Debug.Assert(qEffectiveLen <= qFold.Length, "Effective length must be less than or equal to folded length");
var qLen = qFold.Length;
var tLen = tFold.Length;
// Copy options to local variables to avoid repeated field accesses
var charMatchBonus = _options.CharMatchBonus;
var sameCaseBonus = ignoreSameCaseBonusForThisQuery ? 0 : _options.SameCaseBonus;
var consecutiveMultiplier = _options.ConsecutiveMultiplier;
var camelCaseBonus = _options.CamelCaseBonus;
var startOfWordBonus = _options.StartOfWordBonus;
var pathSeparatorBonus = _options.PathSeparatorBonus;
var wordSeparatorBonus = _options.WordSeparatorBonus;
var separatorAlignmentBonus = _options.SeparatorAlignmentBonus;
var exactSeparatorBonus = _options.ExactSeparatorBonus;
var skipWordSeparators = _options.SkipWordSeparators;
// DP buffer: two rows of length tLen
var bufferSize = tLen * 2;
int[]? rented = null;
try
{
scoped Span<int> buffer;
if (bufferSize <= StackallocThresholdChars)
{
buffer = stackalloc int[bufferSize];
}
else
{
rented = ArrayPool<int>.Shared.Rent(bufferSize);
buffer = rented.AsSpan(0, bufferSize);
}
var scores = buffer[..tLen];
var seqLens = buffer.Slice(tLen, tLen);
scores.Clear();
seqLens.Clear();
ref var scores0 = ref MemoryMarshal.GetReference(scores);
ref var seqLens0 = ref MemoryMarshal.GetReference(seqLens);
ref var qRaw0 = ref MemoryMarshal.GetReference(qRaw);
ref var qFold0 = ref MemoryMarshal.GetReference(qFold);
ref var tRaw0 = ref MemoryMarshal.GetReference(tRaw);
ref var tFold0 = ref MemoryMarshal.GetReference(tFold);
var qiEffective = 0;
for (var qi = 0; qi < qLen; qi++)
{
var qCharFold = Unsafe.Add(ref qFold0, qi);
var qCharKind = SymbolClassifier.Classify(qCharFold);
if (skipWordSeparators && qCharKind == SymbolKind.WordSeparator)
{
continue;
}
// Hoisted values
var qRawIsUpper = char.IsUpper(Unsafe.Add(ref qRaw0, qi));
// row computation
var leftScore = 0;
var diagScore = 0;
var diagSeqLen = 0;
// limit ti to ensure enough remaining characters to match the rest of the query
var tiMax = tLen - qEffectiveLen + qiEffective;
for (var ti = 0; ti <= tiMax; ti++)
{
var upScore = Unsafe.Add(ref scores0, ti);
var upSeqLen = Unsafe.Add(ref seqLens0, ti);
var charScore = 0;
if (diagScore != 0 || qiEffective == 0)
{
charScore = ComputeCharScore(
qi,
ti,
qCharFold,
qCharKind,
diagSeqLen,
qRawIsUpper,
ref tRaw0,
ref qFold0,
ref tFold0);
}
var candidateScore = diagScore + charScore;
if (charScore != 0 && candidateScore >= leftScore)
{
Unsafe.Add(ref scores0, ti) = candidateScore;
Unsafe.Add(ref seqLens0, ti) = diagSeqLen + 1;
leftScore = candidateScore;
}
else
{
Unsafe.Add(ref scores0, ti) = leftScore;
Unsafe.Add(ref seqLens0, ti) = 0;
/* leftScore remains unchanged */
}
diagScore = upScore;
diagSeqLen = upSeqLen;
}
// Early exit: no match possible
if (leftScore == 0)
{
return NoMatchScore;
}
// Advance effective query index
// Only counts non-separator characters if skipWordSeparators is enabled
qiEffective++;
if (qiEffective == qEffectiveLen)
{
return leftScore;
}
}
return scores[tLen - 1];
[MethodImpl(MethodImplOptions.AggressiveInlining | MethodImplOptions.AggressiveOptimization)]
int ComputeCharScore(
int qi,
int ti,
char qCharFold,
SymbolKind qCharKind,
int seqLen,
bool qCharRawCurrIsUpper,
ref char tRaw0,
ref char qFold0,
ref char tFold0)
{
// Match check:
// - exact folded char match always ok
// - otherwise, allow equivalence only for word separators (e.g. '_' matches '-')
var tCharFold = Unsafe.Add(ref tFold0, ti);
if (qCharFold != tCharFold)
{
if (!skipWordSeparators)
{
return 0;
}
if (qCharKind != SymbolKind.WordSeparator ||
SymbolClassifier.Classify(tCharFold) != SymbolKind.WordSeparator)
{
return 0;
}
}
// 0. Base char match bonus
var score = charMatchBonus;
// 1. Consecutive match bonus
if (seqLen > 0)
{
score += seqLen * consecutiveMultiplier;
}
// 2. Same case bonus
// Early outs to appease the branch predictor
if (sameCaseBonus != 0)
{
var tCharRawCurr = Unsafe.Add(ref tRaw0, ti);
var tCharRawCurrIsUpper = char.IsUpper(tCharRawCurr);
if (qCharRawCurrIsUpper == tCharRawCurrIsUpper)
{
score += sameCaseBonus;
}
if (ti == 0)
{
score += startOfWordBonus;
return score;
}
var tPrevFold = Unsafe.Add(ref tFold0, ti - 1);
var tPrevKind = SymbolClassifier.Classify(tPrevFold);
if (tPrevKind != SymbolKind.Other)
{
score += tPrevKind == SymbolKind.PathSeparator
? pathSeparatorBonus
: wordSeparatorBonus;
if (skipWordSeparators && seqLen == 0 && qi > 0)
{
var qPrevFold = Unsafe.Add(ref qFold0, qi - 1);
var qPrevKind = SymbolClassifier.Classify(qPrevFold);
if (qPrevKind == SymbolKind.WordSeparator)
{
score += separatorAlignmentBonus;
if (tPrevKind == SymbolKind.WordSeparator && qPrevFold == tPrevFold)
{
score += exactSeparatorBonus;
}
}
}
return score;
}
if (tCharRawCurrIsUpper && seqLen == 0)
{
score += camelCaseBonus;
return score;
}
return score;
}
else
{
if (ti == 0)
{
score += startOfWordBonus;
return score;
}
var tPrevFold = Unsafe.Add(ref tFold0, ti - 1);
var tPrevKind = SymbolClassifier.Classify(tPrevFold);
if (tPrevKind != SymbolKind.Other)
{
score += tPrevKind == SymbolKind.PathSeparator
? pathSeparatorBonus
: wordSeparatorBonus;
if (skipWordSeparators && seqLen == 0 && qi > 0)
{
var qPrevFold = Unsafe.Add(ref qFold0, qi - 1);
var qPrevKind = SymbolClassifier.Classify(qPrevFold);
if (qPrevKind == SymbolKind.WordSeparator)
{
score += separatorAlignmentBonus;
if (tPrevKind == SymbolKind.WordSeparator && qPrevFold == tPrevFold)
{
score += exactSeparatorBonus;
}
}
}
return score;
}
if (camelCaseBonus != 0 && seqLen == 0 && char.IsUpper(Unsafe.Add(ref tRaw0, ti)))
{
score += camelCaseBonus;
return score;
}
return score;
}
}
}
finally
{
if (rented is not null)
{
ArrayPool<int>.Shared.Return(rented);
}
}
}
// Schema ID is for cache invalidation of precomputed targets.
// Only includes options that affect folding/bloom, not scoring.
private static uint ComputeSchemaId(PrecomputedFuzzyMatcherOptions o)
{
const uint fnvOffset = 2166136261;
const uint fnvPrime = 16777619;
var h = fnvOffset;
h = unchecked((h ^ FolderSchemaVersion) * fnvPrime);
h = unchecked((h ^ BloomSchemaVersion) * fnvPrime);
h = unchecked((h ^ (uint)(o.RemoveDiacritics ? 1 : 0)) * fnvPrime);
return h;
}
}

View File

@@ -0,0 +1,40 @@
// Copyright (c) Microsoft Corporation
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
namespace Microsoft.CmdPal.Core.Common.Text;
public sealed class PrecomputedFuzzyMatcherOptions
{
public static PrecomputedFuzzyMatcherOptions Default { get; } = new();
/*
* Bonuses
*/
public int CharMatchBonus { get; init; } = 1;
public int SameCaseBonus { get; init; } = 1;
public int ConsecutiveMultiplier { get; init; } = 5;
public int CamelCaseBonus { get; init; } = 2;
public int StartOfWordBonus { get; init; } = 8;
public int PathSeparatorBonus { get; init; } = 5;
public int WordSeparatorBonus { get; init; } = 4;
public int SeparatorAlignmentBonus { get; init; } = 2;
public int ExactSeparatorBonus { get; init; } = 1;
/*
* Settings
*/
public bool RemoveDiacritics { get; init; } = true;
public bool SkipWordSeparators { get; init; } = true;
public bool IgnoreSameCaseBonusIfQueryIsAllLowercase { get; init; } = true;
}

View File

@@ -0,0 +1,177 @@
// Copyright (c) Microsoft Corporation
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
using System.Globalization;
using System.Runtime.CompilerServices;
using ToolGood.Words.Pinyin;
namespace Microsoft.CmdPal.Core.Common.Text;
public sealed class PrecomputedFuzzyMatcherWithPinyin : IPrecomputedFuzzyMatcher
{
private readonly IBloomFilter _bloom;
private readonly PrecomputedFuzzyMatcher _core;
private readonly IStringFolder _stringFolder;
private readonly PinyinFuzzyMatcherOptions _pinyin;
public PrecomputedFuzzyMatcherWithPinyin(
PrecomputedFuzzyMatcherOptions coreOptions,
PinyinFuzzyMatcherOptions pinyinOptions,
IStringFolder stringFolder,
IBloomFilter bloom)
{
_pinyin = pinyinOptions;
_stringFolder = stringFolder;
_bloom = bloom;
_core = new PrecomputedFuzzyMatcher(coreOptions, stringFolder, bloom);
SchemaId = CombineSchema(_core.SchemaId, _pinyin);
}
public uint SchemaId { get; }
public FuzzyQuery PrecomputeQuery(string? input)
{
input ??= string.Empty;
var primary = _core.PrecomputeQuery(input);
// Fast exit if effectively off (provider should already filter, but keep robust)
if (!IsPinyinEnabled(_pinyin))
{
return primary;
}
// Match legacy: remove apostrophes for query secondary
var queryForPinyin = _pinyin.RemoveApostrophesForQuery ? RemoveApostrophesIfAny(input) : input;
var pinyin = WordsHelper.GetPinyin(queryForPinyin);
if (string.IsNullOrEmpty(pinyin))
{
return primary;
}
var secondary = _core.PrecomputeQuery(pinyin);
return new FuzzyQuery(
primary.Original,
primary.Folded,
primary.Bloom,
primary.EffectiveLength,
primary.IsAllLowercaseAsciiOrNonLetter,
secondary.Original,
secondary.Folded,
secondary.Bloom,
secondary.EffectiveLength,
secondary.SecondaryIsAllLowercaseAsciiOrNonLetter);
}
public FuzzyTarget PrecomputeTarget(string? input)
{
input ??= string.Empty;
var primary = _core.PrecomputeTarget(input);
if (!IsPinyinEnabled(_pinyin))
{
return primary;
}
// Match legacy: only compute target pinyin when target contains Chinese
if (!ContainsToolGoodChinese(input))
{
return primary;
}
var pinyin = WordsHelper.GetPinyin(input);
if (string.IsNullOrEmpty(pinyin))
{
return primary;
}
var secondary = _core.PrecomputeTarget(pinyin);
return new FuzzyTarget(
primary.Original,
primary.Folded,
primary.Bloom,
secondary.Original,
secondary.Folded,
secondary.Bloom);
}
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public int Score(scoped in FuzzyQuery query, scoped in FuzzyTarget target)
=> _core.Score(in query, in target);
private static bool IsPinyinEnabled(PinyinFuzzyMatcherOptions o) => o.Mode switch
{
PinyinMode.Off => false,
PinyinMode.On => true,
PinyinMode.AutoSimplifiedChineseUi => IsSimplifiedChineseUi(),
_ => false,
};
private static bool IsSimplifiedChineseUi()
{
var culture = CultureInfo.CurrentUICulture;
return culture.Name.StartsWith("zh-CN", StringComparison.OrdinalIgnoreCase)
|| culture.Name.StartsWith("zh-Hans", StringComparison.OrdinalIgnoreCase);
}
private static bool ContainsToolGoodChinese(string s)
{
return WordsHelper.HasChinese(s);
}
private static string RemoveApostrophesIfAny(string input)
{
var first = input.IndexOf('\'');
if (first < 0)
{
return input;
}
var removeCount = 1;
for (var i = first + 1; i < input.Length; i++)
{
if (input[i] == '\'')
{
removeCount++;
}
}
return string.Create(input.Length - removeCount, input, static (dst, src) =>
{
var di = 0;
for (var i = 0; i < src.Length; i++)
{
var c = src[i];
if (c == '\'')
{
continue;
}
dst[di++] = c;
}
});
}
private static uint CombineSchema(uint coreSchemaId, PinyinFuzzyMatcherOptions p)
{
const uint fnvOffset = 2166136261;
const uint fnvPrime = 16777619;
var h = fnvOffset;
h = unchecked((h ^ coreSchemaId) * fnvPrime);
h = unchecked((h ^ (uint)p.Mode) * fnvPrime);
h = unchecked((h ^ (p.RemoveApostrophesForQuery ? 1u : 0u)) * fnvPrime);
// bump if you change formatting/conversion behavior
const uint pinyinAlgoVersion = 1;
h = unchecked((h ^ pinyinAlgoVersion) * fnvPrime);
return h;
}
}

View File

@@ -0,0 +1,163 @@
// Copyright (c) Microsoft Corporation
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
using System.Globalization;
using System.Runtime.CompilerServices;
using System.Text;
namespace Microsoft.CmdPal.Core.Common.Text;
public sealed class StringFolder : IStringFolder
{
// Cache for diacritic-stripped uppercase characters.
// Benign race: worst case is redundant computation writing the same value.
// 0 = uncached, else cachedChar + 1
private static readonly ushort[] StripCacheUpper = new ushort[char.MaxValue + 1];
public string Fold(string input, bool removeDiacritics)
{
if (string.IsNullOrEmpty(input))
{
return string.Empty;
}
if (!removeDiacritics || Ascii.IsValid(input))
{
if (IsAlreadyFoldedAndSlashNormalized(input))
{
return input;
}
return string.Create(input.Length, input, static (dst, src) =>
{
for (var i = 0; i < src.Length; i++)
{
var c = src[i];
dst[i] = c == '\\' ? '/' : char.ToUpperInvariant(c);
}
});
}
return string.Create(input.Length, input, static (dst, src) =>
{
for (var i = 0; i < src.Length; i++)
{
var c = src[i];
var upper = c == '\\' ? '/' : char.ToUpperInvariant(c);
dst[i] = StripDiacriticsFromUpper(upper);
}
});
}
private static bool IsAlreadyFoldedAndSlashNormalized(string input)
{
var sawNonAscii = false;
for (var i = 0; i < input.Length; i++)
{
var c = input[i];
if (c == '\\')
{
return false;
}
if ((uint)(c - 'a') <= 'z' - 'a')
{
return false;
}
if (c > 0x7F)
{
sawNonAscii = true;
}
}
if (sawNonAscii)
{
for (var i = 0; i < input.Length; i++)
{
var c = input[i];
if (c <= 0x7F)
{
continue;
}
var cat = CharUnicodeInfo.GetUnicodeCategory(c);
if (cat is UnicodeCategory.LowercaseLetter or UnicodeCategory.TitlecaseLetter)
{
return false;
}
}
}
return true;
}
[MethodImpl(MethodImplOptions.AggressiveInlining)]
private static char StripDiacriticsFromUpper(char upper)
{
if (upper <= 0x7F)
{
return upper;
}
// Never attempt normalization on lone UTF-16 surrogates.
if (char.IsSurrogate(upper))
{
return upper;
}
var cachedPlus1 = StripCacheUpper[upper];
if (cachedPlus1 != 0)
{
return (char)(cachedPlus1 - 1);
}
var mapped = StripDiacriticsSlow(upper);
StripCacheUpper[upper] = (ushort)(mapped + 1);
return mapped;
}
[MethodImpl(MethodImplOptions.NoInlining)]
private static char StripDiacriticsSlow(char upper)
{
try
{
var baseChar = FirstNonMark(upper, NormalizationForm.FormD);
if (baseChar == '\0' || baseChar == upper)
{
var kd = FirstNonMark(upper, NormalizationForm.FormKD);
if (kd != '\0')
{
baseChar = kd;
}
}
return char.ToUpperInvariant(baseChar == '\0' ? upper : baseChar);
}
catch
{
// Absolute safety: if globalization tables ever throw for some reason,
// degrade gracefully rather than failing hard.
return upper;
}
static char FirstNonMark(char c, NormalizationForm form)
{
var normalized = c.ToString().Normalize(form);
foreach (var ch in normalized)
{
var cat = CharUnicodeInfo.GetUnicodeCategory(ch);
if (cat is not (UnicodeCategory.NonSpacingMark or UnicodeCategory.SpacingCombiningMark or UnicodeCategory.EnclosingMark))
{
return ch;
}
}
return '\0';
}
}
}

View File

@@ -0,0 +1,29 @@
// Copyright (c) Microsoft Corporation
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
using System.Runtime.CompilerServices;
namespace Microsoft.CmdPal.Core.Common.Text;
internal static class SymbolClassifier
{
// Embedded in .data section - no allocation, no static constructor
private static ReadOnlySpan<byte> Lookup =>
[
0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, // 0-15
0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, // 16-31
2, 0, 2, 0, 0, 0, 0, 2, 0, 0, 0, 0, 0, 2, 2, 1, // 32-47: space=2, "=2, '=2, -=2, .=2, /=1
0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 2, 0, 0, 0, 0, 0, // 48-63: :=2
0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, // 64-79
0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 0, 0, 2, // 80-95: _=2
0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, // 96-111
0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0 // 112-127
];
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public static SymbolKind Classify(char c)
{
return c > 0x7F ? SymbolKind.Other : (SymbolKind)Lookup[c];
}
}

View File

@@ -0,0 +1,12 @@
// Copyright (c) Microsoft Corporation
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
namespace Microsoft.CmdPal.Core.Common.Text;
internal enum SymbolKind : byte
{
Other = 0,
PathSeparator = 1,
WordSeparator = 2,
}

View File

@@ -4,6 +4,8 @@
using System.Diagnostics.CodeAnalysis;
using Microsoft.CmdPal.Core.Common;
using Microsoft.CmdPal.Core.Common.Helpers;
using Microsoft.CmdPal.Core.Common.Text;
using Microsoft.CmdPal.Core.ViewModels.Messages;
using Microsoft.CmdPal.Core.ViewModels.Models;
using Microsoft.CommandPalette.Extensions;
@@ -13,7 +15,7 @@ using Windows.ApplicationModel.DataTransfer;
namespace Microsoft.CmdPal.Core.ViewModels;
[DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.All)]
public partial class CommandItemViewModel : ExtensionObjectViewModel, ICommandBarContext
public partial class CommandItemViewModel : ExtensionObjectViewModel, ICommandBarContext, IPrecomputedListItem
{
public ExtensionObject<ICommandItem> Model => _commandItemModel;
@@ -22,6 +24,9 @@ public partial class CommandItemViewModel : ExtensionObjectViewModel, ICommandBa
private readonly ExtensionObject<ICommandItem> _commandItemModel = new(null);
private CommandContextItemViewModel? _defaultCommandContextItemViewModel;
private FuzzyTargetCache _titleCache;
private FuzzyTargetCache _subtitleCache;
internal InitializedState Initialized { get; private set; } = InitializedState.Uninitialized;
protected bool IsFastInitialized => IsInErrorState || Initialized.HasFlag(InitializedState.FastInitialized);
@@ -116,6 +121,8 @@ public partial class CommandItemViewModel : ExtensionObjectViewModel, ICommandBa
_itemTitle = model.Title;
Subtitle = model.Subtitle;
_titleCache.Invalidate();
_subtitleCache.Invalidate();
Initialized |= InitializedState.FastInitialized;
}
@@ -249,6 +256,8 @@ public partial class CommandItemViewModel : ExtensionObjectViewModel, ICommandBa
Subtitle = "Item failed to load";
MoreCommands = [];
_icon = _errorIcon;
_titleCache.Invalidate();
_subtitleCache.Invalidate();
Initialized |= InitializedState.Error;
}
@@ -286,6 +295,8 @@ public partial class CommandItemViewModel : ExtensionObjectViewModel, ICommandBa
Subtitle = "Item failed to load";
MoreCommands = [];
_icon = _errorIcon;
_titleCache.Invalidate();
_subtitleCache.Invalidate();
Initialized |= InitializedState.Error;
}
@@ -335,12 +346,14 @@ public partial class CommandItemViewModel : ExtensionObjectViewModel, ICommandBa
case nameof(Title):
_itemTitle = model.Title;
_titleCache.Invalidate();
break;
case nameof(Subtitle):
var modelSubtitle = model.Subtitle;
this.Subtitle = modelSubtitle;
_defaultCommandContextItemViewModel?.Subtitle = modelSubtitle;
_subtitleCache.Invalidate();
break;
case nameof(Icon):
@@ -415,6 +428,7 @@ public partial class CommandItemViewModel : ExtensionObjectViewModel, ICommandBa
// Extensions based on Command Palette SDK < 0.3 CommandItem class won't notify when Title changes because Command
// or Command.Name change. This is a workaround to ensure that the Title is always up-to-date for extensions with old SDK.
_itemTitle = model.Title;
_titleCache.Invalidate();
UpdateProperty(nameof(Title), nameof(Name));
_defaultCommandContextItemViewModel?.UpdateTitle(model.Command.Name);
@@ -436,6 +450,7 @@ public partial class CommandItemViewModel : ExtensionObjectViewModel, ICommandBa
private void UpdateTitle(string? title)
{
_itemTitle = title ?? string.Empty;
_titleCache.Invalidate();
UpdateProperty(nameof(Title));
}
@@ -456,6 +471,12 @@ public partial class CommandItemViewModel : ExtensionObjectViewModel, ICommandBa
UpdateProperty(nameof(DataPackage));
}
public FuzzyTarget GetTitleTarget(IPrecomputedFuzzyMatcher matcher)
=> _titleCache.GetOrUpdate(matcher, Title);
public FuzzyTarget GetSubtitleTarget(IPrecomputedFuzzyMatcher matcher)
=> _subtitleCache.GetOrUpdate(matcher, Subtitle);
protected override void UnsafeCleanup()
{
base.UnsafeCleanup();

View File

@@ -3,9 +3,12 @@
// See the LICENSE file in the project root for more information.
using System.Collections.ObjectModel;
using System.Runtime.CompilerServices;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Messaging;
using Microsoft.CmdPal.Core.Common;
using Microsoft.CmdPal.Core.Common.Helpers;
using Microsoft.CmdPal.Core.Common.Text;
using Microsoft.CmdPal.Core.ViewModels.Messages;
using Microsoft.CommandPalette.Extensions;
using Microsoft.CommandPalette.Extensions.Toolkit;
@@ -16,6 +19,8 @@ namespace Microsoft.CmdPal.Core.ViewModels;
public partial class ContextMenuViewModel : ObservableObject,
IRecipient<UpdateCommandBarMessage>
{
private readonly IFuzzyMatcherProvider _fuzzyMatcherProvider;
public ICommandBarContext? SelectedItem
{
get => field;
@@ -39,8 +44,9 @@ public partial class ContextMenuViewModel : ObservableObject,
private string _lastSearchText = string.Empty;
public ContextMenuViewModel()
public ContextMenuViewModel(IFuzzyMatcherProvider fuzzyMatcherProvider)
{
_fuzzyMatcherProvider = fuzzyMatcherProvider;
WeakReferenceMessenger.Default.Register<UpdateCommandBarMessage>(this);
}
@@ -91,13 +97,14 @@ public partial class ContextMenuViewModel : ObservableObject,
.OfType<CommandContextItemViewModel>()
.Where(c => c.ShouldBeVisible);
var newResults = ListHelpers.FilterList<CommandContextItemViewModel>(commands, searchText, ScoreContextCommand);
var query = _fuzzyMatcherProvider.Current.PrecomputeQuery(searchText);
var newResults = InternalListHelpers.FilterList(commands, in query, ScoreFunction);
ListHelpers.InPlaceUpdateList(FilteredItems, newResults);
}
private static int ScoreContextCommand(string query, CommandContextItemViewModel item)
private int ScoreFunction(in FuzzyQuery query, CommandContextItemViewModel item)
{
if (string.IsNullOrEmpty(query) || string.IsNullOrWhiteSpace(query))
if (string.IsNullOrWhiteSpace(query.Original))
{
return 1;
}
@@ -107,11 +114,21 @@ public partial class ContextMenuViewModel : ObservableObject,
return 0;
}
var nameMatch = FuzzyStringMatcher.ScoreFuzzy(query, item.Title);
var fuzzyMatcher = _fuzzyMatcherProvider.Current;
var title = item.GetTitleTarget(fuzzyMatcher);
var subtitle = item.GetSubtitleTarget(fuzzyMatcher);
var descriptionMatch = FuzzyStringMatcher.ScoreFuzzy(query, item.Subtitle);
var titleScore = fuzzyMatcher.Score(query, title);
var subtitleScore = (fuzzyMatcher.Score(query, subtitle) - 4) / 2;
return new[] { nameMatch, (descriptionMatch - 4) / 2, 0 }.Max();
return Max3(titleScore, subtitleScore, 0);
}
[MethodImpl(MethodImplOptions.AggressiveInlining)]
private static int Max3(int a, int b, int c)
{
var m = a > b ? a : b;
return m > c ? m : c;
}
/// <summary>

View File

@@ -18,6 +18,8 @@ namespace Microsoft.CmdPal.UI.ViewModels;
public sealed partial class AppearanceSettingsViewModel : ObservableObject, IDisposable
{
private static readonly Color DefaultTintColor = Color.FromArgb(255, 0, 120, 212);
private static readonly ObservableCollection<Color> WindowsColorSwatches = [
// row 0
@@ -128,10 +130,13 @@ public sealed partial class AppearanceSettingsViewModel : ObservableObject, IDis
OnPropertyChanged();
OnPropertyChanged(nameof(ColorizationModeIndex));
OnPropertyChanged(nameof(IsCustomTintVisible));
OnPropertyChanged(nameof(IsCustomTintIntensityVisible));
OnPropertyChanged(nameof(IsColorIntensityVisible));
OnPropertyChanged(nameof(IsImageTintIntensityVisible));
OnPropertyChanged(nameof(EffectiveTintIntensity));
OnPropertyChanged(nameof(IsBackgroundControlsVisible));
OnPropertyChanged(nameof(IsNoBackgroundVisible));
OnPropertyChanged(nameof(IsAccentColorControlsVisible));
OnPropertyChanged(nameof(IsResetButtonVisible));
if (value == ColorizationMode.WindowsAccentColor)
{
@@ -179,6 +184,19 @@ public sealed partial class AppearanceSettingsViewModel : ObservableObject, IDis
{
_settings.CustomThemeColorIntensity = value;
OnPropertyChanged();
OnPropertyChanged(nameof(EffectiveTintIntensity));
Save();
}
}
public int BackgroundImageTintIntensity
{
get => _settings.BackgroundImageTintIntensity;
set
{
_settings.BackgroundImageTintIntensity = value;
OnPropertyChanged();
OnPropertyChanged(nameof(EffectiveTintIntensity));
Save();
}
}
@@ -279,12 +297,108 @@ public sealed partial class AppearanceSettingsViewModel : ObservableObject, IDis
};
}
public int BackdropOpacity
{
get => _settings.BackdropOpacity;
set
{
if (_settings.BackdropOpacity != value)
{
_settings.BackdropOpacity = value;
OnPropertyChanged();
OnPropertyChanged(nameof(EffectiveBackdropStyle));
OnPropertyChanged(nameof(EffectiveImageOpacity));
Save();
}
}
}
public int BackdropStyleIndex
{
get => (int)_settings.BackdropStyle;
set
{
var newStyle = (BackdropStyle)value;
if (_settings.BackdropStyle != newStyle)
{
_settings.BackdropStyle = newStyle;
OnPropertyChanged();
OnPropertyChanged(nameof(IsBackdropOpacityVisible));
OnPropertyChanged(nameof(IsMicaBackdropDescriptionVisible));
OnPropertyChanged(nameof(IsBackgroundSettingsEnabled));
OnPropertyChanged(nameof(IsBackgroundNotAvailableVisible));
if (!IsBackgroundSettingsEnabled)
{
IsColorizationDetailsExpanded = false;
}
Save();
}
}
}
/// <summary>
/// Gets whether the backdrop opacity slider should be visible.
/// </summary>
public bool IsBackdropOpacityVisible =>
BackdropStyles.Get(_settings.BackdropStyle).SupportsOpacity;
/// <summary>
/// Gets whether the backdrop description (for styles without options) should be visible.
/// </summary>
public bool IsMicaBackdropDescriptionVisible =>
!BackdropStyles.Get(_settings.BackdropStyle).SupportsOpacity;
/// <summary>
/// Gets whether background/colorization settings are available.
/// </summary>
public bool IsBackgroundSettingsEnabled =>
BackdropStyles.Get(_settings.BackdropStyle).SupportsColorization;
/// <summary>
/// Gets whether the "not available" message should be shown (inverse of IsBackgroundSettingsEnabled).
/// </summary>
public bool IsBackgroundNotAvailableVisible =>
!BackdropStyles.Get(_settings.BackdropStyle).SupportsColorization;
public BackdropStyle? EffectiveBackdropStyle
{
get
{
// Return style when transparency/blur is visible (not fully opaque Acrylic)
// - Clear/Mica/MicaAlt/AcrylicThin always show their effect
// - Acrylic shows effect only when opacity < 100
if (_settings.BackdropStyle != BackdropStyle.Acrylic || _settings.BackdropOpacity < 100)
{
return _settings.BackdropStyle;
}
return null;
}
}
public double EffectiveImageOpacity =>
EffectiveBackdropStyle is not null
? (BackgroundImageOpacity / 100f) * Math.Sqrt(_settings.BackdropOpacity / 100.0)
: (BackgroundImageOpacity / 100f);
[ObservableProperty]
public partial bool IsColorizationDetailsExpanded { get; set; }
public bool IsCustomTintVisible => _settings.ColorizationMode is ColorizationMode.CustomColor or ColorizationMode.Image;
public bool IsCustomTintIntensityVisible => _settings.ColorizationMode is ColorizationMode.CustomColor or ColorizationMode.WindowsAccentColor or ColorizationMode.Image;
public bool IsColorIntensityVisible => _settings.ColorizationMode is ColorizationMode.CustomColor or ColorizationMode.WindowsAccentColor;
public bool IsImageTintIntensityVisible => _settings.ColorizationMode is ColorizationMode.Image;
/// <summary>
/// Gets the effective tint intensity for the preview, based on the current colorization mode.
/// </summary>
public int EffectiveTintIntensity => _settings.ColorizationMode is ColorizationMode.Image
? _settings.BackgroundImageTintIntensity
: _settings.CustomThemeColorIntensity;
public bool IsBackgroundControlsVisible => _settings.ColorizationMode is ColorizationMode.Image;
@@ -292,16 +406,21 @@ public sealed partial class AppearanceSettingsViewModel : ObservableObject, IDis
public bool IsAccentColorControlsVisible => _settings.ColorizationMode is ColorizationMode.WindowsAccentColor;
public AcrylicBackdropParameters EffectiveBackdrop { get; private set; } = new(Colors.Black, Colors.Black, 0.5f, 0.5f);
public bool IsResetButtonVisible => _settings.ColorizationMode is ColorizationMode.Image;
public BackdropParameters EffectiveBackdrop { get; private set; } = new(Colors.Black, Colors.Black, 0.5f, 0.5f);
public ElementTheme EffectiveTheme => _elementThemeOverride ?? _themeService.Current.Theme;
public Color EffectiveThemeColor => ColorizationMode switch
{
ColorizationMode.WindowsAccentColor => _currentSystemAccentColor,
ColorizationMode.CustomColor or ColorizationMode.Image => ThemeColor,
_ => Colors.Transparent,
};
public Color EffectiveThemeColor =>
!BackdropStyles.Get(_settings.BackdropStyle).SupportsColorization
? Colors.Transparent
: ColorizationMode switch
{
ColorizationMode.WindowsAccentColor => _currentSystemAccentColor,
ColorizationMode.CustomColor or ColorizationMode.Image => ThemeColor,
_ => Colors.Transparent,
};
// Since the blur amount is absolute, we need to scale it down for the preview (which is smaller than full screen).
public int EffectiveBackgroundImageBlurAmount => (int)Math.Round(BackgroundImageBlurAmount / 4f);
@@ -309,11 +428,13 @@ public sealed partial class AppearanceSettingsViewModel : ObservableObject, IDis
public double EffectiveBackgroundImageBrightness => BackgroundImageBrightness / 100.0;
public ImageSource? EffectiveBackgroundImageSource =>
ColorizationMode is ColorizationMode.Image
&& !string.IsNullOrWhiteSpace(BackgroundImagePath)
&& Uri.TryCreate(BackgroundImagePath, UriKind.RelativeOrAbsolute, out var uri)
? new Microsoft.UI.Xaml.Media.Imaging.BitmapImage(uri)
: null;
!BackdropStyles.Get(_settings.BackdropStyle).SupportsBackgroundImage
? null
: ColorizationMode is ColorizationMode.Image
&& !string.IsNullOrWhiteSpace(BackgroundImagePath)
&& Uri.TryCreate(BackgroundImagePath, UriKind.RelativeOrAbsolute, out var uri)
? new Microsoft.UI.Xaml.Media.Imaging.BitmapImage(uri)
: null;
public AppearanceSettingsViewModel(IThemeService themeService, SettingsModel settings)
{
@@ -327,7 +448,7 @@ public sealed partial class AppearanceSettingsViewModel : ObservableObject, IDis
Reapply();
IsColorizationDetailsExpanded = _settings.ColorizationMode != ColorizationMode.None;
IsColorizationDetailsExpanded = _settings.ColorizationMode != ColorizationMode.None && IsBackgroundSettingsEnabled;
}
private void UiSettingsOnColorValuesChanged(UISettings sender, object args) => _uiDispatcher.TryEnqueue(() => UpdateAccentColor(sender));
@@ -357,6 +478,8 @@ public sealed partial class AppearanceSettingsViewModel : ObservableObject, IDis
// Theme services recalculates effective color and opacity based on current settings.
EffectiveBackdrop = _themeService.Current.BackdropParameters;
OnPropertyChanged(nameof(EffectiveBackdrop));
OnPropertyChanged(nameof(EffectiveBackdropStyle));
OnPropertyChanged(nameof(EffectiveImageOpacity));
OnPropertyChanged(nameof(EffectiveBackgroundImageBrightness));
OnPropertyChanged(nameof(EffectiveBackgroundImageSource));
OnPropertyChanged(nameof(EffectiveThemeColor));
@@ -379,7 +502,28 @@ public sealed partial class AppearanceSettingsViewModel : ObservableObject, IDis
BackgroundImageBlurAmount = 0;
BackgroundImageFit = BackgroundImageFit.UniformToFill;
BackgroundImageOpacity = 100;
ColorIntensity = 0;
BackgroundImageTintIntensity = 0;
}
[RelayCommand]
private void ResetAppearanceSettings()
{
// Reset theme
Theme = UserTheme.Default;
// Reset backdrop settings
BackdropStyleIndex = (int)BackdropStyle.Acrylic;
BackdropOpacity = 100;
// Reset background image settings
BackgroundImagePath = string.Empty;
ResetBackgroundImageProperties();
// Reset colorization
ColorizationMode = ColorizationMode.None;
ThemeColor = DefaultTintColor;
ColorIntensity = 100;
BackgroundImageTintIntensity = 0;
}
public void Dispose()

View File

@@ -0,0 +1,41 @@
// Copyright (c) Microsoft Corporation
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
namespace Microsoft.CmdPal.UI.ViewModels;
/// <summary>
/// Specifies the type of system backdrop controller to use.
/// </summary>
public enum BackdropControllerKind
{
/// <summary>
/// Solid color with alpha transparency (TransparentTintBackdrop).
/// </summary>
Solid,
/// <summary>
/// Desktop Acrylic with default blur (DesktopAcrylicKind.Default).
/// </summary>
Acrylic,
/// <summary>
/// Desktop Acrylic with thinner blur (DesktopAcrylicKind.Thin).
/// </summary>
AcrylicThin,
/// <summary>
/// Mica effect (MicaKind.Base).
/// </summary>
Mica,
/// <summary>
/// Mica alternate/darker variant (MicaKind.BaseAlt).
/// </summary>
MicaAlt,
/// <summary>
/// Custom backdrop implementation.
/// </summary>
Custom,
}

View File

@@ -0,0 +1,36 @@
// Copyright (c) Microsoft Corporation
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
namespace Microsoft.CmdPal.UI.ViewModels;
/// <summary>
/// Specifies the visual backdrop style for the window.
/// </summary>
public enum BackdropStyle
{
/// <summary>
/// Standard desktop acrylic with blur effect.
/// </summary>
Acrylic,
/// <summary>
/// Solid color with alpha transparency (no blur).
/// </summary>
Clear,
/// <summary>
/// Mica effect that samples the desktop wallpaper.
/// </summary>
Mica,
/// <summary>
/// Thinner acrylic variant with more transparency.
/// </summary>
AcrylicThin,
/// <summary>
/// Mica alternate variant (darker).
/// </summary>
MicaAlt,
}

View File

@@ -0,0 +1,77 @@
// Copyright (c) Microsoft Corporation
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
namespace Microsoft.CmdPal.UI.ViewModels;
/// <summary>
/// Configuration parameters for a backdrop style.
/// </summary>
public sealed record BackdropStyleConfig
{
/// <summary>
/// Gets the type of system backdrop controller to use.
/// </summary>
public required BackdropControllerKind ControllerKind { get; init; }
/// <summary>
/// Gets the base tint opacity before user adjustments.
/// </summary>
public required float BaseTintOpacity { get; init; }
/// <summary>
/// Gets the base luminosity opacity before user adjustments.
/// </summary>
public required float BaseLuminosityOpacity { get; init; }
/// <summary>
/// Gets the brush type to use for preview approximation.
/// </summary>
public required PreviewBrushKind PreviewBrush { get; init; }
/// <summary>
/// Gets the fixed opacity for styles that don't support user adjustment (e.g., Mica).
/// When <see cref="SupportsOpacity"/> is false, this value is used as the effective opacity.
/// </summary>
public float FixedOpacity { get; init; }
/// <summary>
/// Gets whether this backdrop style supports custom colorization (tint colors).
/// </summary>
public bool SupportsColorization { get; init; } = true;
/// <summary>
/// Gets whether this backdrop style supports custom background images.
/// </summary>
public bool SupportsBackgroundImage { get; init; } = true;
/// <summary>
/// Gets whether this backdrop style supports opacity adjustment.
/// </summary>
public bool SupportsOpacity { get; init; } = true;
/// <summary>
/// Computes the effective tint opacity based on this style's configuration.
/// </summary>
/// <param name="userOpacity">User's backdrop opacity setting (0-1 normalized).</param>
/// <param name="baseTintOpacityOverride">Optional override for base tint opacity (used by colorful theme).</param>
/// <returns>The effective opacity to apply.</returns>
public float ComputeEffectiveOpacity(float userOpacity, float? baseTintOpacityOverride = null)
{
// For styles that don't support opacity (Mica), use FixedOpacity
if (!SupportsOpacity && FixedOpacity > 0)
{
return FixedOpacity;
}
// For Solid: only user opacity matters (controls alpha of solid color)
if (ControllerKind == BackdropControllerKind.Solid)
{
return userOpacity;
}
// For blur effects: multiply base opacity with user opacity
var baseTint = baseTintOpacityOverride ?? BaseTintOpacity;
return baseTint * userOpacity;
}
}

View File

@@ -0,0 +1,65 @@
// Copyright (c) Microsoft Corporation
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
namespace Microsoft.CmdPal.UI.ViewModels;
/// <summary>
/// Central registry of backdrop style configurations.
/// </summary>
public static class BackdropStyles
{
private static readonly Dictionary<BackdropStyle, BackdropStyleConfig> Configs = new()
{
[BackdropStyle.Acrylic] = new()
{
ControllerKind = BackdropControllerKind.Acrylic,
BaseTintOpacity = 0.5f,
BaseLuminosityOpacity = 0.9f,
PreviewBrush = PreviewBrushKind.Acrylic,
},
[BackdropStyle.AcrylicThin] = new()
{
ControllerKind = BackdropControllerKind.AcrylicThin,
BaseTintOpacity = 0.0f,
BaseLuminosityOpacity = 0.85f,
PreviewBrush = PreviewBrushKind.Acrylic,
},
[BackdropStyle.Mica] = new()
{
ControllerKind = BackdropControllerKind.Mica,
BaseTintOpacity = 0.0f,
BaseLuminosityOpacity = 1.0f,
PreviewBrush = PreviewBrushKind.Solid,
FixedOpacity = 0.96f,
SupportsOpacity = false,
},
[BackdropStyle.MicaAlt] = new()
{
ControllerKind = BackdropControllerKind.MicaAlt,
BaseTintOpacity = 0.0f,
BaseLuminosityOpacity = 1.0f,
PreviewBrush = PreviewBrushKind.Solid,
FixedOpacity = 0.98f,
SupportsOpacity = false,
},
[BackdropStyle.Clear] = new()
{
ControllerKind = BackdropControllerKind.Solid,
BaseTintOpacity = 1.0f,
BaseLuminosityOpacity = 1.0f,
PreviewBrush = PreviewBrushKind.Solid,
},
};
/// <summary>
/// Gets the configuration for the specified backdrop style.
/// </summary>
public static BackdropStyleConfig Get(BackdropStyle style) =>
Configs.TryGetValue(style, out var config) ? config : Configs[BackdropStyle.Acrylic];
/// <summary>
/// Gets all registered backdrop styles.
/// </summary>
public static IEnumerable<BackdropStyle> All => Configs.Keys;
}

View File

@@ -8,6 +8,7 @@ using System.Diagnostics;
using CommunityToolkit.Mvvm.Messaging;
using ManagedCommon;
using Microsoft.CmdPal.Core.Common.Helpers;
using Microsoft.CmdPal.Core.Common.Text;
using Microsoft.CmdPal.Core.ViewModels.Messages;
using Microsoft.CmdPal.Ext.Apps;
using Microsoft.CmdPal.Ext.Apps.Programs;
@@ -24,7 +25,7 @@ namespace Microsoft.CmdPal.UI.ViewModels.MainPage;
/// This class encapsulates the data we load from built-in providers and extensions to use within the same extension-UI system for a <see cref="ListPage"/>.
/// TODO: Need to think about how we structure/interop for the page -> section -> item between the main setup, the extensions, and our viewmodels.
/// </summary>
public partial class MainListPage : DynamicListPage,
public sealed partial class MainListPage : DynamicListPage,
IRecipient<ClearSearchMessage>,
IRecipient<UpdateFallbackItemsMessage>, IDisposable
{
@@ -32,13 +33,18 @@ public partial class MainListPage : DynamicListPage,
private readonly AliasManager _aliasManager;
private readonly SettingsModel _settings;
private readonly AppStateModel _appStateModel;
private List<Scored<IListItem>>? _filteredItems;
private List<Scored<IListItem>>? _filteredApps;
private readonly ScoringFunction<IListItem> _scoringFunction;
private readonly ScoringFunction<IListItem> _fallbackScoringFunction;
private readonly IFuzzyMatcherProvider _fuzzyMatcherProvider;
private RoScored<IListItem>[]? _filteredItems;
private RoScored<IListItem>[]? _filteredApps;
// Keep as IEnumerable for deferred execution. Fallback item titles are updated
// asynchronously, so scoring must happen lazily when GetItems is called.
private IEnumerable<Scored<IListItem>>? _scoredFallbackItems;
private IEnumerable<Scored<IListItem>>? _fallbackItems;
private IEnumerable<RoScored<IListItem>>? _scoredFallbackItems;
private IEnumerable<RoScored<IListItem>>? _fallbackItems;
private bool _includeApps;
private bool _filteredItemsIncludesApps;
private int _appResultLimit = 10;
@@ -48,7 +54,12 @@ public partial class MainListPage : DynamicListPage,
private CancellationTokenSource? _cancellationTokenSource;
public MainListPage(TopLevelCommandManager topLevelCommandManager, SettingsModel settings, AliasManager aliasManager, AppStateModel appStateModel)
public MainListPage(
TopLevelCommandManager topLevelCommandManager,
SettingsModel settings,
AliasManager aliasManager,
AppStateModel appStateModel,
IFuzzyMatcherProvider fuzzyMatcherProvider)
{
Title = Resources.builtin_home_name;
Icon = IconHelpers.FromRelativePath("Assets\\StoreLogo.scale-200.png");
@@ -58,6 +69,10 @@ public partial class MainListPage : DynamicListPage,
_aliasManager = aliasManager;
_appStateModel = appStateModel;
_tlcManager = topLevelCommandManager;
_fuzzyMatcherProvider = fuzzyMatcherProvider;
_scoringFunction = (in query, item) => ScoreTopLevelItem(in query, item, _appStateModel.RecentCommands, _fuzzyMatcherProvider.Current);
_fallbackScoringFunction = (in _, item) => ScoreFallbackItem(item, _settings.FallbackRanks);
_tlcManager.PropertyChanged += TlcManager_PropertyChanged;
_tlcManager.TopLevelCommands.CollectionChanged += Commands_CollectionChanged;
@@ -190,8 +205,7 @@ public partial class MainListPage : DynamicListPage,
public override void UpdateSearchText(string oldSearch, string newSearch)
{
var timer = new Stopwatch();
timer.Start();
var stopwatch = Stopwatch.StartNew();
_cancellationTokenSource?.Cancel();
_cancellationTokenSource?.Dispose();
@@ -354,15 +368,14 @@ public partial class MainListPage : DynamicListPage,
if (_includeApps)
{
var allNewApps = AllAppsCommandProvider.Page.GetItems().ToList();
var allNewApps = AllAppsCommandProvider.Page.GetItems().Cast<AppListItem>().ToList();
// We need to remove pinned apps from allNewApps so they don't show twice.
var pinnedApps = PinnedAppsManager.Instance.GetPinnedAppIdentifiers();
if (pinnedApps.Length > 0)
{
newApps = allNewApps.Where(w =>
pinnedApps.IndexOf(((AppListItem)w).AppIdentifier) < 0);
newApps = allNewApps.Where(w => pinnedApps.IndexOf(w.AppIdentifier) < 0);
}
else
{
@@ -376,11 +389,10 @@ public partial class MainListPage : DynamicListPage,
}
}
var history = _appStateModel.RecentCommands!;
Func<string, IListItem, int> scoreItem = (a, b) => { return ScoreTopLevelItem(a, b, history); };
var searchQuery = _fuzzyMatcherProvider.Current.PrecomputeQuery(SearchText);
// Produce a list of everything that matches the current filter.
_filteredItems = [.. ListHelpers.FilterListWithScores<IListItem>(newFilteredItems ?? [], SearchText, scoreItem)];
_filteredItems = InternalListHelpers.FilterListWithScores(newFilteredItems, searchQuery, _scoringFunction);
if (token.IsCancellationRequested)
{
@@ -388,21 +400,14 @@ public partial class MainListPage : DynamicListPage,
}
IEnumerable<IListItem> newFallbacksForScoring = commands.Where(s => s.IsFallback && globalFallbacks.Contains(s.Id));
_scoredFallbackItems = InternalListHelpers.FilterListWithScores(newFallbacksForScoring, searchQuery, _scoringFunction);
if (token.IsCancellationRequested)
{
return;
}
_scoredFallbackItems = ListHelpers.FilterListWithScores<IListItem>(newFallbacksForScoring ?? [], SearchText, scoreItem);
if (token.IsCancellationRequested)
{
return;
}
Func<string, IListItem, int> scoreFallbackItem = (a, b) => { return ScoreFallbackItem(a, b, _settings.FallbackRanks); };
_fallbackItems = [.. ListHelpers.FilterListWithScores<IListItem>(newFallbacks ?? [], SearchText, scoreFallbackItem)];
_fallbackItems = InternalListHelpers.FilterListWithScores<IListItem>(newFallbacks ?? [], searchQuery, _fallbackScoringFunction);
if (token.IsCancellationRequested)
{
@@ -412,18 +417,7 @@ public partial class MainListPage : DynamicListPage,
// Produce a list of filtered apps with the appropriate limit
if (newApps.Any())
{
var scoredApps = ListHelpers.FilterListWithScores<IListItem>(newApps, SearchText, scoreItem);
if (token.IsCancellationRequested)
{
return;
}
// We'll apply this limit in the GetItems method after merging with commands
// but we need to know the limit now to avoid re-scoring apps
var appLimit = AllAppsCommandProvider.TopLevelResultLimit;
_filteredApps = [.. scoredApps];
_filteredApps = InternalListHelpers.FilterListWithScores(newApps, searchQuery, _scoringFunction);
if (token.IsCancellationRequested)
{
@@ -431,10 +425,15 @@ public partial class MainListPage : DynamicListPage,
}
}
var filterDoneTimestamp = stopwatch.ElapsedMilliseconds;
Logger.LogDebug($"Filter with '{newSearch}' in {filterDoneTimestamp}ms");
RaiseItemsChanged();
timer.Stop();
Logger.LogDebug($"Filter with '{newSearch}' in {timer.ElapsedMilliseconds}ms");
var listPageUpdatedTimestamp = stopwatch.ElapsedMilliseconds;
Logger.LogDebug($"Render items with '{newSearch}' in {listPageUpdatedTimestamp}ms /d {listPageUpdatedTimestamp - filterDoneTimestamp}ms");
stopwatch.Stop();
}
}
@@ -478,7 +477,11 @@ public partial class MainListPage : DynamicListPage,
// Almost verbatim ListHelpers.ScoreListItem, but also accounting for the
// fact that we want fallback handlers down-weighted, so that they don't
// _always_ show up first.
internal static int ScoreTopLevelItem(string query, IListItem topLevelOrAppItem, IRecentCommandsManager history)
internal static int ScoreTopLevelItem(
in FuzzyQuery query,
IListItem topLevelOrAppItem,
IRecentCommandsManager history,
IPrecomputedFuzzyMatcher precomputedFuzzyMatcher)
{
var title = topLevelOrAppItem.Title;
if (string.IsNullOrWhiteSpace(title))
@@ -486,94 +489,80 @@ public partial class MainListPage : DynamicListPage,
return 0;
}
var isWhiteSpace = string.IsNullOrWhiteSpace(query);
var isFallback = false;
var isAliasSubstringMatch = false;
var isAliasMatch = false;
var id = IdForTopLevelOrAppItem(topLevelOrAppItem);
var extensionDisplayName = string.Empty;
FuzzyTarget? extensionDisplayNameTarget = null;
if (topLevelOrAppItem is TopLevelViewModel topLevel)
{
isFallback = topLevel.IsFallback;
extensionDisplayNameTarget = topLevel.GetExtensionNameTarget(precomputedFuzzyMatcher);
if (topLevel.HasAlias)
{
var alias = topLevel.AliasText;
isAliasMatch = alias == query;
isAliasSubstringMatch = isAliasMatch || alias.StartsWith(query, StringComparison.CurrentCultureIgnoreCase);
isAliasMatch = alias == query.Original;
isAliasSubstringMatch = isAliasMatch || alias.StartsWith(query.Original, StringComparison.CurrentCultureIgnoreCase);
}
extensionDisplayName = topLevel.ExtensionHost?.Extension?.PackageDisplayName ?? string.Empty;
}
// StringMatcher.FuzzySearch will absolutely BEEF IT if you give it a
// whitespace-only query.
//
// in that scenario, we'll just use a simple string contains for the
// query. Maybe someone is really looking for things with a space in
// them, I don't know.
// Title:
// * whitespace query: 1 point
// * otherwise full weight match
var nameMatch = isWhiteSpace ?
(title.Contains(query) ? 1 : 0) :
FuzzyStringMatcher.ScoreFuzzy(query, title);
// Subtitle:
// * whitespace query: 1/2 point
// * otherwise ~half weight match. Minus a bit, because subtitles tend to be longer
var descriptionMatch = isWhiteSpace ?
(topLevelOrAppItem.Subtitle.Contains(query) ? .5 : 0) :
(FuzzyStringMatcher.ScoreFuzzy(query, topLevelOrAppItem.Subtitle) - 4) / 2.0;
// Extension title: despite not being visible, give the extension name itself some weight
// * whitespace query: 0 points
// * otherwise more weight than a subtitle, but not much
var extensionTitleMatch = isWhiteSpace ? 0 : FuzzyStringMatcher.ScoreFuzzy(query, extensionDisplayName) / 1.5;
var scores = new[]
// Handle whitespace query separately - FuzzySearch doesn't handle it well
if (string.IsNullOrWhiteSpace(query.Original))
{
nameMatch,
descriptionMatch,
isFallback ? 1 : 0, // Always give fallbacks a chance
};
var max = scores.Max();
return ScoreWhitespaceQuery(query.Original, title, topLevelOrAppItem.Subtitle, isFallback);
}
// _Add_ the extension name. This will bubble items that match both
// title and extension name up above ones that just match title.
// e.g. "git" will up-weight "GitHub searches" from the GitHub extension
// above "git" from "whatever"
max = max + extensionTitleMatch;
// Get precomputed targets
var (titleTarget, subtitleTarget) = topLevelOrAppItem is IPrecomputedListItem precomputedItem
? (precomputedItem.GetTitleTarget(precomputedFuzzyMatcher), precomputedItem.GetSubtitleTarget(precomputedFuzzyMatcher))
: (precomputedFuzzyMatcher.PrecomputeTarget(title), precomputedFuzzyMatcher.PrecomputeTarget(topLevelOrAppItem.Subtitle));
// Score components
var nameScore = precomputedFuzzyMatcher.Score(query, titleTarget);
var descriptionScore = (precomputedFuzzyMatcher.Score(query, subtitleTarget) - 4) / 2.0;
var extensionScore = extensionDisplayNameTarget is { } extTarget ? precomputedFuzzyMatcher.Score(query, extTarget) / 1.5 : 0;
// Take best match from title/description/fallback, then add extension score
// Extension adds to max so items matching both title AND extension bubble up
var baseScore = Math.Max(Math.Max(nameScore, descriptionScore), isFallback ? 1 : 0);
var matchScore = baseScore + extensionScore;
// Apply a penalty to fallback items so they rank below direct matches.
// Fallbacks that dynamically match queries (like RDP connections) should
// appear after apps and direct command matches.
if (isFallback && max > 1)
if (isFallback && matchScore > 1)
{
// Reduce fallback scores by 50% to prioritize direct matches
max = max * 0.5;
matchScore = matchScore * 0.5;
}
var matchSomething = max
+ (isAliasMatch ? 9001 : (isAliasSubstringMatch ? 1 : 0));
// Alias matching: exact match is overwhelming priority, substring match adds a small boost
var aliasBoost = isAliasMatch ? 9001 : (isAliasSubstringMatch ? 1 : 0);
var totalMatch = matchScore + aliasBoost;
// If we matched title, subtitle, or alias (something real), then
// here we add the recent command weight boost
//
// Otherwise something like `x` will still match everything you've run before
var finalScore = matchSomething * 10;
if (matchSomething > 0)
// Apply scaling and history boost only if we matched something real
var finalScore = totalMatch * 10;
if (totalMatch > 0)
{
var recentWeightBoost = history.GetCommandHistoryWeight(id);
finalScore += recentWeightBoost;
finalScore += history.GetCommandHistoryWeight(id);
}
return (int)finalScore;
}
internal static int ScoreFallbackItem(string query, IListItem topLevelOrAppItem, string[] fallbackRanks)
private static int ScoreWhitespaceQuery(string query, string title, string subtitle, bool isFallback)
{
// Simple contains check for whitespace queries
var nameMatch = title.Contains(query, StringComparison.Ordinal) ? 1.0 : 0;
var descriptionMatch = subtitle.Contains(query, StringComparison.Ordinal) ? 0.5 : 0;
var baseScore = Math.Max(Math.Max(nameMatch, descriptionMatch), isFallback ? 1 : 0);
return (int)(baseScore * 10);
}
private static int ScoreFallbackItem(IListItem topLevelOrAppItem, string[] fallbackRanks)
{
// Default to 1 so it always shows in list.
var finalScore = 1;

View File

@@ -4,6 +4,7 @@
#pragma warning disable IDE0007 // Use implicit type
using Microsoft.CmdPal.Core.Common.Helpers;
using Microsoft.CommandPalette.Extensions;
using Microsoft.CommandPalette.Extensions.Toolkit;
@@ -16,10 +17,10 @@ internal static class MainListPageResultFactory
/// applying an application result limit and filtering fallback items as needed.
/// </summary>
public static IListItem[] Create(
IList<Scored<IListItem>>? filteredItems,
IList<Scored<IListItem>>? scoredFallbackItems,
IList<Scored<IListItem>>? filteredApps,
IList<Scored<IListItem>>? fallbackItems,
IList<RoScored<IListItem>>? filteredItems,
IList<RoScored<IListItem>>? scoredFallbackItems,
IList<RoScored<IListItem>>? filteredApps,
IList<RoScored<IListItem>>? fallbackItems,
int appResultLimit)
{
if (appResultLimit < 0)
@@ -147,7 +148,7 @@ internal static class MainListPageResultFactory
return result;
}
private static int GetNonEmptyFallbackItemsCount(IList<Scored<IListItem>>? fallbackItems)
private static int GetNonEmptyFallbackItemsCount(IList<RoScored<IListItem>>? fallbackItems)
{
int fallbackItemsCount = 0;

View File

@@ -22,6 +22,7 @@ public partial class MainWindowViewModel : ObservableObject, IDisposable
public partial Stretch BackgroundImageStretch { get; private set; } = Stretch.Fill;
[ObservableProperty]
[NotifyPropertyChangedFor(nameof(EffectiveImageOpacity))]
public partial double BackgroundImageOpacity { get; private set; }
[ObservableProperty]
@@ -39,6 +40,30 @@ public partial class MainWindowViewModel : ObservableObject, IDisposable
[ObservableProperty]
public partial bool ShowBackgroundImage { get; private set; }
[ObservableProperty]
[NotifyPropertyChangedFor(nameof(EffectiveBackdropStyle))]
[NotifyPropertyChangedFor(nameof(EffectiveImageOpacity))]
public partial BackdropStyle BackdropStyle { get; private set; }
[ObservableProperty]
[NotifyPropertyChangedFor(nameof(EffectiveBackdropStyle))]
[NotifyPropertyChangedFor(nameof(EffectiveImageOpacity))]
public partial float BackdropOpacity { get; private set; } = 1.0f;
// Returns null when no transparency needed (BlurImageControl uses this to decide source type)
public BackdropStyle? EffectiveBackdropStyle =>
BackdropStyle == BackdropStyle.Clear ||
BackdropStyle == BackdropStyle.Mica ||
BackdropOpacity < 1.0f
? BackdropStyle
: null;
// When transparency is enabled, use square root curve so image stays visible longer as backdrop fades
public double EffectiveImageOpacity =>
EffectiveBackdropStyle is not null
? BackgroundImageOpacity * Math.Sqrt(BackdropOpacity)
: BackgroundImageOpacity;
public MainWindowViewModel(IThemeService themeService)
{
_themeService = themeService;
@@ -58,6 +83,9 @@ public partial class MainWindowViewModel : ObservableObject, IDisposable
BackgroundImageTintIntensity = _themeService.Current.TintIntensity;
BackgroundImageBlurAmount = _themeService.Current.BlurAmount;
BackdropStyle = _themeService.Current.BackdropParameters.Style;
BackdropOpacity = _themeService.Current.BackdropOpacity;
ShowBackgroundImage = BackgroundImageSource != null;
});
}

View File

@@ -0,0 +1,21 @@
// Copyright (c) Microsoft Corporation
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
namespace Microsoft.CmdPal.UI.ViewModels;
/// <summary>
/// Specifies the brush type to use for backdrop preview approximation.
/// </summary>
public enum PreviewBrushKind
{
/// <summary>
/// SolidColorBrush with computed alpha.
/// </summary>
Solid,
/// <summary>
/// AcrylicBrush with blur effect.
/// </summary>
Acrylic,
}

View File

@@ -1,9 +0,0 @@
// Copyright (c) Microsoft Corporation
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
using Windows.UI;
namespace Microsoft.CmdPal.UI.ViewModels.Services;
public sealed record AcrylicBackdropParameters(Color TintColor, Color FallbackColor, float TintOpacity, float LuminosityOpacity);

View File

@@ -0,0 +1,29 @@
// Copyright (c) Microsoft Corporation
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
using Windows.UI;
namespace Microsoft.CmdPal.UI.ViewModels.Services;
/// <summary>
/// Parameters for configuring the window backdrop appearance.
/// </summary>
/// <param name="TintColor">The tint color applied to the backdrop.</param>
/// <param name="FallbackColor">The fallback color when backdrop effects are unavailable.</param>
/// <param name="EffectiveOpacity">
/// The effective opacity for the backdrop, pre-computed by the theme provider.
/// For Acrylic style: TintOpacity * BackdropOpacity.
/// For Clear style: BackdropOpacity (controls the solid color alpha).
/// </param>
/// <param name="EffectiveLuminosityOpacity">
/// The effective luminosity opacity for Acrylic backdrop, pre-computed by the theme provider.
/// Computed as LuminosityOpacity * BackdropOpacity.
/// </param>
/// <param name="Style">The backdrop style (Acrylic or Clear).</param>
public sealed record BackdropParameters(
Color TintColor,
Color FallbackColor,
float EffectiveOpacity,
float EffectiveLuminosityOpacity,
BackdropStyle Style = BackdropStyle.Acrylic);

View File

@@ -51,12 +51,23 @@ public sealed class ThemeSnapshot
public required double BackgroundImageOpacity { get; init; }
/// <summary>
/// Gets the effective acrylic backdrop parameters based on current settings and theme.
/// Gets the effective backdrop parameters based on current settings and theme.
/// </summary>
/// <returns>The resolved <c>AcrylicBackdropParameters</c> to apply.</returns>
public required AcrylicBackdropParameters BackdropParameters { get; init; }
/// <returns>The resolved <c>BackdropParameters</c> to apply.</returns>
public required BackdropParameters BackdropParameters { get; init; }
/// <summary>
/// Gets the raw backdrop opacity setting (0-1 range).
/// Used for determining if transparency is enabled and for image opacity calculations.
/// </summary>
public required float BackdropOpacity { get; init; }
public required int BlurAmount { get; init; }
public required float BackgroundBrightness { get; init; }
/// <summary>
/// Gets whether colorization is active (accent color, custom color, or image mode).
/// </summary>
public required bool HasColorization { get; init; }
}

View File

@@ -74,6 +74,8 @@ public partial class SettingsModel : ObservableObject
public int CustomThemeColorIntensity { get; set; } = 100;
public int BackgroundImageTintIntensity { get; set; }
public int BackgroundImageOpacity { get; set; } = 20;
public int BackgroundImageBlurAmount { get; set; }
@@ -84,6 +86,10 @@ public partial class SettingsModel : ObservableObject
public string? BackgroundImagePath { get; set; }
public BackdropStyle BackdropStyle { get; set; }
public int BackdropOpacity { get; set; } = 100;
// END SETTINGS
///////////////////////////////////////////////////////////////////////////

View File

@@ -3,8 +3,11 @@
// See the LICENSE file in the project root for more information.
using System.Collections.ObjectModel;
using System.Diagnostics;
using CommunityToolkit.Mvvm.ComponentModel;
using ManagedCommon;
using Microsoft.CmdPal.Core.Common.Helpers;
using Microsoft.CmdPal.Core.Common.Text;
using Microsoft.CmdPal.Core.ViewModels;
using Microsoft.CmdPal.Core.ViewModels.Messages;
using Microsoft.CmdPal.UI.ViewModels.Settings;
@@ -16,7 +19,8 @@ using WyHash;
namespace Microsoft.CmdPal.UI.ViewModels;
public sealed partial class TopLevelViewModel : ObservableObject, IListItem, IExtendedAttributesProvider
[DebuggerDisplay($"{{{nameof(GetDebuggerDisplay)}(),nq}}")]
public sealed partial class TopLevelViewModel : ObservableObject, IListItem, IExtendedAttributesProvider, IPrecomputedListItem
{
private readonly SettingsModel _settings;
private readonly ProviderSettings _providerSettings;
@@ -34,6 +38,10 @@ public sealed partial class TopLevelViewModel : ObservableObject, IListItem, IEx
private HotkeySettings? _hotkey;
private IIconInfo? _initialIcon;
private FuzzyTargetCache _titleCache;
private FuzzyTargetCache _subtitleCache;
private FuzzyTargetCache _extensionNameCache;
private CommandAlias? Alias { get; set; }
public bool IsFallback { get; private set; }
@@ -176,6 +184,8 @@ public sealed partial class TopLevelViewModel : ObservableObject, IListItem, IEx
}
}
public string ExtensionName => ExtensionHost.GetExtensionDisplayName() ?? string.Empty;
public TopLevelViewModel(
CommandItemViewModel item,
bool isFallback,
@@ -230,6 +240,15 @@ public sealed partial class TopLevelViewModel : ObservableObject, IListItem, IEx
{
PropChanged?.Invoke(this, new PropChangedEventArgs(e.PropertyName));
if (e.PropertyName is nameof(CommandItemViewModel.Title) or nameof(CommandItemViewModel.Name))
{
_titleCache.Invalidate();
}
else if (e.PropertyName is nameof(CommandItemViewModel.Subtitle))
{
_subtitleCache.Invalidate();
}
if (e.PropertyName is "IsInitialized" or nameof(CommandItemViewModel.Command))
{
GenerateId();
@@ -420,4 +439,18 @@ public sealed partial class TopLevelViewModel : ObservableObject, IListItem, IEx
[WellKnownExtensionAttributes.DataPackage] = _commandItemViewModel?.DataPackage,
};
}
public FuzzyTarget GetTitleTarget(IPrecomputedFuzzyMatcher matcher)
=> _titleCache.GetOrUpdate(matcher, Title);
public FuzzyTarget GetSubtitleTarget(IPrecomputedFuzzyMatcher matcher)
=> _subtitleCache.GetOrUpdate(matcher, Subtitle);
public FuzzyTarget GetExtensionNameTarget(IPrecomputedFuzzyMatcher matcher)
=> _extensionNameCache.GetOrUpdate(matcher, ExtensionName);
private string GetDebuggerDisplay()
{
return ToString();
}
}

View File

@@ -6,6 +6,7 @@ using ManagedCommon;
using Microsoft.CmdPal.Core.Common;
using Microsoft.CmdPal.Core.Common.Helpers;
using Microsoft.CmdPal.Core.Common.Services;
using Microsoft.CmdPal.Core.Common.Text;
using Microsoft.CmdPal.Core.ViewModels;
using Microsoft.CmdPal.Ext.Apps;
using Microsoft.CmdPal.Ext.Bookmarks;
@@ -67,11 +68,13 @@ public partial class App : Application, IDisposable
/// </summary>
public App()
{
var appInfoService = new ApplicationInfoService();
#if !CMDPAL_DISABLE_GLOBAL_ERROR_HANDLER
_globalErrorHandler.Register(this, GlobalErrorHandler.Options.Default);
_globalErrorHandler.Register(this, GlobalErrorHandler.Options.Default, appInfoService);
#endif
Services = ConfigureServices();
Services = ConfigureServices(appInfoService);
IconCacheProvider.Initialize(Services);
@@ -92,6 +95,9 @@ public partial class App : Application, IDisposable
// This way, log statements from the core project will be captured by the PT logs
var logWrapper = new LogWrapper();
CoreLogger.InitializeLogger(logWrapper);
// Now that CoreLogger is initialized, initialize the logger delegate in ApplicationInfoService
appInfoService.SetLogDirectory(() => Logger.CurrentVersionLogDirectoryPath);
}
/// <summary>
@@ -109,7 +115,7 @@ public partial class App : Application, IDisposable
/// <summary>
/// Configures the services for the application
/// </summary>
private static ServiceProvider ConfigureServices()
private static ServiceProvider ConfigureServices(IApplicationInfoService appInfoService)
{
// TODO: It's in the Labs feed, but we can use Sergio's AOT-friendly source generator for this: https://github.com/CommunityToolkit/Labs-Windows/discussions/463
ServiceCollection services = new();
@@ -120,7 +126,7 @@ public partial class App : Application, IDisposable
AddBuiltInCommands(services);
AddCoreServices(services);
AddCoreServices(services, appInfoService);
AddUIServices(services, dispatcherQueue);
@@ -196,9 +202,11 @@ public partial class App : Application, IDisposable
services.AddIconServices(dispatcherQueue);
}
private static void AddCoreServices(ServiceCollection services)
private static void AddCoreServices(ServiceCollection services, IApplicationInfoService appInfoService)
{
// Core services
services.AddSingleton(appInfoService);
services.AddSingleton<IExtensionService, ExtensionService>();
services.AddSingleton<IRunHistoryService, RunHistoryService>();
@@ -206,6 +214,9 @@ public partial class App : Application, IDisposable
services.AddSingleton<IAppHostService, PowerToysAppHostService>();
services.AddSingleton<ITelemetryService, TelemetryForwarder>();
services.AddSingleton<IFuzzyMatcherProvider, FuzzyMatcherProvider>(
_ => new FuzzyMatcherProvider(new PrecomputedFuzzyMatcherOptions(), new PinyinFuzzyMatcherOptions()));
// ViewModels
services.AddSingleton<ShellViewModel>();
services.AddSingleton<IPageViewModelFactoryService, CommandPalettePageViewModelFactory>();

View File

@@ -288,7 +288,6 @@ internal sealed partial class BlurImageControl : Control
_effectBrush?.Dispose();
_effectBrush = effectFactory.CreateBrush();
// Set initial source
if (ImageSource is not null)
{
_imageBrush ??= _compositor.CreateSurfaceBrush();

View File

@@ -16,24 +16,38 @@
CornerRadius="8"
Translation="0,0,8">
<Grid>
<!-- Clear style: SolidColorBrush with computed alpha (window backdrop) -->
<Border
HorizontalAlignment="Stretch"
VerticalAlignment="Stretch"
BorderBrush="{ThemeResource SurfaceStrokeColorDefaultBrush}"
BorderThickness="1"
Visibility="{x:Bind h:BindTransformers.NegateVisibility(ShowBackgroundImage), Mode=OneWay}">
Visibility="{x:Bind ClearVisibility, Mode=OneWay}">
<Border.Background>
<SolidColorBrush Color="{x:Bind EffectiveClearColor, Mode=OneWay}" />
</Border.Background>
</Border>
<!-- Acrylic/Mica style: AcrylicBrush with effective opacity (window backdrop) -->
<Border
HorizontalAlignment="Stretch"
VerticalAlignment="Stretch"
BorderBrush="{ThemeResource SurfaceStrokeColorDefaultBrush}"
BorderThickness="1"
Visibility="{x:Bind AcrylicVisibility, Mode=OneWay}">
<Border.Background>
<AcrylicBrush
FallbackColor="{x:Bind PreviewBackgroundColor, Mode=OneWay}"
TintColor="{x:Bind PreviewBackgroundColor, Mode=OneWay}"
TintOpacity="{x:Bind PreviewBackgroundOpacity, Mode=OneWay}" />
TintOpacity="{x:Bind PreviewEffectiveOpacity, Mode=OneWay}" />
</Border.Background>
</Border>
<!-- Background image (inside window, on top of backdrop) -->
<local:BlurImageControl
HorizontalAlignment="Stretch"
VerticalAlignment="Stretch"
BlurAmount="{x:Bind PreviewBackgroundImageBlurAmount, Mode=OneWay}"
ImageBrightness="{x:Bind PreviewBackgroundImageBrightness, Mode=OneWay}"
ImageOpacity="{x:Bind PreviewBackgroundImageOpacity, Mode=OneWay}"
ImageSource="{x:Bind PreviewBackgroundImageSource, Mode=OneWay}"
ImageStretch="{x:Bind ToStretch(PreviewBackgroundImageFit), Mode=OneWay}"
IsHitTestVisible="False"

View File

@@ -12,13 +12,11 @@ namespace Microsoft.CmdPal.UI.Controls;
public sealed partial class CommandPalettePreview : UserControl
{
public static readonly DependencyProperty PreviewBackgroundOpacityProperty = DependencyProperty.Register(nameof(PreviewBackgroundOpacity), typeof(double), typeof(CommandPalettePreview), new PropertyMetadata(0d));
public static readonly DependencyProperty PreviewBackgroundColorProperty = DependencyProperty.Register(nameof(PreviewBackgroundColor), typeof(Color), typeof(CommandPalettePreview), new PropertyMetadata(default(Color), OnBackdropPropertyChanged));
public static readonly DependencyProperty PreviewBackgroundColorProperty = DependencyProperty.Register(nameof(PreviewBackgroundColor), typeof(Color), typeof(CommandPalettePreview), new PropertyMetadata(default(Color)));
public static readonly DependencyProperty PreviewBackgroundImageSourceProperty = DependencyProperty.Register(nameof(PreviewBackgroundImageSource), typeof(ImageSource), typeof(CommandPalettePreview), new PropertyMetadata(null, OnBackgroundImageSourceChanged));
public static readonly DependencyProperty PreviewBackgroundImageSourceProperty = DependencyProperty.Register(nameof(PreviewBackgroundImageSource), typeof(ImageSource), typeof(CommandPalettePreview), new PropertyMetadata(null, PropertyChangedCallback));
public static readonly DependencyProperty PreviewBackgroundImageOpacityProperty = DependencyProperty.Register(nameof(PreviewBackgroundImageOpacity), typeof(int), typeof(CommandPalettePreview), new PropertyMetadata(0));
public static readonly DependencyProperty PreviewBackgroundImageOpacityProperty = DependencyProperty.Register(nameof(PreviewBackgroundImageOpacity), typeof(double), typeof(CommandPalettePreview), new PropertyMetadata(1.0));
public static readonly DependencyProperty PreviewBackgroundImageFitProperty = DependencyProperty.Register(nameof(PreviewBackgroundImageFit), typeof(BackgroundImageFit), typeof(CommandPalettePreview), new PropertyMetadata(default(BackgroundImageFit)));
@@ -30,7 +28,18 @@ public sealed partial class CommandPalettePreview : UserControl
public static readonly DependencyProperty PreviewBackgroundImageTintIntensityProperty = DependencyProperty.Register(nameof(PreviewBackgroundImageTintIntensity), typeof(int), typeof(CommandPalettePreview), new PropertyMetadata(0));
public static readonly DependencyProperty ShowBackgroundImageProperty = DependencyProperty.Register(nameof(ShowBackgroundImage), typeof(Visibility), typeof(CommandPalettePreview), new PropertyMetadata(Visibility.Collapsed));
public static readonly DependencyProperty ShowBackgroundImageProperty = DependencyProperty.Register(nameof(ShowBackgroundImage), typeof(Visibility), typeof(CommandPalettePreview), new PropertyMetadata(Visibility.Collapsed, OnVisibilityPropertyChanged));
public static readonly DependencyProperty PreviewBackdropStyleProperty = DependencyProperty.Register(nameof(PreviewBackdropStyle), typeof(BackdropStyle?), typeof(CommandPalettePreview), new PropertyMetadata(null, OnVisibilityPropertyChanged));
public static readonly DependencyProperty PreviewEffectiveOpacityProperty = DependencyProperty.Register(nameof(PreviewEffectiveOpacity), typeof(double), typeof(CommandPalettePreview), new PropertyMetadata(1.0, OnBackdropPropertyChanged));
// Computed read-only dependency properties
public static readonly DependencyProperty EffectiveClearColorProperty = DependencyProperty.Register(nameof(EffectiveClearColor), typeof(Color), typeof(CommandPalettePreview), new PropertyMetadata(default(Color)));
public static readonly DependencyProperty AcrylicVisibilityProperty = DependencyProperty.Register(nameof(AcrylicVisibility), typeof(Visibility), typeof(CommandPalettePreview), new PropertyMetadata(Visibility.Visible));
public static readonly DependencyProperty ClearVisibilityProperty = DependencyProperty.Register(nameof(ClearVisibility), typeof(Visibility), typeof(CommandPalettePreview), new PropertyMetadata(Visibility.Collapsed));
public BackgroundImageFit PreviewBackgroundImageFit
{
@@ -38,12 +47,6 @@ public sealed partial class CommandPalettePreview : UserControl
set { SetValue(PreviewBackgroundImageFitProperty, value); }
}
public double PreviewBackgroundOpacity
{
get { return (double)GetValue(PreviewBackgroundOpacityProperty); }
set { SetValue(PreviewBackgroundOpacityProperty, value); }
}
public Color PreviewBackgroundColor
{
get { return (Color)GetValue(PreviewBackgroundColorProperty); }
@@ -56,10 +59,10 @@ public sealed partial class CommandPalettePreview : UserControl
set { SetValue(PreviewBackgroundImageSourceProperty, value); }
}
public int PreviewBackgroundImageOpacity
public double PreviewBackgroundImageOpacity
{
get { return (int)GetValue(PreviewBackgroundImageOpacityProperty); }
set { SetValue(PreviewBackgroundImageOpacityProperty, value); }
get => (double)GetValue(PreviewBackgroundImageOpacityProperty);
set => SetValue(PreviewBackgroundImageOpacityProperty, value);
}
public double PreviewBackgroundImageBrightness
@@ -92,12 +95,48 @@ public sealed partial class CommandPalettePreview : UserControl
set => SetValue(ShowBackgroundImageProperty, value);
}
public BackdropStyle? PreviewBackdropStyle
{
get => (BackdropStyle?)GetValue(PreviewBackdropStyleProperty);
set => SetValue(PreviewBackdropStyleProperty, value);
}
/// <summary>
/// Gets or sets the effective opacity for the backdrop, pre-computed by the theme provider.
/// For Acrylic style: used directly as TintOpacity.
/// For Clear style: used to compute the alpha channel of the solid color.
/// </summary>
public double PreviewEffectiveOpacity
{
get => (double)GetValue(PreviewEffectiveOpacityProperty);
set => SetValue(PreviewEffectiveOpacityProperty, value);
}
// Computed read-only properties
public Color EffectiveClearColor
{
get => (Color)GetValue(EffectiveClearColorProperty);
private set => SetValue(EffectiveClearColorProperty, value);
}
public Visibility AcrylicVisibility
{
get => (Visibility)GetValue(AcrylicVisibilityProperty);
private set => SetValue(AcrylicVisibilityProperty, value);
}
public Visibility ClearVisibility
{
get => (Visibility)GetValue(ClearVisibilityProperty);
private set => SetValue(ClearVisibilityProperty, value);
}
public CommandPalettePreview()
{
InitializeComponent();
}
private static void PropertyChangedCallback(DependencyObject d, DependencyPropertyChangedEventArgs e)
private static void OnBackgroundImageSourceChanged(DependencyObject d, DependencyPropertyChangedEventArgs e)
{
if (d is not CommandPalettePreview preview)
{
@@ -107,7 +146,46 @@ public sealed partial class CommandPalettePreview : UserControl
preview.ShowBackgroundImage = e.NewValue is ImageSource ? Visibility.Visible : Visibility.Collapsed;
}
private double ToOpacity(int value) => value / 100.0;
private static void OnBackdropPropertyChanged(DependencyObject d, DependencyPropertyChangedEventArgs e)
{
if (d is not CommandPalettePreview preview)
{
return;
}
preview.UpdateComputedClearColor();
}
private static void OnVisibilityPropertyChanged(DependencyObject d, DependencyPropertyChangedEventArgs e)
{
if (d is not CommandPalettePreview preview)
{
return;
}
preview.UpdateComputedVisibilityProperties();
preview.UpdateComputedClearColor();
}
private void UpdateComputedClearColor()
{
EffectiveClearColor = Color.FromArgb(
(byte)(PreviewEffectiveOpacity * 255),
PreviewBackgroundColor.R,
PreviewBackgroundColor.G,
PreviewBackgroundColor.B);
}
private void UpdateComputedVisibilityProperties()
{
var config = BackdropStyles.Get(PreviewBackdropStyle ?? BackdropStyle.Acrylic);
// Show backdrop effect based on style (on top of any background image)
AcrylicVisibility = config.PreviewBrush == PreviewBrushKind.Acrylic
? Visibility.Visible : Visibility.Collapsed;
ClearVisibility = config.PreviewBrush == PreviewBrushKind.Solid
? Visibility.Visible : Visibility.Collapsed;
}
private double ToTintIntensity(int value) => value / 100.0;

View File

@@ -4,9 +4,11 @@
using CommunityToolkit.Mvvm.Messaging;
using CommunityToolkit.WinUI;
using Microsoft.CmdPal.Core.Common.Text;
using Microsoft.CmdPal.Core.ViewModels;
using Microsoft.CmdPal.Core.ViewModels.Messages;
using Microsoft.CmdPal.UI.Messages;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.UI.Input;
using Microsoft.UI.Xaml;
using Microsoft.UI.Xaml.Controls;
@@ -21,21 +23,19 @@ public sealed partial class ContextMenu : UserControl,
IRecipient<UpdateCommandBarMessage>,
IRecipient<TryCommandKeybindingMessage>
{
public ContextMenuViewModel ViewModel { get; } = new();
public ContextMenuViewModel ViewModel { get; }
public ContextMenu()
{
this.InitializeComponent();
ViewModel = new ContextMenuViewModel(App.Current.Services.GetRequiredService<IFuzzyMatcherProvider>());
ViewModel.PropertyChanged += ViewModel_PropertyChanged;
// RegisterAll isn't AOT compatible
WeakReferenceMessenger.Default.Register<OpenContextMenuMessage>(this);
WeakReferenceMessenger.Default.Register<UpdateCommandBarMessage>(this);
WeakReferenceMessenger.Default.Register<TryCommandKeybindingMessage>(this);
if (ViewModel is not null)
{
ViewModel.PropertyChanged += ViewModel_PropertyChanged;
}
}
public void Receive(OpenContextMenuMessage message)

View File

@@ -206,9 +206,10 @@
<!-- More section -->
<TextBlock Style="{ThemeResource SettingsSectionHeaderTextBlockStyle}" Text="More" />
<Border>
<StackPanel Orientation="Horizontal" Spacing="8">
<Button Command="{x:Bind ViewModel.OpenInternalToolsCommand}" Content="Open internal tools" />
</Border>
<Button Command="{x:Bind ViewModel.ToggleDevRibbonVisibilityCommand}" Content="Hide me" />
</StackPanel>
</StackPanel>
<!-- Footer -->

View File

@@ -16,12 +16,12 @@ namespace Microsoft.CmdPal.UI.Controls;
/// </summary>
public partial class IconBox : ContentControl
{
private const double DefaultIconFontSize = 16.0;
private double _lastScale;
private ElementTheme _lastTheme;
private double _lastFontSize;
private const double DefaultIconFontSize = 16.0;
/// <summary>
/// Gets or sets the <see cref="IconSource"/> to display within the <see cref="IconBox"/>. Overwritten, if <see cref="SourceKey"/> is used instead.
/// </summary>
@@ -62,6 +62,12 @@ public partial class IconBox : ContentControl
{
Refresh();
}
#if DEBUG
if (_sourceRequested?.GetInvocationList().Length > 1)
{
Logger.LogWarning("There shouldn't be more than one handler for IconBox.SourceRequested");
}
#endif
}
remove => _sourceRequested -= value;
}
@@ -102,9 +108,12 @@ public partial class IconBox : ContentControl
if (Source is FontIconSource fontIcon)
{
fontIcon.FontSize = _lastFontSize;
UpdatePaddingForFontIcon();
}
}
private void UpdatePaddingForFontIcon() => Padding = new Thickness(Math.Round(_lastFontSize * -0.2));
private void OnActualThemeChanged(FrameworkElement sender, object args)
{
if (_lastTheme == ActualTheme)
@@ -150,10 +159,7 @@ public partial class IconBox : ContentControl
private void Refresh()
{
if (SourceKey is not null)
{
UpdateSourceKey(this, SourceKey);
}
UpdateSourceKey(this, SourceKey);
}
private static void OnSourcePropertyChanged(DependencyObject d, DependencyPropertyChangedEventArgs e)
@@ -170,8 +176,10 @@ public partial class IconBox : ContentControl
self.Padding = default;
break;
case FontIconSource fontIcon:
self.UpdateLastFontSize();
if (self.Content is IconSourceElement iconSourceElement)
{
fontIcon.FontSize = self._lastFontSize;
iconSourceElement.IconSource = fontIcon;
}
else
@@ -190,7 +198,7 @@ public partial class IconBox : ContentControl
self.Content = elem;
}
self.Padding = new Thickness(Math.Round(self._lastFontSize * -0.2));
self.UpdatePaddingForFontIcon();
break;
case BitmapIconSource bitmapIcon:
@@ -206,10 +214,12 @@ public partial class IconBox : ContentControl
self.Padding = default;
break;
case IconSource source:
self.Content = source.CreateIconElement();
self.Padding = default;
break;
default:
throw new InvalidOperationException($"New value of {e.NewValue} is not of type IconSource.");
}
@@ -233,10 +243,10 @@ public partial class IconBox : ContentControl
return;
}
Callback(iconBox, sourceKey);
RequestIconFromSource(iconBox, sourceKey);
}
private static async void Callback(IconBox iconBox, object? sourceKey)
private static async void RequestIconFromSource(IconBox iconBox, object? sourceKey)
{
try
{
@@ -256,17 +266,12 @@ public partial class IconBox : ContentControl
// list virtualization situation, it's very possible we
// may have already been set to a new icon before we
// even got back from the await.
if (eventArgs.Key != sourceKey)
if (!ReferenceEquals(sourceKey, iconBox.SourceKey))
{
// If the requested icon has changed, then just bail
return;
}
if (eventArgs.Value == iconBox.Source)
{
return;
}
iconBox.Source = eventArgs.Value;
}
catch (Exception ex)

View File

@@ -3,6 +3,7 @@
// See the LICENSE file in the project root for more information.
using ManagedCommon;
using Microsoft.CmdPal.Core.Common.Services;
using Microsoft.CmdPal.Core.Common.Services.Reports;
using Windows.Win32;
using Windows.Win32.Foundation;
@@ -17,19 +18,20 @@ namespace Microsoft.CmdPal.UI.Helpers;
/// </summary>
internal sealed partial class GlobalErrorHandler : IDisposable
{
private readonly ErrorReportBuilder _errorReportBuilder = new();
private ErrorReportBuilder? _errorReportBuilder;
private Options? _options;
private App? _app;
// GlobalErrorHandler is designed to be self-contained; it can be registered and invoked before a service provider is available.
internal void Register(App app, Options options)
internal void Register(App app, Options options, IApplicationInfoService? appInfoService = null)
{
ArgumentNullException.ThrowIfNull(app);
ArgumentNullException.ThrowIfNull(options);
_options = options;
_app = app;
_errorReportBuilder = new ErrorReportBuilder(appInfoService);
_app.UnhandledException += App_UnhandledException;
TaskScheduler.UnobservedTaskException += TaskScheduler_UnobservedTaskException;
AppDomain.CurrentDomain.UnhandledException += CurrentDomain_UnhandledException;
@@ -68,7 +70,7 @@ internal sealed partial class GlobalErrorHandler : IDisposable
if (context == Context.MainThreadException)
{
var report = _errorReportBuilder.BuildReport(ex, context.ToString(), _options?.RedactPii ?? true);
var report = _errorReportBuilder!.BuildReport(ex, context.ToString(), _options?.RedactPii ?? true);
StoreReport(report, storeOnDesktop: _options?.StoreReportOnUserDesktop == true);

View File

@@ -219,6 +219,6 @@ internal sealed partial class IconLoaderService : IIconLoaderService
iconSize = DefaultIconSize;
}
return IconPathConverter.IconSourceMUX(iconString, false, fontFamily, iconSize);
return IconPathConverter.IconSourceMUX(iconString, fontFamily, iconSize);
}
}

View File

@@ -22,7 +22,7 @@
VerticalAlignment="Stretch"
BlurAmount="{x:Bind ViewModel.BackgroundImageBlurAmount, Mode=OneWay}"
ImageBrightness="{x:Bind ViewModel.BackgroundImageBrightness, Mode=OneWay}"
ImageOpacity="{x:Bind ViewModel.BackgroundImageOpacity, Mode=OneWay}"
ImageOpacity="{x:Bind ViewModel.EffectiveImageOpacity, Mode=OneWay}"
ImageSource="{x:Bind ViewModel.BackgroundImageSource, Mode=OneWay}"
ImageStretch="{x:Bind ViewModel.BackgroundImageStretch, Mode=OneWay}"
IsHitTestVisible="False"
@@ -31,6 +31,6 @@
TintIntensity="{x:Bind ViewModel.BackgroundImageTintIntensity, Mode=OneWay}"
Visibility="{x:Bind ViewModel.ShowBackgroundImage, Mode=OneWay}" />
<pages:ShellPage />
<pages:ShellPage HostWindow="{x:Bind}" />
</Grid>
</winuiex:WindowEx>

View File

@@ -31,6 +31,7 @@ using Windows.ApplicationModel.Activation;
using Windows.Foundation;
using Windows.Graphics;
using Windows.System;
using Windows.UI;
using Windows.Win32;
using Windows.Win32.Foundation;
using Windows.Win32.Graphics.Dwm;
@@ -53,7 +54,9 @@ public sealed partial class MainWindow : WindowEx,
IRecipient<ErrorOccurredMessage>,
IRecipient<DragStartedMessage>,
IRecipient<DragCompletedMessage>,
IDisposable
IRecipient<ToggleDevRibbonMessage>,
IDisposable,
IHostWindow
{
[System.Diagnostics.CodeAnalysis.SuppressMessage("StyleCop.CSharp.NamingRules", "SA1310:Field names should not contain underscore", Justification = "Stylistically, window messages are WM_")]
[System.Diagnostics.CodeAnalysis.SuppressMessage("StyleCop.CSharp.NamingRules", "SA1306:Field names should begin with lower-case letter", Justification = "Stylistically, window messages are WM_")]
@@ -80,15 +83,21 @@ public sealed partial class MainWindow : WindowEx,
private int _sessionErrorCount;
private DesktopAcrylicController? _acrylicController;
private MicaController? _micaController;
private SystemBackdropConfiguration? _configurationSource;
private bool _isUpdatingBackdrop;
private TimeSpan _autoGoHomeInterval = Timeout.InfiniteTimeSpan;
private WindowPosition _currentWindowPosition = new();
private bool _preventHideWhenDeactivated;
private DevRibbon? _devRibbon;
private MainWindowViewModel ViewModel { get; }
public bool IsVisibleToUser { get; private set; } = true;
public MainWindow()
{
InitializeComponent();
@@ -109,7 +118,7 @@ public sealed partial class MainWindow : WindowEx,
CommandPaletteHost.SetHostHwnd((ulong)_hwnd.Value);
}
SetAcrylic();
InitializeBackdropSupport();
_hiddenOwnerBehavior.ShowInTaskbar(this, Debugger.IsAttached);
@@ -133,6 +142,7 @@ public sealed partial class MainWindow : WindowEx,
WeakReferenceMessenger.Default.Register<ErrorOccurredMessage>(this);
WeakReferenceMessenger.Default.Register<DragStartedMessage>(this);
WeakReferenceMessenger.Default.Register<DragCompletedMessage>(this);
WeakReferenceMessenger.Default.Register<ToggleDevRibbonMessage>(this);
// Hide our titlebar.
// We need to both ExtendsContentIntoTitleBar, then set the height to Collapsed
@@ -158,7 +168,7 @@ public sealed partial class MainWindow : WindowEx,
App.Current.Services.GetService<SettingsModel>()!.SettingsChanged += SettingsChangedHandler;
// Make sure that we update the acrylic theme when the OS theme changes
RootElement.ActualThemeChanged += (s, e) => DispatcherQueue.TryEnqueue(UpdateAcrylic);
RootElement.ActualThemeChanged += (s, e) => DispatcherQueue.TryEnqueue(UpdateBackdrop);
// Hardcoding event name to avoid bringing in the PowerToys.interop dependency. Event name must match CMDPAL_SHOW_EVENT from shared_constants.h
NativeEventWaiter.WaitForEventLoop("Local\\PowerToysCmdPal-ShowEvent-62336fcd-8611-4023-9b30-091a6af4cc5a", () =>
@@ -185,7 +195,7 @@ public sealed partial class MainWindow : WindowEx,
private void ThemeServiceOnThemeChanged(object? sender, ThemeChangedEventArgs e)
{
UpdateAcrylic();
UpdateBackdrop();
}
private static void LocalKeyboardListener_OnKeyPressed(object? sender, LocalKeyboardListenerKeyPressedEventArgs e)
@@ -206,7 +216,8 @@ public sealed partial class MainWindow : WindowEx,
// Add dev ribbon if enabled
if (!BuildInfo.IsCiBuild)
{
RootElement.Children.Add(new DevRibbon { Margin = new Thickness(-1, -1, 120, -1) });
_devRibbon = new DevRibbon { Margin = new Thickness(-1, -1, 120, -1) };
RootElement.Children.Add(_devRibbon);
}
}
@@ -280,48 +291,170 @@ public sealed partial class MainWindow : WindowEx,
_autoGoHomeTimer.Interval = _autoGoHomeInterval;
}
private void SetAcrylic()
private void InitializeBackdropSupport()
{
if (DesktopAcrylicController.IsSupported())
if (DesktopAcrylicController.IsSupported() || MicaController.IsSupported())
{
// Hooking up the policy object.
_configurationSource = new SystemBackdropConfiguration
{
// Initial configuration state.
IsInputActive = true,
};
UpdateAcrylic();
}
}
private void UpdateAcrylic()
private void UpdateBackdrop()
{
// Prevent re-entrance when backdrop changes trigger ActualThemeChanged
if (_isUpdatingBackdrop)
{
return;
}
_isUpdatingBackdrop = true;
var backdrop = _themeService.Current.BackdropParameters;
var isImageMode = ViewModel.ShowBackgroundImage;
var config = BackdropStyles.Get(backdrop.Style);
try
{
if (_acrylicController != null)
switch (config.ControllerKind)
{
_acrylicController.RemoveAllSystemBackdropTargets();
_acrylicController.Dispose();
case BackdropControllerKind.Solid:
CleanupBackdropControllers();
var tintColor = Color.FromArgb(
(byte)(backdrop.EffectiveOpacity * 255),
backdrop.TintColor.R,
backdrop.TintColor.G,
backdrop.TintColor.B);
SetupTransparentBackdrop(tintColor);
break;
case BackdropControllerKind.Mica:
case BackdropControllerKind.MicaAlt:
SetupMica(backdrop, isImageMode, config.ControllerKind);
break;
case BackdropControllerKind.Acrylic:
case BackdropControllerKind.AcrylicThin:
default:
SetupDesktopAcrylic(backdrop, isImageMode, config.ControllerKind);
break;
}
var backdrop = _themeService.Current.BackdropParameters;
_acrylicController = new DesktopAcrylicController
{
TintColor = backdrop.TintColor,
TintOpacity = backdrop.TintOpacity,
FallbackColor = backdrop.FallbackColor,
LuminosityOpacity = backdrop.LuminosityOpacity,
};
// Enable the system backdrop.
// Note: Be sure to have "using WinRT;" to support the Window.As<...>() call.
_acrylicController.AddSystemBackdropTarget(this.As<ICompositionSupportsSystemBackdrop>());
_acrylicController.SetSystemBackdropConfiguration(_configurationSource);
}
catch (Exception ex)
{
Logger.LogError("Failed to update backdrop", ex);
}
finally
{
_isUpdatingBackdrop = false;
}
}
private void SetupTransparentBackdrop(Color tintColor)
{
if (SystemBackdrop is TransparentTintBackdrop existingBackdrop)
{
existingBackdrop.TintColor = tintColor;
}
else
{
SystemBackdrop = new TransparentTintBackdrop { TintColor = tintColor };
}
}
private void CleanupBackdropControllers()
{
if (_acrylicController is not null)
{
_acrylicController.RemoveAllSystemBackdropTargets();
_acrylicController.Dispose();
_acrylicController = null;
}
if (_micaController is not null)
{
_micaController.RemoveAllSystemBackdropTargets();
_micaController.Dispose();
_micaController = null;
}
}
private void SetupDesktopAcrylic(BackdropParameters backdrop, bool isImageMode, BackdropControllerKind kind)
{
CleanupBackdropControllers();
// Fall back to solid color if acrylic not supported
if (_configurationSource is null || !DesktopAcrylicController.IsSupported())
{
SetupTransparentBackdrop(backdrop.FallbackColor);
return;
}
// DesktopAcrylicController and SystemBackdrop can't be active simultaneously
SystemBackdrop = null;
// Image mode: no tint here, BlurImageControl handles it (avoids double-tinting)
var effectiveTintOpacity = isImageMode
? 0.0f
: backdrop.EffectiveOpacity;
_acrylicController = new DesktopAcrylicController
{
Kind = kind == BackdropControllerKind.AcrylicThin
? DesktopAcrylicKind.Thin
: DesktopAcrylicKind.Default,
TintColor = backdrop.TintColor,
TintOpacity = effectiveTintOpacity,
FallbackColor = backdrop.FallbackColor,
LuminosityOpacity = backdrop.EffectiveLuminosityOpacity,
};
// Requires "using WinRT;" for Window.As<>()
_acrylicController.AddSystemBackdropTarget(this.As<ICompositionSupportsSystemBackdrop>());
_acrylicController.SetSystemBackdropConfiguration(_configurationSource);
}
private void SetupMica(BackdropParameters backdrop, bool isImageMode, BackdropControllerKind kind)
{
CleanupBackdropControllers();
// Fall back to solid color if Mica not supported
if (_configurationSource is null || !MicaController.IsSupported())
{
SetupTransparentBackdrop(backdrop.FallbackColor);
return;
}
// MicaController and SystemBackdrop can't be active simultaneously
SystemBackdrop = null;
_configurationSource.Theme = _themeService.Current.Theme == ElementTheme.Dark
? SystemBackdropTheme.Dark
: SystemBackdropTheme.Light;
var hasColorization = _themeService.Current.HasColorization || isImageMode;
_micaController = new MicaController
{
Kind = kind == BackdropControllerKind.MicaAlt
? MicaKind.BaseAlt
: MicaKind.Base,
};
// Only set tint properties when colorization is active
// Otherwise let system handle light/dark theme defaults automatically
if (hasColorization)
{
// Image mode: no tint here, BlurImageControl handles it (avoids double-tinting)
_micaController.TintColor = backdrop.TintColor;
_micaController.TintOpacity = isImageMode ? 0.0f : backdrop.EffectiveOpacity;
_micaController.FallbackColor = backdrop.FallbackColor;
_micaController.LuminosityOpacity = backdrop.EffectiveLuminosityOpacity;
}
_micaController.AddSystemBackdropTarget(this.As<ICompositionSupportsSystemBackdrop>());
_micaController.SetSystemBackdropConfiguration(_configurationSource);
}
private void ShowHwnd(IntPtr hwndValue, MonitorBehavior target)
@@ -575,17 +708,14 @@ public sealed partial class MainWindow : WindowEx,
{
Logger.LogWarning($"DWM cloaking of the main window failed. HRESULT: {hr.Value}.");
}
else
{
IsVisibleToUser = false;
}
wasCloaked = hr.Succeeded;
}
if (wasCloaked)
{
// Because we're only cloaking the window, bury it at the bottom in case something can
// see it - e.g. some accessibility helper (note: this also removes the top-most status).
PInvoke.SetWindowPos(_hwnd, HWND.HWND_BOTTOM, 0, 0, 0, 0, SET_WINDOW_POS_FLAGS.SWP_NOMOVE | SET_WINDOW_POS_FLAGS.SWP_NOSIZE);
}
return wasCloaked;
}
@@ -595,6 +725,7 @@ public sealed partial class MainWindow : WindowEx,
{
BOOL value = false;
PInvoke.DwmSetWindowAttribute(_hwnd, DWMWINDOWATTRIBUTE.DWMWA_CLOAK, &value, (uint)sizeof(BOOL));
IsVisibleToUser = true;
}
}
@@ -637,12 +768,8 @@ public sealed partial class MainWindow : WindowEx,
private void DisposeAcrylic()
{
if (_acrylicController is not null)
{
_acrylicController.Dispose();
_acrylicController = null!;
_configurationSource = null!;
}
CleanupBackdropControllers();
_configurationSource = null!;
}
// Updates our window s.t. the top of the window is draggable.
@@ -1012,6 +1139,11 @@ public sealed partial class MainWindow : WindowEx,
DisposeAcrylic();
}
public void Receive(ToggleDevRibbonMessage message)
{
_devRibbon?.Visibility = _devRibbon.Visibility == Visibility.Visible ? Visibility.Collapsed : Visibility.Visible;
}
public void Receive(DragStartedMessage message)
{
_preventHideWhenDeactivated = true;

View File

@@ -0,0 +1,7 @@
// Copyright (c) Microsoft Corporation
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
namespace Microsoft.CmdPal.UI.Messages;
public record ToggleDevRibbonMessage;

View File

@@ -13,6 +13,7 @@ using Microsoft.CmdPal.Core.ViewModels.Messages;
using Microsoft.CmdPal.UI.Events;
using Microsoft.CmdPal.UI.Helpers;
using Microsoft.CmdPal.UI.Messages;
using Microsoft.CmdPal.UI.Services;
using Microsoft.CmdPal.UI.Settings;
using Microsoft.CmdPal.UI.ViewModels;
using Microsoft.CommandPalette.Extensions;
@@ -72,6 +73,8 @@ public sealed partial class ShellPage : Microsoft.UI.Xaml.Controls.Page,
public event PropertyChangedEventHandler? PropertyChanged;
public IHostWindow? HostWindow { get; set; }
public ShellPage()
{
this.InitializeComponent();
@@ -435,7 +438,7 @@ public sealed partial class ShellPage : Microsoft.UI.Xaml.Controls.Page,
if (!RootFrame.CanGoBack)
{
ViewModel.GoHome();
ViewModel.GoHome(withAnimation, focusSearch);
}
if (focusSearch)
@@ -531,6 +534,11 @@ public sealed partial class ShellPage : Microsoft.UI.Xaml.Controls.Page,
if (shouldSearchBoxBeVisible || page is not ContentPage)
{
if (HostWindow?.IsVisibleToUser != true)
{
return;
}
ViewModel.IsSearchBoxVisible = shouldSearchBoxBeVisible;
SearchBox.Focus(FocusState.Programmatic);
SearchBox.SelectSearch();
@@ -547,6 +555,11 @@ public sealed partial class ShellPage : Microsoft.UI.Xaml.Controls.Page,
try
{
if (HostWindow?.IsVisibleToUser != true)
{
return;
}
await page.DispatcherQueue.EnqueueAsync(
async () =>
{
@@ -556,6 +569,11 @@ public sealed partial class ShellPage : Microsoft.UI.Xaml.Controls.Page,
{
token.ThrowIfCancellationRequested();
if (HostWindow?.IsVisibleToUser != true)
{
break;
}
if (FocusManager.FindFirstFocusableElement(page) is FrameworkElement frameworkElement)
{
var set = frameworkElement.Focus(FocusState.Programmatic);

View File

@@ -6,6 +6,7 @@ using System.Runtime.InteropServices;
using System.Runtime.Versioning;
using ManagedCommon;
using Microsoft.CmdPal.Core.Common.Services;
using Microsoft.CmdPal.Core.Common.Text;
using Microsoft.CmdPal.Core.ViewModels;
using Microsoft.CmdPal.UI.ViewModels;
using Microsoft.CmdPal.UI.ViewModels.MainPage;
@@ -23,13 +24,13 @@ internal sealed class PowerToysRootPageService : IRootPageService
private IExtensionWrapper? _activeExtension;
private Lazy<MainListPage> _mainListPage;
public PowerToysRootPageService(TopLevelCommandManager topLevelCommandManager, SettingsModel settings, AliasManager aliasManager, AppStateModel appStateModel)
public PowerToysRootPageService(TopLevelCommandManager topLevelCommandManager, SettingsModel settings, AliasManager aliasManager, AppStateModel appStateModel, IFuzzyMatcherProvider fuzzyMatcherProvider)
{
_tlcManager = topLevelCommandManager;
_mainListPage = new Lazy<MainListPage>(() =>
{
return new MainListPage(_tlcManager, settings, aliasManager, appStateModel);
return new MainListPage(_tlcManager, settings, aliasManager, appStateModel, fuzzyMatcherProvider);
});
}

View File

@@ -4,6 +4,7 @@
using System.Runtime.InteropServices;
using ManagedCommon;
using Microsoft.CmdPal.Core.Common.Services;
using Microsoft.CmdPal.UI.Events;
using Microsoft.PowerToys.Telemetry;
using Microsoft.UI.Dispatching;
@@ -65,6 +66,25 @@ internal sealed class Program
}
Logger.LogDebug($"Starting at {DateTime.UtcNow}");
// Log application startup information
try
{
var appInfoService = new ApplicationInfoService(() => Logger.CurrentVersionLogDirectoryPath);
var startupMessage = $"""
============================================================
Hello World! Command Palette is starting.
{appInfoService.GetApplicationInfoSummary()}
============================================================
""";
Logger.LogInfo(startupMessage);
}
catch (Exception ex)
{
Logger.LogError("Failed to log application startup information", ex);
}
PowerToysTelemetry.Log.WriteEvent(new CmdPalProcessStarted());
WinRT.ComWrappersSupport.InitializeComWrappers();

View File

@@ -4,6 +4,7 @@
using CommunityToolkit.WinUI.Helpers;
using Microsoft.CmdPal.UI.Helpers;
using Microsoft.CmdPal.UI.ViewModels;
using Microsoft.CmdPal.UI.ViewModels.Services;
using Microsoft.UI.Xaml;
using Windows.UI;
@@ -34,7 +35,7 @@ internal sealed class ColorfulThemeProvider : IThemeProvider
_uiSettings = uiSettings;
}
public AcrylicBackdropParameters GetAcrylicBackdrop(ThemeContext context)
public BackdropParameters GetBackdropParameters(ThemeContext context)
{
var isLight = context.Theme == ElementTheme.Light ||
(context.Theme == ElementTheme.Default &&
@@ -53,7 +54,26 @@ internal sealed class ColorfulThemeProvider : IThemeProvider
var colorIntensity = isLight ? 0.6f * colorIntensityUser : colorIntensityUser;
var effectiveBgColor = ColorBlender.Blend(baseColor, blended, colorIntensity);
return new AcrylicBackdropParameters(effectiveBgColor, effectiveBgColor, 0.8f, 0.8f);
var transparencyMode = context.BackdropStyle ?? BackdropStyle.Acrylic;
var config = BackdropStyles.Get(transparencyMode);
// For colorful theme, boost tint opacity to show color better through blur
// But not for styles with fixed opacity (Mica) - they handle their own opacity
var baseTintOpacity = config.ControllerKind == BackdropControllerKind.Solid || !config.SupportsOpacity
? (float?)null // Use default
: Math.Max(config.BaseTintOpacity, 0.8f);
var effectiveOpacity = config.ComputeEffectiveOpacity(context.BackdropOpacity, baseTintOpacity);
var effectiveLuminosityOpacity = config.SupportsOpacity
? config.BaseLuminosityOpacity * context.BackdropOpacity
: config.BaseLuminosityOpacity;
return new BackdropParameters(
TintColor: effectiveBgColor,
FallbackColor: effectiveBgColor,
EffectiveOpacity: effectiveOpacity,
EffectiveLuminosityOpacity: effectiveLuminosityOpacity,
Style: transparencyMode);
}
private static class ColorBlender

View File

@@ -0,0 +1,16 @@
// Copyright (c) Microsoft Corporation
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
namespace Microsoft.CmdPal.UI.Services;
/// <summary>
/// Represents abstract host window functionality.
/// </summary>
public interface IHostWindow
{
/// <summary>
/// Gets a value indicating whether the window is visible to the user, taking account not only window visibility but also cloaking.
/// </summary>
bool IsVisibleToUser { get; }
}

View File

@@ -8,14 +8,14 @@ using Microsoft.CmdPal.UI.ViewModels.Services;
namespace Microsoft.CmdPal.UI.Services;
/// <summary>
/// Provides theme identification, resource path resolution, and creation of acrylic
/// backdrop parameters based on the current <see cref="ThemeContext"/>.
/// Provides theme identification, resource path resolution, and creation of backdrop
/// parameters based on the current <see cref="ThemeContext"/>.
/// </summary>
/// <remarks>
/// Implementations should expose a stable <see cref="ThemeKey"/> and a valid XAML resource
/// dictionary path via <see cref="ResourcePath"/>. The
/// <see cref="GetAcrylicBackdrop(ThemeContext)"/> method computes
/// <see cref="AcrylicBackdropParameters"/> using the supplied theme context.
/// <see cref="GetBackdropParameters(ThemeContext)"/> method computes
/// <see cref="BackdropParameters"/> using the supplied theme context.
/// </remarks>
internal interface IThemeProvider
{
@@ -30,9 +30,9 @@ internal interface IThemeProvider
string ResourcePath { get; }
/// <summary>
/// Creates acrylic backdrop parameters based on the provided theme context.
/// Creates backdrop parameters based on the provided theme context.
/// </summary>
/// <param name="context">The current theme context, including theme, tint, and optional background details.</param>
/// <returns>The computed <see cref="AcrylicBackdropParameters"/> for the backdrop.</returns>
AcrylicBackdropParameters GetAcrylicBackdrop(ThemeContext context);
/// <param name="context">The current theme context, including theme, tint, transparency mode, and optional background details.</param>
/// <returns>The computed <see cref="BackdropParameters"/> for the backdrop.</returns>
BackdropParameters GetBackdropParameters(ThemeContext context);
}

View File

@@ -2,6 +2,7 @@
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
using Microsoft.CmdPal.UI.ViewModels;
using Microsoft.CmdPal.UI.ViewModels.Services;
using Microsoft.UI.Xaml;
using Windows.UI;
@@ -28,16 +29,28 @@ internal sealed class NormalThemeProvider : IThemeProvider
public string ResourcePath => "ms-appx:///Styles/Theme.Normal.xaml";
public AcrylicBackdropParameters GetAcrylicBackdrop(ThemeContext context)
public BackdropParameters GetBackdropParameters(ThemeContext context)
{
var isLight = context.Theme == ElementTheme.Light ||
(context.Theme == ElementTheme.Default &&
_uiSettings.GetColorValue(UIColorType.Background).R > 128);
return new AcrylicBackdropParameters(
var backdropStyle = context.BackdropStyle ?? BackdropStyle.Acrylic;
var config = BackdropStyles.Get(backdropStyle);
// Apply light/dark theme adjustment to luminosity
var baseLuminosityOpacity = isLight
? config.BaseLuminosityOpacity
: Math.Min(config.BaseLuminosityOpacity + 0.06f, 1.0f);
var effectiveOpacity = config.ComputeEffectiveOpacity(context.BackdropOpacity);
var effectiveLuminosityOpacity = baseLuminosityOpacity * context.BackdropOpacity;
return new BackdropParameters(
TintColor: isLight ? LightBaseColor : DarkBaseColor,
FallbackColor: isLight ? LightBaseColor : DarkBaseColor,
TintOpacity: 0.5f,
LuminosityOpacity: isLight ? 0.9f : 0.96f);
EffectiveOpacity: effectiveOpacity,
EffectiveLuminosityOpacity: effectiveLuminosityOpacity,
Style: backdropStyle);
}
}

View File

@@ -2,12 +2,16 @@
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
using Microsoft.CmdPal.UI.ViewModels;
using Microsoft.UI.Xaml;
using Microsoft.UI.Xaml.Media;
using Windows.UI;
namespace Microsoft.CmdPal.UI.Services;
/// <summary>
/// Input parameters for theme computation, passed to theme providers.
/// </summary>
internal sealed record ThemeContext
{
public ElementTheme Theme { get; init; }
@@ -21,4 +25,8 @@ internal sealed record ThemeContext
public double BackgroundImageOpacity { get; init; }
public int? ColorIntensity { get; init; }
public BackdropStyle? BackdropStyle { get; init; }
public float BackdropOpacity { get; init; } = 1.0f;
}

View File

@@ -72,10 +72,13 @@ internal sealed partial class ThemeService : IThemeService, IDisposable
}
// provider selection
var intensity = Math.Clamp(_settings.CustomThemeColorIntensity, 0, 100);
IThemeProvider provider = intensity > 0 && _settings.ColorizationMode is ColorizationMode.CustomColor or ColorizationMode.WindowsAccentColor or ColorizationMode.Image
? _colorfulThemeProvider
: _normalThemeProvider;
var themeColorIntensity = Math.Clamp(_settings.CustomThemeColorIntensity, 0, 100);
var imageTintIntensity = Math.Clamp(_settings.BackgroundImageTintIntensity, 0, 100);
var effectiveColorIntensity = _settings.ColorizationMode == ColorizationMode.Image
? imageTintIntensity
: themeColorIntensity;
IThemeProvider provider = UseColorfulProvider(effectiveColorIntensity) ? _colorfulThemeProvider : _normalThemeProvider;
// Calculate values
var tint = _settings.ColorizationMode switch
@@ -96,32 +99,39 @@ internal sealed partial class ThemeService : IThemeService, IDisposable
};
var opacity = Math.Clamp(_settings.BackgroundImageOpacity, 0, 100) / 100.0;
// create context and offload to actual theme provider
// create input and offload to actual theme provider
var context = new ThemeContext
{
Tint = tint,
ColorIntensity = intensity,
ColorIntensity = effectiveColorIntensity,
Theme = effectiveTheme,
BackgroundImageSource = imageSource,
BackgroundImageStretch = stretch,
BackgroundImageOpacity = opacity,
BackdropStyle = _settings.BackdropStyle,
BackdropOpacity = Math.Clamp(_settings.BackdropOpacity, 0, 100) / 100f,
};
var backdrop = provider.GetAcrylicBackdrop(context);
var backdrop = provider.GetBackdropParameters(context);
var blur = _settings.BackgroundImageBlurAmount;
var brightness = _settings.BackgroundImageBrightness;
// Create public snapshot (no provider!)
var hasColorization = effectiveColorIntensity > 0
&& _settings.ColorizationMode is ColorizationMode.CustomColor or ColorizationMode.WindowsAccentColor or ColorizationMode.Image;
var snapshot = new ThemeSnapshot
{
Tint = tint,
TintIntensity = intensity / 100f,
TintIntensity = effectiveColorIntensity / 100f,
Theme = effectiveTheme,
BackgroundImageSource = imageSource,
BackgroundImageStretch = stretch,
BackgroundImageOpacity = opacity,
BackdropParameters = backdrop,
BackdropOpacity = context.BackdropOpacity,
BlurAmount = blur,
BackgroundBrightness = brightness / 100f,
HasColorization = hasColorization,
};
// Bundle with provider for internal use
@@ -138,6 +148,12 @@ internal sealed partial class ThemeService : IThemeService, IDisposable
ThemeChanged?.Invoke(this, new ThemeChangedEventArgs());
}
private bool UseColorfulProvider(int effectiveColorIntensity)
{
return _settings.ColorizationMode == ColorizationMode.Image
|| (effectiveColorIntensity > 0 && _settings.ColorizationMode is ColorizationMode.CustomColor or ColorizationMode.WindowsAccentColor);
}
private static BitmapImage? LoadImageSafe(string? path)
{
if (string.IsNullOrWhiteSpace(path))
@@ -195,13 +211,15 @@ internal sealed partial class ThemeService : IThemeService, IDisposable
{
Tint = Colors.Transparent,
Theme = ElementTheme.Light,
BackdropParameters = new AcrylicBackdropParameters(Colors.Black, Colors.Black, 0.5f, 0.5f),
BackdropParameters = new BackdropParameters(Colors.Black, Colors.Black, EffectiveOpacity: 0.5f, EffectiveLuminosityOpacity: 0.5f),
BackdropOpacity = 1.0f,
BackgroundImageOpacity = 1,
BackgroundImageSource = null,
BackgroundImageStretch = Stretch.Fill,
BlurAmount = 0,
TintIntensity = 1.0f,
BackgroundBrightness = 0,
HasColorization = false,
},
Provider = _normalThemeProvider,
};

View File

@@ -22,18 +22,50 @@
HorizontalAlignment="Stretch"
Spacing="{StaticResource SettingsCardSpacing}">
<ptControls:ScreenPreview Margin="0,0,0,16" HorizontalAlignment="Left">
<ptControls:CommandPalettePreview
PreviewBackgroundColor="{x:Bind ViewModel.Appearance.EffectiveBackdrop.TintColor, Mode=OneWay}"
PreviewBackgroundImageBlurAmount="{x:Bind ViewModel.Appearance.EffectiveBackgroundImageBlurAmount, Mode=OneWay}"
PreviewBackgroundImageBrightness="{x:Bind ViewModel.Appearance.EffectiveBackgroundImageBrightness, Mode=OneWay}"
PreviewBackgroundImageFit="{x:Bind ViewModel.Appearance.BackgroundImageFit, Mode=OneWay}"
PreviewBackgroundImageSource="{x:Bind ViewModel.Appearance.EffectiveBackgroundImageSource, Mode=OneWay}"
PreviewBackgroundImageTint="{x:Bind ViewModel.Appearance.EffectiveThemeColor, Mode=OneWay}"
PreviewBackgroundImageTintIntensity="{x:Bind ViewModel.Appearance.ColorIntensity, Mode=OneWay}"
PreviewBackgroundOpacity="{x:Bind ViewModel.Appearance.EffectiveBackdrop.TintOpacity, Mode=OneWay}"
RequestedTheme="{x:Bind ViewModel.Appearance.EffectiveTheme, Mode=OneWay}" />
</ptControls:ScreenPreview>
<StackPanel
Margin="0,0,0,16"
HorizontalAlignment="Left"
Orientation="Horizontal"
Spacing="16">
<ptControls:ScreenPreview>
<ptControls:CommandPalettePreview
PreviewBackdropStyle="{x:Bind ViewModel.Appearance.EffectiveBackdropStyle, Mode=OneWay}"
PreviewBackgroundColor="{x:Bind ViewModel.Appearance.EffectiveBackdrop.TintColor, Mode=OneWay}"
PreviewBackgroundImageBlurAmount="{x:Bind ViewModel.Appearance.EffectiveBackgroundImageBlurAmount, Mode=OneWay}"
PreviewBackgroundImageBrightness="{x:Bind ViewModel.Appearance.EffectiveBackgroundImageBrightness, Mode=OneWay}"
PreviewBackgroundImageFit="{x:Bind ViewModel.Appearance.BackgroundImageFit, Mode=OneWay}"
PreviewBackgroundImageOpacity="{x:Bind ViewModel.Appearance.EffectiveImageOpacity, Mode=OneWay}"
PreviewBackgroundImageSource="{x:Bind ViewModel.Appearance.EffectiveBackgroundImageSource, Mode=OneWay}"
PreviewBackgroundImageTint="{x:Bind ViewModel.Appearance.EffectiveThemeColor, Mode=OneWay}"
PreviewBackgroundImageTintIntensity="{x:Bind ViewModel.Appearance.EffectiveTintIntensity, Mode=OneWay}"
PreviewEffectiveOpacity="{x:Bind ViewModel.Appearance.EffectiveBackdrop.EffectiveOpacity, Mode=OneWay}"
RequestedTheme="{x:Bind ViewModel.Appearance.EffectiveTheme, Mode=OneWay}" />
</ptControls:ScreenPreview>
<StackPanel VerticalAlignment="Bottom" Spacing="8">
<Button
x:Uid="Settings_AppearancePage_OpenCommandPaletteButton"
MinWidth="200"
HorizontalContentAlignment="Left"
Click="OpenCommandPalette_Click"
Style="{StaticResource SubtleButtonStyle}">
<StackPanel Orientation="Horizontal" Spacing="8">
<FontIcon FontSize="16" Glyph="&#xE8A7;" />
<TextBlock x:Uid="Settings_AppearancePage_OpenCommandPaletteButton_Text" />
</StackPanel>
</Button>
<Button
x:Uid="Settings_AppearancePage_ResetAppearanceButton"
MinWidth="200"
HorizontalContentAlignment="Left"
Command="{x:Bind ViewModel.Appearance.ResetAppearanceSettingsCommand}"
Style="{StaticResource SubtleButtonStyle}">
<StackPanel Orientation="Horizontal" Spacing="8">
<FontIcon FontSize="16" Glyph="&#xE72C;" />
<TextBlock x:Uid="Settings_AppearancePage_ResetAppearanceButton_Text" />
</StackPanel>
</Button>
</StackPanel>
</StackPanel>
<controls:SettingsCard x:Uid="Settings_GeneralPage_AppTheme_SettingsCard" HeaderIcon="{ui:FontIcon Glyph=&#xE793;}">
<ComboBox MinWidth="{StaticResource SettingActionControlMinWidth}" SelectedIndex="{x:Bind ViewModel.Appearance.ThemeIndex, Mode=TwoWay}">
@@ -62,19 +94,67 @@
</ComboBox>
</controls:SettingsCard>
<controls:SettingsExpander
x:Uid="Settings_GeneralPage_BackdropStyle_SettingsCard"
HeaderIcon="{ui:FontIcon Glyph=&#xF5EF;}"
IsExpanded="{x:Bind ViewModel.Appearance.IsBackdropOpacityVisible, Mode=OneWay}">
<ComboBox MinWidth="{StaticResource SettingActionControlMinWidth}" SelectedIndex="{x:Bind ViewModel.Appearance.BackdropStyleIndex, Mode=TwoWay}">
<ComboBoxItem x:Uid="Settings_GeneralPage_BackdropStyle_Acrylic" />
<ComboBoxItem x:Uid="Settings_GeneralPage_BackdropStyle_Transparent" />
<ComboBoxItem x:Uid="Settings_GeneralPage_BackdropStyle_Mica" />
<!-- Hidden: preview not working well, kept to preserve index mapping -->
<ComboBoxItem x:Uid="Settings_GeneralPage_BackdropStyle_AcrylicThin" Visibility="Collapsed" />
<ComboBoxItem x:Uid="Settings_GeneralPage_BackdropStyle_MicaAlt" />
</ComboBox>
<controls:SettingsExpander.Items>
<!-- Mica description (no opacity control) -->
<controls:SettingsCard
x:Uid="Settings_GeneralPage_MicaBackdrop_SettingsCard"
HorizontalContentAlignment="Stretch"
ContentAlignment="Vertical"
Visibility="{x:Bind ViewModel.Appearance.IsMicaBackdropDescriptionVisible, Mode=OneWay}">
<TextBlock
x:Uid="Settings_GeneralPage_MicaBackdrop_DescriptionTextBlock"
Margin="24"
HorizontalAlignment="Stretch"
Foreground="{ThemeResource TextFillColorSecondaryBrush}"
HorizontalTextAlignment="Center"
TextAlignment="Center"
TextWrapping="WrapWholeWords" />
</controls:SettingsCard>
<!-- Opacity slider (for non-Mica styles) -->
<controls:SettingsCard x:Uid="Settings_GeneralPage_BackdropOpacity_SettingsCard" Visibility="{x:Bind ViewModel.Appearance.IsBackdropOpacityVisible, Mode=OneWay}">
<Slider
MinWidth="{StaticResource SettingActionControlMinWidth}"
Maximum="100"
Minimum="0"
StepFrequency="1"
Value="{x:Bind ViewModel.Appearance.BackdropOpacity, Mode=TwoWay}" />
</controls:SettingsCard>
</controls:SettingsExpander.Items>
</controls:SettingsExpander>
<controls:SettingsExpander
x:Uid="Settings_GeneralPage_Background_SettingsExpander"
HeaderIcon="{ui:FontIcon Glyph=&#xE790;}"
IsEnabled="{x:Bind ViewModel.Appearance.IsBackgroundSettingsEnabled, Mode=OneWay}"
IsExpanded="{x:Bind ViewModel.Appearance.IsColorizationDetailsExpanded, Mode=TwoWay}">
<ComboBox
x:Uid="Settings_GeneralPage_ColorizationMode"
MinWidth="{StaticResource SettingActionControlMinWidth}"
SelectedIndex="{x:Bind ViewModel.Appearance.ColorizationModeIndex, Mode=TwoWay}">
<ComboBoxItem x:Uid="Settings_GeneralPage_ColorizationMode_None" />
<ComboBoxItem x:Uid="Settings_GeneralPage_ColorizationMode_WindowsAccent" />
<ComboBoxItem x:Uid="Settings_GeneralPage_ColorizationMode_CustomColor" />
<ComboBoxItem x:Uid="Settings_GeneralPage_ColorizationMode_Image" />
</ComboBox>
<Grid>
<ComboBox
x:Uid="Settings_GeneralPage_ColorizationMode"
MinWidth="{StaticResource SettingActionControlMinWidth}"
SelectedIndex="{x:Bind ViewModel.Appearance.ColorizationModeIndex, Mode=TwoWay}"
Visibility="{x:Bind ViewModel.Appearance.IsBackgroundSettingsEnabled, Mode=OneWay}">
<ComboBoxItem x:Uid="Settings_GeneralPage_ColorizationMode_None" />
<ComboBoxItem x:Uid="Settings_GeneralPage_ColorizationMode_WindowsAccent" />
<ComboBoxItem x:Uid="Settings_GeneralPage_ColorizationMode_CustomColor" />
<ComboBoxItem x:Uid="Settings_GeneralPage_ColorizationMode_Image" />
</ComboBox>
<TextBlock
x:Uid="Settings_GeneralPage_Background_NotAvailable"
Foreground="{ThemeResource TextFillColorSecondaryBrush}"
Visibility="{x:Bind ViewModel.Appearance.IsBackgroundNotAvailableVisible, Mode=OneWay}" />
</Grid>
<controls:SettingsExpander.Items>
<!-- none -->
<controls:SettingsCard
@@ -155,7 +235,7 @@
PaletteColors="{x:Bind ViewModel.Appearance.Swatches}"
SelectedColor="{x:Bind ViewModel.Appearance.ThemeColor, Mode=TwoWay}" />
</controls:SettingsCard>
<controls:SettingsCard x:Uid="Settings_GeneralPage_BackgroundTintIntensity_SettingsCard" Visibility="{x:Bind ViewModel.Appearance.IsCustomTintIntensityVisible, Mode=OneWay}">
<controls:SettingsCard x:Uid="Settings_GeneralPage_BackgroundTintIntensity_SettingsCard" Visibility="{x:Bind ViewModel.Appearance.IsColorIntensityVisible, Mode=OneWay}">
<Slider
MinWidth="{StaticResource SettingActionControlMinWidth}"
Maximum="100"
@@ -163,9 +243,17 @@
StepFrequency="1"
Value="{x:Bind ViewModel.Appearance.ColorIntensity, Mode=TwoWay}" />
</controls:SettingsCard>
<controls:SettingsCard x:Uid="Settings_GeneralPage_ImageTintIntensity_SettingsCard" Visibility="{x:Bind ViewModel.Appearance.IsImageTintIntensityVisible, Mode=OneWay}">
<Slider
MinWidth="{StaticResource SettingActionControlMinWidth}"
Maximum="100"
Minimum="0"
StepFrequency="1"
Value="{x:Bind ViewModel.Appearance.BackgroundImageTintIntensity, Mode=TwoWay}" />
</controls:SettingsCard>
<!-- Reset background image properties -->
<controls:SettingsCard x:Uid="Settings_GeneralPage_BackgroundImage_ResetProperties_SettingsCard" Visibility="{x:Bind ViewModel.Appearance.IsBackgroundControlsVisible, Mode=OneWay}">
<!-- Reset appearance properties -->
<controls:SettingsCard x:Uid="Settings_GeneralPage_BackgroundImage_ResetProperties_SettingsCard" Visibility="{x:Bind ViewModel.Appearance.IsResetButtonVisible, Mode=OneWay}">
<StackPanel Orientation="Horizontal" Spacing="8">
<Button x:Uid="Settings_GeneralPage_Background_ResetImagePropertiesButton" Command="{x:Bind ViewModel.Appearance.ResetBackgroundImagePropertiesCommand}" />
</StackPanel>

View File

@@ -3,8 +3,13 @@
// See the LICENSE file in the project root for more information.
using System.Diagnostics;
using CommunityToolkit.Mvvm.Messaging;
using ManagedCommon;
using Microsoft.CmdPal.Core.ViewModels.Messages;
using Microsoft.CmdPal.UI.Events;
using Microsoft.CmdPal.UI.Messages;
using Microsoft.CmdPal.UI.ViewModels;
using Microsoft.CmdPal.UI.ViewModels.Messages;
using Microsoft.CmdPal.UI.ViewModels.Services;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.UI;
@@ -12,6 +17,7 @@ using Microsoft.UI.Xaml;
using Microsoft.UI.Xaml.Controls;
using Microsoft.UI.Xaml.Documents;
using Microsoft.Windows.Storage.Pickers;
using Windows.Win32.Foundation;
namespace Microsoft.CmdPal.UI.Settings;
@@ -86,4 +92,9 @@ public sealed partial class AppearancePage : Page
}
});
}
private void OpenCommandPalette_Click(object sender, RoutedEventArgs e)
{
WeakReferenceMessenger.Default.Send<HotkeySummonMessage>(new(string.Empty, HWND.Null));
}
}

View File

@@ -2,15 +2,13 @@
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
using System.Diagnostics;
using System.Globalization;
using ManagedCommon;
using Microsoft.CmdPal.Core.Common.Services;
using Microsoft.CmdPal.UI.Helpers;
using Microsoft.CmdPal.UI.ViewModels;
using Microsoft.CmdPal.UI.ViewModels.Services;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.UI.Xaml.Controls;
using Windows.ApplicationModel;
namespace Microsoft.CmdPal.UI.Settings;
@@ -19,6 +17,7 @@ public sealed partial class GeneralPage : Page
private readonly TaskScheduler _mainTaskScheduler = TaskScheduler.FromCurrentSynchronizationContext();
private readonly SettingsViewModel? viewModel;
private readonly IApplicationInfoService _appInfoService;
public GeneralPage()
{
@@ -27,6 +26,7 @@ public sealed partial class GeneralPage : Page
var settings = App.Current.Services.GetService<SettingsModel>()!;
var topLevelCommandManager = App.Current.Services.GetService<TopLevelCommandManager>()!;
var themeService = App.Current.Services.GetService<IThemeService>()!;
_appInfoService = App.Current.Services.GetRequiredService<IApplicationInfoService>();
viewModel = new SettingsViewModel(settings, topLevelCommandManager, _mainTaskScheduler, themeService);
}
@@ -35,55 +35,8 @@ public sealed partial class GeneralPage : Page
get
{
var versionNo = ResourceLoaderInstance.GetString("Settings_GeneralPage_VersionNo");
if (!TryGetPackagedVersion(out var version) && !TryGetAssemblyVersion(out version))
{
version = "?";
}
var version = _appInfoService.AppVersion;
return string.Format(CultureInfo.CurrentCulture, versionNo, version);
}
}
private static bool TryGetPackagedVersion(out string version)
{
version = string.Empty;
try
{
// Package.Current throws InvalidOperationException if the app is not packaged
var v = Package.Current.Id.Version;
version = $"{v.Major}.{v.Minor}.{v.Build}.{v.Revision}";
return true;
}
catch (InvalidOperationException)
{
return false;
}
catch (Exception ex)
{
Logger.LogError("Failed to get version from the package", ex);
return false;
}
}
private static bool TryGetAssemblyVersion(out string version)
{
version = string.Empty;
try
{
var processPath = Environment.ProcessPath;
if (string.IsNullOrEmpty(processPath))
{
return false;
}
var info = FileVersionInfo.GetVersionInfo(processPath);
version = $"{info.FileMajorPart}.{info.FileMinorPart}.{info.FileBuildPart}.{info.FilePrivatePart}";
return true;
}
catch (Exception ex)
{
Logger.LogError("Failed to get version from the executable", ex);
return false;
}
}
}

View File

@@ -57,6 +57,13 @@
HeaderIcon="{ui:FontIcon Glyph=&#xF7BB;}">
<Button Click="OpenCurrentLogCardClicked" Content="Open log" />
</controls:SettingsCard>
<controls:SettingsCard
x:Name="ToggleDevRibbonVisibilitySettingsCard"
Description="This is only temporary and state is not saved"
Header="Toggle dev ribbon visibility"
HeaderIcon="{ui:FontIcon Glyph=&#xE8EC;}">
<Button Click="ToggleDevRibbonClicked" Content="Toggle dev ribbon" />
</controls:SettingsCard>
<!-- Data Section -->
<TextBlock Style="{StaticResource SettingsSectionHeaderTextBlockStyle}" Text="Data and Files" />

View File

@@ -2,8 +2,12 @@
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
using CommunityToolkit.Mvvm.Messaging;
using ManagedCommon;
using Microsoft.CmdPal.Core.Common.Services;
using Microsoft.CmdPal.UI.Messages;
using Microsoft.CommandPalette.Extensions.Toolkit;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.UI.Xaml;
using Windows.System;
using Page = Microsoft.UI.Xaml.Controls.Page;
@@ -15,9 +19,13 @@ namespace Microsoft.CmdPal.UI.Settings;
/// </summary>
public sealed partial class InternalPage : Page
{
private readonly IApplicationInfoService _appInfoService;
public InternalPage()
{
InitializeComponent();
_appInfoService = App.Current.Services.GetRequiredService<IApplicationInfoService>();
}
private void ThrowPlainMainThreadException_Click(object sender, RoutedEventArgs e)
@@ -46,7 +54,7 @@ public sealed partial class InternalPage : Page
{
try
{
var logFolderPath = Logger.CurrentVersionLogDirectoryPath;
var logFolderPath = _appInfoService.LogDirectory;
if (Directory.Exists(logFolderPath))
{
await Launcher.LaunchFolderPathAsync(logFolderPath);
@@ -78,7 +86,7 @@ public sealed partial class InternalPage : Page
{
try
{
var directory = Utilities.BaseSettingsPath("Microsoft.CmdPal");
var directory = _appInfoService.ConfigDirectory;
if (Directory.Exists(directory))
{
await Launcher.LaunchFolderPathAsync(directory);
@@ -89,4 +97,9 @@ public sealed partial class InternalPage : Page
Logger.LogError("Failed to open directory in Explorer", ex);
}
}
private void ToggleDevRibbonClicked(object sender, RoutedEventArgs e)
{
WeakReferenceMessenger.Default.Send(new ToggleDevRibbonMessage());
}
}

View File

@@ -1,17 +1,17 @@
<?xml version="1.0" encoding="utf-8"?>
<root>
<!--
Microsoft ResX Schema
<!--
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
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>
@@ -26,36 +26,36 @@
<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
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
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
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
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
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
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
value : The object must be serialized into a byte array
: using a System.ComponentModel.TypeConverter
: and then encoded with base64 encoding.
-->
@@ -577,6 +577,9 @@ Right-click to remove the key combination, thereby deactivating the shortcut.</v
<data name="Settings_GeneralPage_BackgroundTintIntensity_SettingsCard.Header" xml:space="preserve">
<value>Color intensity</value>
</data>
<data name="Settings_GeneralPage_ImageTintIntensity_SettingsCard.Header" xml:space="preserve">
<value>Color intensity</value>
</data>
<data name="OptionalColorPickerButton_UnsetTextBlock.Text" xml:space="preserve">
<value>Choose color</value>
</data>
@@ -668,7 +671,7 @@ Right-click to remove the key combination, thereby deactivating the shortcut.</v
<value>Background image</value>
</data>
<data name="Settings_GeneralPage_NoBackground_DescriptionTextBlock.Text" xml:space="preserve">
<value>No settings</value>
<value>No additional settings are available.</value>
</data>
<data name="Settings_GeneralPage_Background_SettingsExpander.Header" xml:space="preserve">
<value>Background</value>
@@ -676,6 +679,9 @@ Right-click to remove the key combination, thereby deactivating the shortcut.</v
<data name="Settings_GeneralPage_Background_SettingsExpander.Description" xml:space="preserve">
<value>Choose a custom background color or image</value>
</data>
<data name="Settings_GeneralPage_Background_NotAvailable.Text" xml:space="preserve">
<value>Not available with Mica</value>
</data>
<data name="Settings_GeneralPage_WindowsAccentColor_SettingsCard.Header" xml:space="preserve">
<value>System accent color</value>
</data>
@@ -692,7 +698,7 @@ Right-click to remove the key combination, thereby deactivating the shortcut.</v
<value>Restore defaults</value>
</data>
<data name="Settings_GeneralPage_Background_ResetImagePropertiesButton.Content" xml:space="preserve">
<value>Reset</value>
<value>Reset image settings</value>
</data>
<data name="Settings_GeneralPage_WindowsAccentColor_SettingsCard_Description1.Text" xml:space="preserve">
<value>Change the system accent in Windows Settings:</value>
@@ -727,4 +733,45 @@ Right-click to remove the key combination, thereby deactivating the shortcut.</v
<data name="Settings_GeneralPage_VersionNo" xml:space="preserve">
<value>Version {0}</value>
</data>
<data name="Settings_GeneralPage_BackdropOpacity_SettingsCard.Header" xml:space="preserve">
<value>Opacity</value>
</data>
<data name="Settings_GeneralPage_BackdropStyle_SettingsCard.Header" xml:space="preserve">
<value>Material</value>
</data>
<data name="Settings_GeneralPage_BackdropStyle_SettingsCard.Description" xml:space="preserve">
<value>Select the visual material used for the window background</value>
</data>
<data name="Settings_GeneralPage_BackdropStyle_Acrylic.Content" xml:space="preserve">
<value>Acrylic (default)</value>
</data>
<data name="Settings_GeneralPage_BackdropStyle_Transparent.Content" xml:space="preserve">
<value>Transparent</value>
</data>
<data name="Settings_GeneralPage_BackdropStyle_Mica.Content" xml:space="preserve">
<value>Mica</value>
</data>
<data name="Settings_GeneralPage_BackdropStyle_AcrylicThin.Content" xml:space="preserve">
<value>Thin Acrylic</value>
</data>
<data name="Settings_GeneralPage_BackdropStyle_MicaAlt.Content" xml:space="preserve">
<value>Mica Alt</value>
</data>
<data name="Settings_GeneralPage_MicaBackdrop_DescriptionTextBlock.Text" xml:space="preserve">
<value>Mica automatically adapts to your desktop wallpaper. Custom backgrounds and opacity settings are not available for this material.</value>
</data>
<data name="Settings_AppearancePage_OpenCommandPaletteButton.[using:Microsoft.UI.Xaml.Automation]AutomationProperties.Name" xml:space="preserve">
<value>Open Command Palette</value>
<comment>Button to open the Command Palette window to preview appearance changes</comment>
</data>
<data name="Settings_AppearancePage_OpenCommandPaletteButton_Text.Text" xml:space="preserve">
<value>Open Command Palette</value>
</data>
<data name="Settings_AppearancePage_ResetAppearanceButton.[using:Microsoft.UI.Xaml.Automation]AutomationProperties.Name" xml:space="preserve">
<value>Reset appearance settings</value>
<comment>Button to reset all appearance settings to their default values</comment>
</data>
<data name="Settings_AppearancePage_ResetAppearanceButton_Text.Text" xml:space="preserve">
<value>Reset to defaults</value>
</data>
</root>

View File

@@ -107,6 +107,12 @@ internal sealed partial class DevRibbonViewModel : ObservableObject
WeakReferenceMessenger.Default.Send(new OpenSettingsMessage("Internal"));
}
[RelayCommand]
private void ToggleDevRibbonVisibility()
{
WeakReferenceMessenger.Default.Send(new ToggleDevRibbonMessage());
}
private sealed partial class DevRibbonTraceListener(DevRibbonViewModel viewModel) : TraceListener
{
private const string TimestampFormat = "yyyy-MM-dd HH:mm:ss.fff";

View File

@@ -94,40 +94,49 @@ namespace winrt::Microsoft::Terminal::UI::implementation
// - <TIconSource>: The type of IconSource (MUX, WUX) to generate.
// Arguments:
// - path: the full, expanded path to the icon.
// - targetSize: the target size for decoding/rasterizing the icon.
// Return Value:
// - An IconElement with its IconSource set, if possible.
template<typename TIconSource>
TIconSource _getColoredBitmapIcon(const winrt::hstring& path, bool monochrome)
TIconSource _getColoredBitmapIcon(const winrt::hstring& path, int targetSize)
{
// FontIcon uses glyphs in the private use area, whereas valid URIs only contain ASCII characters.
// To skip throwing on Uri construction, we can quickly check if the first character is ASCII.
if (!path.empty() && path.front() < 128)
if (path.empty() || path.front() >= 128)
{
try
{
winrt::Windows::Foundation::Uri iconUri{ path };
if (til::equals_insensitive_ascii(iconUri.Extension(), L".svg"))
{
typename ImageIconSource<TIconSource>::type iconSource;
winrt::Microsoft::UI::Xaml::Media::Imaging::SvgImageSource source{ iconUri };
iconSource.ImageSource(source);
return iconSource;
}
else
{
typename BitmapIconSource<TIconSource>::type iconSource;
// Make sure to set this to false, so we keep the RGB data of the
// image. Otherwise, the icon will be white for all the
// non-transparent pixels in the image.
iconSource.ShowAsMonochrome(monochrome);
iconSource.UriSource(iconUri);
return iconSource;
}
}
CATCH_LOG();
return nullptr;
}
try
{
winrt::Windows::Foundation::Uri iconUri{ path };
if (til::equals_insensitive_ascii(iconUri.Extension(), L".svg"))
{
typename ImageIconSource<TIconSource>::type iconSource;
winrt::Microsoft::UI::Xaml::Media::Imaging::SvgImageSource source{ iconUri };
source.RasterizePixelWidth(static_cast<double>(targetSize));
// Set only single dimension here; the image might not be square and
// this will preserve the aspect ratio (for the price of keeping height unbound).
// source.RasterizePixelHeight(static_cast<double>(targetSize));
iconSource.ImageSource(source);
return iconSource;
}
else
{
typename ImageIconSource<TIconSource>::type iconSource;
winrt::Microsoft::UI::Xaml::Media::Imaging::BitmapImage bitmapImage;
bitmapImage.DecodePixelWidth(targetSize);
// Set only single dimension here; the image might not be square and
// this will preserve the aspect ratio (for the price of keeping height unbound).
// bitmapImage.DecodePixelHeight(targetSize);
bitmapImage.UriSource(iconUri);
iconSource.ImageSource(bitmapImage);
return iconSource;
}
}
CATCH_LOG();
return nullptr;
}
@@ -158,14 +167,14 @@ namespace winrt::Microsoft::Terminal::UI::implementation
// Return Value:
// - An IconElement with its IconSource set, if possible.
template<typename TIconSource>
TIconSource _getIconSource(const winrt::hstring& iconPath, bool monochrome, const winrt::hstring& fontFamily, const int targetSize)
TIconSource _getIconSource(const winrt::hstring& iconPath, const winrt::hstring& fontFamily, const int targetSize)
{
TIconSource iconSource{ nullptr };
if (iconPath.size() != 0)
{
const auto expandedIconPath{ _expandIconPath(iconPath) };
iconSource = _getColoredBitmapIcon<TIconSource>(expandedIconPath, monochrome);
iconSource = _getColoredBitmapIcon<TIconSource>(expandedIconPath, targetSize);
// If we fail to set the icon source using the "icon" as a path,
// let's try it as a symbol/emoji.
@@ -235,9 +244,9 @@ namespace winrt::Microsoft::Terminal::UI::implementation
// return _getIconSource<Windows::UI::Xaml::Controls::IconSource>(path, false);
// }
static Microsoft::UI::Xaml::Controls::IconSource _IconSourceMUX(const hstring& path, bool monochrome, const winrt::hstring& fontFamily, const int targetSize)
static Microsoft::UI::Xaml::Controls::IconSource _IconSourceMUX(const hstring& path, const winrt::hstring& fontFamily, const int targetSize)
{
return _getIconSource<Microsoft::UI::Xaml::Controls::IconSource>(path, monochrome, fontFamily, targetSize);
return _getIconSource<Microsoft::UI::Xaml::Controls::IconSource>(path, fontFamily, targetSize);
}
static SoftwareBitmap _convertToSoftwareBitmap(HICON hicon,
@@ -352,7 +361,6 @@ namespace winrt::Microsoft::Terminal::UI::implementation
}
MUX::Controls::IconSource IconPathConverter::IconSourceMUX(const winrt::hstring& iconPath,
const bool monochrome,
const winrt::hstring& fontFamily,
const int targetSize)
{
@@ -360,7 +368,7 @@ namespace winrt::Microsoft::Terminal::UI::implementation
const auto indexOpt = _getIconIndex(iconPath, iconPathWithoutIndex);
if (!indexOpt.has_value())
{
return _IconSourceMUX(iconPath, monochrome, fontFamily, targetSize);
return _IconSourceMUX(iconPath, fontFamily, targetSize);
}
const auto bitmapSource = _getImageIconSourceForBinary(iconPathWithoutIndex, indexOpt.value(), targetSize);
@@ -374,13 +382,14 @@ namespace winrt::Microsoft::Terminal::UI::implementation
Microsoft::UI::Xaml::Controls::IconElement IconPathConverter::IconMUX(const winrt::hstring& iconPath) {
return IconMUX(iconPath, 24);
}
Microsoft::UI::Xaml::Controls::IconElement IconPathConverter::IconMUX(const winrt::hstring& iconPath, const int targetSize)
{
std::wstring_view iconPathWithoutIndex;
const auto indexOpt = _getIconIndex(iconPath, iconPathWithoutIndex);
if (!indexOpt.has_value())
{
auto source = IconSourceMUX(iconPath, false, L"", targetSize);
auto source = IconSourceMUX(iconPath, L"", targetSize);
Microsoft::UI::Xaml::Controls::IconSourceElement icon;
icon.IconSource(source);
return icon;

View File

@@ -10,7 +10,7 @@ namespace winrt::Microsoft::Terminal::UI::implementation
//static Windows::UI::Xaml::Controls::IconElement IconWUX(const winrt::hstring& iconPath);
//static Windows::UI::Xaml::Controls::IconSource IconSourceWUX(const winrt::hstring& iconPath);
static Microsoft::UI::Xaml::Controls::IconSource IconSourceMUX(const winrt::hstring& iconPath, bool convertToGrayscale, const winrt::hstring& fontFamily, const int targetSize=24);
static Microsoft::UI::Xaml::Controls::IconSource IconSourceMUX(const winrt::hstring& iconPath, const winrt::hstring& fontFamily, const int targetSize=24);
static Microsoft::UI::Xaml::Controls::IconElement IconMUX(const winrt::hstring& iconPath);
static Microsoft::UI::Xaml::Controls::IconElement IconMUX(const winrt::hstring& iconPath, const int targetSize);

View File

@@ -7,7 +7,7 @@ namespace Microsoft.Terminal.UI
{
// static Windows.UI.Xaml.Controls.IconElement IconWUX(String path);
// static Windows.UI.Xaml.Controls.IconSource IconSourceWUX(String path);
static Microsoft.UI.Xaml.Controls.IconSource IconSourceMUX(String path, Boolean convertToGrayscale, String fontFamily, Int32 targetSize);
static Microsoft.UI.Xaml.Controls.IconSource IconSourceMUX(String path, String fontFamily, Int32 targetSize);
static Microsoft.UI.Xaml.Controls.IconElement IconMUX(String path);
static Microsoft.UI.Xaml.Controls.IconElement IconMUX(String path, Int32 targetSize);
};

View File

@@ -201,7 +201,7 @@
<None Include="Microsoft.Terminal.UI.def" />
</ItemGroup>
<PropertyGroup>
<OutDir>$(RepoRoot)$(Platform)\$(Configuration)\WinUI3Apps\CmdPal\</OutDir>
<OutDir>..\..\..\..\$(Platform)\$(Configuration)\WinUI3Apps\CmdPal\</OutDir>
<IntDir>obj\$(Platform)\$(Configuration)\</IntDir>
</PropertyGroup>
<Import Project="$(VCTargetsPath)\Microsoft.Cpp.targets" />

View File

@@ -0,0 +1,78 @@
// Copyright (c) Microsoft Corporation
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
using Microsoft.CmdPal.Core.Common.Text;
namespace Microsoft.CmdPal.Common.UnitTests.Text;
[TestClass]
public sealed class PrecomputedFuzzyMatcherEmojiTests
{
private readonly PrecomputedFuzzyMatcher _matcher = new();
[TestMethod]
public void ExactMatch_SimpleEmoji_ReturnsScore()
{
const string needle = "🚀";
const string haystack = "Launch 🚀 sequence";
var query = _matcher.PrecomputeQuery(needle);
var target = _matcher.PrecomputeTarget(haystack);
var score = _matcher.Score(query, target);
Assert.IsTrue(score > 0, "Expected match for simple emoji");
}
[TestMethod]
public void ExactMatch_SkinTone_ReturnsScore()
{
const string needle = "👍🏽"; // Medium skin tone
const string haystack = "Thumbs up 👍🏽 here";
var query = _matcher.PrecomputeQuery(needle);
var target = _matcher.PrecomputeTarget(haystack);
var score = _matcher.Score(query, target);
Assert.IsTrue(score > 0, "Expected match for emoji with skin tone");
}
[TestMethod]
public void ZWJSequence_Family_Match()
{
const string needle = "👨‍👩‍👧‍👦"; // Family: Man, Woman, Girl, Boy
const string haystack = "Emoji 👨‍👩‍👧‍👦 Test";
var query = _matcher.PrecomputeQuery(needle);
var target = _matcher.PrecomputeTarget(haystack);
var score = _matcher.Score(query, target);
Assert.IsTrue(score > 0, "Expected match for ZWJ sequence");
}
[TestMethod]
public void Flags_Match()
{
const string needle = "🇺🇸"; // US Flag (Regional Indicator U + Regional Indicator S)
const string haystack = "USA 🇺🇸";
var query = _matcher.PrecomputeQuery(needle);
var target = _matcher.PrecomputeTarget(haystack);
var score = _matcher.Score(query, target);
Assert.IsTrue(score > 0, "Expected match for flag emoji");
}
[TestMethod]
public void Emoji_MixedWithText_Search()
{
const string needle = "t🌮o"; // "t" + taco + "o"
const string haystack = "taco 🌮 on tuesday";
var query = _matcher.PrecomputeQuery(needle);
var target = _matcher.PrecomputeTarget(haystack);
var score = _matcher.Score(query, target);
Assert.IsTrue(score > 0, "Expected match for emoji mixed with text");
}
}

View File

@@ -0,0 +1,84 @@
// Copyright (c) Microsoft Corporation
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
using Microsoft.CmdPal.Core.Common.Text;
namespace Microsoft.CmdPal.Common.UnitTests.Text;
[TestClass]
public sealed class PrecomputedFuzzyMatcherOptionsTests
{
[TestMethod]
public void Score_RemoveDiacriticsOption_AffectsMatching()
{
var withDiacriticsRemoved = new PrecomputedFuzzyMatcher(
new PrecomputedFuzzyMatcherOptions { RemoveDiacritics = true });
var withoutDiacriticsRemoved = new PrecomputedFuzzyMatcher(
new PrecomputedFuzzyMatcherOptions { RemoveDiacritics = false });
const string needle = "cafe";
const string haystack = "CAFÉ";
var scoreWithRemoval = withDiacriticsRemoved.Score(
withDiacriticsRemoved.PrecomputeQuery(needle),
withDiacriticsRemoved.PrecomputeTarget(haystack));
var scoreWithoutRemoval = withoutDiacriticsRemoved.Score(
withoutDiacriticsRemoved.PrecomputeQuery(needle),
withoutDiacriticsRemoved.PrecomputeTarget(haystack));
Assert.IsTrue(scoreWithRemoval > 0, "Expected match when diacritics are removed.");
Assert.AreEqual(0, scoreWithoutRemoval, "Expected no match when diacritics are preserved.");
}
[TestMethod]
public void Score_SkipWordSeparatorsOption_AffectsMatching()
{
var skipSeparators = new PrecomputedFuzzyMatcher(
new PrecomputedFuzzyMatcherOptions { SkipWordSeparators = true });
var keepSeparators = new PrecomputedFuzzyMatcher(
new PrecomputedFuzzyMatcherOptions { SkipWordSeparators = false });
const string needle = "a b";
const string haystack = "ab";
var scoreSkip = skipSeparators.Score(
skipSeparators.PrecomputeQuery(needle),
skipSeparators.PrecomputeTarget(haystack));
var scoreKeep = keepSeparators.Score(
keepSeparators.PrecomputeQuery(needle),
keepSeparators.PrecomputeTarget(haystack));
Assert.IsTrue(scoreSkip > 0, "Expected match when word separators are skipped.");
Assert.AreEqual(0, scoreKeep, "Expected no match when word separators are preserved.");
}
[TestMethod]
public void Score_IgnoreSameCaseBonusOption_AffectsLowercaseQuery()
{
var ignoreSameCase = new PrecomputedFuzzyMatcher(
new PrecomputedFuzzyMatcherOptions
{
IgnoreSameCaseBonusIfQueryIsAllLowercase = true,
SameCaseBonus = 10,
});
var applySameCase = new PrecomputedFuzzyMatcher(
new PrecomputedFuzzyMatcherOptions
{
IgnoreSameCaseBonusIfQueryIsAllLowercase = false,
SameCaseBonus = 10,
});
const string needle = "test";
const string haystack = "test";
var scoreIgnore = ignoreSameCase.Score(
ignoreSameCase.PrecomputeQuery(needle),
ignoreSameCase.PrecomputeTarget(haystack));
var scoreApply = applySameCase.Score(
applySameCase.PrecomputeQuery(needle),
applySameCase.PrecomputeTarget(haystack));
Assert.IsTrue(scoreApply > scoreIgnore, "Expected same-case bonus to apply when not ignored.");
}
}

View File

@@ -0,0 +1,227 @@
// Copyright (c) Microsoft Corporation
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
using Microsoft.CmdPal.Core.Common.Text;
namespace Microsoft.CmdPal.Common.UnitTests.Text;
[TestClass]
public sealed class PrecomputedFuzzyMatcherSecondaryInputTests
{
private readonly PrecomputedFuzzyMatcher _matcher = new();
private readonly StringFolder _folder = new();
private readonly BloomFilter _bloom = new();
[TestMethod]
public void Score_PrimaryQueryMatchesSecondaryTarget_ShouldMatch()
{
// Scenario: Searching for "calc" should match a file "calculator.exe" where primary is filename, secondary is path
var query = CreateQuery("calc");
var target = CreateTarget(primary: "important.txt", secondary: "C:\\Programs\\Calculator\\");
var score = _matcher.Score(query, target);
Assert.IsTrue(score > 0, "Expected primary query to match secondary target");
}
[TestMethod]
public void Score_SecondaryQueryMatchesPrimaryTarget_ShouldMatch()
{
// Scenario: User types "documents\\report" and we want to match against filename
var query = CreateQuery(primary: "documents", secondary: "report");
var target = CreateTarget(primary: "report.docx");
var score = _matcher.Score(query, target);
Assert.IsTrue(score > 0, "Expected secondary query to match primary target");
}
[TestMethod]
public void Score_SecondaryQueryMatchesSecondaryTarget_ShouldMatch()
{
// Scenario: Both query and target have secondary info that matches
var query = CreateQuery(primary: "test", secondary: "documents");
var target = CreateTarget(primary: "something.txt", secondary: "C:\\Users\\Documents\\");
var score = _matcher.Score(query, target);
Assert.IsTrue(score > 0, "Expected secondary query to match secondary target");
}
[TestMethod]
public void Score_PrimaryQueryMatchesBothTargets_ShouldReturnBestScore()
{
// The same query matches both primary and secondary of target
var query = CreateQuery("test");
var target = CreateTarget(primary: "test.txt", secondary: "test_folder\\");
var score = _matcher.Score(query, target);
Assert.IsTrue(score > 0, "Expected query to match when it appears in both primary and secondary");
}
[TestMethod]
public void Score_NoSecondaryInQuery_MatchesSecondaryTarget()
{
// Query without secondary can still match target's secondary
var query = CreateQuery("downloads");
var target = CreateTarget(primary: "file.txt", secondary: "C:\\Downloads\\");
var score = _matcher.Score(query, target);
Assert.IsTrue(score > 0, "Expected primary query to match secondary target");
}
[TestMethod]
public void Score_NoSecondaryInTarget_SecondaryQueryShouldNotMatch()
{
// Query with secondary but target without secondary - secondary query shouldn't interfere
var query = CreateQuery(primary: "test", secondary: "extra");
var target = CreateTarget(primary: "test.txt");
var score = _matcher.Score(query, target);
// Primary should still match, secondary query just doesn't contribute
Assert.IsTrue(score > 0, "Expected primary query to match primary target");
}
[TestMethod]
public void Score_SecondaryQueryNoMatch_PrimaryCanStillMatch()
{
// Secondary doesn't match anything, but primary does
var query = CreateQuery(primary: "file", secondary: "nomatch");
var target = CreateTarget(primary: "myfile.txt", secondary: "C:\\Documents\\");
var score = _matcher.Score(query, target);
Assert.IsTrue(score > 0, "Expected primary query to match even when secondary doesn't");
}
[TestMethod]
public void Score_OnlySecondaryMatches_ShouldReturnScore()
{
// Only the secondary parts match, primary doesn't
var query = CreateQuery(primary: "xyz", secondary: "documents");
var target = CreateTarget(primary: "abc.txt", secondary: "C:\\Users\\Documents\\");
var score = _matcher.Score(query, target);
Assert.IsTrue(score > 0, "Expected match when only secondary parts match");
}
[TestMethod]
public void Score_BothQueriesMatchDifferentTargets_ShouldReturnBestScore()
{
// Primary query matches secondary target, secondary query matches primary target
var query = CreateQuery(primary: "docs", secondary: "report");
var target = CreateTarget(primary: "report.pdf", secondary: "C:\\Documents\\");
var score = _matcher.Score(query, target);
Assert.IsTrue(score > 0, "Expected match when queries cross-match with targets");
}
[TestMethod]
public void Score_CompletelyDifferent_ShouldNotMatch()
{
var query = CreateQuery(primary: "xyz", secondary: "abc");
var target = CreateTarget(primary: "hello", secondary: "world");
var score = _matcher.Score(query, target);
Assert.AreEqual(0, score, "Expected no match when nothing matches");
}
[TestMethod]
public void Score_EmptySecondaryInputs_ShouldMatchOnPrimary()
{
var query = CreateQuery(primary: "test", secondary: string.Empty);
var target = CreateTarget(primary: "test.txt", secondary: string.Empty);
var score = _matcher.Score(query, target);
Assert.IsTrue(score > 0, "Expected match on primary when secondaries are empty");
}
[TestMethod]
public void Score_WordSeparatorMatching_AcrossSecondary()
{
// Test that "Power Point" matches "PowerPoint" using secondary
var query = CreateQuery(primary: "power", secondary: "point");
var target = CreateTarget(primary: "PowerPoint.exe");
var score = _matcher.Score(query, target);
Assert.IsTrue(score > 0, "Expected 'power' + 'point' to match 'PowerPoint'");
}
private FuzzyQuery CreateQuery(string primary, string? secondary = null)
{
var primaryFolded = _folder.Fold(primary, removeDiacritics: true);
var primaryBloom = _bloom.Compute(primaryFolded);
var primaryEffectiveLength = primaryFolded.Length;
var primaryIsAllLowercase = IsAllLowercaseAsciiOrNonLetter(primary);
string? secondaryFolded = null;
ulong secondaryBloom = 0;
var secondaryEffectiveLength = 0;
var secondaryIsAllLowercase = true;
if (!string.IsNullOrEmpty(secondary))
{
secondaryFolded = _folder.Fold(secondary, removeDiacritics: true);
secondaryBloom = _bloom.Compute(secondaryFolded);
secondaryEffectiveLength = secondaryFolded.Length;
secondaryIsAllLowercase = IsAllLowercaseAsciiOrNonLetter(secondary);
}
return new FuzzyQuery(
original: primary,
folded: primaryFolded,
bloom: primaryBloom,
effectiveLength: primaryEffectiveLength,
isAllLowercaseAsciiOrNonLetter: primaryIsAllLowercase,
secondaryOriginal: secondary,
secondaryFolded: secondaryFolded,
secondaryBloom: secondaryBloom,
secondaryEffectiveLength: secondaryEffectiveLength,
secondaryIsAllLowercaseAsciiOrNonLetter: secondaryIsAllLowercase);
}
private FuzzyTarget CreateTarget(string primary, string? secondary = null)
{
var primaryFolded = _folder.Fold(primary, removeDiacritics: true);
var primaryBloom = _bloom.Compute(primaryFolded);
string? secondaryFolded = null;
ulong secondaryBloom = 0;
if (!string.IsNullOrEmpty(secondary))
{
secondaryFolded = _folder.Fold(secondary, removeDiacritics: true);
secondaryBloom = _bloom.Compute(secondaryFolded);
}
return new FuzzyTarget(
original: primary,
folded: primaryFolded,
bloom: primaryBloom,
secondaryOriginal: secondary,
secondaryFolded: secondaryFolded,
secondaryBloom: secondaryBloom);
}
private static bool IsAllLowercaseAsciiOrNonLetter(string s)
{
foreach (var c in s)
{
if ((uint)(c - 'A') <= ('Z' - 'A'))
{
return false;
}
}
return true;
}
}

View File

@@ -0,0 +1,209 @@
// Copyright (c) Microsoft Corporation
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
using Microsoft.CmdPal.Core.Common.Text;
namespace Microsoft.CmdPal.Common.UnitTests.Text;
[TestClass]
public class PrecomputedFuzzyMatcherTests
{
private readonly PrecomputedFuzzyMatcher _matcher = new();
public static IEnumerable<object[]> MatchData =>
[
["a", "a"],
["abc", "abc"],
["a", "ab"],
["b", "ab"],
["abc", "axbycz"],
["pt", "PowerToys"],
["calc", "Calculator"],
["vs", "Visual Studio"],
["code", "Visual Studio Code"],
// Diacritics
["abc", "ÁBC"],
// Separators
["p/t", "power\\toys"],
];
public static IEnumerable<object[]> NonMatchData =>
[
["z", "abc"],
["verylongstring", "short"],
];
[TestMethod]
[DynamicData(nameof(MatchData))]
public void Score_Matches_ShouldHavePositiveScore(string needle, string haystack)
{
var query = _matcher.PrecomputeQuery(needle);
var target = _matcher.PrecomputeTarget(haystack);
var score = _matcher.Score(query, target);
Assert.IsTrue(score > 0, $"Expected positive score for needle='{needle}', haystack='{haystack}'");
}
[TestMethod]
[DynamicData(nameof(NonMatchData))]
public void Score_NonMatches_ShouldHaveZeroScore(string needle, string haystack)
{
var query = _matcher.PrecomputeQuery(needle);
var target = _matcher.PrecomputeTarget(haystack);
var score = _matcher.Score(query, target);
Assert.AreEqual(0, score, $"Expected 0 score for needle='{needle}', haystack='{haystack}'");
}
[TestMethod]
public void Score_EmptyQuery_ReturnsZero()
{
var query = _matcher.PrecomputeQuery(string.Empty);
var target = _matcher.PrecomputeTarget("something");
Assert.AreEqual(0, _matcher.Score(query, target));
}
[TestMethod]
public void Score_EmptyTarget_ReturnsZero()
{
var query = _matcher.PrecomputeQuery("something");
var target = _matcher.PrecomputeTarget(string.Empty);
Assert.AreEqual(0, _matcher.Score(query, target));
}
[TestMethod]
public void SchemaId_DefaultMatcher_IsConsistent()
{
var matcher1 = new PrecomputedFuzzyMatcher();
var matcher2 = new PrecomputedFuzzyMatcher();
Assert.AreEqual(matcher1.SchemaId, matcher2.SchemaId, "Default matchers should have the same SchemaId");
}
[TestMethod]
public void SchemaId_SameOptions_ProducesSameId()
{
var options = new PrecomputedFuzzyMatcherOptions { RemoveDiacritics = true };
var matcher1 = new PrecomputedFuzzyMatcher(options);
var matcher2 = new PrecomputedFuzzyMatcher(options);
Assert.AreEqual(matcher1.SchemaId, matcher2.SchemaId, "Matchers with same options should have the same SchemaId");
}
[TestMethod]
public void SchemaId_DifferentRemoveDiacriticsOption_ProducesDifferentId()
{
var matcherWithDiacriticsRemoval = new PrecomputedFuzzyMatcher(
new PrecomputedFuzzyMatcherOptions { RemoveDiacritics = true });
var matcherWithoutDiacriticsRemoval = new PrecomputedFuzzyMatcher(
new PrecomputedFuzzyMatcherOptions { RemoveDiacritics = false });
Assert.AreNotEqual(
matcherWithDiacriticsRemoval.SchemaId,
matcherWithoutDiacriticsRemoval.SchemaId,
"Different RemoveDiacritics option should produce different SchemaId");
}
[TestMethod]
public void SchemaId_ScoringOptionsDoNotAffectId()
{
// SchemaId should only be affected by options that affect folding/bloom, not scoring
var matcher1 = new PrecomputedFuzzyMatcher(
new PrecomputedFuzzyMatcherOptions { CharMatchBonus = 1, CamelCaseBonus = 2 });
var matcher2 = new PrecomputedFuzzyMatcher(
new PrecomputedFuzzyMatcherOptions { CharMatchBonus = 100, CamelCaseBonus = 200 });
Assert.AreEqual(matcher1.SchemaId, matcher2.SchemaId, "Scoring options should not affect SchemaId");
}
[TestMethod]
public void Score_WordSeparatorMatching_PowerPoint()
{
// Test that "Power Point" can match "PowerPoint" when word separators are skipped
var query = _matcher.PrecomputeQuery("Power Point");
var target = _matcher.PrecomputeTarget("PowerPoint");
var score = _matcher.Score(query, target);
Assert.IsTrue(score > 0, "Expected 'Power Point' to match 'PowerPoint'");
}
[TestMethod]
public void Score_WordSeparatorMatching_UnderscoreDash()
{
// Test that different word separators match each other
var query = _matcher.PrecomputeQuery("hello_world");
var target = _matcher.PrecomputeTarget("hello-world");
var score = _matcher.Score(query, target);
Assert.IsTrue(score > 0, "Expected 'hello_world' to match 'hello-world'");
}
[TestMethod]
public void Score_WordSeparatorMatching_MixedSeparators()
{
// Test multiple different separators
var query = _matcher.PrecomputeQuery("my.file_name");
var target = _matcher.PrecomputeTarget("my-file.name");
var score = _matcher.Score(query, target);
Assert.IsTrue(score > 0, "Expected mixed separators to match");
}
[TestMethod]
public void Score_PrecomputedQueryReuse_ShouldWorkConsistently()
{
// Test that precomputed query can be reused across multiple targets
var query = _matcher.PrecomputeQuery("test");
var target1 = _matcher.PrecomputeTarget("test123");
var target2 = _matcher.PrecomputeTarget("mytest");
var target3 = _matcher.PrecomputeTarget("unrelated");
var score1 = _matcher.Score(query, target1);
var score2 = _matcher.Score(query, target2);
var score3 = _matcher.Score(query, target3);
Assert.IsTrue(score1 > 0, "Expected query to match first target");
Assert.IsTrue(score2 > 0, "Expected query to match second target");
Assert.AreEqual(0, score3, "Expected query not to match third target");
}
[TestMethod]
public void Score_PrecomputedTargetReuse_ShouldWorkConsistently()
{
// Test that precomputed target can be reused across multiple queries
var target = _matcher.PrecomputeTarget("calculator");
var query1 = _matcher.PrecomputeQuery("calc");
var query2 = _matcher.PrecomputeQuery("lator");
var query3 = _matcher.PrecomputeQuery("xyz");
var score1 = _matcher.Score(query1, target);
var score2 = _matcher.Score(query2, target);
var score3 = _matcher.Score(query3, target);
Assert.IsTrue(score1 > 0, "Expected first query to match target");
Assert.IsTrue(score2 > 0, "Expected second query to match target");
Assert.AreEqual(0, score3, "Expected third query not to match target");
}
[TestMethod]
public void Score_CaseInsensitiveMatching_Works()
{
// Test various case combinations
var query1 = _matcher.PrecomputeQuery("test");
var query2 = _matcher.PrecomputeQuery("TEST");
var query3 = _matcher.PrecomputeQuery("TeSt");
var target = _matcher.PrecomputeTarget("TestFile");
var score1 = _matcher.Score(query1, target);
var score2 = _matcher.Score(query2, target);
var score3 = _matcher.Score(query3, target);
Assert.IsTrue(score1 > 0, "Expected lowercase query to match");
Assert.IsTrue(score2 > 0, "Expected uppercase query to match");
Assert.IsTrue(score3 > 0, "Expected mixed case query to match");
}
}

View File

@@ -0,0 +1,124 @@
// Copyright (c) Microsoft Corporation
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
using Microsoft.CmdPal.Core.Common.Text;
namespace Microsoft.CmdPal.Common.UnitTests.Text;
[TestClass]
public sealed class PrecomputedFuzzyMatcherUnicodeTests
{
private readonly PrecomputedFuzzyMatcher _defaultMatcher = new();
[TestMethod]
public void UnpairedHighSurrogateInNeedle_ShouldNotThrow()
{
const string needle = "\uD83D"; // high surrogate (unpaired)
const string haystack = "abc";
var q = _defaultMatcher.PrecomputeQuery(needle);
var t = _defaultMatcher.PrecomputeTarget(haystack);
_ = _defaultMatcher.Score(q, t);
}
[TestMethod]
public void UnpairedLowSurrogateInNeedle_ShouldNotThrow()
{
const string needle = "\uDC00"; // low surrogate (unpaired)
const string haystack = "abc";
var q = _defaultMatcher.PrecomputeQuery(needle);
var t = _defaultMatcher.PrecomputeTarget(haystack);
_ = _defaultMatcher.Score(q, t);
}
[TestMethod]
public void UnpairedHighSurrogateInHaystack_ShouldNotThrow()
{
const string needle = "a";
const string haystack = "a\uD83D" + "bc"; // inject unpaired high surrogate
var q = _defaultMatcher.PrecomputeQuery(needle);
var t = _defaultMatcher.PrecomputeTarget(haystack);
_ = _defaultMatcher.Score(q, t);
}
[TestMethod]
public void MixedSurrogatesAndMarks_ShouldNotThrow()
{
// "Garbage smoothie": unpaired surrogate + combining mark + emoji surrogate pair
const string needle = "a\uD83D\u0301"; // 'a' + unpaired high surrogate + combining acute
const string haystack = "a\u0301 \U0001F600"; // 'a' + combining acute + space + 😀 (valid pair)
var q = _defaultMatcher.PrecomputeQuery(needle);
var t = _defaultMatcher.PrecomputeTarget(haystack);
_ = _defaultMatcher.Score(q, t);
}
[TestMethod]
public void ValidEmojiSurrogatePair_ShouldNotThrow_AndCanMatch()
{
// 😀 U+1F600 encoded as surrogate pair in UTF-16
const string needle = "\U0001F600";
const string haystack = "x \U0001F600 y";
var q = _defaultMatcher.PrecomputeQuery(needle);
var t = _defaultMatcher.PrecomputeTarget(haystack);
var score = _defaultMatcher.Score(q, t);
Assert.IsTrue(score > 0, "Expected emoji to produce a match score > 0.");
}
[TestMethod]
public void RandomUtf16Garbage_ShouldNotThrow()
{
// Deterministic pseudo-random "UTF-16 garbage", including surrogates.
var s1 = MakeDeterministicGarbage(seed: 1234, length: 512);
var s2 = MakeDeterministicGarbage(seed: 5678, length: 1024);
var q = _defaultMatcher.PrecomputeQuery(s1);
var t = _defaultMatcher.PrecomputeTarget(s2);
_ = _defaultMatcher.Score(q, t);
}
[TestMethod]
public void HighSurrogateAtEndOfHaystack_ShouldNotThrow()
{
const string needle = "a";
const string haystack = "abc\uD83D"; // Ends with high surrogate
var q = _defaultMatcher.PrecomputeQuery(needle);
var t = _defaultMatcher.PrecomputeTarget(haystack);
_ = _defaultMatcher.Score(q, t);
}
[TestMethod]
public void VeryLongStrings_ShouldNotThrow()
{
var needle = new string('a', 100);
var haystack = new string('b', 10000) + needle + new string('c', 10000);
var q = _defaultMatcher.PrecomputeQuery(needle);
var t = _defaultMatcher.PrecomputeTarget(haystack);
_ = _defaultMatcher.Score(q, t);
}
private static string MakeDeterministicGarbage(int seed, int length)
{
// LCG for deterministic generation without Randoms platform/version surprises.
var x = (uint)seed;
var chars = length <= 2048 ? stackalloc char[length] : new char[length];
for (var i = 0; i < chars.Length; i++)
{
// LCG: x = (a*x + c) mod 2^32
x = unchecked((1664525u * x) + 1013904223u);
// Take top 16 bits as UTF-16 code unit (includes surrogates).
chars[i] = (char)(x >> 16);
}
return new string(chars);
}
}

View File

@@ -0,0 +1,117 @@
// Copyright (c) Microsoft Corporation
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
using System.Globalization;
using Microsoft.CmdPal.Core.Common.Text;
namespace Microsoft.CmdPal.Common.UnitTests.Text;
[TestClass]
public class PrecomputedFuzzyMatcherWithPinyinTests
{
private PrecomputedFuzzyMatcherWithPinyin CreateMatcher(PinyinMode mode = PinyinMode.On, bool removeApostrophes = true)
{
return new PrecomputedFuzzyMatcherWithPinyin(
new PrecomputedFuzzyMatcherOptions(),
new PinyinFuzzyMatcherOptions { Mode = mode, RemoveApostrophesForQuery = removeApostrophes },
new StringFolder(),
new BloomFilter());
}
[TestMethod]
[DataRow("bj", "北京")]
[DataRow("sh", "上海")]
[DataRow("nihao", "你好")]
[DataRow("beijing", "北京")]
[DataRow("ce", "测试")]
public void Score_PinyinMatches_ShouldHavePositiveScore(string needle, string haystack)
{
var matcher = CreateMatcher(PinyinMode.On);
var query = matcher.PrecomputeQuery(needle);
var target = matcher.PrecomputeTarget(haystack);
var score = matcher.Score(query, target);
Assert.IsTrue(score > 0, $"Expected positive score for needle='{needle}', haystack='{haystack}'");
}
[TestMethod]
public void Score_PinyinOff_ShouldNotMatchPinyin()
{
var matcher = CreateMatcher(PinyinMode.Off);
var needle = "bj";
var haystack = "北京";
var query = matcher.PrecomputeQuery(needle);
var target = matcher.PrecomputeTarget(haystack);
var score = matcher.Score(query, target);
Assert.AreEqual(0, score, "Pinyin match should be disabled.");
}
[TestMethod]
public void Score_StandardMatch_WorksWithPinyinMatcher()
{
var matcher = CreateMatcher(PinyinMode.On);
var needle = "abc";
var haystack = "abc";
var query = matcher.PrecomputeQuery(needle);
var target = matcher.PrecomputeTarget(haystack);
var score = matcher.Score(query, target);
Assert.IsTrue(score > 0, "Standard match should still work.");
}
[TestMethod]
public void Score_ApostropheRemoval_Works()
{
var matcher = CreateMatcher(PinyinMode.On, removeApostrophes: true);
var needle = "xi'an";
// "xi'an" -> "xian" -> matches "西安" (Xi An)
var haystack = "西安";
var query = matcher.PrecomputeQuery(needle);
var target = matcher.PrecomputeTarget(haystack);
var score = matcher.Score(query, target);
Assert.IsTrue(score > 0, "Expected match for 'xi'an' -> '西安' with apostrophe removal.");
}
[TestMethod]
public void AutoMode_EnablesForChineseCulture()
{
var originalCulture = CultureInfo.CurrentUICulture;
try
{
CultureInfo.CurrentUICulture = new CultureInfo("zh-CN");
var matcher = CreateMatcher(PinyinMode.AutoSimplifiedChineseUi);
var score = matcher.Score(matcher.PrecomputeQuery("bj"), matcher.PrecomputeTarget("北京"));
Assert.IsTrue(score > 0, "Should match when UI culture is zh-CN");
}
finally
{
CultureInfo.CurrentUICulture = originalCulture;
}
}
[TestMethod]
public void AutoMode_DisablesForNonChineseCulture()
{
var originalCulture = CultureInfo.CurrentUICulture;
try
{
CultureInfo.CurrentUICulture = new CultureInfo("en-US");
var matcher = CreateMatcher(PinyinMode.AutoSimplifiedChineseUi);
var score = matcher.Score(matcher.PrecomputeQuery("bj"), matcher.PrecomputeTarget("北京"));
Assert.AreEqual(0, score, "Should NOT match when UI culture is en-US");
}
finally
{
CultureInfo.CurrentUICulture = originalCulture;
}
}
}

View File

@@ -0,0 +1,55 @@
// Copyright (c) Microsoft Corporation
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
using Microsoft.CmdPal.Core.Common.Text;
namespace Microsoft.CmdPal.Common.UnitTests.Text;
[TestClass]
public class StringFolderTests
{
private readonly StringFolder _folder = new();
[TestMethod]
[DataRow(null, "")]
[DataRow("", "")]
[DataRow("abc", "ABC")]
[DataRow("ABC", "ABC")]
[DataRow("a\\b", "A/B")]
[DataRow("a/b", "A/B")]
[DataRow("ÁBC", "ABC")] // Diacritic removal
[DataRow("ñ", "N")]
[DataRow("hello world", "HELLO WORLD")]
public void Fold_RemoveDiacritics_Works(string input, string expected)
{
Assert.AreEqual(expected, _folder.Fold(input, removeDiacritics: true));
}
[TestMethod]
[DataRow("abc", "ABC")]
[DataRow("ÁBC", "ÁBC")] // No diacritic removal
[DataRow("a\\b", "A/B")]
public void Fold_KeepDiacritics_Works(string input, string expected)
{
Assert.AreEqual(expected, _folder.Fold(input, removeDiacritics: false));
}
[TestMethod]
public void Fold_IsAlreadyFolded_ReturnsSameInstance()
{
var input = "ALREADY/FOLDED";
var result = _folder.Fold(input, removeDiacritics: true);
Assert.AreSame(input, result);
}
[TestMethod]
public void Fold_WithNonAsciiButNoDiacritics_ReturnsFolded()
{
// E.g. Cyrillic or other scripts that might not decompose in a simple way or just upper case
// "привет" -> "ПРИВЕТ"
var input = "привет";
var expected = "ПРИВЕТ";
Assert.AreEqual(expected, _folder.Fold(input, removeDiacritics: true));
}
}

View File

@@ -5,6 +5,7 @@
using System;
using System.Collections.Generic;
using System.Linq;
using Microsoft.CmdPal.Core.Common.Helpers;
using Microsoft.CmdPal.UI.ViewModels.Commands;
using Microsoft.CommandPalette.Extensions;
using Microsoft.CommandPalette.Extensions.Toolkit;
@@ -43,38 +44,34 @@ public partial class MainListPageResultFactoryTests
public override string ToString() => Title;
}
private static Scored<IListItem> S(string title, int score)
private static RoScored<IListItem> S(string title, int score)
{
return new Scored<IListItem>
{
Score = score,
Item = new MockListItem { Title = title },
};
return new RoScored<IListItem>(score: score, item: new MockListItem { Title = title });
}
[TestMethod]
public void Merge_PrioritizesListsCorrectly()
{
var filtered = new List<Scored<IListItem>>
var filtered = new List<RoScored<IListItem>>
{
S("F1", 100),
S("F2", 50),
};
var scoredFallback = new List<Scored<IListItem>>
var scoredFallback = new List<RoScored<IListItem>>
{
S("SF1", 100),
S("SF2", 60),
};
var apps = new List<Scored<IListItem>>
var apps = new List<RoScored<IListItem>>
{
S("A1", 100),
S("A2", 55),
};
// Fallbacks are not scored.
var fallbacks = new List<Scored<IListItem>>
var fallbacks = new List<RoScored<IListItem>>
{
S("FB1", 0),
S("FB2", 0),
@@ -104,7 +101,7 @@ public partial class MainListPageResultFactoryTests
[TestMethod]
public void Merge_AppliesAppLimit()
{
var apps = new List<Scored<IListItem>>
var apps = new List<RoScored<IListItem>>
{
S("A1", 100),
S("A2", 90),
@@ -126,7 +123,7 @@ public partial class MainListPageResultFactoryTests
[TestMethod]
public void Merge_FiltersEmptyFallbacks()
{
var fallbacks = new List<Scored<IListItem>>
var fallbacks = new List<RoScored<IListItem>>
{
S("FB1", 0),
S("FB3", 0),

View File

@@ -5,6 +5,7 @@
using System;
using System.Collections.Generic;
using System.Linq;
using Microsoft.CmdPal.Core.Common.Text;
using Microsoft.CmdPal.Ext.UnitTestBase;
using Microsoft.CmdPal.UI.ViewModels.MainPage;
using Microsoft.CommandPalette.Extensions;
@@ -263,10 +264,12 @@ public partial class RecentCommandsTests : CommandPaletteUnitTestBase
};
var history = CreateHistory(items.Reverse<ListItemMock>().ToList());
var fuzzyMatcher = CreateMatcher();
var q = fuzzyMatcher.PrecomputeQuery("C");
var scoreA = MainListPage.ScoreTopLevelItem("C", items[0], history);
var scoreB = MainListPage.ScoreTopLevelItem("C", items[1], history);
var scoreC = MainListPage.ScoreTopLevelItem("C", items[2], history);
var scoreA = MainListPage.ScoreTopLevelItem(q, items[0], history, fuzzyMatcher);
var scoreB = MainListPage.ScoreTopLevelItem(q, items[1], history, fuzzyMatcher);
var scoreC = MainListPage.ScoreTopLevelItem(q, items[2], history, fuzzyMatcher);
// Assert
// All of these equally match the query, and they're all in the same bucket,
@@ -296,6 +299,11 @@ public partial class RecentCommandsTests : CommandPaletteUnitTestBase
return history;
}
private static IPrecomputedFuzzyMatcher CreateMatcher()
{
return new PrecomputedFuzzyMatcher(new PrecomputedFuzzyMatcherOptions());
}
private sealed record ScoredItem(ListItemMock Item, int Score)
{
public string Title => Item.Title;
@@ -337,9 +345,11 @@ public partial class RecentCommandsTests : CommandPaletteUnitTestBase
var items = CreateMockHistoryItems();
var emptyHistory = CreateMockHistoryService(new());
var history = CreateMockHistoryService(items);
var fuzzyMatcher = CreateMatcher();
var unweightedScores = items.Select(item => MainListPage.ScoreTopLevelItem("C", item, emptyHistory)).ToList();
var weightedScores = items.Select(item => MainListPage.ScoreTopLevelItem("C", item, history)).ToList();
var q = fuzzyMatcher.PrecomputeQuery("C");
var unweightedScores = items.Select(item => MainListPage.ScoreTopLevelItem(q, item, emptyHistory, fuzzyMatcher)).ToList();
var weightedScores = items.Select(item => MainListPage.ScoreTopLevelItem(q, item, history, fuzzyMatcher)).ToList();
Assert.AreEqual(unweightedScores.Count, weightedScores.Count, "Both score lists should have the same number of items");
for (var i = 0; i < unweightedScores.Count; i++)
{
@@ -380,7 +390,10 @@ public partial class RecentCommandsTests : CommandPaletteUnitTestBase
var items = CreateMockHistoryItems();
var emptyHistory = CreateMockHistoryService(new());
var history = CreateMockHistoryService(items);
var weightedScores = items.Select(item => MainListPage.ScoreTopLevelItem("te", item, history)).ToList();
var fuzzyMatcher = CreateMatcher();
var q = fuzzyMatcher.PrecomputeQuery("te");
var weightedScores = items.Select(item => MainListPage.ScoreTopLevelItem(q, item, history, fuzzyMatcher)).ToList();
var weightedMatches = GetMatches(items, weightedScores).ToList();
Assert.AreEqual(3, weightedMatches.Count, "Find Terminal, VsCode and Run commands");
@@ -398,6 +411,8 @@ public partial class RecentCommandsTests : CommandPaletteUnitTestBase
var items = CreateMockHistoryItems();
var emptyHistory = CreateMockHistoryService(new());
var history = CreateMockHistoryService(items);
var fuzzyMatcher = CreateMatcher();
var q = fuzzyMatcher.PrecomputeQuery("te");
// Add extra uses of VS Code to try and push it above Terminal
for (var i = 0; i < 10; i++)
@@ -405,7 +420,7 @@ public partial class RecentCommandsTests : CommandPaletteUnitTestBase
history.AddHistoryItem(items[1].Id);
}
var weightedScores = items.Select(item => MainListPage.ScoreTopLevelItem("te", item, history)).ToList();
var weightedScores = items.Select(item => MainListPage.ScoreTopLevelItem(q, item, history, fuzzyMatcher)).ToList();
var weightedMatches = GetMatches(items, weightedScores).ToList();
Assert.AreEqual(3, weightedMatches.Count, "Find Terminal, VsCode and Run commands");
@@ -423,6 +438,8 @@ public partial class RecentCommandsTests : CommandPaletteUnitTestBase
var items = CreateMockHistoryItems();
var emptyHistory = CreateMockHistoryService(new());
var history = CreateMockHistoryService(items);
var fuzzyMatcher = CreateMatcher();
var q = fuzzyMatcher.PrecomputeQuery("C");
// We're gonna run this test and keep adding more uses of VS Code till
// it breaks past Command Prompt
@@ -431,7 +448,7 @@ public partial class RecentCommandsTests : CommandPaletteUnitTestBase
{
history.AddHistoryItem(vsCodeId);
var weightedScores = items.Select(item => MainListPage.ScoreTopLevelItem("C", item, history)).ToList();
var weightedScores = items.Select(item => MainListPage.ScoreTopLevelItem(q, item, history, fuzzyMatcher)).ToList();
var weightedMatches = GetMatches(items, weightedScores).ToList();
Assert.AreEqual(4, weightedMatches.Count);

View File

@@ -7,6 +7,7 @@ using System.Collections.Generic;
using System.Threading.Tasks;
using ManagedCommon;
using Microsoft.CmdPal.Core.Common.Helpers;
using Microsoft.CmdPal.Core.Common.Text;
using Microsoft.CmdPal.Ext.Apps.Commands;
using Microsoft.CmdPal.Ext.Apps.Helpers;
using Microsoft.CommandPalette.Extensions;
@@ -14,7 +15,7 @@ using Microsoft.CommandPalette.Extensions.Toolkit;
namespace Microsoft.CmdPal.Ext.Apps.Programs;
public sealed partial class AppListItem : ListItem
public sealed partial class AppListItem : ListItem, IPrecomputedListItem
{
private readonly AppCommand _appCommand;
private readonly AppItem _app;
@@ -25,6 +26,35 @@ public sealed partial class AppListItem : ListItem
private InterlockedBoolean _isLoadingIcon;
private InterlockedBoolean _isLoadingDetails;
private FuzzyTargetCache _titleCache;
private FuzzyTargetCache _subtitleCache;
public override string Title
{
get => base.Title;
set
{
if (!string.Equals(base.Title, value, StringComparison.Ordinal))
{
base.Title = value;
_titleCache.Invalidate();
}
}
}
public override string Subtitle
{
get => base.Subtitle;
set
{
if (!string.Equals(value, base.Subtitle, StringComparison.Ordinal))
{
base.Subtitle = value;
_subtitleCache.Invalidate();
}
}
}
public override IDetails? Details
{
get
@@ -259,4 +289,10 @@ public sealed partial class AppListItem : ListItem
return null;
}).ConfigureAwait(false);
}
public FuzzyTarget GetTitleTarget(IPrecomputedFuzzyMatcher matcher)
=> _titleCache.GetOrUpdate(matcher, Title);
public FuzzyTarget GetSubtitleTarget(IPrecomputedFuzzyMatcher matcher)
=> _subtitleCache.GetOrUpdate(matcher, Subtitle);
}

Some files were not shown because too many files have changed in this diff Show More