mirror of
https://github.com/microsoft/PowerToys.git
synced 2026-01-31 00:17:23 +01:00
Compare commits
7 Commits
leilzh/fix
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
055c3011cc | ||
|
|
2f7fc91956 | ||
|
|
6d4f56cd83 | ||
|
|
4986915dae | ||
|
|
cc2dce8816 | ||
|
|
0de2af77ac | ||
|
|
4694e99477 |
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$
|
||||
|
||||
4
.github/actions/spell-check/expect.txt
vendored
4
.github/actions/spell-check/expect.txt
vendored
@@ -647,6 +647,8 @@ GSM
|
||||
gtm
|
||||
guiddata
|
||||
GUITHREADINFO
|
||||
Gotcha
|
||||
Gotchas
|
||||
GValue
|
||||
gwl
|
||||
GWLP
|
||||
@@ -1532,6 +1534,7 @@ riid
|
||||
RKey
|
||||
RNumber
|
||||
rollups
|
||||
ROOTOWNER
|
||||
rop
|
||||
ROUNDSMALL
|
||||
ROWSETEXT
|
||||
@@ -1826,6 +1829,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">
|
||||
|
||||
311
doc/devdocs/development/new-powertoy.md
Normal file
311
doc/devdocs/development/new-powertoy.md
Normal file
@@ -0,0 +1,311 @@
|
||||
# 🧭 Creating a new PowerToy: end-to-end developer guide
|
||||
|
||||
First of all, thank you for wanting to contribute to PowerToys. The work we do would not be possible without the support of community supporters like you.
|
||||
|
||||
This guide documents the process of building a new PowerToys utility from scratch, including architecture decisions, integration steps, and common pitfalls.
|
||||
|
||||
---
|
||||
|
||||
## 1. Overview and prerequisites
|
||||
|
||||
A PowerToy module is a self-contained utility integrated into the PowerToys ecosystem. It can be UI-based, service-based, or both.
|
||||
|
||||
### Requirements
|
||||
|
||||
- [Visual Studio 2026](https://visualstudio.microsoft.com/downloads/) and the following workloads/individual components:
|
||||
- Desktop Development with C++
|
||||
- WinUI application development
|
||||
- .NET desktop development
|
||||
- Windows 10 SDK (10.0.22621.0)
|
||||
- Windows 11 SDK (10.0.26100.3916)
|
||||
- .NET 8 SDK
|
||||
- Fork the [PowerToys repository](https://github.com/microsoft/PowerToys/tree/main) locally
|
||||
- [Validate that you are able to build and run](https://github.com/microsoft/PowerToys/blob/main/doc/devdocs/development/debugging.md) `PowerToys.slnx`.
|
||||
|
||||
Optional:
|
||||
- [WiX v5 toolset](https://github.com/microsoft/PowerToys/tree/main) for the installer
|
||||
|
||||
> [!NOTE]
|
||||
> To ensure all the correct VS Workloads are installed, use [the WinGet configuration files](https://github.com/microsoft/PowerToys/tree/e13d6a78aafbcf32a4bb5f8581d041e1d057c3f1/.config) in the project repository. (Use the one that matches your VS distribution. ie: VS Community would use `configuration.winget`)
|
||||
|
||||
### Folder structure
|
||||
|
||||
```
|
||||
src/
|
||||
modules/
|
||||
your_module/
|
||||
YourModule.sln
|
||||
YourModuleInterface/
|
||||
YourModuleUI/ (if needed)
|
||||
YourModuleService/ (if needed)
|
||||
```
|
||||
|
||||
---
|
||||
## 2. Design and planning
|
||||
|
||||
### Decide the type of module
|
||||
|
||||
Think about how your module works and which existing modules behave similarly. You are going to want to think about the UI needed for the application, the lifecycle, whether it is a service that is always running or event based. Below are some basic scenarios with some modules to explore. You can write your application in C++ or C#.
|
||||
- **UI-only:** e.g., ColorPicker
|
||||
- **Background service:** e.g., LightSwitch, Awake
|
||||
- **Hybrid (UI + background logic):** e.g., ShortcutGuide
|
||||
- **C++/C# interop:** e.g., PowerRename
|
||||
|
||||
### Write your module interface
|
||||
|
||||
Begin by setting up the [PowerToy module template project](https://github.com/microsoft/PowerToys/tree/main/tools/project_template). This will generate boilerplate for you to begin your new module. Below are the key headers in the Module Interface (`dllmain.cpp`) and an explanation of their purpose:
|
||||
1. This is where module settings are defined. These can be anything from strings, bools, ints, and even custom Enums.
|
||||
```c++
|
||||
struct ModuleSettings {};
|
||||
```
|
||||
|
||||
2. This is the header for the full class. It inherits the PowerToyModuleIface
|
||||
```c++
|
||||
class ModuleInterface : public PowertoyModuleIface
|
||||
{
|
||||
private:
|
||||
// the private members of the class
|
||||
// Can include the enabled variable, logic for event handlers, or hotkeys.
|
||||
public:
|
||||
// the public members of the class
|
||||
// Will include the constructor and initialization logic.
|
||||
}
|
||||
```
|
||||
|
||||
> [!NOTE]
|
||||
> Many of the class functions are boilerplate and need simple string replacements with your module name. The rest of the functions below will require bigger changes.
|
||||
|
||||
3. GPO stands for "Group Policy Object" and allows for administrators to configure settings across a network of machines. It is required that your module is on this list of settings. You can right click the `powertoys_gpo` object to go to the definition and set up the `getConfiguredModuleEnabledValue` for your module.
|
||||
```c++
|
||||
virtual powertoys_gpo::gpo_rule_configured_t gpo_policy_enabled_configuration() override
|
||||
{
|
||||
return powertoys_gpo::getConfiguredModuleEnabledValue();
|
||||
}
|
||||
```
|
||||
|
||||
4. `init_settings()` initializes the settings for the interface. Will either pull from existing settings.json or use defaults.
|
||||
```c++
|
||||
void ModuleInterface::init_settings()
|
||||
```
|
||||
|
||||
5. `get_config` retrieves the settings from the settings.json file.
|
||||
```c++
|
||||
virtual bool get_config(wchar_t* buffer, int* buffer_size) override
|
||||
```
|
||||
|
||||
6. `set_config` sets the new settings to the settings.json file.
|
||||
```c++
|
||||
virtual void set_config(const wchar_t* config) override
|
||||
```
|
||||
|
||||
7. `call_custom_action` allows custom actions to be called based on signals from the settings app.
|
||||
```c++
|
||||
void call_custom_action(const wchar_t* action) override
|
||||
```
|
||||
|
||||
8. Lifecycle events control whether the module is enabled or not, as well as the default status of the module.
|
||||
```c++
|
||||
virtual void enable() // starts the module
|
||||
virtual void disable() // terminates the module and performs any cleanup
|
||||
virtual bool is_enabled() // returns if the module is currently enabled
|
||||
virtual bool is_enabled_by_default() const override // allows the module to dictate whether it should be enabled by default in the PowerToys app.
|
||||
```
|
||||
|
||||
9. Hotkey functions control the status of the hotkey.
|
||||
```c++
|
||||
// takes the hotkey from settings into a format that the interface can understand
|
||||
void parse_hotkey(PowerToysSettings::PowerToyValues& settings)
|
||||
|
||||
// returns the hotkeys from settings
|
||||
virtual size_t get_hotkeys(Hotkey* hotkeys, size_t buffer_size) override
|
||||
|
||||
// performs logic when the hotkey event is fired
|
||||
virtual bool on_hotkey(size_t hotkeyId) override
|
||||
```
|
||||
|
||||
### Notes
|
||||
|
||||
- Keep module logic isolated under `/modules/<YourModule>`
|
||||
- Use shared utilities from [`common`](https://github.com/microsoft/PowerToys/tree/main/src/common) instead of cross-module dependencies
|
||||
- init/set/get config use preset functions to access the settings. Check out the [`settings_objects.h`](https://github.com/microsoft/PowerToys/blob/main/src/common/SettingsAPI/settings_helpers.h) in `src\common\SettingsAPI`
|
||||
|
||||
---
|
||||
## 3. Bootstrapping your module
|
||||
|
||||
1. Use the [template](https://github.com/microsoft/PowerToys/tree/main/tools/project_template) to generate the module interface starter code.
|
||||
2. Update all projects and namespaces with your module name.
|
||||
3. Update GUIDs in `.vcxproj` and solution files.
|
||||
4. Update the functions mentioned in the above section with your custom logic.
|
||||
5. In order for your module to be detected by the runner you are required to add references to various lists. In order to register your module, add the corresponding module reference to the lists that can be found in the following files. (Hint: search other modules names to find the lists quicker)
|
||||
- `src/runner/modules.h`
|
||||
- `src/runner/modules.cpp`
|
||||
- `src/runner/resource.h`
|
||||
- `src/runner/settings_window.h`
|
||||
- `src/runner/settings_window.cpp`
|
||||
- `src/runner/main.cpp`
|
||||
- `src/common/logger.h` (for logging)
|
||||
6. ModuleInterface should build your `ModuleInterface.dll`. This will allow the runner to interact with your service.
|
||||
|
||||
> [!TIP]
|
||||
> Mismatched module IDs are one of the most common causes of load failures. Keep your ID consistent across manifest, registry, and service.
|
||||
|
||||
---
|
||||
## 4. Write your service
|
||||
|
||||
This is going to look different for every PowerToy. It may be easier to develop the application independently, and then link in the PowerToys settings logic later. But you have to write the service first, before connecting it to the runner.
|
||||
|
||||
### Notes
|
||||
|
||||
- This is a separate project from the Module Interface.
|
||||
- You can develop this project using C# or C++.
|
||||
- Set the service icon using the `.rc` file.
|
||||
- Set the service name in the `.vcxproj` by setting the `<TargetName>`
|
||||
```
|
||||
<PropertyGroup>
|
||||
<OutDir>..\..\..\..\$(Platform)\$(Configuration)\$(MSBuildProjectName)\</OutDir>
|
||||
<TargetName>PowerToys.LightSwitchService</TargetName>
|
||||
</PropertyGroup>
|
||||
```
|
||||
- To view the code of the `.vcxproj`, right click the item and select **Unload project**
|
||||
- Use the following functions to interact with settings from your service
|
||||
```
|
||||
ModuleSettings::instance().InitFileWatcher();
|
||||
ModuleSettings::instance().LoadSettings();
|
||||
auto& settings = ModuleSettings::instance().settings();
|
||||
```
|
||||
These come from the `ModuleSettings.h` file that lives with the Service. You can copy this from another module (e.g., Light Switch) and adjust to fit your needs.
|
||||
|
||||
If your module has a user interface:
|
||||
- Use the **WinUI Blank App** template when setting up your project
|
||||
- Use [Windows design best practices](https://learn.microsoft.com/windows/apps/design/basics/)
|
||||
- Use the [WinUI 3 Gallery](https://apps.microsoft.com/detail/9p3jfpwwdzrc) for help with your UI code, and additional guidance.
|
||||
|
||||
## 5. Settings integration
|
||||
|
||||
PowerToys settings are stored per-module as JSON under:
|
||||
|
||||
```
|
||||
%LOCALAPPDATA%\Microsoft\PowerToys\<module>\settings.json
|
||||
```
|
||||
|
||||
### Implementation steps
|
||||
|
||||
- In `src\settings-ui\Settings.UI.Library\` create `<module>Properties.cs` and `<module>Settings.cs`
|
||||
- `<module>Properties.cs` is where you will define your defaults. Every setting needs to be represented here. This should match what was set in the Module Interface.
|
||||
- `<module>Settings.cs`is where your settings.json will be built from. The structure should match the following
|
||||
```cs
|
||||
public ModuleSettings()
|
||||
{
|
||||
Name = ModuleName;
|
||||
Version = Assembly.GetExecutingAssembly().GetName().Version.ToString();
|
||||
Properties = new ModuleProperties(); // settings properties you set above.
|
||||
}
|
||||
```
|
||||
|
||||
- In `src\settings-ui\Settings.UI\ViewModels` create `<module>ViewModel.cs` this is where the interaction happens between your settings page in the PowerToys app and the settings file that is stored on the device. Changes here will trigger the settings watcher via a `NotifyPropertyChanged` event.
|
||||
- Create a `SettingsPage.xaml` at `src\settings-ui\Settings.UI\SettingsXAML\Views`. This will be the page where the user interacts with the settings of your module.
|
||||
- Be sure to use resource strings for user facing strings so they can be localized. (`x:Uid` connects to Resources.resw)
|
||||
```xaml
|
||||
// LightSwitch.xaml
|
||||
<ComboBoxItem
|
||||
x:Uid="LightSwitch_ModeOff"
|
||||
AutomationProperties.AutomationId="OffCBItem_LightSwitch"
|
||||
Tag="Off" />
|
||||
|
||||
// Resources.resw
|
||||
<data name="LightSwitch_ModeOff.Content" xml:space="preserve">
|
||||
<value>Off</value>
|
||||
</data>
|
||||
```
|
||||
> [!IMPORTANT]
|
||||
> In the above example we use `.Content` to target the content of the Combobox. This can change per UI element (e.g., `.Text`, `.Header`, etc.)
|
||||
|
||||
> **Reminder:** Manual changes via external editors (VS Code, Notepad) do **not** trigger the settings watcher. Only changes written through PowerToys trigger reloads.
|
||||
|
||||
---
|
||||
|
||||
### Gotchas:
|
||||
|
||||
- Only use the WinUI 3 framework, _not_ UWP.
|
||||
- Use [`DispatcherQueue`](https://learn.microsoft.com/windows/apps/develop/dispatcherqueue) when updating UI from non-UI threads.
|
||||
|
||||
---
|
||||
## 6. Building and debugging
|
||||
|
||||
### Debugging steps
|
||||
|
||||
1. If this is your first time debugging PowerToys, be sure to follow [these steps first](https://github.com/microsoft/PowerToys/blob/main/doc/devdocs/development/debugging.md#pre-debugging-setup).
|
||||
2. Set "runner" as the start up project and ensure your build configuration is set to match your system (ARM64/x64)
|
||||
3. Select <kbd>F5</kbd> or the **Local Windows Debugger** button to begin debugging. This should start the PowerToys runner.
|
||||
4. To set breakpoints in your service, select Ctrl+Alt+P and search for your service to attach to the runner.
|
||||
5. Use logs to document changes. The logs live at `%LOCALAPPDATA%\Microsoft\PowerToys\RunnerLogs` and `%LOCALAPPDATA%\Microsoft\PowerToys\Module\Service\<version>` for the specific module.
|
||||
|
||||
> [!TIP]
|
||||
> PowerToys caches `.nuget` artifacts aggressively. Use `git clean -xfd` when builds behave unexpectedly.
|
||||
|
||||
---
|
||||
## 7. Installer and packaging (WiX)
|
||||
|
||||
### Add your module to installer
|
||||
|
||||
1. Install [`WixToolset.Heat`](https://www.nuget.org/packages/WixToolset.Heat/) for Wix5 via nuget
|
||||
2. Inside `installer\PowerToysInstallerVNext` add a new file for your module: `Module.wxs`
|
||||
3. Inside of this file you will need copy the format from another module (ie: Light Switch) and replace the strings and GUID values.
|
||||
4. The key part will be `<!--ModuleNameFiles_Component_Def-->` which is a placeholder for code that will be generated by `generateFileComponents.ps1`.
|
||||
5. Inside `Product.wxs` add a line item in the `<Feature Id="CoreFeature" ... >` section. It will look like a list of ` <ComponentGroupRef Id="ModuleComponentGroup" />` items.
|
||||
6. Inside `generateFileComponents.ps1` you will need to add an entry to the bottom for your new module. It will follow the following format. `-fileListName <Module>Files` will match the string you set in `Module.wxs`, `<ModuleServiceName>` will match the name of your exe.
|
||||
```bash
|
||||
# Module Name
|
||||
Generate-FileList -fileDepsJson "" -fileListName <Module>Files -wxsFilePath $PSScriptRoot\<Module>.wxs -depsPath "$PSScriptRoot..\..\..\$platform\Release\<ModuleServiceName>"
|
||||
Generate-FileComponents -fileListName "<Module>Files" -wxsFilePath $PSScriptRoot\<Module>.wxs -regroot $registryroot
|
||||
```
|
||||
---
|
||||
## 8. Testing and validation
|
||||
|
||||
### UI tests
|
||||
|
||||
- Place under `/modules/<YourModule>/Tests`
|
||||
- Create a new [WinUI Unit Test App](https://learn.microsoft.com/windows/apps/winui/winui3/testing/create-winui-unit-test-project)
|
||||
- Write unit tests following the format from previous modules (ie: Light Switch). This can be to test your standalone UI (if you're a module like Color Picker) or to verify that the Settings UI in the PowerToys app is controlling your service.
|
||||
|
||||
### Manual validation
|
||||
|
||||
- Enable/disable in PowerToys Settings
|
||||
- Check initialization in logs
|
||||
- Confirm icons, tooltips, and OOBE page appear correctly
|
||||
|
||||
### Pro tips
|
||||
|
||||
1. Validate wake/sleep and elevation states. Background modules often fail silently after resume if event handles aren’t recreated.
|
||||
2. Use Windows Sandbox to simulate clean install environments
|
||||
3. To simulate a "new user" you can delete the PowerToys folder from `%LOCALAPPDATA%\Microsoft`
|
||||
|
||||
### Shortcut conflict detection
|
||||
|
||||
If your module has a shortcut, ensure that it is properly registered following [the steps listed in the documentation](https://github.com/microsoft/PowerToys/blob/main/doc/devdocs/core/settings/settings-implementation.md#shortcut-conflict-detection) for conflict detection.
|
||||
|
||||
---
|
||||
## 9. The final touches
|
||||
|
||||
### Out-of-Box experience (OOBE) page
|
||||
|
||||
The OOBE page is a custom settings page that gives the user at a glance information about each module. This window opens before the Settings application for new users and after updates. Create `OOBE<ModuleName>.xaml` at `src\settings-ui\Settings.UI\SettingsXAML\OOBE\Views`. You will also need to add your module name to the enum at `src\settings-ui\Settings.UI\OOBE\Enums\PowerToysModules.cs`.
|
||||
|
||||
### Module assets
|
||||
|
||||
Now that your PowerToy is _done_ you can start to think about the assets that will represent your module.
|
||||
- Module Icon: This will be displayed in a number of places: OOBE page, in the README, on the home screen of PowerToys, on your individual module settings page, etc.
|
||||
- Module Image: This is the image you see at the top of each individual settings page.
|
||||
- OOBE Image: This is the header you see on the OOBE page for each module
|
||||
|
||||
> [!NOTE]
|
||||
> This step is something that the Design team will handle internally to ensure consistency throughout the application. If you have ideas or recommendations on what the icon or screenshots should be for your module feel free to leave it in the "Additional Comments" section of the PR and the team will take it into consideration.
|
||||
|
||||
### Documentation
|
||||
|
||||
There are two types of documentation that will be required when submitting a new PowerToy:
|
||||
1. Developer documentation: This will live in the [PowerToys repo](https://github.com/microsoft/PowerToys/blob/main/doc/devdocs/modules) at `/doc/devdocs/modules/` and should tell a developer how to work on your app. It should outline the module architecture, key files, testing, and tips on debugging if necessary.
|
||||
2. Microsoft Learn documentation: When your new Module is ready to be merged into the PowerToys repository, an internal team member will create Microsoft Learn documentation so that users will understand how to use your module. There is not much work on your end as the developer for this step, but keep an eye on your PR in case we need more information about your PowerToy for this step.
|
||||
|
||||
---
|
||||
Thank you again for contributing! If you need help, feel free to [open an issue](https://github.com/microsoft/PowerToys/issues/new/choose) and use the `Needs-Team-Response` label so we know you need attention.
|
||||
@@ -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>
|
||||
|
||||
@@ -21,6 +21,7 @@
|
||||
|
||||
<StackPanel Orientation="Horizontal" Spacing="8">
|
||||
<Button
|
||||
x:Name="LaunchButton"
|
||||
x:Uid="Launch_ColorPicker"
|
||||
Click="Start_ColorPicker_Click"
|
||||
Style="{StaticResource AccentButtonStyle}" />
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
using System.Threading;
|
||||
|
||||
using Microsoft.PowerToys.Settings.UI.Library;
|
||||
using Microsoft.PowerToys.Settings.UI.Library.Helpers;
|
||||
using Microsoft.PowerToys.Settings.UI.OOBE.Enums;
|
||||
using Microsoft.PowerToys.Settings.UI.OOBE.ViewModel;
|
||||
using Microsoft.PowerToys.Settings.UI.Views;
|
||||
@@ -53,6 +54,10 @@ namespace Microsoft.PowerToys.Settings.UI.OOBE.Views
|
||||
ColorPickerSettings settings = SettingsUtils.Default.GetSettingsOrDefault<ColorPickerSettings, ColorPickerSettingsVersion1>(ColorPickerSettings.ModuleName, settingsUpgrader: ColorPickerSettings.UpgradeSettings);
|
||||
|
||||
HotkeyControl.Keys = settings.Properties.ActivationShortcut.GetKeysList();
|
||||
|
||||
// Disable the Launch button if the module is disabled
|
||||
var generalSettings = SettingsRepository<GeneralSettings>.GetInstance(SettingsUtils.Default).SettingsConfig;
|
||||
LaunchButton.IsEnabled = ModuleHelper.GetIsModuleEnabled(generalSettings, ManagedCommon.ModuleType.ColorPicker);
|
||||
}
|
||||
|
||||
protected override void OnNavigatedFrom(NavigationEventArgs e)
|
||||
|
||||
@@ -12,6 +12,7 @@
|
||||
<StackPanel Orientation="Vertical" Spacing="12">
|
||||
<StackPanel Orientation="Horizontal" Spacing="8">
|
||||
<Button
|
||||
x:Name="LaunchButton"
|
||||
x:Uid="Launch_EnvironmentVariables"
|
||||
Click="Launch_EnvironmentVariables_Click"
|
||||
Style="{StaticResource AccentButtonStyle}" />
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
using System.Threading;
|
||||
|
||||
using Microsoft.PowerToys.Settings.UI.Library;
|
||||
using Microsoft.PowerToys.Settings.UI.Library.Helpers;
|
||||
using Microsoft.PowerToys.Settings.UI.OOBE.Enums;
|
||||
using Microsoft.PowerToys.Settings.UI.OOBE.ViewModel;
|
||||
using Microsoft.PowerToys.Settings.UI.Views;
|
||||
@@ -28,6 +29,10 @@ namespace Microsoft.PowerToys.Settings.UI.OOBE.Views
|
||||
protected override void OnNavigatedTo(NavigationEventArgs e)
|
||||
{
|
||||
ViewModel.LogOpeningModuleEvent();
|
||||
|
||||
// Disable the Launch button if the module is disabled
|
||||
var generalSettings = SettingsRepository<GeneralSettings>.GetInstance(SettingsUtils.Default).SettingsConfig;
|
||||
LaunchButton.IsEnabled = ModuleHelper.GetIsModuleEnabled(generalSettings, ManagedCommon.ModuleType.EnvironmentVariables);
|
||||
}
|
||||
|
||||
protected override void OnNavigatedFrom(NavigationEventArgs e)
|
||||
|
||||
@@ -12,6 +12,7 @@
|
||||
<StackPanel Orientation="Vertical" Spacing="12">
|
||||
<StackPanel Orientation="Horizontal" Spacing="8">
|
||||
<Button
|
||||
x:Name="LaunchButton"
|
||||
x:Uid="Launch_Hosts"
|
||||
Click="Launch_Hosts_Click"
|
||||
Style="{StaticResource AccentButtonStyle}" />
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
using System.Threading;
|
||||
|
||||
using Microsoft.PowerToys.Settings.UI.Library;
|
||||
using Microsoft.PowerToys.Settings.UI.Library.Helpers;
|
||||
using Microsoft.PowerToys.Settings.UI.OOBE.Enums;
|
||||
using Microsoft.PowerToys.Settings.UI.OOBE.ViewModel;
|
||||
using Microsoft.PowerToys.Settings.UI.Views;
|
||||
@@ -28,6 +29,10 @@ namespace Microsoft.PowerToys.Settings.UI.OOBE.Views
|
||||
protected override void OnNavigatedTo(NavigationEventArgs e)
|
||||
{
|
||||
ViewModel.LogOpeningModuleEvent();
|
||||
|
||||
// Disable the Launch button if the module is disabled
|
||||
var generalSettings = SettingsRepository<GeneralSettings>.GetInstance(SettingsUtils.Default).SettingsConfig;
|
||||
LaunchButton.IsEnabled = ModuleHelper.GetIsModuleEnabled(generalSettings, ManagedCommon.ModuleType.Hosts);
|
||||
}
|
||||
|
||||
protected override void OnNavigatedFrom(NavigationEventArgs e)
|
||||
|
||||
@@ -21,6 +21,7 @@
|
||||
|
||||
<StackPanel Orientation="Horizontal" Spacing="8">
|
||||
<Button
|
||||
x:Name="LaunchButton"
|
||||
x:Uid="Launch_RegistryPreview"
|
||||
Click="Launch_RegistryPreview_Click"
|
||||
Style="{StaticResource AccentButtonStyle}" />
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
// See the LICENSE file in the project root for more information.
|
||||
|
||||
using Microsoft.PowerToys.Settings.UI.Library;
|
||||
using Microsoft.PowerToys.Settings.UI.Library.Helpers;
|
||||
using Microsoft.PowerToys.Settings.UI.OOBE.Enums;
|
||||
using Microsoft.PowerToys.Settings.UI.OOBE.ViewModel;
|
||||
using Microsoft.PowerToys.Settings.UI.Views;
|
||||
@@ -43,6 +44,10 @@ namespace Microsoft.PowerToys.Settings.UI.OOBE.Views
|
||||
protected override void OnNavigatedTo(NavigationEventArgs e)
|
||||
{
|
||||
ViewModel.LogOpeningModuleEvent();
|
||||
|
||||
// Disable the Launch button if the module is disabled
|
||||
var generalSettings = SettingsRepository<GeneralSettings>.GetInstance(SettingsUtils.Default).SettingsConfig;
|
||||
LaunchButton.IsEnabled = ModuleHelper.GetIsModuleEnabled(generalSettings, ManagedCommon.ModuleType.RegistryPreview);
|
||||
}
|
||||
|
||||
protected override void OnNavigatedFrom(NavigationEventArgs e)
|
||||
|
||||
@@ -21,6 +21,7 @@
|
||||
|
||||
<StackPanel Orientation="Horizontal" Spacing="8">
|
||||
<Button
|
||||
x:Name="LaunchButton"
|
||||
x:Uid="Launch_Run"
|
||||
Click="Start_Run_Click"
|
||||
Style="{StaticResource AccentButtonStyle}" />
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
using System.Threading;
|
||||
|
||||
using Microsoft.PowerToys.Settings.UI.Library;
|
||||
using Microsoft.PowerToys.Settings.UI.Library.Helpers;
|
||||
using Microsoft.PowerToys.Settings.UI.OOBE.Enums;
|
||||
using Microsoft.PowerToys.Settings.UI.OOBE.ViewModel;
|
||||
using Microsoft.PowerToys.Settings.UI.Views;
|
||||
@@ -55,6 +56,10 @@ namespace Microsoft.PowerToys.Settings.UI.OOBE.Views
|
||||
ViewModel.LogOpeningModuleEvent();
|
||||
|
||||
HotkeyControl.Keys = SettingsRepository<PowerLauncherSettings>.GetInstance(SettingsUtils.Default).SettingsConfig.Properties.OpenPowerLauncher.GetKeysList();
|
||||
|
||||
// Disable the Launch button if the module is disabled
|
||||
var generalSettings = SettingsRepository<GeneralSettings>.GetInstance(SettingsUtils.Default).SettingsConfig;
|
||||
LaunchButton.IsEnabled = ModuleHelper.GetIsModuleEnabled(generalSettings, ManagedCommon.ModuleType.PowerLauncher);
|
||||
}
|
||||
|
||||
protected override void OnNavigatedFrom(NavigationEventArgs e)
|
||||
|
||||
@@ -16,6 +16,7 @@
|
||||
|
||||
<StackPanel Orientation="Horizontal" Spacing="8">
|
||||
<Button
|
||||
x:Name="LaunchButton"
|
||||
x:Uid="Launch_ShortcutGuide"
|
||||
Click="Start_ShortcutGuide_Click"
|
||||
Style="{StaticResource AccentButtonStyle}" />
|
||||
|
||||
@@ -8,6 +8,7 @@ using System.Globalization;
|
||||
using System.IO;
|
||||
|
||||
using Microsoft.PowerToys.Settings.UI.Library;
|
||||
using Microsoft.PowerToys.Settings.UI.Library.Helpers;
|
||||
using Microsoft.PowerToys.Settings.UI.OOBE.Enums;
|
||||
using Microsoft.PowerToys.Settings.UI.OOBE.ViewModel;
|
||||
using Microsoft.PowerToys.Settings.UI.Views;
|
||||
@@ -66,6 +67,10 @@ namespace Microsoft.PowerToys.Settings.UI.OOBE.Views
|
||||
{
|
||||
HotkeyControl.Keys = settingsProperties.OpenShortcutGuide.GetKeysList();
|
||||
}
|
||||
|
||||
// Disable the Launch button if the module is disabled
|
||||
var generalSettings = SettingsRepository<GeneralSettings>.GetInstance(SettingsUtils.Default).SettingsConfig;
|
||||
LaunchButton.IsEnabled = ModuleHelper.GetIsModuleEnabled(generalSettings, ManagedCommon.ModuleType.ShortcutGuide);
|
||||
}
|
||||
|
||||
protected override void OnNavigatedFrom(NavigationEventArgs e)
|
||||
|
||||
@@ -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