Compare commits

...

16 Commits

Author SHA1 Message Date
Muyuan Li (from Dev Box)
aba4f8d66e Extend agent to label PRs via linked issues, file paths, and title
The agent now handles pull requests in addition to issues:

- Method C: Copy Product-* labels from linked issues (closingIssuesReferences)
- Method D: Parse PR body for #NNNN issue references as fallback
- Method E: Map changed file paths (src/modules/*) to products
- Method F: Parse [ProductName] title prefix convention

Priority order: linked issues > file paths > title/body analysis.
Includes directory-to-label mapping for non-obvious names (e.g.,
launcher/ -> PowerToys Run, MeasureTool/ -> Screen Ruler).

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-05-11 17:21:05 +08:00
Muyuan Li (from Dev Box)
f825923b85 Make agent self-maintaining: discover labels dynamically
Instead of hardcoding all Product-* labels and template values in the
mapping file, the agent now fetches them at runtime via:
- gh label list --search 'Product-' (valid labels)
- gh api to read bug_report.yml (template dropdown values)

The reference file is reduced to only:
- Override mappings for non-obvious name mismatches
- Non-product template values
- Keyword hints for content analysis

New modules/labels are picked up automatically without file updates.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-05-11 17:13:26 +08:00
Muyuan Li (from Dev Box)
47163afa30 Add LabelIssues agent for Product-* label triage
Adds a Copilot agent that applies Product-* labels to issues based on:
- Deterministic mapping from the 'Area(s) with issue?' template field
- AI content analysis for issues without the structured field

The agent accepts natural-language filters (e.g., '5 days', 'my issues',
'Needs-Triage') and classifies confidence as HIGH (auto-apply) or LOW
(present to user for approval).

Includes a reference file with the full template-to-label mapping and
keyword hints for content analysis.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-05-11 16:23:44 +08:00
moooyo
c8ffcb73c3 [ImageResizer] Fix JPEG quality setting ignored after WinUI3 migration (#47134)
<!-- Enter a brief description/summary of your PR here. What does it
fix/what does it change/how was it tested (even manually, if necessary)?
-->
## Summary of the Pull Request
Restores honoring of the user-configured JPEG quality — via the Settings
UI slider, the CLI `--quality` flag, or the persisted
`imageresizer_jpegQualityLevel` — when resizing JPEG files. Since the
WinUI3 migration (#45288) any Q value from 1 to 100 produced
byte-identical output at WIC's internal default (~Q90) because the
transcode encoder silently ignored the setting. Only
`src/modules/imageresizer/ui/Models/ResizeOperation.cs` is changed.

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

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

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

<!-- Describe how you validated the behavior. Add automated tests
wherever possible, but list manual validation steps taken as well -->
## Validation Steps Performed

Built `ImageResizerCLI.csproj` in `Release|x64` with
`/p:RuntimeIdentifier=win-x64`; no new warnings. Ran the resulting
`x64\Release\WinUI3Apps\PowerToys.ImageResizerCLI.exe` against a
synthetic test JPEG (`a.jpg` 2373×905, ~240 KB) and both in-tree EXIF
assets:

- `src/modules/imageresizer/tests/TestMetadataIssue1928.jpg` (42 EXIF
properties)
- `src/modules/imageresizer/tests/TestMetadataIssue2447.jpg` (44 EXIF
properties)

Co-authored-by: Yu Leng (from Dev Box) <yuleng@microsoft.com>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-22 14:46:41 +08:00
Niels Laute
fcfbf83b55 Settings design tweaks + fixes (#47132)
This PR:

- Fixed a UI regression on the ZoomIt page
- Updates the CmdPal settings page to make sure it has the latest links
and imagery
- Updates the New+ assets so they fit inline with all other screenshots
and assets
- Adds missing screenshots to the `docs` folder

Closes: #44521
2026-04-21 14:11:33 +02:00
Niels Laute
de6ba922fd Fixing OOBE and assets for Power Display and Grab And Move (#47033)
## Summary

Adds the out-of-box experience (OOBE) for the new **Grab And Move**
module and refreshes related assets across the repo.

## Changes

### Grab And Move OOBE
- New `OobeGrabAndMove.xaml` / `.xaml.cs` page following the standard
PowerToys OOBE pattern (hero image, How to use, Tips & tricks, Settings
button, Learn more link)
- Wired into `OobeWindow.xaml(.cs)` as a new `NavigationViewItem` so it
appears in the OOBE wizard
- Added localized resource strings (Title, Description, How to use, Tips
and tricks) in `Resources.resw`
- New OOBE animation: `Assets/Settings/Modules/OOBE/GrabAndMove.gif`

### Settings UI polish
- Moved the Grab And Move nav item under *Windowing & Layouts* into its
proper alphabetical position (after FancyZones, before Workspaces)
- Added a "NEW" `InfoBadge` to both the Grab And Move item and its
parent *Windowing & Layouts* group so users can discover the new utility

### Asset refresh
- High-res `GrabAndMove.ico` (replaces the placeholder)
- Updated Settings module icons
(`Assets/Settings/Icons/GrabAndMove.png`,
`Assets/Settings/Modules/GrabAndMove.png`)
- New overview/marketing PNGs under `doc/images/overview/` (large,
small, and original)

### README
- Added **Grab And Move** and **PowerDisplay** to the utilities table in
`README.md`, reflowed alphabetically into a clean 10x3 grid
- New `doc/images/icons/GrabAndMove.png` and
`doc/images/icons/PowerDisplay.png` for the table

## Validation
- Settings UI builds cleanly
- Grab And Move appears in the OOBE wizard navigation and renders
correctly
- "NEW" badges visible on first launch
- README table renders with all 30 utilities, no empty trailing cells

<img width="1307" height="807" alt="image"
src="https://github.com/user-attachments/assets/f8d2ef96-a9f3-4307-9714-c308e216c044"
/>

---------

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-04-21 06:23:54 +02:00
Dave Rayment
71cb9bc54e [Quick Accent] Fix issue where default "All available" setting is not parsed correctly (#47117)
## Summary of the Pull Request
When first enabled in the Settings application, Quick Accent defaults to
**All available** character sets, but the persisted "ALL" option in the
settings.json file is not understood by the application itself. This
leads to the fallback SPECIAL character set being selected - unbeknownst
to the user - which only contains a small subset of the available
mappings.

This PR also adds two new characters to the Hungarian language, as
requested under #47085.

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

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

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

The cause of the issue is:

1. The default `SelectedLang` value for a new Quick Accent settings file
is "ALL", set in `PowerAccentProperties.cs`:
 

5520ae4cfa/src/settings-ui/Settings.UI.Library/PowerAccentProperties.cs (L46)

2. The Settings application understands the "ALL" setting, _but Quick
Accent itself does not_.
3. There is an existing fallback in Quick Accent for when the language
cannot be parsed, which is to select the "SPECIAL" language instead.
This is a grab-bag of mappings for non-language-specific entries such as
cross-cultural punctuation, IPA, currency etc.

The effect is that new users will see that All available character sets
have been selected in the Settings application by default, but that only
a small number of the available mappings will be shown when they trigger
Quick Accent. De-selecting and re-selecting All available will fix the
issue, but a new user is unlikely to do this. This leads to issues being
logged such as #47085, where missing characters are reported, but they
actually are present in the underlying mappings for the language(s).

There is also another issue with `SelectedLang` parsing, in that entries
are compared against the in-built language codes in a case-sensitive
non-trimmed manner. This means that entries such as "FR", "fr" and " FR"
are all distinct. Although this isn't an issue at the moment, it means
that adding new languages or editing the settings file manually is prone
to triggering fall-throughs and the selection of the SPECIAL language
without the user knowing.

The fix here is to:

1. Pre-parse the SelectedLang entries to trim them and remove any empty
values.
2. Do an explicit (case-insensitive) check for "ALL"; if present, all
languages are immediately selected.
3. If "ALL" is not present in the list, do a case-insensitive check for
each entry against the in-built language codes.
4. Reject any non-matches and log a warning. Do not fall through to
auto-select the SPECIAL character set. This respects the principle of
least surprise, and means that the user is never given character options
that they did not explicitly select.

Changes to the Quick Accent application are in `ReadSettings()` in
SettingsService.cs. I have also made the Settings application parsing
more robust, so both it and Quick Accent should be able to correctly
parse entries like "SP, INVALID, EST".

<!-- Describe how you validated the behavior. Add automated tests
wherever possible, but list manual validation steps taken as well -->
## Validation Steps Performed

Follow the manual steps below:

1. Close the Quick Accent process if it is currently running
6. Edit the settings.json file for Quick Accent, and set the
"selected_lang" value to "all" (this setting is no longer
case-sensitive).
7. (Re)start Quick Accent by starting `PowerToys.PowerAccent.exe`.
8. Trigger Quick Accent for the E key and observe the selection dialog
that opens. It should contain E mappings for every language:

<img width="3371" height="155" alt="image"
src="https://github.com/user-attachments/assets/84d709ea-fdcc-4430-8d22-f9732969a20a"
/>

9. Trigger Quick Accent for the Y key and observe the selection dialog
that opens. It should contain Y mappings for every language, including
the Y-With-Diaresis character originally reported as missing:

<img width="1264" height="149" alt="image"
src="https://github.com/user-attachments/assets/ecea3734-e58c-4da6-896b-2e58a9fd05e8"
/>

10. Back in the settings file, change "selected_lang" value to "HR" for
Croatian. Save the file.
11. Trigger Quick Accent for the C key and observe that only the
following mappings are shown:

<img width="1055" height="146" alt="image"
src="https://github.com/user-attachments/assets/69764145-0836-4dfb-8655-3a8a3be7f561"
/>

12. Again, in settings, change the "selected_lang" value to "". Save the
file.
13. Confirm that triggering Quick Accent does not result in an error and
simply does not show the dialog.
14. In settings again, change the "selected_lang" value to "FR, INVALID,
DE" and save.
15. Trigger Quick Accent and confirm that entries for French and German
are available in the dialog.
16. Check the logs and confirm that a warning is present about "INVALID"
not being a valid language and being skipped.

Separately, for the Settings application:

1. Close the PowerToys Runner and ensure Quick Accent and Settings are
not present in the Processes list.
2. Edit the settings.json file for Quick Accent, changing the
"selected_lang" value to "FR, INVALID, DE" and save.
3. Run PowerToys and open the Settings application.
4. On the Quick Accent settings page, ensure that French and German
languages are selected. (Previously, parsing would fail after FR.)
2026-04-21 11:41:25 +08:00
Gordon Lam
8ad571dcde Fix Common.Interop.UnitTests.TestSend infinite hang on CI (#47123)
## Summary

Fixes an infinite hang in Common.Interop.UnitTests.TestSend that caused
the x64 CI job to time out at 80 minutes on retried runs (originally
observed on #47106, but the race is latent in any run that shares a CI
agent with a previous run).

## Root cause

The test used two machine-global named pipes (\\.\pipe\serverside and
\\.\pipe\clientside) as fixed constants, and waited for the pipe
callback with an **unbounded** eset.WaitOne().

If a prior test run on the same CI agent left a pipe handle alive (e.g.
after a job cancellation or a flaky cleanup), the next run's
TwoWayPipeMessageIPCManaged handshake would silently never complete, and
`WaitOne()` would block until the pipeline's job-level timeout (~80
minutes) killed the agent.

## Fix

Two small, orthogonal changes in `InteropTests.cs`:

1. **Unique pipe names per run** — suffix the pipe paths with
`Environment.ProcessId` + a fresh `Guid`, so runs on the same agent can
never collide.
2. **Bounded wait** — `reset.WaitOne(TimeSpan.FromSeconds(30))` wrapped
in `Assert.IsTrue` with a diagnostic message identifying the pipes. A
broken handshake now fails the test in 30 s with a clear error, instead
of hanging the CI job.

The inner `Assert.AreEqual(testString, msg)` — the actual correctness
check — is unchanged. On the happy path the callback fires in
milliseconds and the test behaves identically to before.

## Verification

Built and ran locally with VS2026 MSBuild (x64 Release): `TestSend`
passes in ~139 ms.

## Follow-up (not in this PR)

`TwoWayPipeMessageIPC.cpp` still relies on a `Thread.Sleep(100)` race
workaround (comment in the test) for server-ready timing. A proper
handshake there would let us drop the sleep; out of scope here.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-04-21 11:08:08 +08:00
Niels Laute
bf00c1b94f [Common][PowerDisplay][QuickAccess] Shared flyout positioning helper (#47097)
## Summary

Introduces a shared FlyoutWindowHelper in Common.UI.Controls and
migrates both **PowerDisplay** and **QuickAccess** to it, eliminating
two pre-existing flyout positioning bugs and removing duplicated math.

## Bugs fixed

### 1. PowerDisplay flyout overlapped the taskbar at 100% scaling
The previous PowerDisplay-only positioning math anchored to the screen
bounds rather than `DisplayArea.WorkArea` on certain monitor
configurations, so the bottom edge of the flyout could land on top of
the taskbar.

### 2. QuickAccess flyout rendered too large / partially off-screen
after switching DPI between 150% and 100%
The previous code passed `WindowEx.Width`/`Height` into `MoveAndResize`
on every summon. Those properties are **not** the XAML literals — they
are computed live as `AppWindow.Size / GetDpiForWindow() * 96`. After a
system-scaling switch, the runtime size has drifted, that wrong "DIP"
value got fed into `MoveAndResize`, and the destination DPI multiplier
scaled it again → wrong size, and the wrong size shifted the
bottom-right anchor off-screen.

QuickAccess now caches the XAML design size once at construction (when
the values are still trustworthy) and uses the cache as the source of
truth.

## How the helper works

- Uses **absolute screen coordinates** against `DisplayArea.WorkArea`,
so it handles non-primary and negatively-positioned monitors correctly.
- Performs a **1×1 `MoveAndResize` "teleport"** onto the target display
before the final visible-size call. The 1×1 jump may cross a DPI
boundary, but it's invisible; the second call sets the real size while
the window is already on the destination monitor, so no DPI boundary is
crossed for the rendered size and `WM_DPICHANGED` never fires on a
visible window.
- Exposes overloads for bottom-right anchoring (both flyouts) and
centered placement (PowerDisplay's `IdentifyWindow`).

This teleport-then-size approach matches the technique the original
Settings.UI flyout used for years before it was removed.

## Cleanup

- Deletes the PowerDisplay-only `DpiSuppressor` — its
WM_DPICHANGED-suppression code path is now dead because the helper
sidesteps the message entirely.
- The `DpiSuppressor` class also doubled as a generic WndProc subclass
to route `WM_HOTKEY` into `HotkeyService`. That piece is preserved as
`WindowMessageHook` in `Common.UI.Controls/Window/` since PowerDisplay
still needs in-process hotkey handling.

## Validation

- `Common.UI.Controls`, `PowerDisplay`, and `QuickAccess` build clean
(x64/Debug).
- Manual repro:
- PowerDisplay flyout no longer overlaps taskbar at 100% scaling, on
multiple invocations.
- QuickAccess renders at the correct size and position when switching
system scaling between 150% and 100%.
- PowerDisplay hotkey toggle still works after the `DpiSuppressor` →
`WindowMessageHook` rename.

---------

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-04-21 10:20:34 +08:00
Niels Laute
7a89220a91 [QuickAccent] Add en-dash to VK_MINUS for SPECIAL language (#47106)
## Summary of the Pull Request
Add en-dash to the existing dash characters available under the minus
key for the Special Characters set.

This PR re-creates #45965 by @daverayment because that PR's pipeline
appears to have been corrupted. All credit for the change belongs to
@daverayment — the commit on this branch preserves their original
authorship.

## PR Checklist

- [x] Closes: #44030
- [x] Closes: #36805

## Detailed Description of the Pull Request / Additional comments

This PR adds the en-dash '–' character to the VK_MINUS key under the
Special Characters character set, positioned before the em-dash
character.

Although the character is available under VK_COMMA, it should be present
under VK_MINUS, along with the other dash characters.

Previously, en-dash was available for VK_MINUS under the Hebrew
language, so users who selected **All available** character sets (or who
specifically selected Hebrew as a workaround) had access to en-dash via
the minus key. However, this was seen as a duplication of the VK_COMMA
functionality and the character was removed for the Hebrew character set
in #43504. Although this is technically correct, this has understandably
caused confusion for users who relied on the prior behaviour.

The comment on the VK_COMMA for the Special Characters declaration
previously read:

`csharp
// – is in VK_MINUS for other languages, but not VK_COMMA, so we add it
here.
`

That ""for other languages"" is telling. The Hebrew en-dash entry was
removed, and the en-dash mapping for the minus key is not present for
any other language, orphaning the functionality.

## Validation Steps Performed

Original author @daverayment built and ran the updated Quick Accent code
and confirmed that the character was available when only the Special
character set was selected, and that it was absent when that set was
deselected. Confirmed that the character was available under both comma
and minus keys.

Co-authored-by: Dave Rayment <dave.rayment@gmail.com>
2026-04-21 00:59:35 +00:00
Michael Jolley
3a541bb3eb CmdPal: Adding prop to cmdpal.ui.csproj to enable telem in AOT builds (#47121)
This pull request makes a configuration change to the
`Microsoft.CmdPal.UI.csproj` project file to improve telemetry support
for AOT (Ahead-Of-Time) builds.

Project configuration:

* Added the `EventSourceSupport` property and set it to `true` to ensure
telemetry events are triggered correctly when building with AOT.
2026-04-20 19:49:39 -05:00
Niels Laute
f4d23c85a6 [CmdPal Dock] Compact mode (#46699)
## Summary of the Pull Request

This PR introduces the following changes:
- Shaving off a few pixels of the default height in `Top` or `Bottom`
mode.
- A new `Compact` mode that automatically hides the `Subtitle` and is
28px in height.
- Compact mode is only available for Top/Bottom dock positions.
Left/Right always use the Default size.
- The Dock Size settings card is hidden in the settings UI when Left or
Right is selected.
- At runtime, Left/Right positions force `DockSize.Default` regardless
of the persisted setting, ensuring default item styles and appbar sizing
are always used. The user's Compact preference is preserved so switching
back to Top/Bottom restores it.

Stable vs. Compact mode:

<img width="392" height="131" alt="image"
src="https://github.com/user-attachments/assets/f0ac3126-a773-46c6-87da-001fd66c5899"
/>

<img width="929" height="272" alt="image"
src="https://github.com/user-attachments/assets/684c2ea7-449d-4ed2-989d-5066c7f28200"
/>

## PR Checklist

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


## Detailed Description of the Pull Request / Additional comments

### Compact mode restricted to Top/Bottom
Compact mode is only supported when the dock is positioned at the Top or
Bottom of the screen. When Left or Right is selected:
- The **Dock Size** settings card is hidden in the dock settings UI.
- The runtime forces \\DockSize.Default\\ so the default band template,
default item styles, and default appbar dimensions are always used.
- The persisted \\DockSize\\ value is **not** cleared — switching back
to Top/Bottom restores the user's previous Compact choice.

### Files changed
- \\DockSettingsPage.xaml\\ / \\.xaml.cs\\ — Conditional visibility for
the Dock Size settings card
- \\DockControl.xaml.cs\\ — Effective size override in
\\UpdateSettings()\\
- \\DockWindow.xaml.cs\\ — \\EffectiveDockSize()\\ helper used for
appbar sizing and change detection

## Validation Steps Performed

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: niels9001 <9866362+niels9001@users.noreply.github.com>
2026-04-20 16:07:41 +00:00
Jiří Polášek
5e302bed79 CmdPal: Improve indexer plain query search (#46907)
## Summary of the Pull Request

This PR improves File Search:
- Improves simple free-text Windows Search queries with implicit
filename broadening while preserving structured AQS input.
- Adds resilient fallback behavior for noisy or punctuation-heavy
searches by retrying with literal filename matching (fixes failed
searches with `&` or other symbols).
- Surfaces Windows Search availability and indexing-status notices in
the indexer page and fallback item -- if the Windows Search service is
down or unreachable, we show this to the user.
- Extends production time logging.
- Adds documentation of query transformation for maintainers.
- Adds some unit tests to pretend that we care.
 
## Pictures? Pictures!

Error notices:

<img width="890" height="148" alt="image"
src="https://github.com/user-attachments/assets/2370af01-04de-48a5-aa8e-06b95b54571e"
/>

<img width="880" height="369" alt="image"
src="https://github.com/user-attachments/assets/b2afa52b-02f8-4031-a61a-fa1031f86542"
/>



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

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

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

<!-- Describe how you validated the behavior. Add automated tests
wherever possible, but list manual validation steps taken as well -->
## Validation Steps Performed
2026-04-20 10:41:27 -05:00
Jiří Polášek
520037e128 CmdPal: Add persistent calculator history (#45307)
## Summary of the Pull Request

This PR adds a persistent memory to Calculator extension and updates
result commands.

<img width="815" height="515" alt="image"
src="https://github.com/user-attachments/assets/e3b84ec4-a399-4c63-a773-76bcdf05f94a"
/>


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

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

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

<!-- Describe how you validated the behavior. Add automated tests
wherever possible, but list manual validation steps taken as well -->
## Validation Steps Performed
2026-04-20 10:40:49 -05:00
Jiří Polášek
d80d216bef CmdPal: Fix first-open top-level context menus for slow providers (#46626)
## Summary of the Pull Request

This PR fixes opening of the list item context menu through right-click,
which affected mainly 3rd party out-of-process extensions.

- Keeps the first context-menu request alive when a top-level item is
selected but its out-of-proc MoreCommands are still hydrating.
- Short-circuits CanOpenContextMenu when a valid synthetic primary
command is already available, so items with a usable primary action can
open immediately without waiting for late menu hydration.


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

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

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

<!-- Describe how you validated the behavior. Add automated tests
wherever possible, but list manual validation steps taken as well -->
## Validation Steps Performed
2026-04-20 10:13:24 -05:00
Michael Jolley
b310c55835 fix(cmdpal): handle unavailable extension packages during loading (#47032)
## Summary of the Pull Request

Fixes a Watson crash where \AppExtension.Package\ throws a COM/HRESULT
exception when the underlying package is in a bad state (being updated,
partially installed, recently uninstalled, or corrupted). Previously,
one bad extension killed the entire extension-loading loop, preventing
**all** extensions from loading.

This adds try-catch guards in \ExtensionService\ at two levels so a
single failing extension is logged and skipped rather than aborting the
enumeration.

## PR Checklist

- [ ] **Communication:** I've discussed this with core contributors
already. If the work hasn't been agreed, this work might be rejected
- [x] **Tests:** This is a defensive resilience fix with no behavior
change for the happy path; existing tests still pass
- [ ] **Localization:** N/A — no end-user-facing strings changed (log
messages are developer-facing)
- [ ] **Dev docs:** N/A — no new APIs or features
- [ ] **New binaries:** N/A — no new binaries

## Detailed Description of the Pull Request / Additional comments

**File changed:**
\src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/Models/ExtensionService.cs\

Two try-catch blocks added:

1. **\GetInstalledExtensionsAsync\** — wraps the per-extension loop body
(lines 200–211) so that if \CreateWrappersForExtension\ or any property
access on \AppExtension\ throws, the loop continues to the next
extension.

2. **\CreateWrappersForExtension\** — wraps the per-classId wrapper
creation (lines 252–264) so that if the \ExtensionWrapper\ constructor
throws (e.g. \ppExtension.Package\ is unavailable), remaining class IDs
in the same extension still get processed.

Both catch blocks log via \Logger.LogError\ with the extension display
name and error message.

The \InstallPackageUnderLock\ code path also calls
\CreateWrappersForExtension\ and inherits the inner protection
automatically.

## Validation Steps Performed

- Built \Microsoft.CmdPal.UI.ViewModels\ with \ ools/build/build.cmd\ —
exit code 0, no warnings or errors
- Verified no existing tests are broken by the change

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-04-20 10:12:46 -05:00
131 changed files with 4162 additions and 743 deletions

View File

@@ -2278,6 +2278,7 @@ THEMECHANGED
thickframe
Tianma
tmain
tontrager
tskill
tweakable
UBreak

279
.github/agents/LabelIssues.agent.md vendored Normal file
View File

@@ -0,0 +1,279 @@
---
name: LabelIssues
description: 'Labels GitHub issues and pull requests with Product-* labels based on issue template fields, linked issues, changed files, and content analysis. Accepts natural-language filters like "5 days", "my issues", "Needs-Triage issues", or "unlabeled PRs".'
tools: ['execute', 'read', 'github/*']
argument-hint: 'Description of issues/PRs to label (e.g., "5 days", "my issues", "unlabeled PRs this month", "#12345")'
infer: true
---
# LabelIssues Agent
You are an **issue and PR triage agent** that applies `Product-*` labels to GitHub issues and pull requests in the PowerToys repository.
## Goal
Given a user description of which issues or PRs to process, find matching items that are **missing `Product-*` labels**, determine the correct product label(s), and apply them — with appropriate confidence gating.
## Workflow
### Step 1 — Parse the user's request into a search query
Interpret the user's natural-language input and build a `gh` search query. Determine whether the user wants to process **issues**, **PRs**, or **both**.
| User says | Interpreted as |
|-----------|---------------|
| `5 days` | Issues created in the last 5 days |
| `my issues` | Issues assigned to the authenticated user |
| `Needs-Triage` or `needs triage` | Issues with the `Needs-Triage` label |
| `#12345` or `12345` | A single specific issue or PR |
| `open issues this week` | Open issues created in the last 7 days |
| `closed bugs last month` | Closed issues with `Issue-Bug` label from last month |
| `unlabeled PRs` or `PRs this week` | PRs without Product-* labels |
| `unlabeled PRs and issues` | Both PRs and issues without Product-* labels |
**Always add these implicit filters:**
- Exclude items that already have any `Product-*` label
- For issues: exclude pull requests; for PRs: only pull requests
**Echo back** the parsed query to the user before executing:
```
Searching for: [state:open created:>2026-05-06 -label:"Product-*"]
```
### Step 2 — Fetch matching issues and/or PRs
Use `gh` CLI to fetch items. Example commands:
```bash
# Recent issues (last N days)
gh issue list --repo microsoft/PowerToys --state open --json number,title,body,labels --limit 100
# PRs without product labels
gh pr list --repo microsoft/PowerToys --state open --json number,title,body,labels --limit 100
# Single issue or PR
gh issue view 12345 --repo microsoft/PowerToys --json number,title,body,labels
gh pr view 12345 --repo microsoft/PowerToys --json number,title,body,labels,closingIssuesReferences,files
```
Filter out items that already have a `Product-*` label in post-processing.
Report: `Found N issues and M PRs without Product-* labels.`
If more than 50 items match, warn the user and ask whether to proceed or narrow the scope.
### Step 2.5 — Dynamically discover labels and template fields
**Do this once at the start of every run** so the mapping is always current:
1. **Fetch all `Product-*` labels from the repo:**
```bash
gh label list --repo microsoft/PowerToys --search "Product-" --json name --limit 200 --jq '.[].name'
```
Store these as the set of **valid labels**.
2. **Fetch the current bug report template dropdown values:**
```bash
gh api repos/microsoft/PowerToys/contents/.github/ISSUE_TEMPLATE/bug_report.yml --jq '.content' | base64 -d
```
Parse the YAML to extract the `options` list under the "Area(s) with issue?" dropdown field. These are the **template values**.
3. **Build the live mapping** by matching each template value to a `Product-*` label:
- First, check the **override mapping** in `.github/agents/references/product-label-mapping.md` — this file ONLY contains non-obvious name mismatches (e.g., `Keyboard Manager` → `Product-Keyboard Shortcut Manager`)
- Then, try direct match: prepend `Product-` to the template value and check if it exists in the valid labels set
- If neither matches, the template value has no mapping (treat as needing content analysis)
This approach ensures new modules and labels are picked up automatically — the only maintenance needed is when a template dropdown value has a **different name** from its `Product-*` label.
### Step 3 — Determine product labels
#### For Issues
Use the following methods in order:
##### Method A: Deterministic mapping (HIGH confidence)
Parse the issue body for the structured **"Area(s) with issue?"** field from the bug report template. The field appears in the rendered markdown as:
```
### Area(s) with issue?
Command Palette, FancyZones
```
Extract the text between `### Area(s) with issue?` and the next `###` heading (or end of body). Split by commas. Map each value using the **live mapping built in Step 2.5**.
If all selected areas map to known labels → **HIGH confidence**.
##### Method B: Content analysis (variable confidence)
When Method A produces no result (e.g., feature requests without the area field, or free-form issues), analyze the issue title and body yourself to infer the product.
Use the **valid labels list from Step 2.5** as the universe of possible labels — never invent a label that doesn't exist.
Optionally consult the keyword hints in `.github/agents/references/product-label-mapping.md` for guidance on ambiguous terms.
#### For Pull Requests
Use the following methods in priority order. Stop as soon as you get a HIGH confidence result:
##### Method C: Linked issues (HIGH confidence)
Fetch linked issues using:
```bash
gh pr view <number> --repo microsoft/PowerToys --json closingIssuesReferences --jq '.closingIssuesReferences[].number'
```
This returns issues linked via `Fixes #X`, `Closes #X`, or `Resolves #X` keywords in the PR body (including the `- [ ] Closes: #xxx` checklist item from the PR template).
If linked issues are found:
1. Fetch each linked issue's labels
2. Copy any `Product-*` labels from the linked issues → **HIGH confidence**
If linked issues exist but none have `Product-*` labels, apply the issue labeling methods (A/B) to those linked issues first, then copy the result.
##### Method D: Parse body for issue references (MEDIUM → HIGH confidence)
If `closingIssuesReferences` is empty, scan the PR body for `#NNNN` patterns that might reference issues (not other PRs). Fetch those issues and check for `Product-*` labels.
##### Method E: Changed file paths (HIGH confidence)
If no linked issues are found, fetch the PR's changed files:
```bash
gh pr view <number> --repo microsoft/PowerToys --json files --jq '[.files[].path]'
```
Map file paths to products using the `src/modules/` directory structure:
| Path pattern | Product Label |
|-------------|---------------|
| `src/modules/AdvancedPaste/` | `Product-Advanced Paste` |
| `src/modules/alwaysontop/` | `Product-Always On Top` |
| `src/modules/awake/` | `Product-Awake` |
| `src/modules/cmdNotFound/` | `Product-CommandNotFound` |
| `src/modules/cmdpal/` | `Product-Command Palette` |
| `src/modules/colorPicker/` | `Product-Color Picker` |
| `src/modules/CropAndLock/` | `Product-CropAndLock` |
| `src/modules/EnvironmentVariables/` | `Product-Environment Variables` |
| `src/modules/fancyzones/` | `Product-FancyZones` |
| `src/modules/FileLocksmith/` | `Product-File Locksmith` |
| `src/modules/GrabAndMove/` | `Product-Grab And Move` |
| `src/modules/Hosts/` | `Product-Hosts File Editor` |
| `src/modules/imageresizer/` | `Product-Image Resizer` |
| `src/modules/keyboardmanager/` | `Product-Keyboard Shortcut Manager` |
| `src/modules/launcher/` | `Product-PowerToys Run` |
| `src/modules/LightSwitch/` | `Product-LightSwitch` |
| `src/modules/MeasureTool/` | `Product-Screen Ruler` |
| `src/modules/MouseUtils/` | `Product-Mouse Utilities` |
| `src/modules/MouseWithoutBorders/` | `Product-Mouse Without Borders` |
| `src/modules/NewPlus/` | `Product-New+` |
| `src/modules/peek/` | `Product-Peek` |
| `src/modules/poweraccent/` | `Product-Quick Accent` |
| `src/modules/powerdisplay/` | `Product-PowerDisplay` |
| `src/modules/PowerOCR/` | `Product-Text Extractor` |
| `src/modules/powerrename/` | `Product-PowerRename` |
| `src/modules/previewpane/` | `Product-File Explorer` |
| `src/modules/registrypreview/` | `Product-Registry Preview` |
| `src/modules/ShortcutGuide/` | `Product-Shortcut Guide` |
| `src/modules/Workspaces/` | `Product-Workspaces` |
| `src/modules/ZoomIt/` | `Product-ZoomIt` |
Also check `src/settings-ui/` paths — these often contain the product name (e.g., `ZoomItPage.xaml` → `Product-ZoomIt`, `ImageResizerPage.xaml` → `Product-Image Resizer`).
If **all** changed files map to a single product → **HIGH confidence**.
If changed files span exactly 2 products (one being Settings) → HIGH confidence for the non-Settings product.
If changed files span 3+ products → **LOW confidence**, present to user.
##### Method F: PR title/body content analysis (variable confidence)
As a final fallback, analyze the PR title and body. Many PRs use a `[ProductName]` prefix convention in the title (e.g., `[PowerDisplay] Fix brightness...`, `[ZoomIt] Remove stale...`). This is **HIGH confidence** if the bracketed name matches a known product.
Otherwise, apply the same content analysis rules as for issues.
#### Confidence Classification (applies to both issues and PRs)
**HIGH confidence** — assign automatically when:
- The issue has a deterministic template field match (Method A)
- A PR's linked issues have `Product-*` labels (Method C)
- All changed files in a PR map to one product (Method E)
- The PR title uses `[ProductName]` prefix matching a known product (Method F)
- The title/body explicitly and unambiguously names a single product
**LOW confidence** — present to user for approval when:
- Multiple products are mentioned and it's unclear which is primary
- The item is about cross-cutting infrastructure (installer, settings, system tray)
- The item is in a non-English language and you're unsure of the product
- The described feature/bug doesn't clearly map to any existing product
- Changed files span 3+ products
**NO LABEL** — skip entirely when:
- The item is too vague to determine any product
- The item is about the PowerToys project itself (meta discussions, CI/CD, docs, build infra)
- You have no meaningful signal from any method
### Step 4 — Apply labels and report results
**For HIGH confidence items:** Apply labels automatically using:
```bash
# For issues:
gh issue edit <number> --repo microsoft/PowerToys --add-label "<Product-Label>"
# For PRs (same command works):
gh pr edit <number> --repo microsoft/PowerToys --add-label "<Product-Label>"
```
**For LOW confidence items:** Do NOT apply labels. Instead, present them in a table:
```markdown
| # | Type | Title | Suggested Label | Method | Reason |
|---|------|-------|----------------|--------|--------|
| #123 | Issue | ... | Product-FancyZones | Content | Title mentions "zones" but also "settings" |
| #456 | PR | ... | Product-ZoomIt | Files | Changed files span ZoomIt and Settings |
```
Ask the user: *"Would you like me to apply any of these? Reply with the numbers to approve, or 'skip' to leave them."*
If the user approves specific items, apply those labels.
**For NO LABEL items:** List them briefly:
```
Skipped (insufficient signal): #456 (issue), #789 (PR)
```
### Step 5 — Summary
After processing, always output a summary:
```
=== Label Results ===
Issues PRs Total
Auto-labeled: 12 5 17
Needs review: 3 1 4
Skipped: 2 0 2
Total: 17 6 23
```
## Safety Rules
1. **Never remove existing labels** — only add `Product-*` labels
2. **Never add labels to items that already have a `Product-*` label** — skip them
3. **Never add more than 2 `Product-*` labels** to a single item — if you'd infer 3+, mark as LOW confidence
4. **Always echo the search query** before fetching items
5. **Always ask for confirmation** when processing more than 50 items
6. **Prefer false negatives over false positives** — it's better to skip an item than to mislabel it
7. **For PRs, prefer linked-issue labels over content inference** — if a linked issue has a Product-* label, use that even if the PR title/files suggest something different
## Reference
Read the override mapping and keyword hints from: `.github/agents/references/product-label-mapping.md`
This file contains:
- **Override mappings** for template values whose names don't match their `Product-*` label (e.g., `Keyboard Manager` → `Product-Keyboard Shortcut Manager`)
- **Keyword hints** for content analysis when the structured field is absent
- **Non-product template values** that need special handling (Installer, System tray, Welcome window)
The file does NOT need to list every template value — most map directly by prepending `Product-`. Only non-obvious mismatches need entries. Labels and template values are discovered dynamically at runtime (Step 2.5).
## Prerequisites
- GitHub CLI (`gh`) must be installed and authenticated. Verify with `gh auth status`.
- The agent operates on the `microsoft/PowerToys` repository.

View File

@@ -0,0 +1,106 @@
# Product Label Mapping — Overrides & Hints
This file contains **only the non-obvious mappings** between the bug report template
"Area(s) with issue?" dropdown values and `Product-*` labels. Most template values
map directly by prepending `Product-` — only mismatches are listed here.
Labels and template values are discovered dynamically at runtime by the agent.
## Override Mappings (template value ≠ label name)
These template dropdown values have `Product-*` labels with **different names**:
| Template Dropdown Value | Product Label |
|------------------------|---------------|
| ColorPicker | `Product-Color Picker` |
| Command not found | `Product-CommandNotFound` |
| FancyZones Editor | `Product-FancyZones` |
| File Explorer: Preview Pane | `Product-File Explorer` |
| File Explorer: Thumbnail preview | `Product-File Explorer` |
| Hosts File Editor | `Product-Hosts File Editor` |
| Keyboard Manager | `Product-Keyboard Shortcut Manager` |
| Power Display | `Product-PowerDisplay` |
| TextExtractor | `Product-Text Extractor` |
| Screen ruler | `Product-Screen Ruler` |
## Non-Product Template Values
These template values do NOT map to a product label. Use content analysis instead:
| Template Value | Guidance |
|---------------|----------|
| Installer | Consider `Product-General` or infer from context |
| System tray interaction | Consider `Product-Settings` or `Product-General` |
| Welcome / PowerToys Tour window | Consider `Product-General` |
## Keyword Hints for Content Analysis
When the structured field is not available, use these keyword patterns to infer products:
| Keywords / Patterns | Suggested Label |
|--------------------|-----------------|
| CmdPal, cmdpal, command palette, dock | `Product-Command Palette` |
| zones, layout, snap, window arrangement | `Product-FancyZones` |
| grab, move, drag window | `Product-Grab And Move` |
| zoom, screen annotation, draw on screen | `Product-ZoomIt` |
| settings-ui, flyout, quick access, tray | `Product-Settings` |
| paste, clipboard, AI paste | `Product-Advanced Paste` |
| MWB, mouse without borders, cross-machine | `Product-Mouse Without Borders` |
| rename, regex, bulk rename | `Product-PowerRename` |
| peek, file preview, preview pane | `Product-Peek` |
| resize, image resizer, bulk resize | `Product-Image Resizer` |
| theme, dark mode, light switch | `Product-LightSwitch` |
| accent, diacritics, special characters | `Product-Quick Accent` |
| awake, keep awake, caffeine, screen on | `Product-Awake` |
| color picker, eyedropper, hex color | `Product-Color Picker` |
| hosts, hosts file, DNS | `Product-Hosts File Editor` |
| remap, key remap, shortcut remap | `Product-Keyboard Shortcut Manager` |
| mouse highlighter, click highlight | `Product-Mouse Highlighter` |
| mouse jump, teleport mouse | `Product-Mouse Jump` |
| find my mouse, locate cursor | `Product-Find My Mouse` |
| crosshairs, cursor crosshair | `Product-Mouse Pointer Crosshairs` |
| shortcut guide, keyboard overlay | `Product-Shortcut Guide` |
| OCR, text extractor, screen text | `Product-Text Extractor` |
| workspace, save layout, restore windows | `Product-Workspaces` |
| file locksmith, who is using, file lock | `Product-File Locksmith` |
| crop and lock, crop, thumbnail window | `Product-CropAndLock` |
| environment variable, env var, PATH | `Product-Environment Variables` |
| new+, file template, new file | `Product-New+` |
| registry, registry preview, .reg | `Product-Registry Preview` |
| screen ruler, measure, pixel ruler | `Product-Screen Ruler` |
| run, launcher, powertoys run, plugin | `Product-PowerToys Run` |
| command not found, winget, install suggestion | `Product-CommandNotFound` |
| brightness, monitor, display, DDC | `Product-PowerDisplay` |
| cursor wrap, edge wrap, multi-monitor cursor | `Product-Cursor Wrap` |
## PR Title Prefix Conventions
Many PRs use `[ProductName]` prefixes. Common variants:
| Title prefix | Product Label |
|-------------|---------------|
| `[CmdPal]` | `Product-Command Palette` |
| `[PowerDisplay]` | `Product-PowerDisplay` |
| `[ZoomIt]` | `Product-ZoomIt` |
| `[Image Resizer]` | `Product-Image Resizer` |
| `[GPO]` | `Product-General` |
| `[MWB]` | `Product-Mouse Without Borders` |
Most other prefixes match the label directly (e.g., `[FancyZones]``Product-FancyZones`).
## Source Directory → Label Mapping
Non-obvious `src/modules/` directory name mappings:
| Directory | Product Label |
|----------|---------------|
| `launcher/` | `Product-PowerToys Run` |
| `MeasureTool/` | `Product-Screen Ruler` |
| `poweraccent/` | `Product-Quick Accent` |
| `PowerOCR/` | `Product-Text Extractor` |
| `previewpane/` | `Product-File Explorer` |
| `interface/` | `Product-General` (runner/settings host) |
Most other directories match by prepending `Product-` to the directory name.
<!-- Valid Product-* labels are discovered dynamically at runtime via gh label list -->

View File

@@ -319,6 +319,10 @@
<Platform Solution="*|ARM64" Project="ARM64" />
<Platform Solution="*|x64" Project="x64" />
</Project>
<Project Path="src/modules/cmdpal/Tests/Microsoft.CmdPal.Ext.Indexer.UnitTests/Microsoft.CmdPal.Ext.Indexer.UnitTests.csproj">
<Platform Solution="*|ARM64" Project="ARM64" />
<Platform Solution="*|x64" Project="x64" />
</Project>
<Project Path="src/modules/cmdpal/Tests/Microsoft.CmdPal.Ext.Registry.UnitTests/Microsoft.CmdPal.Ext.Registry.UnitTests.csproj">
<Platform Solution="*|ARM64" Project="ARM64" />
<Platform Solution="*|x64" Project="x64" />

View File

@@ -29,13 +29,13 @@ PowerToys includes over 30 utilities to help you customize and optimize your Win
| [<img src="doc/images/icons/AdvancedPaste.png" alt="Advanced Paste icon" height="16"> Advanced Paste](https://aka.ms/PowerToysOverview_AdvancedPaste) | [<img src="doc/images/icons/Always%20On%20Top.png" alt="Always on Top icon" height="16"> Always on Top](https://aka.ms/PowerToysOverview_AoT) | [<img src="doc/images/icons/Awake.png" alt="Awake icon" height="16"> Awake](https://aka.ms/PowerToysOverview_Awake) |
| [<img src="doc/images/icons/Color%20Picker.png" alt="Color Picker icon" height="16"> Color Picker](https://aka.ms/PowerToysOverview_ColorPicker) | [<img src="doc/images/icons/Command%20Not%20Found.png" alt="Command Not Found icon" height="16"> Command Not Found](https://aka.ms/PowerToysOverview_CmdNotFound) | [<img src="doc/images/icons/Command Palette.png" alt="Command Palette icon" height="16"> Command Palette](https://aka.ms/PowerToysOverview_CmdPal) |
| [<img src="doc/images/icons/Crop%20And%20Lock.png" alt="Crop and Lock icon" height="16"> Crop And Lock](https://aka.ms/PowerToysOverview_CropAndLock) | [<img src="doc/images/icons/Environment%20Manager.png" alt="Environment Variables icon" height="16"> Environment Variables](https://aka.ms/PowerToysOverview_EnvironmentVariables) | [<img src="doc/images/icons/FancyZones.png" alt="FancyZones icon" height="16"> FancyZones](https://aka.ms/PowerToysOverview_FancyZones) |
| [<img src="doc/images/icons/File%20Explorer%20Preview.png" alt="File Explorer Add-ons icon" height="16"> File Explorer Add-ons](https://aka.ms/PowerToysOverview_FileExplorerAddOns) | [<img src="doc/images/icons/File%20Locksmith.png" alt="File Locksmith icon" height="16"> File Locksmith](https://aka.ms/PowerToysOverview_FileLocksmith) | [<img src="doc/images/icons/Host%20File%20Editor.png" alt="Hosts File Editor icon" height="16"> Hosts File Editor](https://aka.ms/PowerToysOverview_HostsFileEditor) |
| [<img src="doc/images/icons/Image%20Resizer.png" alt="Image Resizer icon" height="16"> Image Resizer](https://aka.ms/PowerToysOverview_ImageResizer) | [<img src="doc/images/icons/Keyboard%20Manager.png" alt="Keyboard Manager icon" height="16"> Keyboard Manager](https://aka.ms/PowerToysOverview_KeyboardManager) | [<img src="doc/images/icons/Light Switch.png" alt="Light Switch icon" height="16"> Light Switch](https://aka.ms/PowerToysOverview_LightSwitch) |
| [<img src="doc/images/icons/Find My Mouse.png" alt="Mouse Utilities icon" height="16"> Mouse Utilities](https://aka.ms/PowerToysOverview_MouseUtilities) | [<img src="doc/images/icons/MouseWithoutBorders.png" alt="Mouse Without Borders icon" height="16"> Mouse Without Borders](https://aka.ms/PowerToysOverview_MouseWithoutBorders) | [<img src="doc/images/icons/NewPlus.png" alt="New+ icon" height="16"> New+](https://aka.ms/PowerToysOverview_NewPlus) |
| [<img src="doc/images/icons/Peek.png" alt="Peek icon" height="16"> Peek](https://aka.ms/PowerToysOverview_Peek) | [<img src="doc/images/icons/PowerRename.png" alt="PowerRename icon" height="16"> PowerRename](https://aka.ms/PowerToysOverview_PowerRename) | [<img src="doc/images/icons/PowerToys%20Run.png" alt="PowerToys Run icon" height="16"> PowerToys Run](https://aka.ms/PowerToysOverview_PowerToysRun) |
| [<img src="doc/images/icons/PowerAccent.png" alt="Quick Accent icon" height="16"> Quick Accent](https://aka.ms/PowerToysOverview_QuickAccent) | [<img src="doc/images/icons/Registry%20Preview.png" alt="Registry Preview icon" height="16"> Registry Preview](https://aka.ms/PowerToysOverview_RegistryPreview) | [<img src="doc/images/icons/MeasureTool.png" alt="Screen Ruler icon" height="16"> Screen Ruler](https://aka.ms/PowerToysOverview_ScreenRuler) |
| [<img src="doc/images/icons/Shortcut%20Guide.png" alt="Shortcut Guide icon" height="16"> Shortcut Guide](https://aka.ms/PowerToysOverview_ShortcutGuide) | [<img src="doc/images/icons/PowerOCR.png" alt="Text Extractor icon" height="16"> Text Extractor](https://aka.ms/PowerToysOverview_TextExtractor) | [<img src="doc/images/icons/Workspaces.png" alt="Workspaces icon" height="16"> Workspaces](https://aka.ms/PowerToysOverview_Workspaces) |
| [<img src="doc/images/icons/ZoomIt.png" alt="ZoomIt icon" height="16"> ZoomIt](https://aka.ms/PowerToysOverview_ZoomIt) | | |
| [<img src="doc/images/icons/File%20Explorer%20Preview.png" alt="File Explorer Add-ons icon" height="16"> File Explorer Add-ons](https://aka.ms/PowerToysOverview_FileExplorerAddOns) | [<img src="doc/images/icons/File%20Locksmith.png" alt="File Locksmith icon" height="16"> File Locksmith](https://aka.ms/PowerToysOverview_FileLocksmith) | [<img src="doc/images/icons/GrabAndMove.png" alt="Grab And Move icon" height="16"> Grab And Move](https://aka.ms/PowerToysOverview_GrabAndMove) |
| [<img src="doc/images/icons/Host%20File%20Editor.png" alt="Hosts File Editor icon" height="16"> Hosts File Editor](https://aka.ms/PowerToysOverview_HostsFileEditor) | [<img src="doc/images/icons/Image%20Resizer.png" alt="Image Resizer icon" height="16"> Image Resizer](https://aka.ms/PowerToysOverview_ImageResizer) | [<img src="doc/images/icons/Keyboard%20Manager.png" alt="Keyboard Manager icon" height="16"> Keyboard Manager](https://aka.ms/PowerToysOverview_KeyboardManager) |
| [<img src="doc/images/icons/Light Switch.png" alt="Light Switch icon" height="16"> Light Switch](https://aka.ms/PowerToysOverview_LightSwitch) | [<img src="doc/images/icons/Find My Mouse.png" alt="Mouse Utilities icon" height="16"> Mouse Utilities](https://aka.ms/PowerToysOverview_MouseUtilities) | [<img src="doc/images/icons/MouseWithoutBorders.png" alt="Mouse Without Borders icon" height="16"> Mouse Without Borders](https://aka.ms/PowerToysOverview_MouseWithoutBorders) |
| [<img src="doc/images/icons/NewPlus.png" alt="New+ icon" height="16"> New+](https://aka.ms/PowerToysOverview_NewPlus) | [<img src="doc/images/icons/Peek.png" alt="Peek icon" height="16"> Peek](https://aka.ms/PowerToysOverview_Peek) | [<img src="doc/images/icons/PowerDisplay.png" alt="PowerDisplay icon" height="16"> PowerDisplay](https://aka.ms/PowerToysOverview_PowerDisplay) |
| [<img src="doc/images/icons/PowerRename.png" alt="PowerRename icon" height="16"> PowerRename](https://aka.ms/PowerToysOverview_PowerRename) | [<img src="doc/images/icons/PowerToys%20Run.png" alt="PowerToys Run icon" height="16"> PowerToys Run](https://aka.ms/PowerToysOverview_PowerToysRun) | [<img src="doc/images/icons/PowerAccent.png" alt="Quick Accent icon" height="16"> Quick Accent](https://aka.ms/PowerToysOverview_QuickAccent) |
| [<img src="doc/images/icons/Registry%20Preview.png" alt="Registry Preview icon" height="16"> Registry Preview](https://aka.ms/PowerToysOverview_RegistryPreview) | [<img src="doc/images/icons/MeasureTool.png" alt="Screen Ruler icon" height="16"> Screen Ruler](https://aka.ms/PowerToysOverview_ScreenRuler) | [<img src="doc/images/icons/Shortcut%20Guide.png" alt="Shortcut Guide icon" height="16"> Shortcut Guide](https://aka.ms/PowerToysOverview_ShortcutGuide) |
| [<img src="doc/images/icons/PowerOCR.png" alt="Text Extractor icon" height="16"> Text Extractor](https://aka.ms/PowerToysOverview_TextExtractor) | [<img src="doc/images/icons/Workspaces.png" alt="Workspaces icon" height="16"> Workspaces](https://aka.ms/PowerToysOverview_Workspaces) | [<img src="doc/images/icons/ZoomIt.png" alt="ZoomIt icon" height="16"> ZoomIt](https://aka.ms/PowerToysOverview_ZoomIt) |
## 📦 Installation

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 84 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 56 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 66 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 318 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 141 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 182 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 111 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 41 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

View File

@@ -17,6 +17,7 @@
<ItemGroup>
<PackageReference Include="Microsoft.WindowsAppSDK" />
<PackageReference Include="WinUIEx" />
<PackageReference Include="CommunityToolkit.WinUI.Controls.Primitives" />
<PackageReference Include="CommunityToolkit.WinUI.Extensions" />
<PackageReference Include="CommunityToolkit.WinUI.Converters" />

View File

@@ -0,0 +1,223 @@
// Copyright (c) Microsoft Corporation
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
using System;
using System.Runtime.InteropServices;
using ManagedCommon;
using Microsoft.UI.Windowing;
using Windows.Graphics;
using WinUIEx;
namespace Microsoft.PowerToys.Common.UI.Controls.Flyout;
/// <summary>
/// Shared helper for positioning and sizing flyout-style WinUI 3 windows
/// (e.g. Quick Access, PowerDisplay) that are pinned to a corner of the work area.
///
/// The public API takes sizes in device-independent pixels (DIP). The helper resolves the
/// target monitor's effective DPI and converts to physical pixels. All window positioning
/// uses absolute screen physical-pixel coordinates via
/// <see cref="AppWindow.MoveAndResize(RectInt32)"/> — the same pattern used by the original
/// Settings.UI flyout, which proved reliable across multi-monitor and mixed-DPI setups.
/// </summary>
public static partial class FlyoutWindowHelper
{
private const uint MdtEffectiveDpi = 0;
private const int DefaultDpi = 96;
[StructLayout(LayoutKind.Sequential)]
private struct POINT
{
public int X;
public int Y;
}
[LibraryImport("user32.dll")]
[return: MarshalAs(UnmanagedType.Bool)]
private static partial bool GetCursorPos(out POINT lpPoint);
[LibraryImport("shcore.dll")]
private static partial int GetDpiForMonitor(nint hMonitor, uint dpiType, out uint dpiX, out uint dpiY);
/// <summary>
/// Get the DPI scale factor (1.0 = 100%, 1.25 = 125%, 1.5 = 150%, 2.0 = 200%) for a window.
/// </summary>
public static double GetDpiScale(WindowEx window)
{
ArgumentNullException.ThrowIfNull(window);
return (double)window.GetDpiForWindow() / DefaultDpi;
}
/// <summary>
/// Get the DPI scale factor for a given <see cref="DisplayArea"/>.
/// Resolves DPI from the underlying monitor handle so the value reflects the
/// target display, regardless of which monitor the window is currently on.
/// </summary>
public static double GetDpiScale(DisplayArea displayArea)
{
ArgumentNullException.ThrowIfNull(displayArea);
return (double)GetEffectiveDpi(global::Microsoft.UI.Win32Interop.GetMonitorFromDisplayId(displayArea.DisplayId)) / DefaultDpi;
}
/// <summary>
/// Convert device-independent pixels (DIP) to physical pixels (rounding up).
/// </summary>
public static int ScaleToPhysicalPixels(int dip, double dpiScale)
{
return (int)Math.Ceiling(dip * dpiScale);
}
/// <summary>
/// Convert physical pixels to device-independent pixels (DIP) (rounding down).
/// </summary>
public static int ScaleToDip(int physicalPixels, double dpiScale)
{
return (int)Math.Floor(physicalPixels / dpiScale);
}
/// <summary>
/// Look up the <see cref="DisplayArea"/> currently containing the mouse cursor.
/// </summary>
public static bool TryGetDisplayAreaAtCursor(out DisplayArea? displayArea)
{
displayArea = null;
if (!GetCursorPos(out var cursorPos))
{
return false;
}
displayArea = DisplayArea.GetFromPoint(new PointInt32(cursorPos.X, cursorPos.Y), DisplayAreaFallback.Nearest);
return displayArea is not null;
}
/// <summary>
/// Position a flyout-style window at the bottom-right corner of the work area on the
/// monitor under the mouse cursor.
/// </summary>
public static void PositionWindowBottomRight(
WindowEx window,
int widthDip,
int heightDip,
int rightMarginDip = 0,
int bottomMarginDip = 0)
{
ArgumentNullException.ThrowIfNull(window);
if (!TryGetDisplayAreaAtCursor(out var displayArea) || displayArea is null)
{
Logger.LogWarning("FlyoutWindowHelper.PositionWindowBottomRight: unable to determine display from cursor; skipping positioning");
return;
}
PositionWindowBottomRight(window, displayArea, widthDip, heightDip, rightMarginDip, bottomMarginDip);
}
/// <summary>
/// Position a flyout-style window at the bottom-right corner of the specified display
/// area's work area. Use this overload when the caller has already resolved the target
/// <see cref="DisplayArea"/> (e.g. the cursor monitor) so size and placement are computed
/// from the same source.
///
/// Internally moves the window in two steps to avoid <c>WM_DPICHANGED</c> double-scaling
/// when the target monitor has a different DPI than the one the window was previously on:
/// first a 1×1 teleport into the target display, then the real position+size while the
/// window is already on that monitor (no DPI boundary crossing).
/// </summary>
public static void PositionWindowBottomRight(
WindowEx window,
DisplayArea displayArea,
int widthDip,
int heightDip,
int rightMarginDip = 0,
int bottomMarginDip = 0)
{
ArgumentNullException.ThrowIfNull(window);
ArgumentNullException.ThrowIfNull(displayArea);
double dpiScale = GetDpiScale(displayArea);
var work = displayArea.WorkArea;
int w = ScaleToPhysicalPixels(widthDip, dpiScale);
int h = ScaleToPhysicalPixels(heightDip, dpiScale);
int marginRight = ScaleToPhysicalPixels(rightMarginDip, dpiScale);
int marginBottom = ScaleToPhysicalPixels(bottomMarginDip, dpiScale);
// Clamp size so the window never extends past the work area minus margins.
// Guards against the bottom/right edge spilling into the taskbar when rounding
// (Math.Ceiling above) would push it just past the boundary.
int maxW = Math.Max(0, work.Width - marginRight);
int maxH = Math.Max(0, work.Height - marginBottom);
w = Math.Min(w, maxW);
h = Math.Min(h, maxH);
// Absolute screen physical-pixel coordinates. WorkArea is in screen coordinates,
// so for non-primary monitors WorkArea.X/Y will be non-zero (and may be negative).
int x = work.X + work.Width - w - marginRight;
int y = work.Y + work.Height - h - marginBottom;
MoveAndResizeOnDisplay(window, displayArea, new RectInt32(x, y, w, h));
}
/// <summary>
/// Center a window within the specified display area's work area.
/// Uses a 1×1 teleport into the target display first to avoid WM_DPICHANGED
/// double-scaling when crossing monitors with different DPI.
/// </summary>
public static void CenterWindowOnDisplay(
WindowEx window,
DisplayArea displayArea,
int widthDip,
int heightDip)
{
ArgumentNullException.ThrowIfNull(window);
ArgumentNullException.ThrowIfNull(displayArea);
double dpiScale = GetDpiScale(displayArea);
var work = displayArea.WorkArea;
int w = Math.Min(ScaleToPhysicalPixels(widthDip, dpiScale), work.Width);
int h = Math.Min(ScaleToPhysicalPixels(heightDip, dpiScale), work.Height);
int x = work.X + ((work.Width - w) / 2);
int y = work.Y + ((work.Height - h) / 2);
MoveAndResizeOnDisplay(window, displayArea, new RectInt32(x, y, w, h));
}
/// <summary>
/// Two-step move that avoids WM_DPICHANGED double-scaling. First teleports a 1×1
/// window into the target display (which may trigger an auto-rescale, but on a 1×1
/// rect the effect is invisible). Then sets the real position+size while the window
/// is already on the target monitor — no DPI boundary crossing, so WinUI's auto
/// handler doesn't fire and overwrite our computed rect.
///
/// Skips the teleport when the window is already on the target display, since there
/// is no boundary to cross.
/// </summary>
private static void MoveAndResizeOnDisplay(WindowEx window, DisplayArea targetDisplay, RectInt32 finalRect)
{
var currentDisplay = DisplayArea.GetFromWindowId(window.AppWindow.Id, DisplayAreaFallback.Nearest);
bool needsTeleport = currentDisplay is null || currentDisplay.DisplayId.Value != targetDisplay.DisplayId.Value;
if (needsTeleport)
{
var work = targetDisplay.WorkArea;
window.AppWindow.MoveAndResize(new RectInt32(work.X, work.Y, 1, 1));
}
window.AppWindow.MoveAndResize(finalRect);
}
private static int GetEffectiveDpi(nint hMonitor)
{
if (hMonitor == 0)
{
return DefaultDpi;
}
var hr = GetDpiForMonitor(hMonitor, MdtEffectiveDpi, out var dpiX, out _);
return hr >= 0 && dpiX > 0 ? (int)dpiX : DefaultDpi;
}
}

View File

@@ -0,0 +1,92 @@
// Copyright (c) Microsoft Corporation
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
using System;
using System.Runtime.InteropServices;
using WinUIEx;
namespace Microsoft.PowerToys.Common.UI.Controls.Window;
/// <summary>
/// Subclasses a window's WndProc and invokes a preprocessor callback for every
/// message before the default window procedure runs. Useful for routing low-level
/// Win32 messages (e.g. <c>WM_HOTKEY</c>) into managed handlers without depending
/// on the WinUI XAML message loop.
/// </summary>
/// <remarks>
/// Usage:
/// <code>
/// _hook = new WindowMessageHook(window, (uMsg, wParam, lParam) =>
/// _hotkeyService.HandleMessage(uMsg, wParam));
/// </code>
/// Dispose to restore the original WndProc.
/// </remarks>
public sealed partial class WindowMessageHook : IDisposable
{
// Called for every message before default processing. Return true to swallow.
private readonly Func<uint, nuint, nint, bool> _preProcessor;
private const int GwlWndProc = -4;
private readonly nint _hwnd;
private nint _originalWndProc;
private WndProcDelegate? _wndProcDelegate;
private bool _disposed;
private delegate nint WndProcDelegate(nint hwnd, uint uMsg, nuint wParam, nint lParam);
[LibraryImport("user32.dll", EntryPoint = "SetWindowLongPtrW")]
private static partial nint SetWindowLongPtr(nint hWnd, int nIndex, nint dwNewLong);
[LibraryImport("user32.dll", EntryPoint = "CallWindowProcW")]
private static partial nint CallWindowProc(nint lpPrevWndFunc, nint hWnd, uint msg, nuint wParam, nint lParam);
/// <summary>
/// Initializes a new instance of the <see cref="WindowMessageHook"/> class
/// and subclasses the supplied window's WndProc.
/// </summary>
/// <param name="window">Window to subclass.</param>
/// <param name="preProcessor">Callback invoked for every message before the
/// default WndProc. Receives <c>(uMsg, wParam, lParam)</c>. Return
/// <see langword="true"/> to swallow the message.</param>
public WindowMessageHook(WindowEx window, Func<uint, nuint, nint, bool> preProcessor)
{
ArgumentNullException.ThrowIfNull(window);
ArgumentNullException.ThrowIfNull(preProcessor);
_hwnd = window.GetWindowHandle();
_preProcessor = preProcessor;
_wndProcDelegate = WndProc;
var ptr = Marshal.GetFunctionPointerForDelegate(_wndProcDelegate);
_originalWndProc = SetWindowLongPtr(_hwnd, GwlWndProc, ptr);
}
private nint WndProc(nint hwnd, uint uMsg, nuint wParam, nint lParam)
{
if (_preProcessor(uMsg, wParam, lParam))
{
return 0;
}
return CallWindowProc(_originalWndProc, hwnd, uMsg, wParam, lParam);
}
public void Dispose()
{
if (_disposed)
{
return;
}
_disposed = true;
if (_originalWndProc != 0)
{
SetWindowLongPtr(_hwnd, GwlWndProc, _originalWndProc);
_originalWndProc = 0;
}
_wndProcDelegate = null;
}
}

View File

@@ -13,8 +13,15 @@ namespace Microsoft.Interop.Tests
[TestClass]
public class InteropTests : IDisposable
{
private const string ServerSidePipe = "\\\\.\\pipe\\serverside";
private const string ClientSidePipe = "\\\\.\\pipe\\clientside";
// Pipe names are machine-global, so two concurrent test runs on the same CI agent
// (or a leaked handle from a prior run) would deadlock if we used a shared constant.
// Suffix with process id + a GUID so every test run gets its own pair.
private const string PipePrefix = @"\\.\pipe\";
private static readonly string PipeSuffix = $"{Environment.ProcessId}_{Guid.NewGuid():N}";
private static readonly string ServerSidePipe = $"{PipePrefix}serverside_{PipeSuffix}";
private static readonly string ClientSidePipe = $"{PipePrefix}clientside_{PipeSuffix}";
private static readonly TimeSpan MessageWaitTimeout = TimeSpan.FromSeconds(30);
internal TwoWayPipeMessageIPCManaged ClientPipe { get; set; }
@@ -54,7 +61,11 @@ namespace Microsoft.Interop.Tests
Thread.Sleep(100);
ClientPipe.Send(testString);
reset.WaitOne();
// Bounded wait so a broken pipe handshake fails the test quickly
// instead of hanging the CI agent until the job-level timeout.
var timeoutMessage = $"Pipe callback was not invoked within {MessageWaitTimeout.TotalSeconds}s. Server='{ServerSidePipe}' Client='{ClientSidePipe}'.";
Assert.IsTrue(reset.WaitOne(MessageWaitTimeout), timeoutMessage);
serverPipe.End();
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 766 B

After

Width:  |  Height:  |  Size: 372 KiB

View File

@@ -26,6 +26,7 @@
"src\\modules\\cmdpal\\Tests\\Microsoft.CmdPal.Ext.Bookmarks.UnitTests\\Microsoft.CmdPal.Ext.Bookmarks.UnitTests.csproj",
"src\\modules\\cmdpal\\Tests\\Microsoft.CmdPal.Ext.Calc.UnitTests\\Microsoft.CmdPal.Ext.Calc.UnitTests.csproj",
"src\\modules\\cmdpal\\Tests\\Microsoft.CmdPal.Ext.ClipboardHistory.UnitTests\\Microsoft.CmdPal.Ext.ClipboardHistory.UnitTests.csproj",
"src\\modules\\cmdpal\\Tests\\Microsoft.CmdPal.Ext.Indexer.UnitTests\\Microsoft.CmdPal.Ext.Indexer.UnitTests.csproj",
"src\\modules\\cmdpal\\Tests\\Microsoft.CmdPal.Ext.Registry.UnitTests\\Microsoft.CmdPal.Ext.Registry.UnitTests.csproj",
"src\\modules\\cmdpal\\Tests\\Microsoft.CmdPal.Ext.RemoteDesktop.UnitTests\\Microsoft.CmdPal.Ext.RemoteDesktop.UnitTests.csproj",
"src\\modules\\cmdpal\\Tests\\Microsoft.CmdPal.Ext.Shell.UnitTests\\Microsoft.CmdPal.Ext.Shell.UnitTests.csproj",
@@ -62,4 +63,4 @@
"src\\settings-ui\\Settings.UI.Library\\Settings.UI.Library.csproj"
]
}
}
}

View File

@@ -2,14 +2,9 @@
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
namespace Microsoft.CmdPal.Ext.ClipboardHistory.Messages;
namespace Microsoft.CmdPal.Core.Common.Messages;
/// <summary>
/// Message to request hiding the window.
///
/// Yes, it's a little weird that this lives in the ClipboardHistory extension.
/// Until we need it somewhere else, this is good enough.
/// </summary>
public partial record HideWindowMessage()
{
}
public partial record HideWindowMessage();

View File

@@ -0,0 +1,10 @@
// Copyright (c) Microsoft Corporation
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
namespace Microsoft.CmdPal.Common.Messages;
/// <summary>
/// Message to request hiding the window.
/// </summary>
public sealed partial record HideWindowMessage;

View File

@@ -86,7 +86,14 @@ public partial class CommandItemViewModel : ExtensionObjectViewModel, ICommandBa
public CommandItemViewModel? SecondaryCommand => _secondaryMoreCommand;
public bool CanOpenContextMenu => AllCommands.Any(item => item is CommandItemViewModel command && command.ShouldBeVisible);
public bool CanOpenContextMenu =>
// BEAR LOADING: A visible synthetic primary command makes the item
// context-openable immediately, even if out-of-proc MoreCommands are still
// hydrating. Without this fast path, the first open request can race slow
// menu initialization and get dropped.
_defaultCommandContextItemViewModel?.ShouldBeVisible == true ||
_moreCommandsSnapshot.Any(item => item is CommandItemViewModel command && command.ShouldBeVisible);
public bool ShouldBeVisible => !string.IsNullOrEmpty(Name);
@@ -132,13 +139,15 @@ public partial class CommandItemViewModel : ExtensionObjectViewModel, ICommandBa
return;
}
Command = new(model.Command, PageContext);
var command = model.Command;
Command = new(command, PageContext);
Command.FastInitializeProperties();
_itemTitle = model.Title;
Subtitle = model.Subtitle;
_titleCache.Invalidate();
_subtitleCache.Invalidate();
TryCreateDefaultCommandContextItem(command);
Initialized |= InitializedState.FastInitialized;
}
@@ -215,7 +224,7 @@ public partial class CommandItemViewModel : ExtensionObjectViewModel, ICommandBa
BuildAndInitMoreCommands();
TryCreateDefaultCommandContextItem(model);
TryCreateDefaultCommandContextItem(model.Command);
lock (_moreCommandsLock)
{
@@ -316,7 +325,8 @@ public partial class CommandItemViewModel : ExtensionObjectViewModel, ICommandBa
{
case nameof(Command):
Command.PropertyChanged -= Command_PropertyChanged;
Command = new(model.Command, PageContext);
var command = model.Command;
Command = new(command, PageContext);
Command.InitializeProperties();
Command.PropertyChanged += Command_PropertyChanged;
@@ -332,7 +342,7 @@ public partial class CommandItemViewModel : ExtensionObjectViewModel, ICommandBa
}
else
{
TryCreateDefaultCommandContextItem(model);
TryCreateDefaultCommandContextItem(command);
}
UpdateProperty(nameof(Name));
@@ -407,7 +417,7 @@ public partial class CommandItemViewModel : ExtensionObjectViewModel, ICommandBa
}
else
{
TryCreateDefaultCommandContextItem(model);
TryCreateDefaultCommandContextItem(model.Command);
}
break;
@@ -427,19 +437,22 @@ public partial class CommandItemViewModel : ExtensionObjectViewModel, ICommandBa
/// When a new instance is created, the snapshot is refreshed and
/// <see cref="AllCommands"/> is notified.
/// </summary>
private void TryCreateDefaultCommandContextItem(ICommandItem model)
private void TryCreateDefaultCommandContextItem(ICommand? commandModel)
{
if (_defaultCommandContextItemViewModel is not null)
{
return;
}
if (string.IsNullOrEmpty(model.Command?.Name))
// We only synthesize the primary entry when the command is already
// usable; a null/empty primary must still fall back to late
// MoreCommands-based opening.
if (string.IsNullOrEmpty(Command.Name) || commandModel is null)
{
return;
}
_defaultCommandContextItemViewModel = new CommandContextItemViewModel(new CommandContextItem(model.Command!), PageContext)
_defaultCommandContextItemViewModel = new CommandContextItemViewModel(new CommandContextItem(commandModel), PageContext)
{
_itemTitle = Name,
Subtitle = Subtitle,

View File

@@ -199,8 +199,15 @@ public partial class ExtensionService : IExtensionService, IDisposable
var extensions = await GetInstalledAppExtensionsAsync();
foreach (var extension in extensions)
{
var wrappers = await CreateWrappersForExtension(extension);
UpdateExtensionsListsFromWrappers(wrappers);
try
{
var wrappers = await CreateWrappersForExtension(extension);
UpdateExtensionsListsFromWrappers(wrappers);
}
catch (Exception ex)
{
Logger.LogError($"Failed to load extension '{extension.DisplayName}': {ex.Message}");
}
}
}
@@ -245,8 +252,15 @@ public partial class ExtensionService : IExtensionService, IDisposable
List<ExtensionWrapper> wrappers = [];
foreach (var classId in classIds)
{
var extensionWrapper = CreateExtensionWrapper(extension, cmdPalProvider, classId);
wrappers.Add(extensionWrapper);
try
{
var extensionWrapper = CreateExtensionWrapper(extension, cmdPalProvider, classId);
wrappers.Add(extensionWrapper);
}
catch (Exception ex)
{
Logger.LogError($"Failed to create wrapper for extension '{extension.DisplayName}' classId '{classId}': {ex.Message}");
}
}
return wrappers;

View File

@@ -18,9 +18,7 @@ public record DockSettings
{
public DockSide Side { get; init; } = DockSide.Top;
public DockSize DockSize { get; init; } = DockSize.Small;
public DockSize DockIconsSize { get; init; } = DockSize.Small;
public DockSize DockSize { get; init; } = DockSize.Default;
public bool AlwaysOnTop { get; set; } = true;
@@ -139,9 +137,8 @@ public enum DockSide
public enum DockSize
{
Small,
Medium,
Large,
Default,
Compact,
}
public enum DockBackdrop

View File

@@ -4,7 +4,7 @@
using CommunityToolkit.Mvvm.Messaging;
using CommunityToolkit.WinUI;
using Microsoft.CmdPal.Ext.ClipboardHistory.Messages;
using Microsoft.CmdPal.Common.Messages;
using Microsoft.CmdPal.UI.Helpers;
using Microsoft.CmdPal.UI.ViewModels;
using Microsoft.CmdPal.UI.ViewModels.Commands;

View File

@@ -17,12 +17,9 @@
<UserControl.Resources>
<ResourceDictionary>
<StackLayout
x:Key="ItemsOrientationLayout"
Orientation="{x:Bind ItemsOrientation, Mode=OneWay}"
Spacing="4" />
<StackLayout x:Key="ItemsOrientationLayout" Orientation="{x:Bind ItemsOrientation, Mode=OneWay}" />
<ItemsPanelTemplate x:Key="HorizontalItemsPanel">
<StackPanel Orientation="Horizontal" Spacing="4" />
<StackPanel Orientation="Horizontal" />
</ItemsPanelTemplate>
<ItemsPanelTemplate x:Key="VerticalItemsPanel">
<StackPanel Orientation="Vertical" Spacing="4" />
@@ -76,7 +73,7 @@
<Style x:Key="DockBandListViewItemStyle" TargetType="ListViewItem">
<Setter Property="Padding" Value="0" />
<Setter Property="Margin" Value="0,0,4,0" />
<Setter Property="Margin" Value="0" />
<Setter Property="MinHeight" Value="0" />
<Setter Property="MinWidth" Value="0" />
<Setter Property="HorizontalContentAlignment" Value="Stretch" />
@@ -209,13 +206,13 @@
<Grid
x:Name="RootGrid"
Background="Transparent"
BorderBrush="{ThemeResource SurfaceStrokeColorDefaultBrush}"
BorderThickness="0,0,0,1"
RightTapped="RootGrid_RightTapped">
<!-- Dock content with Start / Center / End sections -->
<local:DockContentControl
x:Name="ContentGrid"
Margin="4"
Background="Transparent"
IsEditMode="{x:Bind IsEditMode, Mode=OneWay}"
RightTapped="RootGrid_RightTapped">
<local:DockContentControl.StartSource>
@@ -247,7 +244,6 @@
<FontIcon FontSize="12" Glyph="&#xE710;" />
</Button>
</local:DockContentControl.StartActionButton>
<local:DockContentControl.CenterSource>
<ListView
x:Name="CenterListView"
@@ -282,6 +278,8 @@
<ListView
x:Name="EndListView"
MinWidth="48"
MinHeight="0"
HorizontalContentAlignment="Stretch"
DragEnter="BandListView_DragEnter"
DragItemsCompleted="BandListView_DragItemsCompleted"
DragItemsStarting="BandListView_DragItemsStarting"
@@ -311,7 +309,6 @@
</Button>
</local:DockContentControl.EndActionButton>
</local:DockContentControl>
<TeachingTip
x:Name="EditButtonsTeachingTip"
MinWidth="0"
@@ -344,7 +341,7 @@
<ui:IsEqualStateTrigger Value="{x:Bind DockSide, Mode=OneWay}" To="Top" />
</VisualState.StateTriggers>
<VisualState.Setters>
<Setter Target="ContentGrid.Margin" Value="4,0,4,4" />
<Setter Target="ContentGrid.Margin" Value="4,0,4,0" />
</VisualState.Setters>
</VisualState>
<VisualState x:Name="DockOnBottom">
@@ -391,6 +388,25 @@
</VisualState.Setters>
</VisualState>
</VisualStateGroup>
<!--
Compact overrides: zeroes margins/borders set by DockOrientation.
Declared after DockOrientation so its setters win when both groups
target the same property.
-->
<VisualStateGroup x:Name="DockSizeStates">
<VisualState x:Name="DefaultSize" />
<VisualState x:Name="CompactSize">
<VisualState.StateTriggers>
<ui:IsEqualStateTrigger Value="{x:Bind DockSize, Mode=OneWay}" To="Compact" />
</VisualState.StateTriggers>
<VisualState.Setters>
<Setter Target="ContentGrid.Margin" Value="0" />
<Setter Target="ContentGrid.Padding" Value="0" />
<Setter Target="RootGrid.BorderThickness" Value="0" />
</VisualState.Setters>
</VisualState>
</VisualStateGroup>
</VisualStateManager.VisualStateGroups>
</Grid>
</UserControl>

View File

@@ -47,6 +47,15 @@ public sealed partial class DockControl : UserControl, IRecipient<CloseContextMe
set => SetValue(DockSideProperty, value);
}
public static readonly DependencyProperty DockSizeProperty =
DependencyProperty.Register(nameof(DockSize), typeof(DockSize), typeof(DockControl), new PropertyMetadata(DockSize.Default));
public DockSize DockSize
{
get => (DockSize)GetValue(DockSizeProperty);
set => SetValue(DockSizeProperty, value);
}
public static readonly DependencyProperty IsEditModeProperty =
DependencyProperty.Register(nameof(IsEditMode), typeof(bool), typeof(DockControl), new PropertyMetadata(false, OnIsEditModeChanged));
@@ -234,7 +243,10 @@ public sealed partial class DockControl : UserControl, IRecipient<CloseContextMe
{
DockSide = settings.Side;
// Compact mode is only supported for Top/Bottom positions
var isHorizontal = settings.Side == DockSide.Top || settings.Side == DockSide.Bottom;
var effectiveSize = isHorizontal ? settings.DockSize : DockSize.Default;
DockSize = effectiveSize;
ItemsOrientation = isHorizontal ? Orientation.Horizontal : Orientation.Vertical;
@@ -290,6 +302,11 @@ public sealed partial class DockControl : UserControl, IRecipient<CloseContextMe
ShowTitlesMenuItem.IsChecked = _editModeContextBand.ShowTitles;
ShowSubtitlesMenuItem.IsChecked = _editModeContextBand.ShowSubtitles;
// Hide subtitle toggle in compact mode — no subtitle in the template
ShowSubtitlesMenuItem.Visibility = DockSize == DockSize.Compact
? Visibility.Collapsed
: Visibility.Visible;
PreparePopupForShow(EditModeContextMenu, dockItem);
EditModeContextMenu.ShowAt(
dockItem,

View File

@@ -43,7 +43,7 @@
<CornerRadius x:Key="DockItemCornerRadius">4</CornerRadius>
<Thickness x:Key="DockItemPadding">4,0,4,0</Thickness>
<Thickness x:Key="DockItemMargin">2,0,2,0</Thickness>
<Style BasedOn="{StaticResource DefaultDockItemControlStyle}" TargetType="local:DockItemControl" />
<Style x:Key="DefaultDockItemControlStyle" TargetType="local:DockItemControl">
@@ -60,12 +60,13 @@
<Setter Property="Template">
<Setter.Value>
<ControlTemplate TargetType="local:DockItemControl">
<Grid x:Name="PART_HitTestGrid" Background="Transparent">
<Grid
x:Name="PART_RootGrid"
Padding="{StaticResource DockItemMargin}"
Background="Transparent">
<Grid
x:Name="PART_RootGrid"
x:Name="PART_BackPlate"
MinWidth="32"
MinHeight="30"
Margin="{TemplateBinding InnerMargin}"
Padding="{TemplateBinding Padding}"
VerticalAlignment="Stretch"
Background="{TemplateBinding Background}"
@@ -128,20 +129,20 @@
<VisualState x:Name="Normal" />
<VisualState x:Name="PointerOver">
<VisualState.Setters>
<Setter Target="PART_RootGrid.Background" Value="{ThemeResource DockItemBackgroundPointerOver}" />
<Setter Target="PART_RootGrid.BorderBrush" Value="{ThemeResource DockItemBorderBrushPointerOver}" />
<Setter Target="PART_BackPlate.Background" Value="{ThemeResource DockItemBackgroundPointerOver}" />
<Setter Target="PART_BackPlate.BorderBrush" Value="{ThemeResource DockItemBorderBrushPointerOver}" />
</VisualState.Setters>
</VisualState>
<VisualState x:Name="Pressed">
<VisualState.Setters>
<Setter Target="PART_RootGrid.Background" Value="{ThemeResource DockItemBackgroundPointerOver}" />
<Setter Target="PART_RootGrid.BorderBrush" Value="{ThemeResource DockItemBorderBrushPressed}" />
<Setter Target="PART_BackPlate.Background" Value="{ThemeResource DockItemBackgroundPointerOver}" />
<Setter Target="PART_BackPlate.BorderBrush" Value="{ThemeResource DockItemBorderBrushPressed}" />
</VisualState.Setters>
</VisualState>
<VisualState x:Name="Disabled">
<VisualState.Setters>
<Setter Target="PART_RootGrid.Background" Value="{ThemeResource DockItemBackground}" />
<Setter Target="PART_RootGrid.BorderBrush" Value="{ThemeResource DockItemBackground}" />
<Setter Target="PART_BackPlate.Background" Value="{ThemeResource DockItemBackground}" />
<Setter Target="PART_BackPlate.BorderBrush" Value="{ThemeResource DockItemBackground}" />
<Setter Target="IconPresenter.Foreground" Value="{ThemeResource ButtonForegroundDisabled}" />
<Setter Target="TitleText.Foreground" Value="{ThemeResource ButtonForegroundDisabled}" />
<Setter Target="SubtitleText.Foreground" Value="{ThemeResource ButtonForegroundDisabled}" />
@@ -192,6 +193,16 @@
</VisualState.Setters>
</VisualState>
</VisualStateGroup>
<VisualStateGroup x:Name="CompactStates">
<VisualState x:Name="DefaultLayout" />
<VisualState x:Name="Compact">
<VisualState.Setters>
<Setter Target="PART_RootGrid.Padding" Value="0" />
<Setter Target="SubtitleText.Visibility" Value="Collapsed" />
<Setter Target="TitleText.Margin" Value="0,-1,0,0" />
</VisualState.Setters>
</VisualState>
</VisualStateGroup>
</VisualStateManager.VisualStateGroups>
</Grid>
</ControlTemplate>

View File

@@ -84,12 +84,35 @@ public sealed partial class DockItemControl : Control
set => SetValue(TextVisibilityProperty, value);
}
public static readonly DependencyProperty IsCompactProperty =
DependencyProperty.Register(nameof(IsCompact), typeof(bool), typeof(DockItemControl), new PropertyMetadata(false, OnIsCompactPropertyChanged));
public bool IsCompact
{
get => (bool)GetValue(IsCompactProperty);
set => SetValue(IsCompactProperty, value);
}
private static void OnIsCompactPropertyChanged(DependencyObject d, DependencyPropertyChangedEventArgs e)
{
if (d is DockItemControl control)
{
control.UpdateCompactState();
}
}
private void UpdateCompactState()
{
VisualStateManager.GoToState(this, IsCompact ? "Compact" : "DefaultLayout", true);
}
private const string IconPresenterName = "IconPresenter";
private FrameworkElement? _iconPresenter;
private DockControl? _parentDock;
private ToolTip? _toolTip;
private long _dockSideCallbackToken = -1;
private long _dockSizeCallbackToken = -1;
private static void OnTextPropertyChanged(DependencyObject d, DependencyPropertyChangedEventArgs e)
{
@@ -122,6 +145,14 @@ public sealed partial class DockItemControl : Control
private void UpdateTextVisibilityState()
{
// When TextVisibility is Collapsed, always hide text and collapse the
// grid column/spacing so the icon-only layout doesn't waste space.
if (TextVisibility == Visibility.Collapsed)
{
VisualStateManager.GoToState(this, "TextHidden", true);
return;
}
// Determine which visual state to use based on title/subtitle presence
var stateName = (HasTitle, HasSubtitle) switch
{
@@ -184,6 +215,7 @@ public sealed partial class DockItemControl : Control
UpdateIconVisibility();
UpdateToolTip();
UpdateAlignment();
UpdateCompactState();
}
private void UpdateToolTip()
@@ -249,10 +281,14 @@ public sealed partial class DockItemControl : Control
{
_parentDock = dock;
UpdateInnerMarginForDockSide(dock.DockSide);
UpdateCompactFromParent(dock);
UpdateAllVisibility();
_dockSideCallbackToken = dock.RegisterPropertyChangedCallback(
DockControl.DockSideProperty,
OnParentDockSideChanged);
_dockSizeCallbackToken = dock.RegisterPropertyChangedCallback(
DockControl.DockSizeProperty,
OnParentDockSizeChanged);
}
UpdateToolTip();
@@ -266,12 +302,24 @@ public sealed partial class DockItemControl : Control
private void DockItemControl_Unloaded(object sender, RoutedEventArgs e)
{
if (_parentDock is not null && _dockSideCallbackToken >= 0)
if (_parentDock is not null)
{
_parentDock.UnregisterPropertyChangedCallback(
DockControl.DockSideProperty,
_dockSideCallbackToken);
_dockSideCallbackToken = -1;
if (_dockSideCallbackToken >= 0)
{
_parentDock.UnregisterPropertyChangedCallback(
DockControl.DockSideProperty,
_dockSideCallbackToken);
_dockSideCallbackToken = -1;
}
if (_dockSizeCallbackToken >= 0)
{
_parentDock.UnregisterPropertyChangedCallback(
DockControl.DockSizeProperty,
_dockSizeCallbackToken);
_dockSizeCallbackToken = -1;
}
_parentDock = null;
}
@@ -283,11 +331,23 @@ public sealed partial class DockItemControl : Control
{
if (sender is DockControl dock)
{
UpdateInnerMarginForDockSide(dock.DockSide);
UpdateAlignment();
}
}
private void OnParentDockSizeChanged(DependencyObject sender, DependencyProperty dp)
{
if (sender is DockControl dock)
{
UpdateCompactFromParent(dock);
}
}
private void UpdateCompactFromParent(DockControl dock)
{
IsCompact = dock.DockSize == DockSize.Compact;
}
private void UpdateInnerMarginForDockSide(DockSide side)
{
// Push the visual (PART_RootGrid) inward on the screen-edge side so
@@ -296,7 +356,7 @@ public sealed partial class DockItemControl : Control
// DockControl's ContentGrid on the screen-edge side.
InnerMargin = side switch
{
DockSide.Top => new Thickness(0, 4, 0, 0),
DockSide.Top => new Thickness(0, 0, 0, 0),
DockSide.Bottom => new Thickness(0, 0, 0, 4),
DockSide.Left => new Thickness(8, 0, 0, 0),
DockSide.Right => new Thickness(0, 0, 8, 0),

View File

@@ -13,9 +13,8 @@ internal static class DockSettingsToViews
{
return size switch
{
DockSize.Small => 128,
DockSize.Medium => 192,
DockSize.Large => 256,
DockSize.Default => 86,
DockSize.Compact => 86,
_ => throw new NotImplementedException(),
};
}
@@ -24,9 +23,8 @@ internal static class DockSettingsToViews
{
return size switch
{
DockSize.Small => 38,
DockSize.Medium => 54,
DockSize.Large => 76,
DockSize.Default => 38,
DockSize.Compact => 24,
_ => throw new NotImplementedException(),
};
}

View File

@@ -1,4 +1,4 @@
<?xml version="1.0" encoding="utf-8" ?>
<?xml version="1.0" encoding="utf-8" ?>
<winuiex:WindowEx
x:Class="Microsoft.CmdPal.UI.Dock.DockWindow"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"

View File

@@ -77,7 +77,7 @@ public sealed partial class DockWindow : WindowEx,
_settingsService = serviceProvider.GetRequiredService<ISettingsService>();
_settingsService.SettingsChanged += SettingsChangedHandler;
_settings = mainSettings.DockSettings;
_lastSize = _settings.DockSize;
_lastSize = EffectiveDockSize(_settings);
viewModel = serviceProvider.GetService<DockViewModel>()!;
_themeService = serviceProvider.GetRequiredService<IThemeService>();
@@ -174,7 +174,7 @@ public sealed partial class DockWindow : WindowEx,
if (_appBarData.hWnd != IntPtr.Zero)
{
var sameEdge = _appBarData.uEdge == side;
var sameSize = _lastSize == _settings.DockSize;
var sameSize = _lastSize == EffectiveDockSize(_settings);
if (sameEdge && sameSize)
{
UpdateTopmostState();
@@ -332,7 +332,7 @@ public sealed partial class DockWindow : WindowEx,
// Stash the last size we created the bar at, so we know when to hot-
// reload it
_lastSize = _settings.DockSize;
_lastSize = EffectiveDockSize(_settings);
UpdateWindowPosition();
}
@@ -384,15 +384,9 @@ public sealed partial class DockWindow : WindowEx,
var dpi = PInvoke.GetDpiForWindow(_hwnd);
var screenWidth = PInvoke.GetSystemMetrics(SYSTEM_METRICS_INDEX.SM_CXSCREEN);
// Get system border metrics
var borderWidth = PInvoke.GetSystemMetrics(SYSTEM_METRICS_INDEX.SM_CXBORDER);
var edgeWidth = PInvoke.GetSystemMetrics(SYSTEM_METRICS_INDEX.SM_CXEDGE);
var frameWidth = PInvoke.GetSystemMetrics(SYSTEM_METRICS_INDEX.SM_CXFRAME);
var scaleFactor = dpi / 96.0;
UpdateAppBarDataForEdge(_settings.Side, _settings.DockSize, scaleFactor);
var effectiveSize = EffectiveDockSize(_settings);
UpdateAppBarDataForEdge(_settings.Side, effectiveSize, scaleFactor);
// Query and set position
PInvoke.SHAppBarMessage(PInvoke.ABM_QUERYPOS, ref _appBarData);
@@ -406,16 +400,16 @@ public sealed partial class DockWindow : WindowEx,
switch (_settings.Side)
{
case DockSide.Top:
_appBarData.rc.bottom = _appBarData.rc.top + (int)(DockSettingsToViews.HeightForSize(_settings.DockSize) * scaleFactor);
_appBarData.rc.bottom = _appBarData.rc.top + (int)(DockSettingsToViews.HeightForSize(effectiveSize) * scaleFactor);
break;
case DockSide.Bottom:
_appBarData.rc.top = _appBarData.rc.bottom - (int)(DockSettingsToViews.HeightForSize(_settings.DockSize) * scaleFactor);
_appBarData.rc.top = _appBarData.rc.bottom - (int)(DockSettingsToViews.HeightForSize(effectiveSize) * scaleFactor);
break;
case DockSide.Left:
_appBarData.rc.right = _appBarData.rc.left + (int)(DockSettingsToViews.WidthForSize(_settings.DockSize) * scaleFactor);
_appBarData.rc.right = _appBarData.rc.left + (int)(DockSettingsToViews.WidthForSize(effectiveSize) * scaleFactor);
break;
case DockSide.Right:
_appBarData.rc.left = _appBarData.rc.right - (int)(DockSettingsToViews.WidthForSize(_settings.DockSize) * scaleFactor);
_appBarData.rc.left = _appBarData.rc.right - (int)(DockSettingsToViews.WidthForSize(effectiveSize) * scaleFactor);
break;
}
@@ -428,23 +422,28 @@ public sealed partial class DockWindow : WindowEx,
// PInvoke.SHAppBarMessage(ABM_SETSTATE, ref _appBarData);
// PInvoke.SHAppBarMessage(PInvoke.ABM_SETAUTOHIDEBAR, ref _appBarData);
// Account for system borders when moving the window
// Adjust position to account for window frame/border
var adjustedLeft = _appBarData.rc.left - frameWidth;
var adjustedTop = _appBarData.rc.top - frameWidth;
var adjustedWidth = (_appBarData.rc.right - _appBarData.rc.left) + (2 * frameWidth);
var adjustedHeight = (_appBarData.rc.bottom - _appBarData.rc.top) + (2 * frameWidth);
// Move the actual window
// The dock window is borderless (SetBorderAndTitleBar(false, false),
// IsResizable = false) so no frame compensation is needed — the
// app bar rect matches the window rect exactly.
PInvoke.MoveWindow(
_hwnd,
adjustedLeft,
adjustedTop,
adjustedWidth,
adjustedHeight,
_appBarData.rc.left,
_appBarData.rc.top,
_appBarData.rc.right - _appBarData.rc.left,
_appBarData.rc.bottom - _appBarData.rc.top,
true);
}
/// <summary>
/// Compact mode is only supported for Top/Bottom dock positions.
/// For Left/Right, always use Default size.
/// </summary>
private static DockSize EffectiveDockSize(DockSettings settings)
{
var isHorizontal = settings.Side == DockSide.Top || settings.Side == DockSide.Bottom;
return isHorizontal ? settings.DockSize : DockSize.Default;
}
private void UpdateAppBarDataForEdge(DockSide side, DockSize size, double scaleFactor)
{
Logger.LogDebug("UpdateAppBarDataForEdge");
@@ -587,11 +586,21 @@ public sealed partial class DockWindow : WindowEx,
}
}
// Handle WM_GETMINMAXINFO to control window size limits
// Handle WM_GETMINMAXINFO to allow the dock to be smaller than
// the default minimum window size (SM_CYMINTRACK ~36px).
else if (msg == PInvoke.WM_GETMINMAXINFO)
{
// We can modify the min/max tracking info here if needed
// For now, let it pass through but we could restrict max size
// Call the original WndProc first so it fills default values,
// then override the minimum tracking size.
var result = PInvoke.CallWindowProc(_originalWndProc, hwnd, msg, wParam, lParam);
unsafe
{
var minMaxInfo = (MINMAXINFO*)lParam.Value;
minMaxInfo->ptMinTrackSize.X = 1;
minMaxInfo->ptMinTrackSize.Y = 1;
}
return result;
}
// Handle the AppBarMessage message

View File

@@ -43,6 +43,8 @@ public sealed partial class ListPage : Page,
private ListItemViewModel? _stickySelectedItem;
private ListItemViewModel? _lastPushedToVm;
private long _pendingContextMenuOpenRequestId;
private Action? _cancelPendingContextMenuOpen;
// A single search-text change can produce multiple ItemsUpdated calls
// dispatched as separate UI-thread callbacks. A later "soft" update
@@ -124,6 +126,8 @@ public sealed partial class ListPage : Page,
{
base.OnNavigatingFrom(e);
CancelPendingContextMenuOpen();
WeakReferenceMessenger.Default.Unregister<NavigateNextCommand>(this);
WeakReferenceMessenger.Default.Unregister<NavigatePreviousCommand>(this);
WeakReferenceMessenger.Default.Unregister<NavigateLeftCommand>(this);
@@ -283,17 +287,7 @@ public sealed partial class ListPage : Page,
ViewModel?.UpdateSelectedItemCommand.Execute(item);
var pos = e.GetPosition(element);
_ = DispatcherQueue.TryEnqueue(
() =>
{
WeakReferenceMessenger.Default.Send<OpenContextMenuMessage>(
new OpenContextMenuMessage(
element,
FlyoutPlacementMode.BottomEdgeAlignedLeft,
pos,
ContextMenuFilterLocation.Top));
});
RequestContextMenuOpen(item, element, pos);
}
}
@@ -1014,21 +1008,14 @@ public sealed partial class ListPage : Page,
pos = new(0, element.ActualHeight);
}
_ = DispatcherQueue.TryEnqueue(
() =>
{
WeakReferenceMessenger.Default.Send<OpenContextMenuMessage>(
new OpenContextMenuMessage(
element,
FlyoutPlacementMode.BottomEdgeAlignedLeft,
pos,
ContextMenuFilterLocation.Top));
});
ViewModel?.UpdateSelectedItemCommand.Execute(item);
RequestContextMenuOpen(item, element, pos);
e.Handled = true;
}
private void Items_OnContextCanceled(UIElement sender, RoutedEventArgs e)
{
CancelPendingContextMenuOpen();
_ = DispatcherQueue.TryEnqueue(() => WeakReferenceMessenger.Default.Send<CloseContextMenuMessage>());
}
@@ -1210,6 +1197,87 @@ public sealed partial class ListPage : Page,
scroll.ChangeView(horizontalOffset: null, verticalOffset: 0, zoomFactor: null, disableAnimation: true);
}
private void RequestContextMenuOpen(ListItemViewModel item, FrameworkElement element, Point pos)
{
// BEAR LOADING: Right-click can arrive before the selected item's slow
// context-menu hydration completes, especially for out-of-proc
// providers. Keep this exact open request alive until the same
// selected item becomes context-openable instead of dropping the first
// click.
CancelPendingContextMenuOpen();
var requestId = Interlocked.Increment(ref _pendingContextMenuOpenRequestId);
System.ComponentModel.PropertyChangedEventHandler? onItemChanged = null;
Action? detach = null;
detach = () =>
{
if (onItemChanged is not null)
{
item.PropertyChanged -= onItemChanged;
}
if (ReferenceEquals(_cancelPendingContextMenuOpen, detach))
{
_cancelPendingContextMenuOpen = null;
}
};
onItemChanged = (_, args) =>
{
if (args.PropertyName is nameof(ListItemViewModel.CanOpenContextMenu) or nameof(ListItemViewModel.AllCommands) &&
TryOpenContextMenuIfReady(item, element, pos, requestId))
{
detach();
}
};
item.PropertyChanged += onItemChanged;
_cancelPendingContextMenuOpen = detach;
if (TryOpenContextMenuIfReady(item, element, pos, requestId))
{
detach();
}
}
private bool TryOpenContextMenuIfReady(ListItemViewModel item, FrameworkElement element, Point pos, long requestId)
{
// Ignore stale requests so rapid selection changes or cancelled opens
// can't resurrect an old context menu on the wrong item.
if (requestId != Volatile.Read(ref _pendingContextMenuOpenRequestId) ||
!ReferenceEquals(ItemView.SelectedItem, item) ||
!item.CanOpenContextMenu)
{
return false;
}
_ = DispatcherQueue.TryEnqueue(
() =>
{
if (requestId != Volatile.Read(ref _pendingContextMenuOpenRequestId) ||
!ReferenceEquals(ItemView.SelectedItem, item))
{
return;
}
WeakReferenceMessenger.Default.Send<OpenContextMenuMessage>(
new OpenContextMenuMessage(
element,
FlyoutPlacementMode.BottomEdgeAlignedLeft,
pos,
ContextMenuFilterLocation.Top));
});
return true;
}
private void CancelPendingContextMenuOpen()
{
Interlocked.Increment(ref _pendingContextMenuOpenRequestId);
_cancelPendingContextMenuOpen?.Invoke();
_cancelPendingContextMenuOpen = null;
}
private IDisposable SuppressSelectionChangedScope()
{
_suppressSelectionChanged = true;

View File

@@ -8,8 +8,8 @@ using CmdPalKeyboardService;
using CommunityToolkit.Mvvm.Messaging;
using ManagedCommon;
using Microsoft.CmdPal.Common.Helpers;
using Microsoft.CmdPal.Common.Messages;
using Microsoft.CmdPal.Common.Services;
using Microsoft.CmdPal.Ext.ClipboardHistory.Messages;
using Microsoft.CmdPal.UI.Controls;
using Microsoft.CmdPal.UI.Dock;
using Microsoft.CmdPal.UI.Events;

View File

@@ -38,6 +38,11 @@
<DefineConstants>$(DefineConstants)</DefineConstants>
</PropertyGroup>
<!-- Added to ensure telemetry events are triggered from AOT build -->
<PropertyGroup>
<EventSourceSupport>true</EventSourceSupport>
</PropertyGroup>
<!-- For debugging purposes, uncomment this block to enable AOT builds -->
<!-- <PropertyGroup>
<EnableCmdPalAOT>true</EnableCmdPalAOT>

View File

@@ -94,6 +94,7 @@ WM_WINDOWPOSCHANGING
WM_SHOWWINDOW
WM_SIZE
WM_GETMINMAXINFO
MINMAXINFO
SetWinEventHook
WINDOW_STYLE
SC_MINIMIZE

View File

@@ -67,6 +67,20 @@
</ComboBox>
</controls:SettingsCard>
<!-- Dock Size (only for Top/Bottom positions) -->
<controls:SettingsCard
x:Name="DockSizeSettingsCard"
x:Uid="DockAppearance_DockSize_SettingsCard"
HeaderIcon="{ui:FontIcon Glyph=&#xE799;}">
<ComboBox
x:Name="DockSizeComboBox"
MinWidth="{StaticResource SettingActionControlMinWidth}"
SelectedIndex="{x:Bind SelectedDockSizeIndex, Mode=TwoWay}">
<ComboBoxItem x:Uid="DockAppearance_DockSize_Default" />
<ComboBoxItem x:Uid="DockAppearance_DockSize_Compact" />
</ComboBox>
</controls:SettingsCard>
<controls:SettingsCard x:Uid="DockAppearance_AppTheme_SettingsCard" HeaderIcon="{ui:FontIcon Glyph=&#xE793;}">
<ComboBox MinWidth="{StaticResource SettingActionControlMinWidth}" SelectedIndex="{x:Bind ViewModel.DockAppearance.ThemeIndex, Mode=TwoWay}">
<ComboBoxItem x:Uid="Settings_GeneralPage_AppTheme_Mode_System_Automation" Tag="Default">

View File

@@ -42,7 +42,9 @@ public sealed partial class DockSettingsPage : Page
{
// Initialize UI controls to match current settings
DockPositionComboBox.SelectedIndex = SelectedSideIndex;
DockSizeComboBox.SelectedIndex = SelectedDockSizeIndex;
BackdropComboBox.SelectedIndex = SelectedBackdropIndex;
UpdateDockSizeCardVisibility();
}
private async void PickBackgroundImage_Click(object sender, RoutedEventArgs e)
@@ -108,7 +110,11 @@ public sealed partial class DockSettingsPage : Page
public int SelectedSideIndex
{
get => SideToSelectedIndex(ViewModel.Dock_Side);
set => ViewModel.Dock_Side = SelectedIndexToSide(value);
set
{
ViewModel.Dock_Side = SelectedIndexToSide(value);
UpdateDockSizeCardVisibility();
}
}
public int SelectedBackdropIndex
@@ -126,18 +132,16 @@ public sealed partial class DockSettingsPage : Page
// Conversion methods for ComboBox bindings
private static int DockSizeToSelectedIndex(DockSize size) => size switch
{
DockSize.Small => 0,
DockSize.Medium => 1,
DockSize.Large => 2,
DockSize.Default => 0,
DockSize.Compact => 1,
_ => 0,
};
private static DockSize SelectedIndexToDockSize(int index) => index switch
{
0 => DockSize.Small,
1 => DockSize.Medium,
2 => DockSize.Large,
_ => DockSize.Small,
0 => DockSize.Default,
1 => DockSize.Compact,
_ => DockSize.Default,
};
private static int SideToSelectedIndex(DockSide side) => side switch
@@ -172,6 +176,13 @@ public sealed partial class DockSettingsPage : Page
_ => DockBackdrop.Acrylic,
};
private void UpdateDockSizeCardVisibility()
{
var side = ViewModel.Dock_Side;
var isTopOrBottom = side == DockSide.Top || side == DockSide.Bottom;
DockSizeSettingsCard.Visibility = isTopOrBottom ? Visibility.Visible : Visibility.Collapsed;
}
private List<TopLevelViewModel> GetAllBands()
{
var allBands = new List<TopLevelViewModel>();

View File

@@ -939,6 +939,18 @@ Right-click to remove the key combination, thereby deactivating the shortcut.</v
<data name="DockAppearance_DockPosition_Bottom.Content" xml:space="preserve">
<value>Bottom</value>
</data>
<data name="DockAppearance_DockSize_SettingsCard.Header" xml:space="preserve">
<value>Size</value>
</data>
<data name="DockAppearance_DockSize_SettingsCard.Description" xml:space="preserve">
<value>Choose the dock size; subtitles of dock items are hidden in compact mode</value>
</data>
<data name="DockAppearance_DockSize_Default.Content" xml:space="preserve">
<value>Default</value>
</data>
<data name="DockAppearance_DockSize_Compact.Content" xml:space="preserve">
<value>Compact</value>
</data>
<data name="top_level_pin_command_name" xml:space="preserve">
<value>Pin to home</value>
<comment>Command name for pinning an item to the top level list of commands</comment>

View File

@@ -5,6 +5,7 @@
using System.Globalization;
using System.Linq;
using Microsoft.CmdPal.Ext.Calc.Helper;
using Microsoft.CommandPalette.Extensions;
using Microsoft.CommandPalette.Extensions.Toolkit;
using Microsoft.VisualStudio.TestTools.UnitTesting;
using Windows.Foundation;
@@ -18,47 +19,49 @@ public class CloseOnEnterTests
public void PrimaryIsCopy_WhenCloseOnEnterTrue()
{
var settings = new Settings(closeOnEnter: true);
TypedEventHandler<object, object> handleSave = (s, e) => { };
TypedEventHandler<object, object> handleReplace = (s, e) => { };
var item = ResultHelper.CreateResult(
var item = ResultHelper.CreateResultForPage(
4m,
CultureInfo.CurrentCulture,
CultureInfo.CurrentCulture,
"2+2",
settings,
handleSave,
handleReplace);
Assert.IsNotNull(item);
Assert.IsInstanceOfType(item.Command, typeof(CopyTextCommand));
Assert.IsTrue(item.MoreCommands.OfType<CommandContextItem>().All(command => command.Command is not SaveCommand));
var firstMore = item.MoreCommands.First();
Assert.IsInstanceOfType(firstMore, typeof(CommandContextItem));
Assert.IsInstanceOfType(((CommandItem)firstMore).Command, typeof(SaveCommand));
var result = ((CopyTextCommand)item.Command).Result;
Assert.AreEqual(CommandResultKind.ShowToast, result.Kind);
var toastArgs = result.Args as ToastArgs;
Assert.IsNotNull(toastArgs);
Assert.AreEqual(CommandResultKind.Hide, ((CommandResult)toastArgs.Result).Kind);
}
[TestMethod]
public void PrimaryIsSave_WhenCloseOnEnterFalse()
public void PrimaryIsCopy_WhenCloseOnEnterFalse()
{
var settings = new Settings(closeOnEnter: false);
TypedEventHandler<object, object> handleSave = (s, e) => { };
TypedEventHandler<object, object> handleReplace = (s, e) => { };
var item = ResultHelper.CreateResult(
var item = ResultHelper.CreateResultForPage(
4m,
CultureInfo.CurrentCulture,
CultureInfo.CurrentCulture,
"2+2",
settings,
handleSave,
handleReplace);
Assert.IsNotNull(item);
Assert.IsInstanceOfType(item.Command, typeof(SaveCommand));
Assert.IsInstanceOfType(item.Command, typeof(CopyTextCommand));
Assert.IsTrue(item.MoreCommands.OfType<CommandContextItem>().All(command => command.Command is not SaveCommand));
var firstMore = item.MoreCommands.First();
Assert.IsInstanceOfType(firstMore, typeof(CommandContextItem));
Assert.IsInstanceOfType(((CommandItem)firstMore).Command, typeof(CopyTextCommand));
var result = ((CopyTextCommand)item.Command).Result;
Assert.AreEqual(CommandResultKind.ShowToast, result.Kind);
var toastArgs = result.Args as ToastArgs;
Assert.IsNotNull(toastArgs);
Assert.AreEqual(CommandResultKind.KeepOpen, ((CommandResult)toastArgs.Result).Kind);
}
}

View File

@@ -0,0 +1,98 @@
// Copyright (c) Microsoft Corporation
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
using System;
using System.Globalization;
using System.Linq;
using Microsoft.CmdPal.Ext.Calc.Helper;
using Microsoft.CmdPal.Ext.Calc.Pages;
using Microsoft.CommandPalette.Extensions;
using Microsoft.CommandPalette.Extensions.Toolkit;
using Microsoft.VisualStudio.TestTools.UnitTesting;
using Windows.Foundation;
namespace Microsoft.CmdPal.Ext.Calc.UnitTests;
[TestClass]
public class PrimaryActionTests
{
[TestMethod]
public void PrimaryActionPaste_UsesPasteAsPrimaryAndCopyAsSecondary()
{
var settings = new Settings(primaryAction: PrimaryAction.Paste);
TypedEventHandler<object, object> handleReplace = (_, _) => { };
var item = ResultHelper.CreateResultForPage(
4m,
CultureInfo.CurrentCulture,
CultureInfo.CurrentCulture,
"2+2",
settings,
handleReplace);
Assert.IsNotNull(item);
Assert.IsInstanceOfType(item.Command, typeof(CalculatorPasteCommand));
var firstMore = item.MoreCommands.OfType<CommandContextItem>().FirstOrDefault();
Assert.IsNotNull(firstMore);
Assert.IsInstanceOfType(((CommandItem)firstMore).Command, typeof(CalculatorCopyCommand));
}
[TestMethod]
public void HistoryItemsUsePasteWhenPrimaryActionPaste()
{
var settings = new Settings(primaryAction: PrimaryAction.Paste);
settings.AddHistoryItem(new HistoryItem("2+2", "4", DateTime.UtcNow));
var page = new CalculatorListPage(settings);
var historyItem = page.GetItems().FirstOrDefault(item => item.Title == "4");
Assert.IsNotNull(historyItem);
Assert.IsInstanceOfType(historyItem.Command, typeof(CalculatorPasteCommand));
}
[DataTestMethod]
[DataRow(false)]
[DataRow(true)]
public void FallbackItemsUseCalculatorCommandsForCopyAndPaste(bool saveFallbackResultsToHistory)
{
var settings = new Settings(saveFallbackResultsToHistory: saveFallbackResultsToHistory);
var page = new CalculatorListPage(settings);
var item = new FallbackCalculatorItem(settings, page);
item.UpdateQuery("2+2");
Assert.IsInstanceOfType(item.Command, typeof(CalculatorCopyCommand));
Assert.IsInstanceOfType(GetFallbackSecondaryCommand(item), typeof(CalculatorPasteCommand));
}
[DataTestMethod]
[DataRow(false)]
[DataRow(true)]
public void FallbackItemsRespectPrimaryActionWhenHistorySavingToggles(bool saveFallbackResultsToHistory)
{
var settings = new Settings(
primaryAction: PrimaryAction.Paste,
saveFallbackResultsToHistory: saveFallbackResultsToHistory);
var page = new CalculatorListPage(settings);
var item = new FallbackCalculatorItem(settings, page);
item.UpdateQuery("2+2");
Assert.IsInstanceOfType(item.Command, typeof(CalculatorPasteCommand));
Assert.IsInstanceOfType(GetFallbackSecondaryCommand(item), typeof(CalculatorCopyCommand));
}
private static ICommand GetFallbackSecondaryCommand(FallbackCalculatorItem item)
{
var secondaryCommand = item.MoreCommands
.OfType<CommandContextItem>()
.Skip(1)
.Select(contextItem => ((CommandItem)contextItem).Command)
.FirstOrDefault();
Assert.IsNotNull(secondaryCommand);
return secondaryCommand;
}
}

View File

@@ -2,6 +2,8 @@
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
using System;
using System.Collections.Generic;
using Microsoft.CmdPal.Ext.Calc.Helper;
namespace Microsoft.CmdPal.Ext.Calc.UnitTests;
@@ -14,7 +16,12 @@ public class Settings : ISettingsInterface
private readonly bool closeOnEnter;
private readonly bool copyResultToSearchBarIfQueryEndsWithEqualSign;
private readonly bool autoFixQuery;
private readonly bool saveFallbackResultsToHistory;
private readonly bool deleteHistoryRequiresConfirmation;
private readonly PrimaryAction primaryAction;
private readonly bool inputNormalization;
private readonly List<HistoryItem> historyItems = [];
private readonly bool replaceQueryOnEnter;
public Settings(
CalculateEngine.TrigMode trigUnit = CalculateEngine.TrigMode.Radians,
@@ -23,7 +30,11 @@ public class Settings : ISettingsInterface
bool closeOnEnter = true,
bool copyResultToSearchBarIfQueryEndsWithEqualSign = true,
bool autoFixQuery = true,
bool inputNormalization = true)
bool saveFallbackResultsToHistory = false,
bool deleteHistoryRequiresConfirmation = true,
PrimaryAction primaryAction = PrimaryAction.Default,
bool inputNormalization = true,
bool replaceQueryOnEnter = true)
{
this.trigUnit = trigUnit;
this.inputUseEnglishFormat = inputUseEnglishFormat;
@@ -31,7 +42,11 @@ public class Settings : ISettingsInterface
this.closeOnEnter = closeOnEnter;
this.copyResultToSearchBarIfQueryEndsWithEqualSign = copyResultToSearchBarIfQueryEndsWithEqualSign;
this.autoFixQuery = autoFixQuery;
this.saveFallbackResultsToHistory = saveFallbackResultsToHistory;
this.deleteHistoryRequiresConfirmation = deleteHistoryRequiresConfirmation;
this.primaryAction = primaryAction;
this.inputNormalization = inputNormalization;
this.replaceQueryOnEnter = replaceQueryOnEnter;
}
public CalculateEngine.TrigMode TrigUnit => trigUnit;
@@ -46,5 +61,46 @@ public class Settings : ISettingsInterface
public bool AutoFixQuery => autoFixQuery;
public bool SaveFallbackResultsToHistory => saveFallbackResultsToHistory;
public bool DeleteHistoryRequiresConfirmation => deleteHistoryRequiresConfirmation;
public PrimaryAction PrimaryAction => primaryAction;
public bool InputNormalization => inputNormalization;
public event EventHandler HistoryChanged;
#pragma warning disable CS0067 // Event is never used
public event EventHandler SettingsChanged;
#pragma warning restore CS0067 // Event is never used
public IReadOnlyList<HistoryItem> HistoryItems => historyItems;
public bool ReplaceQueryOnEnter => replaceQueryOnEnter;
public void AddHistoryItem(HistoryItem historyItem)
{
historyItems.Add(historyItem);
HistoryChanged?.Invoke(this, EventArgs.Empty);
}
public void RemoveHistoryItem(Guid historyItemId)
{
if (historyItems.RemoveAll(item => item.Id == historyItemId) > 0)
{
HistoryChanged?.Invoke(this, EventArgs.Empty);
}
}
public void ClearHistory()
{
if (historyItems.Count == 0)
{
return;
}
historyItems.Clear();
HistoryChanged?.Invoke(this, EventArgs.Empty);
}
}

View File

@@ -2,6 +2,8 @@
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
using System;
using System.Globalization;
using Microsoft.CmdPal.Ext.Calc.Helper;
using Microsoft.VisualStudio.TestTools.UnitTesting;
@@ -33,6 +35,9 @@ public class SettingsManagerTests
Assert.IsFalse(settings.InputUseEnglishFormat);
Assert.IsFalse(settings.OutputUseEnglishFormat);
Assert.IsTrue(settings.CloseOnEnter);
Assert.IsTrue(settings.SaveFallbackResultsToHistory);
Assert.IsTrue(settings.DeleteHistoryRequiresConfirmation);
Assert.AreEqual(PrimaryAction.Default, settings.PrimaryAction);
}
[TestMethod]
@@ -52,4 +57,38 @@ public class SettingsManagerTests
Assert.IsTrue(settings.OutputUseEnglishFormat);
Assert.IsFalse(settings.CloseOnEnter);
}
[TestMethod]
public void HistorySettingsAddRemoveClearTest()
{
var settingsManager = new SettingsManager();
settingsManager.ClearHistory();
var historyItem = new HistoryItem("1+1", "2", DateTime.UtcNow);
settingsManager.AddHistoryItem(historyItem);
Assert.AreEqual(1, settingsManager.HistoryItems.Count);
settingsManager.RemoveHistoryItem(historyItem.Id);
Assert.AreEqual(0, settingsManager.HistoryItems.Count);
settingsManager.AddHistoryItem(new HistoryItem("2+2", "4", DateTime.UtcNow));
settingsManager.ClearHistory();
Assert.AreEqual(0, settingsManager.HistoryItems.Count);
}
[TestMethod]
public void HistorySettingsTrimsToCapacityTest()
{
var settingsManager = new SettingsManager();
settingsManager.ClearHistory();
for (var i = 0; i < 105; i++)
{
settingsManager.AddHistoryItem(new HistoryItem($"{i}+{i}", (i + i).ToString(CultureInfo.InvariantCulture), DateTime.UtcNow));
}
Assert.AreEqual(100, settingsManager.HistoryItems.Count);
settingsManager.ClearHistory();
}
}

View File

@@ -0,0 +1,24 @@
// Copyright (c) Microsoft Corporation
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
using Microsoft.CmdPal.Ext.Indexer.Indexer;
using Microsoft.CmdPal.Ext.Indexer.Properties;
using Microsoft.VisualStudio.TestTools.UnitTesting;
namespace Microsoft.CmdPal.Ext.Indexer.UnitTests;
[TestClass]
public class FallbackOpenFileItemTests
{
[TestMethod]
public void GetFallbackNoticeText_UsesExtensionNameAsTitle()
{
var notice = new SearchNoticeInfo(Resources.Indexer_SearchFailedMessage!, Resources.Indexer_SearchFailedMessageTip!);
var text = FallbackOpenFileItem.GetFallbackNoticeText(notice);
Assert.AreEqual(Resources.IndexerCommandsProvider_DisplayName, text.Title);
Assert.AreEqual(Resources.Indexer_SearchFailedMessage, text.Subtitle);
}
}

View File

@@ -0,0 +1,78 @@
// Copyright (c) Microsoft Corporation
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
using Microsoft.CmdPal.Ext.Indexer.Indexer.Utils;
using Microsoft.VisualStudio.TestTools.UnitTesting;
namespace Microsoft.CmdPal.Ext.Indexer.UnitTests;
[TestClass]
public class ImplicitWildcardQueryBuilderTests
{
[DataTestMethod]
[DataRow("term", null, "((CONTAINS(System.ItemNameDisplay, '\"term\"') OR CONTAINS(System.ItemNameDisplay, '\"term*\"')) OR System.FileName LIKE '%term%')", "System.FileName LIKE '%term%'")]
[DataRow("term Kind:Folder", "Kind:Folder", "((CONTAINS(System.ItemNameDisplay, '\"term\"') OR CONTAINS(System.ItemNameDisplay, '\"term*\"')) OR System.FileName LIKE '%term%')", "System.FileName LIKE '%term%'")]
[DataRow("System.Kind:folders term", "System.Kind:folders", "((CONTAINS(System.ItemNameDisplay, '\"term\"') OR CONTAINS(System.ItemNameDisplay, '\"term*\"')) OR System.FileName LIKE '%term%')", "System.FileName LIKE '%term%'")]
[DataRow("System.Kind:NOT folders term", "System.Kind:NOT folders", "((CONTAINS(System.ItemNameDisplay, '\"term\"') OR CONTAINS(System.ItemNameDisplay, '\"term*\"')) OR System.FileName LIKE '%term%')", "System.FileName LIKE '%term%'")]
[DataRow("\"two words\"", null, "((CONTAINS(System.ItemNameDisplay, '\"two words\"') OR CONTAINS(System.ItemNameDisplay, '\"two words*\"') OR CONTAINS(System.ItemNameDisplay, '\"two\" AND \"words\"') OR CONTAINS(System.ItemNameDisplay, '\"two*\" AND \"words*\"')) OR System.FileName LIKE '%two words%')", "System.FileName LIKE '%two words%'")]
[DataRow("foo bar", null, "((CONTAINS(System.ItemNameDisplay, '\"foo bar\"') OR CONTAINS(System.ItemNameDisplay, '\"foo bar*\"') OR CONTAINS(System.ItemNameDisplay, '\"foo\" AND \"bar\"') OR CONTAINS(System.ItemNameDisplay, '\"foo*\" AND \"bar*\"')) OR (System.FileName LIKE '%foo%' AND System.FileName LIKE '%bar%'))", "(System.FileName LIKE '%foo%' AND System.FileName LIKE '%bar%')")]
[DataRow("foo-bar", null, "((CONTAINS(System.ItemNameDisplay, '\"foo bar\"') OR CONTAINS(System.ItemNameDisplay, '\"foo bar*\"') OR CONTAINS(System.ItemNameDisplay, '\"foo\" AND \"bar\"') OR CONTAINS(System.ItemNameDisplay, '\"foo*\" AND \"bar*\"')) OR System.FileName LIKE '%foo-bar%')", "System.FileName LIKE '%foo-bar%'")]
[DataRow("foo & bar", null, "((CONTAINS(System.ItemNameDisplay, '\"foo bar\"') OR CONTAINS(System.ItemNameDisplay, '\"foo bar*\"') OR CONTAINS(System.ItemNameDisplay, '\"foo\" AND \"bar\"') OR CONTAINS(System.ItemNameDisplay, '\"foo*\" AND \"bar*\"')) OR (System.FileName LIKE '%foo%' AND System.FileName LIKE '%bar%'))", "(System.FileName LIKE '%foo%' AND System.FileName LIKE '%bar%')")]
[DataRow("tonträger", null, "((CONTAINS(System.ItemNameDisplay, '\"tonträger\"') OR CONTAINS(System.ItemNameDisplay, '\"tonträger*\"')) OR System.FileName LIKE '%tonträger%')", "System.FileName LIKE '%tonträger%'")]
[DataRow("O'Hara", null, "((CONTAINS(System.ItemNameDisplay, '\"Hara\"') OR CONTAINS(System.ItemNameDisplay, '\"Hara*\"')) OR System.FileName LIKE '%O''Hara%')", "System.FileName LIKE '%O''Hara%'")]
[DataRow("AT&T", null, "System.FileName LIKE '%AT&T%'", null)]
[DataRow("file_100%", null, "((CONTAINS(System.ItemNameDisplay, '\"file 100\"') OR CONTAINS(System.ItemNameDisplay, '\"file 100*\"') OR CONTAINS(System.ItemNameDisplay, '\"file\" AND \"100\"') OR CONTAINS(System.ItemNameDisplay, '\"file*\" AND \"100*\"')) OR System.FileName LIKE '%file[_]100[%]%')", "System.FileName LIKE '%file[_]100[%]%'")]
public void BuildExpandedQuery_BuildsExpectedRestrictions(string query, string expectedStructuredSearchText, string expectedPrimaryClause, string expectedFallbackClause)
{
var expandedQuery = ImplicitWildcardQueryBuilder.BuildExpandedQuery(query);
Assert.AreEqual(expectedStructuredSearchText, expandedQuery.StructuredSearchText);
Assert.AreEqual(expectedPrimaryClause, expandedQuery.PrimaryRestriction);
Assert.AreEqual(expectedFallbackClause, expandedQuery.FallbackRestriction);
}
[TestMethod]
public void BuildExpandedQuery_PreservesBracketWrappedTermAsLiteralOnly()
{
var expandedQuery = ImplicitWildcardQueryBuilder.BuildExpandedQuery("[red]");
Assert.AreEqual("System.FileName LIKE '%[[]red[]]%'", expandedQuery.PrimaryRestriction);
Assert.IsFalse(expandedQuery.HasFallbackRestriction);
}
[TestMethod]
public void BuildExpandedQuery_TreatsSinglePercentAsLiteralCharacter()
{
var expandedQuery = ImplicitWildcardQueryBuilder.BuildExpandedQuery("%");
Assert.AreEqual("System.FileName LIKE '%[%]%'", expandedQuery.PrimaryRestriction);
Assert.IsFalse(expandedQuery.HasFallbackRestriction);
}
[TestMethod]
public void BuildExpandedQuery_TreatsSingleUnderscoreAsLiteralCharacter()
{
var expandedQuery = ImplicitWildcardQueryBuilder.BuildExpandedQuery("_");
Assert.AreEqual("System.FileName LIKE '%[_]%'", expandedQuery.PrimaryRestriction);
Assert.IsFalse(expandedQuery.HasFallbackRestriction);
}
[DataTestMethod]
[DataRow("kind:folder")]
[DataRow("name:term")]
[DataRow("name: term")]
[DataRow("name:\"two words\"")]
[DataRow("*term*")]
[DataRow("C:\\Users")]
[DataRow("System.Kind:folders")]
[DataRow("kind:folder AND term")]
public void BuildExpandedQuery_DoesNotBroadenStructuredOrExplicitQueries(string query)
{
var expandedQuery = ImplicitWildcardQueryBuilder.BuildExpandedQuery(query);
Assert.IsFalse(expandedQuery.HasPrimaryRestriction);
Assert.IsFalse(expandedQuery.HasFallbackRestriction);
}
}

View File

@@ -0,0 +1,21 @@
<Project Sdk="Microsoft.NET.Sdk">
<!-- Look at Directory.Build.props in root for common stuff as well -->
<Import Project="$(RepoRoot)src\Common.Dotnet.CsWinRT.props" />
<PropertyGroup>
<IsPackable>false</IsPackable>
<IsTestProject>true</IsTestProject>
<RootNamespace>Microsoft.CmdPal.Ext.Indexer.UnitTests</RootNamespace>
<OutputPath>$(SolutionDir)$(Platform)\$(Configuration)\WinUI3Apps\CmdPal\tests\</OutputPath>
<AppendTargetFrameworkToOutputPath>false</AppendTargetFrameworkToOutputPath>
<AppendRuntimeIdentifierToOutputPath>false</AppendRuntimeIdentifierToOutputPath>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="MSTest" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\ext\Microsoft.CmdPal.Ext.Indexer\Microsoft.CmdPal.Ext.Indexer.csproj" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,92 @@
// Copyright (c) Microsoft Corporation
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
using Microsoft.CmdPal.Ext.Indexer.Indexer;
using Microsoft.CmdPal.Ext.Indexer.Properties;
using Microsoft.VisualStudio.TestTools.UnitTesting;
namespace Microsoft.CmdPal.Ext.Indexer.UnitTests;
[TestClass]
public class SearchNoticeInfoBuilderTests
{
[DataTestMethod]
[DataRow((int)SearchQuery.QueryState.NullDataSource)]
[DataRow((int)SearchQuery.QueryState.CreateSessionFailed)]
[DataRow((int)SearchQuery.QueryState.CreateCommandFailed)]
public void FromQueryStatus_ReturnsUnavailableNotice_ForInfrastructureFailures(int stateValue)
{
var state = (SearchQuery.QueryState)stateValue;
var notice = SearchNoticeInfoBuilder.FromQueryStatus(new SearchQuery.SearchExecutionStatus(state, null, "failure"));
Assert.IsNotNull(notice);
Assert.AreEqual(Resources.Indexer_SearchUnavailableMessage, notice.Value.Title);
Assert.AreEqual(Resources.Indexer_SearchUnavailableMessageTip, notice.Value.Subtitle);
}
[TestMethod]
public void FromQueryStatus_ReturnsUnavailableNotice_ForRpcFailures()
{
var notice = SearchNoticeInfoBuilder.FromQueryStatus(
new SearchQuery.SearchExecutionStatus(
SearchQuery.QueryState.ExecuteFailed,
unchecked((int)0x800706BA),
"RPC server unavailable"));
Assert.IsNotNull(notice);
Assert.AreEqual(Resources.Indexer_SearchUnavailableMessage, notice.Value.Title);
}
[TestMethod]
public void FromQueryStatus_ReturnsGenericFailureNotice_ForUnexpectedFailures()
{
var notice = SearchNoticeInfoBuilder.FromQueryStatus(
new SearchQuery.SearchExecutionStatus(
SearchQuery.QueryState.ExecuteFailed,
unchecked((int)0x80004005),
"unexpected"));
Assert.IsNotNull(notice);
Assert.AreEqual(Resources.Indexer_SearchFailedMessage, notice.Value.Title);
Assert.AreEqual(Resources.Indexer_SearchFailedMessageTip, notice.Value.Subtitle);
}
[DataTestMethod]
[DataRow((int)SearchQuery.QueryState.Completed)]
[DataRow((int)SearchQuery.QueryState.NoResults)]
[DataRow((int)SearchQuery.QueryState.AllNoise)]
public void FromQueryStatus_ReturnsNull_ForNonFailureStates(int stateValue)
{
var state = (SearchQuery.QueryState)stateValue;
var notice = SearchNoticeInfoBuilder.FromQueryStatus(new SearchQuery.SearchExecutionStatus(state, null, null));
Assert.IsNull(notice);
}
[TestMethod]
public void FromCatalogStatus_ReturnsIndexingNotice_WhenItemsArePending()
{
var notice = SearchNoticeInfoBuilder.FromCatalogStatus(new SearchCatalogStatus(42, null));
Assert.IsNotNull(notice);
Assert.AreEqual(Resources.Indexer_SearchIndexingMessage, notice.Value.Title);
StringAssert.Contains(notice.Value.Subtitle, "42");
}
[TestMethod]
public void FromCatalogStatus_ReturnsNull_WhenStatusReadFails()
{
var notice = SearchNoticeInfoBuilder.FromCatalogStatus(new SearchCatalogStatus(0, unchecked((int)0x800706BA)));
Assert.IsNull(notice);
}
[TestMethod]
public void FromCatalogStatus_ReturnsNull_WhenIndexingIsIdle()
{
var notice = SearchNoticeInfoBuilder.FromCatalogStatus(new SearchCatalogStatus(0, null));
Assert.IsNull(notice);
}
}

View File

@@ -78,6 +78,25 @@ public class CommandItemViewModelTests
Assert.AreEqual("Secondary", viewModel.SecondaryCommand.Name);
}
[TestMethod]
public void FastInitializeProperties_CreatesPrimaryContextItem()
{
// Context menus are opened from fast-initialized list items before slow init completes.
// The synthetic primary command must already exist so the first right-click can open the menu.
var pageContext = new TestPageContext();
var item = new CommandItem(new NoOpCommand { Name = "Primary" })
{
Title = "Primary",
};
var viewModel = new CommandItemViewModel(new(item), new(pageContext), DefaultContextMenuFactory.Instance);
viewModel.FastInitializeProperties();
Assert.AreEqual(1, viewModel.AllCommands.Count);
Assert.IsTrue(viewModel.CanOpenContextMenu);
Assert.AreEqual("Primary", ((CommandContextItemViewModel)viewModel.AllCommands[0]).Name);
}
[TestMethod]
public void LatePrimaryCommandCreation_AddsPrimaryToAllCommands()
{

View File

@@ -12,20 +12,23 @@ namespace Microsoft.CmdPal.Ext.Calc;
public partial class CalculatorCommandProvider : CommandProvider
{
private static ISettingsInterface settings = new SettingsManager();
private readonly ListItem _listItem = new(new CalculatorListPage(settings))
{
MoreCommands = [new CommandContextItem(((SettingsManager)settings).Settings.SettingsPage)],
};
private readonly FallbackCalculatorItem _fallback = new(settings);
private readonly ISettingsInterface _settings = new SettingsManager();
private readonly ListItem _listItem;
private readonly FallbackCalculatorItem _fallback;
public CalculatorCommandProvider()
{
Id = "com.microsoft.cmdpal.builtin.calculator";
DisplayName = Resources.calculator_display_name;
Icon = Icons.CalculatorIcon;
Settings = ((SettingsManager)settings).Settings;
Settings = ((SettingsManager)_settings).Settings;
var calculatorListPage = new CalculatorListPage(_settings);
_listItem = new ListItem(calculatorListPage)
{
MoreCommands = [new CommandContextItem(((SettingsManager)_settings).Settings.SettingsPage)],
};
_fallback = new(_settings, calculatorListPage);
}
public override ICommandItem[] TopLevelCommands() => [_listItem];

View File

@@ -0,0 +1,56 @@
// Copyright (c) Microsoft Corporation
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
using System;
using Microsoft.CommandPalette.Extensions;
using Microsoft.CommandPalette.Extensions.Toolkit;
using Windows.Foundation;
namespace Microsoft.CmdPal.Ext.Calc.Helper;
public sealed partial class CalculatorCopyCommand : CopyTextCommand
{
public event TypedEventHandler<object, object> ReplaceRequested;
private readonly ISettingsInterface _settings;
private readonly Func<bool> _canStoreHistory;
private string _query;
public CalculatorCopyCommand(string result, string query, ISettingsInterface settings, bool canStoreHistory = true)
: this(result, query, settings, () => canStoreHistory)
{
}
public CalculatorCopyCommand(string result, string query, ISettingsInterface settings, Func<bool> canStoreHistory)
: base(result)
{
ArgumentNullException.ThrowIfNull(settings);
ArgumentNullException.ThrowIfNull(canStoreHistory);
_settings = settings;
_canStoreHistory = canStoreHistory;
_query = query ?? string.Empty;
Name = Properties.Resources.calculator_copy_command_name;
Result = ResultHelper.CreateCopyCommandResult(settings.CloseOnEnter);
}
public void Update(string text, string query)
{
Text = text;
_query = query ?? string.Empty;
}
public override ICommandResult Invoke()
{
ClipboardHelper.SetText(Text);
if (_canStoreHistory())
{
_settings.AddHistoryItem(new HistoryItem(_query, Text, DateTime.UtcNow));
}
ReplaceRequested?.Invoke(this, null);
return Result;
}
}

View File

@@ -0,0 +1,83 @@
// Copyright (c) Microsoft Corporation
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
using System;
using System.Threading;
using CommunityToolkit.Mvvm.Messaging;
using Microsoft.CmdPal.Common.Messages;
using Microsoft.CmdPal.Ext.Calc.Properties;
using Microsoft.CommandPalette.Extensions;
using Microsoft.CommandPalette.Extensions.Toolkit;
using Windows.Foundation;
namespace Microsoft.CmdPal.Ext.Calc.Helper;
public sealed partial class CalculatorPasteCommand : InvokableCommand
{
public event TypedEventHandler<object, object> ReplaceRequested;
private readonly ISettingsInterface _settings;
private readonly Func<bool> _canStoreHistory;
private string _query;
private string _text;
public CalculatorPasteCommand(string result, string query, ISettingsInterface settings, bool canStoreHistory = true)
: this(result, query, settings, () => canStoreHistory)
{
}
public CalculatorPasteCommand(string result, string query, ISettingsInterface settings, Func<bool> canStoreHistory)
{
ArgumentNullException.ThrowIfNull(settings);
ArgumentNullException.ThrowIfNull(canStoreHistory);
_settings = settings;
_canStoreHistory = canStoreHistory;
_query = query ?? string.Empty;
_text = result;
Name = Resources.calculator_paste_command_name;
Icon = Icons.PasteIcon;
}
private static void HideWindow()
{
// TODO GH #524: This isn't great - this requires us to have Secret Sauce in
// the clipboard extension to be able to manipulate the HWND.
// We probably need to put some window manipulation into the API, but
// what form that takes is not clear yet.
WeakReferenceMessenger.Default.Send<HideWindowMessage>();
}
public void Update(string text, string query)
{
_text = text;
_query = query ?? string.Empty;
}
public override ICommandResult Invoke()
{
ClipboardHelper.SetText(_text);
if (_canStoreHistory())
{
_settings.AddHistoryItem(new HistoryItem(_query, _text, DateTime.UtcNow));
}
HideWindow();
// Give the window some time to hide, and allow the other app to gain focus.
// Since we don't currently have a way to wait until the other window is ready
// to receive input, we just wing it with a short delay.
Thread.Sleep(200);
PasteHelper.SendPasteKeyCombination();
ReplaceRequested?.Invoke(this, null);
return CommandResult.ShowToast(new ToastArgs()
{
Message = Resources.calculator_paste_toast_text,
Result = CommandResult.KeepOpen(),
});
}
}

View File

@@ -0,0 +1,29 @@
// Copyright (c) Microsoft Corporation
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
using System;
using Microsoft.CmdPal.Ext.Calc.Properties;
using Microsoft.CommandPalette.Extensions.Toolkit;
namespace Microsoft.CmdPal.Ext.Calc.Helper;
internal sealed partial class ClearHistoryCommand : InvokableCommand
{
private readonly ISettingsInterface _settings;
public ClearHistoryCommand(ISettingsInterface settings)
{
ArgumentNullException.ThrowIfNull(settings);
_settings = settings;
Name = Resources.calculator_history_delete_all;
Icon = Icons.DeleteIcon;
}
public override CommandResult Invoke()
{
_settings.ClearHistory();
return CommandResult.KeepOpen();
}
}

View File

@@ -0,0 +1,31 @@
// Copyright (c) Microsoft Corporation
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
using System;
using Microsoft.CmdPal.Ext.Calc.Properties;
using Microsoft.CommandPalette.Extensions.Toolkit;
namespace Microsoft.CmdPal.Ext.Calc.Helper;
internal sealed partial class DeleteHistoryItemCommand : InvokableCommand
{
private readonly ISettingsInterface _settings;
private readonly Guid _historyItemId;
public DeleteHistoryItemCommand(ISettingsInterface settings, Guid historyItemId)
{
ArgumentNullException.ThrowIfNull(settings);
_settings = settings;
_historyItemId = historyItemId;
Name = Resources.calculator_history_delete;
Icon = Icons.DeleteIcon;
}
public override CommandResult Invoke()
{
_settings.RemoveHistoryItem(_historyItemId);
return CommandResult.KeepOpen();
}
}

View File

@@ -1,4 +1,4 @@
// Copyright (c) Microsoft Corporation
// Copyright (c) Microsoft Corporation
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.

View File

@@ -0,0 +1,17 @@
// Copyright (c) Microsoft Corporation
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
using System;
using System.Collections.Generic;
using System.Text.Json.Serialization;
namespace Microsoft.CmdPal.Ext.Calc.Helper;
[JsonSerializable(typeof(DateTime))]
[JsonSerializable(typeof(Guid))]
[JsonSerializable(typeof(string))]
[JsonSerializable(typeof(HistoryItem))]
[JsonSerializable(typeof(List<HistoryItem>), TypeInfoPropertyName = "HistoryItemList")]
[JsonSourceGenerationOptions(UseStringEnumConverter = true, WriteIndented = true, IncludeFields = true, PropertyNameCaseInsensitive = true, AllowTrailingCommas = true)]
internal sealed partial class CalculatorJsonSerializationContext : JsonSerializerContext;

View File

@@ -0,0 +1,30 @@
// Copyright (c) Microsoft Corporation
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
using System;
namespace Microsoft.CmdPal.Ext.Calc.Helper;
public sealed class HistoryItem
{
public Guid Id { get; set; } = Guid.NewGuid();
public string Query { get; set; } = string.Empty;
public string Result { get; set; } = string.Empty;
public DateTime Timestamp { get; set; } = DateTime.UtcNow;
public HistoryItem()
{
}
public HistoryItem(string query, string result, DateTime timestamp)
{
Id = Guid.NewGuid();
Query = query;
Result = result;
Timestamp = timestamp;
}
}

View File

@@ -0,0 +1,162 @@
// Copyright (c) Microsoft Corporation
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
using System;
using System.Collections.Generic;
using System.IO;
using System.Text.Json;
using System.Threading;
using ManagedCommon;
namespace Microsoft.CmdPal.Ext.Calc.Helper;
internal sealed class HistoryStore
{
private readonly string _filePath;
private readonly List<HistoryItem> _items = [];
private readonly Lock _lock = new();
private int _capacity;
public event EventHandler Changed;
public HistoryStore(string filePath, int capacity)
{
ArgumentNullException.ThrowIfNull(filePath);
ArgumentOutOfRangeException.ThrowIfNegative(capacity);
_filePath = filePath;
_capacity = capacity;
_items.AddRange(LoadFromDiskSafe());
TrimNoLock();
}
public IReadOnlyList<HistoryItem> HistoryItems
{
get
{
lock (_lock)
{
return [.. _items];
}
}
}
public void Add(HistoryItem item)
{
ArgumentNullException.ThrowIfNull(item);
lock (_lock)
{
_items.Add(item);
_ = TrimNoLock();
SaveNoLock();
}
Changed?.Invoke(this, EventArgs.Empty);
}
public bool Remove(Guid id)
{
var removed = false;
lock (_lock)
{
var index = _items.FindIndex(item => item.Id == id);
if (index >= 0)
{
_items.RemoveAt(index);
SaveNoLock();
removed = true;
}
}
if (removed)
{
Changed?.Invoke(this, EventArgs.Empty);
}
return removed;
}
public bool Clear()
{
var cleared = false;
lock (_lock)
{
if (_items.Count > 0)
{
_items.Clear();
SaveNoLock();
cleared = true;
}
}
if (cleared)
{
Changed?.Invoke(this, EventArgs.Empty);
}
return cleared;
}
public void SetCapacity(int capacity)
{
ArgumentOutOfRangeException.ThrowIfNegative(capacity);
bool trimmed;
lock (_lock)
{
_capacity = capacity;
trimmed = TrimNoLock();
if (trimmed)
{
SaveNoLock();
}
}
if (trimmed)
{
Changed?.Invoke(this, EventArgs.Empty);
}
}
private bool TrimNoLock()
{
var max = _capacity;
if (_items.Count > max)
{
_items.RemoveRange(0, _items.Count - max);
return true;
}
return false;
}
private List<HistoryItem> LoadFromDiskSafe()
{
try
{
if (!File.Exists(_filePath))
{
return [];
}
var fileContent = File.ReadAllText(_filePath);
var historyItems = JsonSerializer.Deserialize<List<HistoryItem>>(fileContent, CalculatorJsonSerializationContext.Default.HistoryItemList) ?? [];
return historyItems;
}
catch (Exception ex)
{
Logger.LogError("Unable to load calculator history", ex);
return [];
}
}
private void SaveNoLock()
{
var json = JsonSerializer.Serialize(_items, CalculatorJsonSerializationContext.Default.HistoryItemList);
File.WriteAllText(_filePath, json);
}
}

View File

@@ -2,10 +2,17 @@
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
using System;
using System.Collections.Generic;
namespace Microsoft.CmdPal.Ext.Calc.Helper;
public interface ISettingsInterface
{
public event EventHandler HistoryChanged;
public event EventHandler SettingsChanged;
public CalculateEngine.TrigMode TrigUnit { get; }
public bool InputUseEnglishFormat { get; }
@@ -14,7 +21,23 @@ public interface ISettingsInterface
public bool CloseOnEnter { get; }
public bool ReplaceQueryOnEnter { get; }
public bool CopyResultToSearchBarIfQueryEndsWithEqualSign { get; }
public bool AutoFixQuery { get; }
public bool SaveFallbackResultsToHistory { get; }
public bool DeleteHistoryRequiresConfirmation { get; }
public PrimaryAction PrimaryAction { get; }
public IReadOnlyList<HistoryItem> HistoryItems { get; }
public void AddHistoryItem(HistoryItem historyItem);
public void RemoveHistoryItem(Guid historyItemId);
public void ClearHistory();
}

View File

@@ -0,0 +1,150 @@
// Copyright (c) Microsoft Corporation
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
using System;
using System.Runtime.InteropServices;
using Microsoft.CommandPalette.Extensions.Toolkit;
using Windows.System;
namespace Microsoft.CmdPal.Ext.Calc.Helper;
internal static partial class PasteHelper
{
private const nuint IgnoreKeyEventFlag = 0x5555;
private static void SendSingleKeyboardInput(VirtualKey keyCode, KeyEventF keyStatus)
{
var input = new INPUT
{
type = INPUTTYPE.INPUT_KEYBOARD,
data = new InputUnion
{
ki = new KEYBDINPUT
{
wVk = (short)keyCode,
dwFlags = (uint)keyStatus,
// Any key event with the extraInfo set to this value will be ignored
// by the keyboard hook and sent to the system instead.
dwExtraInfo = IgnoreKeyEventFlag,
},
},
};
Span<INPUT> inputs = [input];
_ = SendInput(1, inputs, INPUT.Size);
}
private static bool IsKeyDown(VirtualKey key) => (GetAsyncKeyState((int)key) & 0x8000) != 0;
private static void ReleaseModifierIfPressed(VirtualKey key)
{
if (IsKeyDown(key))
{
SendSingleKeyboardInput(key, KeyEventF.KeyUp);
}
}
internal static void SendPasteKeyCombination()
{
ExtensionHost.LogMessage(new LogMessage { Message = "Sending paste keys..." });
// Only release modifier keys that are actually pressed
ReleaseModifierIfPressed(VirtualKey.LeftControl);
ReleaseModifierIfPressed(VirtualKey.RightControl);
ReleaseModifierIfPressed(VirtualKey.LeftWindows);
ReleaseModifierIfPressed(VirtualKey.RightWindows);
ReleaseModifierIfPressed(VirtualKey.LeftShift);
ReleaseModifierIfPressed(VirtualKey.RightShift);
ReleaseModifierIfPressed(VirtualKey.LeftMenu);
ReleaseModifierIfPressed(VirtualKey.RightMenu);
// Send Ctrl + V
SendSingleKeyboardInput(VirtualKey.Control, KeyEventF.KeyDown);
SendSingleKeyboardInput(VirtualKey.V, KeyEventF.KeyDown);
SendSingleKeyboardInput(VirtualKey.V, KeyEventF.KeyUp);
SendSingleKeyboardInput(VirtualKey.Control, KeyEventF.KeyUp);
ExtensionHost.LogMessage(new LogMessage { Message = "Paste sent" });
}
[LibraryImport("user32.dll")]
private static partial uint SendInput(uint nInputs, Span<INPUT> pInputs, int cbSize);
[LibraryImport("user32.dll")]
private static partial short GetAsyncKeyState(int vKey);
[StructLayout(LayoutKind.Sequential)]
[System.Diagnostics.CodeAnalysis.SuppressMessage("StyleCop.CSharp.NamingRules", "SA1307:Accessible fields should begin with upper-case letter", Justification = "Matching Native Structure")]
private struct INPUT
{
public INPUTTYPE type;
public InputUnion data;
public static int Size => Marshal.SizeOf<INPUT>();
}
[StructLayout(LayoutKind.Explicit)]
[System.Diagnostics.CodeAnalysis.SuppressMessage("StyleCop.CSharp.NamingRules", "SA1307:Accessible fields should begin with upper-case letter", Justification = "Matching Native Structure")]
private struct InputUnion
{
[FieldOffset(0)]
public MOUSEINPUT mi;
[FieldOffset(0)]
public KEYBDINPUT ki;
[FieldOffset(0)]
public HARDWAREINPUT hi;
}
[StructLayout(LayoutKind.Sequential)]
[System.Diagnostics.CodeAnalysis.SuppressMessage("StyleCop.CSharp.NamingRules", "SA1307:Accessible fields should begin with upper-case letter", Justification = "Matching Native Structure")]
private struct MOUSEINPUT
{
public int dx;
public int dy;
public int mouseData;
public uint dwFlags;
public uint time;
public nuint dwExtraInfo;
}
[StructLayout(LayoutKind.Sequential)]
[System.Diagnostics.CodeAnalysis.SuppressMessage("StyleCop.CSharp.NamingRules", "SA1307:Accessible fields should begin with upper-case letter", Justification = "Matching Native Structure")]
private struct KEYBDINPUT
{
public short wVk;
public short wScan;
public uint dwFlags;
public int time;
public nuint dwExtraInfo;
}
[StructLayout(LayoutKind.Sequential)]
[System.Diagnostics.CodeAnalysis.SuppressMessage("StyleCop.CSharp.NamingRules", "SA1307:Accessible fields should begin with upper-case letter", Justification = "Matching Native Structure")]
private struct HARDWAREINPUT
{
public int uMsg;
public short wParamL;
public short wParamH;
}
private enum INPUTTYPE : uint
{
INPUT_MOUSE = 0,
INPUT_KEYBOARD = 1,
INPUT_HARDWARE = 2,
}
[Flags]
private enum KeyEventF : uint
{
KeyDown = 0x0000,
ExtendedKey = 0x0001,
KeyUp = 0x0002,
Unicode = 0x0004,
Scancode = 0x0008,
}
}

View File

@@ -0,0 +1,12 @@
// Copyright (c) Microsoft Corporation
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
namespace Microsoft.CmdPal.Ext.Calc.Helper;
public enum PrimaryAction
{
Default,
Copy,
Paste,
}

View File

@@ -17,14 +17,9 @@ public static partial class QueryHelper
ISettingsInterface settings,
bool isFallbackSearch,
out string displayQuery,
TypedEventHandler<object, object> handleSave = null,
TypedEventHandler<object, object> handleReplace = null)
{
ArgumentNullException.ThrowIfNull(query);
if (!isFallbackSearch)
{
ArgumentNullException.ThrowIfNull(handleSave);
}
CultureInfo inputCulture =
settings.InputUseEnglishFormat ? new CultureInfo("en-us") : CultureInfo.CurrentCulture;
@@ -87,13 +82,9 @@ public static partial class QueryHelper
return errorMessage == default ? null : ErrorHandler.OnError(isFallbackSearch, query, errorMessage);
}
if (isFallbackSearch)
{
// Fallback search
return ResultHelper.CreateResult(result.RoundedResult, inputCulture, outputCulture, displayQuery);
}
return ResultHelper.CreateResult(result.RoundedResult, inputCulture, outputCulture, displayQuery, settings, handleSave, handleReplace);
return isFallbackSearch
? ResultHelper.CreateResultForFallback(result.RoundedResult, inputCulture, outputCulture, displayQuery)
: ResultHelper.CreateResultForPage(result.RoundedResult, inputCulture, outputCulture, displayQuery, settings, handleReplace);
}
catch (OverflowException)
{

View File

@@ -15,13 +15,21 @@ namespace Microsoft.CmdPal.Ext.Calc.Helper;
public static class ResultHelper
{
public static ListItem CreateResult(
internal static CommandResult CreateCopyCommandResult(bool hideOnCopy)
{
return CommandResult.ShowToast(new ToastArgs
{
Message = Properties.Resources.calculator_copy_toast_text,
Result = hideOnCopy ? CommandResult.Hide() : CommandResult.KeepOpen(),
});
}
public static ListItem CreateResultForPage(
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.
@@ -32,33 +40,44 @@ public static class ResultHelper
var result = roundedResult?.ToString(outputCulture);
// Create a SaveCommand and subscribe to the SaveRequested event
// This can append the result to the history list.
var saveCommand = new SaveCommand(result);
saveCommand.SaveRequested += handleSave;
var replaceCommand = new ReplaceQueryCommand();
replaceCommand.ReplaceRequested += handleReplace;
var copyCommandItem = CreateResult(roundedResult, inputCulture, outputCulture, query);
var copyCommand = new CalculatorCopyCommand(result, query, settings);
copyCommand.ReplaceRequested += ReplaceOnAction;
var pasteCommand = new CalculatorPasteCommand(result, query, settings);
pasteCommand.ReplaceRequested += ReplaceOnAction;
var usePaste = settings.PrimaryAction == PrimaryAction.Paste;
var primaryCommand = usePaste ? (ICommand)pasteCommand : copyCommand;
var secondaryCommand = usePaste ? (ICommand)copyCommand : pasteCommand;
var copyCommandItem = CreateResultItem(roundedResult, inputCulture, outputCulture, query, primaryCommand, settings.CloseOnEnter);
// No TextToSuggest on the main save command item. We don't want to keep suggesting what the result is,
// as the user is typing it.
return new ListItem(settings.CloseOnEnter ? copyCommandItem.Command : saveCommand)
return new ListItem(primaryCommand)
{
// Using CurrentCulture since this is user facing
Icon = Icons.ResultIcon,
Title = result,
Subtitle = query,
MoreCommands = [
new CommandContextItem(settings.CloseOnEnter ? saveCommand : copyCommandItem.Command),
new CommandContextItem(secondaryCommand),
new CommandContextItem(replaceCommand) { RequestedShortcut = KeyChords.CopyResultToSearchBox, },
..copyCommandItem.MoreCommands,
],
};
void ReplaceOnAction(object sender, object args)
{
if (settings.ReplaceQueryOnEnter)
{
handleReplace(sender, args);
}
}
}
public static ListItem CreateResult(decimal? roundedResult, CultureInfo inputCulture, CultureInfo outputCulture, string query)
public static ListItem CreateResultForFallback(decimal? roundedResult, CultureInfo inputCulture, CultureInfo outputCulture, string query)
{
// Return null when the expression is not a valid calculator query.
if (roundedResult is null)
@@ -66,6 +85,13 @@ public static class ResultHelper
return null;
}
var decimalResult = roundedResult?.ToString(outputCulture);
var copyCommand = CreateCopyCommand(decimalResult, Properties.Resources.calculator_copy_command_name, hideOnCopy: true);
return CreateResultItem(roundedResult, inputCulture, outputCulture, query, copyCommand, hideOnCopy: true);
}
private static ListItem CreateResultItem(decimal? roundedResult, CultureInfo inputCulture, CultureInfo outputCulture, string query, ICommand copyCommand, bool hideOnCopy)
{
var decimalResult = roundedResult?.ToString(outputCulture);
var decimalValue = (decimal)roundedResult;
@@ -83,7 +109,7 @@ public static class ResultHelper
try
{
var hexResult = BaseConverter.Convert(i, 16);
context.Add(new CommandContextItem(new CopyTextCommand(hexResult) { Name = Properties.Resources.calculator_copy_hex })
context.Add(new CommandContextItem(CreateCopyCommand(hexResult, Properties.Resources.calculator_copy_hex, hideOnCopy))
{
Title = hexResult,
});
@@ -97,7 +123,7 @@ public static class ResultHelper
try
{
var binaryResult = BaseConverter.Convert(i, 2);
context.Add(new CommandContextItem(new CopyTextCommand(binaryResult) { Name = Properties.Resources.calculator_copy_binary })
context.Add(new CommandContextItem(CreateCopyCommand(binaryResult, Properties.Resources.calculator_copy_binary, hideOnCopy))
{
Title = binaryResult,
});
@@ -111,7 +137,7 @@ public static class ResultHelper
try
{
var octalResult = BaseConverter.Convert(i, 8);
context.Add(new CommandContextItem(new CopyTextCommand(octalResult) { Name = Properties.Resources.calculator_copy_octal })
context.Add(new CommandContextItem(CreateCopyCommand(octalResult, Properties.Resources.calculator_copy_octal, hideOnCopy))
{
Title = octalResult,
});
@@ -127,7 +153,7 @@ public static class ResultHelper
Logger.LogError("Error creating integer context items", ex);
}
return new ListItem(new CopyTextCommand(decimalResult))
return new ListItem(copyCommand)
{
// Using CurrentCulture since this is user facing
Title = decimalResult,
@@ -136,4 +162,15 @@ public static class ResultHelper
MoreCommands = context.ToArray(),
};
}
private static CopyTextCommand CreateCopyCommand(string text, string name, bool hideOnCopy)
{
var command = new CopyTextCommand(text)
{
Name = name,
Result = CreateCopyCommandResult(hideOnCopy),
};
return command;
}
}

View File

@@ -2,8 +2,10 @@
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
using System;
using System.Collections.Generic;
using System.IO;
using ManagedCommon;
using Microsoft.CommandPalette.Extensions.Toolkit;
namespace Microsoft.CmdPal.Ext.Calc.Helper;
@@ -11,6 +13,17 @@ namespace Microsoft.CmdPal.Ext.Calc.Helper;
public class SettingsManager : JsonSettingsManager, ISettingsInterface
{
private static readonly string _namespace = "calculator";
private const int HistoryCapacity = 100;
public event EventHandler HistoryChanged
{
add => _history.Changed += value;
remove => _history.Changed -= value;
}
public event EventHandler SettingsChanged;
private readonly HistoryStore _history;
private static string Namespaced(string propertyName) => $"{_namespace}.{propertyName}";
@@ -45,6 +58,12 @@ public class SettingsManager : JsonSettingsManager, ISettingsInterface
Properties.Resources.calculator_settings_close_on_enter_description,
true);
private readonly ToggleSetting _replaceQueryOnEnter = new(
Namespaced(nameof(ReplaceQueryOnEnter)),
Properties.Resources.calculator_settings_replace_query_on_enter,
Properties.Resources.calculator_settings_replace_query_on_enter_description,
true);
private readonly ToggleSetting _copyResultToSearchBarIfQueryEndsWithEqualSign = new(
Namespaced(nameof(CopyResultToSearchBarIfQueryEndsWithEqualSign)),
Properties.Resources.calculator_settings_copy_result_to_search_bar,
@@ -57,6 +76,28 @@ public class SettingsManager : JsonSettingsManager, ISettingsInterface
Properties.Resources.calculator_settings_auto_fix_query_description,
true);
private readonly ToggleSetting _saveFallbackResultsToHistory = new(
Namespaced(nameof(SaveFallbackResultsToHistory)),
Properties.Resources.calculator_settings_fallback_history,
Properties.Resources.calculator_settings_fallback_history_description,
true);
private readonly ToggleSetting _confirmDelete = new(
Namespaced(nameof(DeleteHistoryRequiresConfirmation)),
Properties.Resources.calculator_settings_confirm_delete_title,
Properties.Resources.calculator_settings_confirm_delete_description,
true);
private readonly ChoiceSetSetting _primaryAction = new(
Namespaced(nameof(PrimaryAction)),
Properties.Resources.calculator_settings_primary_action_title,
Properties.Resources.calculator_settings_primary_action_description,
[
new ChoiceSetSetting.Choice(Properties.Resources.calculator_settings_primary_action_default, PrimaryAction.Default.ToString("G")),
new ChoiceSetSetting.Choice(Properties.Resources.calculator_settings_primary_action_copy, PrimaryAction.Copy.ToString("G")),
new ChoiceSetSetting.Choice(Properties.Resources.calculator_settings_primary_action_paste, PrimaryAction.Paste.ToString("G")),
]);
public CalculateEngine.TrigMode TrigUnit
{
get
@@ -93,10 +134,20 @@ public class SettingsManager : JsonSettingsManager, ISettingsInterface
public bool CloseOnEnter => _closeOnEnter.Value;
public bool ReplaceQueryOnEnter => _replaceQueryOnEnter.Value;
public bool CopyResultToSearchBarIfQueryEndsWithEqualSign => _copyResultToSearchBarIfQueryEndsWithEqualSign.Value;
public bool AutoFixQuery => _autoFixQuery.Value;
public bool SaveFallbackResultsToHistory => _saveFallbackResultsToHistory.Value;
public bool DeleteHistoryRequiresConfirmation => _confirmDelete.Value;
public PrimaryAction PrimaryAction => Enum.TryParse<PrimaryAction>(_primaryAction.Value, out var action) ? action : PrimaryAction.Default;
public IReadOnlyList<HistoryItem> HistoryItems => _history.HistoryItems;
internal static string SettingsJsonPath()
{
var directory = Utilities.BaseSettingsPath("Microsoft.CmdPal");
@@ -113,11 +164,68 @@ public class SettingsManager : JsonSettingsManager, ISettingsInterface
Settings.Add(_inputUseEnNumberFormat);
Settings.Add(_outputUseEnNumberFormat);
Settings.Add(_closeOnEnter);
Settings.Add(_replaceQueryOnEnter);
Settings.Add(_copyResultToSearchBarIfQueryEndsWithEqualSign);
Settings.Add(_autoFixQuery);
Settings.Add(_saveFallbackResultsToHistory);
Settings.Add(_confirmDelete);
Settings.Add(_primaryAction);
LoadSettings();
Settings.SettingsChanged += (s, a) => this.SaveSettings();
_history = new HistoryStore(HistoryStateJsonPath(), HistoryCapacity);
Settings.SettingsChanged += (s, a) =>
{
this.SaveSettings();
SettingsChanged?.Invoke(this, EventArgs.Empty);
};
}
private static string HistoryStateJsonPath()
{
var directory = Utilities.BaseSettingsPath("Microsoft.CmdPal");
Directory.CreateDirectory(directory);
return Path.Combine(directory, "calculator_history.json");
}
public void AddHistoryItem(HistoryItem historyItem)
{
try
{
_history.Add(historyItem);
}
catch (Exception ex)
{
Logger.LogError("Failed to add item to the calculator history", ex);
ExtensionHost.LogMessage(new LogMessage() { Message = ex.ToString() });
}
}
public void RemoveHistoryItem(Guid historyItemId)
{
try
{
_history.Remove(historyItemId);
}
catch (Exception ex)
{
Logger.LogError("Failed to remove item from the calculator history", ex);
ExtensionHost.LogMessage(new LogMessage() { Message = ex.ToString() });
}
}
public void ClearHistory()
{
try
{
_history.Clear();
}
catch (Exception ex)
{
Logger.LogError("Failed to clear calculator history", ex);
ExtensionHost.LogMessage(new LogMessage() { Message = ex.ToString() });
}
}
}

View File

@@ -6,7 +6,7 @@ using Microsoft.CommandPalette.Extensions.Toolkit;
namespace Microsoft.CmdPal.Ext.Calc;
internal sealed class Icons
internal static class Icons
{
internal static IconInfo CalculatorIcon => IconHelpers.FromRelativePath("Assets\\Calculator.svg");
@@ -14,5 +14,11 @@ internal sealed class Icons
internal static IconInfo SaveIcon => new("\uE74E"); // Save icon
internal static IconInfo DeleteIcon => new("\uE74D"); // Delete icon
internal static IconInfo HistoryIcon => new("\uE81C"); // History icon
internal static IconInfo PasteIcon => new("\uE77F"); // Paste icon
internal static IconInfo ErrorIcon => new("\uE783"); // Error icon
}

View File

@@ -10,4 +10,8 @@ namespace Microsoft.CmdPal.Ext.Calc;
internal static class KeyChords
{
internal static KeyChord CopyResultToSearchBox { get; } = new(VirtualKeyModifiers.Control | VirtualKeyModifiers.Shift, (int)VirtualKey.Enter, 0);
internal static KeyChord DeleteItemFromHistory { get; } = new(VirtualKeyModifiers.Control | VirtualKeyModifiers.Shift, (int)VirtualKey.Delete, 0);
internal static KeyChord ClearHistory { get; } = new(VirtualKeyModifiers.Control | VirtualKeyModifiers.Shift | VirtualKeyModifiers.Menu, (int)VirtualKey.Delete, 0);
}

View File

@@ -18,8 +18,14 @@
<ItemGroup>
<ProjectReference Include="..\..\..\..\common\CalculatorEngineCommon\CalculatorEngineCommon.vcxproj" />
<ProjectReference Include="..\..\..\..\common\ManagedCommon\ManagedCommon.csproj" />
<ProjectReference Include="..\..\Microsoft.CmdPal.Common\Microsoft.CmdPal.Common.csproj" />
<!-- CmdPal Toolkit reference now included via Common.ExtDependencies.props -->
</ItemGroup>
<ItemGroup>
<PackageReference Include="CommunityToolkit.Mvvm" />
</ItemGroup>
<ItemGroup>
<CsWinRTInputs Include="..\..\..\..\..\$(Platform)\$(Configuration)\CalculatorEngineCommon.winmd" />
<None Include="..\..\..\..\..\$(Platform)\$(Configuration)\CalculatorEngineCommon.winmd" Link="CalculatorEngineCommon.winmd">

View File

@@ -2,8 +2,10 @@
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
using System;
using System.Collections.Generic;
using System.Threading;
using Microsoft.CmdPal.Common.Commands;
using Microsoft.CmdPal.Ext.Calc.Helper;
using Microsoft.CmdPal.Ext.Calc.Properties;
using Microsoft.CommandPalette.Extensions;
@@ -23,10 +25,11 @@ namespace Microsoft.CmdPal.Ext.Calc.Pages;
public sealed partial class CalculatorListPage : DynamicListPage
{
private readonly Lock _resultsLock = new();
private readonly Lock _historyLock = new();
private readonly ISettingsInterface _settingsManager;
private readonly List<ListItem> _items = [];
private readonly List<ListItem> _history = [];
private readonly ListItem _emptyItem;
private List<ListItem> _historyItems = [];
// 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.
@@ -51,9 +54,26 @@ public sealed partial class CalculatorListPage : DynamicListPage
Title = Resources.calculator_placeholder_text,
};
_settingsManager.HistoryChanged += SettingsManagerOnHistoryChanged;
_settingsManager.SettingsChanged += SettingsManagerOnSettingsChanged;
UpdateHistory();
AppendResult(null);
UpdateSearchText(string.Empty, string.Empty);
}
private void SettingsManagerOnHistoryChanged(object sender, EventArgs e)
{
UpdateHistory();
AppendResult(GetCurrentResultItem());
}
private void SettingsManagerOnSettingsChanged(object sender, EventArgs e)
{
UpdateHistory();
AppendResult(RequeryCurrentResult());
}
private void HandleReplaceQuery(object sender, object args)
{
var lastResult = _items[0].Title;
@@ -90,9 +110,9 @@ public sealed partial class CalculatorListPage : DynamicListPage
_emptyItem.Subtitle = newSearch;
var result = QueryHelper.Query(newSearch, _settingsManager, isFallbackSearch: false, out var displayQuery, HandleSave, HandleReplaceQuery);
var result = QueryHelper.Query(newSearch, _settingsManager, isFallbackSearch: false, out var displayQuery, HandleReplaceQuery);
UpdateResult(result);
AppendResult(result);
if (copyResultToSearchText && result is not null)
{
@@ -105,7 +125,7 @@ public sealed partial class CalculatorListPage : DynamicListPage
}
}
private void UpdateResult(ListItem result)
private void AppendResult(ListItem result)
{
lock (_resultsLock)
{
@@ -120,42 +140,114 @@ public sealed partial class CalculatorListPage : DynamicListPage
_items.Add(_emptyItem);
}
this._items.AddRange(_history);
lock (_historyLock)
{
if (_historyItems.Count > 0)
{
this._items.Add(CreateSectionHeader(Resources.calculator_history_header));
this._items.AddRange(_historyItems);
}
}
}
RaiseItemsChanged(this._items.Count);
}
private void HandleSave(object sender, object args)
private void UpdateHistory()
{
var lastResult = _items[0].Title;
if (!string.IsNullOrEmpty(lastResult))
List<ListItem> history = [];
var items = _settingsManager.HistoryItems;
for (var index = items.Count - 1; index >= 0; index--)
{
var li = new ListItem(new CopyTextCommand(lastResult))
{
Title = _items[0].Title,
Subtitle = _items[0].Subtitle,
TextToSuggest = lastResult,
};
var historyItem = items[index];
history.Add(CreateHistoryItem(historyItem));
}
_history.Insert(0, li);
_items.Insert(1, li);
lock (_historyLock)
{
_historyItems = history;
}
}
// Why we need to clean the query record? Removed, but if necessary, please move it back.
// _items[0].Subtitle = string.Empty;
private ListItem CreateHistoryItem(HistoryItem historyItem)
{
var copyCommand = new CalculatorCopyCommand(historyItem.Result, historyItem.Query, _settingsManager, canStoreHistory: false);
var pasteCommand = new CalculatorPasteCommand(historyItem.Result, historyItem.Query, _settingsManager, canStoreHistory: false);
var primaryCommand = _settingsManager.PrimaryAction == PrimaryAction.Paste ? (ICommand)pasteCommand : copyCommand;
var secondaryCommand = _settingsManager.PrimaryAction == PrimaryAction.Paste ? (ICommand)copyCommand : pasteCommand;
// this change will call the UpdateSearchText again.
// We need to avoid it.
_skipQuerySearchText = lastResult;
SearchText = lastResult;
var replaceResultCommand = new ReplaceQueryCommand();
replaceResultCommand.ReplaceRequested += (_, _) =>
{
_skipQuerySearchText = SearchText = historyItem.Result;
// 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);
var deleteConfirmationCommand = new ConfirmableCommand
{
Command = new DeleteHistoryItemCommand(_settingsManager, historyItem.Id),
ConfirmationTitle = Resources.calculator_delete_confirmation_title,
ConfirmationMessage = Resources.calculator_delete_confirmation_message,
IsConfirmationRequired = () => _settingsManager.DeleteHistoryRequiresConfirmation,
};
var deleteAllConfirmationCommand = new ConfirmableCommand
{
Command = new ClearHistoryCommand(_settingsManager),
ConfirmationTitle = Resources.calculator_delete_all_confirmation_title,
ConfirmationMessage = Resources.calculator_delete_all_confirmation_message,
IsConfirmationRequired = () => _settingsManager.DeleteHistoryRequiresConfirmation,
};
return new ListItem(primaryCommand)
{
Icon = Icons.HistoryIcon,
Title = historyItem.Result,
Subtitle = historyItem.Query,
TextToSuggest = historyItem.Result,
MoreCommands =
[
new CommandContextItem(secondaryCommand),
new CommandContextItem(replaceResultCommand),
new Separator(),
new CommandContextItem(deleteConfirmationCommand) { IsCritical = true, RequestedShortcut = KeyChords.DeleteItemFromHistory, },
new CommandContextItem(deleteAllConfirmationCommand) { IsCritical = true, RequestedShortcut = KeyChords.ClearHistory, },
],
};
}
private ListItem GetCurrentResultItem()
{
lock (_resultsLock)
{
return _items.Count > 0 ? _items[0] : _emptyItem;
}
}
private ListItem RequeryCurrentResult()
{
var searchText = SearchText ?? string.Empty;
if (string.IsNullOrEmpty(searchText))
{
return null;
}
return QueryHelper.Query(searchText, _settingsManager, isFallbackSearch: false, out _, HandleReplaceQuery);
}
public override IListItem[] GetItems() => _items.ToArray();
private static ListItem CreateSectionHeader(string title)
{
return new ListItem(new NoOpCommand())
{
Title = title,
Section = title,
Command = null!,
};
}
}

View File

@@ -2,8 +2,10 @@
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
using System.Collections.Generic;
using Microsoft.CmdPal.Ext.Calc.Helper;
using Microsoft.CmdPal.Ext.Calc.Properties;
using Microsoft.CommandPalette.Extensions;
using Microsoft.CommandPalette.Extensions.Toolkit;
namespace Microsoft.CmdPal.Ext.Calc.Pages;
@@ -11,18 +13,30 @@ namespace Microsoft.CmdPal.Ext.Calc.Pages;
public sealed partial class FallbackCalculatorItem : FallbackCommandItem
{
private const string _id = "com.microsoft.cmdpal.builtin.calculator.fallback";
private readonly CopyTextCommand _copyCommand = new(string.Empty);
private readonly ISettingsInterface _settings;
public FallbackCalculatorItem(ISettingsInterface settings)
private readonly NoOpCommand _noOpCommand = new();
private readonly CalculatorCopyCommand _copyCommand;
private readonly CalculatorPasteCommand _pasteCommand;
private readonly ISettingsInterface _settings;
private readonly CalculatorListPage _calculatorListPage;
private readonly CommandContextItem _openCalculatorPageContextItem;
public FallbackCalculatorItem(ISettingsInterface settings, CalculatorListPage calculatorListPage)
: base(new NoOpCommand(), Resources.calculator_title, _id)
{
Command = _copyCommand;
_copyCommand.Name = string.Empty;
_copyCommand = new CalculatorCopyCommand(string.Empty, string.Empty, settings, () => settings.SaveFallbackResultsToHistory);
_pasteCommand = new CalculatorPasteCommand(string.Empty, string.Empty, settings, () => settings.SaveFallbackResultsToHistory);
Command = _noOpCommand;
Title = string.Empty;
Subtitle = Resources.calculator_placeholder_text;
Icon = Icons.CalculatorIcon;
_settings = settings;
_calculatorListPage = calculatorListPage;
_openCalculatorPageContextItem = new CommandContextItem(_calculatorListPage)
{
Title = Resources.calculator_open_in_calculator,
};
}
public override void UpdateQuery(string query)
@@ -31,16 +45,22 @@ public sealed partial class FallbackCalculatorItem : FallbackCommandItem
if (result is null)
{
_copyCommand.Text = string.Empty;
_copyCommand.Name = string.Empty;
Command = _noOpCommand;
Title = string.Empty;
Subtitle = string.Empty;
MoreCommands = [];
return;
}
_copyCommand.Text = result.Title;
_copyCommand.Name = string.IsNullOrWhiteSpace(query) ? string.Empty : Resources.calculator_copy_command_name;
var pasteIsPrimary = _settings.PrimaryAction == PrimaryAction.Paste;
var primaryCommand = pasteIsPrimary ? (IInvokableCommand)_pasteCommand : _copyCommand;
var secondaryCommand = pasteIsPrimary ? (IInvokableCommand)_copyCommand : _pasteCommand;
// Update the selected commands with current query/result
UpdateCommand(primaryCommand, query, result);
UpdateCommand(secondaryCommand, query, result);
Command = primaryCommand;
Title = result.Title;
// we have to make the subtitle into an equation,
@@ -48,6 +68,28 @@ public sealed partial class FallbackCalculatorItem : FallbackCommandItem
// Otherwise, something like 1+2 will have a title of "3" and not match
Subtitle = query;
MoreCommands = result.MoreCommands;
// Set the search text in the calculator list page
_calculatorListPage.SearchText = query;
var fallbackCommands = new List<IContextItem>
{
_openCalculatorPageContextItem,
new CommandContextItem(secondaryCommand),
};
MoreCommands = [.. fallbackCommands, .. result.MoreCommands];
}
private static void UpdateCommand(IInvokableCommand command, string query, ListItem result)
{
switch (command)
{
case CalculatorPasteCommand pasteCommand:
pasteCommand.Update(result.Title, query);
break;
case CalculatorCopyCommand copyCommand:
copyCommand.Update(result.Title, query);
break;
}
}
}

View File

@@ -105,6 +105,51 @@ namespace Microsoft.CmdPal.Ext.Calc.Properties {
}
}
/// <summary>
/// Looks up a localized string similar to Copied to clipboard.
/// </summary>
public static string calculator_copy_toast_text {
get {
return ResourceManager.GetString("calculator_copy_toast_text", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to Are you sure you want to delete all history items?.
/// </summary>
public static string calculator_delete_all_confirmation_message {
get {
return ResourceManager.GetString("calculator_delete_all_confirmation_message", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to Delete all history.
/// </summary>
public static string calculator_delete_all_confirmation_title {
get {
return ResourceManager.GetString("calculator_delete_all_confirmation_title", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to Are you sure you want to delete this history item?.
/// </summary>
public static string calculator_delete_confirmation_message {
get {
return ResourceManager.GetString("calculator_delete_confirmation_message", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to Delete history item.
/// </summary>
public static string calculator_delete_confirmation_title {
get {
return ResourceManager.GetString("calculator_delete_confirmation_title", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to Calculator.
/// </summary>
@@ -159,6 +204,33 @@ namespace Microsoft.CmdPal.Ext.Calc.Properties {
}
}
/// <summary>
/// Looks up a localized string similar to Delete.
/// </summary>
public static string calculator_history_delete {
get {
return ResourceManager.GetString("calculator_history_delete", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to Delete all.
/// </summary>
public static string calculator_history_delete_all {
get {
return ResourceManager.GetString("calculator_history_delete_all", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to History.
/// </summary>
public static string calculator_history_header {
get {
return ResourceManager.GetString("calculator_history_header", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to Calculation result is not a valid number (NaN).
/// </summary>
@@ -177,6 +249,33 @@ namespace Microsoft.CmdPal.Ext.Calc.Properties {
}
}
/// <summary>
/// Looks up a localized string similar to Open in calculator.
/// </summary>
public static string calculator_open_in_calculator {
get {
return ResourceManager.GetString("calculator_open_in_calculator", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to Paste.
/// </summary>
public static string calculator_paste_command_name {
get {
return ResourceManager.GetString("calculator_paste_command_name", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to Pasted from calculator.
/// </summary>
public static string calculator_paste_toast_text {
get {
return ResourceManager.GetString("calculator_paste_toast_text", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to Type an equation....
/// </summary>
@@ -231,6 +330,24 @@ namespace Microsoft.CmdPal.Ext.Calc.Properties {
}
}
/// <summary>
/// Looks up a localized string similar to Prompt before deleting history entries.
/// </summary>
public static string calculator_settings_confirm_delete_description {
get {
return ResourceManager.GetString("calculator_settings_confirm_delete_description", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to Ask for confirmation before deleting items.
/// </summary>
public static string calculator_settings_confirm_delete_title {
get {
return ResourceManager.GetString("calculator_settings_confirm_delete_title", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to Replace query with result on equals.
/// </summary>
@@ -249,6 +366,24 @@ namespace Microsoft.CmdPal.Ext.Calc.Properties {
}
}
/// <summary>
/// Looks up a localized string similar to Save fallback calculations.
/// </summary>
public static string calculator_settings_fallback_history {
get {
return ResourceManager.GetString("calculator_settings_fallback_history", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to Save copied results from fallback calculations to history.
/// </summary>
public static string calculator_settings_fallback_history_description {
get {
return ResourceManager.GetString("calculator_settings_fallback_history_description", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to Use English (United States) number format for input.
/// </summary>
@@ -303,6 +438,51 @@ namespace Microsoft.CmdPal.Ext.Calc.Properties {
}
}
/// <summary>
/// Looks up a localized string similar to Copy.
/// </summary>
public static string calculator_settings_primary_action_copy {
get {
return ResourceManager.GetString("calculator_settings_primary_action_copy", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to Default.
/// </summary>
public static string calculator_settings_primary_action_default {
get {
return ResourceManager.GetString("calculator_settings_primary_action_default", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to Choose the default action for results.
/// </summary>
public static string calculator_settings_primary_action_description {
get {
return ResourceManager.GetString("calculator_settings_primary_action_description", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to Paste.
/// </summary>
public static string calculator_settings_primary_action_paste {
get {
return ResourceManager.GetString("calculator_settings_primary_action_paste", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to Primary action (Enter key).
/// </summary>
public static string calculator_settings_primary_action_title {
get {
return ResourceManager.GetString("calculator_settings_primary_action_title", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to Replace input if query ends with &apos;=&apos;.
/// </summary>
@@ -321,6 +501,24 @@ namespace Microsoft.CmdPal.Ext.Calc.Properties {
}
}
/// <summary>
/// Looks up a localized string similar to Replace query on Enter.
/// </summary>
public static string calculator_settings_replace_query_on_enter {
get {
return ResourceManager.GetString("calculator_settings_replace_query_on_enter", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to Replace input when executing Copy or Paste.
/// </summary>
public static string calculator_settings_replace_query_on_enter_description {
get {
return ResourceManager.GetString("calculator_settings_replace_query_on_enter_description", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to Degrees.
/// </summary>

View File

@@ -140,6 +140,24 @@
<data name="calculator_copy_command_name" xml:space="preserve">
<value>Copy</value>
</data>
<data name="calculator_paste_command_name" xml:space="preserve">
<value>Paste</value>
</data>
<data name="calculator_paste_toast_text" xml:space="preserve">
<value>Pasted from calculator</value>
</data>
<data name="calculator_copy_toast_text" xml:space="preserve">
<value>Copied to clipboard</value>
</data>
<data name="calculator_history_delete" xml:space="preserve">
<value>Delete</value>
</data>
<data name="calculator_history_header" xml:space="preserve">
<value>History</value>
</data>
<data name="calculator_history_delete_all" xml:space="preserve">
<value>Delete all</value>
</data>
<data name="calculator_calculation_failed_title" xml:space="preserve">
<value>Failed to calculate the input</value>
</data>
@@ -199,6 +217,9 @@
<data name="calculator_not_covert_to_decimal" xml:space="preserve">
<value>Result value was either too large or too small for a decimal number</value>
</data>
<data name="calculator_open_in_calculator" xml:space="preserve">
<value>Open in calculator</value>
</data>
<data name="calculator_copy_hex" xml:space="preserve">
<value>Copy hexadecimal</value>
</data>
@@ -217,6 +238,45 @@
<data name="calculator_settings_auto_fix_query" xml:space="preserve">
<value>Fix incomplete calculations automatically</value>
</data>
<data name="calculator_settings_fallback_history" xml:space="preserve">
<value>Save fallback calculations</value>
</data>
<data name="calculator_settings_fallback_history_description" xml:space="preserve">
<value>Save copied results from fallback calculations to history</value>
</data>
<data name="calculator_settings_confirm_delete_title" xml:space="preserve">
<value>Ask for confirmation before deleting items</value>
</data>
<data name="calculator_settings_confirm_delete_description" xml:space="preserve">
<value>Prompt before deleting history entries</value>
</data>
<data name="calculator_settings_primary_action_title" xml:space="preserve">
<value>Primary action (Enter key)</value>
</data>
<data name="calculator_settings_primary_action_description" xml:space="preserve">
<value>Choose the default action for results</value>
</data>
<data name="calculator_settings_primary_action_default" xml:space="preserve">
<value>Default</value>
</data>
<data name="calculator_settings_primary_action_copy" xml:space="preserve">
<value>Copy</value>
</data>
<data name="calculator_settings_primary_action_paste" xml:space="preserve">
<value>Paste</value>
</data>
<data name="calculator_delete_confirmation_title" xml:space="preserve">
<value>Delete history item</value>
</data>
<data name="calculator_delete_confirmation_message" xml:space="preserve">
<value>Are you sure you want to delete this history item?</value>
</data>
<data name="calculator_delete_all_confirmation_title" xml:space="preserve">
<value>Delete all history</value>
</data>
<data name="calculator_delete_all_confirmation_message" xml:space="preserve">
<value>Are you sure you want to delete all history items?</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>
@@ -229,4 +289,10 @@
<data name="calculator_copy_octal" xml:space="preserve">
<value>Copy octal</value>
</data>
<data name="calculator_settings_replace_query_on_enter" xml:space="preserve">
<value>Replace query on Enter</value>
</data>
<data name="calculator_settings_replace_query_on_enter_description" xml:space="preserve">
<value>Replace input when executing Copy or Paste</value>
</data>
</root>

View File

@@ -3,8 +3,8 @@
// See the LICENSE file in the project root for more information.
using CommunityToolkit.Mvvm.Messaging;
using Microsoft.CmdPal.Common.Messages;
using Microsoft.CmdPal.Ext.ClipboardHistory.Helpers;
using Microsoft.CmdPal.Ext.ClipboardHistory.Messages;
using Microsoft.CmdPal.Ext.ClipboardHistory.Models;
using Microsoft.CommandPalette.Extensions.Toolkit;
using Windows.ApplicationModel.DataTransfer;

View File

@@ -12,6 +12,7 @@ using System.Threading;
using System.Threading.Tasks;
using Microsoft.CmdPal.Ext.Indexer.Data;
using Microsoft.CmdPal.Ext.Indexer.Helpers;
using Microsoft.CmdPal.Ext.Indexer.Indexer;
using Microsoft.CmdPal.Ext.Indexer.Properties;
using Microsoft.CommandPalette.Extensions;
using Microsoft.CommandPalette.Extensions.Toolkit;
@@ -120,12 +121,19 @@ internal sealed partial class FallbackOpenFileItem : FallbackCommandItem, IDispo
ct.ThrowIfCancellationRequested();
// We only need to know whether there are 0, 1, or more than one result
var results = searchEngine.FetchItems(0, 2, queryCookie: HardQueryCookie, out _, noIcons: true);
var results = searchEngine.FetchItems(0, 2, queryCookie: HardQueryCookie, out _, out var notice, noIcons: true);
var count = results.Count;
if (count == 0)
{
ClearResultForCurrentQuery(ct);
if (notice is { } searchNotice)
{
UpdateSearchNoticeForCurrentQuery(query, searchNotice, ct);
}
else
{
ClearResultForCurrentQuery(ct);
}
}
else if (count == 1)
{
@@ -233,6 +241,35 @@ internal sealed partial class FallbackOpenFileItem : FallbackCommandItem, IDispo
}
}
private bool UpdateSearchNoticeForCurrentQuery(string query, SearchNoticeInfo notice, CancellationToken ct)
{
var (title, subtitle) = GetFallbackNoticeText(notice);
var indexerPage = new IndexerPage(query);
var set = UpdateResultForCurrentQuery(
title,
subtitle,
Icons.FileExplorerIcon,
indexerPage,
[
new CommandContextItem(new OpenUrlCommand("ms-settings:search") { Name = Resources.Indexer_Command_OpenIndexerSettings! }),
],
null,
skipIcon: false,
ct);
if (!set)
{
indexerPage.Dispose();
}
return set;
}
internal static (string Title, string Subtitle) GetFallbackNoticeText(SearchNoticeInfo notice)
{
return (Resources.IndexerCommandsProvider_DisplayName!, notice.Title);
}
private void UpdateIconForCurrentQuery(IIconInfo icon, CancellationToken ct)
{
lock (_resultLock)

View File

@@ -26,19 +26,26 @@ internal static class DataSourceManager
private static bool InitializeDataSource()
{
var riid = typeof(IDBInitialize).GUID;
try
{
_dataSource = ComHelper.CreateComInstance<IDBInitialize>(ref Unsafe.AsRef(in CLSID.CollatorDataSource), CLSCTX.InProcServer);
}
catch (Exception e)
catch (Exception ex)
{
Logger.LogError($"Failed to create datasource. ex: {e.Message}");
Logger.LogError("Failed to create datasource.", ex);
return false;
}
_dataSource.Initialize();
try
{
_dataSource.Initialize();
}
catch (Exception ex)
{
Logger.LogError("Failed to initialize datasource.", ex);
_dataSource = null;
return false;
}
return true;
}

View File

@@ -0,0 +1,10 @@
// Copyright (c) Microsoft Corporation
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
namespace Microsoft.CmdPal.Ext.Indexer.Indexer;
internal readonly record struct SearchCatalogStatus(uint PendingItemsCount, int? HResult)
{
public bool IsAvailable => HResult is null;
}

View File

@@ -0,0 +1,76 @@
// Copyright (c) Microsoft Corporation
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
#nullable enable
using System;
using System.Runtime.CompilerServices;
using System.Threading;
using ManagedCommon;
using ManagedCsWin32;
using Microsoft.CmdPal.Ext.Indexer.Indexer.SystemSearch;
namespace Microsoft.CmdPal.Ext.Indexer.Indexer;
internal static class SearchCatalogStatusReader
{
private const string SystemIndex = "SystemIndex";
private static readonly Lock FailureLoggingLock = new();
private static int? _lastLoggedFailureHResult;
internal static SearchCatalogStatus GetStatus()
{
try
{
var catalogManager = CreateCatalogManager();
var pendingItemsCount = catalogManager.NumberOfItemsToIndex();
ResetFailureLoggingState();
return new SearchCatalogStatus(pendingItemsCount, null);
}
catch (Exception ex)
{
LogFailure(ex);
return new SearchCatalogStatus(0, ex.HResult);
}
}
private static ISearchCatalogManager CreateCatalogManager()
{
var searchManager = ComHelper.CreateComInstance<ISearchManager>(ref Unsafe.AsRef(in CLSID.SearchManager), CLSCTX.LocalServer);
var catalogManager = searchManager.GetCatalog(SystemIndex);
return catalogManager ?? throw new ArgumentException($"Failed to get catalog manager for {SystemIndex}");
}
private static void LogFailure(Exception ex)
{
var shouldLogWarning = false;
lock (FailureLoggingLock)
{
if (_lastLoggedFailureHResult != ex.HResult)
{
_lastLoggedFailureHResult = ex.HResult;
shouldLogWarning = true;
}
}
var message = $"Failed to read Windows Search catalog status. HResult=0x{ex.HResult:X8}, Message={ex.Message}";
if (shouldLogWarning)
{
Logger.LogWarning(message);
}
else
{
Logger.LogDebug(message);
}
}
private static void ResetFailureLoggingState()
{
lock (FailureLoggingLock)
{
_lastLoggedFailureHResult = null;
}
}
}

View File

@@ -0,0 +1,7 @@
// Copyright (c) Microsoft Corporation
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
namespace Microsoft.CmdPal.Ext.Indexer.Indexer;
public readonly record struct SearchNoticeInfo(string Title, string Subtitle);

View File

@@ -0,0 +1,67 @@
// Copyright (c) Microsoft Corporation
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
#nullable enable
using System;
using System.Diagnostics.CodeAnalysis;
using System.Globalization;
using Microsoft.CmdPal.Ext.Indexer.Properties;
namespace Microsoft.CmdPal.Ext.Indexer.Indexer;
internal static class SearchNoticeInfoBuilder
{
private const int RpcServerUnavailable = unchecked((int)0x800706BA);
private const int RpcDisconnected = unchecked((int)0x80010108);
private const int RpcCallRejected = unchecked((int)0x80010001);
private const int RpcServerCallRetryLater = unchecked((int)0x8001010A);
private const int ServiceDisabled = unchecked((int)0x80070422);
private const int ServiceNotActive = unchecked((int)0x80070426);
private const int ClassNotRegistered = unchecked((int)0x80040154);
private const int ServerExecutionFailed = unchecked((int)0x80080005);
internal static SearchNoticeInfo? FromQueryStatus(SearchQuery.SearchExecutionStatus status)
{
return status.State switch
{
SearchQuery.QueryState.NullDataSource or
SearchQuery.QueryState.CreateSessionFailed or
SearchQuery.QueryState.CreateCommandFailed => CreateUnavailableNotice(),
SearchQuery.QueryState.ExecuteFailed when IsSearchUnavailableHResult(status.HResult) => CreateUnavailableNotice(),
SearchQuery.QueryState.ExecuteFailed => CreateSearchFailedNotice(),
_ => null,
};
}
[SuppressMessage("Performance", "CA1863:Cache a 'CompositeFormat' for repeated use in this formatting operation", Justification = "Formatting a low-frequency user-visible notice once per query is sufficient.")]
internal static SearchNoticeInfo? FromCatalogStatus(SearchCatalogStatus status)
{
if (status.PendingItemsCount > 0)
{
return new SearchNoticeInfo(
Resources.Indexer_SearchIndexingMessage,
string.Format(CultureInfo.CurrentCulture, Resources.Indexer_SearchIndexingMessageTip, status.PendingItemsCount));
}
return null;
}
private static SearchNoticeInfo CreateUnavailableNotice() =>
new(Resources.Indexer_SearchUnavailableMessage, Resources.Indexer_SearchUnavailableMessageTip);
private static SearchNoticeInfo CreateSearchFailedNotice() =>
new(Resources.Indexer_SearchFailedMessage, Resources.Indexer_SearchFailedMessageTip);
private static bool IsSearchUnavailableHResult(int? hresult) =>
hresult is RpcServerUnavailable
or RpcDisconnected
or RpcCallRejected
or RpcServerCallRetryLater
or ServiceDisabled
or ServiceNotActive
or ClassNotRegistered
or ServerExecutionFailed;
}

View File

@@ -2,6 +2,8 @@
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
#nullable enable
using System;
using System.Collections.Concurrent;
using System.Diagnostics.CodeAnalysis;
@@ -23,17 +25,19 @@ internal sealed partial class SearchQuery : IDisposable
private readonly Lock _lockObject = new();
private IRowset _currentRowset;
private IRowset? _currentRowset;
private SearchSqlQueryPlan _queryPlan;
private bool _fallbackAttempted;
public QueryState State { get; private set; } = QueryState.NotStarted;
private int? LastHResult { get; set; }
private string LastErrorMessage { get; set; }
private string? LastErrorMessage { get; set; }
public uint Cookie { get; private set; }
public string SearchText { get; private set; }
public string SearchText { get; private set; } = string.Empty;
public ConcurrentQueue<SearchResult> SearchResults { get; private set; } = [];
@@ -52,18 +56,44 @@ internal sealed partial class SearchQuery : IDisposable
{
SearchText = searchText;
Cookie = cookie;
ExecuteSyncInternal();
_fallbackAttempted = false;
try
{
_queryPlan = QueryStringBuilder.GenerateQueryPlan(searchText);
}
catch (Exception ex)
{
lock (_lockObject)
{
State = QueryState.ExecuteFailed;
LastHResult = ex.HResult;
LastErrorMessage = ex.Message;
_currentRowset = null;
SearchResults.Clear();
}
Logger.LogError("Error preparing query", ex);
return;
}
ExecuteSyncInternal(_queryPlan.PrimarySqlQuery);
if (_currentRowset is null && State is QueryState.NoResults or QueryState.AllNoise)
{
TryExecuteFallbackQuery("primary query returned no rowset");
}
}
private void ExecuteSyncInternal()
private void ExecuteSyncInternal(string queryStr)
{
lock (_lockObject)
{
State = QueryState.Running;
LastHResult = null;
LastErrorMessage = null;
_currentRowset = null;
var queryStr = QueryStringBuilder.GenerateQuery(SearchText);
try
{
var result = ExecuteCommand(queryStr);
@@ -117,6 +147,11 @@ internal sealed partial class SearchQuery : IDisposable
{
if (_currentRowset is null)
{
if (offset == 0 && State is QueryState.NoResults or QueryState.AllNoise && TryExecuteFallbackQuery("primary query returned no results"))
{
return FetchRows(offset, limit);
}
var message = $"No rowset to fetch rows from. State={State}, HResult={LastHResult}, Error='{LastErrorMessage}'";
switch (State)
@@ -149,7 +184,7 @@ internal sealed partial class SearchQuery : IDisposable
Logger.LogInfo($"Reset the current rowset. State={State}, HResult={LastHResult}, Error='{LastErrorMessage}'");
Logger.LogError("Failed to cast current rowset to IGetRow", ex);
ExecuteSyncInternal();
ExecuteSyncInternal(_queryPlan.PrimarySqlQuery);
if (_currentRowset is null)
{
@@ -180,6 +215,11 @@ internal sealed partial class SearchQuery : IDisposable
if (rowCountReturned == 0)
{
if (offset == 0 && TryExecuteFallbackQuery("primary query returned zero rows"))
{
return FetchRows(offset, limit);
}
// No more rows to fetch
return false;
}
@@ -218,6 +258,20 @@ internal sealed partial class SearchQuery : IDisposable
}
}
private bool TryExecuteFallbackQuery(string reason)
{
if (_fallbackAttempted || !_queryPlan.HasFallback || State == QueryState.Cancelled)
{
return false;
}
_fallbackAttempted = true;
Logger.LogInfo($"Retrying search with implicit filename wildcard matching. Reason={reason}, Query=\"{SearchText}\"");
ExecuteSyncInternal(_queryPlan.FallbackSqlQuery!);
return _currentRowset is not null;
}
private static ExecuteCommandResult ExecuteCommand(string queryStr)
{
if (string.IsNullOrEmpty(queryStr))
@@ -286,6 +340,14 @@ internal sealed partial class SearchQuery : IDisposable
CancelOutstandingQueries();
}
internal SearchExecutionStatus GetExecutionStatus()
{
lock (_lockObject)
{
return new SearchExecutionStatus(State, LastHResult, LastErrorMessage);
}
}
internal enum QueryState
{
NotStarted = 0,
@@ -301,8 +363,13 @@ internal sealed partial class SearchQuery : IDisposable
}
private readonly record struct ExecuteCommandResult(
IRowset Rowset,
IRowset? Rowset,
QueryState State,
int? HResult,
string ErrorMessage);
string? ErrorMessage);
internal readonly record struct SearchExecutionStatus(
QueryState State,
int? HResult,
string? ErrorMessage);
}

View File

@@ -0,0 +1,450 @@
// Copyright (c) Microsoft Corporation
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
#nullable enable
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
namespace Microsoft.CmdPal.Ext.Indexer.Indexer.Utils;
internal static class ImplicitWildcardQueryBuilder
{
private const int MinimumContainsTermLength = 3;
internal static ImplicitWildcardExpandedQuery BuildExpandedQuery(string searchText)
{
if (string.IsNullOrWhiteSpace(searchText) || ContainsExplicitWildcards(searchText))
{
return default;
}
var parsedTokens = ParseTokens(searchText);
if (parsedTokens.Count == 0 || parsedTokens.Any(static token => token.Kind == ParsedTokenKind.ComplexSyntax))
{
return default;
}
var rawTerms = parsedTokens
.Where(static token => token.Kind == ParsedTokenKind.PlainTextTerm)
.Select(static token => token.Value)
.Distinct(StringComparer.OrdinalIgnoreCase)
.ToList();
if (rawTerms.Count == 0)
{
return default;
}
var structuredTokens = parsedTokens
.Where(static token => token.Kind == ParsedTokenKind.StructuredToken)
.Select(static token => token.Value)
.ToList();
var structuredSearchText = structuredTokens.Count > 0
? string.Join(' ', structuredTokens)
: null;
var containsRestriction = BuildContainsRestriction(ExtractContainsTerms(rawTerms));
var likeRestriction = BuildLikeRestriction(rawTerms);
var primaryRestriction = CombineRestrictions(containsRestriction, likeRestriction);
if (string.IsNullOrWhiteSpace(primaryRestriction))
{
return default;
}
var fallbackRestriction = !string.IsNullOrWhiteSpace(containsRestriction) && !string.IsNullOrWhiteSpace(likeRestriction)
? likeRestriction
: null;
return new ImplicitWildcardExpandedQuery(
structuredSearchText,
primaryRestriction,
fallbackRestriction);
}
private static List<ParsedToken> ParseTokens(string searchText)
{
var parsedTokens = new List<ParsedToken>();
var seenTerms = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
var expectsStructuredValue = false;
foreach (var token in Tokenize(searchText))
{
if (string.IsNullOrWhiteSpace(token))
{
continue;
}
if (IsComplexSyntaxToken(token))
{
parsedTokens.Add(new ParsedToken(token, ParsedTokenKind.ComplexSyntax));
expectsStructuredValue = false;
continue;
}
if (expectsStructuredValue || IsStructuredToken(token))
{
parsedTokens.Add(new ParsedToken(token, ParsedTokenKind.StructuredToken));
expectsStructuredValue = ExpectsAnotherStructuredValue(token);
continue;
}
var candidate = Unquote(token).Trim();
if (candidate.Length == 0 || !ContainsSearchableCharacters(candidate))
{
expectsStructuredValue = false;
continue;
}
if (seenTerms.Add(candidate))
{
parsedTokens.Add(new ParsedToken(candidate, ParsedTokenKind.PlainTextTerm));
}
expectsStructuredValue = false;
}
return parsedTokens;
}
private static bool ContainsExplicitWildcards(string searchText)
{
return searchText.Contains('*') || searchText.Contains('?');
}
private static List<string> Tokenize(string searchText)
{
var tokens = new List<string>();
var currentToken = new StringBuilder();
var inQuotes = false;
foreach (var ch in searchText)
{
if (ch == '"')
{
inQuotes = !inQuotes;
currentToken.Append(ch);
continue;
}
if (char.IsWhiteSpace(ch) && !inQuotes)
{
AppendCurrentToken(tokens, currentToken);
continue;
}
currentToken.Append(ch);
}
AppendCurrentToken(tokens, currentToken);
return tokens;
}
private static void AppendCurrentToken(List<string> tokens, StringBuilder currentToken)
{
if (currentToken.Length == 0)
{
return;
}
tokens.Add(currentToken.ToString());
currentToken.Clear();
}
private static bool IsStructuredToken(string token)
{
if (token.Length > 0 && token[0] is '+' or '-')
{
return true;
}
if (token.Contains('\\') || token.Contains('/'))
{
return true;
}
if (token.Contains('=') || token.Contains('>') || token.Contains('<'))
{
return true;
}
return token.Contains(':') && !LooksLikeDrivePath(token);
}
private static bool ExpectsAnotherStructuredValue(string token)
{
if (!token.Contains(':') || LooksLikeDrivePath(token))
{
return false;
}
var suffix = token[(token.LastIndexOf(':') + 1)..];
return suffix.Length == 0 || suffix.Equals("NOT", StringComparison.OrdinalIgnoreCase);
}
private static bool IsComplexSyntaxToken(string token)
{
return token.Contains('(')
|| token.Contains(')')
|| IsBooleanOperator(token);
}
private static bool IsBooleanOperator(string token)
{
return token.Equals("AND", StringComparison.OrdinalIgnoreCase)
|| token.Equals("OR", StringComparison.OrdinalIgnoreCase)
|| token.Equals("NOT", StringComparison.OrdinalIgnoreCase);
}
private static bool LooksLikeDrivePath(string token)
{
return token.Length >= 2
&& char.IsLetter(token[0])
&& token[1] == ':'
&& (token.Length == 2 || token[2] is '\\' or '/');
}
private static bool ContainsSearchableCharacters(string token)
{
foreach (var ch in token)
{
if (char.IsLetterOrDigit(ch) || IsLiteralLikeSearchCharacter(ch))
{
return true;
}
}
return false;
}
private static bool IsLiteralLikeSearchCharacter(char ch)
{
return ch is '%' or '_';
}
private static string Unquote(string token)
{
return token switch
{
['"', .. var inner, '"'] => inner,
_ => token,
};
}
private static List<string> ExtractContainsTerms(IReadOnlyList<string> rawTerms)
{
var terms = new List<string>();
var seenTerms = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
foreach (var rawTerm in rawTerms)
{
foreach (var candidate in ExtractContainsTermCandidates(rawTerm))
{
if (candidate.Length < MinimumContainsTermLength)
{
continue;
}
if (seenTerms.Add(candidate))
{
terms.Add(candidate);
}
}
}
return terms;
}
private static IEnumerable<string> ExtractContainsTermCandidates(string rawTerm)
{
if (ShouldUseLiteralOnlyMatching(rawTerm))
{
return [];
}
var normalized = new StringBuilder(rawTerm.Length);
foreach (var ch in rawTerm)
{
normalized.Append(char.IsLetterOrDigit(ch) ? ch : ' ');
}
return normalized
.ToString()
.Split(' ', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
}
private static bool ShouldUseLiteralOnlyMatching(string rawTerm)
{
if (rawTerm.Length < 2 || !IsWrapperPair(rawTerm[0], rawTerm[^1]))
{
return false;
}
var inner = rawTerm[1..^1];
if (!ContainsSearchableCharacters(inner))
{
return false;
}
return !HasInternalSeparatorPunctuation(inner);
}
private static bool IsWrapperPair(char start, char end) =>
(start, end) is ('[', ']') or ('{', '}') or ('<', '>');
private static bool HasInternalSeparatorPunctuation(string value)
{
for (var i = 1; i < value.Length - 1; i++)
{
if (!char.IsLetterOrDigit(value[i]) && IsLetterOrDigitNeighbor(value, i - 1, i + 1))
{
return true;
}
}
return false;
}
private static bool IsLetterOrDigitNeighbor(string value, int leftIndex, int rightIndex) =>
char.IsLetterOrDigit(value[leftIndex]) && char.IsLetterOrDigit(value[rightIndex]);
private static string? BuildContainsRestriction(IReadOnlyList<string> terms)
{
if (terms.Count == 0)
{
return null;
}
var predicates = new List<string>();
if (terms.Count == 1)
{
predicates.Add(BuildContainsPredicate(terms[0], usePrefixWildcard: false));
predicates.Add(BuildContainsPredicate(terms[0], usePrefixWildcard: true));
}
else
{
var phrase = string.Join(' ', terms);
predicates.Add(BuildContainsPredicate(phrase, usePrefixWildcard: false));
predicates.Add(BuildContainsPredicate(phrase, usePrefixWildcard: true));
predicates.Add(BuildContainsAllTermsPredicate(terms, usePrefixWildcard: false));
predicates.Add(BuildContainsAllTermsPredicate(terms, usePrefixWildcard: true));
}
return $"({string.Join(" OR ", predicates)})";
}
private static string BuildContainsPredicate(string term, bool usePrefixWildcard)
{
var escapedTerm = EscapeContainsTerm(term);
var query = usePrefixWildcard
? $"\"{escapedTerm}*\""
: $"\"{escapedTerm}\"";
return $"CONTAINS(System.ItemNameDisplay, '{query}')";
}
private static string BuildContainsAllTermsPredicate(IReadOnlyList<string> terms, bool usePrefixWildcard)
{
var joinedTerms = string.Join(
" AND ",
terms.Select(term =>
{
var escapedTerm = EscapeContainsTerm(term);
return usePrefixWildcard
? $"\"{escapedTerm}*\""
: $"\"{escapedTerm}\"";
}));
return $"CONTAINS(System.ItemNameDisplay, '{joinedTerms}')";
}
private static string? BuildLikeRestriction(IReadOnlyList<string> rawTerms)
{
if (rawTerms.Count == 0)
{
return null;
}
var predicates = rawTerms
.Select(BuildLikePredicate)
.ToList();
return predicates.Count == 1
? predicates[0]
: $"({string.Join(" AND ", predicates)})";
}
private static string BuildLikePredicate(string term)
{
var escapedTerm = EscapeLikeTerm(term);
return $"System.FileName LIKE '%{escapedTerm}%'";
}
private static string? CombineRestrictions(string? containsRestriction, string? likeRestriction)
{
if (string.IsNullOrWhiteSpace(containsRestriction))
{
return likeRestriction;
}
if (string.IsNullOrWhiteSpace(likeRestriction))
{
return containsRestriction;
}
return $"({containsRestriction} OR {likeRestriction})";
}
private static string EscapeContainsTerm(string value)
{
return value
.Replace("'", "''", StringComparison.Ordinal)
.Replace("\"", "\"\"", StringComparison.Ordinal);
}
private static string EscapeLikeTerm(string value)
{
var escaped = new StringBuilder(value.Length);
foreach (var ch in value)
{
escaped.Append(ch switch
{
'[' => "[[]",
']' => "[]]",
'%' => "[%]",
'_' => "[_]",
'\'' => "''",
_ => ch,
});
}
return escaped.ToString();
}
internal readonly record struct ImplicitWildcardExpandedQuery(
string? StructuredSearchText,
string? PrimaryRestriction,
string? FallbackRestriction)
{
public bool HasPrimaryRestriction => !string.IsNullOrWhiteSpace(PrimaryRestriction);
public bool HasFallbackRestriction => !string.IsNullOrWhiteSpace(FallbackRestriction);
}
private enum ParsedTokenKind
{
PlainTextTerm = 0,
StructuredToken,
ComplexSyntax,
}
private readonly record struct ParsedToken(string Value, ParsedTokenKind Kind);
}

View File

@@ -2,6 +2,8 @@
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
#nullable enable
using System;
using System.Runtime.CompilerServices;
using ManagedCommon;
@@ -16,43 +18,89 @@ internal static class QueryStringBuilder
private const string SystemIndex = "SystemIndex";
private const string ScopeFileConditions = "SCOPE='file:'";
private const string OrderConditions = "System.DateModified DESC";
private const string ContentProperties = "System.FileName";
private static ISearchQueryHelper queryHelper;
public static string GenerateQuery(string searchText)
public static SearchSqlQueryPlan GenerateQueryPlan(string searchText)
{
if (queryHelper is null)
var expandedQuery = ImplicitWildcardQueryBuilder.BuildExpandedQuery(searchText);
var primarySqlQuery = expandedQuery.HasPrimaryRestriction
? BuildQuery(expandedQuery.StructuredSearchText, expandedQuery.PrimaryRestriction!)
: GenerateQuery(searchText);
var fallbackSqlQuery = expandedQuery.HasFallbackRestriction
? BuildQuery(expandedQuery.StructuredSearchText, expandedQuery.FallbackRestriction!)
: null;
return new SearchSqlQueryPlan(primarySqlQuery, fallbackSqlQuery);
}
private static string GenerateQuery(string searchText, string? additionalRestrictions = null)
{
var queryHelper = CreateQueryHelper();
queryHelper.SetQuerySelectColumns(Properties);
queryHelper.SetQueryContentProperties(ContentProperties);
queryHelper.SetQuerySorting(OrderConditions);
queryHelper.SetQuerySyntax(SEARCH_QUERY_SYNTAX.SEARCH_ADVANCED_QUERY_SYNTAX);
var restrictions = $"AND {ScopeFileConditions}";
if (!string.IsNullOrWhiteSpace(additionalRestrictions))
{
ISearchManager searchManager;
try
{
searchManager = ComHelper.CreateComInstance<ISearchManager>(ref Unsafe.AsRef(in CLSID.SearchManager), CLSCTX.LocalServer);
}
catch (Exception ex)
{
Logger.LogError($"Failed to create searchManager. ex: {ex.Message}");
throw;
}
var catalogManager = searchManager.GetCatalog(SystemIndex);
if (catalogManager is null)
{
throw new ArgumentException($"Failed to get catalog manager for {SystemIndex}");
}
queryHelper = catalogManager.GetQueryHelper();
if (queryHelper is null)
{
throw new ArgumentException("Failed to get query helper from catalog manager");
}
queryHelper.SetQuerySelectColumns(Properties);
queryHelper.SetQueryContentProperties("System.FileName");
queryHelper.SetQuerySorting(OrderConditions);
restrictions += $" AND ({additionalRestrictions})";
}
queryHelper.SetQueryWhereRestrictions($"AND {ScopeFileConditions}");
queryHelper.SetQueryWhereRestrictions(restrictions);
return queryHelper.GenerateSQLFromUserQuery(searchText);
}
private static string BuildQuery(string? structuredSearchText, string restriction)
{
return string.IsNullOrWhiteSpace(structuredSearchText)
? GenerateRestrictionOnlyQuery(restriction)
: GenerateQuery(structuredSearchText, restriction);
}
private static string GenerateRestrictionOnlyQuery(string restriction)
{
return $"""
SELECT {Properties}
FROM {SystemIndex}
WHERE {ScopeFileConditions} AND ({restriction})
ORDER BY {OrderConditions}
""";
}
private static ISearchQueryHelper CreateQueryHelper()
{
ISearchManager searchManager;
try
{
searchManager = ComHelper.CreateComInstance<ISearchManager>(ref Unsafe.AsRef(in CLSID.SearchManager), CLSCTX.LocalServer);
}
catch (Exception ex)
{
Logger.LogError("Failed to create searchManager.", ex);
throw;
}
var catalogManager = searchManager.GetCatalog(SystemIndex);
if (catalogManager is null)
{
throw new ArgumentException($"Failed to get catalog manager for {SystemIndex}");
}
var queryHelper = catalogManager.GetQueryHelper();
if (queryHelper is null)
{
throw new ArgumentException("Failed to get query helper from catalog manager");
}
return queryHelper;
}
}
internal readonly record struct SearchSqlQueryPlan(string PrimarySqlQuery, string? FallbackSqlQuery)
{
public bool HasFallback => !string.IsNullOrWhiteSpace(FallbackSqlQuery);
}

View File

@@ -59,4 +59,8 @@
<AdditionalFiles Include="NativeMethods.json" />
</ItemGroup>
<ItemGroup>
<InternalsVisibleTo Include="Microsoft.CmdPal.Ext.Indexer.UnitTests" />
</ItemGroup>
</Project>

View File

@@ -34,10 +34,13 @@ internal sealed partial class IndexerPage : DynamicListPage, IDisposable
private CommandItem? _noSearchEmptyContent;
private CommandItem? _nothingFoundEmptyContent;
private CommandItem? _noticeEmptyContent;
private ListItem? _noticeListItem;
private SearchNoticeInfo? _currentNotice;
private bool _deferredLoad;
public override ICommandItem EmptyContent => _isEmptyQuery ? _noSearchEmptyContent! : _nothingFoundEmptyContent!;
public override ICommandItem EmptyContent => _isEmptyQuery ? _noSearchEmptyContent! : _currentNotice is null ? _nothingFoundEmptyContent! : _noticeEmptyContent!;
public IndexerPage()
{
@@ -94,6 +97,19 @@ internal sealed partial class IndexerPage : DynamicListPage, IDisposable
},
],
};
_noticeEmptyContent = new CommandItem(new OpenUrlCommand("ms-settings:search") { Name = Resources.Indexer_Command_OpenIndexerSettings! })
{
Icon = Icon,
};
_noticeListItem = new ListItem(new NoOpCommand())
{
Icon = Icon,
MoreCommands = [
new CommandContextItem(new OpenUrlCommand("ms-settings:search") { Name = Resources.Indexer_Command_OpenIndexerSettings! }),
],
};
}
private void StartManualSearch()
@@ -127,7 +143,9 @@ internal sealed partial class IndexerPage : DynamicListPage, IDisposable
_deferredLoad = false;
}
return [.. _indexerListItems];
return _currentNotice is null
? [.. _indexerListItems]
: [_noticeListItem!, .. _indexerListItems];
}
private string FullSearchString(string query)
@@ -160,7 +178,8 @@ internal sealed partial class IndexerPage : DynamicListPage, IDisposable
offset = _indexerListItems.Count;
}
var results = searchEngine?.FetchItems(offset, 20, queryCookie: HardQueryCookie, out hasMore) ?? [];
SearchNoticeInfo? notice = null;
var results = searchEngine?.FetchItems(offset, 20, queryCookie: HardQueryCookie, out hasMore, out notice) ?? [];
if (ct?.IsCancellationRequested == true)
{
@@ -176,10 +195,11 @@ internal sealed partial class IndexerPage : DynamicListPage, IDisposable
return;
}
ApplyNotice(notice);
_indexerListItems.AddRange(results);
HasMoreItems = hasMore;
IsLoading = false;
RaiseItemsChanged(_indexerListItems.Count);
RaiseItemsChanged(GetVisibleItemCount());
}
}
@@ -188,7 +208,8 @@ internal sealed partial class IndexerPage : DynamicListPage, IDisposable
lock (_searchLock)
{
_indexerListItems.Clear();
_searchEngine?.Query(query, queryCookie: HardQueryCookie);
var notice = _searchEngine?.Query(query, queryCookie: HardQueryCookie);
ApplyNotice(notice);
}
}
@@ -226,6 +247,7 @@ internal sealed partial class IndexerPage : DynamicListPage, IDisposable
// If the user hasn't provided any base query text, results should be empty
// regardless of the currently selected filter.
_isEmptyQuery = string.IsNullOrWhiteSpace(newSearch);
ApplyNotice(null);
if (_isEmptyQuery)
{
@@ -256,6 +278,7 @@ internal sealed partial class IndexerPage : DynamicListPage, IDisposable
lock (_searchLock)
{
RaiseItemsChanged(GetVisibleItemCount());
OnPropertyChanged(nameof(EmptyContent));
}
},
@@ -281,4 +304,21 @@ internal sealed partial class IndexerPage : DynamicListPage, IDisposable
GC.SuppressFinalize(this);
}
private void ApplyNotice(SearchNoticeInfo? notice)
{
_currentNotice = notice;
if (notice is null)
{
return;
}
_noticeEmptyContent!.Title = notice.Value.Title;
_noticeEmptyContent.Subtitle = notice.Value.Subtitle;
_noticeListItem!.Title = notice.Value.Title;
_noticeListItem.Subtitle = notice.Value.Subtitle;
}
private int GetVisibleItemCount() => _indexerListItems.Count + (_currentNotice is null ? 0 : 1);
}

View File

@@ -295,6 +295,60 @@ namespace Microsoft.CmdPal.Ext.Indexer.Properties {
}
}
/// <summary>
/// Looks up a localized string similar to Search couldn&apos;t be completed.
/// </summary>
internal static string Indexer_SearchFailedMessage {
get {
return ResourceManager.GetString("Indexer_SearchFailedMessage", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to Windows Search returned an unexpected error. Try again, or open Windows Search settings if the problem continues..
/// </summary>
internal static string Indexer_SearchFailedMessageTip {
get {
return ResourceManager.GetString("Indexer_SearchFailedMessageTip", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to Windows Search is still indexing files.
/// </summary>
internal static string Indexer_SearchIndexingMessage {
get {
return ResourceManager.GetString("Indexer_SearchIndexingMessage", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to There are still {0:N0} items waiting to be indexed, so some files and folders might not appear yet..
/// </summary>
internal static string Indexer_SearchIndexingMessageTip {
get {
return ResourceManager.GetString("Indexer_SearchIndexingMessageTip", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to Windows Search is unavailable.
/// </summary>
internal static string Indexer_SearchUnavailableMessage {
get {
return ResourceManager.GetString("Indexer_SearchUnavailableMessage", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to The Windows Search service or connection is unavailable right now. Start the service, then try your search again..
/// </summary>
internal static string Indexer_SearchUnavailableMessageTip {
get {
return ResourceManager.GetString("Indexer_SearchUnavailableMessageTip", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to Always on.
/// </summary>

View File

@@ -159,6 +159,24 @@
<data name="Indexer_PlaceholderText" xml:space="preserve">
<value>Search for files and folders...</value>
</data>
<data name="Indexer_SearchFailedMessage" xml:space="preserve">
<value>Search couldn't be completed</value>
</data>
<data name="Indexer_SearchFailedMessageTip" xml:space="preserve">
<value>Windows Search returned an unexpected error. Try again, or open Windows Search settings if the problem continues.</value>
</data>
<data name="Indexer_SearchIndexingMessage" xml:space="preserve">
<value>Windows Search is still indexing files</value>
</data>
<data name="Indexer_SearchIndexingMessageTip" xml:space="preserve">
<value>There are still {0:N0} items waiting to be indexed, so some files and folders might not appear yet.</value>
</data>
<data name="Indexer_SearchUnavailableMessage" xml:space="preserve">
<value>Windows Search is unavailable</value>
</data>
<data name="Indexer_SearchUnavailableMessageTip" xml:space="preserve">
<value>The Windows Search service or connection is unavailable right now. Start the service, then try your search again.</value>
</data>
<data name="Indexer_Settings_FallbackCommand_AlwaysOn" xml:space="preserve">
<value>Always on</value>
</data>
@@ -214,4 +232,4 @@ You can try searching all files on this PC or adjust your indexing settings.</va
<data name="Indexer_Fallback_MultipleResults_Subtitle" xml:space="preserve">
<value>The query matches multiple items</value>
</data>
</root>
</root>

View File

@@ -0,0 +1,110 @@
# File Search Built-in Extension
## Building Search Query
### Query Handling Contract
The module does not always forward the user query to Windows Search unchanged.
For simple free-text queries, it broadens filename matching so search feels more natural.
For queries that already look like AQS or other Windows Search syntax, it does not rewrite them.
That split is intentional.
The module is trying to improve plain filename search without breaking structured Windows Search queries.
### When We Do Not Rewrite
If the input looks structured, we pass it through `ISearchQueryHelper.GenerateSQLFromUserQuery(...)` as-is.
Examples:
- `name:report`
- `kind:folder`
- `kind:folder AND report`
- `*report*`
- `C:\Users`
- `size>10MB`
- `(report)`
Parentheses are treated conservatively because they can be real query syntax.
### What Broadening Means
For simple free-text input we may build two filename restrictions:
- a literal `LIKE` restriction on `System.FileName`
- an indexed `CONTAINS(System.ItemNameDisplay, ...)` restriction
They serve different purposes:
- `LIKE` preserves the original text literally
- `CONTAINS` gives better indexed matching and can normalize separator-like punctuation
The primary query may use both.
The fallback query uses the `LIKE` branch only.
### Intentional Asymmetry
The broadening is intentionally asymmetric.
Desired behavior:
- `red` should find `[red]`
- `[red]` should stay mostly literal
In other words:
- plain terms are broadened
- punctuation-wrapped literals are usually not normalized
- separator punctuation inside a token can still broaden
This is the most important design rule in the module.
### Separator Punctuation vs Wrapper Punctuation
Some punctuation behaves like a separator inside filenames.
Examples:
- `foo-bar`
- `20220409-tontrager.xlsx`
Users usually expect broadening here, because `tontrager` should still find `20220409-tontrager.xlsx`.
Other punctuation usually signals literal intent.
Examples:
- `[red]`
- `{draft}`
- `<todo>`
Those should usually stay on the literal filename path instead of being normalized to bare words.
### Examples
| User input | Behavior |
| --- | --- |
| `red` | broad plain-text search; can match `random [red] search.txt` |
| `[red]` | literal filename match; does not also broaden to plain `red` |
| `foo-bar` | keeps literal `foo-bar` matching and also broadens as a separator-style term |
| `term Kind:Folder` | broadens `term`, preserves `Kind:Folder` |
| `%` | treated as a literal percent sign in the filename match |
| `_` | treated as a literal underscore in the filename match |
| `(report)` | not rewritten locally; passed through to Windows Search |
### Why The Fallback Exists
Some inputs are valid literal filename searches but poor full-text searches.
Typical failure mode:
- the `CONTAINS(...)` side returns `QUERY_E_ALLNOISE`
- or the primary query otherwise fails to produce a useful rowset
When both branches exist:
- primary query = `CONTAINS(...) OR LIKE ...`
- fallback query = `LIKE ...` only
The fallback exists so punctuation-heavy or noisy input can still produce useful filename matches.

View File

@@ -20,12 +20,12 @@ public sealed partial class SearchEngine : IDisposable
{
private SearchQuery? _searchQuery = new();
public void Query(string query, uint queryCookie)
public SearchNoticeInfo? Query(string query, uint queryCookie)
{
var searchQuery = _searchQuery;
if (searchQuery is null)
{
return;
return null;
}
searchQuery.SearchResults.Clear();
@@ -33,7 +33,7 @@ public sealed partial class SearchEngine : IDisposable
if (string.IsNullOrWhiteSpace(query))
{
return;
return null;
}
Stopwatch stopwatch = new();
@@ -43,11 +43,14 @@ public sealed partial class SearchEngine : IDisposable
stopwatch.Stop();
Logger.LogDebug($"Query time: {stopwatch.ElapsedMilliseconds} ms, query: \"{query}\"");
return BuildNotice(searchQuery);
}
public IList<IListItem> FetchItems(int offset, int limit, uint queryCookie, out bool hasMore, bool noIcons = false)
public IList<IListItem> FetchItems(int offset, int limit, uint queryCookie, out bool hasMore, out SearchNoticeInfo? notice, bool noIcons = false)
{
hasMore = false;
notice = null;
var searchQuery = _searchQuery;
if (searchQuery is null)
@@ -64,6 +67,7 @@ public sealed partial class SearchEngine : IDisposable
var results = new List<IListItem>();
var index = 0;
var hasMoreItems = searchQuery.FetchRows(offset, limit);
notice = BuildNotice(searchQuery);
while (!searchQuery.SearchResults.IsEmpty && searchQuery.SearchResults.TryDequeue(out var result) && ++index <= limit)
{
@@ -100,6 +104,12 @@ public sealed partial class SearchEngine : IDisposable
return results;
}
private static SearchNoticeInfo? BuildNotice(SearchQuery searchQuery)
{
return SearchNoticeInfoBuilder.FromQueryStatus(searchQuery.GetExecutionStatus())
?? SearchNoticeInfoBuilder.FromCatalogStatus(SearchCatalogStatusReader.GetStatus());
}
public void Dispose()
{
var searchQuery = _searchQuery;

View File

@@ -102,11 +102,14 @@ namespace ImageResizer.Models
using (var outputStream = _fileSystem.File.Open(path, FileMode.CreateNew, FileAccess.ReadWrite))
{
var winrtOutputStream = outputStream.AsRandomAccessStream();
bool forceFresh = encoderGuid == BitmapEncoder.JpegEncoderId && !noTransformNeeded;
await EncodeToStreamAsync(
decoder,
winrtInputStream,
winrtOutputStream,
encoderGuid,
forceFresh,
async (encoder, isTranscode) =>
{
if (isTranscode)
@@ -121,14 +124,6 @@ namespace ImageResizer.Models
{
encoder.BitmapTransform.Bounds = cropBounds.Value;
}
// Apply codec-specific properties (e.g., JPEG quality).
// Must be set after transforms since re-encoding will occur.
var encoderProps = GetEncoderPropertySet(encoderGuid);
if (encoderProps != null)
{
await encoder.BitmapProperties.SetPropertiesAsync(encoderProps);
}
}
}
else
@@ -188,11 +183,15 @@ namespace ImageResizer.Models
using (var outputStream = _fileSystem.File.Open(path, FileMode.CreateNew, FileAccess.ReadWrite))
{
var winrtOutputStream = outputStream.AsRandomAccessStream();
// SetSoftwareBitmap requires a fresh encoder; the transcode encoder only
// accepts BitmapTransform. Also forces quality to apply for JPEG output.
await EncodeToStreamAsync(
decoder,
winrtInputStream,
winrtOutputStream,
encoderGuid,
forceFresh: true,
(encoder, _) =>
{
encoder.SetSoftwareBitmap(aiResult);
@@ -214,10 +213,12 @@ namespace ImageResizer.Models
IRandomAccessStream inputStream,
IRandomAccessStream outputStream,
Guid encoderGuid,
bool forceFresh,
Func<BitmapEncoder, bool, Task> writeContent)
{
var decoderEncoderId = CodecHelper.GetEncoderIdForDecoder(decoder);
bool canTranscode = !_settings.RemoveMetadata
bool canTranscode = !forceFresh
&& !_settings.RemoveMetadata
&& decoderEncoderId.HasValue
&& decoderEncoderId.Value == encoderGuid;
@@ -257,7 +258,8 @@ namespace ImageResizer.Models
/// <summary>
/// Fresh encoder path: creates a blank encoder and manually writes pixel data.
/// Used when metadata must be stripped (RemoveMetadata) or format doesn't match (ICO→PNG).
/// Used when codec options (e.g. JPEG quality) must apply, when metadata must be
/// stripped (RemoveMetadata), or when the format doesn't match (ICO→PNG).
/// The <paramref name="writeContent"/> callback receives isTranscode=false and should
/// call <see cref="EncodeFramesAsync"/> or <see cref="BitmapEncoder.SetSoftwareBitmap"/>.
/// </summary>
@@ -267,22 +269,18 @@ namespace ImageResizer.Models
Guid encoderGuid,
Func<BitmapEncoder, bool, Task> writeContent)
{
// Read rendering-critical metadata before encoding so we can restore it on
// the blank encoder. Only needed for RemoveMetadata; format-mismatch files
// (e.g. ICO) rarely carry meaningful EXIF data.
BitmapPropertySet renderingMetadata = null;
if (_settings.RemoveMetadata)
{
renderingMetadata = await ReadMetadataAsync(decoder, RenderingMetadataProperties);
}
// The blank encoder inherits nothing from the source, so we have to carry
// metadata over ourselves. RemoveMetadata keeps only the rendering-critical
// properties (orientation/colorspace); otherwise mirror the transcode path's
// best-effort EXIF preservation.
var propsToPreserve = _settings.RemoveMetadata
? RenderingMetadataProperties
: KnownMetadataProperties;
var preservedMetadata = await ReadMetadataAsync(decoder, propsToPreserve);
var encoder = await CreateFreshEncoderAsync(encoderGuid, outputStream);
await writeContent(encoder, false);
if (renderingMetadata != null)
{
await WriteMetadataAsync(encoder, renderingMetadata);
}
await WriteMetadataAsync(encoder, preservedMetadata);
await encoder.FlushAsync();
}

View File

@@ -227,7 +227,7 @@ namespace PowerAccent.Core
LetterKey.VK_Z => new[] { "ʒ", "ǯ", "", "ᶻ" },
LetterKey.VK_COMMA => new[] { "∙", "₋", "⁻", "", "√", "‟", "《", "》", "", "〈", "〉", "″", "‴", "⁗" }, // is in VK_MINUS for other languages, but not VK_COMMA, so we add it here.
LetterKey.VK_PERIOD => new[] { "…", "⁝", "\u0300", "\u0301", "\u0302", "\u0303", "\u0304", "\u0308", "\u030B", "\u030C" },
LetterKey.VK_MINUS => new[] { "~", "", "", "", "—", "―", "", "", "⸺", "⸻", "∓", "₋", "⁻" },
LetterKey.VK_MINUS => new[] { "~", "", "", "", "", "—", "―", "", "", "⸺", "⸻", "∓", "₋", "⁻" },
LetterKey.VK_SLASH_ => new[] { "÷", "√" },
LetterKey.VK_DIVIDE_ => new[] { "÷", "√" },
LetterKey.VK_MULTIPLY_ => new[] { "×", "⋅", "ˣ", "ₓ" },
@@ -744,6 +744,7 @@ namespace PowerAccent.Core
LetterKey.VK_I => new[] { "í" },
LetterKey.VK_O => new[] { "ó", "ő", "ö" },
LetterKey.VK_U => new[] { "ú", "ű", "ü" },
LetterKey.VK_Y => new[] { "ÿ", "ý" },
LetterKey.VK_COMMA => new[] { "„", "”", "»", "«" },
_ => Array.Empty<string>(),
};

View File

@@ -61,11 +61,36 @@ public class SettingsService
ExcludedApps = settings.Properties.ExcludedApps.Value;
_keyboardListener.UpdateExcludedApps(ExcludedApps);
SelectedLang = settings.Properties.SelectedLang.Value
var selectedLangEntries = settings.Properties.SelectedLang.Value
.Split(',', StringSplitOptions.RemoveEmptyEntries)
.Select(lang => Enum.TryParse(lang, out Language selectedLangValue) ? selectedLangValue : Language.SPECIAL)
.Select(lang => lang.Trim())
.ToArray();
// Either select all languages if "ALL" is specified, or parse
// the specified languages while ignoring unrecognized values.
bool isAllSelected = selectedLangEntries.Any(lang =>
lang.Equals("ALL", StringComparison.OrdinalIgnoreCase));
SelectedLang = isAllSelected
? Enum.GetValues<Language>()
: selectedLangEntries
.Select(lang =>
{
if (Enum.TryParse(lang, ignoreCase: true, out Language parsedLang))
{
return (Language?)parsedLang;
}
// Skip unrecognized values.
Logger.LogWarning($"Unknown language value '{lang}' in settings, skipping.");
return null;
})
.Where(lang => lang.HasValue)
.Select(lang => lang!.Value)
.ToArray();
Logger.LogInfo(
$"Languages selected: {(isAllSelected ? "ALL" : string.Join(", ", SelectedLang))}");
switch (settings.Properties.ToolbarPosition.Value)
{
case "Top center":

View File

@@ -1,116 +0,0 @@
// Copyright (c) Microsoft Corporation
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
using System;
using System.Runtime.InteropServices;
using WinUIEx;
namespace PowerDisplay.Helpers
{
/// <summary>
/// Subclasses a window's WndProc to suppress WM_DPICHANGED messages during
/// cross-monitor MoveAndResize calls. Without suppression, the framework
/// auto-scales it a second time, causing double-scaling artifacts.
///
/// Usage:
/// var suppressor = new DpiSuppressor(window);
/// using (suppressor.Suppress())
/// {
/// window.AppWindow.MoveAndResize(rect, displayArea);
/// }
/// </summary>
internal sealed partial class DpiSuppressor : IDisposable
{
// Optional external WndProc handler (e.g., HotkeyService) called before default processing.
// Return true to indicate the message was handled.
private readonly Func<uint, nuint, nint, bool>? _preProcessor;
private const int GwlWndProc = -4;
private const uint WmDpiChanged = 0x02E0;
private readonly nint _hwnd;
private nint _originalWndProc;
private WndProcDelegate? _wndProcDelegate;
private bool _suppressing;
private bool _disposed;
[LibraryImport("user32.dll", EntryPoint = "SetWindowLongPtrW")]
private static partial nint SetWindowLongPtr(nint hWnd, int nIndex, nint dwNewLong);
[LibraryImport("user32.dll", EntryPoint = "CallWindowProcW")]
private static partial nint CallWindowProc(nint lpPrevWndFunc, nint hWnd, uint msg, nuint wParam, nint lParam);
/// <summary>
/// Initializes a new instance of the <see cref="DpiSuppressor"/> class.
/// Subclass the window's WndProc to enable DPI suppression.
/// </summary>
/// <param name="window">Window to subclass.</param>
/// <param name="preProcessor">Optional callback invoked for every message before default processing.
/// Receives (uMsg, wParam, lParam). Return true to swallow the message.</param>
public DpiSuppressor(WindowEx window, Func<uint, nuint, nint, bool>? preProcessor = null)
{
_hwnd = window.GetWindowHandle();
_preProcessor = preProcessor;
_wndProcDelegate = WndProc;
var ptr = Marshal.GetFunctionPointerForDelegate(_wndProcDelegate);
_originalWndProc = SetWindowLongPtr(_hwnd, GwlWndProc, ptr);
}
/// <summary>
/// Returns a disposable scope during which WM_DPICHANGED is suppressed.
/// </summary>
public SuppressScope Suppress() => new(this);
private nint WndProc(nint hwnd, uint uMsg, nuint wParam, nint lParam)
{
// Let external handler process first (e.g., hotkey messages)
if (_preProcessor?.Invoke(uMsg, wParam, lParam) == true)
{
return 0;
}
if (uMsg == WmDpiChanged && _suppressing)
{
return 0;
}
return CallWindowProc(_originalWndProc, hwnd, uMsg, wParam, lParam);
}
public void Dispose()
{
if (_disposed)
{
return;
}
_disposed = true;
// Restore original WndProc
if (_originalWndProc != 0)
{
SetWindowLongPtr(_hwnd, GwlWndProc, _originalWndProc);
_originalWndProc = 0;
}
_wndProcDelegate = null;
}
internal readonly struct SuppressScope : IDisposable
{
private readonly DpiSuppressor _owner;
internal SuppressScope(DpiSuppressor owner)
{
_owner = owner;
_owner._suppressing = true;
}
public void Dispose()
{
_owner._suppressing = false;
}
}
}
}

View File

@@ -2,31 +2,16 @@
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
using System;
using System.Runtime.InteropServices;
using Microsoft.UI.Windowing;
using WinUIEx;
namespace PowerDisplay.Helpers
{
/// <summary>
/// PowerDisplay-local window helpers. Flyout positioning/sizing now lives in
/// <c>Microsoft.PowerToys.Common.UI.Flyout.FlyoutWindowHelper</c> (Common.UI.Controls).
/// </summary>
internal static partial class WindowHelper
{
// Cursor position structure for GetCursorPos
[StructLayout(LayoutKind.Sequential)]
private struct POINT
{
public int X;
public int Y;
}
// Cursor position for detecting the monitor with the mouse
[LibraryImport("user32.dll")]
[return: MarshalAs(UnmanagedType.Bool)]
private static partial bool GetCursorPos(out POINT lpPoint);
[LibraryImport("shcore.dll")]
private static partial int GetDpiForMonitor(nint hMonitor, uint dpiType, out uint dpiX, out uint dpiY);
// Window Styles
private const int GwlStyle = -16;
private const int WsCaption = 0x00C00000;
@@ -44,8 +29,6 @@ namespace PowerDisplay.Helpers
private const uint SwpNosize = 0x0001;
private const uint SwpNomove = 0x0002;
private const uint SwpFramechanged = 0x0020;
private const uint MdtEffectiveDpi = 0;
private const int DefaultDpi = 96;
// P/Invoke declarations (64-bit only - PowerToys only builds for x64/ARM64)
[LibraryImport("user32.dll", EntryPoint = "GetWindowLongPtrW")]
@@ -66,7 +49,7 @@ namespace PowerDisplay.Helpers
uint uFlags);
/// <summary>
/// Disable window moving and resizing functionality
/// Disable window moving and resizing functionality.
/// </summary>
public static void DisableWindowMovingAndResizing(nint hWnd)
{
@@ -78,7 +61,7 @@ namespace PowerDisplay.Helpers
style &= ~WsMaximizebox;
style &= ~WsMinimizebox;
style &= ~WsCaption; // Remove entire title bar
style &= ~WsSysmenu; // Remove system menu
style &= ~WsSysmenu; // Remove system menu
// Set new window style
_ = SetWindowLong(hWnd, GwlStyle, style);
@@ -101,159 +84,5 @@ namespace PowerDisplay.Helpers
0,
SwpNomove | SwpNosize | SwpFramechanged);
}
/// <summary>
/// Get the DPI scale factor for a window (relative to standard 96 DPI)
/// </summary>
/// <param name="window">WinUIEx window</param>
/// <returns>DPI scale factor (1.0 = 100%, 1.25 = 125%, 1.5 = 150%, 2.0 = 200%)</returns>
public static double GetDpiScale(WindowEx window)
{
return (double)window.GetDpiForWindow() / DefaultDpi;
}
/// <summary>
/// Get the DPI scale factor for a display area (relative to standard 96 DPI)
/// </summary>
/// <param name="displayArea">Target display area</param>
/// <returns>DPI scale factor (1.0 = 100%, 1.25 = 125%, 1.5 = 150%, 2.0 = 200%)</returns>
public static double GetDpiScale(DisplayArea displayArea)
{
return (double)GetEffectiveDpi(global::Microsoft.UI.Win32Interop.GetMonitorFromDisplayId(displayArea.DisplayId)) / DefaultDpi;
}
/// <summary>
/// Convert device-independent pixels (DIP) to physical pixels.
/// </summary>
/// <param name="dip">Device-independent pixel value</param>
/// <param name="dpiScale">DPI scale factor</param>
/// <returns>Physical pixel value</returns>
public static int ScaleToPhysicalPixels(int dip, double dpiScale)
{
return (int)Math.Ceiling(dip * dpiScale);
}
/// <summary>
/// Convert physical pixels to device-independent pixels (DIP).
/// </summary>
/// <param name="physicalPixels">Physical pixel value</param>
/// <param name="dpiScale">DPI scale factor</param>
/// <returns>Device-independent pixel value</returns>
public static int ScaleToDip(int physicalPixels, double dpiScale)
{
return (int)Math.Floor(physicalPixels / dpiScale);
}
/// <summary>
/// Position a window at the bottom-right corner of the monitor where the mouse cursor is located.
/// Correctly handles all edge cases:
/// - Multi-monitor setups
/// - Taskbar at any position (top/bottom/left/right)
/// - Different DPI settings
/// </summary>
/// <param name="window">WinUIEx window to position</param>
/// <param name="widthDip">Window width in device-independent pixels (DIP)</param>
/// <param name="heightDip">Window height in device-independent pixels (DIP)</param>
/// <param name="rightMarginDip">Right margin in device-independent pixels (DIP)</param>
/// <param name="bottomMarginDip">Bottom margin in device-independent pixels (DIP)</param>
public static void PositionWindowBottomRight(
WindowEx window,
int widthDip,
int heightDip,
int rightMarginDip = 0,
int bottomMarginDip = 0)
{
if (!TryGetDisplayAreaAtCursor(out var displayArea) || displayArea is null)
{
ManagedCommon.Logger.LogWarning("PositionWindowBottomRight: Unable to determine target display from cursor, skipping positioning");
return;
}
MoveWindowBottomRight(window, displayArea, widthDip, heightDip, rightMarginDip, bottomMarginDip);
}
/// <summary>
/// Center a window within the specified display area's work area.
/// </summary>
public static void CenterWindowOnDisplay(
WindowEx window,
DisplayArea displayArea,
int widthDip,
int heightDip)
{
double dpiScale = GetDpiScale(displayArea);
int w = ScaleToPhysicalPixels(widthDip, dpiScale);
int h = ScaleToPhysicalPixels(heightDip, dpiScale);
// WorkArea relative to DisplayArea (accounts for taskbar position)
var rel = GetWorkAreaRelativeToDisplay(displayArea);
int x = rel.X + ((rel.Width - w) / 2);
int y = rel.Y + ((rel.Height - h) / 2);
window.AppWindow.MoveAndResize(new Windows.Graphics.RectInt32(x, y, w, h), displayArea);
}
private static void MoveWindowBottomRight(
WindowEx window,
DisplayArea displayArea,
int widthDip,
int heightDip,
int rightMarginDip,
int bottomMarginDip)
{
double dpiScale = GetDpiScale(displayArea);
int w = ScaleToPhysicalPixels(widthDip, dpiScale);
int h = ScaleToPhysicalPixels(heightDip, dpiScale);
int marginRight = ScaleToPhysicalPixels(rightMarginDip, dpiScale);
int marginBottom = ScaleToPhysicalPixels(bottomMarginDip, dpiScale);
// WorkArea relative to DisplayArea (accounts for taskbar position)
var rel = GetWorkAreaRelativeToDisplay(displayArea);
int x = rel.X + rel.Width - w - marginRight;
int y = rel.Y + rel.Height - h - marginBottom;
window.AppWindow.MoveAndResize(new Windows.Graphics.RectInt32(x, y, w, h), displayArea);
}
/// <summary>
/// Get the work area rectangle relative to the display area's origin.
/// MoveAndResize(rect, displayArea) expects coordinates relative to the DisplayArea,
/// but WorkArea.X/Y are in absolute screen coordinates, so we subtract the DisplayArea origin.
/// The resulting rect describes where the usable area is within the display (e.g., offset by taskbar).
/// </summary>
private static Windows.Graphics.RectInt32 GetWorkAreaRelativeToDisplay(DisplayArea displayArea)
{
var outer = displayArea.OuterBounds;
var work = displayArea.WorkArea;
return new Windows.Graphics.RectInt32(
work.X - outer.X,
work.Y - outer.Y,
work.Width,
work.Height);
}
internal static bool TryGetDisplayAreaAtCursor(out DisplayArea? displayArea)
{
displayArea = null;
if (!GetCursorPos(out var cursorPos))
{
return false;
}
displayArea = DisplayArea.GetFromPoint(new Windows.Graphics.PointInt32(cursorPos.X, cursorPos.Y), DisplayAreaFallback.None);
return displayArea is not null;
}
private static int GetEffectiveDpi(nint hMonitor)
{
if (hMonitor == 0)
{
return DefaultDpi;
}
var hr = GetDpiForMonitor(hMonitor, MdtEffectiveDpi, out var dpiX, out _);
return hr >= 0 && dpiX > 0 ? (int)dpiX : DefaultDpi;
}
}
}

View File

@@ -90,6 +90,7 @@
<!-- Removed Common.UI dependency - SettingsDeepLink is now implemented locally -->
<ProjectReference Include="..\..\..\common\GPOWrapper\GPOWrapper.vcxproj" />
<ProjectReference Include="..\..\..\common\ManagedCommon\ManagedCommon.csproj" />
<ProjectReference Include="..\..\..\common\Common.UI.Controls\Common.UI.Controls.csproj" />
<!-- Removed ManagedCsWin32 - using CsWin32 directly for TrayIcon APIs -->
<ProjectReference Include="..\..\..\common\interop\PowerToys.Interop.vcxproj" />
<ProjectReference Include="..\..\..\settings-ui\Settings.UI.Library\Settings.UI.Library.csproj" />

View File

@@ -3,10 +3,10 @@
// See the LICENSE file in the project root for more information.
using System;
using Microsoft.PowerToys.Common.UI.Controls.Flyout;
using Microsoft.UI.Dispatching;
using Microsoft.UI.Windowing;
using PowerDisplay.Configuration;
using PowerDisplay.Helpers;
using WinUIEx;
namespace PowerDisplay.PowerDisplayXAML
@@ -16,7 +16,6 @@ namespace PowerDisplay.PowerDisplayXAML
/// </summary>
public sealed partial class IdentifyWindow : WindowEx, IDisposable
{
private DpiSuppressor? _dpiSuppressor;
private DispatcherQueueTimer? _autoCloseTimer;
private bool _disposed;
@@ -36,10 +35,7 @@ namespace PowerDisplay.PowerDisplayXAML
// Configure window style
ConfigureWindow();
// Subclass WndProc to suppress WM_DPICHANGED during cross-DPI positioning
_dpiSuppressor = new DpiSuppressor(this);
// Ensure DpiSuppressor is disposed when window closes
// Dispose timer when window closes
this.Closed += (_, _) => Dispose();
// Auto close after 3 seconds. DispatcherQueueTimer runs on the UI thread
@@ -71,25 +67,22 @@ namespace PowerDisplay.PowerDisplayXAML
{
var (windowWidthDip, windowHeightDip) = GetAdaptiveWindowSizeDip(displayArea);
// Suppress WM_DPICHANGED during MoveAndResize to prevent double-scaling
// when positioning on a monitor with different DPI than the primary.
using (_dpiSuppressor?.Suppress() ?? default)
{
WindowHelper.CenterWindowOnDisplay(this, displayArea, windowWidthDip, windowHeightDip);
}
// FlyoutWindowHelper handles cross-monitor DPI internally via a 1×1 teleport
// before the final move, so no WM_DPICHANGED suppression is required here.
FlyoutWindowHelper.CenterWindowOnDisplay(this, displayArea, windowWidthDip, windowHeightDip);
}
private static (int WidthDip, int HeightDip) GetAdaptiveWindowSizeDip(DisplayArea displayArea)
{
var workArea = displayArea.WorkArea;
double dpiScale = WindowHelper.GetDpiScale(displayArea);
double dpiScale = FlyoutWindowHelper.GetDpiScale(displayArea);
int maxWidthDip = Math.Max(
AppConstants.UI.IdentifyWindowMinWidthDip,
WindowHelper.ScaleToDip((int)Math.Floor(workArea.Width * AppConstants.UI.IdentifyWindowMaxWorkAreaRatio), dpiScale));
FlyoutWindowHelper.ScaleToDip((int)Math.Floor(workArea.Width * AppConstants.UI.IdentifyWindowMaxWorkAreaRatio), dpiScale));
int maxHeightDip = Math.Max(
AppConstants.UI.IdentifyWindowMinHeightDip,
WindowHelper.ScaleToDip((int)Math.Floor(workArea.Height * AppConstants.UI.IdentifyWindowMaxWorkAreaRatio), dpiScale));
FlyoutWindowHelper.ScaleToDip((int)Math.Floor(workArea.Height * AppConstants.UI.IdentifyWindowMaxWorkAreaRatio), dpiScale));
int widthDip = Math.Max(
AppConstants.UI.IdentifyWindowMinWidthDip,
@@ -112,7 +105,6 @@ namespace PowerDisplay.PowerDisplayXAML
_autoCloseTimer?.Stop();
_autoCloseTimer = null;
_dpiSuppressor?.Dispose();
}
}
}

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