mirror of
https://github.com/microsoft/PowerToys.git
synced 2026-02-01 08:50:22 +01:00
Compare commits
7 Commits
leilzh/fix
...
user/yeela
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2a25b4385f | ||
|
|
c2f916f72b | ||
|
|
6d4f56cd83 | ||
|
|
4986915dae | ||
|
|
cc2dce8816 | ||
|
|
0de2af77ac | ||
|
|
4694e99477 |
1
.claude/CLAUDE.md
Symbolic link
1
.claude/CLAUDE.md
Symbolic link
@@ -0,0 +1 @@
|
||||
../.github/copilot-instructions.md
|
||||
1
.claude/agents
Symbolic link
1
.claude/agents
Symbolic link
@@ -0,0 +1 @@
|
||||
../.github/agents
|
||||
1
.claude/commands
Symbolic link
1
.claude/commands
Symbolic link
@@ -0,0 +1 @@
|
||||
../.github/prompts
|
||||
1
.claude/rules
Symbolic link
1
.claude/rules
Symbolic link
@@ -0,0 +1 @@
|
||||
../.github/instructions
|
||||
1
.claude/skills
Symbolic link
1
.claude/skills
Symbolic link
@@ -0,0 +1 @@
|
||||
../.github/skills
|
||||
2
.github/actions/spell-check/excludes.txt
vendored
2
.github/actions/spell-check/excludes.txt
vendored
@@ -104,6 +104,8 @@
|
||||
^src/common/ManagedCommon/ColorFormatHelper\.cs$
|
||||
^src/common/notifications/BackgroundActivatorDLL/cpp\.hint$
|
||||
^src/common/sysinternals/Eula/
|
||||
^src/modules/cmdpal/Tests/Microsoft\.CommandPalette\.Extensions\.Toolkit\.UnitTests/FuzzyMatcherComparisonTests.cs$
|
||||
^src/modules/cmdpal/Tests/Microsoft\.CommandPalette\.Extensions\.Toolkit\.UnitTests/FuzzyMatcherDiacriticsTests.cs$
|
||||
^src/modules/cmdpal/doc/initial-sdk-spec/list-elements-mock-002\.pdn$
|
||||
^src/modules/cmdpal/ext/SamplePagesExtension/Pages/SampleMarkdownImagesPage\.cs$
|
||||
^src/modules/cmdpal/Microsoft\.CmdPal\.UI/Settings/InternalPage\.SampleData\.cs$
|
||||
|
||||
2
.github/actions/spell-check/expect.txt
vendored
2
.github/actions/spell-check/expect.txt
vendored
@@ -1532,6 +1532,7 @@ riid
|
||||
RKey
|
||||
RNumber
|
||||
rollups
|
||||
ROOTOWNER
|
||||
rop
|
||||
ROUNDSMALL
|
||||
ROWSETEXT
|
||||
@@ -1826,6 +1827,7 @@ TEXTBOXNEWLINE
|
||||
textextractor
|
||||
TEXTINCLUDE
|
||||
tfopen
|
||||
tgamma
|
||||
tgz
|
||||
THEMECHANGED
|
||||
themeresources
|
||||
|
||||
@@ -360,6 +360,10 @@
|
||||
<Platform Solution="*|ARM64" Project="ARM64" />
|
||||
<Platform Solution="*|x64" Project="x64" />
|
||||
</Project>
|
||||
<Project Path="src/modules/cmdpal/Tests/Microsoft.CommandPalette.Extensions.Toolkit.UnitTests/Microsoft.CommandPalette.Extensions.Toolkit.UnitTests.csproj" Id="2eca18b7-33b7-4829-88f1-439b20fd60f6">
|
||||
<Platform Solution="*|ARM64" Project="ARM64" />
|
||||
<Platform Solution="*|x64" Project="x64" />
|
||||
</Project>
|
||||
</Folder>
|
||||
<Folder Name="/modules/CommandPalette/UI/">
|
||||
<Project Path="src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/Microsoft.CmdPal.UI.ViewModels.csproj">
|
||||
|
||||
@@ -146,7 +146,7 @@
|
||||
<Custom Action="UnRegisterCmdPalPackage" Before="RemoveFiles" Condition="Installed AND (NOT UPGRADINGPRODUCTCODE) AND (REMOVE="ALL")" />
|
||||
<Custom Action="UninstallCommandNotFound" Before="RemoveFiles" Condition="Installed AND (NOT UPGRADINGPRODUCTCODE) AND (REMOVE="ALL")" />
|
||||
<Custom Action="UpgradeCommandNotFound" After="InstallFiles" Condition="WIX_UPGRADE_DETECTED" />
|
||||
<Custom Action="UninstallPackageIdentityMSIX" Before="RemoveFiles" Condition="Installed AND (REMOVE="ALL")" />
|
||||
<Custom Action="UninstallPackageIdentityMSIX" Before="RemoveFiles" Condition="Installed AND (NOT UPGRADINGPRODUCTCODE) AND (REMOVE="ALL")" />
|
||||
<Custom Action="UninstallServicesTask" After="InstallFinalize" Condition="Installed AND (NOT UPGRADINGPRODUCTCODE) AND (REMOVE="ALL")" />
|
||||
<!-- TODO: Use to activate embedded MSIX -->
|
||||
<!--<Custom Action="UninstallEmbeddedMSIXTask" After="InstallFinalize">
|
||||
|
||||
@@ -3,9 +3,27 @@
|
||||
#include <iomanip>
|
||||
#include <iostream>
|
||||
#include <sstream>
|
||||
#include <cmath>
|
||||
#include <limits>
|
||||
|
||||
namespace ExprtkCalculator::internal
|
||||
{
|
||||
static double factorial(const double n)
|
||||
{
|
||||
// Only allow non-negative integers
|
||||
if (n < 0.0 || std::floor(n) != n)
|
||||
{
|
||||
return std::numeric_limits<double>::quiet_NaN();
|
||||
}
|
||||
return std::tgamma(n + 1.0);
|
||||
}
|
||||
|
||||
static double sign(const double n)
|
||||
{
|
||||
if (n > 0.0) return 1.0;
|
||||
if (n < 0.0) return -1.0;
|
||||
return 0.0;
|
||||
}
|
||||
|
||||
std::wstring ToWStringFullPrecision(double value)
|
||||
{
|
||||
@@ -25,6 +43,9 @@ namespace ExprtkCalculator::internal
|
||||
symbol_table.add_constant(name, value);
|
||||
}
|
||||
|
||||
symbol_table.add_function("factorial", factorial);
|
||||
symbol_table.add_function("sign", sign);
|
||||
|
||||
exprtk::expression<double> expression;
|
||||
expression.register_symbol_table(symbol_table);
|
||||
|
||||
|
||||
@@ -72,6 +72,10 @@ namespace CommonSharedConstants
|
||||
|
||||
const wchar_t ALWAYS_ON_TOP_TERMINATE_EVENT[] = L"Local\\AlwaysOnTopTerminateEvent-cfdf1eae-791f-4953-8021-2f18f3837eae";
|
||||
|
||||
const wchar_t ALWAYS_ON_TOP_INCREASE_OPACITY_EVENT[] = L"Local\\AlwaysOnTopIncreaseOpacityEvent-a1b2c3d4-e5f6-7890-abcd-ef1234567890";
|
||||
|
||||
const wchar_t ALWAYS_ON_TOP_DECREASE_OPACITY_EVENT[] = L"Local\\AlwaysOnTopDecreaseOpacityEvent-b2c3d4e5-f6a7-8901-bcde-f12345678901";
|
||||
|
||||
// Path to the event used by PowerAccent
|
||||
const wchar_t POWERACCENT_EXIT_EVENT[] = L"Local\\PowerToysPowerAccentExitEvent-53e93389-d19a-4fbb-9b36-1981c8965e17";
|
||||
|
||||
|
||||
@@ -153,9 +153,21 @@ LRESULT AlwaysOnTop::WndProc(HWND window, UINT message, WPARAM wparam, LPARAM lp
|
||||
{
|
||||
if (message == WM_HOTKEY)
|
||||
{
|
||||
int hotkeyId = static_cast<int>(wparam);
|
||||
if (HWND fw{ GetForegroundWindow() })
|
||||
{
|
||||
ProcessCommand(fw);
|
||||
if (hotkeyId == static_cast<int>(HotkeyId::Pin))
|
||||
{
|
||||
ProcessCommand(fw);
|
||||
}
|
||||
else if (hotkeyId == static_cast<int>(HotkeyId::IncreaseOpacity))
|
||||
{
|
||||
StepWindowTransparency(fw, Settings::transparencyStep);
|
||||
}
|
||||
else if (hotkeyId == static_cast<int>(HotkeyId::DecreaseOpacity))
|
||||
{
|
||||
StepWindowTransparency(fw, -Settings::transparencyStep);
|
||||
}
|
||||
}
|
||||
}
|
||||
else if (message == WM_PRIV_SETTINGS_CHANGED)
|
||||
@@ -191,6 +203,10 @@ void AlwaysOnTop::ProcessCommand(HWND window)
|
||||
m_topmostWindows.erase(iter);
|
||||
}
|
||||
|
||||
// Restore transparency when unpinning
|
||||
RestoreWindowAlpha(window);
|
||||
m_windowOriginalLayeredState.erase(window);
|
||||
|
||||
Trace::AlwaysOnTop::UnpinWindow();
|
||||
}
|
||||
}
|
||||
@@ -200,6 +216,7 @@ void AlwaysOnTop::ProcessCommand(HWND window)
|
||||
{
|
||||
soundType = Sound::Type::On;
|
||||
AssignBorder(window);
|
||||
|
||||
Trace::AlwaysOnTop::PinWindow();
|
||||
}
|
||||
}
|
||||
@@ -269,11 +286,22 @@ void AlwaysOnTop::RegisterHotkey() const
|
||||
{
|
||||
if (m_useCentralizedLLKH)
|
||||
{
|
||||
// All hotkeys are handled by centralized LLKH
|
||||
return;
|
||||
}
|
||||
|
||||
// Register hotkeys only when not using centralized LLKH
|
||||
UnregisterHotKey(m_window, static_cast<int>(HotkeyId::Pin));
|
||||
UnregisterHotKey(m_window, static_cast<int>(HotkeyId::IncreaseOpacity));
|
||||
UnregisterHotKey(m_window, static_cast<int>(HotkeyId::DecreaseOpacity));
|
||||
|
||||
// Register pin hotkey
|
||||
RegisterHotKey(m_window, static_cast<int>(HotkeyId::Pin), AlwaysOnTopSettings::settings().hotkey.get_modifiers(), AlwaysOnTopSettings::settings().hotkey.get_code());
|
||||
|
||||
// Register transparency hotkeys using the same modifiers as the pin hotkey
|
||||
UINT modifiers = AlwaysOnTopSettings::settings().hotkey.get_modifiers();
|
||||
RegisterHotKey(m_window, static_cast<int>(HotkeyId::IncreaseOpacity), modifiers, VK_OEM_PLUS);
|
||||
RegisterHotKey(m_window, static_cast<int>(HotkeyId::DecreaseOpacity), modifiers, VK_OEM_MINUS);
|
||||
}
|
||||
|
||||
void AlwaysOnTop::RegisterLLKH()
|
||||
@@ -285,6 +313,8 @@ void AlwaysOnTop::RegisterLLKH()
|
||||
|
||||
m_hPinEvent = CreateEventW(nullptr, false, false, CommonSharedConstants::ALWAYS_ON_TOP_PIN_EVENT);
|
||||
m_hTerminateEvent = CreateEventW(nullptr, false, false, CommonSharedConstants::ALWAYS_ON_TOP_TERMINATE_EVENT);
|
||||
m_hIncreaseOpacityEvent = CreateEventW(nullptr, false, false, CommonSharedConstants::ALWAYS_ON_TOP_INCREASE_OPACITY_EVENT);
|
||||
m_hDecreaseOpacityEvent = CreateEventW(nullptr, false, false, CommonSharedConstants::ALWAYS_ON_TOP_DECREASE_OPACITY_EVENT);
|
||||
|
||||
if (!m_hPinEvent)
|
||||
{
|
||||
@@ -298,30 +328,54 @@ void AlwaysOnTop::RegisterLLKH()
|
||||
return;
|
||||
}
|
||||
|
||||
HANDLE handles[2] = { m_hPinEvent,
|
||||
m_hTerminateEvent };
|
||||
if (!m_hIncreaseOpacityEvent)
|
||||
{
|
||||
Logger::warn(L"Failed to create increaseOpacityEvent. {}", get_last_error_or_default(GetLastError()));
|
||||
}
|
||||
|
||||
if (!m_hDecreaseOpacityEvent)
|
||||
{
|
||||
Logger::warn(L"Failed to create decreaseOpacityEvent. {}", get_last_error_or_default(GetLastError()));
|
||||
}
|
||||
|
||||
HANDLE handles[4] = { m_hPinEvent,
|
||||
m_hTerminateEvent,
|
||||
m_hIncreaseOpacityEvent,
|
||||
m_hDecreaseOpacityEvent };
|
||||
|
||||
m_thread = std::thread([this, handles]() {
|
||||
MSG msg;
|
||||
while (m_running)
|
||||
{
|
||||
DWORD dwEvt = MsgWaitForMultipleObjects(2, handles, false, INFINITE, QS_ALLINPUT);
|
||||
DWORD dwEvt = MsgWaitForMultipleObjects(4, handles, false, INFINITE, QS_ALLINPUT);
|
||||
if (!m_running)
|
||||
{
|
||||
break;
|
||||
}
|
||||
switch (dwEvt)
|
||||
{
|
||||
case WAIT_OBJECT_0:
|
||||
case WAIT_OBJECT_0: // Pin event
|
||||
if (HWND fw{ GetForegroundWindow() })
|
||||
{
|
||||
ProcessCommand(fw);
|
||||
}
|
||||
break;
|
||||
case WAIT_OBJECT_0 + 1:
|
||||
case WAIT_OBJECT_0 + 1: // Terminate event
|
||||
PostThreadMessage(m_mainThreadId, WM_QUIT, 0, 0);
|
||||
break;
|
||||
case WAIT_OBJECT_0 + 2:
|
||||
case WAIT_OBJECT_0 + 2: // Increase opacity event
|
||||
if (HWND fw{ GetForegroundWindow() })
|
||||
{
|
||||
StepWindowTransparency(fw, Settings::transparencyStep);
|
||||
}
|
||||
break;
|
||||
case WAIT_OBJECT_0 + 3: // Decrease opacity event
|
||||
if (HWND fw{ GetForegroundWindow() })
|
||||
{
|
||||
StepWindowTransparency(fw, -Settings::transparencyStep);
|
||||
}
|
||||
break;
|
||||
case WAIT_OBJECT_0 + 4: // Message queue
|
||||
if (PeekMessageW(&msg, nullptr, 0, 0, PM_REMOVE))
|
||||
{
|
||||
TranslateMessage(&msg);
|
||||
@@ -370,9 +424,12 @@ void AlwaysOnTop::UnpinAll()
|
||||
{
|
||||
Logger::error(L"Unpinning topmost window failed");
|
||||
}
|
||||
// Restore transparency when unpinning all
|
||||
RestoreWindowAlpha(topWindow);
|
||||
}
|
||||
|
||||
m_topmostWindows.clear();
|
||||
m_windowOriginalLayeredState.clear();
|
||||
}
|
||||
|
||||
void AlwaysOnTop::CleanUp()
|
||||
@@ -456,6 +513,7 @@ void AlwaysOnTop::HandleWinHookEvent(WinHookEvent* data) noexcept
|
||||
for (const auto window : toErase)
|
||||
{
|
||||
m_topmostWindows.erase(window);
|
||||
m_windowOriginalLayeredState.erase(window);
|
||||
}
|
||||
|
||||
switch (data->event)
|
||||
@@ -556,4 +614,166 @@ void AlwaysOnTop::RefreshBorders()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
HWND AlwaysOnTop::ResolveTransparencyTargetWindow(HWND window)
|
||||
{
|
||||
if (!window || !IsWindow(window))
|
||||
{
|
||||
return nullptr;
|
||||
}
|
||||
|
||||
// Only allow transparency changes on pinned windows
|
||||
if (!IsPinned(window))
|
||||
{
|
||||
return nullptr;
|
||||
}
|
||||
|
||||
return window;
|
||||
}
|
||||
|
||||
|
||||
void AlwaysOnTop::StepWindowTransparency(HWND window, int delta)
|
||||
{
|
||||
HWND targetWindow = ResolveTransparencyTargetWindow(window);
|
||||
if (!targetWindow)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
int currentTransparency = Settings::maxTransparencyPercentage;
|
||||
LONG exStyle = GetWindowLong(targetWindow, GWL_EXSTYLE);
|
||||
if (exStyle & WS_EX_LAYERED)
|
||||
{
|
||||
BYTE alpha = 255;
|
||||
if (GetLayeredWindowAttributes(targetWindow, nullptr, &alpha, nullptr))
|
||||
{
|
||||
currentTransparency = (alpha * 100) / 255;
|
||||
}
|
||||
}
|
||||
|
||||
int newTransparency = (std::max)(Settings::minTransparencyPercentage,
|
||||
(std::min)(Settings::maxTransparencyPercentage, currentTransparency + delta));
|
||||
|
||||
if (newTransparency != currentTransparency)
|
||||
{
|
||||
ApplyWindowAlpha(targetWindow, newTransparency);
|
||||
|
||||
if (AlwaysOnTopSettings::settings().enableSound)
|
||||
{
|
||||
m_sound.Play(delta > 0 ? Sound::Type::IncreaseOpacity : Sound::Type::DecreaseOpacity);
|
||||
}
|
||||
|
||||
Logger::debug(L"Transparency adjusted to {}%", newTransparency);
|
||||
}
|
||||
}
|
||||
|
||||
void AlwaysOnTop::ApplyWindowAlpha(HWND window, int percentage)
|
||||
{
|
||||
if (!window || !IsWindow(window))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
percentage = (std::max)(Settings::minTransparencyPercentage,
|
||||
(std::min)(Settings::maxTransparencyPercentage, percentage));
|
||||
|
||||
LONG exStyle = GetWindowLong(window, GWL_EXSTYLE);
|
||||
bool isCurrentlyLayered = (exStyle & WS_EX_LAYERED) != 0;
|
||||
|
||||
// Cache original state on first transparency application
|
||||
if (m_windowOriginalLayeredState.find(window) == m_windowOriginalLayeredState.end())
|
||||
{
|
||||
WindowLayeredState state;
|
||||
state.hadLayeredStyle = isCurrentlyLayered;
|
||||
|
||||
if (isCurrentlyLayered)
|
||||
{
|
||||
BYTE alpha = 255;
|
||||
COLORREF colorKey = 0;
|
||||
DWORD flags = 0;
|
||||
if (GetLayeredWindowAttributes(window, &colorKey, &alpha, &flags))
|
||||
{
|
||||
state.originalAlpha = alpha;
|
||||
state.usedColorKey = (flags & LWA_COLORKEY) != 0;
|
||||
state.colorKey = colorKey;
|
||||
}
|
||||
else
|
||||
{
|
||||
Logger::warn(L"GetLayeredWindowAttributes failed for layered window, skipping");
|
||||
return;
|
||||
}
|
||||
}
|
||||
m_windowOriginalLayeredState[window] = state;
|
||||
}
|
||||
|
||||
// Clear WS_EX_LAYERED first to ensure SetLayeredWindowAttributes works
|
||||
if (isCurrentlyLayered)
|
||||
{
|
||||
SetWindowLong(window, GWL_EXSTYLE, exStyle & ~WS_EX_LAYERED);
|
||||
SetWindowPos(window, nullptr, 0, 0, 0, 0, SWP_NOMOVE | SWP_NOSIZE | SWP_NOZORDER | SWP_FRAMECHANGED);
|
||||
exStyle = GetWindowLong(window, GWL_EXSTYLE);
|
||||
}
|
||||
|
||||
BYTE alphaValue = static_cast<BYTE>((255 * percentage) / 100);
|
||||
SetWindowLong(window, GWL_EXSTYLE, exStyle | WS_EX_LAYERED);
|
||||
SetLayeredWindowAttributes(window, 0, alphaValue, LWA_ALPHA);
|
||||
}
|
||||
|
||||
void AlwaysOnTop::RestoreWindowAlpha(HWND window)
|
||||
{
|
||||
if (!window || !IsWindow(window))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
LONG exStyle = GetWindowLong(window, GWL_EXSTYLE);
|
||||
auto it = m_windowOriginalLayeredState.find(window);
|
||||
|
||||
if (it != m_windowOriginalLayeredState.end())
|
||||
{
|
||||
const auto& originalState = it->second;
|
||||
|
||||
if (originalState.hadLayeredStyle)
|
||||
{
|
||||
// Window originally had WS_EX_LAYERED - restore original attributes
|
||||
// Clear and re-add to ensure clean state
|
||||
if (exStyle & WS_EX_LAYERED)
|
||||
{
|
||||
SetWindowLong(window, GWL_EXSTYLE, exStyle & ~WS_EX_LAYERED);
|
||||
exStyle = GetWindowLong(window, GWL_EXSTYLE);
|
||||
}
|
||||
SetWindowLong(window, GWL_EXSTYLE, exStyle | WS_EX_LAYERED);
|
||||
|
||||
// Restore original alpha and/or color key
|
||||
DWORD flags = LWA_ALPHA;
|
||||
if (originalState.usedColorKey)
|
||||
{
|
||||
flags |= LWA_COLORKEY;
|
||||
}
|
||||
SetLayeredWindowAttributes(window, originalState.colorKey, originalState.originalAlpha, flags);
|
||||
SetWindowPos(window, nullptr, 0, 0, 0, 0, SWP_NOMOVE | SWP_NOSIZE | SWP_NOZORDER | SWP_FRAMECHANGED);
|
||||
}
|
||||
else
|
||||
{
|
||||
// Window originally didn't have WS_EX_LAYERED - remove it completely
|
||||
if (exStyle & WS_EX_LAYERED)
|
||||
{
|
||||
SetLayeredWindowAttributes(window, 0, 255, LWA_ALPHA);
|
||||
SetWindowLong(window, GWL_EXSTYLE, exStyle & ~WS_EX_LAYERED);
|
||||
SetWindowPos(window, nullptr, 0, 0, 0, 0, SWP_NOMOVE | SWP_NOSIZE | SWP_NOZORDER | SWP_FRAMECHANGED);
|
||||
}
|
||||
}
|
||||
|
||||
m_windowOriginalLayeredState.erase(it);
|
||||
}
|
||||
else
|
||||
{
|
||||
// Fallback: no cached state, just remove layered style
|
||||
if (exStyle & WS_EX_LAYERED)
|
||||
{
|
||||
SetLayeredWindowAttributes(window, 0, 255, LWA_ALPHA);
|
||||
SetWindowLong(window, GWL_EXSTYLE, exStyle & ~WS_EX_LAYERED);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -10,6 +10,7 @@
|
||||
|
||||
#include <common/hooks/WinHookEvent.h>
|
||||
#include <common/notifications/NotificationUtil.h>
|
||||
#include <common/utils/window.h>
|
||||
|
||||
class AlwaysOnTop : public SettingsObserver
|
||||
{
|
||||
@@ -38,6 +39,8 @@ private:
|
||||
enum class HotkeyId : int
|
||||
{
|
||||
Pin = 1,
|
||||
IncreaseOpacity = 2,
|
||||
DecreaseOpacity = 3,
|
||||
};
|
||||
|
||||
static inline AlwaysOnTop* s_instance = nullptr;
|
||||
@@ -48,8 +51,20 @@ private:
|
||||
HWND m_window{ nullptr };
|
||||
HINSTANCE m_hinstance;
|
||||
std::map<HWND, std::unique_ptr<WindowBorder>> m_topmostWindows{};
|
||||
|
||||
// Store original window layered state for proper restoration
|
||||
struct WindowLayeredState {
|
||||
bool hadLayeredStyle = false;
|
||||
BYTE originalAlpha = 255;
|
||||
bool usedColorKey = false;
|
||||
COLORREF colorKey = 0;
|
||||
};
|
||||
std::map<HWND, WindowLayeredState> m_windowOriginalLayeredState{};
|
||||
|
||||
HANDLE m_hPinEvent;
|
||||
HANDLE m_hTerminateEvent;
|
||||
HANDLE m_hIncreaseOpacityEvent;
|
||||
HANDLE m_hDecreaseOpacityEvent;
|
||||
DWORD m_mainThreadId;
|
||||
std::thread m_thread;
|
||||
const bool m_useCentralizedLLKH;
|
||||
@@ -78,6 +93,12 @@ private:
|
||||
bool AssignBorder(HWND window);
|
||||
void RefreshBorders();
|
||||
|
||||
// Transparency methods
|
||||
HWND ResolveTransparencyTargetWindow(HWND window);
|
||||
void StepWindowTransparency(HWND window, int delta);
|
||||
void ApplyWindowAlpha(HWND window, int percentage);
|
||||
void RestoreWindowAlpha(HWND window);
|
||||
|
||||
virtual void SettingsUpdate(SettingId type) override;
|
||||
|
||||
static void CALLBACK WinHookProc(HWINEVENTHOOK winEventHook,
|
||||
|
||||
@@ -15,6 +15,9 @@ class SettingsObserver;
|
||||
struct Settings
|
||||
{
|
||||
PowerToysSettings::HotkeyObject hotkey = PowerToysSettings::HotkeyObject::from_settings(true, true, false, false, 84); // win + ctrl + T
|
||||
static constexpr int minTransparencyPercentage = 20; // minimum transparency (can't go below 20%)
|
||||
static constexpr int maxTransparencyPercentage = 100; // maximum (fully opaque)
|
||||
static constexpr int transparencyStep = 10; // step size for +/- adjustment
|
||||
bool enableFrame = true;
|
||||
bool enableSound = true;
|
||||
bool roundCornersEnabled = true;
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
|
||||
#include "pch.h"
|
||||
|
||||
#include <atomic>
|
||||
#include <mmsystem.h> // sound
|
||||
|
||||
class Sound
|
||||
@@ -12,12 +11,10 @@ public:
|
||||
{
|
||||
On,
|
||||
Off,
|
||||
IncreaseOpacity,
|
||||
DecreaseOpacity,
|
||||
};
|
||||
|
||||
Sound()
|
||||
: isPlaying(false)
|
||||
{}
|
||||
|
||||
void Play(Type type)
|
||||
{
|
||||
BOOL success = false;
|
||||
@@ -29,6 +26,12 @@ public:
|
||||
case Type::Off:
|
||||
success = PlaySound(TEXT("Media\\Speech Sleep.wav"), NULL, SND_FILENAME | SND_ASYNC);
|
||||
break;
|
||||
case Type::IncreaseOpacity:
|
||||
success = PlaySound(TEXT("Media\\Windows Hardware Insert.wav"), NULL, SND_FILENAME | SND_ASYNC);
|
||||
break;
|
||||
case Type::DecreaseOpacity:
|
||||
success = PlaySound(TEXT("Media\\Windows Hardware Remove.wav"), NULL, SND_FILENAME | SND_ASYNC);
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
@@ -38,7 +41,4 @@ public:
|
||||
Logger::error(L"Sound playing error");
|
||||
}
|
||||
}
|
||||
|
||||
private:
|
||||
std::atomic<bool> isPlaying;
|
||||
};
|
||||
@@ -105,17 +105,28 @@ public:
|
||||
}
|
||||
}
|
||||
|
||||
virtual bool on_hotkey(size_t /*hotkeyId*/) override
|
||||
virtual bool on_hotkey(size_t hotkeyId) override
|
||||
{
|
||||
if (m_enabled)
|
||||
{
|
||||
Logger::trace(L"AlwaysOnTop hotkey pressed");
|
||||
Logger::trace(L"AlwaysOnTop hotkey pressed, id={}", hotkeyId);
|
||||
if (!is_process_running())
|
||||
{
|
||||
Enable();
|
||||
}
|
||||
|
||||
SetEvent(m_hPinEvent);
|
||||
if (hotkeyId == 0)
|
||||
{
|
||||
SetEvent(m_hPinEvent);
|
||||
}
|
||||
else if (hotkeyId == 1)
|
||||
{
|
||||
SetEvent(m_hIncreaseOpacityEvent);
|
||||
}
|
||||
else if (hotkeyId == 2)
|
||||
{
|
||||
SetEvent(m_hDecreaseOpacityEvent);
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
@@ -125,19 +136,48 @@ public:
|
||||
|
||||
virtual size_t get_hotkeys(Hotkey* hotkeys, size_t buffer_size) override
|
||||
{
|
||||
size_t count = 0;
|
||||
|
||||
// Hotkey 0: Pin/Unpin (e.g., Win+Ctrl+T)
|
||||
if (m_hotkey.key)
|
||||
{
|
||||
if (hotkeys && buffer_size >= 1)
|
||||
if (hotkeys && buffer_size > count)
|
||||
{
|
||||
hotkeys[0] = m_hotkey;
|
||||
hotkeys[count] = m_hotkey;
|
||||
Logger::trace(L"AlwaysOnTop hotkey[0]: win={}, ctrl={}, shift={}, alt={}, key={}",
|
||||
m_hotkey.win, m_hotkey.ctrl, m_hotkey.shift, m_hotkey.alt, m_hotkey.key);
|
||||
}
|
||||
count++;
|
||||
}
|
||||
|
||||
return 1;
|
||||
}
|
||||
else
|
||||
// Hotkey 1: Increase opacity (same modifiers + VK_OEM_PLUS '=')
|
||||
if (m_hotkey.key)
|
||||
{
|
||||
return 0;
|
||||
if (hotkeys && buffer_size > count)
|
||||
{
|
||||
hotkeys[count] = m_hotkey;
|
||||
hotkeys[count].key = VK_OEM_PLUS; // '=' key
|
||||
Logger::trace(L"AlwaysOnTop hotkey[1] (increase opacity): win={}, ctrl={}, shift={}, alt={}, key={}",
|
||||
hotkeys[count].win, hotkeys[count].ctrl, hotkeys[count].shift, hotkeys[count].alt, hotkeys[count].key);
|
||||
}
|
||||
count++;
|
||||
}
|
||||
|
||||
// Hotkey 2: Decrease opacity (same modifiers + VK_OEM_MINUS '-')
|
||||
if (m_hotkey.key)
|
||||
{
|
||||
if (hotkeys && buffer_size > count)
|
||||
{
|
||||
hotkeys[count] = m_hotkey;
|
||||
hotkeys[count].key = VK_OEM_MINUS; // '-' key
|
||||
Logger::trace(L"AlwaysOnTop hotkey[2] (decrease opacity): win={}, ctrl={}, shift={}, alt={}, key={}",
|
||||
hotkeys[count].win, hotkeys[count].ctrl, hotkeys[count].shift, hotkeys[count].alt, hotkeys[count].key);
|
||||
}
|
||||
count++;
|
||||
}
|
||||
|
||||
Logger::trace(L"AlwaysOnTop get_hotkeys returning count={}", count);
|
||||
return count;
|
||||
}
|
||||
|
||||
// Enable the powertoy
|
||||
@@ -175,6 +215,8 @@ public:
|
||||
app_key = NonLocalizable::ModuleKey;
|
||||
m_hPinEvent = CreateDefaultEvent(CommonSharedConstants::ALWAYS_ON_TOP_PIN_EVENT);
|
||||
m_hTerminateEvent = CreateDefaultEvent(CommonSharedConstants::ALWAYS_ON_TOP_TERMINATE_EVENT);
|
||||
m_hIncreaseOpacityEvent = CreateDefaultEvent(CommonSharedConstants::ALWAYS_ON_TOP_INCREASE_OPACITY_EVENT);
|
||||
m_hDecreaseOpacityEvent = CreateDefaultEvent(CommonSharedConstants::ALWAYS_ON_TOP_DECREASE_OPACITY_EVENT);
|
||||
init_settings();
|
||||
}
|
||||
|
||||
@@ -292,6 +334,8 @@ private:
|
||||
// Handle to event used to pin/unpin windows
|
||||
HANDLE m_hPinEvent;
|
||||
HANDLE m_hTerminateEvent;
|
||||
HANDLE m_hIncreaseOpacityEvent;
|
||||
HANDLE m_hDecreaseOpacityEvent;
|
||||
};
|
||||
|
||||
extern "C" __declspec(dllexport) PowertoyModuleIface* __cdecl powertoy_create()
|
||||
|
||||
@@ -30,6 +30,7 @@
|
||||
"src\\modules\\cmdpal\\Tests\\Microsoft.CmdPal.Ext.WindowWalker.UnitTests\\Microsoft.CmdPal.Ext.WindowWalker.UnitTests.csproj",
|
||||
"src\\modules\\cmdpal\\Tests\\Microsoft.CmdPal.UI.ViewModels.UnitTests\\Microsoft.CmdPal.UI.ViewModels.UnitTests.csproj",
|
||||
"src\\modules\\cmdpal\\Tests\\Microsoft.CmdPal.UITests\\Microsoft.CmdPal.UITests.csproj",
|
||||
"src\\modules\\cmdpal\\Tests\\Microsoft.CommandPalette.Extensions.Toolkit.UnitTests\\Microsoft.CommandPalette.Extensions.Toolkit.UnitTests.csproj",
|
||||
"src\\modules\\cmdpal\\ext\\Microsoft.CmdPal.Ext.Apps\\Microsoft.CmdPal.Ext.Apps.csproj",
|
||||
"src\\modules\\cmdpal\\ext\\Microsoft.CmdPal.Ext.Bookmark\\Microsoft.CmdPal.Ext.Bookmarks.csproj",
|
||||
"src\\modules\\cmdpal\\ext\\Microsoft.CmdPal.Ext.Calc\\Microsoft.CmdPal.Ext.Calc.csproj",
|
||||
|
||||
@@ -0,0 +1,136 @@
|
||||
// Copyright (c) Microsoft Corporation
|
||||
// The Microsoft Corporation licenses this file to you under the MIT license.
|
||||
// See the LICENSE file in the project root for more information.
|
||||
|
||||
using System.Collections.Concurrent;
|
||||
using Microsoft.CmdPal.Core.Common;
|
||||
using Microsoft.CmdPal.Core.Common.Helpers;
|
||||
|
||||
namespace Microsoft.CmdPal.Core.ViewModels;
|
||||
|
||||
internal static class BatchUpdateManager
|
||||
{
|
||||
private const int ExpectedBatchSize = 32;
|
||||
|
||||
// 30 ms chosen empirically to balance responsiveness and batching:
|
||||
// - Keeps perceived latency low (< ~50 ms) for user-visible updates.
|
||||
// - Still allows multiple COM/background events to be coalesced into a single batch.
|
||||
private static readonly TimeSpan BatchDelay = TimeSpan.FromMilliseconds(30);
|
||||
private static readonly ConcurrentQueue<IBatchUpdateTarget> DirtyQueue = [];
|
||||
private static readonly Timer Timer = new(static _ => Flush(), null, Timeout.InfiniteTimeSpan, Timeout.InfiniteTimeSpan);
|
||||
|
||||
private static InterlockedBoolean _isFlushScheduled;
|
||||
|
||||
/// <summary>
|
||||
/// Enqueue a target for batched processing. Safe to call from any thread (including COM callbacks).
|
||||
/// </summary>
|
||||
public static void Queue(IBatchUpdateTarget target)
|
||||
{
|
||||
if (!target.TryMarkBatchQueued())
|
||||
{
|
||||
return; // already queued in current batch window
|
||||
}
|
||||
|
||||
DirtyQueue.Enqueue(target);
|
||||
TryScheduleFlush();
|
||||
}
|
||||
|
||||
private static void TryScheduleFlush()
|
||||
{
|
||||
if (!_isFlushScheduled.Set())
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (DirtyQueue.IsEmpty)
|
||||
{
|
||||
_isFlushScheduled.Clear();
|
||||
|
||||
if (DirtyQueue.IsEmpty)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (!_isFlushScheduled.Set())
|
||||
{
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
Timer.Change(BatchDelay, Timeout.InfiniteTimeSpan);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_isFlushScheduled.Clear();
|
||||
CoreLogger.LogError("Failed to arm batch timer.", ex);
|
||||
}
|
||||
}
|
||||
|
||||
private static void Flush()
|
||||
{
|
||||
try
|
||||
{
|
||||
var drained = new List<IBatchUpdateTarget>(ExpectedBatchSize);
|
||||
while (DirtyQueue.TryDequeue(out var item))
|
||||
{
|
||||
drained.Add(item);
|
||||
}
|
||||
|
||||
if (drained.Count == 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
// LOAD BEARING:
|
||||
// ApplyPendingUpdates must run on a background thread.
|
||||
// The VM itself is responsible for marshaling UI notifications to its _uiScheduler.
|
||||
ApplyBatch(drained);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
// Don't kill the timer thread.
|
||||
CoreLogger.LogError("Batch flush failed.", ex);
|
||||
}
|
||||
finally
|
||||
{
|
||||
_isFlushScheduled.Clear();
|
||||
TryScheduleFlush();
|
||||
}
|
||||
}
|
||||
|
||||
private static void ApplyBatch(List<IBatchUpdateTarget> items)
|
||||
{
|
||||
// Runs on the Timer callback thread (ThreadPool). That's fine: background work only.
|
||||
foreach (var item in items)
|
||||
{
|
||||
// Allow re-queueing immediately if more COM events arrive during apply.
|
||||
item.ClearBatchQueued();
|
||||
|
||||
try
|
||||
{
|
||||
item.ApplyPendingUpdates();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
CoreLogger.LogError("Failed to apply pending updates for a batched target.", ex);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
internal interface IBatchUpdateTarget
|
||||
{
|
||||
/// <summary>UI scheduler (used by targets internally for UI marshaling). Kept here for diagnostics / consistency.</summary>
|
||||
TaskScheduler UIScheduler { get; }
|
||||
|
||||
/// <summary>Apply any coalesced updates. Must be safe to call on a background thread.</summary>
|
||||
void ApplyPendingUpdates();
|
||||
|
||||
/// <summary>De-dupe gate: returns true only for the first enqueue until cleared.</summary>
|
||||
bool TryMarkBatchQueued();
|
||||
|
||||
/// <summary>Clear the de-dupe gate so the item can be queued again.</summary>
|
||||
void ClearBatchQueued();
|
||||
}
|
||||
@@ -2,36 +2,99 @@
|
||||
// The Microsoft Corporation licenses this file to you under the MIT license.
|
||||
// See the LICENSE file in the project root for more information.
|
||||
|
||||
using System.Buffers;
|
||||
using System.Collections.Concurrent;
|
||||
using System.ComponentModel;
|
||||
using System.Runtime.CompilerServices;
|
||||
using CommunityToolkit.Mvvm.ComponentModel;
|
||||
using Microsoft.CmdPal.Core.Common;
|
||||
using Microsoft.CmdPal.Core.Common.Helpers;
|
||||
|
||||
namespace Microsoft.CmdPal.Core.ViewModels;
|
||||
|
||||
public abstract partial class ExtensionObjectViewModel : ObservableObject
|
||||
public abstract partial class ExtensionObjectViewModel : ObservableObject, IBatchUpdateTarget, IBackgroundPropertyChangedNotification
|
||||
{
|
||||
public WeakReference<IPageContext> PageContext { get; set; }
|
||||
private const int InitialPropertyBatchingBufferSize = 16;
|
||||
|
||||
internal ExtensionObjectViewModel(IPageContext? context)
|
||||
{
|
||||
var realContext = context ?? (this is IPageContext c ? c : throw new ArgumentException("You need to pass in an IErrorContext"));
|
||||
PageContext = new(realContext);
|
||||
}
|
||||
// Raised on the background thread before UI notifications. It's raised on the background thread to prevent
|
||||
// blocking the COM proxy.
|
||||
public event PropertyChangedEventHandler? PropertyChangedBackground;
|
||||
|
||||
internal ExtensionObjectViewModel(WeakReference<IPageContext> context)
|
||||
{
|
||||
PageContext = context;
|
||||
}
|
||||
private readonly ConcurrentQueue<string> _pendingProps = [];
|
||||
|
||||
public async virtual Task InitializePropertiesAsync()
|
||||
private readonly TaskScheduler _uiScheduler;
|
||||
|
||||
private InterlockedBoolean _batchQueued;
|
||||
|
||||
public WeakReference<IPageContext> PageContext { get; private set; } = null!;
|
||||
|
||||
TaskScheduler IBatchUpdateTarget.UIScheduler => _uiScheduler;
|
||||
|
||||
void IBatchUpdateTarget.ApplyPendingUpdates() => ApplyPendingUpdates();
|
||||
|
||||
bool IBatchUpdateTarget.TryMarkBatchQueued() => _batchQueued.Set();
|
||||
|
||||
void IBatchUpdateTarget.ClearBatchQueued() => _batchQueued.Clear();
|
||||
|
||||
private protected ExtensionObjectViewModel(TaskScheduler scheduler)
|
||||
{
|
||||
var t = new Task(() =>
|
||||
if (this is not IPageContext)
|
||||
{
|
||||
SafeInitializePropertiesSynchronous();
|
||||
});
|
||||
t.Start();
|
||||
await t;
|
||||
throw new InvalidOperationException($"Constructor overload without IPageContext can only be used when the derived class implements IPageContext. Type: {GetType().FullName}");
|
||||
}
|
||||
|
||||
_uiScheduler = scheduler ?? throw new ArgumentNullException(nameof(scheduler));
|
||||
|
||||
// Defer PageContext assignment - derived constructor MUST call InitializePageContext()
|
||||
// or we set it lazily on first access
|
||||
}
|
||||
|
||||
private protected ExtensionObjectViewModel(IPageContext context)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(context);
|
||||
|
||||
PageContext = new WeakReference<IPageContext>(context);
|
||||
_uiScheduler = context.Scheduler;
|
||||
|
||||
LogIfDefaultScheduler();
|
||||
}
|
||||
|
||||
private protected ExtensionObjectViewModel(WeakReference<IPageContext> contextRef)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(contextRef);
|
||||
|
||||
if (!contextRef.TryGetTarget(out var context))
|
||||
{
|
||||
throw new ArgumentException("IPageContext must be alive when creating view models.", nameof(contextRef));
|
||||
}
|
||||
|
||||
PageContext = contextRef;
|
||||
_uiScheduler = context.Scheduler;
|
||||
|
||||
LogIfDefaultScheduler();
|
||||
}
|
||||
|
||||
protected void InitializeSelfAsPageContext()
|
||||
{
|
||||
if (this is not IPageContext self)
|
||||
{
|
||||
throw new InvalidOperationException("This method can only be called when the class implements IPageContext.");
|
||||
}
|
||||
|
||||
PageContext = new WeakReference<IPageContext>(self);
|
||||
}
|
||||
|
||||
private void LogIfDefaultScheduler()
|
||||
{
|
||||
if (_uiScheduler == TaskScheduler.Default)
|
||||
{
|
||||
CoreLogger.LogDebug($"ExtensionObjectViewModel created with TaskScheduler.Default. Type: {GetType().FullName}");
|
||||
}
|
||||
}
|
||||
|
||||
public virtual Task InitializePropertiesAsync()
|
||||
=> Task.Run(SafeInitializePropertiesSynchronous);
|
||||
|
||||
public void SafeInitializePropertiesSynchronous()
|
||||
{
|
||||
try
|
||||
@@ -46,49 +109,151 @@ public abstract partial class ExtensionObjectViewModel : ObservableObject
|
||||
|
||||
public abstract void InitializeProperties();
|
||||
|
||||
protected void UpdateProperty(string propertyName)
|
||||
{
|
||||
DoOnUiThread(() => OnPropertyChanged(propertyName));
|
||||
}
|
||||
protected void UpdateProperty(string propertyName) => MarkPropertyDirty(propertyName);
|
||||
|
||||
protected void UpdateProperty(string propertyName1, string propertyName2)
|
||||
{
|
||||
DoOnUiThread(() =>
|
||||
{
|
||||
OnPropertyChanged(propertyName1);
|
||||
OnPropertyChanged(propertyName2);
|
||||
});
|
||||
}
|
||||
|
||||
protected void UpdateProperty(string propertyName1, string propertyName2, string propertyName3)
|
||||
{
|
||||
DoOnUiThread(() =>
|
||||
{
|
||||
OnPropertyChanged(propertyName1);
|
||||
OnPropertyChanged(propertyName2);
|
||||
OnPropertyChanged(propertyName3);
|
||||
});
|
||||
MarkPropertyDirty(propertyName1);
|
||||
MarkPropertyDirty(propertyName2);
|
||||
}
|
||||
|
||||
protected void UpdateProperty(params string[] propertyNames)
|
||||
{
|
||||
DoOnUiThread(() =>
|
||||
foreach (var p in propertyNames)
|
||||
{
|
||||
foreach (var propertyName in propertyNames)
|
||||
{
|
||||
OnPropertyChanged(propertyName);
|
||||
}
|
||||
});
|
||||
MarkPropertyDirty(p);
|
||||
}
|
||||
}
|
||||
|
||||
internal void MarkPropertyDirty(string? propertyName)
|
||||
{
|
||||
if (string.IsNullOrEmpty(propertyName))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
// We should re-consider if this worth deduping
|
||||
_pendingProps.Enqueue(propertyName);
|
||||
BatchUpdateManager.Queue(this);
|
||||
}
|
||||
|
||||
public void ApplyPendingUpdates()
|
||||
{
|
||||
((IBatchUpdateTarget)this).ClearBatchQueued();
|
||||
|
||||
var buffer = ArrayPool<string>.Shared.Rent(InitialPropertyBatchingBufferSize);
|
||||
var count = 0;
|
||||
var transferred = false;
|
||||
|
||||
try
|
||||
{
|
||||
while (_pendingProps.TryDequeue(out var name))
|
||||
{
|
||||
if (count == buffer.Length)
|
||||
{
|
||||
var bigger = ArrayPool<string>.Shared.Rent(buffer.Length * 2);
|
||||
Array.Copy(buffer, bigger, buffer.Length);
|
||||
ArrayPool<string>.Shared.Return(buffer, clearArray: true);
|
||||
buffer = bigger;
|
||||
}
|
||||
|
||||
buffer[count++] = name;
|
||||
}
|
||||
|
||||
if (count == 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
// 1) Background subscribers (must be raised before UI notifications).
|
||||
var propertyChangedEventHandler = PropertyChangedBackground;
|
||||
if (propertyChangedEventHandler is not null)
|
||||
{
|
||||
RaiseBackground(propertyChangedEventHandler, this, buffer, count);
|
||||
}
|
||||
|
||||
// 2) UI-facing PropertyChanged: ALWAYS marshal to UI scheduler.
|
||||
// Hand-off pooled buffer to UI task (UI task returns it).
|
||||
//
|
||||
// It would be lovely to do nothing if no one is actually listening on PropertyChanged,
|
||||
// but ObservableObject doesn't expose that information.
|
||||
_ = Task.Factory.StartNew(
|
||||
static state =>
|
||||
{
|
||||
var p = (UiBatch)state!;
|
||||
try
|
||||
{
|
||||
p.Owner.RaiseUi(p.Names, p.Count);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
CoreLogger.LogError("Failed to raise property change notifications on UI thread.", ex);
|
||||
}
|
||||
finally
|
||||
{
|
||||
ArrayPool<string>.Shared.Return(p.Names, clearArray: true);
|
||||
}
|
||||
},
|
||||
new UiBatch(this, buffer, count),
|
||||
CancellationToken.None,
|
||||
TaskCreationOptions.DenyChildAttach,
|
||||
_uiScheduler);
|
||||
|
||||
transferred = true;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
CoreLogger.LogError("Failed to apply pending property updates.", ex);
|
||||
}
|
||||
finally
|
||||
{
|
||||
if (!transferred)
|
||||
{
|
||||
ArrayPool<string>.Shared.Return(buffer, clearArray: true);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void RaiseUi(string[] names, int count)
|
||||
{
|
||||
for (var i = 0; i < count; i++)
|
||||
{
|
||||
OnPropertyChanged(Args(names[i]));
|
||||
}
|
||||
}
|
||||
|
||||
private static void RaiseBackground(PropertyChangedEventHandler handlers, object sender, string[] names, int count)
|
||||
{
|
||||
try
|
||||
{
|
||||
for (var i = 0; i < count; i++)
|
||||
{
|
||||
handlers(sender, Args(names[i]));
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
CoreLogger.LogError("Failed to raise PropertyChangedBackground notifications.", ex);
|
||||
}
|
||||
}
|
||||
|
||||
private sealed record UiBatch(ExtensionObjectViewModel Owner, string[] Names, int Count);
|
||||
|
||||
protected void ShowException(Exception ex, string? extensionHint = null)
|
||||
{
|
||||
if (PageContext.TryGetTarget(out var pageContext))
|
||||
{
|
||||
pageContext.ShowException(ex, extensionHint);
|
||||
}
|
||||
else
|
||||
{
|
||||
CoreLogger.LogError("Failed to show exception because PageContext is no longer available.", ex);
|
||||
}
|
||||
}
|
||||
|
||||
[MethodImpl(MethodImplOptions.AggressiveInlining)]
|
||||
private static PropertyChangedEventArgs Args(string name) => new(name);
|
||||
|
||||
protected void DoOnUiThread(Action action)
|
||||
{
|
||||
if (PageContext.TryGetTarget(out var pageContext))
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
// Copyright (c) Microsoft Corporation
|
||||
// The Microsoft Corporation licenses this file to you under the MIT license.
|
||||
// See the LICENSE file in the project root for more information.
|
||||
|
||||
using System.ComponentModel;
|
||||
|
||||
namespace Microsoft.CmdPal.Core.ViewModels;
|
||||
|
||||
/// <summary>
|
||||
/// Provides a notification mechanism for property changes that fires
|
||||
/// synchronously on the calling thread.
|
||||
/// </summary>
|
||||
public interface IBackgroundPropertyChangedNotification
|
||||
{
|
||||
/// <summary>
|
||||
/// Occurs when the value of a property changes.
|
||||
/// </summary>
|
||||
event PropertyChangedEventHandler? PropertyChangedBackground;
|
||||
}
|
||||
@@ -77,11 +77,11 @@ public partial class PageViewModel : ExtensionObjectViewModel, IPageContext
|
||||
public IconInfoViewModel Icon { get; protected set; }
|
||||
|
||||
public PageViewModel(IPage? model, TaskScheduler scheduler, AppExtensionHost extensionHost)
|
||||
: base((IPageContext?)null)
|
||||
: base(scheduler)
|
||||
{
|
||||
InitializeSelfAsPageContext();
|
||||
_pageModel = new(model);
|
||||
Scheduler = scheduler;
|
||||
PageContext = new(this);
|
||||
ExtensionHost = extensionHost;
|
||||
Icon = new(null);
|
||||
|
||||
|
||||
@@ -199,7 +199,7 @@ public sealed partial class TopLevelViewModel : ObservableObject, IListItem, IEx
|
||||
_fallbackId = fallback.Id;
|
||||
}
|
||||
|
||||
item.PropertyChanged += Item_PropertyChanged;
|
||||
item.PropertyChangedBackground += Item_PropertyChanged;
|
||||
|
||||
// UpdateAlias();
|
||||
// UpdateHotkey();
|
||||
|
||||
@@ -19,6 +19,7 @@ public class CloseOnEnterTests
|
||||
{
|
||||
var settings = new Settings(closeOnEnter: true);
|
||||
TypedEventHandler<object, object> handleSave = (s, e) => { };
|
||||
TypedEventHandler<object, object> handleReplace = (s, e) => { };
|
||||
|
||||
var item = ResultHelper.CreateResult(
|
||||
4m,
|
||||
@@ -26,7 +27,8 @@ public class CloseOnEnterTests
|
||||
CultureInfo.CurrentCulture,
|
||||
"2+2",
|
||||
settings,
|
||||
handleSave);
|
||||
handleSave,
|
||||
handleReplace);
|
||||
|
||||
Assert.IsNotNull(item);
|
||||
Assert.IsInstanceOfType(item.Command, typeof(CopyTextCommand));
|
||||
@@ -41,6 +43,7 @@ public class CloseOnEnterTests
|
||||
{
|
||||
var settings = new Settings(closeOnEnter: false);
|
||||
TypedEventHandler<object, object> handleSave = (s, e) => { };
|
||||
TypedEventHandler<object, object> handleReplace = (s, e) => { };
|
||||
|
||||
var item = ResultHelper.CreateResult(
|
||||
4m,
|
||||
@@ -48,7 +51,8 @@ public class CloseOnEnterTests
|
||||
CultureInfo.CurrentCulture,
|
||||
"2+2",
|
||||
settings,
|
||||
handleSave);
|
||||
handleSave,
|
||||
handleReplace);
|
||||
|
||||
Assert.IsNotNull(item);
|
||||
Assert.IsInstanceOfType(item.Command, typeof(SaveCommand));
|
||||
|
||||
@@ -65,6 +65,9 @@ public class ExtendedCalculatorParserTests : CommandPaletteUnitTestBase
|
||||
["log10(3)", 0.47712125471966M],
|
||||
["ln(e)", 1M],
|
||||
["cosh(0)", 1M],
|
||||
["1*10^(-5)", 0.00001M],
|
||||
["1*10^(-15)", 0.0000000000000001M],
|
||||
["1*10^(-16)", 0M],
|
||||
];
|
||||
|
||||
[DataTestMethod]
|
||||
@@ -192,9 +195,11 @@ public class ExtendedCalculatorParserTests : CommandPaletteUnitTestBase
|
||||
private static IEnumerable<object[]> Interpret_MustReturnExpectedResult_WhenCalled_Data =>
|
||||
[
|
||||
|
||||
// ["factorial(5)", 120M], ToDo: this don't support now
|
||||
// ["sign(-2)", -1M],
|
||||
// ["sign(2)", +1M],
|
||||
["factorial(5)", 120M],
|
||||
["5!", 120M],
|
||||
["(2+3)!", 120M],
|
||||
["sign(-2)", -1M],
|
||||
["sign(2)", +1M],
|
||||
["abs(-2)", 2M],
|
||||
["abs(2)", 2M],
|
||||
["0+(1*2)/(0+1)", 2M], // Validate that division by "(0+1)" is not interpret as division by zero.
|
||||
@@ -221,6 +226,9 @@ public class ExtendedCalculatorParserTests : CommandPaletteUnitTestBase
|
||||
[
|
||||
["0.2E1", "en-US", 2M],
|
||||
["0,2E1", "pt-PT", 2M],
|
||||
["3.5e3 + 2.5E2", "en-US", 3750M],
|
||||
["3,5e3 + 2,5E2", "fr-FR", 3750M],
|
||||
["1E3-1E3/1.5", "en-US", 333.333333333333371M],
|
||||
];
|
||||
|
||||
[DataTestMethod]
|
||||
@@ -389,4 +397,17 @@ public class ExtendedCalculatorParserTests : CommandPaletteUnitTestBase
|
||||
Assert.IsNotNull(result);
|
||||
Assert.AreEqual(expectedResult, result);
|
||||
}
|
||||
|
||||
[DataTestMethod]
|
||||
[DataRow("171!")]
|
||||
[DataRow("1000!")]
|
||||
public void Interpret_ReturnsError_WhenValueOverflowsDecimal(string input)
|
||||
{
|
||||
var settings = new Settings();
|
||||
|
||||
CalculateEngine.Interpret(settings, input, CultureInfo.InvariantCulture, out var error);
|
||||
|
||||
Assert.IsFalse(string.IsNullOrEmpty(error));
|
||||
Assert.AreNotEqual(null, error);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
// Copyright (c) Microsoft Corporation
|
||||
// The Microsoft Corporation licenses this file to you under the MIT license.
|
||||
// See the LICENSE file in the project root for more information.
|
||||
|
||||
using Microsoft.CmdPal.Ext.Calc.Helper;
|
||||
using Microsoft.VisualStudio.TestTools.UnitTesting;
|
||||
|
||||
namespace Microsoft.CmdPal.Ext.Calc.UnitTests;
|
||||
|
||||
[TestClass]
|
||||
public class IncompleteQueryTests
|
||||
{
|
||||
[DataTestMethod]
|
||||
[DataRow("2+2+", "2+2")]
|
||||
[DataRow("2+2*", "2+2")]
|
||||
[DataRow("sin(30", "sin(30)")]
|
||||
[DataRow("((1+2)", "((1+2))")]
|
||||
[DataRow("2*(3+4", "2*(3+4)")]
|
||||
[DataRow("(1+2", "(1+2)")]
|
||||
[DataRow("2*(", "2")]
|
||||
[DataRow("2*(((", "2")]
|
||||
public void TestTryGetIncompleteQuerySuccess(string input, string expected)
|
||||
{
|
||||
var result = QueryHelper.TryGetIncompleteQuery(input, out var newQuery);
|
||||
Assert.IsTrue(result);
|
||||
Assert.AreEqual(expected, newQuery);
|
||||
}
|
||||
|
||||
[DataTestMethod]
|
||||
[DataRow("")]
|
||||
[DataRow(" ")]
|
||||
public void TestTryGetIncompleteQueryFail(string input)
|
||||
{
|
||||
var result = QueryHelper.TryGetIncompleteQuery(input, out var newQuery);
|
||||
Assert.IsFalse(result);
|
||||
Assert.AreEqual(input, newQuery);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
// Copyright (c) Microsoft Corporation
|
||||
// The Microsoft Corporation licenses this file to you under the MIT license.
|
||||
// See the LICENSE file in the project root for more information.
|
||||
|
||||
using Microsoft.CmdPal.Ext.Calc.Helper;
|
||||
using Microsoft.VisualStudio.TestTools.UnitTesting;
|
||||
|
||||
namespace Microsoft.CmdPal.Ext.Calc.UnitTests;
|
||||
|
||||
[TestClass]
|
||||
public class QueryHelperTests
|
||||
{
|
||||
[DataTestMethod]
|
||||
[DataRow("2²", "4")]
|
||||
[DataRow("2³", "8")]
|
||||
[DataRow("2!", "2")]
|
||||
[DataRow("2\u00A0*\u00A02", "4")] // Non-breaking space
|
||||
[DataRow("20:10", "2")] // Colon as division
|
||||
public void Interpret_HandlesNormalizedInputs(string input, string expected)
|
||||
{
|
||||
var settings = new Settings();
|
||||
var result = QueryHelper.Query(input, settings, false, out _, (_, _) => { });
|
||||
|
||||
Assert.IsNotNull(result);
|
||||
Assert.AreEqual(expected, result.Title);
|
||||
}
|
||||
}
|
||||
@@ -6,7 +6,6 @@ using System.Linq;
|
||||
using Microsoft.CmdPal.Ext.Calc.Helper;
|
||||
using Microsoft.CmdPal.Ext.Calc.Pages;
|
||||
using Microsoft.CmdPal.Ext.UnitTestBase;
|
||||
using Microsoft.CommandPalette.Extensions.Toolkit;
|
||||
using Microsoft.VisualStudio.TestTools.UnitTesting;
|
||||
|
||||
namespace Microsoft.CmdPal.Ext.Calc.UnitTests;
|
||||
@@ -72,7 +71,7 @@ public class QueryTests : CommandPaletteUnitTestBase
|
||||
[DataRow("sin(60)", "0.809016", CalculateEngine.TrigMode.Gradians)]
|
||||
public void TrigModeSettingsTest(string input, string expected, CalculateEngine.TrigMode trigMode)
|
||||
{
|
||||
var settings = new Settings(trigUnit: trigMode);
|
||||
var settings = new Settings(trigUnit: trigMode, outputUseEnglishFormat: true);
|
||||
|
||||
var page = new CalculatorListPage(settings);
|
||||
|
||||
|
||||
@@ -12,17 +12,26 @@ public class Settings : ISettingsInterface
|
||||
private readonly bool inputUseEnglishFormat;
|
||||
private readonly bool outputUseEnglishFormat;
|
||||
private readonly bool closeOnEnter;
|
||||
private readonly bool copyResultToSearchBarIfQueryEndsWithEqualSign;
|
||||
private readonly bool autoFixQuery;
|
||||
private readonly bool inputNormalization;
|
||||
|
||||
public Settings(
|
||||
CalculateEngine.TrigMode trigUnit = CalculateEngine.TrigMode.Radians,
|
||||
bool inputUseEnglishFormat = false,
|
||||
bool outputUseEnglishFormat = false,
|
||||
bool closeOnEnter = true)
|
||||
bool closeOnEnter = true,
|
||||
bool copyResultToSearchBarIfQueryEndsWithEqualSign = true,
|
||||
bool autoFixQuery = true,
|
||||
bool inputNormalization = true)
|
||||
{
|
||||
this.trigUnit = trigUnit;
|
||||
this.inputUseEnglishFormat = inputUseEnglishFormat;
|
||||
this.outputUseEnglishFormat = outputUseEnglishFormat;
|
||||
this.closeOnEnter = closeOnEnter;
|
||||
this.copyResultToSearchBarIfQueryEndsWithEqualSign = copyResultToSearchBarIfQueryEndsWithEqualSign;
|
||||
this.autoFixQuery = autoFixQuery;
|
||||
this.inputNormalization = inputNormalization;
|
||||
}
|
||||
|
||||
public CalculateEngine.TrigMode TrigUnit => trigUnit;
|
||||
@@ -32,4 +41,10 @@ public class Settings : ISettingsInterface
|
||||
public bool OutputUseEnglishFormat => outputUseEnglishFormat;
|
||||
|
||||
public bool CloseOnEnter => closeOnEnter;
|
||||
|
||||
public bool CopyResultToSearchBarIfQueryEndsWithEqualSign => copyResultToSearchBarIfQueryEndsWithEqualSign;
|
||||
|
||||
public bool AutoFixQuery => autoFixQuery;
|
||||
|
||||
public bool InputNormalization => inputNormalization;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,235 @@
|
||||
// Copyright (c) Microsoft Corporation
|
||||
// The Microsoft Corporation licenses this file to you under the MIT license.
|
||||
// See the LICENSE file in the project root for more information.
|
||||
|
||||
using System.Collections.Generic;
|
||||
using Microsoft.CommandPalette.Extensions.Toolkit.UnitTests.Legacy;
|
||||
using Microsoft.VisualStudio.TestTools.UnitTesting;
|
||||
|
||||
namespace Microsoft.CommandPalette.Extensions.Toolkit.UnitTests;
|
||||
|
||||
[TestClass]
|
||||
public class FuzzyMatcherComparisonTests
|
||||
{
|
||||
public static IEnumerable<object[]> TestData =>
|
||||
[
|
||||
["a", "a"],
|
||||
["a", "A"],
|
||||
["A", "a"],
|
||||
["abc", "abc"],
|
||||
["abc", "axbycz"],
|
||||
["abc", "abxcyz"],
|
||||
["sln", "solution.sln"],
|
||||
["vs", "visualstudio"],
|
||||
["test", "Test"],
|
||||
["pt", "PowerToys"],
|
||||
["p/t", "power\\toys"],
|
||||
["p\\t", "power/toys"],
|
||||
["c/w", "c:\\windows"],
|
||||
["foo", "bar"],
|
||||
["verylongstringthatdoesnotmatch", "short"],
|
||||
[string.Empty, "anything"],
|
||||
["something", string.Empty],
|
||||
["git", "git"],
|
||||
["em", "Emmy"],
|
||||
["my", "Emmy"],
|
||||
["word", "word"],
|
||||
["wd", "word"],
|
||||
["w d", "word"],
|
||||
["a", "ba"],
|
||||
["a", "ab"],
|
||||
["a", "bab"],
|
||||
["z", "abcdefg"],
|
||||
["CC", "CamelCase"],
|
||||
["cc", "camelCase"],
|
||||
["cC", "camelCase"],
|
||||
["some", "awesome"],
|
||||
["some", "somewhere"],
|
||||
["1", "1"],
|
||||
["1", "2"],
|
||||
[".", "."],
|
||||
["f.t", "file.txt"],
|
||||
["excel", "Excel"],
|
||||
["Excel", "excel"],
|
||||
["PowerPoint", "Power Point"],
|
||||
["power point", "PowerPoint"],
|
||||
["visual studio code", "Visual Studio Code"],
|
||||
["vsc", "Visual Studio Code"],
|
||||
["code", "Visual Studio Code"],
|
||||
["vs code", "Visual Studio Code"],
|
||||
["word", "Microsoft Word"],
|
||||
["ms word", "Microsoft Word"],
|
||||
["browser", "Internet Explorer"],
|
||||
["chrome", "Google Chrome"],
|
||||
["edge", "Microsoft Edge"],
|
||||
["term", "Windows Terminal"],
|
||||
["cmd", "Command Prompt"],
|
||||
["calc", "Calculator"],
|
||||
["snipping", "Snipping Tool"],
|
||||
["note", "Notepad"],
|
||||
["file expl", "File Explorer"],
|
||||
["settings", "Settings"],
|
||||
["p t", "PowerToys"],
|
||||
["p t", "PowerToys"],
|
||||
[" v ", " Visual Studio "],
|
||||
[" a b ", " a b c d "],
|
||||
[string.Empty, string.Empty],
|
||||
[" ", " "],
|
||||
[" ", " "],
|
||||
[" ", "abc"],
|
||||
["abc", " "],
|
||||
[" ", " "],
|
||||
[" ", " a b "],
|
||||
["sh", "ShangHai"],
|
||||
["bj", "BeiJing"],
|
||||
["bj", "北京"],
|
||||
["sh", "上海"],
|
||||
["nh", "你好"],
|
||||
["bj", "Beijing"],
|
||||
["hello", "你好"],
|
||||
["nihao", "你好"],
|
||||
["rmb", "人民币"],
|
||||
["zwr", "中文"],
|
||||
["zw", "中文"],
|
||||
["fbr", "foobar"],
|
||||
["w11", "windows 11"],
|
||||
["pwr", "powershell"],
|
||||
["vm", "void main"],
|
||||
["ps", "PowerShell"],
|
||||
["az", "Azure"],
|
||||
["od", "onedrive"],
|
||||
["gc", "google chrome"],
|
||||
["ff", "firefox"],
|
||||
["fs", "file_system"],
|
||||
["pt", "power-toys"],
|
||||
["jt", "json.test"],
|
||||
["ps", "power shell"],
|
||||
["ps", "power'shell"],
|
||||
["ps", "power\"shell"],
|
||||
["hw", "hello:world"],
|
||||
["abc", "a_b_c"],
|
||||
["abc", "a-b-c"],
|
||||
["abc", "a.b.c"],
|
||||
["abc", "a b c"],
|
||||
["abc", "a'b'c"],
|
||||
["abc", "a\"b\"c"],
|
||||
["abc", "a:b:c"],
|
||||
["_a", "_a"],
|
||||
["a_", "a_"],
|
||||
["-a", "-a"],
|
||||
["a-", "a-"]
|
||||
];
|
||||
|
||||
[TestMethod]
|
||||
[DynamicData(nameof(TestData))]
|
||||
public void CompareScores(string needle, string haystack)
|
||||
{
|
||||
var legacyScore = LegacyFuzzyStringMatcher.ScoreFuzzy(needle, haystack);
|
||||
var newScore = FuzzyStringMatcher.ScoreFuzzy(needle, haystack);
|
||||
|
||||
Assert.AreEqual(legacyScore, newScore, $"Score mismatch for needle='{needle}', haystack='{haystack}'");
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
[DynamicData(nameof(TestData))]
|
||||
public void ComparePositions(string needle, string haystack)
|
||||
{
|
||||
var (legacyScore, legacyPos) = LegacyFuzzyStringMatcher.ScoreFuzzyWithPositions(needle, haystack, true);
|
||||
var (newScore, newPos) = FuzzyStringMatcher.ScoreFuzzyWithPositions(needle, haystack, true);
|
||||
|
||||
Assert.AreEqual(legacyScore, newScore, $"Score mismatch (with pos) for needle='{needle}', haystack='{haystack}'");
|
||||
|
||||
// Ensure lists are not null
|
||||
legacyPos ??= [];
|
||||
newPos ??= [];
|
||||
|
||||
// Compare list contents
|
||||
var legacyPosStr = string.Join(',', legacyPos);
|
||||
var newPosStr = string.Join(',', newPos);
|
||||
|
||||
Assert.AreEqual(legacyPos.Count, newPos.Count, $"Position count mismatch: Legacy=[{legacyPosStr}], New=[{newPosStr}]");
|
||||
|
||||
for (var i = 0; i < legacyPos.Count; i++)
|
||||
{
|
||||
Assert.AreEqual(legacyPos[i], newPos[i], $"Position mismatch at index {i}: Legacy=[{legacyPosStr}], New=[{newPosStr}]");
|
||||
}
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
[DynamicData(nameof(TestData))]
|
||||
public void CompareScores_ContiguousOnly(string needle, string haystack)
|
||||
{
|
||||
var legacyScore = LegacyFuzzyStringMatcher.ScoreFuzzy(needle, haystack, allowNonContiguousMatches: false);
|
||||
var newScore = FuzzyStringMatcher.ScoreFuzzy(needle, haystack, allowNonContiguousMatches: false);
|
||||
|
||||
Assert.AreEqual(legacyScore, newScore, $"Score mismatch (contiguous only) for needle='{needle}', haystack='{haystack}'");
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
[DynamicData(nameof(TestData))]
|
||||
public void CompareScores_PinyinEnabled(string needle, string haystack)
|
||||
{
|
||||
var originalNew = FuzzyStringMatcher.ChinesePinYinSupport;
|
||||
var originalLegacy = LegacyFuzzyStringMatcher.ChinesePinYinSupport;
|
||||
try
|
||||
{
|
||||
FuzzyStringMatcher.ChinesePinYinSupport = true;
|
||||
LegacyFuzzyStringMatcher.ChinesePinYinSupport = true;
|
||||
|
||||
var legacyScore = LegacyFuzzyStringMatcher.ScoreFuzzy(needle, haystack);
|
||||
var newScore = FuzzyStringMatcher.ScoreFuzzy(needle, haystack);
|
||||
|
||||
Assert.AreEqual(legacyScore, newScore, $"Score mismatch (Pinyin enabled) for needle='{needle}', haystack='{haystack}'");
|
||||
}
|
||||
finally
|
||||
{
|
||||
FuzzyStringMatcher.ChinesePinYinSupport = originalNew;
|
||||
LegacyFuzzyStringMatcher.ChinesePinYinSupport = originalLegacy;
|
||||
}
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
[DynamicData(nameof(TestData))]
|
||||
public void ComparePositions_PinyinEnabled(string needle, string haystack)
|
||||
{
|
||||
var originalNew = FuzzyStringMatcher.ChinesePinYinSupport;
|
||||
var originalLegacy = LegacyFuzzyStringMatcher.ChinesePinYinSupport;
|
||||
try
|
||||
{
|
||||
FuzzyStringMatcher.ChinesePinYinSupport = true;
|
||||
LegacyFuzzyStringMatcher.ChinesePinYinSupport = true;
|
||||
|
||||
var (legacyScore, legacyPos) = LegacyFuzzyStringMatcher.ScoreFuzzyWithPositions(needle, haystack, true);
|
||||
var (newScore, newPos) = FuzzyStringMatcher.ScoreFuzzyWithPositions(needle, haystack, true);
|
||||
|
||||
Assert.AreEqual(legacyScore, newScore, $"Score mismatch (with pos, Pinyin enabled) for needle='{needle}', haystack='{haystack}'");
|
||||
|
||||
// Ensure lists are not null
|
||||
legacyPos ??= [];
|
||||
newPos ??= [];
|
||||
|
||||
// If newPos is empty but newScore > 0, it means it's a secondary match (like Pinyin)
|
||||
// which we don't return positions for in the new matcher.
|
||||
if (newScore > 0 && newPos.Count == 0 && legacyPos.Count > 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
// Compare list contents
|
||||
var legacyPosStr = string.Join(',', legacyPos);
|
||||
var newPosStr = string.Join(',', newPos);
|
||||
|
||||
Assert.AreEqual(legacyPos.Count, newPos.Count, $"Position count mismatch: Legacy=[{legacyPosStr}], New=[{newPosStr}]");
|
||||
|
||||
for (var i = 0; i < legacyPos.Count; i++)
|
||||
{
|
||||
Assert.AreEqual(legacyPos[i], newPos[i], $"Position mismatch at index {i}: Legacy=[{legacyPosStr}], New=[{newPosStr}]");
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
FuzzyStringMatcher.ChinesePinYinSupport = originalNew;
|
||||
LegacyFuzzyStringMatcher.ChinesePinYinSupport = originalLegacy;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
// Copyright (c) Microsoft Corporation
|
||||
// The Microsoft Corporation licenses this file to you under the MIT license.
|
||||
// See the LICENSE file in the project root for more information.
|
||||
|
||||
using Microsoft.VisualStudio.TestTools.UnitTesting;
|
||||
|
||||
namespace Microsoft.CommandPalette.Extensions.Toolkit.UnitTests;
|
||||
|
||||
[TestClass]
|
||||
public class FuzzyMatcherDiacriticsTests
|
||||
{
|
||||
[TestMethod]
|
||||
public void ScoreFuzzy_WithDiacriticsRemoval_MatchesWithDiacritics()
|
||||
{
|
||||
// "eco" should match "école" when diacritics are removed (é -> E)
|
||||
var score = FuzzyStringMatcher.ScoreFuzzy("eco", "école", allowNonContiguousMatches: true, removeDiacritics: true);
|
||||
Assert.IsTrue(score > 0, "Should match 'école' with 'eco' when diacritics are removed");
|
||||
|
||||
// "uber" should match "über"
|
||||
score = FuzzyStringMatcher.ScoreFuzzy("uber", "über", allowNonContiguousMatches: true, removeDiacritics: true);
|
||||
Assert.IsTrue(score > 0, "Should match 'über' with 'uber' when diacritics are removed");
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void ScoreFuzzy_WithoutDiacriticsRemoval_DoesNotMatchWhenCharactersDiffer()
|
||||
{
|
||||
// "eco" should NOT match "école" if 'é' is treated as distinct from 'e' and order is strict
|
||||
// 'é' (index 0) != 'e'. 'e' (index 4) is after 'c' (index 1) and 'o' (index 2).
|
||||
// Since needle is "e-c-o", to match "école":
|
||||
// 'e' matches 'e' at 4.
|
||||
// 'c' must show up after. No.
|
||||
// So no match.
|
||||
var score = FuzzyStringMatcher.ScoreFuzzy("eco", "école", allowNonContiguousMatches: true, removeDiacritics: false);
|
||||
Assert.AreEqual(0, score, "Should not match 'école' with 'eco' when diacritics are NOT removed");
|
||||
|
||||
// "uber" vs "über"
|
||||
// u != ü.
|
||||
// b (index 1) match b (index 2). e (2) match e (3). r (3) match r (4).
|
||||
// but 'u' has no match.
|
||||
score = FuzzyStringMatcher.ScoreFuzzy("uber", "über", allowNonContiguousMatches: true, removeDiacritics: false);
|
||||
Assert.AreEqual(0, score, "Should not match 'über' with 'uber' when diacritics are NOT removed");
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void ScoreFuzzy_DefaultRemovesDiacritics()
|
||||
{
|
||||
// Now default is true, so "eco" vs "école" should match
|
||||
var score = FuzzyStringMatcher.ScoreFuzzy("eco", "école");
|
||||
Assert.IsTrue(score > 0, "Default should remove diacritics and match 'école'");
|
||||
}
|
||||
|
||||
[DataTestMethod]
|
||||
[DataRow("a", "à", true)]
|
||||
[DataRow("e", "é", true)]
|
||||
[DataRow("i", "ï", true)]
|
||||
[DataRow("o", "ô", true)]
|
||||
[DataRow("u", "ü", true)]
|
||||
[DataRow("c", "ç", true)]
|
||||
[DataRow("n", "ñ", true)]
|
||||
[DataRow("s", "ß", false)] // ß doesn't strip to s via simple invalid-uppercasing
|
||||
public void VerifySpecificCharacters(string needle, string haystack, bool expectingMatch)
|
||||
{
|
||||
var score = FuzzyStringMatcher.ScoreFuzzy(needle, haystack, allowNonContiguousMatches: true, removeDiacritics: true);
|
||||
if (expectingMatch)
|
||||
{
|
||||
Assert.IsTrue(score > 0, $"Expected match for '{needle}' in '{haystack}' with diacritics removal");
|
||||
}
|
||||
else
|
||||
{
|
||||
Assert.AreEqual(0, score, $"Expected NO match for '{needle}' in '{haystack}' even with diacritics removal");
|
||||
}
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void VerifyBothPathsWorkSameForASCII()
|
||||
{
|
||||
var needle = "test";
|
||||
var haystack = "TestString";
|
||||
|
||||
var score1 = FuzzyStringMatcher.ScoreFuzzy(needle, haystack, allowNonContiguousMatches: true, removeDiacritics: true);
|
||||
var score2 = FuzzyStringMatcher.ScoreFuzzy(needle, haystack, allowNonContiguousMatches: true, removeDiacritics: false);
|
||||
|
||||
Assert.AreEqual(score1, score2, "Scores should be identical for ASCII strings regardless of diacritics setting");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
// Copyright (c) Microsoft Corporation
|
||||
// The Microsoft Corporation licenses this file to you under the MIT license.
|
||||
// See the LICENSE file in the project root for more information.
|
||||
|
||||
using Microsoft.VisualStudio.TestTools.UnitTesting;
|
||||
|
||||
namespace Microsoft.CommandPalette.Extensions.Toolkit.UnitTests;
|
||||
|
||||
[TestClass]
|
||||
public class FuzzyMatcherPinyinLogicTests
|
||||
{
|
||||
[TestInitialize]
|
||||
public void Setup()
|
||||
{
|
||||
FuzzyStringMatcher.ChinesePinYinSupport = true;
|
||||
FuzzyStringMatcher.ClearCache();
|
||||
}
|
||||
|
||||
[TestCleanup]
|
||||
public void Cleanup()
|
||||
{
|
||||
FuzzyStringMatcher.ChinesePinYinSupport = false; // Reset to default state
|
||||
FuzzyStringMatcher.ClearCache();
|
||||
}
|
||||
|
||||
[DataTestMethod]
|
||||
[DataRow("bj", "北京")]
|
||||
[DataRow("sh", "上海")]
|
||||
[DataRow("nihao", "你好")]
|
||||
[DataRow("北京", "北京")]
|
||||
[DataRow("北京", "Beijing")]
|
||||
[DataRow("北", "北京")]
|
||||
[DataRow("你好", "nihao")]
|
||||
public void PinyinMatch_DataDriven(string needle, string haystack)
|
||||
{
|
||||
Assert.IsTrue(FuzzyStringMatcher.ScoreFuzzy(needle, haystack) > 0, $"Expected match for '{needle}' in '{haystack}'");
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void PinyinPositions_ShouldBeEmpty()
|
||||
{
|
||||
var (score, positions) = FuzzyStringMatcher.ScoreFuzzyWithPositions("bj", "北京", true);
|
||||
Assert.IsTrue(score > 0);
|
||||
Assert.AreEqual(0, positions.Count);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
// Copyright (c) Microsoft Corporation
|
||||
// The Microsoft Corporation licenses this file to you under the MIT license.
|
||||
// See the LICENSE file in the project root for more information.
|
||||
|
||||
using Microsoft.VisualStudio.TestTools.UnitTesting;
|
||||
|
||||
namespace Microsoft.CommandPalette.Extensions.Toolkit.UnitTests;
|
||||
|
||||
[TestClass]
|
||||
public class FuzzyMatcherValidationTests
|
||||
{
|
||||
[DataTestMethod]
|
||||
[DataRow(null, "haystack")]
|
||||
[DataRow("", "haystack")]
|
||||
[DataRow("needle", null)]
|
||||
[DataRow("needle", "")]
|
||||
[DataRow(null, null)]
|
||||
public void ScoreFuzzy_HandlesIncorrectInputs(string needle, string haystack)
|
||||
{
|
||||
Assert.AreEqual(0, FuzzyStringMatcher.ScoreFuzzy(needle!, haystack!));
|
||||
Assert.AreEqual(0, FuzzyStringMatcher.ScoreFuzzy(needle!, haystack!, allowNonContiguousMatches: true, removeDiacritics: true));
|
||||
Assert.AreEqual(0, FuzzyStringMatcher.ScoreFuzzy(needle!, haystack!, allowNonContiguousMatches: false, removeDiacritics: false));
|
||||
}
|
||||
|
||||
[DataTestMethod]
|
||||
[DataRow(null, "haystack")]
|
||||
[DataRow("", "haystack")]
|
||||
[DataRow("needle", null)]
|
||||
[DataRow("needle", "")]
|
||||
[DataRow(null, null)]
|
||||
public void ScoreFuzzyWithPositions_HandlesIncorrectInputs(string needle, string haystack)
|
||||
{
|
||||
var (score1, pos1) = FuzzyStringMatcher.ScoreFuzzyWithPositions(needle!, haystack!, true);
|
||||
Assert.AreEqual(0, score1);
|
||||
Assert.IsNotNull(pos1);
|
||||
Assert.AreEqual(0, pos1.Count);
|
||||
|
||||
var (score2, pos2) = FuzzyStringMatcher.ScoreFuzzyWithPositions(needle!, haystack!, allowNonContiguousMatches: true, removeDiacritics: true);
|
||||
Assert.AreEqual(0, score2);
|
||||
Assert.IsNotNull(pos2);
|
||||
Assert.AreEqual(0, pos2.Count);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,225 @@
|
||||
// Copyright (c) Microsoft Corporation
|
||||
// The Microsoft Corporation licenses this file to you under the MIT license.
|
||||
// See the LICENSE file in the project root for more information.
|
||||
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Globalization;
|
||||
using System.Linq;
|
||||
using ToolGood.Words.Pinyin;
|
||||
|
||||
namespace Microsoft.CommandPalette.Extensions.Toolkit.UnitTests.Legacy;
|
||||
|
||||
// Inspired by the fuzzy.rs from edit.exe
|
||||
public static class LegacyFuzzyStringMatcher
|
||||
{
|
||||
private const int NOMATCH = 0;
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets a value indicating whether to support Chinese PinYin.
|
||||
/// Automatically enabled when the system UI culture is Simplified Chinese.
|
||||
/// </summary>
|
||||
public static bool ChinesePinYinSupport { get; set; } = IsSimplifiedChinese();
|
||||
|
||||
private static bool IsSimplifiedChinese()
|
||||
{
|
||||
var culture = CultureInfo.CurrentUICulture;
|
||||
|
||||
// Detect Simplified Chinese: zh-CN, zh-Hans, zh-Hans-*
|
||||
return culture.Name.StartsWith("zh-CN", StringComparison.OrdinalIgnoreCase)
|
||||
|| culture.Name.StartsWith("zh-Hans", StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
public static int ScoreFuzzy(string needle, string haystack, bool allowNonContiguousMatches = true)
|
||||
{
|
||||
var (s, _) = ScoreFuzzyWithPositions(needle, haystack, allowNonContiguousMatches);
|
||||
return s;
|
||||
}
|
||||
|
||||
public static (int Score, List<int> Positions) ScoreFuzzyWithPositions(string needle, string haystack, bool allowNonContiguousMatches)
|
||||
=> ScoreAllFuzzyWithPositions(needle, haystack, allowNonContiguousMatches).MaxBy(i => i.Score);
|
||||
|
||||
public static IEnumerable<(int Score, List<int> Positions)> ScoreAllFuzzyWithPositions(string needle, string haystack, bool allowNonContiguousMatches)
|
||||
{
|
||||
List<string> needles = [needle];
|
||||
List<string> haystacks = [haystack];
|
||||
|
||||
if (ChinesePinYinSupport)
|
||||
{
|
||||
// Remove IME composition split characters.
|
||||
var input = needle.Replace("'", string.Empty);
|
||||
needles.Add(WordsHelper.GetPinyin(input));
|
||||
if (WordsHelper.HasChinese(haystack))
|
||||
{
|
||||
haystacks.Add(WordsHelper.GetPinyin(haystack));
|
||||
}
|
||||
}
|
||||
|
||||
return needles.SelectMany(i => haystacks.Select(j => ScoreFuzzyWithPositionsInternal(i, j, allowNonContiguousMatches)));
|
||||
}
|
||||
|
||||
private static (int Score, List<int> Positions) ScoreFuzzyWithPositionsInternal(string needle, string haystack, bool allowNonContiguousMatches)
|
||||
{
|
||||
if (string.IsNullOrEmpty(haystack) || string.IsNullOrEmpty(needle))
|
||||
{
|
||||
return (NOMATCH, new List<int>());
|
||||
}
|
||||
|
||||
var target = haystack.ToCharArray();
|
||||
var query = needle.ToCharArray();
|
||||
|
||||
if (target.Length < query.Length)
|
||||
{
|
||||
return (NOMATCH, new List<int>());
|
||||
}
|
||||
|
||||
var targetUpper = FoldCase(haystack);
|
||||
var queryUpper = FoldCase(needle);
|
||||
var targetUpperChars = targetUpper.ToCharArray();
|
||||
var queryUpperChars = queryUpper.ToCharArray();
|
||||
|
||||
var area = query.Length * target.Length;
|
||||
var scores = new int[area];
|
||||
var matches = new int[area];
|
||||
|
||||
for (var qi = 0; qi < query.Length; qi++)
|
||||
{
|
||||
var qiOffset = qi * target.Length;
|
||||
var qiPrevOffset = qi > 0 ? (qi - 1) * target.Length : 0;
|
||||
|
||||
for (var ti = 0; ti < target.Length; ti++)
|
||||
{
|
||||
var currentIndex = qiOffset + ti;
|
||||
var diagIndex = (qi > 0 && ti > 0) ? qiPrevOffset + ti - 1 : 0;
|
||||
var leftScore = ti > 0 ? scores[currentIndex - 1] : 0;
|
||||
var diagScore = (qi > 0 && ti > 0) ? scores[diagIndex] : 0;
|
||||
var matchSeqLen = (qi > 0 && ti > 0) ? matches[diagIndex] : 0;
|
||||
|
||||
var score = (diagScore == 0 && qi != 0) ? 0 :
|
||||
ComputeCharScore(
|
||||
query[qi],
|
||||
queryUpperChars[qi],
|
||||
ti != 0 ? target[ti - 1] : null,
|
||||
target[ti],
|
||||
targetUpperChars[ti],
|
||||
matchSeqLen);
|
||||
|
||||
var isValidScore = score != 0 && diagScore + score >= leftScore &&
|
||||
(allowNonContiguousMatches || qi > 0 ||
|
||||
targetUpperChars.Skip(ti).Take(queryUpperChars.Length).SequenceEqual(queryUpperChars));
|
||||
|
||||
if (isValidScore)
|
||||
{
|
||||
matches[currentIndex] = matchSeqLen + 1;
|
||||
scores[currentIndex] = diagScore + score;
|
||||
}
|
||||
else
|
||||
{
|
||||
matches[currentIndex] = NOMATCH;
|
||||
scores[currentIndex] = leftScore;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var positions = new List<int>();
|
||||
if (query.Length > 0 && target.Length > 0)
|
||||
{
|
||||
var qi = query.Length - 1;
|
||||
var ti = target.Length - 1;
|
||||
|
||||
while (true)
|
||||
{
|
||||
var index = (qi * target.Length) + ti;
|
||||
if (matches[index] == NOMATCH)
|
||||
{
|
||||
if (ti == 0)
|
||||
{
|
||||
break;
|
||||
}
|
||||
|
||||
ti--;
|
||||
}
|
||||
else
|
||||
{
|
||||
positions.Add(ti);
|
||||
if (qi == 0 || ti == 0)
|
||||
{
|
||||
break;
|
||||
}
|
||||
|
||||
qi--;
|
||||
ti--;
|
||||
}
|
||||
}
|
||||
|
||||
positions.Reverse();
|
||||
}
|
||||
|
||||
return (scores[area - 1], positions);
|
||||
}
|
||||
|
||||
private static string FoldCase(string input)
|
||||
{
|
||||
return input.ToUpperInvariant();
|
||||
}
|
||||
|
||||
private static int ComputeCharScore(
|
||||
char query,
|
||||
char queryLower,
|
||||
char? targetPrev,
|
||||
char targetCurr,
|
||||
char targetLower,
|
||||
int matchSeqLen)
|
||||
{
|
||||
if (!ConsiderAsEqual(queryLower, targetLower))
|
||||
{
|
||||
return 0;
|
||||
}
|
||||
|
||||
var score = 1; // Character match bonus
|
||||
|
||||
if (matchSeqLen > 0)
|
||||
{
|
||||
score += matchSeqLen * 5; // Consecutive match bonus
|
||||
}
|
||||
|
||||
if (query == targetCurr)
|
||||
{
|
||||
score += 1; // Same case bonus
|
||||
}
|
||||
|
||||
if (targetPrev.HasValue)
|
||||
{
|
||||
var sepBonus = ScoreSeparator(targetPrev.Value);
|
||||
if (sepBonus > 0)
|
||||
{
|
||||
score += sepBonus;
|
||||
}
|
||||
else if (char.IsUpper(targetCurr) && matchSeqLen == 0)
|
||||
{
|
||||
score += 2; // CamelCase bonus
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
score += 8; // Start of word bonus
|
||||
}
|
||||
|
||||
return score;
|
||||
}
|
||||
|
||||
private static bool ConsiderAsEqual(char a, char b)
|
||||
{
|
||||
return a == b || (a == '/' && b == '\\') || (a == '\\' && b == '/');
|
||||
}
|
||||
|
||||
private static int ScoreSeparator(char ch)
|
||||
{
|
||||
return ch switch
|
||||
{
|
||||
'/' or '\\' => 5,
|
||||
'_' or '-' or '.' or ' ' or '\'' or '"' or ':' => 4,
|
||||
_ => 0,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<!-- Look at Directory.Build.props in root for common stuff as well -->
|
||||
<Import Project="..\..\..\..\Common.Dotnet.CsWinRT.props" />
|
||||
|
||||
<PropertyGroup>
|
||||
<RepoRoot>$(MSBuildThisFileDirectory)..\..\..\..\..\</RepoRoot>
|
||||
<IsPackable>false</IsPackable>
|
||||
<IsTestProject>true</IsTestProject>
|
||||
<RootNamespace>Microsoft.CommandPalette.Extensions.Toolkit.UnitTests</RootNamespace>
|
||||
<OutputPath>$(SolutionDir)$(Platform)\$(Configuration)\WinUI3Apps\CmdPal\tests\</OutputPath>
|
||||
<AppendTargetFrameworkToOutputPath>false</AppendTargetFrameworkToOutputPath>
|
||||
<AppendRuntimeIdentifierToOutputPath>false</AppendRuntimeIdentifierToOutputPath>
|
||||
<Nullable>enable</Nullable>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Moq" />
|
||||
<PackageReference Include="MSTest" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\extensionsdk\Microsoft.CommandPalette.Extensions.Toolkit\Microsoft.CommandPalette.Extensions.Toolkit.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<PropertyGroup Condition="'$(CIBuild)'=='true'">
|
||||
<SignAssembly>true</SignAssembly>
|
||||
<DelaySign>true</DelaySign>
|
||||
<AssemblyOriginatorKeyFile>$(RepoRoot).pipelines\272MSSharedLibSN2048.snk</AssemblyOriginatorKeyFile>
|
||||
</PropertyGroup>
|
||||
</Project>
|
||||
@@ -53,6 +53,56 @@ public static class BracketHelper
|
||||
return trailTest.Count == 0;
|
||||
}
|
||||
|
||||
public static string BalanceBrackets(string query)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(query))
|
||||
{
|
||||
return query ?? string.Empty;
|
||||
}
|
||||
|
||||
var openBrackets = new Stack<TrailType>();
|
||||
|
||||
for (var i = 0; i < query.Length; i++)
|
||||
{
|
||||
var (direction, type) = BracketTrail(query[i]);
|
||||
|
||||
if (direction == TrailDirection.None)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (direction == TrailDirection.Open)
|
||||
{
|
||||
openBrackets.Push(type);
|
||||
}
|
||||
else if (direction == TrailDirection.Close)
|
||||
{
|
||||
// Only pop if we have a matching open bracket
|
||||
if (openBrackets.Count > 0 && openBrackets.Peek() == type)
|
||||
{
|
||||
openBrackets.Pop();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (openBrackets.Count == 0)
|
||||
{
|
||||
return query;
|
||||
}
|
||||
|
||||
// Build closing brackets in LIFO order
|
||||
var closingBrackets = new char[openBrackets.Count];
|
||||
var index = 0;
|
||||
|
||||
while (openBrackets.Count > 0)
|
||||
{
|
||||
var type = openBrackets.Pop();
|
||||
closingBrackets[index++] = type == TrailType.Round ? ')' : ']';
|
||||
}
|
||||
|
||||
return query + new string(closingBrackets);
|
||||
}
|
||||
|
||||
private static (TrailDirection Direction, TrailType Type) BracketTrail(char @char)
|
||||
{
|
||||
switch (@char)
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
// Copyright (c) Microsoft Corporation
|
||||
// Copyright (c) Microsoft Corporation
|
||||
// The Microsoft Corporation licenses this file to you under the MIT license.
|
||||
// See the LICENSE file in the project root for more information.
|
||||
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Globalization;
|
||||
using System.Text.RegularExpressions;
|
||||
using CalculatorEngineCommon;
|
||||
@@ -16,6 +15,7 @@ public static class CalculateEngine
|
||||
private static readonly PropertySet _constants = new()
|
||||
{
|
||||
{ "pi", Math.PI },
|
||||
{ "π", Math.PI },
|
||||
{ "e", Math.E },
|
||||
};
|
||||
|
||||
@@ -59,6 +59,8 @@ public static class CalculateEngine
|
||||
|
||||
input = CalculateHelper.FixHumanMultiplicationExpressions(input);
|
||||
|
||||
input = CalculateHelper.UpdateFactorialFunctions(input);
|
||||
|
||||
// Get the user selected trigonometry unit
|
||||
TrigMode trigMode = settings.TrigUnit;
|
||||
|
||||
@@ -77,6 +79,13 @@ public static class CalculateEngine
|
||||
return default;
|
||||
}
|
||||
|
||||
// If we're out of bounds
|
||||
if (result is "inf" or "-inf")
|
||||
{
|
||||
error = Properties.Resources.calculator_not_covert_to_decimal;
|
||||
return default;
|
||||
}
|
||||
|
||||
if (string.IsNullOrEmpty(result))
|
||||
{
|
||||
return default;
|
||||
@@ -110,15 +119,19 @@ public static class CalculateEngine
|
||||
/// </summary>
|
||||
public static decimal FormatMax15Digits(decimal value, CultureInfo cultureInfo)
|
||||
{
|
||||
const int maxDisplayDigits = 15;
|
||||
|
||||
if (value == 0m)
|
||||
{
|
||||
return 0m;
|
||||
}
|
||||
|
||||
var absValue = Math.Abs(value);
|
||||
var integerDigits = absValue >= 1 ? (int)Math.Floor(Math.Log10((double)absValue)) + 1 : 1;
|
||||
|
||||
var maxDecimalDigits = Math.Max(0, 15 - integerDigits);
|
||||
var maxDecimalDigits = Math.Max(0, maxDisplayDigits - integerDigits);
|
||||
|
||||
var rounded = Math.Round(value, maxDecimalDigits, MidpointRounding.AwayFromZero);
|
||||
|
||||
var formatted = rounded.ToString("G29", cultureInfo);
|
||||
|
||||
return Convert.ToDecimal(formatted, cultureInfo);
|
||||
return rounded / 1.000000000000000000000000000000000m;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,11 +3,12 @@
|
||||
// See the LICENSE file in the project root for more information.
|
||||
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Text.RegularExpressions;
|
||||
|
||||
namespace Microsoft.CmdPal.Ext.Calc.Helper;
|
||||
|
||||
public static class CalculateHelper
|
||||
public static partial class CalculateHelper
|
||||
{
|
||||
private static readonly Regex RegValidExpressChar = new Regex(
|
||||
@"^(" +
|
||||
@@ -19,7 +20,7 @@ public static class CalculateHelper
|
||||
@"rad\s*\(|deg\s*\(|grad\s*\(|" + /* trigonometry unit conversion macros */
|
||||
@"pi|" +
|
||||
@"==|~=|&&|\|\||" +
|
||||
@"((-?(\d+(\.\d*)?)|-?(\.\d+))[Ee](-?\d+))|" + /* expression from CheckScientificNotation between parenthesis */
|
||||
@"((\d+(?:\.\d*)?|\.\d+)[eE](-?\d+))|" + /* expression from CheckScientificNotation between parenthesis */
|
||||
@"e|[0-9]|0[xX][0-9a-fA-F]+|0[bB][01]+|0[oO][0-7]+|[\+\-\*\/\^\., ""]|[\(\)\|\!\[\]]" +
|
||||
@")+$",
|
||||
RegexOptions.Compiled);
|
||||
@@ -31,6 +32,94 @@ public static class CalculateHelper
|
||||
private const string RadToDeg = "(180 / pi) * ";
|
||||
private const string RadToGrad = "(200 / pi) * ";
|
||||
|
||||
// replacements from the user input to displayed query
|
||||
private static readonly Dictionary<string, string> QueryReplacements = new()
|
||||
{
|
||||
{ "%", "%" }, { "﹪", "%" },
|
||||
{ "−", "-" }, { "–", "-" }, { "—", "-" },
|
||||
{ "!", "!" },
|
||||
{ "*", "×" }, { "∗", "×" }, { "·", "×" }, { "⊗", "×" }, { "⋅", "×" }, { "✕", "×" }, { "✖", "×" }, { "\u2062", "×" },
|
||||
{ "/", "÷" }, { "∕", "÷" }, { "➗", "÷" }, { ":", "÷" },
|
||||
};
|
||||
|
||||
// replacements from a query to engine input
|
||||
private static readonly Dictionary<string, string> EngineReplacements = new()
|
||||
{
|
||||
{ "×", "*" },
|
||||
{ "÷", "/" },
|
||||
};
|
||||
|
||||
private static readonly Dictionary<string, string> SuperscriptReplacements = new()
|
||||
{
|
||||
{ "²", "^2" }, { "³", "^3" },
|
||||
};
|
||||
|
||||
private static readonly HashSet<char> StandardOperators = [
|
||||
|
||||
// binary operators; doesn't make sense for them to be at the end of a query
|
||||
'+', '-', '*', '/', '%', '^', '=', '&', '|', '\\',
|
||||
|
||||
// parentheses
|
||||
'(', '[',
|
||||
];
|
||||
|
||||
private static readonly HashSet<char> SuffixOperators = [
|
||||
|
||||
// unary operators; can appear at the end of a query
|
||||
')', ']', '!',
|
||||
];
|
||||
|
||||
private static readonly Regex ReplaceScientificNotationRegex = CreateReplaceScientificNotationRegex();
|
||||
|
||||
public static char[] GetQueryOperators()
|
||||
{
|
||||
var ops = new HashSet<char>(StandardOperators);
|
||||
ops.ExceptWith(SuffixOperators);
|
||||
return [.. ops];
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Normalizes the query for display
|
||||
/// This replaces standard operators with more visually appealing ones (e.g., '*' -> '×') if enabled.
|
||||
/// Always applies safe normalizations (standardizing variants like minus, percent, etc.).
|
||||
/// </summary>
|
||||
/// <param name="input">The query string to normalize.</param>
|
||||
public static string NormalizeCharsForDisplayQuery(string input)
|
||||
{
|
||||
// 1. Safe/Trivial replacements (Variant -> Standard)
|
||||
// These are always applied to ensure consistent behavior for non-math symbols (spaces) and
|
||||
// operator variants like minus, percent, and exclamation mark.
|
||||
foreach (var (key, value) in QueryReplacements)
|
||||
{
|
||||
input = input.Replace(key, value);
|
||||
}
|
||||
|
||||
return input;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Normalizes the query for the calculation engine.
|
||||
/// This replaces all supported operator variants (visual or standard) with the specific
|
||||
/// ASCII operators required by the engine (e.g., '×' -> '*').
|
||||
/// It duplicates and expands upon replacements in NormalizeQuery to ensure the engine
|
||||
/// receives valid input regardless of whether NormalizeQuery was executed.
|
||||
/// </summary>
|
||||
public static string NormalizeCharsToEngine(string input)
|
||||
{
|
||||
foreach (var (key, value) in EngineReplacements)
|
||||
{
|
||||
input = input.Replace(key, value);
|
||||
}
|
||||
|
||||
// Replace superscript characters with their engine equivalents (e.g., '²' -> '^2')
|
||||
foreach (var (key, value) in SuperscriptReplacements)
|
||||
{
|
||||
input = input.Replace(key, value);
|
||||
}
|
||||
|
||||
return input;
|
||||
}
|
||||
|
||||
public static bool InputValid(string input)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(input))
|
||||
@@ -50,7 +139,7 @@ public static class CalculateHelper
|
||||
|
||||
// If the input ends with a binary operator then it is not a valid input to mages and the Interpret function would throw an exception. Because we expect here that the user has not finished typing we block those inputs.
|
||||
var trimmedInput = input.TrimEnd();
|
||||
if (trimmedInput.EndsWith('+') || trimmedInput.EndsWith('-') || trimmedInput.EndsWith('*') || trimmedInput.EndsWith('|') || trimmedInput.EndsWith('\\') || trimmedInput.EndsWith('^') || trimmedInput.EndsWith('=') || trimmedInput.EndsWith('&') || trimmedInput.EndsWith('/') || trimmedInput.EndsWith('%'))
|
||||
if (EndsWithBinaryOperator(trimmedInput))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
@@ -58,6 +147,18 @@ public static class CalculateHelper
|
||||
return true;
|
||||
}
|
||||
|
||||
private static bool EndsWithBinaryOperator(string input)
|
||||
{
|
||||
var operators = GetQueryOperators();
|
||||
if (string.IsNullOrEmpty(input))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var lastChar = input[^1];
|
||||
return Array.Exists(operators, op => op == lastChar);
|
||||
}
|
||||
|
||||
public static string FixHumanMultiplicationExpressions(string input)
|
||||
{
|
||||
var output = CheckScientificNotation(input);
|
||||
@@ -72,18 +173,7 @@ public static class CalculateHelper
|
||||
|
||||
private static string CheckScientificNotation(string input)
|
||||
{
|
||||
/**
|
||||
* NOTE: By the time that the expression gets to us, it's already in English format.
|
||||
*
|
||||
* Regex explanation:
|
||||
* (-?(\d+({0}\d*)?)|-?({0}\d+)): Used to capture one of two types:
|
||||
* -?(\d+({0}\d*)?): Captures a decimal number starting with a number (e.g. "-1.23")
|
||||
* -?({0}\d+): Captures a decimal number without leading number (e.g. ".23")
|
||||
* e: Captures 'e' or 'E'
|
||||
* (-?\d+): Captures an integer number (e.g. "-1" or "23")
|
||||
*/
|
||||
var p = @"(-?(\d+(\.\d*)?)|-?(\.\d+))e(-?\d+)";
|
||||
return Regex.Replace(input, p, "($1 * 10^($5))", RegexOptions.IgnoreCase);
|
||||
return ReplaceScientificNotationRegex.Replace(input, "($1 * 10^($2))");
|
||||
}
|
||||
|
||||
/*
|
||||
@@ -292,6 +382,86 @@ public static class CalculateHelper
|
||||
return modifiedInput;
|
||||
}
|
||||
|
||||
public static string UpdateFactorialFunctions(string input)
|
||||
{
|
||||
// Handle n! -> factorial(n)
|
||||
int startSearch = 0;
|
||||
while (true)
|
||||
{
|
||||
var index = input.IndexOf('!', startSearch);
|
||||
if (index == -1)
|
||||
{
|
||||
break;
|
||||
}
|
||||
|
||||
// Ignore !=
|
||||
if (index + 1 < input.Length && input[index + 1] == '=')
|
||||
{
|
||||
startSearch = index + 2;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (index == 0)
|
||||
{
|
||||
startSearch = index + 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Scan backwards
|
||||
var endArg = index - 1;
|
||||
while (endArg >= 0 && char.IsWhiteSpace(input[endArg]))
|
||||
{
|
||||
endArg--;
|
||||
}
|
||||
|
||||
if (endArg < 0)
|
||||
{
|
||||
startSearch = index + 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
var startArg = endArg;
|
||||
if (input[endArg] == ')')
|
||||
{
|
||||
// Find matching '('
|
||||
startArg = FindOpeningBracketIndexInFrontOfIndex(input, endArg);
|
||||
if (startArg == -1)
|
||||
{
|
||||
startSearch = index + 1;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
// Scan back for number or word
|
||||
while (startArg >= 0 && (char.IsLetterOrDigit(input[startArg]) || input[startArg] == '.'))
|
||||
{
|
||||
startArg--;
|
||||
}
|
||||
|
||||
startArg++; // Move back to first valid char
|
||||
}
|
||||
|
||||
if (startArg > endArg)
|
||||
{
|
||||
// No argument found
|
||||
startSearch = index + 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Extract argument
|
||||
var arg = input.Substring(startArg, endArg - startArg + 1);
|
||||
|
||||
// Replace <arg><whitespace>! with factorial(<arg>)
|
||||
input = input.Remove(startArg, index - startArg + 1);
|
||||
input = input.Insert(startArg, $"factorial({arg})");
|
||||
|
||||
startSearch = 0; // Reset search because string changed
|
||||
}
|
||||
|
||||
return input;
|
||||
}
|
||||
|
||||
private static string ModifyMathFunction(string input, string function, string modification)
|
||||
{
|
||||
// Create the pattern to match the function, opening bracket, and any spaces in between
|
||||
@@ -325,4 +495,43 @@ public static class CalculateHelper
|
||||
|
||||
return modifiedInput;
|
||||
}
|
||||
|
||||
private static int FindOpeningBracketIndexInFrontOfIndex(string input, int end)
|
||||
{
|
||||
var bracketCount = 0;
|
||||
for (var i = end; i >= 0; i--)
|
||||
{
|
||||
switch (input[i])
|
||||
{
|
||||
case ')':
|
||||
bracketCount++;
|
||||
break;
|
||||
case '(':
|
||||
{
|
||||
bracketCount--;
|
||||
if (bracketCount == 0)
|
||||
{
|
||||
return i;
|
||||
}
|
||||
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return -1;
|
||||
}
|
||||
|
||||
/*
|
||||
* NOTE: By the time that the expression gets to us, it's already in English format.
|
||||
*
|
||||
* Regex explanation:
|
||||
* (-?(\d+({0}\d*)?)|-?({0}\d+)): Used to capture one of two types:
|
||||
* -?(\d+({0}\d*)?): Captures a decimal number starting with a number (e.g. "-1.23")
|
||||
* -?({0}\d+): Captures a decimal number without leading number (e.g. ".23")
|
||||
* e: Captures 'e' or 'E'
|
||||
* (?\d+): Captures an integer number (e.g. "-1" or "23")
|
||||
*/
|
||||
[GeneratedRegex(@"(\d+(?:\.\d*)?|\.\d+)e(-?\d+)", RegexOptions.IgnoreCase, "en-US")]
|
||||
private static partial Regex CreateReplaceScientificNotationRegex();
|
||||
}
|
||||
|
||||
@@ -2,8 +2,6 @@
|
||||
// The Microsoft Corporation licenses this file to you under the MIT license.
|
||||
// See the LICENSE file in the project root for more information.
|
||||
|
||||
using Microsoft.CmdPal.Ext.Calc.Helper;
|
||||
|
||||
namespace Microsoft.CmdPal.Ext.Calc.Helper;
|
||||
|
||||
public interface ISettingsInterface
|
||||
@@ -15,4 +13,8 @@ public interface ISettingsInterface
|
||||
public bool OutputUseEnglishFormat { get; }
|
||||
|
||||
public bool CloseOnEnter { get; }
|
||||
|
||||
public bool CopyResultToSearchBarIfQueryEndsWithEqualSign { get; }
|
||||
|
||||
public bool AutoFixQuery { get; }
|
||||
}
|
||||
|
||||
@@ -12,7 +12,13 @@ namespace Microsoft.CmdPal.Ext.Calc.Helper;
|
||||
|
||||
public static partial class QueryHelper
|
||||
{
|
||||
public static ListItem Query(string query, ISettingsInterface settings, bool isFallbackSearch, TypedEventHandler<object, object> handleSave = null)
|
||||
public static ListItem Query(
|
||||
string query,
|
||||
ISettingsInterface settings,
|
||||
bool isFallbackSearch,
|
||||
out string displayQuery,
|
||||
TypedEventHandler<object, object> handleSave = null,
|
||||
TypedEventHandler<object, object> handleReplace = null)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(query);
|
||||
if (!isFallbackSearch)
|
||||
@@ -20,26 +26,50 @@ public static partial class QueryHelper
|
||||
ArgumentNullException.ThrowIfNull(handleSave);
|
||||
}
|
||||
|
||||
CultureInfo inputCulture = settings.InputUseEnglishFormat ? new CultureInfo("en-us") : CultureInfo.CurrentCulture;
|
||||
CultureInfo outputCulture = settings.OutputUseEnglishFormat ? new CultureInfo("en-us") : CultureInfo.CurrentCulture;
|
||||
CultureInfo inputCulture =
|
||||
settings.InputUseEnglishFormat ? new CultureInfo("en-us") : CultureInfo.CurrentCulture;
|
||||
CultureInfo outputCulture =
|
||||
settings.OutputUseEnglishFormat ? new CultureInfo("en-us") : CultureInfo.CurrentCulture;
|
||||
|
||||
// In case the user pastes a query with a leading =
|
||||
query = query.TrimStart('=');
|
||||
query = query.TrimStart('=').TrimStart();
|
||||
|
||||
// Enables better looking characters for multiplication and division (e.g., '×' and '÷')
|
||||
displayQuery = CalculateHelper.NormalizeCharsForDisplayQuery(query);
|
||||
|
||||
// Happens if the user has only typed the action key so far
|
||||
if (string.IsNullOrEmpty(query))
|
||||
if (string.IsNullOrEmpty(displayQuery))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
NumberTranslator translator = NumberTranslator.Create(inputCulture, new CultureInfo("en-US"));
|
||||
var input = translator.Translate(query.Normalize(NormalizationForm.FormKC));
|
||||
// Normalize query to engine format (e.g., replace '×' with '*', converts superscripts to functions)
|
||||
// This must be done before any further normalization to avoid losing information
|
||||
var engineQuery = CalculateHelper.NormalizeCharsToEngine(displayQuery);
|
||||
|
||||
// Cleanup rest of the Unicode characters, whitespace
|
||||
var queryForEngine2 = engineQuery.Normalize(NormalizationForm.FormKC);
|
||||
|
||||
// Translate numbers from input culture to en-US culture for the calculation engine
|
||||
var translator = NumberTranslator.Create(inputCulture, new CultureInfo("en-US"));
|
||||
|
||||
// Translate the input query
|
||||
var input = translator.Translate(queryForEngine2);
|
||||
|
||||
if (string.IsNullOrWhiteSpace(input))
|
||||
{
|
||||
return ErrorHandler.OnError(isFallbackSearch, query, Properties.Resources.calculator_expression_empty);
|
||||
}
|
||||
|
||||
// normalize again to engine chars after translation
|
||||
input = CalculateHelper.NormalizeCharsToEngine(input);
|
||||
|
||||
// Auto fix incomplete queries (if enabled)
|
||||
if (settings.AutoFixQuery && TryGetIncompleteQuery(input, out var newInput))
|
||||
{
|
||||
input = newInput;
|
||||
}
|
||||
|
||||
if (!CalculateHelper.InputValid(input))
|
||||
{
|
||||
return null;
|
||||
@@ -60,10 +90,10 @@ public static partial class QueryHelper
|
||||
if (isFallbackSearch)
|
||||
{
|
||||
// Fallback search
|
||||
return ResultHelper.CreateResult(result.RoundedResult, inputCulture, outputCulture, query);
|
||||
return ResultHelper.CreateResult(result.RoundedResult, inputCulture, outputCulture, displayQuery);
|
||||
}
|
||||
|
||||
return ResultHelper.CreateResult(result.RoundedResult, inputCulture, outputCulture, query, settings, handleSave);
|
||||
return ResultHelper.CreateResult(result.RoundedResult, inputCulture, outputCulture, displayQuery, settings, handleSave, handleReplace);
|
||||
}
|
||||
catch (OverflowException)
|
||||
{
|
||||
@@ -77,4 +107,32 @@ public static partial class QueryHelper
|
||||
return ErrorHandler.OnError(isFallbackSearch, query, default, e);
|
||||
}
|
||||
}
|
||||
|
||||
public static bool TryGetIncompleteQuery(string query, out string newQuery)
|
||||
{
|
||||
newQuery = query;
|
||||
|
||||
var trimmed = query.TrimEnd();
|
||||
if (string.IsNullOrEmpty(trimmed))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
// 1. Trim trailing operators
|
||||
var operators = CalculateHelper.GetQueryOperators();
|
||||
while (trimmed.Length > 0 && Array.IndexOf(operators, trimmed[^1]) > -1)
|
||||
{
|
||||
trimmed = trimmed[..^1].TrimEnd();
|
||||
}
|
||||
|
||||
if (trimmed.Length == 0)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
// 2. Fix brackets
|
||||
newQuery = BracketHelper.BalanceBrackets(trimmed);
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
// Copyright (c) Microsoft Corporation
|
||||
// The Microsoft Corporation licenses this file to you under the MIT license.
|
||||
// See the LICENSE file in the project root for more information.
|
||||
|
||||
using Microsoft.CommandPalette.Extensions;
|
||||
using Microsoft.CommandPalette.Extensions.Toolkit;
|
||||
using Windows.Foundation;
|
||||
|
||||
namespace Microsoft.CmdPal.Ext.Calc.Helper;
|
||||
|
||||
public sealed partial class ReplaceQueryCommand : InvokableCommand
|
||||
{
|
||||
public event TypedEventHandler<object, object> ReplaceRequested;
|
||||
|
||||
public ReplaceQueryCommand()
|
||||
{
|
||||
Name = "Replace query";
|
||||
Icon = new IconInfo("\uE70F"); // Edit icon
|
||||
}
|
||||
|
||||
public override ICommandResult Invoke()
|
||||
{
|
||||
ReplaceRequested?.Invoke(this, null);
|
||||
return CommandResult.KeepOpen();
|
||||
}
|
||||
}
|
||||
@@ -6,6 +6,7 @@ using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Globalization;
|
||||
using ManagedCommon;
|
||||
using Microsoft.CommandPalette.Extensions;
|
||||
using Microsoft.CommandPalette.Extensions.Toolkit;
|
||||
using Windows.Foundation;
|
||||
|
||||
@@ -13,7 +14,14 @@ namespace Microsoft.CmdPal.Ext.Calc.Helper;
|
||||
|
||||
public static class ResultHelper
|
||||
{
|
||||
public static ListItem CreateResult(decimal? roundedResult, CultureInfo inputCulture, CultureInfo outputCulture, string query, ISettingsInterface settings, TypedEventHandler<object, object> handleSave)
|
||||
public static ListItem CreateResult(
|
||||
decimal? roundedResult,
|
||||
CultureInfo inputCulture,
|
||||
CultureInfo outputCulture,
|
||||
string query,
|
||||
ISettingsInterface settings,
|
||||
TypedEventHandler<object, object> handleSave,
|
||||
TypedEventHandler<object, object> handleReplace)
|
||||
{
|
||||
// Return null when the expression is not a valid calculator query.
|
||||
if (roundedResult is null)
|
||||
@@ -28,6 +36,9 @@ public static class ResultHelper
|
||||
var saveCommand = new SaveCommand(result);
|
||||
saveCommand.SaveRequested += handleSave;
|
||||
|
||||
var replaceCommand = new ReplaceQueryCommand();
|
||||
replaceCommand.ReplaceRequested += handleReplace;
|
||||
|
||||
var copyCommandItem = CreateResult(roundedResult, inputCulture, outputCulture, query);
|
||||
|
||||
// No TextToSuggest on the main save command item. We don't want to keep suggesting what the result is,
|
||||
@@ -40,6 +51,7 @@ public static class ResultHelper
|
||||
Subtitle = query,
|
||||
MoreCommands = [
|
||||
new CommandContextItem(settings.CloseOnEnter ? saveCommand : copyCommandItem.Command),
|
||||
new CommandContextItem(replaceCommand) { RequestedShortcut = KeyChords.CopyResultToSearchBox, },
|
||||
..copyCommandItem.MoreCommands,
|
||||
],
|
||||
};
|
||||
@@ -55,11 +67,15 @@ public static class ResultHelper
|
||||
|
||||
var decimalResult = roundedResult?.ToString(outputCulture);
|
||||
|
||||
List<CommandContextItem> context = [];
|
||||
List<IContextItem> context = [];
|
||||
|
||||
if (decimal.IsInteger((decimal)roundedResult))
|
||||
{
|
||||
context.Add(new Separator());
|
||||
|
||||
var i = decimal.ToInt64((decimal)roundedResult);
|
||||
|
||||
// hexadecimal
|
||||
try
|
||||
{
|
||||
var hexResult = "0x" + i.ToString("X", outputCulture);
|
||||
@@ -70,9 +86,10 @@ public static class ResultHelper
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Logger.LogError("Error parsing hex format", ex);
|
||||
Logger.LogError("Error converting to hex format", ex);
|
||||
}
|
||||
|
||||
// binary
|
||||
try
|
||||
{
|
||||
var binaryResult = "0b" + i.ToString("B", outputCulture);
|
||||
@@ -83,7 +100,21 @@ public static class ResultHelper
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Logger.LogError("Error parsing binary format", ex);
|
||||
Logger.LogError("Error converting to binary format", ex);
|
||||
}
|
||||
|
||||
// octal
|
||||
try
|
||||
{
|
||||
var octalResult = "0o" + Convert.ToString(i, 8);
|
||||
context.Add(new CommandContextItem(new CopyTextCommand(octalResult) { Name = Properties.Resources.calculator_copy_octal })
|
||||
{
|
||||
Title = octalResult,
|
||||
});
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Logger.LogError("Error converting to octal format", ex);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -45,6 +45,18 @@ public class SettingsManager : JsonSettingsManager, ISettingsInterface
|
||||
Properties.Resources.calculator_settings_close_on_enter_description,
|
||||
true);
|
||||
|
||||
private readonly ToggleSetting _copyResultToSearchBarIfQueryEndsWithEqualSign = new(
|
||||
Namespaced(nameof(CopyResultToSearchBarIfQueryEndsWithEqualSign)),
|
||||
Properties.Resources.calculator_settings_copy_result_to_search_bar,
|
||||
Properties.Resources.calculator_settings_copy_result_to_search_bar_description,
|
||||
false);
|
||||
|
||||
private readonly ToggleSetting _autoFixQuery = new(
|
||||
Namespaced(nameof(AutoFixQuery)),
|
||||
Properties.Resources.calculator_settings_auto_fix_query,
|
||||
Properties.Resources.calculator_settings_auto_fix_query_description,
|
||||
true);
|
||||
|
||||
public CalculateEngine.TrigMode TrigUnit
|
||||
{
|
||||
get
|
||||
@@ -81,6 +93,10 @@ public class SettingsManager : JsonSettingsManager, ISettingsInterface
|
||||
|
||||
public bool CloseOnEnter => _closeOnEnter.Value;
|
||||
|
||||
public bool CopyResultToSearchBarIfQueryEndsWithEqualSign => _copyResultToSearchBarIfQueryEndsWithEqualSign.Value;
|
||||
|
||||
public bool AutoFixQuery => _autoFixQuery.Value;
|
||||
|
||||
internal static string SettingsJsonPath()
|
||||
{
|
||||
var directory = Utilities.BaseSettingsPath("Microsoft.CmdPal");
|
||||
@@ -98,6 +114,8 @@ public class SettingsManager : JsonSettingsManager, ISettingsInterface
|
||||
Settings.Add(_inputUseEnNumberFormat);
|
||||
Settings.Add(_outputUseEnNumberFormat);
|
||||
Settings.Add(_closeOnEnter);
|
||||
Settings.Add(_copyResultToSearchBarIfQueryEndsWithEqualSign);
|
||||
Settings.Add(_autoFixQuery);
|
||||
|
||||
// Load settings from file upon initialization
|
||||
LoadSettings();
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
// Copyright (c) Microsoft Corporation
|
||||
// The Microsoft Corporation licenses this file to you under the MIT license.
|
||||
// See the LICENSE file in the project root for more information.
|
||||
|
||||
using Microsoft.CommandPalette.Extensions;
|
||||
using Windows.System;
|
||||
|
||||
namespace Microsoft.CmdPal.Ext.Calc;
|
||||
|
||||
internal static class KeyChords
|
||||
{
|
||||
internal static KeyChord CopyResultToSearchBox { get; } = new(VirtualKeyModifiers.Control | VirtualKeyModifiers.Shift, (int)VirtualKey.Enter, 0);
|
||||
}
|
||||
@@ -25,12 +25,12 @@ public sealed partial class CalculatorListPage : DynamicListPage
|
||||
private readonly Lock _resultsLock = new();
|
||||
private readonly ISettingsInterface _settingsManager;
|
||||
private readonly List<ListItem> _items = [];
|
||||
private readonly List<ListItem> history = [];
|
||||
private readonly List<ListItem> _history = [];
|
||||
private readonly ListItem _emptyItem;
|
||||
|
||||
// This is the text that saved when the user click the result.
|
||||
// We need to avoid the double calculation. This may cause some wierd behaviors.
|
||||
private string skipQuerySearchText = string.Empty;
|
||||
private string _skipQuerySearchText = string.Empty;
|
||||
|
||||
public CalculatorListPage(ISettingsInterface settings)
|
||||
{
|
||||
@@ -54,6 +54,17 @@ public sealed partial class CalculatorListPage : DynamicListPage
|
||||
UpdateSearchText(string.Empty, string.Empty);
|
||||
}
|
||||
|
||||
private void HandleReplaceQuery(object sender, object args)
|
||||
{
|
||||
var lastResult = _items[0].Title;
|
||||
if (!string.IsNullOrEmpty(lastResult))
|
||||
{
|
||||
_skipQuerySearchText = lastResult;
|
||||
SearchText = lastResult;
|
||||
OnPropertyChanged(nameof(SearchText));
|
||||
}
|
||||
}
|
||||
|
||||
public override void UpdateSearchText(string oldSearch, string newSearch)
|
||||
{
|
||||
if (oldSearch == newSearch)
|
||||
@@ -61,19 +72,37 @@ public sealed partial class CalculatorListPage : DynamicListPage
|
||||
return;
|
||||
}
|
||||
|
||||
if (!string.IsNullOrEmpty(skipQuerySearchText) && newSearch == skipQuerySearchText)
|
||||
if (!string.IsNullOrEmpty(_skipQuerySearchText) && newSearch == _skipQuerySearchText)
|
||||
{
|
||||
// only skip once.
|
||||
skipQuerySearchText = string.Empty;
|
||||
_skipQuerySearchText = string.Empty;
|
||||
return;
|
||||
}
|
||||
|
||||
skipQuerySearchText = string.Empty;
|
||||
var copyResultToSearchText = false;
|
||||
if (_settingsManager.CopyResultToSearchBarIfQueryEndsWithEqualSign && newSearch.EndsWith('='))
|
||||
{
|
||||
newSearch = newSearch.TrimEnd('=').TrimEnd();
|
||||
copyResultToSearchText = true;
|
||||
}
|
||||
|
||||
_skipQuerySearchText = string.Empty;
|
||||
|
||||
_emptyItem.Subtitle = newSearch;
|
||||
|
||||
var result = QueryHelper.Query(newSearch, _settingsManager, false, HandleSave);
|
||||
var result = QueryHelper.Query(newSearch, _settingsManager, isFallbackSearch: false, out var displayQuery, HandleSave, HandleReplaceQuery);
|
||||
|
||||
UpdateResult(result);
|
||||
|
||||
if (copyResultToSearchText && result is not null)
|
||||
{
|
||||
_skipQuerySearchText = result.Title;
|
||||
SearchText = result.Title;
|
||||
|
||||
// LOAD BEARING: The SearchText setter does not raise a PropertyChanged notification,
|
||||
// so we must raise it explicitly to ensure the UI updates correctly.
|
||||
OnPropertyChanged(nameof(SearchText));
|
||||
}
|
||||
}
|
||||
|
||||
private void UpdateResult(ListItem result)
|
||||
@@ -91,7 +120,7 @@ public sealed partial class CalculatorListPage : DynamicListPage
|
||||
_items.Add(_emptyItem);
|
||||
}
|
||||
|
||||
this._items.AddRange(history);
|
||||
this._items.AddRange(_history);
|
||||
}
|
||||
|
||||
RaiseItemsChanged(this._items.Count);
|
||||
@@ -109,7 +138,7 @@ public sealed partial class CalculatorListPage : DynamicListPage
|
||||
TextToSuggest = lastResult,
|
||||
};
|
||||
|
||||
history.Insert(0, li);
|
||||
_history.Insert(0, li);
|
||||
_items.Insert(1, li);
|
||||
|
||||
// Why we need to clean the query record? Removed, but if necessary, please move it back.
|
||||
@@ -117,9 +146,14 @@ public sealed partial class CalculatorListPage : DynamicListPage
|
||||
|
||||
// this change will call the UpdateSearchText again.
|
||||
// We need to avoid it.
|
||||
skipQuerySearchText = lastResult;
|
||||
_skipQuerySearchText = lastResult;
|
||||
SearchText = lastResult;
|
||||
this.RaiseItemsChanged(this._items.Count);
|
||||
|
||||
// LOAD BEARING: The SearchText setter does not raise a PropertyChanged notification,
|
||||
// so we must raise it explicitly to ensure the UI updates correctly.
|
||||
OnPropertyChanged(nameof(SearchText));
|
||||
|
||||
RaiseItemsChanged(this._items.Count);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -27,7 +27,7 @@ public sealed partial class FallbackCalculatorItem : FallbackCommandItem
|
||||
|
||||
public override void UpdateQuery(string query)
|
||||
{
|
||||
var result = QueryHelper.Query(query, _settings, true, null);
|
||||
var result = QueryHelper.Query(query, _settings, true, out _);
|
||||
|
||||
if (result is null)
|
||||
{
|
||||
|
||||
@@ -19,7 +19,7 @@ namespace Microsoft.CmdPal.Ext.Calc.Properties {
|
||||
// class via a tool like ResGen or Visual Studio.
|
||||
// To add or remove a member, edit your .ResX file then rerun ResGen
|
||||
// with the /str option, or rebuild your VS project.
|
||||
[global::System.CodeDom.Compiler.GeneratedCodeAttribute("System.Resources.Tools.StronglyTypedResourceBuilder", "17.0.0.0")]
|
||||
[global::System.CodeDom.Compiler.GeneratedCodeAttribute("System.Resources.Tools.StronglyTypedResourceBuilder", "18.0.0.0")]
|
||||
[global::System.Diagnostics.DebuggerNonUserCodeAttribute()]
|
||||
[global::System.Runtime.CompilerServices.CompilerGeneratedAttribute()]
|
||||
public class Resources {
|
||||
@@ -96,6 +96,15 @@ namespace Microsoft.CmdPal.Ext.Calc.Properties {
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Looks up a localized string similar to Copy octal.
|
||||
/// </summary>
|
||||
public static string calculator_copy_octal {
|
||||
get {
|
||||
return ResourceManager.GetString("calculator_copy_octal", resourceCulture);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Looks up a localized string similar to Calculator.
|
||||
/// </summary>
|
||||
@@ -186,6 +195,24 @@ namespace Microsoft.CmdPal.Ext.Calc.Properties {
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Looks up a localized string similar to Fix incomplete calculations automatically.
|
||||
/// </summary>
|
||||
public static string calculator_settings_auto_fix_query {
|
||||
get {
|
||||
return ResourceManager.GetString("calculator_settings_auto_fix_query", resourceCulture);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Looks up a localized string similar to Attempt to evaluate incomplete calculations by ignoring extra operators or symbols.
|
||||
/// </summary>
|
||||
public static string calculator_settings_auto_fix_query_description {
|
||||
get {
|
||||
return ResourceManager.GetString("calculator_settings_auto_fix_query_description", resourceCulture);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Looks up a localized string similar to Close on Enter.
|
||||
/// </summary>
|
||||
@@ -204,6 +231,24 @@ namespace Microsoft.CmdPal.Ext.Calc.Properties {
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Looks up a localized string similar to Replace query with result on equals.
|
||||
/// </summary>
|
||||
public static string calculator_settings_copy_result_to_search_bar {
|
||||
get {
|
||||
return ResourceManager.GetString("calculator_settings_copy_result_to_search_bar", resourceCulture);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Looks up a localized string similar to Updates the query to the result when (=) is entered.
|
||||
/// </summary>
|
||||
public static string calculator_settings_copy_result_to_search_bar_description {
|
||||
get {
|
||||
return ResourceManager.GetString("calculator_settings_copy_result_to_search_bar_description", resourceCulture);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Looks up a localized string similar to Use English (United States) number format for input.
|
||||
/// </summary>
|
||||
@@ -222,6 +267,24 @@ namespace Microsoft.CmdPal.Ext.Calc.Properties {
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Looks up a localized string similar to Handle extra operators and symbols.
|
||||
/// </summary>
|
||||
public static string calculator_settings_input_normalization {
|
||||
get {
|
||||
return ResourceManager.GetString("calculator_settings_input_normalization", resourceCulture);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Looks up a localized string similar to Enable advanced input normalization and extra symbols (e.g. ÷, ×, π).
|
||||
/// </summary>
|
||||
public static string calculator_settings_input_normalization_description {
|
||||
get {
|
||||
return ResourceManager.GetString("calculator_settings_input_normalization_description", resourceCulture);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Looks up a localized string similar to Use English (United States) number format for output.
|
||||
/// </summary>
|
||||
|
||||
@@ -208,4 +208,25 @@
|
||||
<data name="calculator_expression_empty" xml:space="preserve">
|
||||
<value>Please enter an expression</value>
|
||||
</data>
|
||||
<data name="calculator_settings_copy_result_to_search_bar" xml:space="preserve">
|
||||
<value>Replace query with result on equals</value>
|
||||
</data>
|
||||
<data name="calculator_settings_copy_result_to_search_bar_description" xml:space="preserve">
|
||||
<value>Updates the query to the result when (=) is entered</value>
|
||||
</data>
|
||||
<data name="calculator_settings_auto_fix_query" xml:space="preserve">
|
||||
<value>Fix incomplete calculations automatically</value>
|
||||
</data>
|
||||
<data name="calculator_settings_auto_fix_query_description" xml:space="preserve">
|
||||
<value>Attempt to evaluate incomplete calculations by ignoring extra operators or symbols</value>
|
||||
</data>
|
||||
<data name="calculator_settings_input_normalization" xml:space="preserve">
|
||||
<value>Handle extra operators and symbols</value>
|
||||
</data>
|
||||
<data name="calculator_settings_input_normalization_description" xml:space="preserve">
|
||||
<value>Enable advanced input normalization and extra symbols (e.g. ÷, ×, π)</value>
|
||||
</data>
|
||||
<data name="calculator_copy_octal" xml:space="preserve">
|
||||
<value>Copy octal</value>
|
||||
</data>
|
||||
</root>
|
||||
@@ -1,134 +0,0 @@
|
||||
// Copyright (c) Microsoft Corporation
|
||||
// The Microsoft Corporation licenses this file to you under the MIT license.
|
||||
// See the LICENSE file in the project root for more information.
|
||||
|
||||
// Code forked from Betsegaw Tadele's https://github.com/betsegaw/windowwalker/
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Globalization;
|
||||
using System.Linq;
|
||||
|
||||
namespace Microsoft.CmdPal.Ext.WindowWalker.Components;
|
||||
|
||||
/// <summary>
|
||||
/// Class housing fuzzy matching methods
|
||||
/// </summary>
|
||||
internal static class FuzzyMatching
|
||||
{
|
||||
/// <summary>
|
||||
/// Finds the best match (the one with the most
|
||||
/// number of letters adjacent to each other) and
|
||||
/// returns the index location of each of the letters
|
||||
/// of the matches
|
||||
/// </summary>
|
||||
/// <param name="text">The text to search inside of</param>
|
||||
/// <param name="searchText">the text to search for</param>
|
||||
/// <returns>returns the index location of each of the letters of the matches</returns>
|
||||
internal static List<int> FindBestFuzzyMatch(string text, string searchText)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(searchText);
|
||||
|
||||
ArgumentNullException.ThrowIfNull(text);
|
||||
|
||||
// Using CurrentCulture since this is user facing
|
||||
searchText = searchText.ToLower(CultureInfo.CurrentCulture);
|
||||
text = text.ToLower(CultureInfo.CurrentCulture);
|
||||
|
||||
// Create a grid to march matches like
|
||||
// e.g.
|
||||
// a b c a d e c f g
|
||||
// a x x
|
||||
// c x x
|
||||
var matches = new bool[text.Length, searchText.Length];
|
||||
for (var firstIndex = 0; firstIndex < text.Length; firstIndex++)
|
||||
{
|
||||
for (var secondIndex = 0; secondIndex < searchText.Length; secondIndex++)
|
||||
{
|
||||
matches[firstIndex, secondIndex] =
|
||||
searchText[secondIndex] == text[firstIndex] ?
|
||||
true :
|
||||
false;
|
||||
}
|
||||
}
|
||||
|
||||
// use this table to get all the possible matches
|
||||
List<List<int>> allMatches = GetAllMatchIndexes(matches);
|
||||
|
||||
// return the score that is the max
|
||||
var maxScore = allMatches.Count > 0 ? CalculateScoreForMatches(allMatches[0]) : 0;
|
||||
List<int> bestMatch = allMatches.Count > 0 ? allMatches[0] : new List<int>();
|
||||
|
||||
foreach (var match in allMatches)
|
||||
{
|
||||
var score = CalculateScoreForMatches(match);
|
||||
if (score > maxScore)
|
||||
{
|
||||
bestMatch = match;
|
||||
maxScore = score;
|
||||
}
|
||||
}
|
||||
|
||||
return bestMatch;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets all the possible matches to the search string with in the text
|
||||
/// </summary>
|
||||
/// <param name="matches"> a table showing the matches as generated by
|
||||
/// a two dimensional array with the first dimension the text and the second
|
||||
/// one the search string and each cell marked as an intersection between the two</param>
|
||||
/// <returns>a list of the possible combinations that match the search text</returns>
|
||||
internal static List<List<int>> GetAllMatchIndexes(bool[,] matches)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(matches);
|
||||
|
||||
List<List<int>> results = new List<List<int>>();
|
||||
|
||||
for (var secondIndex = 0; secondIndex < matches.GetLength(1); secondIndex++)
|
||||
{
|
||||
for (var firstIndex = 0; firstIndex < matches.GetLength(0); firstIndex++)
|
||||
{
|
||||
if (secondIndex == 0 && matches[firstIndex, secondIndex])
|
||||
{
|
||||
results.Add(new List<int> { firstIndex });
|
||||
}
|
||||
else if (matches[firstIndex, secondIndex])
|
||||
{
|
||||
var tempList = results.Where(x => x.Count == secondIndex && x[x.Count - 1] < firstIndex).Select(x => x.ToList()).ToList();
|
||||
|
||||
foreach (var pathSofar in tempList)
|
||||
{
|
||||
pathSofar.Add(firstIndex);
|
||||
}
|
||||
|
||||
results.AddRange(tempList);
|
||||
}
|
||||
}
|
||||
|
||||
results = results.Where(x => x.Count == secondIndex + 1).ToList();
|
||||
}
|
||||
|
||||
return results.Where(x => x.Count == matches.GetLength(1)).ToList();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Calculates the score for a string
|
||||
/// </summary>
|
||||
/// <param name="matches">the index of the matches</param>
|
||||
/// <returns>an integer representing the score</returns>
|
||||
internal static int CalculateScoreForMatches(List<int> matches)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(matches);
|
||||
|
||||
var score = 0;
|
||||
|
||||
for (var currentIndex = 1; currentIndex < matches.Count; currentIndex++)
|
||||
{
|
||||
var previousIndex = currentIndex - 1;
|
||||
|
||||
score -= matches[currentIndex] - matches[previousIndex];
|
||||
}
|
||||
|
||||
return score == 0 ? -10000 : score;
|
||||
}
|
||||
}
|
||||
@@ -3,7 +3,7 @@
|
||||
// See the LICENSE file in the project root for more information.
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.CmdPal.Ext.WindowWalker.Commands;
|
||||
using Microsoft.CmdPal.Ext.WindowWalker.Helpers;
|
||||
using Microsoft.CmdPal.Ext.WindowWalker.Properties;
|
||||
@@ -19,33 +19,58 @@ internal static class ResultHelper
|
||||
/// <summary>
|
||||
/// Returns a list of all results for the query.
|
||||
/// </summary>
|
||||
/// <param name="searchControllerResults">List with all search controller matches</param>
|
||||
/// <param name="scoredWindows">List with all search controller matches</param>
|
||||
/// <returns>List of results</returns>
|
||||
internal static List<WindowWalkerListItem> GetResultList(List<SearchResult> searchControllerResults, bool isKeywordSearch)
|
||||
internal static WindowWalkerListItem[] GetResultList(ICollection<Scored<Window>>? scoredWindows)
|
||||
{
|
||||
if (searchControllerResults is null || searchControllerResults.Count == 0)
|
||||
if (scoredWindows is null || scoredWindows.Count == 0)
|
||||
{
|
||||
return [];
|
||||
}
|
||||
|
||||
var resultsList = new List<WindowWalkerListItem>(searchControllerResults.Count);
|
||||
var addExplorerInfo = searchControllerResults.Any(x =>
|
||||
string.Equals(x.Result.Process.Name, "explorer.exe", StringComparison.OrdinalIgnoreCase) &&
|
||||
x.Result.Process.IsShellProcess);
|
||||
var list = scoredWindows as IList<Scored<Window>> ?? new List<Scored<Window>>(scoredWindows);
|
||||
|
||||
// Process each SearchResult to convert it into a Result.
|
||||
// Using parallel processing if the operation is CPU-bound and the list is large.
|
||||
resultsList = searchControllerResults
|
||||
.AsParallel()
|
||||
.Select(x => CreateResultFromSearchResult(x))
|
||||
.ToList();
|
||||
var addExplorerInfo = false;
|
||||
for (var i = 0; i < list.Count; i++)
|
||||
{
|
||||
var window = list[i].Item;
|
||||
if (window?.Process is null)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (string.Equals(window.Process.Name, "explorer.exe", StringComparison.OrdinalIgnoreCase) && window.Process.IsShellProcess)
|
||||
{
|
||||
addExplorerInfo = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
var projected = new WindowWalkerListItem[list.Count];
|
||||
if (list.Count >= 32)
|
||||
{
|
||||
Parallel.For(0, list.Count, i =>
|
||||
{
|
||||
projected[i] = CreateResultFromSearchResult(list[i]);
|
||||
});
|
||||
}
|
||||
else
|
||||
{
|
||||
for (var i = 0; i < list.Count; i++)
|
||||
{
|
||||
projected[i] = CreateResultFromSearchResult(list[i]);
|
||||
}
|
||||
}
|
||||
|
||||
if (addExplorerInfo && !SettingsManager.Instance.HideExplorerSettingInfo)
|
||||
{
|
||||
resultsList.Insert(0, GetExplorerInfoResult());
|
||||
var withInfo = new WindowWalkerListItem[projected.Length + 1];
|
||||
withInfo[0] = GetExplorerInfoResult();
|
||||
Array.Copy(projected, 0, withInfo, 1, projected.Length);
|
||||
return withInfo;
|
||||
}
|
||||
|
||||
return resultsList;
|
||||
return projected;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -53,16 +78,15 @@ internal static class ResultHelper
|
||||
/// </summary>
|
||||
/// <param name="searchResult">The SearchResult object to convert.</param>
|
||||
/// <returns>A Result object populated with data from the SearchResult.</returns>
|
||||
private static WindowWalkerListItem CreateResultFromSearchResult(SearchResult searchResult)
|
||||
private static WindowWalkerListItem CreateResultFromSearchResult(Scored<Window> searchResult)
|
||||
{
|
||||
var item = new WindowWalkerListItem(searchResult.Result)
|
||||
var item = new WindowWalkerListItem(searchResult.Item)
|
||||
{
|
||||
Title = searchResult.Result.Title,
|
||||
Subtitle = GetSubtitle(searchResult.Result),
|
||||
Tags = GetTags(searchResult.Result),
|
||||
Title = searchResult.Item.Title,
|
||||
Subtitle = GetSubtitle(searchResult.Item),
|
||||
Tags = GetTags(searchResult.Item),
|
||||
};
|
||||
item.MoreCommands = ContextMenuHelper.GetContextMenuResults(item).ToArray();
|
||||
|
||||
return item;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,150 +0,0 @@
|
||||
// Copyright (c) Microsoft Corporation
|
||||
// The Microsoft Corporation licenses this file to you under the MIT license.
|
||||
// See the LICENSE file in the project root for more information.
|
||||
|
||||
// Code forked from Betsegaw Tadele's https://github.com/betsegaw/windowwalker/
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Globalization;
|
||||
using System.Linq;
|
||||
using Microsoft.CmdPal.Ext.WindowWalker.Helpers;
|
||||
|
||||
namespace Microsoft.CmdPal.Ext.WindowWalker.Components;
|
||||
|
||||
/// <summary>
|
||||
/// Responsible for searching and finding matches for the strings provided.
|
||||
/// Essentially the UI independent model of the application
|
||||
/// </summary>
|
||||
internal sealed class SearchController
|
||||
{
|
||||
/// <summary>
|
||||
/// the current search text
|
||||
/// </summary>
|
||||
private string searchText;
|
||||
|
||||
/// <summary>
|
||||
/// Open window search results
|
||||
/// </summary>
|
||||
private List<SearchResult>? searchMatches;
|
||||
|
||||
/// <summary>
|
||||
/// Singleton pattern
|
||||
/// </summary>
|
||||
private static SearchController? instance;
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the current search text
|
||||
/// </summary>
|
||||
internal string SearchText
|
||||
{
|
||||
get => searchText;
|
||||
|
||||
set =>
|
||||
searchText = value.ToLower(CultureInfo.CurrentCulture).Trim();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the open window search results
|
||||
/// </summary>
|
||||
internal List<SearchResult> SearchMatches => new List<SearchResult>(searchMatches ?? []).OrderByDescending(x => x.Score).ToList();
|
||||
|
||||
/// <summary>
|
||||
/// Gets singleton Pattern
|
||||
/// </summary>
|
||||
internal static SearchController Instance
|
||||
{
|
||||
get
|
||||
{
|
||||
instance ??= new SearchController();
|
||||
|
||||
return instance;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="SearchController"/> class.
|
||||
/// Initializes the search controller object
|
||||
/// </summary>
|
||||
private SearchController()
|
||||
{
|
||||
searchText = string.Empty;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Event handler for when the search text has been updated
|
||||
/// </summary>
|
||||
internal void UpdateSearchText(string searchText)
|
||||
{
|
||||
SearchText = searchText;
|
||||
SyncOpenWindowsWithModel();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Syncs the open windows with the OpenWindows Model
|
||||
/// </summary>
|
||||
internal void SyncOpenWindowsWithModel()
|
||||
{
|
||||
System.Diagnostics.Debug.Print("Syncing WindowSearch result with OpenWindows Model");
|
||||
|
||||
var snapshotOfOpenWindows = OpenWindows.Instance.Windows;
|
||||
|
||||
searchMatches = string.IsNullOrWhiteSpace(SearchText) ? AllOpenWindows(snapshotOfOpenWindows) : FuzzySearchOpenWindows(snapshotOfOpenWindows);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Search method that matches the title of windows with the user search text
|
||||
/// </summary>
|
||||
/// <param name="openWindows">what windows are open</param>
|
||||
/// <returns>Returns search results</returns>
|
||||
private List<SearchResult> FuzzySearchOpenWindows(List<Window> openWindows)
|
||||
{
|
||||
List<SearchResult> result = [];
|
||||
var searchStrings = new SearchString(searchText, SearchResult.SearchType.Fuzzy);
|
||||
|
||||
foreach (var window in openWindows)
|
||||
{
|
||||
var titleMatch = FuzzyMatching.FindBestFuzzyMatch(window.Title, searchStrings.SearchText);
|
||||
var processMatch = FuzzyMatching.FindBestFuzzyMatch(window.Process.Name ?? string.Empty, searchStrings.SearchText);
|
||||
|
||||
if ((titleMatch.Count != 0 || processMatch.Count != 0) && window.Title.Length != 0)
|
||||
{
|
||||
result.Add(new SearchResult(window, titleMatch, processMatch, searchStrings.SearchType));
|
||||
}
|
||||
}
|
||||
|
||||
System.Diagnostics.Debug.Print("Found " + result.Count + " windows that match the search text");
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Search method that matches all the windows with a title
|
||||
/// </summary>
|
||||
/// <param name="openWindows">what windows are open</param>
|
||||
/// <returns>Returns search results</returns>
|
||||
private List<SearchResult> AllOpenWindows(List<Window> openWindows)
|
||||
{
|
||||
List<SearchResult> result = [];
|
||||
|
||||
foreach (var window in openWindows)
|
||||
{
|
||||
if (window.Title.Length != 0)
|
||||
{
|
||||
result.Add(new SearchResult(window));
|
||||
}
|
||||
}
|
||||
|
||||
return SettingsManager.Instance.InMruOrder
|
||||
? result.ToList()
|
||||
: result
|
||||
.OrderBy(w => w.Result.Title)
|
||||
.ToList();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Event args for a window list update event
|
||||
/// </summary>
|
||||
internal sealed class SearchResultUpdateEventArgs : EventArgs
|
||||
{
|
||||
}
|
||||
}
|
||||
@@ -1,147 +0,0 @@
|
||||
// Copyright (c) Microsoft Corporation
|
||||
// The Microsoft Corporation licenses this file to you under the MIT license.
|
||||
// See the LICENSE file in the project root for more information.
|
||||
|
||||
// Code forked from Betsegaw Tadele's https://github.com/betsegaw/windowwalker/
|
||||
using System.Collections.Generic;
|
||||
|
||||
namespace Microsoft.CmdPal.Ext.WindowWalker.Components;
|
||||
|
||||
/// <summary>
|
||||
/// Contains search result windows with each window including the reason why the result was included
|
||||
/// </summary>
|
||||
internal sealed class SearchResult
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets the actual window reference for the search result
|
||||
/// </summary>
|
||||
internal Window Result
|
||||
{
|
||||
get;
|
||||
private set;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the list of indexes of the matching characters for the search in the title window
|
||||
/// </summary>
|
||||
internal List<int> SearchMatchesInTitle
|
||||
{
|
||||
get;
|
||||
private set;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the list of indexes of the matching characters for the search in the
|
||||
/// name of the process
|
||||
/// </summary>
|
||||
internal List<int> SearchMatchesInProcessName
|
||||
{
|
||||
get;
|
||||
private set;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the type of match (shortcut, fuzzy or nothing)
|
||||
/// </summary>
|
||||
internal SearchType SearchResultMatchType
|
||||
{
|
||||
get;
|
||||
private set;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets a score indicating how well this matches what we are looking for
|
||||
/// </summary>
|
||||
internal int Score
|
||||
{
|
||||
get;
|
||||
private set;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the source of where the best score was found
|
||||
/// </summary>
|
||||
internal TextType BestScoreSource
|
||||
{
|
||||
get;
|
||||
private set;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="SearchResult"/> class.
|
||||
/// Constructor
|
||||
/// </summary>
|
||||
internal SearchResult(Window window, List<int> matchesInTitle, List<int> matchesInProcessName, SearchType matchType)
|
||||
{
|
||||
Result = window;
|
||||
SearchMatchesInTitle = matchesInTitle;
|
||||
SearchMatchesInProcessName = matchesInProcessName;
|
||||
SearchResultMatchType = matchType;
|
||||
CalculateScore();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="SearchResult"/> class.
|
||||
/// </summary>
|
||||
internal SearchResult(Window window)
|
||||
{
|
||||
Result = window;
|
||||
SearchMatchesInTitle = new List<int>();
|
||||
SearchMatchesInProcessName = new List<int>();
|
||||
SearchResultMatchType = SearchType.Empty;
|
||||
CalculateScore();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Calculates the score for how closely this window matches the search string
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Higher Score is better
|
||||
/// </remarks>
|
||||
private void CalculateScore()
|
||||
{
|
||||
if (FuzzyMatching.CalculateScoreForMatches(SearchMatchesInProcessName) >
|
||||
FuzzyMatching.CalculateScoreForMatches(SearchMatchesInTitle))
|
||||
{
|
||||
Score = FuzzyMatching.CalculateScoreForMatches(SearchMatchesInProcessName);
|
||||
BestScoreSource = TextType.ProcessName;
|
||||
}
|
||||
else
|
||||
{
|
||||
Score = FuzzyMatching.CalculateScoreForMatches(SearchMatchesInTitle);
|
||||
BestScoreSource = TextType.WindowTitle;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The type of text that a string represents
|
||||
/// </summary>
|
||||
internal enum TextType
|
||||
{
|
||||
ProcessName,
|
||||
WindowTitle,
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The type of search
|
||||
/// </summary>
|
||||
internal enum SearchType
|
||||
{
|
||||
/// <summary>
|
||||
/// the search string is empty, which means all open windows are
|
||||
/// going to be returned
|
||||
/// </summary>
|
||||
Empty,
|
||||
|
||||
/// <summary>
|
||||
/// Regular fuzzy match search
|
||||
/// </summary>
|
||||
Fuzzy,
|
||||
|
||||
/// <summary>
|
||||
/// The user has entered text that has been matched to a shortcut
|
||||
/// and the shortcut is now being searched
|
||||
/// </summary>
|
||||
Shortcut,
|
||||
}
|
||||
}
|
||||
@@ -1,45 +0,0 @@
|
||||
// Copyright (c) Microsoft Corporation
|
||||
// The Microsoft Corporation licenses this file to you under the MIT license.
|
||||
// See the LICENSE file in the project root for more information.
|
||||
|
||||
// Code forked from Betsegaw Tadele's https://github.com/betsegaw/windowwalker/
|
||||
namespace Microsoft.CmdPal.Ext.WindowWalker.Components;
|
||||
|
||||
/// <summary>
|
||||
/// A class to represent a search string
|
||||
/// </summary>
|
||||
/// <remarks>Class was added in order to be able to attach various context data to
|
||||
/// a search string</remarks>
|
||||
internal sealed class SearchString
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets where is the search string coming from (is it a shortcut
|
||||
/// or direct string, etc...)
|
||||
/// </summary>
|
||||
internal SearchResult.SearchType SearchType
|
||||
{
|
||||
get;
|
||||
private set;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the actual text we are searching for
|
||||
/// </summary>
|
||||
internal string SearchText
|
||||
{
|
||||
get;
|
||||
private set;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="SearchString"/> class.
|
||||
/// Constructor
|
||||
/// </summary>
|
||||
/// <param name="searchText">text from search</param>
|
||||
/// <param name="searchType">type of search</param>
|
||||
internal SearchString(string searchText, SearchResult.SearchType searchType)
|
||||
{
|
||||
SearchText = searchText;
|
||||
SearchType = searchType;
|
||||
}
|
||||
}
|
||||
@@ -3,9 +3,8 @@
|
||||
// See the LICENSE file in the project root for more information.
|
||||
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Globalization;
|
||||
using Microsoft.CmdPal.Ext.WindowWalker.Components;
|
||||
using Microsoft.CmdPal.Ext.WindowWalker.Helpers;
|
||||
using Microsoft.CmdPal.Ext.WindowWalker.Properties;
|
||||
using Microsoft.CommandPalette.Extensions;
|
||||
using Microsoft.CommandPalette.Extensions.Toolkit;
|
||||
@@ -33,10 +32,12 @@ internal sealed partial class WindowWalkerListPage : DynamicListPage, IDisposabl
|
||||
};
|
||||
}
|
||||
|
||||
public override void UpdateSearchText(string oldSearch, string newSearch) =>
|
||||
public override void UpdateSearchText(string oldSearch, string newSearch)
|
||||
{
|
||||
RaiseItemsChanged(0);
|
||||
}
|
||||
|
||||
public List<WindowWalkerListItem> Query(string query)
|
||||
private WindowWalkerListItem[] Query(string query)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(query);
|
||||
|
||||
@@ -46,13 +47,37 @@ internal sealed partial class WindowWalkerListPage : DynamicListPage, IDisposabl
|
||||
|
||||
WindowWalkerCommandsProvider.VirtualDesktopHelperInstance.UpdateDesktopList();
|
||||
OpenWindows.Instance.UpdateOpenWindowsList(_cancellationTokenSource.Token);
|
||||
SearchController.Instance.UpdateSearchText(query);
|
||||
var searchControllerResults = SearchController.Instance.SearchMatches;
|
||||
|
||||
return ResultHelper.GetResultList(searchControllerResults, !string.IsNullOrEmpty(query));
|
||||
var windows = OpenWindows.Instance.Windows;
|
||||
|
||||
if (string.IsNullOrWhiteSpace(query))
|
||||
{
|
||||
if (!SettingsManager.Instance.InMruOrder)
|
||||
{
|
||||
windows.Sort(static (a, b) => string.Compare(a?.Title, b?.Title, StringComparison.OrdinalIgnoreCase));
|
||||
}
|
||||
|
||||
var results = new Scored<Window>[windows.Count];
|
||||
for (var i = 0; i < windows.Count; i++)
|
||||
{
|
||||
results[i] = new Scored<Window> { Item = windows[i], Score = 100 };
|
||||
}
|
||||
|
||||
return ResultHelper.GetResultList(results);
|
||||
}
|
||||
|
||||
var scored = ListHelpers.FilterListWithScores(windows, query, ScoreFunction);
|
||||
return ResultHelper.GetResultList([.. scored]);
|
||||
}
|
||||
|
||||
public override IListItem[] GetItems() => Query(SearchText).ToArray();
|
||||
private static int ScoreFunction(string q, Window window)
|
||||
{
|
||||
var titleScore = FuzzyStringMatcher.ScoreFuzzy(q, window.Title);
|
||||
var processNameScore = FuzzyStringMatcher.ScoreFuzzy(q, window.Process?.Name ?? string.Empty);
|
||||
return Math.Max(titleScore, processNameScore);
|
||||
}
|
||||
|
||||
public override IListItem[] GetItems() => Query(SearchText);
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -56,7 +56,7 @@
|
||||
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
|
||||
</None>
|
||||
</ItemGroup>
|
||||
|
||||
|
||||
<ItemGroup>
|
||||
<Content Include="$(RepoRoot)$(Platform)\$(Configuration)\Microsoft.CommandPalette.Extensions\Microsoft.CommandPalette.Extensions.winmd" Link="Microsoft.CommandPalette.Extensions.winmd" CopyToOutputDirectory="PreserveNewest" />
|
||||
</ItemGroup>
|
||||
@@ -83,4 +83,8 @@
|
||||
<!-- Suppress DynamicallyAccessedMemberTypes.PublicParameterlessConstructor in fallback code path of Windows SDK projection -->
|
||||
<WarningsNotAsErrors>IL2081;$(WarningsNotAsErrors)</WarningsNotAsErrors>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<InternalsVisibleTo Include="Microsoft.CommandPalette.Extensions.Toolkit.UnitTests, PublicKey=002400000c80000014010000060200000024000052534131000800000100010085aad0bef0688d1b994a0d78e1fd29fc24ac34ed3d3ac3fb9b3d0c48386ba834aa880035060a8848b2d8adf58e670ed20914be3681a891c9c8c01eef2ab22872547c39be00af0e6c72485d7cfd1a51df8947d36ceba9989106b58abe79e6a3e71a01ed6bdc867012883e0b1a4d35b1b5eeed6df21e401bb0c22f2246ccb69979dc9e61eef262832ed0f2064853725a75485fa8a3efb7e027319c86dec03dc3b1bca2b5081bab52a627b9917450dfad534799e1c7af58683bdfa135f1518ff1ea60e90d7b993a6c87fd3dd93408e35d1296f9a7f9a97c5db56c0f3cc25ad11e9777f94d138b3cea53b9a8331c2e6dcb8d2ea94e18bf1163ff112a22dbd92d429a" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
|
||||
@@ -38,6 +38,24 @@
|
||||
</tkcontrols:SettingsCard>
|
||||
</tkcontrols:SettingsExpander.Items>
|
||||
</tkcontrols:SettingsExpander>
|
||||
<tkcontrols:SettingsExpander
|
||||
x:Uid="AlwaysOnTop_TransparencyInfo"
|
||||
HeaderIcon="{ui:FontIcon Glyph=}"
|
||||
IsExpanded="True">
|
||||
<tkcontrols:SettingsExpander.Items>
|
||||
<tkcontrols:SettingsCard ContentAlignment="Left">
|
||||
<controls:ShortcutWithTextLabelControl x:Uid="AlwaysOnTop_IncreaseOpacity" Keys="{x:Bind ViewModel.IncreaseOpacityKeysList, Mode=OneWay}" />
|
||||
</tkcontrols:SettingsCard>
|
||||
<tkcontrols:SettingsCard ContentAlignment="Left">
|
||||
<controls:ShortcutWithTextLabelControl x:Uid="AlwaysOnTop_DecreaseOpacity" Keys="{x:Bind ViewModel.DecreaseOpacityKeysList, Mode=OneWay}" />
|
||||
</tkcontrols:SettingsCard>
|
||||
<tkcontrols:SettingsCard>
|
||||
<tkcontrols:SettingsCard.Description>
|
||||
<TextBlock x:Uid="AlwaysOnTop_TransparencyRange" Style="{StaticResource SecondaryTextStyle}" />
|
||||
</tkcontrols:SettingsCard.Description>
|
||||
</tkcontrols:SettingsCard>
|
||||
</tkcontrols:SettingsExpander.Items>
|
||||
</tkcontrols:SettingsExpander>
|
||||
</controls:SettingsGroup>
|
||||
|
||||
<controls:SettingsGroup x:Uid="AlwaysOnTop_Behavior_GroupSettings" IsEnabled="{x:Bind ViewModel.IsEnabled, Mode=OneWay}">
|
||||
|
||||
@@ -3240,7 +3240,19 @@ Right-click to remove the key combination, thereby deactivating the shortcut.</v
|
||||
<value>Activation shortcut</value>
|
||||
</data>
|
||||
<data name="AlwaysOnTop_ActivationShortcut.Description" xml:space="preserve">
|
||||
<value>Customize the shortcut to pin or unpin an app window</value>
|
||||
<value>Customize the shortcut to pin or unpin an app window. Use the same modifier keys with + or - to adjust window transparency.</value>
|
||||
</data>
|
||||
<data name="AlwaysOnTop_TransparencyInfo.Header" xml:space="preserve">
|
||||
<value>Transparency adjustment</value>
|
||||
</data>
|
||||
<data name="AlwaysOnTop_IncreaseOpacity.Text" xml:space="preserve">
|
||||
<value>Increase opacity</value>
|
||||
</data>
|
||||
<data name="AlwaysOnTop_DecreaseOpacity.Text" xml:space="preserve">
|
||||
<value>Decrease opacity</value>
|
||||
</data>
|
||||
<data name="AlwaysOnTop_TransparencyRange.Text" xml:space="preserve">
|
||||
<value>Range: 20%-100%</value>
|
||||
</data>
|
||||
<data name="Oobe_AlwaysOnTop.Title" xml:space="preserve">
|
||||
<value>Always On Top</value>
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Globalization;
|
||||
using System.Linq;
|
||||
using System.Runtime.CompilerServices;
|
||||
using System.Text.Json;
|
||||
using global::PowerToys.GPOWrapper;
|
||||
@@ -133,6 +134,10 @@ namespace Microsoft.PowerToys.Settings.UI.ViewModels
|
||||
Settings.Properties.Hotkey.Value = _hotkey;
|
||||
NotifyPropertyChanged();
|
||||
|
||||
// Also notify that transparency keys have changed
|
||||
OnPropertyChanged(nameof(IncreaseOpacityKeysList));
|
||||
OnPropertyChanged(nameof(DecreaseOpacityKeysList));
|
||||
|
||||
// Using InvariantCulture as this is an IPC message
|
||||
SendConfigMSG(
|
||||
string.Format(
|
||||
@@ -289,6 +294,62 @@ namespace Microsoft.PowerToys.Settings.UI.ViewModels
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the keys list for increasing window opacity (modifier keys + "+").
|
||||
/// </summary>
|
||||
public List<object> IncreaseOpacityKeysList
|
||||
{
|
||||
get
|
||||
{
|
||||
var keys = GetModifierKeysList();
|
||||
keys.Add("+");
|
||||
return keys;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the keys list for decreasing window opacity (modifier keys + "-").
|
||||
/// </summary>
|
||||
public List<object> DecreaseOpacityKeysList
|
||||
{
|
||||
get
|
||||
{
|
||||
var keys = GetModifierKeysList();
|
||||
keys.Add("-");
|
||||
return keys;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets only the modifier keys from the current hotkey setting.
|
||||
/// </summary>
|
||||
private List<object> GetModifierKeysList()
|
||||
{
|
||||
var modifierKeys = new List<object>();
|
||||
|
||||
if (_hotkey.Win)
|
||||
{
|
||||
modifierKeys.Add(92); // The Windows key
|
||||
}
|
||||
|
||||
if (_hotkey.Ctrl)
|
||||
{
|
||||
modifierKeys.Add("Ctrl");
|
||||
}
|
||||
|
||||
if (_hotkey.Alt)
|
||||
{
|
||||
modifierKeys.Add("Alt");
|
||||
}
|
||||
|
||||
if (_hotkey.Shift)
|
||||
{
|
||||
modifierKeys.Add(16); // The Shift key
|
||||
}
|
||||
|
||||
return modifierKeys;
|
||||
}
|
||||
|
||||
public void NotifyPropertyChanged([CallerMemberName] string propertyName = null)
|
||||
{
|
||||
OnPropertyChanged(propertyName);
|
||||
|
||||
Reference in New Issue
Block a user