mirror of
https://github.com/microsoft/PowerToys.git
synced 2026-07-04 09:30:04 +02:00
Compare commits
44 Commits
dev/snickl
...
dev/vanzue
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
db0d77fbd6 | ||
|
|
87b24afa23 | ||
|
|
74c53c14e6 | ||
|
|
77173cd075 | ||
|
|
149e7b1efe | ||
|
|
0c2d24c3f6 | ||
|
|
b81ea23c68 | ||
|
|
39bbf0593e | ||
|
|
4620f6f381 | ||
|
|
da3b12d536 | ||
|
|
bab77edd11 | ||
|
|
414ee86fb3 | ||
|
|
eeeb6c0c93 | ||
|
|
70e082ce4f | ||
|
|
8404bfbebb | ||
|
|
77412d1961 | ||
|
|
fad5a3ac69 | ||
|
|
52cab7058a | ||
|
|
35a3c55f29 | ||
|
|
ed16ae7b2a | ||
|
|
f049cc5839 | ||
|
|
f82fb2a411 | ||
|
|
90131e35d9 | ||
|
|
77355ef2fb | ||
|
|
a130969d0a | ||
|
|
d1605640ca | ||
|
|
9859fb6196 | ||
|
|
3bd85efc56 | ||
|
|
f8453214fb | ||
|
|
0aca7c292c | ||
|
|
c6f1a09fa2 | ||
|
|
b72224ea0b | ||
|
|
e323da939b | ||
|
|
75fb296bb2 | ||
|
|
3d69785ca4 | ||
|
|
f6b0996c9b | ||
|
|
748d5e485c | ||
|
|
1718cecedb | ||
|
|
4f0c8f476a | ||
|
|
a953a39aec | ||
|
|
8c4ff37a50 | ||
|
|
02062dd023 | ||
|
|
bcbca0d5dd | ||
|
|
f0134e4448 |
9
.github/ISSUE_TEMPLATE/bug_report.yml
vendored
9
.github/ISSUE_TEMPLATE/bug_report.yml
vendored
@@ -40,7 +40,6 @@ body:
|
||||
- Other (please specify in "Steps to Reproduce")
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: dropdown
|
||||
attributes:
|
||||
label: Area(s) with issue?
|
||||
@@ -106,7 +105,13 @@ body:
|
||||
placeholder: What happened instead?
|
||||
validations:
|
||||
required: false
|
||||
|
||||
- type: upload
|
||||
id: bugreportfile
|
||||
attributes:
|
||||
label: Upload Bug Report ZIP-file
|
||||
description: Right-clicking the PowerToys tray icon in the taskbar and selecting “Report bug” generates a ZIP file containing diagnostic information about your setup and PowerToys logs, helping us better understand and troubleshoot the issue.
|
||||
validations:
|
||||
required: false
|
||||
- id: additionalInfo
|
||||
type: textarea
|
||||
attributes:
|
||||
|
||||
73
.github/actions/spell-check/expect.txt
vendored
73
.github/actions/spell-check/expect.txt
vendored
@@ -16,6 +16,7 @@ adaptivecards
|
||||
ADDSTRING
|
||||
ADDUNDORECORD
|
||||
ADifferent
|
||||
ADMINS
|
||||
adml
|
||||
admx
|
||||
advfirewall
|
||||
@@ -129,6 +130,7 @@ bthprops
|
||||
bti
|
||||
BTNFACE
|
||||
bugreport
|
||||
bugreportfile
|
||||
BUILDARCH
|
||||
BUILDNUMBER
|
||||
buildtransitive
|
||||
@@ -168,7 +170,11 @@ cim
|
||||
CImage
|
||||
cla
|
||||
CLASSDC
|
||||
classguid
|
||||
classmethod
|
||||
CLASSNOTAVAILABLE
|
||||
claude
|
||||
CLEARTYPE
|
||||
clickable
|
||||
clickonce
|
||||
clientside
|
||||
@@ -200,6 +206,7 @@ colorformat
|
||||
colorhistory
|
||||
colorhistorylimit
|
||||
COLORKEY
|
||||
colorref
|
||||
comctl
|
||||
comdlg
|
||||
comexp
|
||||
@@ -217,6 +224,8 @@ CONTEXTHELP
|
||||
CONTEXTMENUHANDLER
|
||||
contractversion
|
||||
CONTROLPARENT
|
||||
Convs
|
||||
cooldown
|
||||
copiedcolorrepresentation
|
||||
COPYPEN
|
||||
COREWINDOW
|
||||
@@ -227,6 +236,8 @@ cpcontrols
|
||||
cph
|
||||
cplusplus
|
||||
CPower
|
||||
cpptools
|
||||
cppvsdbg
|
||||
cppwinrt
|
||||
createdump
|
||||
CREATEPROCESS
|
||||
@@ -249,6 +260,8 @@ CTLCOLORSTATIC
|
||||
CURRENTDIR
|
||||
CURSORINFO
|
||||
cursorpos
|
||||
CURSORSHOWING
|
||||
cursorwrap
|
||||
customaction
|
||||
CUSTOMACTIONTEST
|
||||
CVal
|
||||
@@ -265,12 +278,14 @@ dacl
|
||||
datareader
|
||||
datatracker
|
||||
Dayof
|
||||
dbcc
|
||||
DBID
|
||||
DBLCLKS
|
||||
DBLEPSILON
|
||||
DBPROP
|
||||
DBPROPIDSET
|
||||
DBPROPSET
|
||||
DBT
|
||||
DCBA
|
||||
DCOM
|
||||
DComposition
|
||||
@@ -286,6 +301,8 @@ DEFAULTFLAGS
|
||||
DEFAULTICON
|
||||
defaultlib
|
||||
DEFAULTONLY
|
||||
DEFAULTSIZE
|
||||
defaulttonearest
|
||||
DEFAULTTONULL
|
||||
DEFAULTTOPRIMARY
|
||||
DEFERERASE
|
||||
@@ -305,11 +322,21 @@ DESKTOPABSOLUTEPARSING
|
||||
desktopshorcutinstalled
|
||||
devblogs
|
||||
devdocs
|
||||
devenv
|
||||
DEVICEINTERFACE
|
||||
devicetype
|
||||
DEVINTERFACE
|
||||
devmgmt
|
||||
DEVMODE
|
||||
DEVMODEW
|
||||
DEVNODES
|
||||
devpal
|
||||
DEVTYP
|
||||
dfx
|
||||
DIALOGEX
|
||||
diffs
|
||||
digicert
|
||||
DINORMAL
|
||||
DISABLEASACTIONKEY
|
||||
DISABLENOSCROLL
|
||||
diskmgmt
|
||||
@@ -423,6 +450,12 @@ eyetracker
|
||||
FANCYZONESDRAWLAYOUTTEST
|
||||
FANCYZONESEDITOR
|
||||
FARPROC
|
||||
fdw
|
||||
fdx
|
||||
FErase
|
||||
fesf
|
||||
FFFF
|
||||
Figma
|
||||
FILEEXPLORER
|
||||
FILEFLAGS
|
||||
FILEFLAGSMASK
|
||||
@@ -439,6 +472,7 @@ FILESYSPATH
|
||||
Filetime
|
||||
FILEVERSION
|
||||
FILTERMODE
|
||||
FInc
|
||||
findfast
|
||||
FIXEDFILEINFO
|
||||
FIXEDSYS
|
||||
@@ -494,6 +528,7 @@ GPOCA
|
||||
gpp
|
||||
gpu
|
||||
gradians
|
||||
GRGX
|
||||
GSM
|
||||
gtm
|
||||
guiddata
|
||||
@@ -524,11 +559,13 @@ HCRYPTPROV
|
||||
hcursor
|
||||
hcwhite
|
||||
hdc
|
||||
HDEVNOTIFY
|
||||
hdr
|
||||
hdrop
|
||||
hdwwiz
|
||||
Helpline
|
||||
helptext
|
||||
hgdiobj
|
||||
HGFE
|
||||
hglobal
|
||||
hhk
|
||||
@@ -673,12 +710,12 @@ jfif
|
||||
jgeosdfsdsgmkedfgdfgdfgbkmhcgcflmi
|
||||
jjw
|
||||
jobject
|
||||
JOBOBJECT
|
||||
jpe
|
||||
jpnime
|
||||
Jsons
|
||||
jsonval
|
||||
jxr
|
||||
kbmcontrols
|
||||
keybd
|
||||
KEYBDDATA
|
||||
KEYBDINPUT
|
||||
@@ -707,6 +744,7 @@ Ldone
|
||||
Ldr
|
||||
LEFTSCROLLBAR
|
||||
LEFTTEXT
|
||||
leftclick
|
||||
LError
|
||||
LEVELID
|
||||
LExit
|
||||
@@ -738,6 +776,8 @@ lowlevel
|
||||
LOWORD
|
||||
lparam
|
||||
LPBITMAPINFOHEADER
|
||||
LPCFHOOKPROC
|
||||
lpch
|
||||
LPCITEMIDLIST
|
||||
LPCLSID
|
||||
lpcmi
|
||||
@@ -755,6 +795,7 @@ LPMONITORINFO
|
||||
LPOSVERSIONINFOEXW
|
||||
LPQUERY
|
||||
lprc
|
||||
LPrivate
|
||||
LPSAFEARRAY
|
||||
lpstr
|
||||
lpsz
|
||||
@@ -796,10 +837,13 @@ MAPPEDTOSAMEKEY
|
||||
MAPTOSAMESHORTCUT
|
||||
MAPVK
|
||||
MARKDOWNPREVIEWHANDLERCPP
|
||||
MAXDWORD
|
||||
MAXSHORTCUTSIZE
|
||||
maxversiontested
|
||||
MBM
|
||||
MBR
|
||||
Mbuttondown
|
||||
mcp
|
||||
MDICHILD
|
||||
MDL
|
||||
mdtext
|
||||
@@ -811,11 +855,13 @@ MENUITEMINFO
|
||||
MENUITEMINFOW
|
||||
MERGECOPY
|
||||
MERGEPAINT
|
||||
Metacharacter
|
||||
metadatamatters
|
||||
Metadatas
|
||||
Metacharacter
|
||||
metafile
|
||||
metapackage
|
||||
mfc
|
||||
mfalse
|
||||
Mgmt
|
||||
Microwaved
|
||||
midl
|
||||
@@ -838,6 +884,7 @@ mmsys
|
||||
mobileredirect
|
||||
mockapi
|
||||
MODALFRAME
|
||||
modelcontextprotocol
|
||||
MODESPRUNED
|
||||
MONITORENUMPROC
|
||||
MONITORINFO
|
||||
@@ -872,9 +919,10 @@ MSLLHOOKSTRUCT
|
||||
Mso
|
||||
msrc
|
||||
msstore
|
||||
mstsc
|
||||
msvcp
|
||||
MT
|
||||
MTND
|
||||
mtrue
|
||||
MULTIPLEUSE
|
||||
multizone
|
||||
muxc
|
||||
@@ -882,6 +930,8 @@ mvvm
|
||||
MVVMTK
|
||||
MWBEx
|
||||
MYICON
|
||||
myorg
|
||||
myrepo
|
||||
NAMECHANGE
|
||||
namespaceanddescendants
|
||||
nao
|
||||
@@ -996,6 +1046,8 @@ OEMCONVERT
|
||||
officehubintl
|
||||
OFN
|
||||
ofs
|
||||
OICI
|
||||
OICIIO
|
||||
oldcolor
|
||||
olditem
|
||||
oldpath
|
||||
@@ -1006,6 +1058,7 @@ openas
|
||||
opencode
|
||||
OPENFILENAME
|
||||
opensource
|
||||
openurl
|
||||
openxmlformats
|
||||
OPTIMIZEFORINVOKE
|
||||
ORPHANEDDIALOGTITLE
|
||||
@@ -1028,6 +1081,7 @@ Packagemanager
|
||||
PACL
|
||||
padx
|
||||
pady
|
||||
PAI
|
||||
PAINTSTRUCT
|
||||
PALETTEWINDOW
|
||||
PARENTNOTIFY
|
||||
@@ -1200,6 +1254,7 @@ RAWPATH
|
||||
rbhid
|
||||
rclsid
|
||||
RCZOOMIT
|
||||
rdp
|
||||
RDW
|
||||
READMODE
|
||||
READOBJECTS
|
||||
@@ -1227,6 +1282,7 @@ remappings
|
||||
REMAPSUCCESSFUL
|
||||
REMAPUNSUCCESSFUL
|
||||
Remotable
|
||||
remotedesktop
|
||||
remoteip
|
||||
Removelnk
|
||||
renamable
|
||||
@@ -1257,6 +1313,7 @@ RIGHTSCROLLBAR
|
||||
riid
|
||||
RKey
|
||||
RNumber
|
||||
rollups
|
||||
rop
|
||||
ROUNDSMALL
|
||||
rpcrt
|
||||
@@ -1289,7 +1346,7 @@ SCREENFONTS
|
||||
screensaver
|
||||
screenshots
|
||||
scrollviewer
|
||||
SDDL
|
||||
sddl
|
||||
SDKDDK
|
||||
sdns
|
||||
searchterm
|
||||
@@ -1468,6 +1525,9 @@ SVGIO
|
||||
svgz
|
||||
SVSI
|
||||
SWFO
|
||||
swp
|
||||
SWPNOSIZE
|
||||
SWPNOZORDER
|
||||
SWRESTORE
|
||||
symbolrequestprod
|
||||
SYMCACHE
|
||||
@@ -1484,6 +1544,8 @@ SYSKEY
|
||||
syskeydown
|
||||
SYSKEYUP
|
||||
SYSLIB
|
||||
sysmenu
|
||||
systemai
|
||||
SYSTEMAPPS
|
||||
SYSTEMMODAL
|
||||
SYSTEMTIME
|
||||
@@ -1570,6 +1632,9 @@ UHash
|
||||
UIA
|
||||
UIEx
|
||||
ULONGLONG
|
||||
Ultrawide
|
||||
UMax
|
||||
UMin
|
||||
ums
|
||||
uncompilable
|
||||
UNCPRIORITY
|
||||
|
||||
21
.github/skills/winmd-api-search/LICENSE.txt
vendored
Normal file
21
.github/skills/winmd-api-search/LICENSE.txt
vendored
Normal file
@@ -0,0 +1,21 @@
|
||||
The MIT License
|
||||
|
||||
Copyright (c) Microsoft Corporation. All rights reserved.
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in
|
||||
all copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
||||
THE SOFTWARE.
|
||||
192
.github/skills/winmd-api-search/SKILL.md
vendored
Normal file
192
.github/skills/winmd-api-search/SKILL.md
vendored
Normal file
@@ -0,0 +1,192 @@
|
||||
---
|
||||
name: winmd-api-search
|
||||
description: 'Find and explore Windows desktop APIs. Use when building features that need platform capabilities — camera, file access, notifications, UI controls, AI/ML, sensors, networking, etc. Discovers the right API for a task and retrieves full type details (methods, properties, events, enumeration values).'
|
||||
license: Complete terms in LICENSE.txt
|
||||
---
|
||||
|
||||
# WinMD API Search
|
||||
|
||||
This skill helps you find the right Windows API for any capability and get its full details. It searches a local cache of all WinMD metadata from:
|
||||
|
||||
- **Windows Platform SDK** — all `Windows.*` WinRT APIs (always available, no restore needed)
|
||||
- **WinAppSDK / WinUI** — bundled as a baseline in the cache generator (always available, no restore needed)
|
||||
- **NuGet packages** — any additional packages in restored projects that contain `.winmd` files
|
||||
- **Project-output WinMD** — class libraries (C++/WinRT, C#) that produce `.winmd` as build output
|
||||
|
||||
Even on a fresh clone with no restore or build, you still get full Platform SDK + WinAppSDK coverage.
|
||||
|
||||
## When to Use This Skill
|
||||
|
||||
- User wants to build a feature and you need to find which API provides that capability
|
||||
- User asks "how do I do X?" where X involves a platform feature (camera, files, notifications, sensors, AI, etc.)
|
||||
- You need the exact methods, properties, events, or enumeration values of a type before writing code
|
||||
- You're unsure which control, class, or interface to use for a UI or system task
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- **.NET SDK 8.0 or later** — required to build the cache generator. Install from [dotnet.microsoft.com](https://dotnet.microsoft.com/download) if not available.
|
||||
|
||||
## Cache Setup (Required Before First Use)
|
||||
|
||||
All query and search commands read from a local JSON cache. **You must generate the cache before running any queries.**
|
||||
|
||||
```powershell
|
||||
# All projects in the repo (recommended for first run)
|
||||
.\.github\skills\winmd-api-search\scripts\Update-WinMdCache.ps1
|
||||
|
||||
# Single project
|
||||
.\.github\skills\winmd-api-search\scripts\Update-WinMdCache.ps1 -ProjectDir <project-folder>
|
||||
```
|
||||
|
||||
No project restore or build is needed for baseline coverage (Platform SDK + WinAppSDK). For additional NuGet packages, the project needs `dotnet restore` (which generates `project.assets.json`) or a `packages.config` file.
|
||||
|
||||
Cache is stored at `Generated Files\winmd-cache\`, deduplicated per-package+version.
|
||||
|
||||
### What gets indexed
|
||||
|
||||
| Source | When available |
|
||||
|--------|----------------|
|
||||
| Windows Platform SDK | Always (reads from local SDK install) |
|
||||
| WinAppSDK (latest) | Always (bundled as baseline in cache generator) |
|
||||
| WinAppSDK Runtime | When installed on the system (detected via `Get-AppxPackage`) |
|
||||
| Project NuGet packages | After `dotnet restore` or with `packages.config` |
|
||||
| Project-output `.winmd` | After project build (class libraries that produce WinMD) |
|
||||
|
||||
> **Note:** This cache directory should be in `.gitignore` — it's generated, not source.
|
||||
|
||||
## How to Use
|
||||
|
||||
Pick the path that matches the situation:
|
||||
|
||||
---
|
||||
|
||||
### Discover — "I don't know which API to use"
|
||||
|
||||
The user describes a capability in their own words. You need to find the right API.
|
||||
|
||||
**0. Ensure the cache exists**
|
||||
|
||||
If the cache hasn't been generated yet, run `Update-WinMdCache.ps1` first — see [Cache Setup](#cache-setup-required-before-first-use) above.
|
||||
|
||||
**1. Translate user language → search keywords**
|
||||
|
||||
Map the user's daily language to programming terms. Try multiple variations:
|
||||
|
||||
| User says | Search keywords to try (in order) |
|
||||
|-----------|-----------------------------------|
|
||||
| "take a picture" | `camera`, `capture`, `photo`, `MediaCapture` |
|
||||
| "load from disk" | `file open`, `picker`, `FileOpen`, `StorageFile` |
|
||||
| "describe what's in it" | `image description`, `Vision`, `Recognition` |
|
||||
| "show a popup" | `dialog`, `flyout`, `popup`, `ContentDialog` |
|
||||
| "drag and drop" | `drag`, `drop`, `DragDrop` |
|
||||
| "save settings" | `settings`, `ApplicationData`, `LocalSettings` |
|
||||
|
||||
Start with simple everyday words. If results are weak or irrelevant, try the more technical variation.
|
||||
|
||||
**2. Run searches**
|
||||
|
||||
```powershell
|
||||
.\.github\skills\winmd-api-search\scripts\Invoke-WinMdQuery.ps1 -Action search -Query "<keyword>"
|
||||
```
|
||||
|
||||
This returns ranked namespaces with top matching types and the **JSON file path**.
|
||||
|
||||
If results have **low scores (below 60) or are irrelevant**, fall back to searching online documentation:
|
||||
|
||||
1. Use web search to find the right API on Microsoft Learn, for example:
|
||||
- `site:learn.microsoft.com/uwp/api <capability keywords>` for `Windows.*` APIs
|
||||
- `site:learn.microsoft.com/windows/windows-app-sdk/api/winrt <capability keywords>` for `Microsoft.*` WinAppSDK APIs
|
||||
2. Read the documentation pages to identify which type matches the user's requirement.
|
||||
3. Once you know the type name, come back and use `-Action members` or `-Action enums` to get the exact local signatures.
|
||||
|
||||
**3. Read the JSON to choose the right API**
|
||||
|
||||
Read the file at the path(s) from the top results. The JSON has all types in that namespace — full members, signatures, parameters, return types, enumeration values.
|
||||
|
||||
Read and decide which types and members fit the user's requirement.
|
||||
|
||||
**4. Look up official documentation for context**
|
||||
|
||||
The cache contains only signatures — no descriptions or usage guidance. For explanations, examples, and remarks, look up the type on Microsoft Learn:
|
||||
|
||||
| Namespace prefix | Documentation base URL |
|
||||
|-----------------|----------------------|
|
||||
| `Windows.*` | `https://learn.microsoft.com/uwp/api/{fully.qualified.typename}` |
|
||||
| `Microsoft.*` (WinAppSDK) | `https://learn.microsoft.com/windows/windows-app-sdk/api/winrt/{fully.qualified.typename}` |
|
||||
|
||||
For example, `Microsoft.UI.Xaml.Controls.NavigationView` maps to:
|
||||
`https://learn.microsoft.com/windows/windows-app-sdk/api/winrt/microsoft.ui.xaml.controls.navigationview`
|
||||
|
||||
**5. Use the API knowledge to answer or write code**
|
||||
|
||||
---
|
||||
|
||||
### Lookup — "I know the API, show me the details"
|
||||
|
||||
You already know (or suspect) the type or namespace name. Go direct:
|
||||
|
||||
```powershell
|
||||
# Get all members of a known type
|
||||
.\.github\skills\winmd-api-search\scripts\Invoke-WinMdQuery.ps1 -Action members -TypeName "Microsoft.UI.Xaml.Controls.NavigationView"
|
||||
|
||||
# Get enum values
|
||||
.\.github\skills\winmd-api-search\scripts\Invoke-WinMdQuery.ps1 -Action enums -TypeName "Microsoft.UI.Xaml.Visibility"
|
||||
|
||||
# List all types in a namespace
|
||||
.\.github\skills\winmd-api-search\scripts\Invoke-WinMdQuery.ps1 -Action types -Namespace "Microsoft.UI.Xaml.Controls"
|
||||
|
||||
# Browse namespaces
|
||||
.\.github\skills\winmd-api-search\scripts\Invoke-WinMdQuery.ps1 -Action namespaces -Filter "Microsoft.UI"
|
||||
```
|
||||
|
||||
If you need full detail beyond what `-Action members` shows, use `-Action search` to get the JSON file path, then read the JSON file directly.
|
||||
|
||||
---
|
||||
|
||||
### Other Commands
|
||||
|
||||
```powershell
|
||||
# List cached projects
|
||||
.\.github\skills\winmd-api-search\scripts\Invoke-WinMdQuery.ps1 -Action projects
|
||||
|
||||
# List packages for a project
|
||||
.\.github\skills\winmd-api-search\scripts\Invoke-WinMdQuery.ps1 -Action packages
|
||||
|
||||
# Show stats
|
||||
.\.github\skills\winmd-api-search\scripts\Invoke-WinMdQuery.ps1 -Action stats
|
||||
```
|
||||
|
||||
> If only one project is cached, `-Project` is auto-selected.
|
||||
> If multiple projects exist, add `-Project <name>` (use `-Action projects` to see available names).
|
||||
> In scan mode, manifest names include a short hash suffix to avoid collisions; you can pass the base project name without the suffix if it's unambiguous.
|
||||
|
||||
## Search Scoring
|
||||
|
||||
The search ranks type names and member names against your query:
|
||||
|
||||
| Score | Match type | Example |
|
||||
|-------|-----------|---------|
|
||||
| 100 | Exact name | `Button` → `Button` |
|
||||
| 80 | Starts with | `Navigation` → `NavigationView` |
|
||||
| 60 | Contains | `Dialog` → `ContentDialog` |
|
||||
| 50 | PascalCase initials | `ASB` → `AutoSuggestBox` |
|
||||
| 40 | Multi-keyword AND | `navigation item` → `NavigationViewItem` |
|
||||
| 20 | Fuzzy character match | `NavVw` → `NavigationView` |
|
||||
|
||||
Results are grouped by namespace. Higher-scored namespaces appear first.
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
| Issue | Fix |
|
||||
|-------|-----|
|
||||
| "Cache not found" | Run `Update-WinMdCache.ps1` |
|
||||
| "Multiple projects cached" | Add `-Project <name>` |
|
||||
| "Namespace not found" | Use `-Action namespaces` to list available ones |
|
||||
| "Type not found" | Use fully qualified name (e.g., `Microsoft.UI.Xaml.Controls.Button`) |
|
||||
| Stale after NuGet update | Re-run `Update-WinMdCache.ps1` |
|
||||
| Cache in git history | Add `Generated Files/` to `.gitignore` |
|
||||
|
||||
## References
|
||||
|
||||
- [Windows Platform SDK API reference](https://learn.microsoft.com/uwp/api/) — documentation for `Windows.*` namespaces
|
||||
- [Windows App SDK API reference](https://learn.microsoft.com/windows/windows-app-sdk/api/winrt/) — documentation for `Microsoft.*` WinAppSDK namespaces
|
||||
505
.github/skills/winmd-api-search/scripts/Invoke-WinMdQuery.ps1
vendored
Normal file
505
.github/skills/winmd-api-search/scripts/Invoke-WinMdQuery.ps1
vendored
Normal file
@@ -0,0 +1,505 @@
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Query WinMD API metadata from cached JSON files.
|
||||
|
||||
.DESCRIPTION
|
||||
Reads pre-built JSON cache of WinMD types, members, and namespaces.
|
||||
The cache is organized per-package (deduplicated) with project manifests
|
||||
that map each project to its referenced packages.
|
||||
|
||||
Supports listing namespaces, types, members, searching, enum value lookup,
|
||||
and listing cached projects/packages.
|
||||
|
||||
.PARAMETER Action
|
||||
The query action to perform:
|
||||
- projects : List cached projects
|
||||
- packages : List packages for a project
|
||||
- stats : Show aggregate statistics for a project
|
||||
- namespaces : List all namespaces (optional -Filter prefix)
|
||||
- types : List types in a namespace (-Namespace required)
|
||||
- members : List members of a type (-TypeName required)
|
||||
- search : Search types and members by name (-Query required)
|
||||
- enums : List enum values (-TypeName required)
|
||||
|
||||
.PARAMETER Project
|
||||
Project name to query. Auto-selected if only one project is cached.
|
||||
Use -Action projects to list available projects.
|
||||
|
||||
.PARAMETER Namespace
|
||||
Namespace to query types from (used with -Action types).
|
||||
|
||||
.PARAMETER TypeName
|
||||
Full type name e.g. "Microsoft.UI.Xaml.Controls.Button" (used with -Action members, enums).
|
||||
|
||||
.PARAMETER Query
|
||||
Search query string (used with -Action search).
|
||||
|
||||
.PARAMETER Filter
|
||||
Optional prefix filter for namespaces (used with -Action namespaces).
|
||||
|
||||
.PARAMETER CacheDir
|
||||
Path to the winmd-cache directory. Defaults to "Generated Files\winmd-cache"
|
||||
relative to the workspace root.
|
||||
|
||||
.PARAMETER MaxResults
|
||||
Maximum number of results to return for search. Defaults to 30.
|
||||
|
||||
.EXAMPLE
|
||||
.\Invoke-WinMdQuery.ps1 -Action projects
|
||||
.\Invoke-WinMdQuery.ps1 -Action packages -Project BlankWinUI
|
||||
.\Invoke-WinMdQuery.ps1 -Action stats -Project BlankWinUI
|
||||
.\Invoke-WinMdQuery.ps1 -Action namespaces -Filter "Microsoft.UI"
|
||||
.\Invoke-WinMdQuery.ps1 -Action types -Namespace "Microsoft.UI.Xaml.Controls"
|
||||
.\Invoke-WinMdQuery.ps1 -Action members -TypeName "Microsoft.UI.Xaml.Controls.Button"
|
||||
.\Invoke-WinMdQuery.ps1 -Action search -Query "NavigationView"
|
||||
.\Invoke-WinMdQuery.ps1 -Action enums -TypeName "Microsoft.UI.Xaml.Visibility"
|
||||
#>
|
||||
[CmdletBinding()]
|
||||
param(
|
||||
[Parameter(Mandatory)]
|
||||
[ValidateSet('projects', 'packages', 'stats', 'namespaces', 'types', 'members', 'search', 'enums')]
|
||||
[string]$Action,
|
||||
|
||||
[string]$Project,
|
||||
[string]$Namespace,
|
||||
[string]$TypeName,
|
||||
[string]$Query,
|
||||
[string]$Filter,
|
||||
[string]$CacheDir,
|
||||
[int]$MaxResults = 30
|
||||
)
|
||||
|
||||
# ─── Resolve cache directory ─────────────────────────────────────────────────
|
||||
|
||||
if (-not $CacheDir) {
|
||||
# Convention: skill lives at .github/skills/winmd-api-search/scripts/
|
||||
# so workspace root is 4 levels up from $PSScriptRoot.
|
||||
$scriptDir = $PSScriptRoot
|
||||
$root = (Resolve-Path (Join-Path $scriptDir '..\..\..\..')).Path
|
||||
$CacheDir = Join-Path $root 'Generated Files\winmd-cache'
|
||||
}
|
||||
|
||||
if (-not (Test-Path $CacheDir)) {
|
||||
Write-Error "Cache not found at: $CacheDir`nRun: .\Update-WinMdCache.ps1 (from .github\skills\winmd-api-search\scripts\)"
|
||||
exit 1
|
||||
}
|
||||
|
||||
# ─── Project resolution helpers ──────────────────────────────────────────────
|
||||
|
||||
function Get-CachedProjects {
|
||||
$projectsDir = Join-Path $CacheDir 'projects'
|
||||
if (-not (Test-Path $projectsDir)) { return @() }
|
||||
Get-ChildItem $projectsDir -Filter '*.json' | ForEach-Object { $_.BaseName }
|
||||
}
|
||||
|
||||
function Resolve-ProjectManifest {
|
||||
param([string]$Name)
|
||||
|
||||
$projectsDir = Join-Path $CacheDir 'projects'
|
||||
if (-not (Test-Path $projectsDir)) {
|
||||
Write-Error "No projects cached. Run Update-WinMdCache.ps1 first."
|
||||
exit 1
|
||||
}
|
||||
|
||||
if ($Name) {
|
||||
$path = Join-Path $projectsDir "$Name.json"
|
||||
if (-not (Test-Path $path)) {
|
||||
# Scan mode appends a hash suffix -- try prefix match
|
||||
$matching = @(Get-ChildItem $projectsDir -Filter "${Name}_*.json" -ErrorAction SilentlyContinue)
|
||||
if ($matching.Count -eq 1) {
|
||||
return Get-Content $matching[0].FullName -Raw | ConvertFrom-Json
|
||||
}
|
||||
if ($matching.Count -gt 1) {
|
||||
$names = ($matching | ForEach-Object { $_.BaseName }) -join ', '
|
||||
Write-Error "Multiple projects match '$Name'. Specify the full name: $names"
|
||||
exit 1
|
||||
}
|
||||
$available = (Get-CachedProjects) -join ', '
|
||||
Write-Error "Project '$Name' not found. Available: $available"
|
||||
exit 1
|
||||
}
|
||||
return Get-Content $path -Raw | ConvertFrom-Json
|
||||
}
|
||||
|
||||
# Auto-select if only one project
|
||||
$manifests = Get-ChildItem $projectsDir -Filter '*.json' -ErrorAction SilentlyContinue
|
||||
if ($manifests.Count -eq 0) {
|
||||
Write-Error "No projects cached. Run Update-WinMdCache.ps1 first."
|
||||
exit 1
|
||||
}
|
||||
if ($manifests.Count -eq 1) {
|
||||
return Get-Content $manifests[0].FullName -Raw | ConvertFrom-Json
|
||||
}
|
||||
|
||||
$available = ($manifests | ForEach-Object { $_.BaseName }) -join ', '
|
||||
Write-Error "Multiple projects cached -- use -Project to specify. Available: $available"
|
||||
exit 1
|
||||
}
|
||||
|
||||
function Get-PackageCacheDirs {
|
||||
param($Manifest)
|
||||
$dirs = @()
|
||||
foreach ($pkg in $Manifest.packages) {
|
||||
$dir = Join-Path (Join-Path (Join-Path $CacheDir 'packages') $pkg.id) $pkg.version
|
||||
if (Test-Path $dir) {
|
||||
$dirs += $dir
|
||||
}
|
||||
}
|
||||
return $dirs
|
||||
}
|
||||
|
||||
# ─── Action: projects ────────────────────────────────────────────────────────
|
||||
|
||||
function Show-Projects {
|
||||
$projects = Get-CachedProjects
|
||||
if ($projects.Count -eq 0) {
|
||||
Write-Output "No projects cached."
|
||||
return
|
||||
}
|
||||
Write-Output "Cached projects ($($projects.Count)):"
|
||||
foreach ($p in $projects) {
|
||||
$manifest = Get-Content (Join-Path (Join-Path $CacheDir 'projects') "$p.json") -Raw | ConvertFrom-Json
|
||||
$pkgCount = $manifest.packages.Count
|
||||
Write-Output " $p ($pkgCount package(s))"
|
||||
}
|
||||
}
|
||||
|
||||
# ─── Action: packages ────────────────────────────────────────────────────────
|
||||
|
||||
function Show-Packages {
|
||||
$manifest = Resolve-ProjectManifest -Name $Project
|
||||
Write-Output "Packages for project '$($manifest.projectName)' ($($manifest.packages.Count)):"
|
||||
foreach ($pkg in $manifest.packages) {
|
||||
$metaPath = Join-Path (Join-Path (Join-Path (Join-Path $CacheDir 'packages') $pkg.id) $pkg.version) 'meta.json'
|
||||
if (Test-Path $metaPath) {
|
||||
$meta = Get-Content $metaPath -Raw | ConvertFrom-Json
|
||||
Write-Output " $($pkg.id)@$($pkg.version) -- $($meta.totalTypes) types, $($meta.totalMembers) members"
|
||||
} else {
|
||||
Write-Output " $($pkg.id)@$($pkg.version) -- (cache missing)"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
# ─── Action: stats ───────────────────────────────────────────────────────────
|
||||
|
||||
function Show-Stats {
|
||||
$manifest = Resolve-ProjectManifest -Name $Project
|
||||
$totalTypes = 0
|
||||
$totalMembers = 0
|
||||
$totalNamespaces = 0
|
||||
$totalWinMd = 0
|
||||
|
||||
foreach ($pkg in $manifest.packages) {
|
||||
$metaPath = Join-Path (Join-Path (Join-Path (Join-Path $CacheDir 'packages') $pkg.id) $pkg.version) 'meta.json'
|
||||
if (Test-Path $metaPath) {
|
||||
$meta = Get-Content $metaPath -Raw | ConvertFrom-Json
|
||||
$totalTypes += $meta.totalTypes
|
||||
$totalMembers += $meta.totalMembers
|
||||
$totalNamespaces += $meta.totalNamespaces
|
||||
$totalWinMd += $meta.winMdFiles.Count
|
||||
}
|
||||
}
|
||||
|
||||
Write-Output "WinMD Index Statistics -- $($manifest.projectName)"
|
||||
Write-Output "======================================"
|
||||
Write-Output " Packages: $($manifest.packages.Count)"
|
||||
Write-Output " Namespaces: $totalNamespaces (may overlap across packages)"
|
||||
Write-Output " Types: $totalTypes"
|
||||
Write-Output " Members: $totalMembers"
|
||||
Write-Output " WinMD files: $totalWinMd"
|
||||
}
|
||||
|
||||
# ─── Action: namespaces ──────────────────────────────────────────────────────
|
||||
|
||||
function Get-Namespaces {
|
||||
param([string]$Prefix)
|
||||
$manifest = Resolve-ProjectManifest -Name $Project
|
||||
$dirs = Get-PackageCacheDirs -Manifest $manifest
|
||||
$allNs = @()
|
||||
|
||||
foreach ($dir in $dirs) {
|
||||
$nsFile = Join-Path $dir 'namespaces.json'
|
||||
if (Test-Path $nsFile) {
|
||||
$allNs += (Get-Content $nsFile -Raw | ConvertFrom-Json)
|
||||
}
|
||||
}
|
||||
|
||||
$allNs = $allNs | Sort-Object -Unique
|
||||
if ($Prefix) {
|
||||
$allNs = $allNs | Where-Object { $_ -like "$Prefix*" }
|
||||
}
|
||||
$allNs | ForEach-Object { Write-Output $_ }
|
||||
}
|
||||
|
||||
# ─── Action: types ───────────────────────────────────────────────────────────
|
||||
|
||||
function Get-TypesInNamespace {
|
||||
param([string]$Ns)
|
||||
if (-not $Ns) {
|
||||
Write-Error "-Namespace is required for 'types' action."
|
||||
exit 1
|
||||
}
|
||||
|
||||
$manifest = Resolve-ProjectManifest -Name $Project
|
||||
$dirs = Get-PackageCacheDirs -Manifest $manifest
|
||||
$safeFile = $Ns.Replace('.', '_') + '.json'
|
||||
$found = $false
|
||||
$seen = @{}
|
||||
|
||||
foreach ($dir in $dirs) {
|
||||
$filePath = Join-Path $dir "types\$safeFile"
|
||||
if (-not (Test-Path $filePath)) { continue }
|
||||
$found = $true
|
||||
$types = Get-Content $filePath -Raw | ConvertFrom-Json
|
||||
foreach ($t in $types) {
|
||||
if ($seen.ContainsKey($t.fullName)) { continue }
|
||||
$seen[$t.fullName] = $true
|
||||
Write-Output "$($t.kind) $($t.fullName)$(if ($t.baseType) { " : $($t.baseType)" } else { '' })"
|
||||
}
|
||||
}
|
||||
|
||||
if (-not $found) {
|
||||
Write-Error "Namespace not found: $Ns"
|
||||
exit 1
|
||||
}
|
||||
}
|
||||
|
||||
# ─── Action: members ─────────────────────────────────────────────────────────
|
||||
|
||||
function Get-MembersOfType {
|
||||
param([string]$FullName)
|
||||
if (-not $FullName) {
|
||||
Write-Error "-TypeName is required for 'members' action."
|
||||
exit 1
|
||||
}
|
||||
|
||||
$lastDot = $FullName.LastIndexOf('.')
|
||||
if ($lastDot -lt 0) {
|
||||
Write-Error "-TypeName must include a namespace (for example: 'MyNamespace.MyType'). Provided: $FullName"
|
||||
exit 1
|
||||
}
|
||||
|
||||
$ns = $FullName.Substring(0, $lastDot)
|
||||
$safeFile = $ns.Replace('.', '_') + '.json'
|
||||
|
||||
$manifest = Resolve-ProjectManifest -Name $Project
|
||||
$dirs = Get-PackageCacheDirs -Manifest $manifest
|
||||
|
||||
foreach ($dir in $dirs) {
|
||||
$filePath = Join-Path $dir "types\$safeFile"
|
||||
if (-not (Test-Path $filePath)) { continue }
|
||||
|
||||
$types = Get-Content $filePath -Raw | ConvertFrom-Json
|
||||
$type = $types | Where-Object { $_.fullName -eq $FullName }
|
||||
if (-not $type) { continue }
|
||||
|
||||
Write-Output "$($type.kind) $($type.fullName)"
|
||||
if ($type.baseType) { Write-Output " Extends: $($type.baseType)" }
|
||||
Write-Output ""
|
||||
foreach ($m in $type.members) {
|
||||
Write-Output " [$($m.kind)] $($m.signature)"
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
Write-Error "Type not found: $FullName"
|
||||
exit 1
|
||||
}
|
||||
|
||||
# ─── Action: search ──────────────────────────────────────────────────────────
|
||||
# Ranks namespaces by best match score on type names and member names.
|
||||
# Outputs: ranked namespaces with top matching types and the JSON file path.
|
||||
# The agent can then read the JSON file to inspect all members intelligently.
|
||||
|
||||
function Search-WinMd {
|
||||
param([string]$SearchQuery, [int]$Max)
|
||||
if (-not $SearchQuery) {
|
||||
Write-Error "-Query is required for 'search' action."
|
||||
exit 1
|
||||
}
|
||||
|
||||
$manifest = Resolve-ProjectManifest -Name $Project
|
||||
$dirs = Get-PackageCacheDirs -Manifest $manifest
|
||||
|
||||
# Collect: namespace -> { bestScore, matchingTypes[], filePath }
|
||||
$nsResults = @{}
|
||||
|
||||
foreach ($dir in $dirs) {
|
||||
$nsFile = Join-Path $dir 'namespaces.json'
|
||||
if (-not (Test-Path $nsFile)) { continue }
|
||||
$nsList = Get-Content $nsFile -Raw | ConvertFrom-Json
|
||||
|
||||
foreach ($n in $nsList) {
|
||||
$safeFile = $n.Replace('.', '_') + '.json'
|
||||
$filePath = Join-Path $dir "types\$safeFile"
|
||||
if (-not (Test-Path $filePath)) { continue }
|
||||
|
||||
$types = Get-Content $filePath -Raw | ConvertFrom-Json
|
||||
foreach ($t in $types) {
|
||||
$typeScore = Get-MatchScore -Name $t.name -FullName $t.fullName -Query $SearchQuery
|
||||
|
||||
# Also search member names for matches
|
||||
$bestMemberScore = 0
|
||||
$matchingMember = $null
|
||||
if ($t.members) {
|
||||
foreach ($m in $t.members) {
|
||||
$memberName = $m.name
|
||||
$mScore = Get-MatchScore -Name $memberName -FullName "$($t.fullName).$memberName" -Query $SearchQuery
|
||||
if ($mScore -gt $bestMemberScore) {
|
||||
$bestMemberScore = $mScore
|
||||
$matchingMember = $m.signature
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$score = [Math]::Max($typeScore, $bestMemberScore)
|
||||
if ($score -le 0) { continue }
|
||||
|
||||
if (-not $nsResults.ContainsKey($n)) {
|
||||
$nsResults[$n] = @{ BestScore = 0; Types = @(); FilePaths = @() }
|
||||
}
|
||||
$entry = $nsResults[$n]
|
||||
if ($score -gt $entry.BestScore) { $entry.BestScore = $score }
|
||||
if ($entry.FilePaths -notcontains $filePath) {
|
||||
$entry.FilePaths += $filePath
|
||||
}
|
||||
|
||||
if ($typeScore -ge $bestMemberScore) {
|
||||
$entry.Types += @{ Text = "$($t.kind) $($t.fullName) [$typeScore]"; Score = $typeScore }
|
||||
} else {
|
||||
$entry.Types += @{ Text = "$($t.kind) $($t.fullName) -> $matchingMember [$bestMemberScore]"; Score = $bestMemberScore }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if ($nsResults.Count -eq 0) {
|
||||
Write-Output "No results found for: $SearchQuery"
|
||||
return
|
||||
}
|
||||
|
||||
$ranked = $nsResults.GetEnumerator() |
|
||||
Sort-Object { $_.Value.BestScore } -Descending |
|
||||
Select-Object -First $Max
|
||||
|
||||
foreach ($r in $ranked) {
|
||||
$ns = $r.Key
|
||||
$info = $r.Value
|
||||
Write-Output "[$($info.BestScore)] $ns"
|
||||
foreach ($fp in $info.FilePaths) {
|
||||
Write-Output " File: $fp"
|
||||
}
|
||||
# Show top 5 highest-scoring matching types in this namespace
|
||||
$info.Types | Sort-Object { $_.Score } -Descending |
|
||||
Select-Object -First 5 |
|
||||
ForEach-Object { Write-Output " $($_.Text)" }
|
||||
Write-Output ""
|
||||
}
|
||||
}
|
||||
|
||||
# ─── Search scoring ──────────────────────────────────────────────────────────
|
||||
# Simple ranked scoring on type names. Higher = better.
|
||||
# 100 = exact name 80 = starts-with 60 = substring
|
||||
# 50 = PascalCase 40 = multi-keyword 20 = fuzzy subsequence
|
||||
|
||||
function Get-MatchScore {
|
||||
param([string]$Name, [string]$FullName, [string]$Query)
|
||||
|
||||
$q = $Query.Trim()
|
||||
if (-not $q) { return 0 }
|
||||
|
||||
if ($Name -eq $q) { return 100 }
|
||||
if ($Name -like "$q*") { return 80 }
|
||||
if ($Name -like "*$q*" -or $FullName -like "*$q*") { return 60 }
|
||||
|
||||
$initials = ($Name.ToCharArray() | Where-Object { [char]::IsUpper($_) }) -join ''
|
||||
if ($initials.Length -ge 2 -and $initials -like "*$q*") { return 50 }
|
||||
|
||||
$words = $q -split '\s+' | Where-Object { $_.Length -gt 0 }
|
||||
if ($words.Count -gt 1) {
|
||||
$allFound = $true
|
||||
foreach ($w in $words) {
|
||||
if ($Name -notlike "*$w*" -and $FullName -notlike "*$w*") {
|
||||
$allFound = $false
|
||||
break
|
||||
}
|
||||
}
|
||||
if ($allFound) { return 40 }
|
||||
}
|
||||
|
||||
if (Test-FuzzySubsequence -Text $Name -Pattern $q) { return 20 }
|
||||
|
||||
return 0
|
||||
}
|
||||
|
||||
function Test-FuzzySubsequence {
|
||||
param([string]$Text, [string]$Pattern)
|
||||
$ti = 0
|
||||
$tLower = $Text.ToLowerInvariant()
|
||||
$pLower = $Pattern.ToLowerInvariant()
|
||||
foreach ($ch in $pLower.ToCharArray()) {
|
||||
$idx = $tLower.IndexOf($ch, $ti)
|
||||
if ($idx -lt 0) { return $false }
|
||||
$ti = $idx + 1
|
||||
}
|
||||
return $true
|
||||
}
|
||||
|
||||
# ─── Action: enums ───────────────────────────────────────────────────────────
|
||||
|
||||
function Get-EnumValues {
|
||||
param([string]$FullName)
|
||||
if (-not $FullName) {
|
||||
Write-Error "-TypeName is required for 'enums' action."
|
||||
exit 1
|
||||
}
|
||||
|
||||
$lastDot = $FullName.LastIndexOf('.')
|
||||
if ($lastDot -lt 1) {
|
||||
Write-Error "-TypeName must be a fully-qualified type name including namespace, e.g. 'Namespace.TypeName'. Provided: $FullName"
|
||||
exit 1
|
||||
}
|
||||
|
||||
$ns = $FullName.Substring(0, $lastDot)
|
||||
$safeFile = $ns.Replace('.', '_') + '.json'
|
||||
|
||||
$manifest = Resolve-ProjectManifest -Name $Project
|
||||
$dirs = Get-PackageCacheDirs -Manifest $manifest
|
||||
|
||||
foreach ($dir in $dirs) {
|
||||
$filePath = Join-Path $dir "types\$safeFile"
|
||||
if (-not (Test-Path $filePath)) { continue }
|
||||
|
||||
$types = Get-Content $filePath -Raw | ConvertFrom-Json
|
||||
$type = $types | Where-Object { $_.fullName -eq $FullName }
|
||||
if (-not $type) { continue }
|
||||
|
||||
if ($type.kind -ne 'Enum') {
|
||||
Write-Error "$FullName is not an Enum (kind: $($type.kind))"
|
||||
exit 1
|
||||
}
|
||||
Write-Output "Enum $($type.fullName)"
|
||||
if ($type.enumValues) {
|
||||
$type.enumValues | ForEach-Object { Write-Output " $_" }
|
||||
} else {
|
||||
Write-Output " (no values)"
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
Write-Error "Type not found: $FullName"
|
||||
exit 1
|
||||
}
|
||||
|
||||
# ─── Dispatch ─────────────────────────────────────────────────────────────────
|
||||
|
||||
switch ($Action) {
|
||||
'projects' { Show-Projects }
|
||||
'packages' { Show-Packages }
|
||||
'stats' { Show-Stats }
|
||||
'namespaces' { Get-Namespaces -Prefix $Filter }
|
||||
'types' { Get-TypesInNamespace -Ns $Namespace }
|
||||
'members' { Get-MembersOfType -FullName $TypeName }
|
||||
'search' { Search-WinMd -SearchQuery $Query -Max $MaxResults }
|
||||
'enums' { Get-EnumValues -FullName $TypeName }
|
||||
}
|
||||
208
.github/skills/winmd-api-search/scripts/Update-WinMdCache.ps1
vendored
Normal file
208
.github/skills/winmd-api-search/scripts/Update-WinMdCache.ps1
vendored
Normal file
@@ -0,0 +1,208 @@
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Generate or refresh the WinMD cache for the Agent Skill.
|
||||
|
||||
.DESCRIPTION
|
||||
Builds and runs the standalone cache generator to export cached JSON files
|
||||
from all WinMD metadata found in project NuGet packages and Windows SDK.
|
||||
|
||||
The cache is per-package+version: if two projects reference the same
|
||||
package at the same version, the WinMD data is parsed once and shared.
|
||||
|
||||
Supports single project or recursive scan of an entire repo.
|
||||
|
||||
.PARAMETER ProjectDir
|
||||
Path to a project directory (contains .csproj/.vcxproj), or a project file itself.
|
||||
Defaults to scanning the workspace root.
|
||||
|
||||
.PARAMETER Scan
|
||||
Recursively discover all .csproj/.vcxproj files under ProjectDir.
|
||||
|
||||
.PARAMETER OutputDir
|
||||
Path to the cache output directory. Defaults to "Generated Files\winmd-cache".
|
||||
|
||||
.EXAMPLE
|
||||
.\Update-WinMdCache.ps1
|
||||
.\Update-WinMdCache.ps1 -ProjectDir BlankWinUI
|
||||
.\Update-WinMdCache.ps1 -Scan -ProjectDir .
|
||||
.\Update-WinMdCache.ps1 -ProjectDir "src\MyApp\MyApp.csproj"
|
||||
#>
|
||||
[CmdletBinding()]
|
||||
param(
|
||||
[string]$ProjectDir,
|
||||
[switch]$Scan,
|
||||
[string]$OutputDir = 'Generated Files\winmd-cache'
|
||||
)
|
||||
|
||||
$ErrorActionPreference = 'Stop'
|
||||
|
||||
# Convention: skill lives at .github/skills/winmd-api-search/scripts/
|
||||
# so workspace root is 4 levels up from $PSScriptRoot.
|
||||
$root = (Resolve-Path (Join-Path $PSScriptRoot '..\..\..\..')).Path
|
||||
$generatorProj = Join-Path (Join-Path $PSScriptRoot 'cache-generator') 'CacheGenerator.csproj'
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# WinAppSDK version detection -- look only at the repo root folder (no recursion)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
function Get-WinAppSdkVersionFromDirectoryPackagesProps {
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Extract Microsoft.WindowsAppSDK version from a Directory.Packages.props
|
||||
(Central Package Management) at the repo root.
|
||||
#>
|
||||
param([string]$RepoRoot)
|
||||
$propsFile = Join-Path $RepoRoot 'Directory.Packages.props'
|
||||
if (-not (Test-Path $propsFile)) { return $null }
|
||||
try {
|
||||
[xml]$xml = Get-Content $propsFile -Raw
|
||||
$node = $xml.SelectNodes('//PackageVersion') |
|
||||
Where-Object { $_.Include -eq 'Microsoft.WindowsAppSDK' } |
|
||||
Select-Object -First 1
|
||||
if ($node) { return $node.Version }
|
||||
} catch {
|
||||
Write-Verbose "Could not parse $propsFile : $_"
|
||||
}
|
||||
return $null
|
||||
}
|
||||
|
||||
function Get-WinAppSdkVersionFromPackagesConfig {
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Extract Microsoft.WindowsAppSDK version from a packages.config at the repo root.
|
||||
#>
|
||||
param([string]$RepoRoot)
|
||||
$configFile = Join-Path $RepoRoot 'packages.config'
|
||||
if (-not (Test-Path $configFile)) { return $null }
|
||||
try {
|
||||
[xml]$xml = Get-Content $configFile -Raw
|
||||
$node = $xml.SelectNodes('//package') |
|
||||
Where-Object { $_.id -eq 'Microsoft.WindowsAppSDK' } |
|
||||
Select-Object -First 1
|
||||
if ($node) { return $node.version }
|
||||
} catch {
|
||||
Write-Verbose "Could not parse $configFile : $_"
|
||||
}
|
||||
return $null
|
||||
}
|
||||
|
||||
# Try Directory.Packages.props first (CPM), then packages.config
|
||||
$winAppSdkVersion = Get-WinAppSdkVersionFromDirectoryPackagesProps -RepoRoot $root
|
||||
if (-not $winAppSdkVersion) {
|
||||
$winAppSdkVersion = Get-WinAppSdkVersionFromPackagesConfig -RepoRoot $root
|
||||
}
|
||||
if ($winAppSdkVersion) {
|
||||
Write-Host "Detected WinAppSDK version from repo: $winAppSdkVersion" -ForegroundColor Cyan
|
||||
} else {
|
||||
Write-Host "No WinAppSDK version found at repo root; will use latest (Version=*)" -ForegroundColor Yellow
|
||||
}
|
||||
|
||||
# Default: if no ProjectDir, scan the workspace root
|
||||
if (-not $ProjectDir) {
|
||||
$ProjectDir = $root
|
||||
$Scan = $true
|
||||
}
|
||||
|
||||
Push-Location $root
|
||||
|
||||
try {
|
||||
# Detect installed .NET SDK -- require >= 8.0, prefer stable over preview
|
||||
$dotnetSdks = dotnet --list-sdks 2>$null
|
||||
$bestMajor = $dotnetSdks |
|
||||
Where-Object { $_ -notmatch 'preview|rc|alpha|beta' } |
|
||||
ForEach-Object { if ($_ -match '^(\d+)\.') { [int]$Matches[1] } } |
|
||||
Where-Object { $_ -ge 8 } |
|
||||
Sort-Object -Descending |
|
||||
Select-Object -First 1
|
||||
|
||||
# Fall back to preview SDKs if no stable SDK found
|
||||
if (-not $bestMajor) {
|
||||
$bestMajor = $dotnetSdks |
|
||||
ForEach-Object { if ($_ -match '^(\d+)\.') { [int]$Matches[1] } } |
|
||||
Where-Object { $_ -ge 8 } |
|
||||
Sort-Object -Descending |
|
||||
Select-Object -First 1
|
||||
}
|
||||
|
||||
if (-not $bestMajor) {
|
||||
Write-Error "No .NET SDK >= 8.0 found. Install from https://dotnet.microsoft.com/download"
|
||||
exit 1
|
||||
}
|
||||
|
||||
$targetFramework = "net$bestMajor.0"
|
||||
Write-Host "Using .NET SDK: $targetFramework" -ForegroundColor Cyan
|
||||
|
||||
# Build MSBuild properties -- pass detected WinAppSDK version when available
|
||||
$sdkVersionProp = ''
|
||||
if ($winAppSdkVersion) {
|
||||
$sdkVersionProp = "-p:WinAppSdkVersion=$winAppSdkVersion"
|
||||
}
|
||||
|
||||
Write-Host "Building cache generator..." -ForegroundColor Cyan
|
||||
$restoreArgs = @($generatorProj, "-p:TargetFramework=$targetFramework", '--nologo', '-v', 'q')
|
||||
if ($sdkVersionProp) { $restoreArgs += $sdkVersionProp }
|
||||
dotnet restore @restoreArgs
|
||||
if ($LASTEXITCODE -ne 0) {
|
||||
Write-Error "Restore failed"
|
||||
exit 1
|
||||
}
|
||||
$buildArgs = @($generatorProj, '-c', 'Release', '--nologo', '-v', 'q', "-p:TargetFramework=$targetFramework", '--no-restore')
|
||||
if ($sdkVersionProp) { $buildArgs += $sdkVersionProp }
|
||||
dotnet build @buildArgs
|
||||
if ($LASTEXITCODE -ne 0) {
|
||||
Write-Error "Build failed"
|
||||
exit 1
|
||||
}
|
||||
|
||||
# Run the built executable directly (avoids dotnet run target framework mismatch issues)
|
||||
$generatorDir = Join-Path $PSScriptRoot 'cache-generator'
|
||||
$exePath = Join-Path $generatorDir "bin\Release\$targetFramework\CacheGenerator.exe"
|
||||
if (-not (Test-Path $exePath)) {
|
||||
# Fallback: try dll with dotnet
|
||||
$dllPath = Join-Path $generatorDir "bin\Release\$targetFramework\CacheGenerator.dll"
|
||||
if (Test-Path $dllPath) {
|
||||
$exePath = $null
|
||||
} else {
|
||||
Write-Error "Built executable not found at: $exePath"
|
||||
exit 1
|
||||
}
|
||||
}
|
||||
|
||||
$runArgs = @()
|
||||
if ($Scan) {
|
||||
$runArgs += '--scan'
|
||||
}
|
||||
|
||||
# Detect installed WinAppSDK runtime via Get-AppxPackage (the WindowsApps
|
||||
# folder is ACL-restricted so C# cannot enumerate it directly).
|
||||
# WinMD files are architecture-independent metadata, so pick whichever arch
|
||||
# matches the current OS to ensure the package is present.
|
||||
$osArch = [System.Runtime.InteropServices.RuntimeInformation]::OSArchitecture.ToString()
|
||||
$runtimePkg = Get-AppxPackage -Name 'Microsoft.WindowsAppRuntime.*' -ErrorAction SilentlyContinue |
|
||||
Where-Object { $_.Name -notmatch 'CBS' -and $_.Architecture -eq $osArch } |
|
||||
Sort-Object -Property Version -Descending |
|
||||
Select-Object -First 1
|
||||
if ($runtimePkg -and $runtimePkg.InstallLocation -and (Test-Path $runtimePkg.InstallLocation)) {
|
||||
Write-Host "Detected WinAppSDK runtime: $($runtimePkg.Name) v$($runtimePkg.Version)" -ForegroundColor Cyan
|
||||
$runArgs += '--winappsdk-runtime'
|
||||
$runArgs += $runtimePkg.InstallLocation
|
||||
}
|
||||
|
||||
$runArgs += $ProjectDir
|
||||
$runArgs += $OutputDir
|
||||
|
||||
Write-Host "Exporting WinMD cache..." -ForegroundColor Cyan
|
||||
if ($exePath) {
|
||||
& $exePath @runArgs
|
||||
} else {
|
||||
dotnet $dllPath @runArgs
|
||||
}
|
||||
if ($LASTEXITCODE -ne 0) {
|
||||
Write-Error "Cache export failed"
|
||||
exit 1
|
||||
}
|
||||
|
||||
Write-Host "Cache updated at: $OutputDir" -ForegroundColor Green
|
||||
} finally {
|
||||
Pop-Location
|
||||
}
|
||||
29
.github/skills/winmd-api-search/scripts/cache-generator/CacheGenerator.csproj
vendored
Normal file
29
.github/skills/winmd-api-search/scripts/cache-generator/CacheGenerator.csproj
vendored
Normal file
@@ -0,0 +1,29 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup>
|
||||
<OutputType>Exe</OutputType>
|
||||
<!-- Default fallback; Update-WinMdCache.ps1 overrides via -p:TargetFramework=net{X}.0 -->
|
||||
<TargetFramework Condition="'$(TargetFramework)' == ''">net8.0</TargetFramework>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
</PropertyGroup>
|
||||
<!-- System.Reflection.Metadata is inbox in net9.0+, only needed for net8.0 -->
|
||||
<ItemGroup Condition="'$(TargetFramework)' == 'net8.0'">
|
||||
<PackageReference Include="System.Reflection.Metadata" Version="8.0.1" />
|
||||
</ItemGroup>
|
||||
|
||||
<!--
|
||||
Baseline WinAppSDK packages: downloaded during restore so the cache generator
|
||||
can always index WinAppSDK APIs, even if the target project hasn't been restored.
|
||||
ExcludeAssets="all" means they're downloaded but don't affect this tool's build.
|
||||
|
||||
When the repo has a known version (passed via -p:WinAppSdkVersion=X.Y.Z from
|
||||
Update-WinMdCache.ps1), prefer that version to avoid unnecessary NuGet downloads.
|
||||
Falls back to Version="*" (latest) on fresh clones with no restore.
|
||||
-->
|
||||
<ItemGroup Condition="'$(WinAppSdkVersion)' != ''">
|
||||
<PackageReference Include="Microsoft.WindowsAppSDK" Version="$(WinAppSdkVersion)" ExcludeAssets="all" />
|
||||
</ItemGroup>
|
||||
<ItemGroup Condition="'$(WinAppSdkVersion)' == ''">
|
||||
<PackageReference Include="Microsoft.WindowsAppSDK" Version="*" ExcludeAssets="all" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
3
.github/skills/winmd-api-search/scripts/cache-generator/Directory.Build.props
vendored
Normal file
3
.github/skills/winmd-api-search/scripts/cache-generator/Directory.Build.props
vendored
Normal file
@@ -0,0 +1,3 @@
|
||||
<Project>
|
||||
<!-- Isolate this standalone tool from the repo-level build configuration -->
|
||||
</Project>
|
||||
3
.github/skills/winmd-api-search/scripts/cache-generator/Directory.Build.targets
vendored
Normal file
3
.github/skills/winmd-api-search/scripts/cache-generator/Directory.Build.targets
vendored
Normal file
@@ -0,0 +1,3 @@
|
||||
<Project>
|
||||
<!-- Isolate this standalone tool from the repo-level build targets -->
|
||||
</Project>
|
||||
3
.github/skills/winmd-api-search/scripts/cache-generator/Directory.Packages.props
vendored
Normal file
3
.github/skills/winmd-api-search/scripts/cache-generator/Directory.Packages.props
vendored
Normal file
@@ -0,0 +1,3 @@
|
||||
<Project>
|
||||
<!-- Isolate this standalone tool from the repo-level Central Package Management -->
|
||||
</Project>
|
||||
1222
.github/skills/winmd-api-search/scripts/cache-generator/Program.cs
vendored
Normal file
1222
.github/skills/winmd-api-search/scripts/cache-generator/Program.cs
vendored
Normal file
File diff suppressed because it is too large
Load Diff
@@ -109,7 +109,8 @@
|
||||
"PowerToys.KeyboardManager.dll",
|
||||
|
||||
"KeyboardManagerEditor\\PowerToys.KeyboardManagerEditor.exe",
|
||||
"KeyboardManagerEditorUI\\PowerToys.KeyboardManagerEditorUI.exe",
|
||||
"WinUI3Apps\\PowerToys.KeyboardManagerEditorUI.exe",
|
||||
"WinUI3Apps\\PowerToys.KeyboardManagerEditorUI.dll",
|
||||
"KeyboardManagerEngine\\PowerToys.KeyboardManagerEngine.exe",
|
||||
"PowerToys.KeyboardManagerEditorLibraryWrapper.dll",
|
||||
"WinUI3Apps\\PowerToys.HostsModuleInterface.dll",
|
||||
|
||||
@@ -210,6 +210,9 @@ jobs:
|
||||
& '.pipelines/applyXamlStyling.ps1' -Passive
|
||||
displayName: Verify XAML formatting
|
||||
|
||||
- task: NuGetAuthenticate@1
|
||||
displayName: Authenticate NuGet feeds for verification
|
||||
|
||||
- pwsh: |-
|
||||
& '.pipelines/verifyNugetPackages.ps1' -solution '$(build.sourcesdirectory)\PowerToys.slnx'
|
||||
displayName: Verify Nuget package versions for PowerToys.slnx
|
||||
|
||||
@@ -26,7 +26,7 @@
|
||||
<PackageVersion Include="CommunityToolkit.WinUI.Controls.Sizers" Version="8.2.250402" />
|
||||
<PackageVersion Include="CommunityToolkit.WinUI.Converters" Version="8.2.250402" />
|
||||
<PackageVersion Include="CommunityToolkit.WinUI.Extensions" Version="8.2.250402" />
|
||||
<PackageVersion Include="CommunityToolkit.WinUI.UI.Controls.DataGrid" Version="7.1.2" />
|
||||
<PackageVersion Include="CommunityToolkit.WinUI.UI.Controls.DataGrid" Version="7.1.2" />
|
||||
<PackageVersion Include="CommunityToolkit.Labs.WinUI.Controls.MarkdownTextBlock" Version="0.1.260116-build.2514" />
|
||||
<PackageVersion Include="ControlzEx" Version="6.0.0" />
|
||||
<PackageVersion Include="HelixToolkit" Version="2.24.0" />
|
||||
@@ -40,7 +40,6 @@
|
||||
<!-- Including MessagePack to force version, since it's used by StreamJsonRpc but contains vulnerabilities. After StreamJsonRpc updates the version of MessagePack, we can upgrade StreamJsonRpc instead. -->
|
||||
<PackageVersion Include="MessagePack" Version="3.1.3" />
|
||||
<PackageVersion Include="Microsoft.CodeAnalysis.NetAnalyzers" Version="9.0.0" />
|
||||
<PackageVersion Include="Microsoft.CommandPalette.Extensions" Version="0.9.260303001" />
|
||||
<PackageVersion Include="Microsoft.Data.Sqlite" Version="9.0.10" />
|
||||
<!-- Including Microsoft.Bcl.AsyncInterfaces to force version, since it's used by Microsoft.SemanticKernel. -->
|
||||
<PackageVersion Include="Microsoft.Bcl.AsyncInterfaces" Version="9.0.10" />
|
||||
|
||||
@@ -497,7 +497,7 @@
|
||||
<Project Path="src/modules/keyboardmanager/KeyboardManagerEngine/KeyboardManagerEngine.vcxproj" Id="ba661f5b-1d5a-4ffc-9bf1-fc39df280bdd" />
|
||||
<Project Path="src/modules/keyboardmanager/KeyboardManagerEngineLibrary/KeyboardManagerEngineLibrary.vcxproj" Id="e496b7fc-1e99-4bab-849b-0e8367040b02" />
|
||||
</Folder>
|
||||
<Folder Name="/modules/keyboardmanager/MouseUtils/">
|
||||
<Folder Name="/modules/MouseUtils/">
|
||||
<Project Path="src/modules/MouseUtils/CursorWrap/CursorWrap.vcxproj" Id="48a1db8c-5df8-4fb3-9e14-2b67f3f2d8b5" />
|
||||
<Project Path="src/modules/MouseUtils/FindMyMouse/FindMyMouse.vcxproj" Id="e94fd11c-0591-456f-899f-efc0ca548336" />
|
||||
<Project Path="src/modules/MouseUtils/MouseHighlighter/MouseHighlighter.vcxproj" Id="782a61be-9d85-4081-b35c-1ccc9dcc1e88" />
|
||||
@@ -512,7 +512,7 @@
|
||||
</Project>
|
||||
<Project Path="src/modules/MouseUtils/MousePointerCrosshairs/MousePointerCrosshairs.vcxproj" Id="eae14c0e-7a6b-45da-9080-a7d8c077ba6e" />
|
||||
</Folder>
|
||||
<Folder Name="/modules/keyboardmanager/MouseUtils/Tests/">
|
||||
<Folder Name="/modules/MouseUtils/Tests/">
|
||||
<Project Path="src/modules/MouseUtils/MouseJump.Common.UnitTests/MouseJump.Common.UnitTests.csproj">
|
||||
<Platform Solution="*|ARM64" Project="ARM64" />
|
||||
<Platform Solution="*|x64" Project="x64" />
|
||||
|
||||
16
README.md
16
README.md
@@ -53,17 +53,17 @@ Go to the <a href="https://aka.ms/installPowerToys">PowerToys GitHub releases</a
|
||||
<!-- items that need to be updated release to release -->
|
||||
[github-next-release-work]: https://github.com/microsoft/PowerToys/issues?q=is%3Aissue+milestone%3A%22PowerToys+0.98%22
|
||||
[github-current-release-work]: https://github.com/microsoft/PowerToys/issues?q=is%3Aissue+milestone%3A%22PowerToys+0.97%22
|
||||
[ptUserX64]: https://github.com/microsoft/PowerToys/releases/download/v0.97.1/PowerToysUserSetup-0.97.1-x64.exe
|
||||
[ptUserArm64]: https://github.com/microsoft/PowerToys/releases/download/v0.97.1/PowerToysUserSetup-0.97.1-arm64.exe
|
||||
[ptMachineX64]: https://github.com/microsoft/PowerToys/releases/download/v0.97.1/PowerToysSetup-0.97.1-x64.exe
|
||||
[ptMachineArm64]: https://github.com/microsoft/PowerToys/releases/download/v0.97.1/PowerToysSetup-0.97.1-arm64.exe
|
||||
[ptUserX64]: https://github.com/microsoft/PowerToys/releases/download/v0.97.2/PowerToysUserSetup-0.97.2-x64.exe
|
||||
[ptUserArm64]: https://github.com/microsoft/PowerToys/releases/download/v0.97.2/PowerToysUserSetup-0.97.2-arm64.exe
|
||||
[ptMachineX64]: https://github.com/microsoft/PowerToys/releases/download/v0.97.2/PowerToysSetup-0.97.2-x64.exe
|
||||
[ptMachineArm64]: https://github.com/microsoft/PowerToys/releases/download/v0.97.2/PowerToysSetup-0.97.2-arm64.exe
|
||||
|
||||
| Description | Filename |
|
||||
|----------------|----------|
|
||||
| Per user - x64 | [PowerToysUserSetup-0.97.1-x64.exe][ptUserX64] |
|
||||
| Per user - ARM64 | [PowerToysUserSetup-0.97.1-arm64.exe][ptUserArm64] |
|
||||
| Machine wide - x64 | [PowerToysSetup-0.97.1-x64.exe][ptMachineX64] |
|
||||
| Machine wide - ARM64 | [PowerToysSetup-0.97.1-arm64.exe][ptMachineArm64] |
|
||||
| Per user - x64 | [PowerToysUserSetup-0.97.2-x64.exe][ptUserX64] |
|
||||
| Per user - ARM64 | [PowerToysUserSetup-0.97.2-arm64.exe][ptUserArm64] |
|
||||
| Machine wide - x64 | [PowerToysSetup-0.97.2-x64.exe][ptMachineX64] |
|
||||
| Machine wide - ARM64 | [PowerToysSetup-0.97.2-arm64.exe][ptMachineArm64] |
|
||||
|
||||
</details>
|
||||
|
||||
|
||||
@@ -22,6 +22,16 @@
|
||||
|
||||
<ComponentGroup Id="DscResourcesComponentGroup">
|
||||
<ComponentRef Id="PowerToysDSCReference" />
|
||||
<?if $(var.PerUser) = "false" ?>
|
||||
<Component Id="SecureDSCModulesFolder" Guid="7D2F4E57-CCB2-4F89-9B8B-62E9B3CC4E12" Directory="DSCModulesReferenceFolder" Bitness="always64">
|
||||
<RegistryKey Root="$(var.RegistryScope)" Key="Software\Classes\powertoys\components">
|
||||
<RegistryValue Type="string" Name="SecureDSCModulesFolder" Value="" KeyPath="yes" />
|
||||
</RegistryKey>
|
||||
<CreateFolder>
|
||||
<PermissionEx Sddl="D:PAI(A;OICI;GA;;;SY)(A;OICI;GA;;;BA)(A;OICI;GRGX;;;BU)(A;OICIIO;GA;;;CO)" />
|
||||
</CreateFolder>
|
||||
</Component>
|
||||
<?endif?>
|
||||
<Component Id="RemoveDSCModulesFolder" Guid="A3C77D92-4E97-4C1A-9F2E-8B3C5D6E7F80" Directory="DSCModulesReferenceFolder">
|
||||
<RegistryKey Root="$(var.RegistryScope)" Key="Software\Classes\powertoys\components">
|
||||
<RegistryValue Type="string" Name="RemoveDSCModulesFolder" Value="" KeyPath="yes" />
|
||||
|
||||
@@ -2,7 +2,29 @@
|
||||
|
||||
<?include $(sys.CURRENTDIR)\Common.wxi?>
|
||||
|
||||
<?define KeyboardManagerAssetsFiles=?>
|
||||
<?define KeyboardManagerAssetsWinUI3Files=?>
|
||||
<?define KeyboardManagerAssetsFilesPath=$(var.BinDir)\Assets\KeyboardManager\?>
|
||||
<?define KeyboardManagerAssetsWinUI3FilesPath=$(var.BinDir)\WinUI3Apps\Assets\KeyboardManagerEditor\?>
|
||||
|
||||
<Fragment>
|
||||
<DirectoryRef Id="BaseApplicationsAssetsFolder">
|
||||
<Directory Id="KeyboardManagerAssetsInstallFolder" Name="KeyboardManager" />
|
||||
</DirectoryRef>
|
||||
<DirectoryRef Id="WinUI3AppsAssetsFolder">
|
||||
<Directory Id="KeyboardManagerAssetsWinUI3InstallFolder" Name="KeyboardManagerEditor" />
|
||||
</DirectoryRef>
|
||||
|
||||
<DirectoryRef Id="KeyboardManagerAssetsInstallFolder" FileSource="$(var.KeyboardManagerAssetsFilesPath)">
|
||||
<!-- Generated by generateFileComponents.ps1 -->
|
||||
<!--KeyboardManagerAssetsFiles_Component_Def-->
|
||||
</DirectoryRef>
|
||||
|
||||
<DirectoryRef Id="KeyboardManagerAssetsWinUI3InstallFolder" FileSource="$(var.KeyboardManagerAssetsWinUI3FilesPath)">
|
||||
<!-- Generated by generateFileComponents.ps1 -->
|
||||
<!--KeyboardManagerAssetsWinUI3Files_Component_Def-->
|
||||
</DirectoryRef>
|
||||
|
||||
<DirectoryRef Id="INSTALLFOLDER">
|
||||
<Directory Id="KeyboardManagerEditorInstallFolder" Name="KeyboardManagerEditor" />
|
||||
<Directory Id="KeyboardManagerEngineInstallFolder" Name="KeyboardManagerEngine" />
|
||||
@@ -44,6 +66,8 @@
|
||||
<RegistryKey Root="$(var.RegistryScope)" Key="Software\Classes\powertoys\components">
|
||||
<RegistryValue Type="string" Name="RemoveKeyboardManagerFolder" Value="" KeyPath="yes" />
|
||||
</RegistryKey>
|
||||
<RemoveFolder Id="RemoveFolderKeyboardManagerAssetsInstallFolder" Directory="KeyboardManagerAssetsInstallFolder" On="uninstall" />
|
||||
<RemoveFolder Id="RemoveFolderKeyboardManagerAssetsWinUI3InstallFolder" Directory="KeyboardManagerAssetsWinUI3InstallFolder" On="uninstall" />
|
||||
<RemoveFolder Id="RemoveFolderKeyboardManagerEditorFolder" Directory="KeyboardManagerEditorInstallFolder" On="uninstall" />
|
||||
<RemoveFolder Id="RemoveFolderKeyboardManagerEngineFolder" Directory="KeyboardManagerEngineInstallFolder" On="uninstall" />
|
||||
</Component>
|
||||
|
||||
@@ -172,6 +172,12 @@ Generate-FileComponents -fileListName "HostsAssetsFiles" -wxsFilePath $PSScriptR
|
||||
Generate-FileList -fileDepsJson "" -fileListName ImageResizerAssetsFiles -wxsFilePath $PSScriptRoot\ImageResizer.wxs -depsPath "$PSScriptRoot..\..\..\$platform\Release\WinUI3Apps\Assets\ImageResizer"
|
||||
Generate-FileComponents -fileListName "ImageResizerAssetsFiles" -wxsFilePath $PSScriptRoot\ImageResizer.wxs
|
||||
|
||||
#KeyboardManager
|
||||
Generate-FileList -fileDepsJson "" -fileListName KeyboardManagerAssetsFiles -wxsFilePath $PSScriptRoot\KeyboardManager.wxs -depsPath "$PSScriptRoot..\..\..\$platform\Release\Assets\KeyboardManager"
|
||||
Generate-FileList -fileDepsJson "" -fileListName KeyboardManagerAssetsWinUI3Files -wxsFilePath $PSScriptRoot\KeyboardManager.wxs -depsPath "$PSScriptRoot..\..\..\$platform\Release\WinUI3Apps\Assets\KeyboardManagerEditor"
|
||||
Generate-FileComponents -fileListName "KeyboardManagerAssetsFiles" -wxsFilePath $PSScriptRoot\KeyboardManager.wxs
|
||||
Generate-FileComponents -fileListName "KeyboardManagerAssetsWinUI3Files" -wxsFilePath $PSScriptRoot\KeyboardManager.wxs
|
||||
|
||||
# Light Switch Service
|
||||
Generate-FileList -fileDepsJson "" -fileListName LightSwitchFiles -wxsFilePath $PSScriptRoot\LightSwitch.wxs -depsPath "$PSScriptRoot..\..\..\$platform\Release\LightSwitchService"
|
||||
Generate-FileComponents -fileListName "LightSwitchFiles" -wxsFilePath $PSScriptRoot\LightSwitch.wxs
|
||||
|
||||
@@ -287,8 +287,26 @@ namespace winrt::PowerToys::Interop::implementation
|
||||
{
|
||||
return CommonSharedConstants::POWER_DISPLAY_TERMINATE_APP_MESSAGE;
|
||||
}
|
||||
hstring Constants::MWBToggleEasyMouseEvent()
|
||||
{
|
||||
return CommonSharedConstants::MWB_TOGGLE_EASY_MOUSE_EVENT;
|
||||
}
|
||||
hstring Constants::MWBReconnectEvent()
|
||||
{
|
||||
return CommonSharedConstants::MWB_RECONNECT_EVENT;
|
||||
}
|
||||
|
||||
hstring Constants::OpenNewKeyboardManagerEvent()
|
||||
{
|
||||
return CommonSharedConstants::OPEN_NEW_KEYBOARD_MANAGER_EVENT;
|
||||
}
|
||||
hstring Constants::ToggleKeyboardManagerActiveEvent()
|
||||
{
|
||||
return CommonSharedConstants::TOGGLE_KEYBOARD_MANAGER_ACTIVE_EVENT;
|
||||
}
|
||||
hstring Constants::KeyboardManagerEngineInstanceMutex()
|
||||
{
|
||||
return CommonSharedConstants::KEYBOARD_MANAGER_ENGINE_INSTANCE_MUTEX;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -75,7 +75,11 @@ namespace winrt::PowerToys::Interop::implementation
|
||||
static hstring PowerDisplayToggleMessage();
|
||||
static hstring PowerDisplayApplyProfileMessage();
|
||||
static hstring PowerDisplayTerminateAppMessage();
|
||||
static hstring MWBToggleEasyMouseEvent();
|
||||
static hstring MWBReconnectEvent();
|
||||
static hstring OpenNewKeyboardManagerEvent();
|
||||
static hstring ToggleKeyboardManagerActiveEvent();
|
||||
static hstring KeyboardManagerEngineInstanceMutex();
|
||||
};
|
||||
}
|
||||
|
||||
@@ -85,3 +89,4 @@ namespace winrt::PowerToys::Interop::factory_implementation
|
||||
{
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -72,7 +72,12 @@ namespace PowerToys
|
||||
static String PowerDisplayToggleMessage();
|
||||
static String PowerDisplayApplyProfileMessage();
|
||||
static String PowerDisplayTerminateAppMessage();
|
||||
static String MWBToggleEasyMouseEvent();
|
||||
static String MWBReconnectEvent();
|
||||
static String OpenNewKeyboardManagerEvent();
|
||||
static String ToggleKeyboardManagerActiveEvent();
|
||||
static String KeyboardManagerEngineInstanceMutex();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -172,11 +172,18 @@ namespace CommonSharedConstants
|
||||
|
||||
// Path to events used by Keyboard Manager
|
||||
const wchar_t OPEN_NEW_KEYBOARD_MANAGER_EVENT[] = L"Local\\PowerToysOpenNewKeyboardManagerEvent-9c1d2e3f-4b5a-6c7d-8e9f-0a1b2c3d4e5f";
|
||||
const wchar_t TOGGLE_KEYBOARD_MANAGER_ACTIVE_EVENT[] = L"Local\\PowerToysToggleKeyboardManagerActiveEvent-7f3a1d5c-2e94-4ff4-8b6a-90fd2bc4d2a7";
|
||||
const wchar_t KEYBOARD_MANAGER_ENGINE_INSTANCE_MUTEX[] = L"Local\\PowerToys_KBMEngine_InstanceMutex";
|
||||
|
||||
// used from quick access window
|
||||
const wchar_t CMDPAL_SHOW_EVENT[] = L"Local\\PowerToysCmdPal-ShowEvent-62336fcd-8611-4023-9b30-091a6af4cc5a";
|
||||
const wchar_t CMDPAL_EXIT_EVENT[] = L"Local\\PowerToysCmdPal-ExitEvent-eb73f6be-3f22-4b36-aee3-62924ba40bfd";
|
||||
|
||||
// Path to the events used by MouseWithoutBorders
|
||||
const wchar_t MWB_TOGGLE_EASY_MOUSE_EVENT[] = L"Local\\PowerToysMWB-ToggleEasyMouseEvent-a9c8d7b6-e5f4-3c2a-1b0d-9e8f7a6b5c4d";
|
||||
const wchar_t MWB_RECONNECT_EVENT[] = L"Local\\PowerToysMWB-ReconnectEvent-b8d7c6a5-f4e3-2b1c-0a9d-8e7f6a5b4c3d";
|
||||
|
||||
// Max DWORD for key code to disable keys.
|
||||
const DWORD VK_DISABLED = 0x100;
|
||||
}
|
||||
|
||||
|
||||
@@ -14,7 +14,6 @@ void MonitorTopology::Initialize(const std::vector<MonitorInfo>& monitors)
|
||||
Logger::info(L"======= TOPOLOGY INITIALIZATION START =======");
|
||||
Logger::info(L"Initializing edge-based topology for {} monitors", monitors.size());
|
||||
|
||||
|
||||
m_monitors = monitors;
|
||||
m_outerEdges.clear();
|
||||
m_edgeMap.clear();
|
||||
@@ -692,7 +691,6 @@ int MonitorTopology::GetAbsolutePosition(const MonitorEdge& edge, double relativ
|
||||
return static_cast<int>(result);
|
||||
}
|
||||
|
||||
|
||||
std::vector<MonitorTopology::GapInfo> MonitorTopology::DetectMonitorGaps() const
|
||||
{
|
||||
std::vector<GapInfo> gaps;
|
||||
|
||||
@@ -84,7 +84,7 @@ private:
|
||||
bool m_disableWrapDuringDrag = true; // Default to true to prevent wrap during drag
|
||||
bool m_disableOnSingleMonitor = false; // Default to false
|
||||
int m_wrapMode = 0; // 0=Both (default), 1=VerticalOnly, 2=HorizontalOnly
|
||||
int m_activationMode = 0; // 0=Always (default), 1=HoldingCtrl (disables wrap), 2=HoldingShift (disables wrap)
|
||||
int m_activationMode = 0; // 0=Always (default), 1=HoldingCtrl (wraps only while held), 2=HoldingShift (wraps only while held)
|
||||
|
||||
// Mouse hook
|
||||
HHOOK m_mouseHook = nullptr;
|
||||
@@ -689,23 +689,23 @@ private:
|
||||
|
||||
if (g_cursorWrapInstance && g_cursorWrapInstance->m_hookActive)
|
||||
{
|
||||
// Check activation mode to determine if wrapping should be disabled
|
||||
// 0=Always, 1=HoldingCtrl (disables wrap when Ctrl held), 2=HoldingShift (disables wrap when Shift held)
|
||||
// Check activation mode to determine if wrapping should happen.
|
||||
// 0=Always, 1=HoldingCtrl (wraps only when Ctrl held), 2=HoldingShift (wraps only when Shift held)
|
||||
int activationMode = g_cursorWrapInstance->m_activationMode;
|
||||
bool disableByKey = false;
|
||||
bool shouldWrap = true;
|
||||
|
||||
if (activationMode == 1) // HoldingCtrl - disable wrap when Ctrl is held
|
||||
if (activationMode == 1) // HoldingCtrl - wrap only when Ctrl is held
|
||||
{
|
||||
disableByKey = (GetAsyncKeyState(VK_CONTROL) & 0x8000) != 0;
|
||||
shouldWrap = (GetAsyncKeyState(VK_CONTROL) & 0x8000) != 0;
|
||||
}
|
||||
else if (activationMode == 2) // HoldingShift - disable wrap when Shift is held
|
||||
else if (activationMode == 2) // HoldingShift - wrap only when Shift is held
|
||||
{
|
||||
disableByKey = (GetAsyncKeyState(VK_SHIFT) & 0x8000) != 0;
|
||||
shouldWrap = (GetAsyncKeyState(VK_SHIFT) & 0x8000) != 0;
|
||||
}
|
||||
|
||||
if (disableByKey)
|
||||
if (!shouldWrap)
|
||||
{
|
||||
// Key is held, do not wrap - let normal behavior happen
|
||||
// Activation key is not held, do not wrap - let normal behavior happen.
|
||||
return CallNextHookEx(nullptr, nCode, wParam, lParam);
|
||||
}
|
||||
|
||||
|
||||
@@ -229,6 +229,7 @@ namespace MouseWithoutBorders.Class
|
||||
if (!Common.RunOnLogonDesktop)
|
||||
{
|
||||
StartSettingSyncThread();
|
||||
CommandEventHandler.StartListening();
|
||||
}
|
||||
|
||||
Application.EnableVisualStyles();
|
||||
|
||||
114
src/modules/MouseWithoutBorders/App/Core/CommandEventHandler.cs
Normal file
114
src/modules/MouseWithoutBorders/App/Core/CommandEventHandler.cs
Normal file
@@ -0,0 +1,114 @@
|
||||
// Copyright (c) Microsoft Corporation
|
||||
// The Microsoft Corporation licenses this file to you under the MIT license.
|
||||
// See the LICENSE file in the project root for more information.
|
||||
|
||||
using System;
|
||||
using System.Threading;
|
||||
|
||||
using MouseWithoutBorders.Class;
|
||||
using PowerToys.Interop;
|
||||
|
||||
namespace MouseWithoutBorders.Core
|
||||
{
|
||||
/// <summary>
|
||||
/// Handles command events from external sources (e.g., Command Palette).
|
||||
/// Uses named events for inter-process communication, following the same pattern as other PowerToys modules.
|
||||
/// </summary>
|
||||
internal static class CommandEventHandler
|
||||
{
|
||||
private static CancellationTokenSource _cancellationTokenSource;
|
||||
|
||||
/// <summary>
|
||||
/// Starts listening for command events on background threads.
|
||||
/// </summary>
|
||||
public static void StartListening()
|
||||
{
|
||||
_cancellationTokenSource = new CancellationTokenSource();
|
||||
CancellationToken exitToken = _cancellationTokenSource.Token;
|
||||
|
||||
// Start listener for Toggle Easy Mouse event
|
||||
StartEventListener(Constants.MWBToggleEasyMouseEvent(), ToggleEasyMouse, exitToken);
|
||||
|
||||
// Start listener for Reconnect event
|
||||
StartEventListener(Constants.MWBReconnectEvent(), Reconnect, exitToken);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Stops listening for command events.
|
||||
/// </summary>
|
||||
public static void StopListening()
|
||||
{
|
||||
_cancellationTokenSource?.Cancel();
|
||||
_cancellationTokenSource?.Dispose();
|
||||
_cancellationTokenSource = null;
|
||||
}
|
||||
|
||||
private static void StartEventListener(string eventName, Action callback, CancellationToken cancel)
|
||||
{
|
||||
new System.Threading.Thread(() =>
|
||||
{
|
||||
try
|
||||
{
|
||||
using var eventHandle = new EventWaitHandle(false, EventResetMode.AutoReset, eventName);
|
||||
WaitHandle[] waitHandles = new WaitHandle[] { cancel.WaitHandle, eventHandle };
|
||||
|
||||
while (!cancel.IsCancellationRequested)
|
||||
{
|
||||
int result = WaitHandle.WaitAny(waitHandles);
|
||||
if (result == 1)
|
||||
{
|
||||
// Execute callback on UI thread using Common.DoSomethingInUIThread
|
||||
Common.DoSomethingInUIThread(callback);
|
||||
}
|
||||
else
|
||||
{
|
||||
// Cancellation requested
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Logger.Log($"Error in event listener for {eventName}: {ex.Message}");
|
||||
}
|
||||
})
|
||||
{ IsBackground = true, Name = $"MWB-{eventName}-Listener" }.Start();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Toggles Easy Mouse between Enabled and Disabled states.
|
||||
/// This is the same logic used by the hotkey handler.
|
||||
/// </summary>
|
||||
public static void ToggleEasyMouse()
|
||||
{
|
||||
if (Common.RunOnLogonDesktop || Common.RunOnScrSaverDesktop)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
EasyMouseOption easyMouseOption = (EasyMouseOption)Setting.Values.EasyMouse;
|
||||
|
||||
if (easyMouseOption is EasyMouseOption.Disable or EasyMouseOption.Enable)
|
||||
{
|
||||
Setting.Values.EasyMouse = (int)(easyMouseOption == EasyMouseOption.Disable ? EasyMouseOption.Enable : EasyMouseOption.Disable);
|
||||
|
||||
Common.ShowToolTip($"Easy Mouse has been toggled to [{(EasyMouseOption)Setting.Values.EasyMouse}].", 3000);
|
||||
|
||||
Logger.Log($"Easy Mouse toggled to {(EasyMouseOption)Setting.Values.EasyMouse} via command event.");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Initiates a reconnection attempt to all machines.
|
||||
/// This is the same logic used by the hotkey handler.
|
||||
/// </summary>
|
||||
public static void Reconnect()
|
||||
{
|
||||
Common.ShowToolTip("Reconnecting...", 2000);
|
||||
Common.LastReconnectByHotKeyTime = Common.GetTick();
|
||||
InitAndCleanup.PleaseReopenSocket = InitAndCleanup.REOPEN_WHEN_HOTKEY;
|
||||
|
||||
Logger.Log("Reconnect initiated via command event.");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -218,6 +218,7 @@
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\..\common\GPOWrapper\GPOWrapper.vcxproj" />
|
||||
<ProjectReference Include="..\..\..\common\interop\PowerToys.Interop.vcxproj" />
|
||||
<ProjectReference Include="..\..\..\settings-ui\Settings.UI.Library\Settings.UI.Library.csproj" />
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
|
||||
@@ -5679,6 +5679,16 @@ winrt::fire_and_forget StartRecordingAsync( HWND hWnd, LPRECT rcCrop, HWND hWndR
|
||||
// Recording completed (closed via hotkey or item close). Proceed to save/trim workflow.
|
||||
OutputDebugStringW(L"[Recording] StartAsync completed, entering save workflow\n");
|
||||
|
||||
// Release the writer stream and session objects before trim/save. Keeping the temp file
|
||||
// open here can cause trimming and later MoveAndReplaceAsync calls to fail on the same file.
|
||||
if (stream)
|
||||
{
|
||||
stream.Close();
|
||||
stream = nullptr;
|
||||
}
|
||||
g_RecordingSession = nullptr;
|
||||
g_GifRecordingSession = nullptr;
|
||||
|
||||
// Resume on the UI thread for the save dialog
|
||||
co_await uiThread;
|
||||
OutputDebugStringW(L"[Recording] Resumed on UI thread\n");
|
||||
|
||||
@@ -71,7 +71,8 @@ bool isExcluded(HWND window)
|
||||
auto processPath = get_process_path(window);
|
||||
CharUpperBuffW(processPath.data(), static_cast<DWORD>(processPath.length()));
|
||||
|
||||
return check_excluded_app(window, processPath, AlwaysOnTopSettings::settings().excludedApps);
|
||||
const auto settings = AlwaysOnTopSettings::settings();
|
||||
return check_excluded_app(window, processPath, settings->excludedApps);
|
||||
}
|
||||
|
||||
AlwaysOnTop::AlwaysOnTop(bool useLLKH, DWORD mainThreadId) :
|
||||
@@ -155,7 +156,8 @@ void AlwaysOnTop::SettingsUpdate(SettingId id)
|
||||
break;
|
||||
case SettingId::FrameEnabled:
|
||||
{
|
||||
if (AlwaysOnTopSettings::settings().enableFrame)
|
||||
const auto settings = AlwaysOnTopSettings::settings();
|
||||
if (settings->enableFrame)
|
||||
{
|
||||
for (auto& iter : m_topmostWindows)
|
||||
{
|
||||
@@ -194,7 +196,8 @@ void AlwaysOnTop::SettingsUpdate(SettingId id)
|
||||
break;
|
||||
case SettingId::ShowInSystemMenu:
|
||||
{
|
||||
UpdateSystemMenuEventHooks(AlwaysOnTopSettings::settings().showInSystemMenu);
|
||||
const auto settings = AlwaysOnTopSettings::settings();
|
||||
UpdateSystemMenuEventHooks(settings->showInSystemMenu);
|
||||
m_lastSystemMenuWindow = nullptr;
|
||||
UpdateSystemMenuItem(GetForegroundWindow());
|
||||
}
|
||||
@@ -236,7 +239,7 @@ LRESULT AlwaysOnTop::WndProc(HWND window, UINT message, WPARAM wparam, LPARAM lp
|
||||
void AlwaysOnTop::ProcessCommand(HWND window)
|
||||
{
|
||||
bool gameMode = detect_game_mode();
|
||||
if (AlwaysOnTopSettings::settings().blockInGameMode && gameMode)
|
||||
if (AlwaysOnTopSettings::settings()->blockInGameMode && gameMode)
|
||||
{
|
||||
return;
|
||||
}
|
||||
@@ -276,7 +279,7 @@ void AlwaysOnTop::ProcessCommand(HWND window)
|
||||
}
|
||||
}
|
||||
|
||||
if (AlwaysOnTopSettings::settings().enableSound)
|
||||
if (AlwaysOnTopSettings::settings()->enableSound)
|
||||
{
|
||||
m_sound.Play(soundType);
|
||||
}
|
||||
@@ -323,7 +326,7 @@ void AlwaysOnTop::StartTrackingTopmostWindows()
|
||||
|
||||
bool AlwaysOnTop::AssignBorder(HWND window)
|
||||
{
|
||||
if (m_virtualDesktopUtils.IsWindowOnCurrentDesktop(window) && AlwaysOnTopSettings::settings().enableFrame)
|
||||
if (m_virtualDesktopUtils.IsWindowOnCurrentDesktop(window) && AlwaysOnTopSettings::settings()->enableFrame)
|
||||
{
|
||||
auto border = WindowBorder::Create(window, m_hinstance);
|
||||
if (border)
|
||||
@@ -352,11 +355,13 @@ void AlwaysOnTop::RegisterHotkey() const
|
||||
UnregisterHotKey(m_window, static_cast<int>(HotkeyId::IncreaseOpacity));
|
||||
UnregisterHotKey(m_window, static_cast<int>(HotkeyId::DecreaseOpacity));
|
||||
|
||||
const auto settings = AlwaysOnTopSettings::settings();
|
||||
|
||||
// Register pin hotkey
|
||||
RegisterHotKey(m_window, static_cast<int>(HotkeyId::Pin), AlwaysOnTopSettings::settings().hotkey.get_modifiers(), AlwaysOnTopSettings::settings().hotkey.get_code());
|
||||
RegisterHotKey(m_window, static_cast<int>(HotkeyId::Pin), settings->hotkey.get_modifiers(), settings->hotkey.get_code());
|
||||
|
||||
// Register transparency hotkeys using the same modifiers as the pin hotkey
|
||||
UINT modifiers = AlwaysOnTopSettings::settings().hotkey.get_modifiers();
|
||||
UINT modifiers = settings->hotkey.get_modifiers();
|
||||
RegisterHotKey(m_window, static_cast<int>(HotkeyId::IncreaseOpacity), modifiers, VK_OEM_PLUS);
|
||||
RegisterHotKey(m_window, static_cast<int>(HotkeyId::DecreaseOpacity), modifiers, VK_OEM_MINUS);
|
||||
}
|
||||
@@ -472,7 +477,7 @@ void AlwaysOnTop::SubscribeToEvents()
|
||||
}
|
||||
}
|
||||
|
||||
UpdateSystemMenuEventHooks(AlwaysOnTopSettings::settings().showInSystemMenu);
|
||||
UpdateSystemMenuEventHooks(AlwaysOnTopSettings::settings()->showInSystemMenu);
|
||||
}
|
||||
|
||||
void AlwaysOnTop::UpdateSystemMenuEventHooks(bool enable)
|
||||
@@ -525,7 +530,8 @@ void AlwaysOnTop::UpdateSystemMenuItem(HWND window) const noexcept
|
||||
return;
|
||||
}
|
||||
|
||||
if (!AlwaysOnTopSettings::settings().showInSystemMenu)
|
||||
const auto settings = AlwaysOnTopSettings::settings();
|
||||
if (!settings->showInSystemMenu)
|
||||
{
|
||||
if (IsAlwaysOnTopMenuCommand(systemMenu))
|
||||
{
|
||||
@@ -644,7 +650,7 @@ void AlwaysOnTop::HandleWinHookEvent(WinHookEvent* data) noexcept
|
||||
{
|
||||
if (data->idObject == OBJID_SYSMENU && data->hwnd)
|
||||
{
|
||||
m_lastSystemMenuWindow = AlwaysOnTopSettings::settings().showInSystemMenu ? data->hwnd : nullptr;
|
||||
m_lastSystemMenuWindow = AlwaysOnTopSettings::settings()->showInSystemMenu ? data->hwnd : nullptr;
|
||||
UpdateSystemMenuItem(data->hwnd);
|
||||
}
|
||||
}
|
||||
@@ -659,7 +665,7 @@ void AlwaysOnTop::HandleWinHookEvent(WinHookEvent* data) noexcept
|
||||
return;
|
||||
case EVENT_OBJECT_INVOKED:
|
||||
{
|
||||
if (!AlwaysOnTopSettings::settings().showInSystemMenu)
|
||||
if (!AlwaysOnTopSettings::settings()->showInSystemMenu)
|
||||
{
|
||||
return;
|
||||
}
|
||||
@@ -710,7 +716,7 @@ void AlwaysOnTop::HandleWinHookEvent(WinHookEvent* data) noexcept
|
||||
break;
|
||||
}
|
||||
|
||||
if (!AlwaysOnTopSettings::settings().enableFrame || !data->hwnd)
|
||||
if (!AlwaysOnTopSettings::settings()->enableFrame || !data->hwnd)
|
||||
{
|
||||
return;
|
||||
}
|
||||
@@ -879,7 +885,7 @@ void AlwaysOnTop::StepWindowTransparency(HWND window, int delta)
|
||||
{
|
||||
ApplyWindowAlpha(targetWindow, newTransparency);
|
||||
|
||||
if (AlwaysOnTopSettings::settings().enableSound)
|
||||
if (AlwaysOnTopSettings::settings()->enableSound)
|
||||
{
|
||||
m_sound.Play(delta > 0 ? Sound::Type::IncreaseOpacity : Sound::Type::DecreaseOpacity);
|
||||
}
|
||||
|
||||
@@ -44,12 +44,14 @@ inline COLORREF HexToRGB(std::wstring_view hex, const COLORREF fallbackColor = R
|
||||
}
|
||||
}
|
||||
|
||||
AlwaysOnTopSettings::AlwaysOnTopSettings()
|
||||
AlwaysOnTopSettings::AlwaysOnTopSettings() :
|
||||
m_settings(std::make_shared<Settings>())
|
||||
{
|
||||
m_uiSettings.ColorValuesChanged([&](winrt::Windows::UI::ViewManagement::UISettings const& settings,
|
||||
winrt::Windows::Foundation::IInspectable const& args)
|
||||
{
|
||||
if (m_settings.frameAccentColor)
|
||||
const auto currentSettings = AlwaysOnTopSettings::settings();
|
||||
if (currentSettings->frameAccentColor)
|
||||
{
|
||||
NotifyObservers(SettingId::FrameAccentColor);
|
||||
}
|
||||
@@ -95,94 +97,97 @@ void AlwaysOnTopSettings::LoadSettings()
|
||||
try
|
||||
{
|
||||
PowerToysSettings::PowerToyValues values = PowerToysSettings::PowerToyValues::load_from_settings_file(NonLocalizable::ModuleKey);
|
||||
const auto currentSettings = AlwaysOnTopSettings::settings();
|
||||
auto updatedSettings = std::make_shared<Settings>(*currentSettings);
|
||||
std::vector<SettingId> changedSettings;
|
||||
|
||||
if (const auto jsonVal = values.get_json(NonLocalizable::HotkeyID))
|
||||
{
|
||||
auto val = PowerToysSettings::HotkeyObject::from_json(*jsonVal);
|
||||
if (m_settings.hotkey.get_modifiers() != val.get_modifiers() || m_settings.hotkey.get_key() != val.get_key() || m_settings.hotkey.get_code() != val.get_code())
|
||||
if (updatedSettings->hotkey.get_modifiers() != val.get_modifiers() || updatedSettings->hotkey.get_key() != val.get_key() || updatedSettings->hotkey.get_code() != val.get_code())
|
||||
{
|
||||
m_settings.hotkey = val;
|
||||
NotifyObservers(SettingId::Hotkey);
|
||||
updatedSettings->hotkey = val;
|
||||
changedSettings.push_back(SettingId::Hotkey);
|
||||
}
|
||||
}
|
||||
|
||||
if (const auto jsonVal = values.get_bool_value(NonLocalizable::SoundEnabledID))
|
||||
{
|
||||
auto val = *jsonVal;
|
||||
if (m_settings.enableSound != val)
|
||||
if (updatedSettings->enableSound != val)
|
||||
{
|
||||
m_settings.enableSound = val;
|
||||
NotifyObservers(SettingId::SoundEnabled);
|
||||
updatedSettings->enableSound = val;
|
||||
changedSettings.push_back(SettingId::SoundEnabled);
|
||||
}
|
||||
}
|
||||
|
||||
if (const auto jsonVal = values.get_bool_value(NonLocalizable::ShowInSystemMenuID))
|
||||
{
|
||||
auto val = *jsonVal;
|
||||
if (m_settings.showInSystemMenu != val)
|
||||
if (updatedSettings->showInSystemMenu != val)
|
||||
{
|
||||
m_settings.showInSystemMenu = val;
|
||||
NotifyObservers(SettingId::ShowInSystemMenu);
|
||||
updatedSettings->showInSystemMenu = val;
|
||||
changedSettings.push_back(SettingId::ShowInSystemMenu);
|
||||
}
|
||||
}
|
||||
|
||||
if (const auto jsonVal = values.get_int_value(NonLocalizable::FrameThicknessID))
|
||||
{
|
||||
auto val = *jsonVal;
|
||||
if (m_settings.frameThickness != val)
|
||||
if (updatedSettings->frameThickness != val)
|
||||
{
|
||||
m_settings.frameThickness = val;
|
||||
NotifyObservers(SettingId::FrameThickness);
|
||||
updatedSettings->frameThickness = val;
|
||||
changedSettings.push_back(SettingId::FrameThickness);
|
||||
}
|
||||
}
|
||||
|
||||
if (const auto jsonVal = values.get_string_value(NonLocalizable::FrameColorID))
|
||||
{
|
||||
auto val = HexToRGB(*jsonVal);
|
||||
if (m_settings.frameColor != val)
|
||||
if (updatedSettings->frameColor != val)
|
||||
{
|
||||
m_settings.frameColor = val;
|
||||
NotifyObservers(SettingId::FrameColor);
|
||||
updatedSettings->frameColor = val;
|
||||
changedSettings.push_back(SettingId::FrameColor);
|
||||
}
|
||||
}
|
||||
|
||||
if (const auto jsonVal = values.get_int_value(NonLocalizable::FrameOpacityID))
|
||||
{
|
||||
auto val = *jsonVal;
|
||||
if (m_settings.frameOpacity != val)
|
||||
if (updatedSettings->frameOpacity != val)
|
||||
{
|
||||
m_settings.frameOpacity = val;
|
||||
NotifyObservers(SettingId::FrameOpacity);
|
||||
updatedSettings->frameOpacity = val;
|
||||
changedSettings.push_back(SettingId::FrameOpacity);
|
||||
}
|
||||
}
|
||||
|
||||
if (const auto jsonVal = values.get_bool_value(NonLocalizable::FrameEnabledID))
|
||||
{
|
||||
auto val = *jsonVal;
|
||||
if (m_settings.enableFrame != val)
|
||||
if (updatedSettings->enableFrame != val)
|
||||
{
|
||||
m_settings.enableFrame = val;
|
||||
NotifyObservers(SettingId::FrameEnabled);
|
||||
updatedSettings->enableFrame = val;
|
||||
changedSettings.push_back(SettingId::FrameEnabled);
|
||||
}
|
||||
}
|
||||
|
||||
if (const auto jsonVal = values.get_bool_value(NonLocalizable::BlockInGameModeID))
|
||||
{
|
||||
auto val = *jsonVal;
|
||||
if (m_settings.blockInGameMode != val)
|
||||
if (updatedSettings->blockInGameMode != val)
|
||||
{
|
||||
m_settings.blockInGameMode = val;
|
||||
NotifyObservers(SettingId::BlockInGameMode);
|
||||
updatedSettings->blockInGameMode = val;
|
||||
changedSettings.push_back(SettingId::BlockInGameMode);
|
||||
}
|
||||
}
|
||||
|
||||
if (const auto jsonVal = values.get_bool_value(NonLocalizable::RoundCornersEnabledID))
|
||||
{
|
||||
auto val = *jsonVal;
|
||||
if (m_settings.roundCornersEnabled != val)
|
||||
if (updatedSettings->roundCornersEnabled != val)
|
||||
{
|
||||
m_settings.roundCornersEnabled = val;
|
||||
NotifyObservers(SettingId::RoundCornersEnabled);
|
||||
updatedSettings->roundCornersEnabled = val;
|
||||
changedSettings.push_back(SettingId::RoundCornersEnabled);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -203,20 +208,29 @@ void AlwaysOnTopSettings::LoadSettings()
|
||||
view = left_trim<wchar_t>(trim<wchar_t>(view));
|
||||
}
|
||||
|
||||
if (m_settings.excludedApps != excludedApps)
|
||||
if (updatedSettings->excludedApps != excludedApps)
|
||||
{
|
||||
m_settings.excludedApps = excludedApps;
|
||||
NotifyObservers(SettingId::ExcludeApps);
|
||||
updatedSettings->excludedApps = excludedApps;
|
||||
changedSettings.push_back(SettingId::ExcludeApps);
|
||||
}
|
||||
}
|
||||
|
||||
if (const auto jsonVal = values.get_bool_value(NonLocalizable::FrameAccentColor))
|
||||
{
|
||||
auto val = *jsonVal;
|
||||
if (m_settings.frameAccentColor != val)
|
||||
if (updatedSettings->frameAccentColor != val)
|
||||
{
|
||||
m_settings.frameAccentColor = val;
|
||||
NotifyObservers(SettingId::FrameAccentColor);
|
||||
updatedSettings->frameAccentColor = val;
|
||||
changedSettings.push_back(SettingId::FrameAccentColor);
|
||||
}
|
||||
}
|
||||
|
||||
if (!changedSettings.empty())
|
||||
{
|
||||
m_settings.store(std::shared_ptr<const Settings>(updatedSettings), std::memory_order_release);
|
||||
for (const auto changedSetting : changedSettings)
|
||||
{
|
||||
NotifyObservers(changedSetting);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
#pragma once
|
||||
|
||||
#include <atomic>
|
||||
#include <memory>
|
||||
#include <unordered_set>
|
||||
#include <vector>
|
||||
|
||||
#include <common/SettingsAPI/FileWatcher.h>
|
||||
#include <common/SettingsAPI/settings_objects.h>
|
||||
@@ -34,9 +37,9 @@ class AlwaysOnTopSettings
|
||||
{
|
||||
public:
|
||||
static AlwaysOnTopSettings& instance();
|
||||
static inline const Settings& settings()
|
||||
static inline std::shared_ptr<const Settings> settings()
|
||||
{
|
||||
return instance().m_settings;
|
||||
return instance().m_settings.load(std::memory_order_acquire);
|
||||
}
|
||||
|
||||
void InitFileWatcher();
|
||||
@@ -52,7 +55,7 @@ private:
|
||||
~AlwaysOnTopSettings() = default;
|
||||
|
||||
winrt::Windows::UI::ViewManagement::UISettings m_uiSettings;
|
||||
Settings m_settings;
|
||||
std::atomic<std::shared_ptr<const Settings>> m_settings;
|
||||
std::unique_ptr<FileWatcher> m_settingsFileWatcher;
|
||||
std::unordered_set<SettingsObserver*> m_observers;
|
||||
|
||||
|
||||
@@ -23,7 +23,7 @@ std::optional<RECT> GetFrameRect(HWND window)
|
||||
return std::nullopt;
|
||||
}
|
||||
|
||||
int border = AlwaysOnTopSettings::settings().frameThickness;
|
||||
int border = AlwaysOnTopSettings::settings()->frameThickness;
|
||||
rect.top -= border;
|
||||
rect.left -= border;
|
||||
rect.right += border;
|
||||
@@ -194,8 +194,9 @@ void WindowBorder::UpdateBorderProperties() const
|
||||
|
||||
RECT frameRect{ 0, 0, windowRect.right - windowRect.left, windowRect.bottom - windowRect.top };
|
||||
|
||||
const auto settings = AlwaysOnTopSettings::settings();
|
||||
COLORREF color;
|
||||
if (AlwaysOnTopSettings::settings().frameAccentColor)
|
||||
if (settings->frameAccentColor)
|
||||
{
|
||||
winrt::Windows::UI::ViewManagement::UISettings settings;
|
||||
auto accentValue = settings.GetColorValue(winrt::Windows::UI::ViewManagement::UIColorType::Accent);
|
||||
@@ -203,14 +204,14 @@ void WindowBorder::UpdateBorderProperties() const
|
||||
}
|
||||
else
|
||||
{
|
||||
color = AlwaysOnTopSettings::settings().frameColor;
|
||||
color = settings->frameColor;
|
||||
}
|
||||
|
||||
float opacity = AlwaysOnTopSettings::settings().frameOpacity / 100.0f;
|
||||
float opacity = settings->frameOpacity / 100.0f;
|
||||
float scalingFactor = ScalingUtils::ScalingFactor(m_trackingWindow);
|
||||
float thickness = AlwaysOnTopSettings::settings().frameThickness * scalingFactor;
|
||||
float thickness = settings->frameThickness * scalingFactor;
|
||||
float cornerRadius = 0.0;
|
||||
if (AlwaysOnTopSettings::settings().roundCornersEnabled)
|
||||
if (settings->roundCornersEnabled)
|
||||
{
|
||||
cornerRadius = WindowCornerUtils::CornersRadius(m_trackingWindow) * scalingFactor;
|
||||
}
|
||||
@@ -268,7 +269,7 @@ LRESULT WindowBorder::WndProc(UINT message, WPARAM wparam, LPARAM lparam) noexce
|
||||
|
||||
void WindowBorder::SettingsUpdate(SettingId id)
|
||||
{
|
||||
if (!AlwaysOnTopSettings::settings().enableFrame)
|
||||
if (!AlwaysOnTopSettings::settings()->enableFrame)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
<ManagePackageVersionsCentrally>true</ManagePackageVersionsCentrally>
|
||||
</PropertyGroup>
|
||||
<ItemGroup>
|
||||
<PackageVersion Include="Microsoft.CommandPalette.Extensions" Version="0.5.250829002" />
|
||||
<PackageVersion Include="Microsoft.CommandPalette.Extensions" Version="0.9.260303001" />
|
||||
<PackageVersion Include="Microsoft.CodeAnalysis.NetAnalyzers" Version="9.0.0-preview.24508.2" />
|
||||
<PackageVersion Include="Microsoft.Web.WebView2" Version="1.0.2903.40" />
|
||||
<PackageVersion Include="Microsoft.Windows.CsWin32" Version="0.3.183" />
|
||||
|
||||
@@ -0,0 +1,163 @@
|
||||
// Copyright (c) Microsoft Corporation
|
||||
// The Microsoft Corporation licenses this file to you under the MIT license.
|
||||
// See the LICENSE file in the project root for more information.
|
||||
|
||||
namespace Microsoft.CmdPal.Core.Common.Helpers;
|
||||
|
||||
public sealed partial class ThrottledDebouncedAction : IDisposable
|
||||
{
|
||||
private static readonly TimeSpan DefaultInterval = TimeSpan.FromMilliseconds(150);
|
||||
|
||||
private readonly Lock _lock = new();
|
||||
private readonly Action _action;
|
||||
private readonly TimeSpan _defaultInterval;
|
||||
private readonly bool _runImmediately;
|
||||
|
||||
private CancellationTokenSource? _cts;
|
||||
private bool _isRunning;
|
||||
private bool _isPending;
|
||||
private TimeSpan _pendingInterval;
|
||||
|
||||
public ThrottledDebouncedAction(Action action)
|
||||
: this(action, DefaultInterval)
|
||||
{
|
||||
}
|
||||
|
||||
public ThrottledDebouncedAction(Action action, TimeSpan interval, bool runImmediately = false)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(action);
|
||||
ArgumentOutOfRangeException.ThrowIfLessThan(interval, TimeSpan.Zero);
|
||||
|
||||
_action = action;
|
||||
_defaultInterval = interval;
|
||||
_runImmediately = runImmediately;
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
Cancel();
|
||||
}
|
||||
|
||||
public void Invoke() => Invoke(null);
|
||||
|
||||
public void Invoke(TimeSpan? interval)
|
||||
{
|
||||
var effectiveInterval = interval ?? _defaultInterval;
|
||||
ArgumentOutOfRangeException.ThrowIfLessThan(effectiveInterval, TimeSpan.Zero);
|
||||
|
||||
if (effectiveInterval == TimeSpan.Zero)
|
||||
{
|
||||
Cancel();
|
||||
_action();
|
||||
return;
|
||||
}
|
||||
|
||||
if (!_runImmediately)
|
||||
{
|
||||
// Trailing-edge debounce: each call resets the delay with the new interval.
|
||||
CancellationTokenSource? oldCts;
|
||||
CancellationToken token;
|
||||
|
||||
lock (_lock)
|
||||
{
|
||||
oldCts = _cts;
|
||||
_cts = new CancellationTokenSource();
|
||||
token = _cts.Token;
|
||||
}
|
||||
|
||||
oldCts?.Cancel();
|
||||
oldCts?.Dispose();
|
||||
|
||||
_ = Task.Run(
|
||||
async () =>
|
||||
{
|
||||
try
|
||||
{
|
||||
await Task.Delay(effectiveInterval, token).ConfigureAwait(false);
|
||||
if (token.IsCancellationRequested)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_action();
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
// expected during reschedules/dispose
|
||||
}
|
||||
},
|
||||
CancellationToken.None);
|
||||
}
|
||||
else
|
||||
{
|
||||
// Leading + Trailing throttle/debounce
|
||||
lock (_lock)
|
||||
{
|
||||
if (_isRunning)
|
||||
{
|
||||
_isPending = true;
|
||||
_pendingInterval = effectiveInterval;
|
||||
return;
|
||||
}
|
||||
|
||||
_isRunning = true;
|
||||
}
|
||||
|
||||
_action();
|
||||
|
||||
_ = Task.Run(async () =>
|
||||
{
|
||||
while (true)
|
||||
{
|
||||
TimeSpan delayInterval;
|
||||
lock (_lock)
|
||||
{
|
||||
// Snapshot the interval to use for this cooldown.
|
||||
// If no pending call yet, use the interval from the
|
||||
// leading invocation; otherwise use the most recent
|
||||
// pending interval (which may be updated by new calls
|
||||
// arriving during the delay).
|
||||
delayInterval = _isPending ? _pendingInterval : effectiveInterval;
|
||||
}
|
||||
|
||||
await Task.Delay(delayInterval).ConfigureAwait(false);
|
||||
|
||||
bool shouldRun;
|
||||
lock (_lock)
|
||||
{
|
||||
if (!_isPending)
|
||||
{
|
||||
_isRunning = false;
|
||||
return;
|
||||
}
|
||||
|
||||
_isPending = false;
|
||||
shouldRun = true;
|
||||
}
|
||||
|
||||
if (shouldRun)
|
||||
{
|
||||
_action();
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
public void InvokeImmediately() => Invoke(TimeSpan.Zero);
|
||||
|
||||
public void Cancel()
|
||||
{
|
||||
CancellationTokenSource? toCancel;
|
||||
lock (_lock)
|
||||
{
|
||||
toCancel = _cts;
|
||||
_cts = null;
|
||||
_isPending = false;
|
||||
_isRunning = false;
|
||||
}
|
||||
|
||||
toCancel?.Cancel();
|
||||
toCancel?.Dispose();
|
||||
}
|
||||
}
|
||||
Binary file not shown.
@@ -15,7 +15,7 @@ internal static class BatchUpdateManager
|
||||
// 30 ms chosen empirically to balance responsiveness and batching:
|
||||
// - Keeps perceived latency low (< ~50 ms) for user-visible updates.
|
||||
// - Still allows multiple COM/background events to be coalesced into a single batch.
|
||||
private static readonly TimeSpan BatchDelay = TimeSpan.FromMilliseconds(30);
|
||||
private static readonly TimeSpan BatchDelay = TimeSpan.FromMilliseconds(40);
|
||||
private static readonly ConcurrentQueue<IBatchUpdateTarget> DirtyQueue = [];
|
||||
private static readonly Timer Timer = new(static _ => Flush(), null, Timeout.InfiniteTimeSpan, Timeout.InfiniteTimeSpan);
|
||||
|
||||
|
||||
@@ -122,11 +122,9 @@ public sealed partial class CommandBarViewModel : ObservableObject,
|
||||
}
|
||||
|
||||
SecondaryCommand = SelectedItem.SecondaryCommand;
|
||||
var moreCommands = SelectedItem.MoreCommands;
|
||||
|
||||
var hasMoreThanOneContextItem = SelectedItem.MoreCommands.Count() > 1;
|
||||
var hasMoreThanOneCommand = SelectedItem.MoreCommands.OfType<CommandContextItemViewModel>().Any();
|
||||
|
||||
ShouldShowContextMenu = hasMoreThanOneContextItem && hasMoreThanOneCommand;
|
||||
ShouldShowContextMenu = moreCommands.Count > 1 && SelectedItem.HasMoreCommands;
|
||||
|
||||
OnPropertyChanged(nameof(HasSecondaryCommand));
|
||||
OnPropertyChanged(nameof(SecondaryCommand));
|
||||
|
||||
@@ -21,6 +21,12 @@ public partial class CommandItemViewModel : ExtensionObjectViewModel, ICommandBa
|
||||
|
||||
private readonly IContextMenuFactory? _contextMenuFactory;
|
||||
|
||||
private readonly Lock _moreCommandsLock = new();
|
||||
private readonly List<IContextItemViewModel> _moreCommands = [];
|
||||
private volatile CommandContextItemViewModel? _secondaryMoreCommand;
|
||||
private volatile IContextItemViewModel[] _moreCommandsSnapshot = [];
|
||||
private volatile IContextItemViewModel[] _allCommandsSnapshot = [];
|
||||
|
||||
private ExtensionObject<IExtendedAttributesProvider>? ExtendedAttributesProvider { get; set; }
|
||||
|
||||
private readonly ExtensionObject<ICommandItem> _commandItemModel = new(null);
|
||||
@@ -63,33 +69,22 @@ public partial class CommandItemViewModel : ExtensionObjectViewModel, ICommandBa
|
||||
|
||||
public CommandViewModel Command { get; private set; }
|
||||
|
||||
public List<IContextItemViewModel> MoreCommands { get; private set; } = [];
|
||||
// Reuse a cached read-only snapshot so repeated reads don't allocate.
|
||||
public IReadOnlyList<IContextItemViewModel> MoreCommands => _moreCommandsSnapshot;
|
||||
|
||||
IEnumerable<IContextItemViewModel> IContextMenuContext.MoreCommands => MoreCommands;
|
||||
IReadOnlyList<IContextItemViewModel> IContextMenuContext.MoreCommands => _moreCommandsSnapshot;
|
||||
|
||||
private List<CommandContextItemViewModel> ActualCommands => MoreCommands.OfType<CommandContextItemViewModel>().ToList();
|
||||
protected Lock MoreCommandsLock => _moreCommandsLock;
|
||||
|
||||
public bool HasMoreCommands => ActualCommands.Count > 0;
|
||||
protected List<IContextItemViewModel> UnsafeMoreCommands => _moreCommands;
|
||||
|
||||
public string SecondaryCommandName => SecondaryCommand?.Name ?? string.Empty;
|
||||
public bool HasMoreCommands => _secondaryMoreCommand is not null;
|
||||
|
||||
public string SecondaryCommandName => _secondaryMoreCommand?.Name ?? string.Empty;
|
||||
|
||||
public CommandItemViewModel? PrimaryCommand => this;
|
||||
|
||||
public CommandItemViewModel? SecondaryCommand
|
||||
{
|
||||
get
|
||||
{
|
||||
if (HasMoreCommands)
|
||||
{
|
||||
if (MoreCommands[0] is CommandContextItemViewModel command)
|
||||
{
|
||||
return command;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
public CommandItemViewModel? SecondaryCommand => _secondaryMoreCommand;
|
||||
|
||||
public bool ShouldBeVisible => !string.IsNullOrEmpty(Name);
|
||||
|
||||
@@ -101,18 +96,7 @@ public partial class CommandItemViewModel : ExtensionObjectViewModel, ICommandBa
|
||||
|
||||
public DataPackageView? DataPackage { get; private set; }
|
||||
|
||||
public List<IContextItemViewModel> AllCommands
|
||||
{
|
||||
get
|
||||
{
|
||||
List<IContextItemViewModel> l = _defaultCommandContextItemViewModel is null ?
|
||||
new() :
|
||||
[_defaultCommandContextItemViewModel];
|
||||
|
||||
l.AddRange(MoreCommands);
|
||||
return l;
|
||||
}
|
||||
}
|
||||
public IReadOnlyList<IContextItemViewModel> AllCommands => _allCommandsSnapshot;
|
||||
|
||||
private static readonly IconInfoViewModel _errorIcon;
|
||||
|
||||
@@ -246,6 +230,11 @@ public partial class CommandItemViewModel : ExtensionObjectViewModel, ICommandBa
|
||||
UpdateDefaultContextItemIcon();
|
||||
}
|
||||
|
||||
lock (_moreCommandsLock)
|
||||
{
|
||||
RefreshMoreCommandStateUnsafe();
|
||||
}
|
||||
|
||||
Initialized |= InitializedState.SelectionInitialized;
|
||||
UpdateProperty(nameof(MoreCommands));
|
||||
UpdateProperty(nameof(AllCommands));
|
||||
@@ -265,7 +254,7 @@ public partial class CommandItemViewModel : ExtensionObjectViewModel, ICommandBa
|
||||
Command = new(null, PageContext);
|
||||
_itemTitle = "Error";
|
||||
Subtitle = "Item failed to load";
|
||||
MoreCommands = [];
|
||||
ClearMoreCommands();
|
||||
_icon = _errorIcon;
|
||||
_titleCache.Invalidate();
|
||||
_subtitleCache.Invalidate();
|
||||
@@ -304,7 +293,7 @@ public partial class CommandItemViewModel : ExtensionObjectViewModel, ICommandBa
|
||||
Command = new(null, PageContext);
|
||||
_itemTitle = "Error";
|
||||
Subtitle = "Item failed to load";
|
||||
MoreCommands = [];
|
||||
ClearMoreCommands();
|
||||
_icon = _errorIcon;
|
||||
_titleCache.Invalidate();
|
||||
_subtitleCache.Invalidate();
|
||||
@@ -385,9 +374,7 @@ public partial class CommandItemViewModel : ExtensionObjectViewModel, ICommandBa
|
||||
|
||||
case nameof(model.MoreCommands):
|
||||
BuildAndInitMoreCommands();
|
||||
UpdateProperty(nameof(SecondaryCommand));
|
||||
UpdateProperty(nameof(SecondaryCommandName));
|
||||
UpdateProperty(nameof(HasMoreCommands));
|
||||
UpdateProperty(nameof(SecondaryCommand), nameof(SecondaryCommandName), nameof(HasMoreCommands), nameof(AllCommands));
|
||||
|
||||
break;
|
||||
case nameof(DataPackage):
|
||||
@@ -478,9 +465,10 @@ public partial class CommandItemViewModel : ExtensionObjectViewModel, ICommandBa
|
||||
var results = factory.UnsafeBuildAndInitMoreCommands(more, this);
|
||||
|
||||
List<IContextItemViewModel>? freedItems;
|
||||
lock (MoreCommands)
|
||||
lock (_moreCommandsLock)
|
||||
{
|
||||
ListHelpers.InPlaceUpdateList(MoreCommands, results, out freedItems);
|
||||
ListHelpers.InPlaceUpdateList(_moreCommands, results, out freedItems);
|
||||
RefreshMoreCommandStateUnsafe();
|
||||
}
|
||||
|
||||
freedItems.OfType<CommandContextItemViewModel>()
|
||||
@@ -516,20 +504,30 @@ public partial class CommandItemViewModel : ExtensionObjectViewModel, ICommandBa
|
||||
{
|
||||
base.UnsafeCleanup();
|
||||
|
||||
lock (MoreCommands)
|
||||
List<IContextItemViewModel> freedItems;
|
||||
CommandContextItemViewModel? freedDefault;
|
||||
lock (_moreCommandsLock)
|
||||
{
|
||||
MoreCommands.OfType<CommandContextItemViewModel>()
|
||||
.ToList()
|
||||
.ForEach(c => c.SafeCleanup());
|
||||
MoreCommands.Clear();
|
||||
freedItems = [.. _moreCommands];
|
||||
_moreCommands.Clear();
|
||||
|
||||
// Null out here so the single RefreshMoreCommandStateUnsafe call
|
||||
// produces an _allCommandsSnapshot that excludes the default command.
|
||||
freedDefault = _defaultCommandContextItemViewModel;
|
||||
_defaultCommandContextItemViewModel = null;
|
||||
|
||||
RefreshMoreCommandStateUnsafe();
|
||||
}
|
||||
|
||||
// Cleanup outside lock to avoid holding it during RPC calls
|
||||
freedItems.OfType<CommandContextItemViewModel>()
|
||||
.ToList()
|
||||
.ForEach(c => c.SafeCleanup());
|
||||
freedDefault?.SafeCleanup();
|
||||
|
||||
// _listItemIcon.SafeCleanup();
|
||||
_icon = new(null); // necessary?
|
||||
|
||||
_defaultCommandContextItemViewModel?.SafeCleanup();
|
||||
_defaultCommandContextItemViewModel = null;
|
||||
|
||||
Command.PropertyChanged -= Command_PropertyChanged;
|
||||
Command.SafeCleanup();
|
||||
|
||||
@@ -545,6 +543,40 @@ public partial class CommandItemViewModel : ExtensionObjectViewModel, ICommandBa
|
||||
base.SafeCleanup();
|
||||
Initialized |= InitializedState.CleanedUp;
|
||||
}
|
||||
|
||||
protected void RefreshMoreCommandStateUnsafe()
|
||||
{
|
||||
_moreCommandsSnapshot = [.. _moreCommands];
|
||||
|
||||
_secondaryMoreCommand = null;
|
||||
foreach (var item in _moreCommands)
|
||||
{
|
||||
if (item is CommandContextItemViewModel command)
|
||||
{
|
||||
_secondaryMoreCommand = command;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
_allCommandsSnapshot = _defaultCommandContextItemViewModel is null ?
|
||||
_moreCommandsSnapshot :
|
||||
[_defaultCommandContextItemViewModel, .. _moreCommandsSnapshot];
|
||||
}
|
||||
|
||||
private void ClearMoreCommands()
|
||||
{
|
||||
List<IContextItemViewModel> freedItems;
|
||||
lock (_moreCommandsLock)
|
||||
{
|
||||
freedItems = [.. _moreCommands];
|
||||
_moreCommands.Clear();
|
||||
RefreshMoreCommandStateUnsafe();
|
||||
}
|
||||
|
||||
freedItems.OfType<CommandContextItemViewModel>()
|
||||
.ToList()
|
||||
.ForEach(c => c.SafeCleanup());
|
||||
}
|
||||
}
|
||||
|
||||
[Flags]
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
// The Microsoft Corporation licenses this file to you under the MIT license.
|
||||
// See the LICENSE file in the project root for more information.
|
||||
|
||||
using Microsoft.CmdPal.UI.ViewModels.MainPage;
|
||||
using Microsoft.CommandPalette.Extensions;
|
||||
|
||||
namespace Microsoft.CmdPal.UI.ViewModels;
|
||||
@@ -22,6 +23,7 @@ public class CommandPalettePageViewModelFactory
|
||||
{
|
||||
return page switch
|
||||
{
|
||||
MainListPage listPage => new ListViewModel(listPage, _scheduler, host, providerContext, _contextMenuFactory) { IsRootPage = !nested, IsMainPage = true },
|
||||
IListPage listPage => new ListViewModel(listPage, _scheduler, host, providerContext, _contextMenuFactory) { IsRootPage = !nested },
|
||||
IContentPage contentPage => new CommandPaletteContentPageViewModel(contentPage, _scheduler, host, providerContext),
|
||||
_ => null,
|
||||
|
||||
@@ -2,13 +2,17 @@
|
||||
// The Microsoft Corporation licenses this file to you under the MIT license.
|
||||
// See the LICENSE file in the project root for more information.
|
||||
|
||||
using System.Collections.Immutable;
|
||||
/*
|
||||
#define CMDPAL_FF_MAINPAGE_TIME_RAISE_ITEMS
|
||||
*/
|
||||
|
||||
using System.Collections.Specialized;
|
||||
using System.Diagnostics;
|
||||
using CommunityToolkit.Mvvm.Messaging;
|
||||
using ManagedCommon;
|
||||
using Microsoft.CmdPal.Common.Helpers;
|
||||
using Microsoft.CmdPal.Common.Text;
|
||||
using Microsoft.CmdPal.Core.Common.Helpers;
|
||||
using Microsoft.CmdPal.Ext.Apps;
|
||||
using Microsoft.CmdPal.Ext.Apps.Programs;
|
||||
using Microsoft.CmdPal.UI.ViewModels.Commands;
|
||||
@@ -25,8 +29,17 @@ namespace Microsoft.CmdPal.UI.ViewModels.MainPage;
|
||||
/// </summary>
|
||||
public sealed partial class MainListPage : DynamicListPage,
|
||||
IRecipient<ClearSearchMessage>,
|
||||
IRecipient<UpdateFallbackItemsMessage>, IDisposable
|
||||
IRecipient<UpdateFallbackItemsMessage>,
|
||||
IDisposable
|
||||
{
|
||||
// Throttle for raising items changed events from external sources
|
||||
private static readonly TimeSpan RaiseItemsChangedThrottle = TimeSpan.FromMilliseconds(100);
|
||||
|
||||
// Throttle for raising items changed events from user input - we want this to feel more responsive, so a shorter throttle.
|
||||
private static readonly TimeSpan RaiseItemsChangedThrottleForUserInput = TimeSpan.FromMilliseconds(50);
|
||||
|
||||
private readonly FallbackUpdateManager _fallbackUpdateManager;
|
||||
private readonly ThrottledDebouncedAction _refreshThrottledDebouncedAction;
|
||||
private readonly TopLevelCommandManager _tlcManager;
|
||||
private readonly AliasManager _aliasManager;
|
||||
private readonly SettingsModel _settings;
|
||||
@@ -54,11 +67,16 @@ public sealed partial class MainListPage : DynamicListPage,
|
||||
|
||||
private int AppResultLimit => AllAppsCommandProvider.TopLevelResultLimit;
|
||||
|
||||
private InterlockedBoolean _fullRefreshRequested;
|
||||
private InterlockedBoolean _refreshRunning;
|
||||
private InterlockedBoolean _refreshRequested;
|
||||
|
||||
private CancellationTokenSource? _cancellationTokenSource;
|
||||
|
||||
#if CMDPAL_FF_MAINPAGE_TIME_RAISE_ITEMS
|
||||
private DateTimeOffset _last = DateTimeOffset.UtcNow;
|
||||
#endif
|
||||
|
||||
public MainListPage(
|
||||
TopLevelCommandManager topLevelCommandManager,
|
||||
SettingsModel settings,
|
||||
@@ -68,7 +86,7 @@ public sealed partial class MainListPage : DynamicListPage,
|
||||
{
|
||||
Id = "com.microsoft.cmdpal.home";
|
||||
Title = Resources.builtin_home_name;
|
||||
Icon = IconHelpers.FromRelativePath("Assets\\StoreLogo.scale-200.png");
|
||||
Icon = IconHelpers.FromRelativePath("Assets\\Square44x44Logo.altform-unplated_targetsize-256.png");
|
||||
PlaceholderText = Properties.Resources.builtin_main_list_page_searchbar_placeholder;
|
||||
|
||||
_settings = settings;
|
||||
@@ -82,16 +100,52 @@ public sealed partial class MainListPage : DynamicListPage,
|
||||
_tlcManager.PropertyChanged += TlcManager_PropertyChanged;
|
||||
_tlcManager.TopLevelCommands.CollectionChanged += Commands_CollectionChanged;
|
||||
|
||||
_refreshThrottledDebouncedAction = new ThrottledDebouncedAction(
|
||||
() =>
|
||||
{
|
||||
try
|
||||
{
|
||||
#if CMDPAL_FF_MAINPAGE_TIME_RAISE_ITEMS
|
||||
var delta = DateTimeOffset.UtcNow - _last;
|
||||
_last = DateTimeOffset.UtcNow;
|
||||
Logger.LogDebug($"UpdateFallbacks: RaiseItemsChanged, delta {delta}");
|
||||
|
||||
var sw = Stopwatch.StartNew();
|
||||
#endif
|
||||
if (_fullRefreshRequested.Clear())
|
||||
{
|
||||
// full refresh
|
||||
RaiseItemsChanged();
|
||||
}
|
||||
else
|
||||
{
|
||||
// preserve selection
|
||||
RaiseItemsChanged(ListViewModel.IncrementalRefresh);
|
||||
}
|
||||
|
||||
#if CMDPAL_FF_MAINPAGE_TIME_RAISE_ITEMS
|
||||
Logger.LogInfo($"UpdateFallbacks: RaiseItemsChanged took {sw.Elapsed}");
|
||||
#endif
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Logger.LogError("Unhandled exception in MainListPage refresh debounced action", ex);
|
||||
}
|
||||
},
|
||||
RaiseItemsChangedThrottle);
|
||||
|
||||
_fallbackUpdateManager = new FallbackUpdateManager(() => RequestRefresh(fullRefresh: false));
|
||||
|
||||
// The all apps page will kick off a BG thread to start loading apps.
|
||||
// We just want to know when it is done.
|
||||
var allApps = AllAppsCommandProvider.Page;
|
||||
allApps.PropChanged += (s, p) =>
|
||||
{
|
||||
if (p.PropertyName == nameof(allApps.IsLoading))
|
||||
{
|
||||
if (p.PropertyName == nameof(allApps.IsLoading))
|
||||
{
|
||||
IsLoading = ActuallyLoading();
|
||||
}
|
||||
};
|
||||
IsLoading = ActuallyLoading();
|
||||
}
|
||||
};
|
||||
|
||||
WeakReferenceMessenger.Default.Register<ClearSearchMessage>(this);
|
||||
WeakReferenceMessenger.Default.Register<UpdateFallbackItemsMessage>(this);
|
||||
@@ -120,10 +174,20 @@ public sealed partial class MainListPage : DynamicListPage,
|
||||
}
|
||||
else
|
||||
{
|
||||
RaiseItemsChanged(ListViewModel.IncrementalRefresh);
|
||||
RequestRefresh(fullRefresh: false);
|
||||
}
|
||||
}
|
||||
|
||||
private void RequestRefresh(bool fullRefresh, TimeSpan? interval = null)
|
||||
{
|
||||
if (fullRefresh)
|
||||
{
|
||||
_fullRefreshRequested.Set();
|
||||
}
|
||||
|
||||
_refreshThrottledDebouncedAction.Invoke(interval);
|
||||
}
|
||||
|
||||
private void ReapplySearchInBackground()
|
||||
{
|
||||
_refreshRequested.Set();
|
||||
@@ -151,7 +215,7 @@ public sealed partial class MainListPage : DynamicListPage,
|
||||
}
|
||||
|
||||
var currentSearchText = SearchText;
|
||||
UpdateSearchText(currentSearchText, currentSearchText);
|
||||
UpdateSearchTextCore(currentSearchText, currentSearchText, isUserInput: false);
|
||||
}
|
||||
while (_refreshRequested.Value);
|
||||
}
|
||||
@@ -243,6 +307,11 @@ public sealed partial class MainListPage : DynamicListPage,
|
||||
}
|
||||
|
||||
public override void UpdateSearchText(string oldSearch, string newSearch)
|
||||
{
|
||||
UpdateSearchTextCore(oldSearch, newSearch, isUserInput: true);
|
||||
}
|
||||
|
||||
private void UpdateSearchTextCore(string oldSearch, string newSearch, bool isUserInput)
|
||||
{
|
||||
var stopwatch = Stopwatch.StartNew();
|
||||
|
||||
@@ -297,7 +366,7 @@ public sealed partial class MainListPage : DynamicListPage,
|
||||
// prefilter fallbacks
|
||||
var globalFallbacks = _settings.GetGlobalFallbacks();
|
||||
var specialFallbacks = new List<TopLevelViewModel>(globalFallbacks.Length);
|
||||
var commonFallbacks = new List<TopLevelViewModel>();
|
||||
var commonFallbacks = new List<TopLevelViewModel>(commands.Count - globalFallbacks.Length);
|
||||
|
||||
foreach (var s in commands)
|
||||
{
|
||||
@@ -316,10 +385,7 @@ public sealed partial class MainListPage : DynamicListPage,
|
||||
}
|
||||
}
|
||||
|
||||
// start update of fallbacks; update special fallbacks separately,
|
||||
// so they can finish faster
|
||||
UpdateFallbacks(SearchText, specialFallbacks, token);
|
||||
UpdateFallbacks(SearchText, commonFallbacks, token);
|
||||
_fallbackUpdateManager.BeginUpdate(SearchText, [.. specialFallbacks, .. commonFallbacks], token);
|
||||
|
||||
if (token.IsCancellationRequested)
|
||||
{
|
||||
@@ -327,11 +393,13 @@ public sealed partial class MainListPage : DynamicListPage,
|
||||
}
|
||||
|
||||
// Cleared out the filter text? easy. Reset _filteredItems, and bail out.
|
||||
if (string.IsNullOrEmpty(newSearch))
|
||||
if (string.IsNullOrWhiteSpace(newSearch))
|
||||
{
|
||||
_filteredItemsIncludesApps = _includeApps;
|
||||
ClearResults();
|
||||
RaiseItemsChanged();
|
||||
var wasAlreadyEmpty = string.IsNullOrWhiteSpace(oldSearch);
|
||||
RequestRefresh(fullRefresh: true, interval: wasAlreadyEmpty ? null : TimeSpan.Zero);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -466,49 +534,35 @@ public sealed partial class MainListPage : DynamicListPage,
|
||||
}
|
||||
}
|
||||
|
||||
#if CMDPAL_FF_MAINPAGE_TIME_RAISE_ITEMS
|
||||
var filterDoneTimestamp = stopwatch.ElapsedMilliseconds;
|
||||
Logger.LogDebug($"Filter with '{newSearch}' in {filterDoneTimestamp}ms");
|
||||
#endif
|
||||
if (isUserInput)
|
||||
{
|
||||
// Make sure that the throttle delay is consistent from the user's perspective, even if filtering
|
||||
// takes a long time. If we always use the full throttle duration, then a slow filter could make the UI feel sluggish.
|
||||
var adjustedInterval = RaiseItemsChangedThrottleForUserInput - stopwatch.Elapsed;
|
||||
if (adjustedInterval < TimeSpan.Zero)
|
||||
{
|
||||
adjustedInterval = TimeSpan.Zero;
|
||||
}
|
||||
|
||||
RaiseItemsChanged();
|
||||
RequestRefresh(fullRefresh: true, adjustedInterval);
|
||||
}
|
||||
else
|
||||
{
|
||||
RequestRefresh(fullRefresh: true);
|
||||
}
|
||||
|
||||
#if CMDPAL_FF_MAINPAGE_TIME_RAISE_ITEMS
|
||||
var listPageUpdatedTimestamp = stopwatch.ElapsedMilliseconds;
|
||||
Logger.LogDebug($"Render items with '{newSearch}' in {listPageUpdatedTimestamp}ms /d {listPageUpdatedTimestamp - filterDoneTimestamp}ms");
|
||||
#endif
|
||||
|
||||
stopwatch.Stop();
|
||||
}
|
||||
}
|
||||
|
||||
private void UpdateFallbacks(string newSearch, IReadOnlyList<TopLevelViewModel> commands, CancellationToken token)
|
||||
{
|
||||
_ = Task.Run(
|
||||
() =>
|
||||
{
|
||||
var needsToUpdate = false;
|
||||
|
||||
foreach (var command in commands)
|
||||
{
|
||||
if (token.IsCancellationRequested)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var changedVisibility = command.SafeUpdateFallbackTextSynchronous(newSearch);
|
||||
needsToUpdate = needsToUpdate || changedVisibility;
|
||||
}
|
||||
|
||||
if (needsToUpdate)
|
||||
{
|
||||
if (token.IsCancellationRequested)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
RaiseItemsChanged(ListViewModel.IncrementalRefresh);
|
||||
}
|
||||
},
|
||||
token);
|
||||
}
|
||||
|
||||
private bool ActuallyLoading()
|
||||
{
|
||||
var allApps = AllAppsCommandProvider.Page;
|
||||
@@ -644,7 +698,10 @@ public sealed partial class MainListPage : DynamicListPage,
|
||||
|
||||
public void Receive(ClearSearchMessage message) => SearchText = string.Empty;
|
||||
|
||||
public void Receive(UpdateFallbackItemsMessage message) => RaiseItemsChanged(_tlcManager.TopLevelCommands.Count);
|
||||
public void Receive(UpdateFallbackItemsMessage message)
|
||||
{
|
||||
RequestRefresh(fullRefresh: false);
|
||||
}
|
||||
|
||||
private void SettingsChangedHandler(SettingsModel sender, object? args) => HotReloadSettings(sender);
|
||||
|
||||
@@ -654,6 +711,7 @@ public sealed partial class MainListPage : DynamicListPage,
|
||||
{
|
||||
_cancellationTokenSource?.Cancel();
|
||||
_cancellationTokenSource?.Dispose();
|
||||
_fallbackUpdateManager.Dispose();
|
||||
|
||||
_tlcManager.PropertyChanged -= TlcManager_PropertyChanged;
|
||||
_tlcManager.TopLevelCommands.CollectionChanged -= Commands_CollectionChanged;
|
||||
|
||||
@@ -30,7 +30,8 @@ internal sealed partial class NewExtensionForm : NewExtensionFormBase
|
||||
{
|
||||
"type": "TextBlock",
|
||||
"text": {{FormatJsonString(Properties.Resources.builtin_create_extension_page_title)}},
|
||||
"size": "large"
|
||||
"size": "medium",
|
||||
"weight": "bolder"
|
||||
},
|
||||
{
|
||||
"type": "Input.Text",
|
||||
@@ -122,9 +123,8 @@ internal sealed partial class NewExtensionForm : NewExtensionFormBase
|
||||
}
|
||||
catch (Exception e)
|
||||
{
|
||||
BuiltinsExtensionHost.Instance.HideStatus(_creatingMessage);
|
||||
|
||||
_creatingMessage.State = MessageState.Error;
|
||||
_creatingMessage.Progress = null;
|
||||
_creatingMessage.Message = $"Error: {e.Message}";
|
||||
}
|
||||
|
||||
|
||||
@@ -17,13 +17,15 @@ namespace Microsoft.CmdPal.UI.ViewModels;
|
||||
public partial class ContentPageViewModel : PageViewModel, ICommandBarContext
|
||||
{
|
||||
private readonly ExtensionObject<IContentPage> _model;
|
||||
private readonly Lock _commandsLock = new();
|
||||
private volatile CommandSnapshot _snapshot = CommandSnapshot.Empty;
|
||||
|
||||
[ObservableProperty]
|
||||
public partial ObservableCollection<ContentViewModel> Content { get; set; } = [];
|
||||
|
||||
public List<IContextItemViewModel> Commands { get; private set; } = [];
|
||||
private List<IContextItemViewModel> Commands { get; } = [];
|
||||
|
||||
public bool HasCommands => ActualCommands.Count > 0;
|
||||
public bool HasCommands => _snapshot.PrimaryCommand is not null;
|
||||
|
||||
public DetailsViewModel? Details { get; private set; }
|
||||
|
||||
@@ -31,19 +33,17 @@ public partial class ContentPageViewModel : PageViewModel, ICommandBarContext
|
||||
public bool HasDetails => Details is not null;
|
||||
|
||||
/////// ICommandBarContext ///////
|
||||
public IEnumerable<IContextItemViewModel> MoreCommands => Commands.Skip(1);
|
||||
public IReadOnlyList<IContextItemViewModel> MoreCommands => _snapshot.MoreCommands;
|
||||
|
||||
private List<CommandContextItemViewModel> ActualCommands => Commands.OfType<CommandContextItemViewModel>().ToList();
|
||||
public bool HasMoreCommands => _snapshot.SecondaryCommand is not null;
|
||||
|
||||
public bool HasMoreCommands => ActualCommands.Count > 1;
|
||||
public string SecondaryCommandName => _snapshot.SecondaryCommand?.Name ?? string.Empty;
|
||||
|
||||
public string SecondaryCommandName => SecondaryCommand?.Name ?? string.Empty;
|
||||
public CommandItemViewModel? PrimaryCommand => _snapshot.PrimaryCommand;
|
||||
|
||||
public CommandItemViewModel? PrimaryCommand => HasCommands ? ActualCommands[0] : null;
|
||||
public CommandItemViewModel? SecondaryCommand => _snapshot.SecondaryCommand;
|
||||
|
||||
public CommandItemViewModel? SecondaryCommand => HasMoreCommands ? ActualCommands[1] : null;
|
||||
|
||||
public List<IContextItemViewModel> AllCommands => Commands;
|
||||
public IReadOnlyList<IContextItemViewModel> AllCommands => _snapshot.AllCommands;
|
||||
|
||||
// Remember - "observable" properties from the model (via PropChanged)
|
||||
// cannot be marked [ObservableProperty]
|
||||
@@ -109,28 +109,14 @@ public partial class ContentPageViewModel : PageViewModel, ICommandBarContext
|
||||
return; // throw?
|
||||
}
|
||||
|
||||
Commands = model.Commands
|
||||
.ToList()
|
||||
.Select<IContextItem, IContextItemViewModel>(item =>
|
||||
{
|
||||
if (item is ICommandContextItem contextItem)
|
||||
{
|
||||
return new CommandContextItemViewModel(contextItem, PageContext);
|
||||
}
|
||||
else
|
||||
{
|
||||
return new SeparatorViewModel();
|
||||
}
|
||||
})
|
||||
.ToList();
|
||||
var commands = BuildCommandViewModels(model.Commands);
|
||||
InitializeCommandViewModels(commands, static contextItem => contextItem.InitializeProperties());
|
||||
|
||||
Commands
|
||||
.OfType<CommandContextItemViewModel>()
|
||||
.ToList()
|
||||
.ForEach(contextItem =>
|
||||
{
|
||||
contextItem.InitializeProperties();
|
||||
});
|
||||
lock (_commandsLock)
|
||||
{
|
||||
ListHelpers.InPlaceUpdateList(Commands, commands);
|
||||
RefreshCommandSnapshotsUnsafe();
|
||||
}
|
||||
|
||||
var extensionDetails = model.Details;
|
||||
if (extensionDetails is not null)
|
||||
@@ -168,37 +154,29 @@ public partial class ContentPageViewModel : PageViewModel, ICommandBarContext
|
||||
var more = model.Commands;
|
||||
if (more is not null)
|
||||
{
|
||||
var newContextMenu = more
|
||||
.ToList()
|
||||
.Select(item =>
|
||||
{
|
||||
if (item is ICommandContextItem contextItem)
|
||||
{
|
||||
return new CommandContextItemViewModel(contextItem, PageContext) as IContextItemViewModel;
|
||||
}
|
||||
else
|
||||
{
|
||||
return new SeparatorViewModel();
|
||||
}
|
||||
})
|
||||
.ToList();
|
||||
var newContextMenu = BuildCommandViewModels(more);
|
||||
InitializeCommandViewModels(newContextMenu, static contextItem => contextItem.SlowInitializeProperties());
|
||||
|
||||
lock (Commands)
|
||||
List<IContextItemViewModel> removedItems;
|
||||
lock (_commandsLock)
|
||||
{
|
||||
ListHelpers.InPlaceUpdateList(Commands, newContextMenu);
|
||||
ListHelpers.InPlaceUpdateList(Commands, newContextMenu, out removedItems);
|
||||
RefreshCommandSnapshotsUnsafe();
|
||||
}
|
||||
|
||||
Commands
|
||||
.OfType<CommandContextItemViewModel>()
|
||||
.ToList()
|
||||
.ForEach(contextItem =>
|
||||
{
|
||||
contextItem.SlowInitializeProperties();
|
||||
});
|
||||
CleanupCommandViewModels(removedItems);
|
||||
}
|
||||
else
|
||||
{
|
||||
Commands.Clear();
|
||||
List<IContextItemViewModel> removedItems;
|
||||
lock (_commandsLock)
|
||||
{
|
||||
removedItems = [.. Commands];
|
||||
Commands.Clear();
|
||||
RefreshCommandSnapshotsUnsafe();
|
||||
}
|
||||
|
||||
CleanupCommandViewModels(removedItems);
|
||||
}
|
||||
|
||||
UpdateProperty(nameof(PrimaryCommand));
|
||||
@@ -206,6 +184,7 @@ public partial class ContentPageViewModel : PageViewModel, ICommandBarContext
|
||||
UpdateProperty(nameof(SecondaryCommandName));
|
||||
UpdateProperty(nameof(HasCommands));
|
||||
UpdateProperty(nameof(HasMoreCommands));
|
||||
UpdateProperty(nameof(MoreCommands));
|
||||
UpdateProperty(nameof(AllCommands));
|
||||
DoOnUiThread(
|
||||
() =>
|
||||
@@ -243,6 +222,72 @@ public partial class ContentPageViewModel : PageViewModel, ICommandBarContext
|
||||
});
|
||||
}
|
||||
|
||||
private List<IContextItemViewModel> BuildCommandViewModels(IContextItem[]? items)
|
||||
{
|
||||
if (items is null)
|
||||
{
|
||||
return [];
|
||||
}
|
||||
|
||||
return items
|
||||
.Select<IContextItem, IContextItemViewModel>(item =>
|
||||
{
|
||||
if (item is ICommandContextItem contextItem)
|
||||
{
|
||||
return new CommandContextItemViewModel(contextItem, PageContext);
|
||||
}
|
||||
|
||||
return new SeparatorViewModel();
|
||||
})
|
||||
.ToList();
|
||||
}
|
||||
|
||||
private static void InitializeCommandViewModels(IEnumerable<IContextItemViewModel> commands, Action<CommandContextItemViewModel> initialize)
|
||||
{
|
||||
foreach (var contextItem in commands.OfType<CommandContextItemViewModel>())
|
||||
{
|
||||
initialize(contextItem);
|
||||
}
|
||||
}
|
||||
|
||||
private static void CleanupCommandViewModels(IEnumerable<IContextItemViewModel> commands)
|
||||
{
|
||||
foreach (var contextItem in commands.OfType<CommandContextItemViewModel>())
|
||||
{
|
||||
contextItem.SafeCleanup();
|
||||
}
|
||||
}
|
||||
|
||||
private void RefreshCommandSnapshotsUnsafe()
|
||||
{
|
||||
var allCommands = (IContextItemViewModel[])[.. Commands];
|
||||
var moreCommands = allCommands.Length > 1
|
||||
? allCommands[1..]
|
||||
: [];
|
||||
|
||||
CommandContextItemViewModel? primary = null;
|
||||
CommandContextItemViewModel? secondary = null;
|
||||
foreach (var item in allCommands)
|
||||
{
|
||||
if (item is not CommandContextItemViewModel command)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (primary is null)
|
||||
{
|
||||
primary = command;
|
||||
}
|
||||
else if (secondary is null)
|
||||
{
|
||||
secondary = command;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
_snapshot = new(allCommands, moreCommands, primary, secondary);
|
||||
}
|
||||
|
||||
// InvokeItemCommand is what this will be in Xaml due to source generator
|
||||
// this comes in on Enter keypresses in the SearchBox
|
||||
[RelayCommand]
|
||||
@@ -270,12 +315,15 @@ public partial class ContentPageViewModel : PageViewModel, ICommandBarContext
|
||||
|
||||
Details?.SafeCleanup();
|
||||
|
||||
Commands
|
||||
.OfType<CommandContextItemViewModel>()
|
||||
.ToList()
|
||||
.ForEach(item => item.SafeCleanup());
|
||||
List<IContextItemViewModel> removedItems;
|
||||
lock (_commandsLock)
|
||||
{
|
||||
removedItems = [.. Commands];
|
||||
Commands.Clear();
|
||||
RefreshCommandSnapshotsUnsafe();
|
||||
}
|
||||
|
||||
Commands.Clear();
|
||||
CleanupCommandViewModels(removedItems);
|
||||
|
||||
foreach (var item in Content)
|
||||
{
|
||||
@@ -290,4 +338,25 @@ public partial class ContentPageViewModel : PageViewModel, ICommandBarContext
|
||||
model.ItemsChanged -= Model_ItemsChanged;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Immutable bundle of derived command state, published atomically via a
|
||||
/// single volatile write so readers never see a torn snapshot.
|
||||
/// </summary>
|
||||
private sealed class CommandSnapshot(
|
||||
IContextItemViewModel[] allCommands,
|
||||
IContextItemViewModel[] moreCommands,
|
||||
CommandContextItemViewModel? primaryCommand,
|
||||
CommandContextItemViewModel? secondaryCommand)
|
||||
{
|
||||
public static CommandSnapshot Empty { get; } = new([], [], null, null);
|
||||
|
||||
public IContextItemViewModel[] AllCommands { get; } = allCommands;
|
||||
|
||||
public IContextItemViewModel[] MoreCommands { get; } = moreCommands;
|
||||
|
||||
public CommandContextItemViewModel? PrimaryCommand { get; } = primaryCommand;
|
||||
|
||||
public CommandContextItemViewModel? SecondaryCommand { get; } = secondaryCommand;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,302 @@
|
||||
// Copyright (c) Microsoft Corporation
|
||||
// The Microsoft Corporation licenses this file to you under the MIT license.
|
||||
// See the LICENSE file in the project root for more information.
|
||||
|
||||
using System.Collections.Concurrent;
|
||||
using Microsoft.CmdPal.Common.Helpers;
|
||||
|
||||
namespace Microsoft.CmdPal.UI.ViewModels;
|
||||
|
||||
/// <summary>
|
||||
/// An elastic pool of dedicated background threads for running blocking work
|
||||
/// off the ThreadPool. Starts with <c>minThreads</c> always-alive threads and
|
||||
/// expands up to <c>maxThreads</c> on demand. Threads above the minimum exit
|
||||
/// automatically after <c>idleTimeout</c> with no work. Items are processed
|
||||
/// FIFO; cancelled items are skipped at dequeue time.
|
||||
/// </summary>
|
||||
internal sealed partial class DedicatedThreadPool : IDisposable
|
||||
{
|
||||
private const int DrainTimeoutMs = 3000;
|
||||
|
||||
private readonly BlockingCollection<Action> _workQueue = new();
|
||||
private readonly int _minThreads;
|
||||
private readonly int _maxThreads;
|
||||
private readonly TimeSpan _idleTimeout;
|
||||
private readonly string _name;
|
||||
|
||||
// Total live threads (Interlocked). Owned by the thread that wins the CAS.
|
||||
private int _threadCount;
|
||||
|
||||
// Threads currently blocked in TryTake waiting for work (Interlocked).
|
||||
// Used as the expansion trigger: if zero, all threads are busy.
|
||||
private int _idleCount;
|
||||
|
||||
// Ever-increasing counter for unique thread names across expand/shrink cycles.
|
||||
private int _nextThreadId;
|
||||
|
||||
private InterlockedBoolean _disposed;
|
||||
|
||||
public DedicatedThreadPool(int minThreads, int maxThreads, string name = "DedicatedWorker", TimeSpan? idleTimeout = null)
|
||||
{
|
||||
ArgumentOutOfRangeException.ThrowIfNegativeOrZero(minThreads);
|
||||
ArgumentOutOfRangeException.ThrowIfLessThan(maxThreads, minThreads);
|
||||
|
||||
_minThreads = minThreads;
|
||||
_maxThreads = maxThreads;
|
||||
_name = name;
|
||||
_idleTimeout = idleTimeout ?? TimeSpan.FromSeconds(30);
|
||||
|
||||
_threadCount = minThreads;
|
||||
for (var i = 0; i < minThreads; i++)
|
||||
{
|
||||
StartThread();
|
||||
}
|
||||
}
|
||||
|
||||
private void StartThread()
|
||||
{
|
||||
var id = Interlocked.Increment(ref _nextThreadId);
|
||||
var thread = new Thread(WorkerLoop)
|
||||
{
|
||||
IsBackground = true,
|
||||
Name = $"{_name}-{id}",
|
||||
Priority = ThreadPriority.BelowNormal,
|
||||
};
|
||||
thread.Start();
|
||||
}
|
||||
|
||||
private void WorkerLoop()
|
||||
{
|
||||
while (true)
|
||||
{
|
||||
Interlocked.Increment(ref _idleCount);
|
||||
|
||||
bool got;
|
||||
Action? action;
|
||||
try
|
||||
{
|
||||
got = _workQueue.TryTake(out action, _idleTimeout);
|
||||
}
|
||||
catch (ObjectDisposedException)
|
||||
{
|
||||
// Pool was disposed while we were waiting.
|
||||
Interlocked.Decrement(ref _idleCount);
|
||||
Interlocked.Decrement(ref _threadCount);
|
||||
return;
|
||||
}
|
||||
|
||||
Interlocked.Decrement(ref _idleCount);
|
||||
|
||||
if (got)
|
||||
{
|
||||
try
|
||||
{
|
||||
action!();
|
||||
}
|
||||
catch (Exception)
|
||||
{
|
||||
// QueueAsync wraps work in its own try-catch, so this should
|
||||
// never fire. Keep the thread alive defensively.
|
||||
}
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
// TryTake timed out (no work for idleTimeout).
|
||||
if (_workQueue.IsCompleted)
|
||||
{
|
||||
break;
|
||||
}
|
||||
|
||||
// Try to shrink: exit if we're above the minimum.
|
||||
// CAS ensures exactly one thread wins each decrement race.
|
||||
while (true)
|
||||
{
|
||||
var count = _threadCount;
|
||||
if (count <= _minThreads)
|
||||
{
|
||||
break; // At minimum — stay alive.
|
||||
}
|
||||
|
||||
if (Interlocked.CompareExchange(ref _threadCount, count - 1, count) == count)
|
||||
{
|
||||
return; // Decremented successfully — this thread exits.
|
||||
}
|
||||
|
||||
// Another thread changed _threadCount concurrently; retry.
|
||||
}
|
||||
}
|
||||
|
||||
Interlocked.Decrement(ref _threadCount);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Queue a blocking work item. Returns a <see cref="Task"/> that
|
||||
/// completes when the work finishes on a dedicated thread.
|
||||
/// If <paramref name="cancellationToken"/> is already cancelled when
|
||||
/// the item reaches the front of the queue, it is skipped immediately.
|
||||
/// Spawns an extra thread (up to <c>maxThreads</c>) if all current
|
||||
/// threads are occupied.
|
||||
/// </summary>
|
||||
public Task QueueAsync(Action work, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var tcs = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
try
|
||||
{
|
||||
_workQueue.Add(
|
||||
() =>
|
||||
{
|
||||
if (cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
tcs.TrySetCanceled(cancellationToken);
|
||||
return;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
work();
|
||||
tcs.TrySetResult();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
tcs.TrySetException(ex);
|
||||
}
|
||||
},
|
||||
cancellationToken);
|
||||
|
||||
// If no thread is idle, all are blocked in COM calls — try to expand.
|
||||
if (Volatile.Read(ref _idleCount) == 0)
|
||||
{
|
||||
TryExpand();
|
||||
}
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
tcs.TrySetCanceled(cancellationToken);
|
||||
}
|
||||
catch (ObjectDisposedException)
|
||||
{
|
||||
tcs.TrySetCanceled(CancellationToken.None);
|
||||
}
|
||||
catch (InvalidOperationException)
|
||||
{
|
||||
// CompleteAdding was called — pool is shutting down.
|
||||
tcs.TrySetCanceled(CancellationToken.None);
|
||||
}
|
||||
|
||||
return tcs.Task;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Queue a blocking work item. Returns a <see cref="Task{T}"/> that
|
||||
/// completes when the work finishes on a dedicated thread.
|
||||
/// If <paramref name="cancellationToken"/> is already cancelled when
|
||||
/// the item reaches the front of the queue, it is skipped immediately.
|
||||
/// Spawns an extra thread (up to <c>maxThreads</c>) if all current
|
||||
/// threads are occupied.
|
||||
/// </summary>
|
||||
public Task<T> QueueAsync<T>(Func<T> work, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var tcs = new TaskCompletionSource<T>(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
try
|
||||
{
|
||||
_workQueue.Add(
|
||||
() =>
|
||||
{
|
||||
if (cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
tcs.TrySetCanceled(cancellationToken);
|
||||
return;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
tcs.TrySetResult(work());
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
tcs.TrySetException(ex);
|
||||
}
|
||||
},
|
||||
cancellationToken);
|
||||
|
||||
// If no thread is idle, all are blocked in COM calls — try to expand.
|
||||
if (Volatile.Read(ref _idleCount) == 0)
|
||||
{
|
||||
TryExpand();
|
||||
}
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
tcs.TrySetCanceled(cancellationToken);
|
||||
}
|
||||
catch (ObjectDisposedException)
|
||||
{
|
||||
tcs.TrySetCanceled(CancellationToken.None);
|
||||
}
|
||||
catch (InvalidOperationException)
|
||||
{
|
||||
// CompleteAdding was called — pool is shutting down.
|
||||
tcs.TrySetCanceled(CancellationToken.None);
|
||||
}
|
||||
|
||||
return tcs.Task;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Attempt to spawn one additional thread, up to <c>maxThreads</c>.
|
||||
/// CAS on <c>_threadCount</c> ensures at most one thread wins per slot.
|
||||
/// </summary>
|
||||
private void TryExpand()
|
||||
{
|
||||
if (_disposed.Value)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
while (true)
|
||||
{
|
||||
var count = _threadCount;
|
||||
if (count >= _maxThreads)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (Interlocked.CompareExchange(ref _threadCount, count + 1, count) == count)
|
||||
{
|
||||
StartThread();
|
||||
return;
|
||||
}
|
||||
|
||||
// Another concurrent expand won this slot; recheck the ceiling.
|
||||
}
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
if (!_disposed.Set())
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_workQueue.CompleteAdding();
|
||||
|
||||
// Give worker threads a chance to drain remaining items and exit.
|
||||
// After CompleteAdding, idle threads see IsCompleted and exit
|
||||
// quickly, but threads blocked in long COM calls won't return
|
||||
// until their call finishes — don't wait forever.
|
||||
var deadline = Environment.TickCount64 + DrainTimeoutMs;
|
||||
var spin = default(SpinWait);
|
||||
while (Volatile.Read(ref _threadCount) > 0 && Environment.TickCount64 < deadline)
|
||||
{
|
||||
spin.SpinOnce();
|
||||
}
|
||||
|
||||
// Dispose the queue even if threads are still alive. Threads
|
||||
// blocked in TryTake will get ObjectDisposedException and exit
|
||||
// via the catch in WorkerLoop. Threads busy in action!() will
|
||||
// finish their item, then hit ObjectDisposedException on the
|
||||
// next TryTake and exit.
|
||||
_workQueue.Dispose();
|
||||
}
|
||||
}
|
||||
@@ -29,7 +29,7 @@ public sealed partial class DockViewModel
|
||||
|
||||
public ObservableCollection<DockBandViewModel> EndItems { get; } = new();
|
||||
|
||||
public ObservableCollection<TopLevelViewModel> AllItems => _topLevelCommandManager.DockBands;
|
||||
public IReadOnlyList<TopLevelViewModel> AllItems => _topLevelCommandManager.GetDockBandsSnapshot();
|
||||
|
||||
public DockViewModel(
|
||||
TopLevelCommandManager tlcManager,
|
||||
|
||||
@@ -0,0 +1,292 @@
|
||||
// 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.
|
||||
|
||||
/*
|
||||
#define CMDPAL_FF_MAINPAGE_TIME_FALLBACK_UPDATES
|
||||
*/
|
||||
|
||||
using System.Collections.Concurrent;
|
||||
using System.Diagnostics;
|
||||
using ManagedCommon;
|
||||
using Microsoft.CmdPal.UI.ViewModels.Commands;
|
||||
|
||||
namespace Microsoft.CmdPal.UI.ViewModels;
|
||||
|
||||
/// <summary>
|
||||
/// Manages adaptive dispatch of fallback update work on a dedicated thread pool.
|
||||
/// Tracks per-command inflight calls, pending-retry slots, and enforces a per-batch
|
||||
/// sibling-spawn cap to prevent runaway thread expansion.
|
||||
/// </summary>
|
||||
internal sealed partial class FallbackUpdateManager : IDisposable
|
||||
{
|
||||
// For individual fallback item updates - if an item takes longer than this, we will detach it
|
||||
// and continue with others.
|
||||
private static readonly TimeSpan FallbackItemSlowTimeout = TimeSpan.FromMilliseconds(200);
|
||||
|
||||
#if CMDPAL_FF_MAINPAGE_TIME_FALLBACK_UPDATES
|
||||
// For reporting only - if an item takes longer than this, we'll log it.
|
||||
private static readonly TimeSpan FallbackItemUltraSlowTimeout = TimeSpan.FromMilliseconds(1000);
|
||||
#endif
|
||||
|
||||
// Initial number of workers to use for fallback updates.
|
||||
private const int InitialFallbackWorkers = 2;
|
||||
|
||||
// Upper limit of threads in case things go awry
|
||||
private const int MaximumFallbackWorkersMaxThreads = 32;
|
||||
|
||||
// Per-command limit on concurrent in-flight COM calls. Prevents a single
|
||||
// misbehaving extension from monopolizing the pool across overlapping query batches.
|
||||
private const int MaxInflightPerFallback = 4;
|
||||
|
||||
// Per-batch cap on sibling workers
|
||||
private static readonly int MaxWorkersPerBatch = Math.Max(2, Environment.ProcessorCount / 2);
|
||||
|
||||
private readonly ConcurrentDictionary<string, InflightCounter> _inflightFallbacks = new();
|
||||
|
||||
// Dedicated background threads for fallback COM/RPC calls so they never block the
|
||||
// ThreadPool. Stuck extensions consume a dedicated thread, not a pool thread.
|
||||
// Max is intentionally above ProcessorCount: blocked threads consume no CPU, so
|
||||
// core count is not the right ceiling. Pool expands on demand and shrinks when idle.
|
||||
private readonly DedicatedThreadPool _fallbackThreadPool = new(minThreads: InitialFallbackWorkers, maxThreads: MaximumFallbackWorkersMaxThreads, name: "Fallbacks");
|
||||
|
||||
private readonly Action _onFallbackChanged;
|
||||
|
||||
#if CMDPAL_FF_MAINPAGE_TIME_FALLBACK_UPDATES
|
||||
private ulong _updateBatchCounter;
|
||||
#endif
|
||||
|
||||
internal FallbackUpdateManager(Action onFallbackChanged)
|
||||
{
|
||||
_onFallbackChanged = onFallbackChanged;
|
||||
}
|
||||
|
||||
internal void BeginUpdate(string query, IReadOnlyList<TopLevelViewModel> commands, CancellationToken cancellationToken)
|
||||
{
|
||||
if (commands.Count == 0 || string.IsNullOrWhiteSpace(query))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
#if CMDPAL_FF_MAINPAGE_TIME_FALLBACK_UPDATES
|
||||
var batchNumber = _updateBatchCounter++;
|
||||
Logger.LogDebug($"UpdateFallbacks: Batch start {batchNumber} for query '{query}'");
|
||||
#endif
|
||||
|
||||
// Adaptive dispatch on dedicated threads — same semantics as the old
|
||||
// ParallelHelper.AdaptiveForEachAdaptiveAsync, but without any ThreadPool involvement:
|
||||
// - Start 2 workers; each claims commands via a shared atomic index (FIFO, no double-work).
|
||||
// - If a command is slow (> FallbackItemSlowTimeout), the worker spawns a sibling so
|
||||
// remaining fast commands aren't blocked waiting in the worker's loop.
|
||||
// - _onFallbackChanged is called on the dedicated thread when a result changes
|
||||
var sharedIndex = 0;
|
||||
var totalCommands = commands.Count;
|
||||
var startingWorkers = Math.Min(InitialFallbackWorkers, totalCommands);
|
||||
var activeWorkerCount = startingWorkers;
|
||||
|
||||
void Worker()
|
||||
{
|
||||
while (!cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
var i = Interlocked.Increment(ref sharedIndex) - 1;
|
||||
if (i >= totalCommands)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var command = commands[i];
|
||||
var counter = _inflightFallbacks.GetOrAdd(command.Id, static _ => new InflightCounter());
|
||||
if (!counter.TryClaim(MaxInflightPerFallback))
|
||||
{
|
||||
// At capacity — store this query as a pending retry so it runs
|
||||
// when one of the in-flight calls finishes. Latest query wins.
|
||||
var pendingCommand = command;
|
||||
var pendingQuery = query;
|
||||
var pendingCt = cancellationToken;
|
||||
counter.SetPending(() => RetryFallbackUpdate(pendingCommand, pendingQuery, pendingCt, counter), pendingCt);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Arm a timer: if this item is still running after FallbackItemSlowTimeout,
|
||||
// spawn a sibling worker WHILE we're blocked in the COM call so remaining
|
||||
// commands don't have to wait for us to finish first.
|
||||
// Linking to cancellationToken cancels the timer immediately when the outer
|
||||
// query is abandoned — preventing stale siblings from being scheduled.
|
||||
// Disposing the linked CTS at iteration end removes the link registration.
|
||||
using var expandCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
|
||||
expandCts.CancelAfter(FallbackItemSlowTimeout);
|
||||
expandCts.Token.Register(() =>
|
||||
{
|
||||
// Fires on timeout (slow item) OR on outer cancellation.
|
||||
// Only spawn a sibling on timeout — when the outer query is still active.
|
||||
if (!cancellationToken.IsCancellationRequested && Volatile.Read(ref sharedIndex) < totalCommands)
|
||||
{
|
||||
// Per-batch cap — restore the constraint from ParallelHelper
|
||||
var current = Volatile.Read(ref activeWorkerCount);
|
||||
if (current < MaxWorkersPerBatch
|
||||
&& Interlocked.CompareExchange(ref activeWorkerCount, current + 1, current) == current)
|
||||
{
|
||||
_ = _fallbackThreadPool.QueueAsync(Worker, cancellationToken);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
var changed = false;
|
||||
try
|
||||
{
|
||||
#if CMDPAL_FF_MAINPAGE_TIME_FALLBACK_UPDATES
|
||||
var sw = Stopwatch.StartNew();
|
||||
Logger.LogDebug($"UpdateFallbacks: Worker: command id '{command.Id}', '{command.DisplayTitle}' updating with '{query}'");
|
||||
#endif
|
||||
changed = command.SafeUpdateFallbackTextSynchronous(query);
|
||||
#if CMDPAL_FF_MAINPAGE_TIME_FALLBACK_UPDATES
|
||||
var elapsed = sw.Elapsed;
|
||||
var tail = elapsed > FallbackItemSlowTimeout ? " is slow" : string.Empty;
|
||||
if (elapsed > FallbackItemUltraSlowTimeout)
|
||||
{
|
||||
tail += " <---------------- (ultra slow)";
|
||||
}
|
||||
|
||||
if (cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
Logger.LogDebug($"UpdateFallbacks: Worker: command id '{command.Id}', '{command.DisplayTitle}' updated with '{query}' processed in {elapsed}, has {(changed ? "changed" : "not changed")} and title is '{command.Title}'{tail}");
|
||||
#endif
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Logger.LogError($"UpdateFallbacks: Worker: command id '{command.Id}', '{command.DisplayTitle}' failed to update fallback text with '{query}'", ex);
|
||||
}
|
||||
finally
|
||||
{
|
||||
counter.Release();
|
||||
DispatchPending(counter.TakePending());
|
||||
}
|
||||
|
||||
// Guard against a stale refresh if the COM call returned after cancellation.
|
||||
if (changed && !cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
_onFallbackChanged();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Dispatches a pending work item to the dedicated pool. The pending's
|
||||
// own CT is forwarded so the pool can skip it at dequeue time when the
|
||||
// originating query batch has been superseded by a newer keystroke.
|
||||
void DispatchPending(PendingWork? pending)
|
||||
{
|
||||
if (pending == null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_ = _fallbackThreadPool.QueueAsync(pending.Work, pending.CancellationToken);
|
||||
}
|
||||
|
||||
for (var i = 0; i < startingWorkers; i++)
|
||||
{
|
||||
_ = _fallbackThreadPool.QueueAsync(Worker, cancellationToken);
|
||||
}
|
||||
|
||||
return;
|
||||
|
||||
// One-shot retry for a command that was skipped due to MaxInflightPerFallback.
|
||||
// Claims a slot, runs the COM call, releases, and propagates the next pending (if any).
|
||||
void RetryFallbackUpdate(TopLevelViewModel cmd, string q, CancellationToken ct, InflightCounter ctr)
|
||||
{
|
||||
if (ct.IsCancellationRequested)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (!ctr.TryClaim(MaxInflightPerFallback))
|
||||
{
|
||||
// Still at capacity (a newer worker claimed the freed slot first).
|
||||
// The pending was already consumed from TakePending, so it's dropped here.
|
||||
return;
|
||||
}
|
||||
|
||||
var changed = false;
|
||||
try
|
||||
{
|
||||
changed = cmd.SafeUpdateFallbackTextSynchronous(q);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Logger.LogError($"UpdateFallbacks: Pending retry: command id '{cmd.Id}', '{cmd.DisplayTitle}' failed with '{q}'", ex);
|
||||
}
|
||||
finally
|
||||
{
|
||||
ctr.Release();
|
||||
DispatchPending(ctr.TakePending());
|
||||
}
|
||||
|
||||
if (changed && !ct.IsCancellationRequested)
|
||||
{
|
||||
_onFallbackChanged();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
_fallbackThreadPool.Dispose();
|
||||
_inflightFallbacks.Clear();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A pending work item paired with the cancellation token of the query
|
||||
/// batch that created it, so the pool can skip it at dequeue time when
|
||||
/// a newer keystroke has already superseded the query.
|
||||
/// </summary>
|
||||
private sealed record PendingWork(Action Work, CancellationToken CancellationToken);
|
||||
|
||||
/// <summary>
|
||||
/// Thread-safe counter for tracking concurrent in-flight calls per command,
|
||||
/// with a single pending retry slot for queries that couldn't claim immediately.
|
||||
/// </summary>
|
||||
private sealed class InflightCounter
|
||||
{
|
||||
private int _count;
|
||||
|
||||
// Latest pending work item. Only one is stored; newer queries overwrite older ones.
|
||||
private PendingWork? _pendingWork;
|
||||
|
||||
/// <summary>
|
||||
/// Try to claim a slot. Returns true if the count was below
|
||||
/// <paramref name="max"/> and was incremented; false if at capacity.
|
||||
/// </summary>
|
||||
public bool TryClaim(int max)
|
||||
{
|
||||
while (true)
|
||||
{
|
||||
var current = Volatile.Read(ref _count);
|
||||
if (current >= max)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (Interlocked.CompareExchange(ref _count, current + 1, current) == current)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Stores a pending work item to run when the next slot opens.
|
||||
/// Overwrites any previously stored item — latest query always wins.
|
||||
/// </summary>
|
||||
public void SetPending(Action work, CancellationToken ct) => Interlocked.Exchange(ref _pendingWork, new PendingWork(work, ct));
|
||||
|
||||
/// <summary>
|
||||
/// Atomically removes and returns any pending work item, or null if none.
|
||||
/// </summary>
|
||||
public PendingWork? TakePending() => Interlocked.Exchange(ref _pendingWork, null);
|
||||
|
||||
public void Release() => Interlocked.Decrement(ref _count);
|
||||
}
|
||||
}
|
||||
@@ -22,7 +22,7 @@ public partial class IconInfoViewModel : ObservableObject, IIconInfo
|
||||
|
||||
public IconDataViewModel Dark { get; private set; }
|
||||
|
||||
public IconDataViewModel IconForTheme(bool light) => Light = light ? Light : Dark;
|
||||
public IconDataViewModel IconForTheme(bool light) => light ? Light : Dark;
|
||||
|
||||
public bool HasIcon(bool light) => IconForTheme(light).HasIcon;
|
||||
|
||||
|
||||
@@ -159,7 +159,6 @@ public partial class ListItemViewModel : CommandItemViewModel
|
||||
UpdateShowDetailsCommand();
|
||||
break;
|
||||
case nameof(model.MoreCommands):
|
||||
UpdateProperty(nameof(MoreCommands));
|
||||
AddShowDetailsCommands();
|
||||
break;
|
||||
case nameof(model.Title):
|
||||
@@ -195,19 +194,27 @@ public partial class ListItemViewModel : CommandItemViewModel
|
||||
pageContext is ListViewModel listViewModel &&
|
||||
!listViewModel.ShowDetails)
|
||||
{
|
||||
// Check if "Show Details" action already exists to prevent duplicates
|
||||
if (!MoreCommands.Any(cmd => cmd is CommandContextItemViewModel contextItemViewModel &&
|
||||
contextItemViewModel.Command.Id == ShowDetailsCommand.ShowDetailsCommandId))
|
||||
var addedCommand = false;
|
||||
lock (MoreCommandsLock)
|
||||
{
|
||||
// Create the view model for the show details command
|
||||
var showDetailsCommand = new ShowDetailsCommand(Details);
|
||||
var showDetailsContextItem = new CommandContextItem(showDetailsCommand);
|
||||
var showDetailsContextItemViewModel = new CommandContextItemViewModel(showDetailsContextItem, PageContext);
|
||||
showDetailsContextItemViewModel.SlowInitializeProperties();
|
||||
MoreCommands.Add(showDetailsContextItemViewModel);
|
||||
// Check if "Show Details" action already exists to prevent duplicates
|
||||
if (!UnsafeMoreCommands.Any(cmd => cmd is CommandContextItemViewModel contextItemViewModel &&
|
||||
contextItemViewModel.Command.Id == ShowDetailsCommand.ShowDetailsCommandId))
|
||||
{
|
||||
var showDetailsCommand = new ShowDetailsCommand(Details);
|
||||
var showDetailsContextItem = new CommandContextItem(showDetailsCommand);
|
||||
var showDetailsContextItemViewModel = new CommandContextItemViewModel(showDetailsContextItem, PageContext);
|
||||
showDetailsContextItemViewModel.SlowInitializeProperties();
|
||||
UnsafeMoreCommands.Add(showDetailsContextItemViewModel);
|
||||
RefreshMoreCommandStateUnsafe();
|
||||
addedCommand = true;
|
||||
}
|
||||
}
|
||||
|
||||
UpdateProperty(nameof(MoreCommands), nameof(AllCommands));
|
||||
if (addedCommand)
|
||||
{
|
||||
UpdateProperty(nameof(MoreCommands), nameof(AllCommands));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -222,22 +229,27 @@ public partial class ListItemViewModel : CommandItemViewModel
|
||||
pageContext is ListViewModel listViewModel &&
|
||||
!listViewModel.ShowDetails)
|
||||
{
|
||||
var existingCommand = MoreCommands.FirstOrDefault(cmd =>
|
||||
cmd is CommandContextItemViewModel contextItemViewModel &&
|
||||
contextItemViewModel.Command.Id == ShowDetailsCommand.ShowDetailsCommandId);
|
||||
|
||||
// If the command already exists, remove it to update with the new details
|
||||
if (existingCommand is not null)
|
||||
CommandContextItemViewModel? oldCommand = null;
|
||||
lock (MoreCommandsLock)
|
||||
{
|
||||
MoreCommands.Remove(existingCommand);
|
||||
oldCommand = UnsafeMoreCommands
|
||||
.OfType<CommandContextItemViewModel>()
|
||||
.FirstOrDefault(contextItemViewModel => contextItemViewModel.Command.Id == ShowDetailsCommand.ShowDetailsCommandId);
|
||||
|
||||
if (oldCommand is not null)
|
||||
{
|
||||
UnsafeMoreCommands.Remove(oldCommand);
|
||||
}
|
||||
|
||||
var showDetailsCommand = new ShowDetailsCommand(Details);
|
||||
var showDetailsContextItem = new CommandContextItem(showDetailsCommand);
|
||||
var showDetailsContextItemViewModel = new CommandContextItemViewModel(showDetailsContextItem, PageContext);
|
||||
showDetailsContextItemViewModel.SlowInitializeProperties();
|
||||
UnsafeMoreCommands.Add(showDetailsContextItemViewModel);
|
||||
RefreshMoreCommandStateUnsafe();
|
||||
}
|
||||
|
||||
// Create the view model for the show details command
|
||||
var showDetailsCommand = new ShowDetailsCommand(Details);
|
||||
var showDetailsContextItem = new CommandContextItem(showDetailsCommand);
|
||||
var showDetailsContextItemViewModel = new CommandContextItemViewModel(showDetailsContextItem, PageContext);
|
||||
showDetailsContextItemViewModel.SlowInitializeProperties();
|
||||
MoreCommands.Add(showDetailsContextItemViewModel);
|
||||
oldCommand?.SafeCleanup();
|
||||
|
||||
UpdateProperty(nameof(MoreCommands), nameof(AllCommands));
|
||||
}
|
||||
|
||||
@@ -70,6 +70,8 @@ public partial class ListViewModel : PageViewModel, IDisposable
|
||||
|
||||
public bool IsMainPage { get; init; }
|
||||
|
||||
public bool HasCustomDebounceLogic => IsMainPage;
|
||||
|
||||
private bool _isDynamic;
|
||||
|
||||
private Task? _initializeItemsTask;
|
||||
|
||||
@@ -18,11 +18,11 @@ public record UpdateCommandBarMessage(ICommandBarContext? ViewModel)
|
||||
|
||||
public interface IContextMenuContext : INotifyPropertyChanged
|
||||
{
|
||||
public IEnumerable<IContextItemViewModel> MoreCommands { get; }
|
||||
public IReadOnlyList<IContextItemViewModel> MoreCommands { get; }
|
||||
|
||||
public bool HasMoreCommands { get; }
|
||||
|
||||
public List<IContextItemViewModel> AllCommands { get; }
|
||||
public IReadOnlyList<IContextItemViewModel> AllCommands { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Generates a mapping of key -> command item for this particular item's
|
||||
|
||||
@@ -484,7 +484,7 @@ namespace Microsoft.CmdPal.UI.ViewModels.Properties {
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Looks up a localized string similar to Edit dock.
|
||||
/// Looks up a localized string similar to Edit Dock.
|
||||
/// </summary>
|
||||
public static string dock_edit_dock_name {
|
||||
get {
|
||||
@@ -511,7 +511,7 @@ namespace Microsoft.CmdPal.UI.ViewModels.Properties {
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Looks up a localized string similar to Dock settings.
|
||||
/// Looks up a localized string similar to Settings.
|
||||
/// </summary>
|
||||
public static string dock_settings_name {
|
||||
get {
|
||||
|
||||
@@ -277,11 +277,11 @@
|
||||
<value>Fallbacks</value>
|
||||
</data>
|
||||
<data name="dock_edit_dock_name" xml:space="preserve">
|
||||
<value>Edit dock</value>
|
||||
<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>
|
||||
<value>Settings</value>
|
||||
<comment>Command name for opening dock settings</comment>
|
||||
</data>
|
||||
<data name="ShowDetailsCommand" xml:space="preserve">
|
||||
|
||||
@@ -12,6 +12,7 @@ public class ProviderSettings
|
||||
private readonly string[] _excludedBuiltInFallbacks = [
|
||||
"com.microsoft.cmdpal.builtin.indexer.fallback",
|
||||
"com.microsoft.cmdpal.builtin.calculator.fallback",
|
||||
"com.microsoft.cmdpal.builtin.remotedesktop.fallback",
|
||||
];
|
||||
|
||||
public bool IsEnabled { get; set; } = true;
|
||||
|
||||
@@ -585,9 +585,9 @@ public sealed 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)
|
||||
{
|
||||
foreach (var extension in extensions)
|
||||
lock (TopLevelCommands)
|
||||
{
|
||||
foreach (var command in TopLevelCommands)
|
||||
{
|
||||
@@ -597,7 +597,10 @@ public sealed partial class TopLevelCommandManager : ObservableObject,
|
||||
commandsToRemove.Add(command);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
lock (_dockBandsLock)
|
||||
{
|
||||
foreach (var band in DockBands)
|
||||
{
|
||||
var host = band.ExtensionHost;
|
||||
@@ -675,6 +678,14 @@ public sealed partial class TopLevelCommandManager : ObservableObject,
|
||||
return null;
|
||||
}
|
||||
|
||||
public List<TopLevelViewModel> GetDockBandsSnapshot()
|
||||
{
|
||||
lock (_dockBandsLock)
|
||||
{
|
||||
return [.. DockBands];
|
||||
}
|
||||
}
|
||||
|
||||
public void Receive(ReloadCommandsMessage message) =>
|
||||
_ = ReloadAllCommandsAsync();
|
||||
|
||||
|
||||
@@ -135,6 +135,8 @@ public partial class IconBox : ContentControl
|
||||
_lastScale = XamlRoot.RasterizationScale;
|
||||
XamlRoot.Changed += OnXamlRootChanged;
|
||||
}
|
||||
|
||||
Refresh();
|
||||
}
|
||||
|
||||
private void OnUnloaded(object sender, RoutedEventArgs e)
|
||||
@@ -149,10 +151,13 @@ public partial class IconBox : ContentControl
|
||||
{
|
||||
var newScale = sender.RasterizationScale;
|
||||
var changedLastTheme = _lastTheme != ActualTheme;
|
||||
var changedScale = Math.Abs(newScale - _lastScale) > 0.01;
|
||||
|
||||
_lastScale = newScale;
|
||||
_lastTheme = ActualTheme;
|
||||
if ((changedLastTheme || Math.Abs(newScale - _lastScale) > 0.01) && SourceKey is not null)
|
||||
|
||||
if ((changedLastTheme || changedScale) && SourceKey is not null)
|
||||
{
|
||||
_lastScale = newScale;
|
||||
UpdateSourceKey(this, SourceKey);
|
||||
}
|
||||
}
|
||||
@@ -257,7 +262,11 @@ public partial class IconBox : ContentControl
|
||||
return;
|
||||
}
|
||||
|
||||
var eventArgs = new SourceRequestedEventArgs(sourceKey, iconBox._lastTheme, iconBox._lastScale);
|
||||
var scale = iconBox._lastScale > 0
|
||||
? iconBox._lastScale
|
||||
: (iconBox.XamlRoot?.RasterizationScale > 0 ? iconBox.XamlRoot.RasterizationScale : 1.0);
|
||||
|
||||
var eventArgs = new SourceRequestedEventArgs(sourceKey, iconBox._lastTheme, scale);
|
||||
await iconBoxSourceRequestedHandler.InvokeAsync(iconBox, eventArgs);
|
||||
|
||||
// After the await:
|
||||
|
||||
@@ -351,17 +351,24 @@ public sealed partial class SearchBar : UserControl,
|
||||
}
|
||||
|
||||
// TODO: We could encapsulate this in a Behavior if we wanted to bind to the Filter property.
|
||||
_debounceTimer.Debounce(
|
||||
() =>
|
||||
{
|
||||
DoFilterBoxUpdate();
|
||||
},
|
||||
//// Couldn't find a good recommendation/resource for value here. PT uses 50ms as default, so that is a reasonable default
|
||||
//// This seems like a useful testing site for typing times: https://keyboardtester.info/keyboard-latency-test/
|
||||
//// i.e. if another keyboard press comes in within 50ms of the last, we'll wait before we fire off the request
|
||||
interval: TimeSpan.FromMilliseconds(50),
|
||||
//// If we're not already waiting, and this is blanking out or the first character type, we'll start filtering immediately instead to appear more responsive and either clear the filter to get back home faster or at least chop to the first starting letter.
|
||||
immediate: FilterBox.Text.Length <= 1);
|
||||
var hasCustomDebounce = (CurrentPageViewModel as ListViewModel)?.HasCustomDebounceLogic == true;
|
||||
if (hasCustomDebounce)
|
||||
{
|
||||
// Good, the page handles debouncing on its own
|
||||
DoFilterBoxUpdate();
|
||||
}
|
||||
else
|
||||
{
|
||||
_debounceTimer.Debounce(
|
||||
DoFilterBoxUpdate,
|
||||
//// Couldn't find a good recommendation/resource for value here. PT uses 50ms as default, so that is a reasonable default
|
||||
//// This seems like a useful testing site for typing times: https://keyboardtester.info/keyboard-latency-test/
|
||||
//// i.e. if another keyboard press comes in within 50ms of the last, we'll wait before we fire off the request
|
||||
interval: TimeSpan.FromMilliseconds(50),
|
||||
//// If we're not already waiting, and this is blanking out or the first character type, we'll start filtering immediately
|
||||
//// instead to appear more responsive and either clear the filter to get back home faster or at least chop to the first starting letter.
|
||||
immediate: FilterBox.Text.Length <= 1);
|
||||
}
|
||||
}
|
||||
|
||||
private void DoFilterBoxUpdate()
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
<?xml version="1.0" encoding="utf-8" ?>
|
||||
<?xml version="1.0" encoding="utf-8" ?>
|
||||
<UserControl
|
||||
x:Class="Microsoft.CmdPal.UI.Dock.DockControl"
|
||||
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
|
||||
@@ -108,24 +108,24 @@
|
||||
|
||||
<!-- Edit mode context menu for dock bands -->
|
||||
<MenuFlyout x:Name="EditModeContextMenu" ShouldConstrainToRootBounds="False">
|
||||
<MenuFlyoutSubItem x:Name="LabelsSubMenu" Text="Labels">
|
||||
<MenuFlyoutSubItem x:Name="LabelsSubMenu" x:Uid="Dock_EditMode_Labels">
|
||||
<MenuFlyoutSubItem.Icon>
|
||||
<FontIcon Glyph="" />
|
||||
</MenuFlyoutSubItem.Icon>
|
||||
<ToggleMenuFlyoutItem
|
||||
x:Name="ShowTitlesMenuItem"
|
||||
Click="ShowTitlesMenuItem_Click"
|
||||
Text="Show titles" />
|
||||
x:Uid="Dock_EditMode_ShowTitles"
|
||||
Click="ShowTitlesMenuItem_Click" />
|
||||
<ToggleMenuFlyoutItem
|
||||
x:Name="ShowSubtitlesMenuItem"
|
||||
Click="ShowSubtitlesMenuItem_Click"
|
||||
Text="Show subtitles" />
|
||||
x:Uid="Dock_EditMode_ShowSubtitles"
|
||||
Click="ShowSubtitlesMenuItem_Click" />
|
||||
</MenuFlyoutSubItem>
|
||||
<MenuFlyoutSeparator />
|
||||
<MenuFlyoutItem
|
||||
x:Name="UnpinBandMenuItem"
|
||||
Click="UnpinBandMenuItem_Click"
|
||||
Text="Unpin">
|
||||
x:Uid="Dock_EditMode_Unpin"
|
||||
Click="UnpinBandMenuItem_Click">
|
||||
<MenuFlyoutItem.Icon>
|
||||
<FontIcon Glyph="" />
|
||||
</MenuFlyoutItem.Icon>
|
||||
@@ -138,16 +138,19 @@
|
||||
Placement="Bottom"
|
||||
ShouldConstrainToRootBounds="False">
|
||||
<StackPanel Width="320">
|
||||
<TextBlock
|
||||
x:Uid="Dock_Bands_Header"
|
||||
Margin="8,8,8,12"
|
||||
Foreground="{ThemeResource TextFillColorSecondaryBrush}" />
|
||||
<TextBlock
|
||||
x:Name="NoAvailableBandsText"
|
||||
Padding="12"
|
||||
Foreground="{ThemeResource TextFillColorSecondaryBrush}"
|
||||
Text="No commands available to pin"
|
||||
TextAlignment="Center"
|
||||
x:Uid="Dock_AddBand_NoCommandsAvailable"
|
||||
Margin="8,0,0,0"
|
||||
Visibility="Collapsed" />
|
||||
<ListView
|
||||
x:Name="AddBandListView"
|
||||
MaxHeight="300"
|
||||
Margin="-12,0,-12,0"
|
||||
HorizontalAlignment="Stretch"
|
||||
IsItemClickEnabled="True"
|
||||
ItemClick="AddBandListView_ItemClick"
|
||||
@@ -175,6 +178,30 @@
|
||||
</DataTemplate>
|
||||
</ListView.ItemTemplate>
|
||||
</ListView>
|
||||
<Rectangle
|
||||
Height="1"
|
||||
Margin="-16,24,-16,0"
|
||||
HorizontalAlignment="Stretch"
|
||||
Fill="{ThemeResource DividerStrokeColorDefaultBrush}" />
|
||||
<Grid Margin="8,24,0,0" ColumnSpacing="8">
|
||||
<Grid.ColumnDefinitions>
|
||||
<ColumnDefinition Width="Auto" />
|
||||
<ColumnDefinition Width="*" />
|
||||
</Grid.ColumnDefinitions>
|
||||
<FontIcon
|
||||
Margin="0,4,0,0"
|
||||
VerticalAlignment="Top"
|
||||
AutomationProperties.AccessibilityView="Raw"
|
||||
FontSize="11"
|
||||
Foreground="{ThemeResource TextFillColorSecondaryBrush}"
|
||||
Glyph="" />
|
||||
<TextBlock
|
||||
x:Uid="Dock_Pin_Instruction"
|
||||
Grid.Column="1"
|
||||
Foreground="{ThemeResource TextFillColorSecondaryBrush}"
|
||||
Style="{ThemeResource CaptionTextBlockStyle}"
|
||||
TextWrapping="Wrap" />
|
||||
</Grid>
|
||||
</StackPanel>
|
||||
</Flyout>
|
||||
</ResourceDictionary>
|
||||
@@ -188,16 +215,18 @@
|
||||
<local:DockContentControl
|
||||
x:Name="ContentGrid"
|
||||
Margin="4"
|
||||
Padding="0,0,0,0"
|
||||
Background="Transparent"
|
||||
IsEditMode="{x:Bind IsEditMode, Mode=OneWay}"
|
||||
RightTapped="RootGrid_RightTapped">
|
||||
<local:DockContentControl.StartSource>
|
||||
<ListView
|
||||
x:Name="StartListView"
|
||||
MinWidth="48"
|
||||
HorizontalAlignment="Stretch"
|
||||
DragEnter="BandListView_DragEnter"
|
||||
DragItemsCompleted="BandListView_DragItemsCompleted"
|
||||
DragItemsStarting="BandListView_DragItemsStarting"
|
||||
DragLeave="BandListView_DragLeave"
|
||||
DragOver="BandListView_DragOver"
|
||||
Drop="StartListView_Drop"
|
||||
ItemContainerStyle="{StaticResource DockBandListViewItemStyle}"
|
||||
@@ -210,10 +239,11 @@
|
||||
<local:DockContentControl.StartActionButton>
|
||||
<Button
|
||||
x:Name="StartAddButton"
|
||||
x:Uid="Dock_AddBand_StartTooltip"
|
||||
MinHeight="30"
|
||||
Click="AddBandButton_Click"
|
||||
Style="{StaticResource SubtleButtonStyle}"
|
||||
Tag="Start"
|
||||
ToolTipService.ToolTip="Add band to Start">
|
||||
Tag="Start">
|
||||
<FontIcon FontSize="12" Glyph="" />
|
||||
</Button>
|
||||
</local:DockContentControl.StartActionButton>
|
||||
@@ -221,9 +251,12 @@
|
||||
<local:DockContentControl.CenterSource>
|
||||
<ListView
|
||||
x:Name="CenterListView"
|
||||
MinWidth="48"
|
||||
HorizontalAlignment="Stretch"
|
||||
DragEnter="BandListView_DragEnter"
|
||||
DragItemsCompleted="BandListView_DragItemsCompleted"
|
||||
DragItemsStarting="BandListView_DragItemsStarting"
|
||||
DragLeave="BandListView_DragLeave"
|
||||
DragOver="BandListView_DragOver"
|
||||
Drop="CenterListView_Drop"
|
||||
ItemContainerStyle="{StaticResource DockBandListViewItemStyle}"
|
||||
@@ -236,10 +269,11 @@
|
||||
<local:DockContentControl.CenterActionButton>
|
||||
<Button
|
||||
x:Name="CenterAddButton"
|
||||
x:Uid="Dock_AddBand_CenterTooltip"
|
||||
MinHeight="30"
|
||||
Click="AddBandButton_Click"
|
||||
Style="{StaticResource SubtleButtonStyle}"
|
||||
Tag="Center"
|
||||
ToolTipService.ToolTip="Add band to Center">
|
||||
Tag="Center">
|
||||
<FontIcon FontSize="12" Glyph="" />
|
||||
</Button>
|
||||
</local:DockContentControl.CenterActionButton>
|
||||
@@ -247,8 +281,11 @@
|
||||
<local:DockContentControl.EndSource>
|
||||
<ListView
|
||||
x:Name="EndListView"
|
||||
MinWidth="48"
|
||||
DragEnter="BandListView_DragEnter"
|
||||
DragItemsCompleted="BandListView_DragItemsCompleted"
|
||||
DragItemsStarting="BandListView_DragItemsStarting"
|
||||
DragLeave="BandListView_DragLeave"
|
||||
DragOver="BandListView_DragOver"
|
||||
Drop="EndListView_Drop"
|
||||
ItemContainerStyle="{StaticResource DockBandListViewItemStyle}"
|
||||
@@ -265,10 +302,11 @@
|
||||
<local:DockContentControl.EndActionButton>
|
||||
<Button
|
||||
x:Name="EndAddButton"
|
||||
x:Uid="Dock_AddBand_EndTooltip"
|
||||
MinHeight="30"
|
||||
Click="AddBandButton_Click"
|
||||
Style="{StaticResource SubtleButtonStyle}"
|
||||
Tag="End"
|
||||
ToolTipService.ToolTip="Add band to End">
|
||||
Tag="End">
|
||||
<FontIcon FontSize="12" Glyph="" />
|
||||
</Button>
|
||||
</local:DockContentControl.EndActionButton>
|
||||
@@ -281,7 +319,6 @@
|
||||
ShouldConstrainToRootBounds="False"
|
||||
Style="{StaticResource TeachingTipWithoutCloseButtonStyle}"
|
||||
Target="{x:Bind ContentGrid}">
|
||||
|
||||
<TeachingTip.Content>
|
||||
<StackPanel
|
||||
x:Name="EditButtonsPanel"
|
||||
@@ -289,14 +326,14 @@
|
||||
Orientation="Vertical"
|
||||
Spacing="4">
|
||||
<Button
|
||||
x:Uid="Dock_EditMode_Save"
|
||||
HorizontalAlignment="Stretch"
|
||||
Click="DoneEditingButton_Click"
|
||||
Content="Save"
|
||||
Style="{StaticResource AccentButtonStyle}" />
|
||||
<Button
|
||||
x:Uid="Dock_EditMode_Discard"
|
||||
HorizontalAlignment="Stretch"
|
||||
Click="DiscardEditingButton_Click"
|
||||
Content="Discard" />
|
||||
Click="DiscardEditingButton_Click" />
|
||||
</StackPanel>
|
||||
</TeachingTip.Content>
|
||||
</TeachingTip>
|
||||
@@ -328,6 +365,9 @@
|
||||
<Setter Target="ContentGrid.Margin" Value="0,0,4,4" />
|
||||
<Setter Target="ContentGrid.Padding" Value="0,0,4,8" />
|
||||
<Setter Target="RootGrid.BorderThickness" Value="0,0,1,0" />
|
||||
<Setter Target="StartListView.MinHeight" Value="48" />
|
||||
<Setter Target="CenterListView.MinHeight" Value="48" />
|
||||
<Setter Target="EndListView.MinHeight" Value="48" />
|
||||
<Setter Target="StartListView.ItemsPanel" Value="{StaticResource VerticalItemsPanel}" />
|
||||
<Setter Target="CenterListView.ItemsPanel" Value="{StaticResource VerticalItemsPanel}" />
|
||||
<Setter Target="EndListView.ItemsPanel" Value="{StaticResource VerticalItemsPanel}" />
|
||||
@@ -342,6 +382,9 @@
|
||||
<Setter Target="ContentGrid.Margin" Value="4,0,0,4" />
|
||||
<Setter Target="ContentGrid.Padding" Value="4,0,0,8" />
|
||||
<Setter Target="RootGrid.BorderThickness" Value="1,0,0,0" />
|
||||
<Setter Target="StartListView.MinHeight" Value="48" />
|
||||
<Setter Target="CenterListView.MinHeight" Value="48" />
|
||||
<Setter Target="EndListView.MinHeight" Value="48" />
|
||||
<Setter Target="StartListView.ItemsPanel" Value="{StaticResource VerticalItemsPanel}" />
|
||||
<Setter Target="CenterListView.ItemsPanel" Value="{StaticResource VerticalItemsPanel}" />
|
||||
<Setter Target="EndListView.ItemsPanel" Value="{StaticResource VerticalItemsPanel}" />
|
||||
|
||||
@@ -76,7 +76,7 @@ public sealed partial class DockControl : UserControl, IRecipient<CloseContextMe
|
||||
UpdateEditMode(false);
|
||||
}
|
||||
|
||||
private void CenterItems_CollectionChanged(object? sender, System.Collections.Specialized.NotifyCollectionChangedEventArgs e)
|
||||
private void CenterItems_CollectionChanged(object? sender, NotifyCollectionChangedEventArgs e)
|
||||
{
|
||||
UpdateCenterVisibility();
|
||||
}
|
||||
@@ -308,6 +308,12 @@ public sealed partial class DockControl : UserControl, IRecipient<CloseContextMe
|
||||
|
||||
private void RootGrid_RightTapped(object sender, Microsoft.UI.Xaml.Input.RightTappedRoutedEventArgs e)
|
||||
{
|
||||
// Don't show the dock context menu while in edit mode
|
||||
if (IsEditMode)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var pos = e.GetPosition(null);
|
||||
var item = this.ViewModel.GetContextMenuForDock();
|
||||
if (item.HasMoreCommands)
|
||||
@@ -384,16 +390,19 @@ public sealed partial class DockControl : UserControl, IRecipient<CloseContextMe
|
||||
private void StartListView_Drop(object sender, DragEventArgs e)
|
||||
{
|
||||
HandleCrossListDrop(DockPinSide.Start, e);
|
||||
ResetListViewState(sender);
|
||||
}
|
||||
|
||||
private void CenterListView_Drop(object sender, DragEventArgs e)
|
||||
{
|
||||
HandleCrossListDrop(DockPinSide.Center, e);
|
||||
ResetListViewState(sender);
|
||||
}
|
||||
|
||||
private void EndListView_Drop(object sender, DragEventArgs e)
|
||||
{
|
||||
HandleCrossListDrop(DockPinSide.End, e);
|
||||
ResetListViewState(sender);
|
||||
}
|
||||
|
||||
private void HandleCrossListDrop(DockPinSide targetSide, DragEventArgs e)
|
||||
@@ -522,4 +531,27 @@ public sealed partial class DockControl : UserControl, IRecipient<CloseContextMe
|
||||
AddBandFlyout.Hide();
|
||||
}
|
||||
}
|
||||
|
||||
private void BandListView_DragEnter(object sender, DragEventArgs e)
|
||||
{
|
||||
if (sender is ListView view)
|
||||
{
|
||||
view.Background = Application.Current.Resources["ControlAltFillColorQuarternaryBrush"] as SolidColorBrush;
|
||||
e.DragUIOverride.IsGlyphVisible = false;
|
||||
e.DragUIOverride.IsCaptionVisible = false;
|
||||
}
|
||||
}
|
||||
|
||||
private void BandListView_DragLeave(object sender, DragEventArgs e)
|
||||
{
|
||||
ResetListViewState(sender);
|
||||
}
|
||||
|
||||
private void ResetListViewState(object sender)
|
||||
{
|
||||
if (sender is ListView listView)
|
||||
{
|
||||
listView.Background = new SolidColorBrush(Colors.Transparent);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -60,10 +60,7 @@
|
||||
<Setter Property="Template">
|
||||
<Setter.Value>
|
||||
<ControlTemplate TargetType="local:DockItemControl">
|
||||
<Grid
|
||||
x:Name="PART_HitTestGrid"
|
||||
Background="Transparent"
|
||||
ToolTipService.ToolTip="{TemplateBinding ToolTip}">
|
||||
<Grid x:Name="PART_HitTestGrid" Background="Transparent">
|
||||
<Grid
|
||||
x:Name="PART_RootGrid"
|
||||
MinWidth="32"
|
||||
@@ -95,13 +92,14 @@
|
||||
<StackPanel
|
||||
x:Name="TextPanel"
|
||||
Grid.Column="1"
|
||||
HorizontalAlignment="Stretch"
|
||||
VerticalAlignment="Center"
|
||||
Visibility="{TemplateBinding TextVisibility}">
|
||||
<TextBlock
|
||||
x:Name="TitleText"
|
||||
MinWidth="24"
|
||||
MaxWidth="100"
|
||||
HorizontalAlignment="Left"
|
||||
HorizontalAlignment="Stretch"
|
||||
VerticalAlignment="Center"
|
||||
FontFamily="Segoe UI"
|
||||
FontSize="12"
|
||||
@@ -112,14 +110,14 @@
|
||||
<TextBlock
|
||||
x:Name="SubtitleText"
|
||||
MaxWidth="100"
|
||||
Margin="0,-4,0,0"
|
||||
HorizontalAlignment="Left"
|
||||
Margin="0,-2,0,0"
|
||||
HorizontalAlignment="Stretch"
|
||||
VerticalAlignment="Center"
|
||||
FontFamily="Segoe UI"
|
||||
FontSize="10"
|
||||
Foreground="{ThemeResource TextFillColorTertiary}"
|
||||
Text="{TemplateBinding Subtitle}"
|
||||
TextAlignment="Center"
|
||||
TextAlignment="Left"
|
||||
TextTrimming="CharacterEllipsis"
|
||||
TextWrapping="NoWrap" />
|
||||
</StackPanel>
|
||||
@@ -176,6 +174,21 @@
|
||||
<VisualState x:Name="IconHidden">
|
||||
<VisualState.Setters>
|
||||
<Setter Target="ContentGrid.ColumnSpacing" Value="0" />
|
||||
<Setter Target="IconPresenter.Visibility" Value="Collapsed" />
|
||||
</VisualState.Setters>
|
||||
</VisualState>
|
||||
</VisualStateGroup>
|
||||
<VisualStateGroup x:Name="TextAlignmentStates">
|
||||
<VisualState x:Name="TextLeftAligned">
|
||||
<VisualState.Setters>
|
||||
<Setter Target="TitleText.TextAlignment" Value="Left" />
|
||||
<Setter Target="SubtitleText.TextAlignment" Value="Left" />
|
||||
</VisualState.Setters>
|
||||
</VisualState>
|
||||
<VisualState x:Name="TextCentered">
|
||||
<VisualState.Setters>
|
||||
<Setter Target="TitleText.TextAlignment" Value="Center" />
|
||||
<Setter Target="SubtitleText.TextAlignment" Value="Center" />
|
||||
</VisualState.Setters>
|
||||
</VisualState>
|
||||
</VisualStateGroup>
|
||||
|
||||
@@ -23,7 +23,7 @@ public sealed partial class DockItemControl : Control
|
||||
}
|
||||
|
||||
public static readonly DependencyProperty ToolTipProperty =
|
||||
DependencyProperty.Register(nameof(ToolTip), typeof(string), typeof(DockItemControl), new PropertyMetadata(null));
|
||||
DependencyProperty.Register(nameof(ToolTip), typeof(string), typeof(DockItemControl), new PropertyMetadata(null, OnToolTipPropertyChanged));
|
||||
|
||||
public string ToolTip
|
||||
{
|
||||
@@ -31,6 +31,17 @@ public sealed partial class DockItemControl : Control
|
||||
set => SetValue(ToolTipProperty, value);
|
||||
}
|
||||
|
||||
private static void OnToolTipPropertyChanged(DependencyObject d, DependencyPropertyChangedEventArgs e)
|
||||
{
|
||||
if (d is DockItemControl control)
|
||||
{
|
||||
// Collapse the tooltip when the string is null or empty so an
|
||||
// empty tooltip bubble doesn't appear on hover.
|
||||
var text = e.NewValue as string;
|
||||
ToolTipService.SetToolTip(control, string.IsNullOrEmpty(text) ? null : text);
|
||||
}
|
||||
}
|
||||
|
||||
public static readonly DependencyProperty TitleProperty =
|
||||
DependencyProperty.Register(nameof(Title), typeof(string), typeof(DockItemControl), new PropertyMetadata(null, OnTextPropertyChanged));
|
||||
|
||||
@@ -127,50 +138,46 @@ public sealed partial class DockItemControl : Control
|
||||
|
||||
private void UpdateIconVisibility()
|
||||
{
|
||||
if (Icon is IconBox icon)
|
||||
var shouldShowIcon = ShouldShowIcon();
|
||||
if (_iconPresenter is not null)
|
||||
{
|
||||
var dt = icon.DataContext;
|
||||
var src = icon.Source;
|
||||
|
||||
if (_iconPresenter is not null)
|
||||
{
|
||||
// n.b. this might be wrong - I think we always have an Icon (an IconBox),
|
||||
// we need to check if the box has an icon
|
||||
_iconPresenter.Visibility = Icon is null ? Visibility.Collapsed : Visibility.Visible;
|
||||
}
|
||||
|
||||
UpdateIconVisibilityState();
|
||||
_iconPresenter.Visibility = shouldShowIcon ? Visibility.Visible : Visibility.Collapsed;
|
||||
}
|
||||
|
||||
UpdateIconVisibilityState();
|
||||
}
|
||||
|
||||
private void UpdateIconVisibilityState()
|
||||
{
|
||||
var hasIcon = Icon is not null;
|
||||
VisualStateManager.GoToState(this, hasIcon ? "IconVisible" : "IconHidden", true);
|
||||
VisualStateManager.GoToState(this, ShouldShowIcon() ? "IconVisible" : "IconHidden", true);
|
||||
}
|
||||
|
||||
private void UpdateAlignment()
|
||||
{
|
||||
// If this item has both an icon and a label, left align so that the
|
||||
// icons don't wobble if the text changes.
|
||||
//
|
||||
// Otherwise, center align.
|
||||
var requestedTheme = ActualTheme;
|
||||
var isLight = requestedTheme == ElementTheme.Light;
|
||||
var showText = HasText;
|
||||
if (Icon is IconBox icoBox &&
|
||||
icoBox.DataContext is DockItemViewModel item &&
|
||||
item.Icon is IconInfoViewModel icon)
|
||||
HorizontalAlignment = HorizontalAlignment.Stretch;
|
||||
UpdateTextAlignmentState();
|
||||
}
|
||||
|
||||
private bool ShouldShowIcon()
|
||||
{
|
||||
if (Icon is IconBox icoBox)
|
||||
{
|
||||
var showIcon = icon is not null && icon.HasIcon(isLight);
|
||||
if (showText && showIcon)
|
||||
if (icoBox.SourceKey is IconInfoViewModel icon)
|
||||
{
|
||||
HorizontalAlignment = HorizontalAlignment.Left;
|
||||
return;
|
||||
return icon.HasIcon(ActualTheme == ElementTheme.Light);
|
||||
}
|
||||
|
||||
return icoBox.Source is not null;
|
||||
}
|
||||
|
||||
HorizontalAlignment = HorizontalAlignment.Stretch;
|
||||
return Icon is not null;
|
||||
}
|
||||
|
||||
private void UpdateTextAlignmentState()
|
||||
{
|
||||
var verticalDock = _parentDock?.DockSide is DockSide.Left or DockSide.Right;
|
||||
var shouldCenterText = verticalDock && !ShouldShowIcon();
|
||||
VisualStateManager.GoToState(this, shouldCenterText ? "TextCentered" : "TextLeftAligned", true);
|
||||
}
|
||||
|
||||
private void UpdateAllVisibility()
|
||||
@@ -184,12 +191,14 @@ public sealed partial class DockItemControl : Control
|
||||
{
|
||||
base.OnApplyTemplate();
|
||||
IsEnabledChanged -= OnIsEnabledChanged;
|
||||
ActualThemeChanged -= DockItemControl_ActualThemeChanged;
|
||||
|
||||
PointerEntered -= Control_PointerEntered;
|
||||
PointerExited -= Control_PointerExited;
|
||||
Loaded -= DockItemControl_Loaded;
|
||||
Unloaded -= DockItemControl_Unloaded;
|
||||
|
||||
ActualThemeChanged += DockItemControl_ActualThemeChanged;
|
||||
PointerEntered += Control_PointerEntered;
|
||||
PointerExited += Control_PointerExited;
|
||||
Loaded += DockItemControl_Loaded;
|
||||
@@ -218,12 +227,19 @@ public sealed partial class DockItemControl : Control
|
||||
{
|
||||
_parentDock = dock;
|
||||
UpdateInnerMarginForDockSide(dock.DockSide);
|
||||
UpdateAllVisibility();
|
||||
_dockSideCallbackToken = dock.RegisterPropertyChangedCallback(
|
||||
DockControl.DockSideProperty,
|
||||
OnParentDockSideChanged);
|
||||
}
|
||||
}
|
||||
|
||||
private void DockItemControl_ActualThemeChanged(FrameworkElement sender, object args)
|
||||
{
|
||||
UpdateIconVisibility();
|
||||
UpdateAlignment();
|
||||
}
|
||||
|
||||
private void DockItemControl_Unloaded(object sender, RoutedEventArgs e)
|
||||
{
|
||||
if (_parentDock is not null && _dockSideCallbackToken >= 0)
|
||||
@@ -241,6 +257,7 @@ public sealed partial class DockItemControl : Control
|
||||
if (sender is DockControl dock)
|
||||
{
|
||||
UpdateInnerMarginForDockSide(dock.DockSide);
|
||||
UpdateAlignment();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -25,24 +25,13 @@ internal static class DockSettingsToViews
|
||||
{
|
||||
return size switch
|
||||
{
|
||||
DockSize.Small => 32,
|
||||
DockSize.Small => 38,
|
||||
DockSize.Medium => 54,
|
||||
DockSize.Large => 76,
|
||||
_ => throw new NotImplementedException(),
|
||||
};
|
||||
}
|
||||
|
||||
public static double IconSizeForSize(DockSize size)
|
||||
{
|
||||
return size switch
|
||||
{
|
||||
DockSize.Small => 32 / 2,
|
||||
DockSize.Medium => 54 / 2,
|
||||
DockSize.Large => 76 / 2,
|
||||
_ => throw new NotImplementedException(),
|
||||
};
|
||||
}
|
||||
|
||||
public static Microsoft.UI.Xaml.Media.SystemBackdrop? GetSystemBackdrop(DockBackdrop backdrop)
|
||||
{
|
||||
return backdrop switch
|
||||
|
||||
@@ -43,6 +43,13 @@ public sealed partial class ListPage : Page,
|
||||
private ListItemViewModel? _stickySelectedItem;
|
||||
private ListItemViewModel? _lastPushedToVm;
|
||||
|
||||
// A single search-text change can produce multiple ItemsUpdated calls
|
||||
// dispatched as separate UI-thread callbacks. A later "soft" update
|
||||
// (ForceFirstItem = false) must not overwrite a prior force-first
|
||||
// intent. This flag latches true whenever any update requests
|
||||
// force-first and is only cleared once selection stabilizes.
|
||||
private bool _forceFirstPending;
|
||||
|
||||
internal ListViewModel? ViewModel
|
||||
{
|
||||
get => (ListViewModel?)GetValue(ViewModelProperty);
|
||||
@@ -224,6 +231,10 @@ public sealed partial class ListPage : Page,
|
||||
|
||||
_stickySelectedItem = li;
|
||||
|
||||
// User explicitly changed selection — any pending force-first intent
|
||||
// is superseded by the user's navigation.
|
||||
_forceFirstPending = false;
|
||||
|
||||
// Do not Task.Run (it reorders selection updates).
|
||||
vm?.UpdateSelectedItemCommand.Execute(li);
|
||||
|
||||
@@ -606,10 +617,12 @@ public sealed partial class ListPage : Page,
|
||||
|
||||
if (e.NewValue is ListViewModel page)
|
||||
{
|
||||
@this._forceFirstPending = false;
|
||||
page.ItemsUpdated += @this.Page_ItemsUpdated;
|
||||
}
|
||||
else if (e.NewValue is null)
|
||||
{
|
||||
@this._forceFirstPending = false;
|
||||
Logger.LogDebug("cleared view model");
|
||||
}
|
||||
}
|
||||
@@ -620,25 +633,32 @@ public sealed partial class ListPage : Page,
|
||||
private void Page_ItemsUpdated(ListViewModel sender, ItemsUpdatedEventArgs args)
|
||||
{
|
||||
var version = Interlocked.Increment(ref _itemsUpdatedVersion);
|
||||
var forceFirstItem = args.ForceFirstItem;
|
||||
|
||||
// Latch: once any update requests force-first, keep it until consumed.
|
||||
_forceFirstPending |= args.ForceFirstItem;
|
||||
var forceFirstItem = _forceFirstPending;
|
||||
|
||||
// Try to handle selection immediately — items should already be available
|
||||
// since FilteredItems is a direct ObservableCollection bound as ItemsSource.
|
||||
if (!TrySetSelectionAfterUpdate(sender, version, forceFirstItem))
|
||||
// TrySetSelectionAfterUpdate clears _forceFirstPending internally once
|
||||
// selection stabilizes (no repair needed), so we don't clear it here.
|
||||
if (TrySetSelectionAfterUpdate(sender, version, forceFirstItem))
|
||||
{
|
||||
// Fallback: binding hasn't propagated yet, defer to next tick.
|
||||
_ = DispatcherQueue.TryEnqueue(
|
||||
Microsoft.UI.Dispatching.DispatcherQueuePriority.Low,
|
||||
() =>
|
||||
{
|
||||
if (version != Volatile.Read(ref _itemsUpdatedVersion))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
TrySetSelectionAfterUpdate(sender, version, forceFirstItem);
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Fallback: binding hasn't propagated yet, defer to next tick.
|
||||
_ = DispatcherQueue.TryEnqueue(
|
||||
Microsoft.UI.Dispatching.DispatcherQueuePriority.Low,
|
||||
() =>
|
||||
{
|
||||
if (version != Volatile.Read(ref _itemsUpdatedVersion))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
TrySetSelectionAfterUpdate(sender, version, forceFirstItem);
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -722,13 +742,18 @@ public sealed partial class ListPage : Page,
|
||||
using (SuppressSelectionChangedScope())
|
||||
{
|
||||
ListItemViewModel? stickyRestored = null;
|
||||
ListItemViewModel? firstSelected = null;
|
||||
|
||||
if (!forceFirstItem &&
|
||||
_stickySelectedItem is not null &&
|
||||
items.Contains(_stickySelectedItem) &&
|
||||
!IsSeparator(_stickySelectedItem))
|
||||
{
|
||||
// Preserve sticky selection for nested dynamic updates.
|
||||
// Restore sticky selection only when force-first is not
|
||||
// active. The latched _forceFirstPending flag guarantees
|
||||
// that a prior force-first intent survives superseding
|
||||
// soft updates, so we never accidentally restore a stale
|
||||
// sticky item when the list was meant to reset.
|
||||
ItemView.SelectedItem = _stickySelectedItem;
|
||||
stickyRestored = _stickySelectedItem;
|
||||
}
|
||||
@@ -736,6 +761,7 @@ public sealed partial class ListPage : Page,
|
||||
{
|
||||
// Select the first interactive item.
|
||||
ItemView.SelectedItem = items[firstUsefulIndex];
|
||||
firstSelected = ItemView.SelectedItem as ListItemViewModel;
|
||||
}
|
||||
|
||||
// Prevent any pending "scroll on selection" logic from fighting this.
|
||||
@@ -748,10 +774,17 @@ public sealed partial class ListPage : Page,
|
||||
return;
|
||||
}
|
||||
|
||||
ItemView.UpdateLayout();
|
||||
|
||||
if (stickyRestored is not null)
|
||||
{
|
||||
ScrollToItem(stickyRestored);
|
||||
}
|
||||
else if (firstSelected is not null)
|
||||
{
|
||||
ScrollToItem(firstSelected);
|
||||
ResetScrollToTop();
|
||||
}
|
||||
else
|
||||
{
|
||||
ResetScrollToTop();
|
||||
@@ -761,7 +794,11 @@ public sealed partial class ListPage : Page,
|
||||
}
|
||||
else
|
||||
{
|
||||
// Selection is valid and unchanged, just make sure the item is visible
|
||||
// Selection is valid and unchanged: the force-first intent (if any)
|
||||
// has been fully delivered and selection has stabilized. Safe to clear.
|
||||
_forceFirstPending = false;
|
||||
|
||||
// Just make sure the item is visible
|
||||
if (_stickySelectedItem is ListItemViewModel li)
|
||||
{
|
||||
_ = DispatcherQueue.TryEnqueue(Microsoft.UI.Dispatching.DispatcherQueuePriority.Low, () =>
|
||||
|
||||
@@ -94,6 +94,7 @@ public sealed partial class MainWindow : WindowEx,
|
||||
private WindowPosition _currentWindowPosition = new();
|
||||
|
||||
private bool _preventHideWhenDeactivated;
|
||||
private bool _isLoadedFromDock;
|
||||
|
||||
private DevRibbon? _devRibbon;
|
||||
|
||||
@@ -142,7 +143,7 @@ public sealed partial class MainWindow : WindowEx,
|
||||
|
||||
this.SetIcon();
|
||||
AppWindow.Title = RS_.GetString("AppName");
|
||||
RestoreWindowPosition();
|
||||
RestoreWindowPositionFromSavedSettings();
|
||||
UpdateWindowPositionInMemory();
|
||||
|
||||
WeakReferenceMessenger.Default.Register<DismissMessage>(this);
|
||||
@@ -245,10 +246,26 @@ public sealed partial class MainWindow : WindowEx,
|
||||
|
||||
private void PositionCentered(DisplayArea displayArea)
|
||||
{
|
||||
// Use the saved window size when available so that a dock-resized HWND
|
||||
// (hidden but not destroyed) doesn't dictate the size on normal reopen.
|
||||
SizeInt32 windowSize;
|
||||
int windowDpi;
|
||||
|
||||
if (_currentWindowPosition.IsSizeValid)
|
||||
{
|
||||
windowSize = new SizeInt32(_currentWindowPosition.Width, _currentWindowPosition.Height);
|
||||
windowDpi = _currentWindowPosition.Dpi;
|
||||
}
|
||||
else
|
||||
{
|
||||
windowSize = AppWindow.Size;
|
||||
windowDpi = (int)this.GetDpiForWindow();
|
||||
}
|
||||
|
||||
var rect = WindowPositionHelper.CenterOnDisplay(
|
||||
displayArea,
|
||||
AppWindow.Size,
|
||||
(int)this.GetDpiForWindow());
|
||||
displayArea,
|
||||
windowSize,
|
||||
windowDpi);
|
||||
|
||||
if (rect is not null)
|
||||
{
|
||||
@@ -256,10 +273,9 @@ public sealed partial class MainWindow : WindowEx,
|
||||
}
|
||||
}
|
||||
|
||||
private void RestoreWindowPosition()
|
||||
private void RestoreWindowPosition(WindowPosition? savedPosition)
|
||||
{
|
||||
var settings = App.Current.Services.GetService<SettingsModel>();
|
||||
if (settings?.LastWindowPosition is not { Width: > 0, Height: > 0 } savedPosition)
|
||||
if (savedPosition?.IsSizeValid != true)
|
||||
{
|
||||
// don't try to restore if the saved position is invalid, just recenter
|
||||
PositionCentered();
|
||||
@@ -274,6 +290,17 @@ public sealed partial class MainWindow : WindowEx,
|
||||
MoveAndResizeDpiAware(newRect);
|
||||
}
|
||||
|
||||
private void RestoreWindowPositionFromSavedSettings()
|
||||
{
|
||||
var settings = App.Current.Services.GetService<SettingsModel>();
|
||||
RestoreWindowPosition(settings?.LastWindowPosition);
|
||||
}
|
||||
|
||||
private void RestoreWindowPositionFromMemory()
|
||||
{
|
||||
RestoreWindowPosition(_currentWindowPosition);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Moves and resizes the window while suppressing WM_DPICHANGED.
|
||||
/// The caller is expected to provide a rect already scaled for the target display's DPI.
|
||||
@@ -678,6 +705,8 @@ public sealed partial class MainWindow : WindowEx,
|
||||
|
||||
public void Receive(ShowWindowMessage message)
|
||||
{
|
||||
_isLoadedFromDock = false;
|
||||
|
||||
var settings = App.Current.Services.GetService<SettingsModel>()!;
|
||||
|
||||
// Start session tracking
|
||||
@@ -690,6 +719,13 @@ public sealed partial class MainWindow : WindowEx,
|
||||
|
||||
internal void Receive(ShowPaletteAtMessage message)
|
||||
{
|
||||
_isLoadedFromDock = true;
|
||||
|
||||
// Reset the size in case users have resized a dock window.
|
||||
// Ideally in the future, we'll have defined sizes that opening
|
||||
// a dock window will adhere to, but alas, that's the future.
|
||||
RestoreWindowPositionFromMemory();
|
||||
|
||||
ShowHwnd(HWND.Null, message.PosPixels, message.Anchor);
|
||||
}
|
||||
|
||||
@@ -860,12 +896,17 @@ public sealed partial class MainWindow : WindowEx,
|
||||
internal void MainWindow_Closed(object sender, WindowEventArgs args)
|
||||
{
|
||||
var serviceProvider = App.Current.Services;
|
||||
UpdateWindowPositionInMemory();
|
||||
|
||||
if (!_isLoadedFromDock)
|
||||
{
|
||||
UpdateWindowPositionInMemory();
|
||||
}
|
||||
|
||||
var settings = serviceProvider.GetService<SettingsModel>();
|
||||
if (settings is not null)
|
||||
{
|
||||
// a quick sanity check, so we don't overwrite correct values
|
||||
// If we were last shown from the dock, _currentWindowPosition still holds
|
||||
// the last non-dock placement because dock sessions intentionally skip updates.
|
||||
if (_currentWindowPosition.IsSizeValid)
|
||||
{
|
||||
settings.LastWindowPosition = _currentWindowPosition;
|
||||
@@ -960,7 +1001,11 @@ public sealed partial class MainWindow : WindowEx,
|
||||
if (args.WindowActivationState == WindowActivationState.Deactivated)
|
||||
{
|
||||
// Save the current window position before hiding the window
|
||||
UpdateWindowPositionInMemory();
|
||||
// but not when opened from dock — preserve the pre-dock size.
|
||||
if (!_isLoadedFromDock)
|
||||
{
|
||||
UpdateWindowPositionInMemory();
|
||||
}
|
||||
|
||||
// If there's a debugger attached...
|
||||
if (System.Diagnostics.Debugger.IsAttached)
|
||||
|
||||
@@ -29,6 +29,15 @@
|
||||
<UseWinRT>true</UseWinRT>
|
||||
</PropertyGroup>
|
||||
|
||||
<!-- Defines: to quickly add project-wide define constants / feature flags -->
|
||||
<PropertyGroup Condition=" '$(Configuration)' == 'DEBUG' ">
|
||||
<DefineConstants>$(DefineConstants);CMDPAL_FF_EXAMPLE_FLAG</DefineConstants>
|
||||
</PropertyGroup>
|
||||
|
||||
<PropertyGroup Condition=" '$(Configuration)' == 'RELEASE' ">
|
||||
<DefineConstants>$(DefineConstants)</DefineConstants>
|
||||
</PropertyGroup>
|
||||
|
||||
<!-- For debugging purposes, uncomment this block to enable AOT builds -->
|
||||
<!-- <PropertyGroup>
|
||||
<EnableCmdPalAOT>true</EnableCmdPalAOT>
|
||||
|
||||
@@ -322,6 +322,7 @@ public sealed partial class ShellPage : Microsoft.UI.Xaml.Controls.Page,
|
||||
|
||||
try
|
||||
{
|
||||
DetailsContent.ChangeView(null, 0, null, true);
|
||||
ViewModel.Details = details;
|
||||
PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(HasHeroImage)));
|
||||
ViewModel.IsDetailsVisible = true;
|
||||
@@ -586,12 +587,13 @@ public sealed partial class ShellPage : Microsoft.UI.Xaml.Controls.Page,
|
||||
|
||||
if (shouldSearchBoxBeVisible || page is not ContentPage)
|
||||
{
|
||||
ViewModel.IsSearchBoxVisible = shouldSearchBoxBeVisible;
|
||||
|
||||
if (HostWindow?.IsVisibleToUser != true)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
ViewModel.IsSearchBoxVisible = shouldSearchBoxBeVisible;
|
||||
SearchBox.Focus(FocusState.Programmatic);
|
||||
SearchBox.SelectSearch();
|
||||
}
|
||||
|
||||
@@ -21,7 +21,7 @@
|
||||
<RowDefinition Height="*" />
|
||||
</Grid.RowDefinitions>
|
||||
<ScrollViewer Grid.Row="1">
|
||||
<Grid Padding="16">
|
||||
<Grid Padding="16,0,16,16">
|
||||
<StackPanel
|
||||
MaxWidth="1000"
|
||||
HorizontalAlignment="Stretch"
|
||||
@@ -37,6 +37,12 @@
|
||||
<RepositionThemeTransition IsStaggeringEnabled="False" />
|
||||
</StackPanel.ChildrenTransitions>-->
|
||||
|
||||
<HyperlinkButton
|
||||
x:Uid="CmdPalDock_LearnMore"
|
||||
Margin="0,0,0,36"
|
||||
Padding="0"
|
||||
FontWeight="SemiBold"
|
||||
NavigateUri="https://aka.ms/cmdpal-dock" />
|
||||
<!-- Enable Dock -->
|
||||
<controls:SettingsCard x:Uid="Settings_GeneralPage_EnableDock_SettingsCard" HeaderIcon="{ui:FontIcon Glyph=}">
|
||||
<ToggleSwitch IsOn="{x:Bind ViewModel.EnableDock, Mode=TwoWay}" />
|
||||
@@ -207,7 +213,7 @@
|
||||
</controls:SettingsExpander>
|
||||
|
||||
<!-- Bands Section -->
|
||||
<TextBlock x:Uid="DockBandsSettingsHeader" Style="{StaticResource SettingsSectionHeaderTextBlockStyle}" />
|
||||
<!-- <TextBlock x:Uid="DockBandsSettingsHeader" Style="{StaticResource SettingsSectionHeaderTextBlockStyle}" />
|
||||
|
||||
<ItemsRepeater ItemsSource="{x:Bind AllDockBandItems}">
|
||||
<ItemsRepeater.Layout>
|
||||
@@ -235,7 +241,7 @@
|
||||
</controls:SettingsCard>
|
||||
</DataTemplate>
|
||||
</ItemsRepeater.ItemTemplate>
|
||||
</ItemsRepeater>
|
||||
</ItemsRepeater>-->
|
||||
</StackPanel>
|
||||
</Grid>
|
||||
</ScrollViewer>
|
||||
|
||||
@@ -178,7 +178,7 @@ public sealed partial class DockSettingsPage : Page
|
||||
|
||||
var tlcManager = App.Current.Services.GetService<TopLevelCommandManager>()!;
|
||||
|
||||
foreach (var item in tlcManager.DockBands)
|
||||
foreach (var item in tlcManager.GetDockBandsSnapshot())
|
||||
{
|
||||
if (item.IsDockBand)
|
||||
{
|
||||
@@ -197,7 +197,7 @@ public sealed partial class DockSettingsPage : Page
|
||||
var tlcManager = App.Current.Services.GetService<TopLevelCommandManager>()!;
|
||||
var settingsModel = App.Current.Services.GetService<SettingsModel>()!;
|
||||
var dockViewModel = App.Current.Services.GetService<DockViewModel>()!;
|
||||
var allBands = tlcManager.DockBands;
|
||||
var allBands = tlcManager.GetDockBandsSnapshot();
|
||||
foreach (var band in allBands)
|
||||
{
|
||||
var setting = band.DockBandSettings;
|
||||
|
||||
@@ -19,7 +19,7 @@
|
||||
<RowDefinition Height="*" />
|
||||
</Grid.RowDefinitions>
|
||||
<ScrollViewer Grid.Row="1">
|
||||
<Grid Padding="16">
|
||||
<Grid Padding="16,0,16,16">
|
||||
<StackPanel
|
||||
MaxWidth="1000"
|
||||
HorizontalAlignment="Stretch"
|
||||
@@ -36,6 +36,11 @@
|
||||
</StackPanel.ChildrenTransitions>-->
|
||||
|
||||
<!-- 'Activation' section -->
|
||||
<HyperlinkButton
|
||||
x:Uid="CmdPal_LearnMore"
|
||||
Padding="0"
|
||||
FontWeight="SemiBold"
|
||||
NavigateUri="https://aka.ms/cmdpal" />
|
||||
|
||||
<TextBlock x:Uid="ActivationSettingsHeader" Style="{StaticResource SettingsSectionHeaderTextBlockStyle}" />
|
||||
<controls:SettingsExpander
|
||||
|
||||
@@ -76,7 +76,11 @@
|
||||
x:Name="DockSettingsPageNavItem"
|
||||
x:Uid="Settings_GeneralPage_NavigationViewItem_Dock"
|
||||
Icon="{ui:FontIcon Glyph=}"
|
||||
Tag="Dock" />
|
||||
Tag="Dock">
|
||||
<NavigationViewItem.InfoBadge>
|
||||
<InfoBadge Style="{StaticResource NewInfoBadge}" />
|
||||
</NavigationViewItem.InfoBadge>
|
||||
</NavigationViewItem>
|
||||
<!-- "Internal Tools" page item is added dynamically from code -->
|
||||
</NavigationView.MenuItems>
|
||||
<Grid>
|
||||
|
||||
@@ -428,7 +428,7 @@ Right-click to remove the key combination, thereby deactivating the shortcut.</v
|
||||
<data name="Settings_GeneralPage_DisableAnimations_SettingsCard.Description" xml:space="preserve">
|
||||
<value>Disable animations when switching between pages</value>
|
||||
</data>
|
||||
<data name="Settings_GeneralPage_EnableDock_SettingsCard.Header" xml:space="preserve">
|
||||
<data name="Settings_GeneralPage_EnableDock_SettingsCard.Header" xml:space="preserve">
|
||||
<value>Enable Dock</value>
|
||||
</data>
|
||||
<data name="Settings_GeneralPage_EnableDock_SettingsCard.Description" xml:space="preserve">
|
||||
@@ -757,23 +757,17 @@ Right-click to remove the key combination, thereby deactivating the shortcut.</v
|
||||
<data name="Settings_GeneralPage_VersionNo" xml:space="preserve">
|
||||
<value>Version {0}</value>
|
||||
</data>
|
||||
<data name="Settings_NavigationViewItem_DockAppearance.Content" xml:space="preserve">
|
||||
<value>Dock Appearance</value>
|
||||
</data>
|
||||
<data name="Settings_PageTitles_DockAppearancePage" xml:space="preserve">
|
||||
<value>Dock Appearance</value>
|
||||
</data>
|
||||
<data name="DockAppearance_AppTheme_SettingsCard.Header" xml:space="preserve">
|
||||
<value>Dock theme mode</value>
|
||||
<value>Theme mode</value>
|
||||
</data>
|
||||
<data name="DockAppearance_AppTheme_SettingsCard.Description" xml:space="preserve">
|
||||
<value>Select which theme to display for the dock</value>
|
||||
<value>Select which theme to display</value>
|
||||
</data>
|
||||
<data name="DockAppearance_Backdrop_SettingsCard.Header" xml:space="preserve">
|
||||
<value>Material</value>
|
||||
</data>
|
||||
<data name="DockAppearance_Backdrop_SettingsCard.Description" xml:space="preserve">
|
||||
<value>Select the visual material used for the dock</value>
|
||||
<value>Select the visual material</value>
|
||||
</data>
|
||||
<data name="DockAppearance_Backdrop_Mica.Content" xml:space="preserve">
|
||||
<value>Mica</value>
|
||||
@@ -882,17 +876,17 @@ Right-click to remove the key combination, thereby deactivating the shortcut.</v
|
||||
<value>K</value>
|
||||
<comment>Keyboard key</comment>
|
||||
</data>
|
||||
<data name="ConfigureShortcut" xml:space="preserve">
|
||||
<data name="ConfigureShortcut" xml:space="preserve">
|
||||
<value>Configure shortcut</value>
|
||||
</data>
|
||||
<data name="ConfigureShortcutText.Text" xml:space="preserve">
|
||||
<value>Assign shortcut</value>
|
||||
</data>
|
||||
<data name="DockAppearance_DockPosition_SettingsCard.Header" xml:space="preserve">
|
||||
<value>Dock position</value>
|
||||
<value>Position</value>
|
||||
</data>
|
||||
<data name="DockAppearance_DockPosition_SettingsExpander.Description" xml:space="preserve">
|
||||
<value>Choose where the dock appears on your screen</value>
|
||||
<value>Choose where the Dock appears on your screen</value>
|
||||
</data>
|
||||
<data name="DockAppearance_DockPosition_Left.Content" xml:space="preserve">
|
||||
<value>Left</value>
|
||||
@@ -906,12 +900,6 @@ Right-click to remove the key combination, thereby deactivating the shortcut.</v
|
||||
<data name="DockAppearance_DockPosition_Bottom.Content" xml:space="preserve">
|
||||
<value>Bottom</value>
|
||||
</data>
|
||||
<data name="DockAppearance_ShowLabels_CheckBox.Header" xml:space="preserve">
|
||||
<value>Show labels</value>
|
||||
</data>
|
||||
<data name="DockAppearance_ShowLabels_CheckBox.Description" xml:space="preserve">
|
||||
<value>Show labels for dock items by default</value>
|
||||
</data>
|
||||
<data name="top_level_pin_command_name" xml:space="preserve">
|
||||
<value>Pin to home</value>
|
||||
<comment>Command name for pinning an item to the top level list of commands</comment>
|
||||
@@ -921,11 +909,11 @@ Right-click to remove the key combination, thereby deactivating the shortcut.</v
|
||||
<comment>Command name for unpinning an item from the top level list of commands</comment>
|
||||
</data>
|
||||
<data name="dock_pin_command_name" xml:space="preserve">
|
||||
<value>Pin to dock</value>
|
||||
<value>Pin to Dock</value>
|
||||
<comment>Command name for pinning an item to the dock</comment>
|
||||
</data>
|
||||
<data name="dock_unpin_command_name" xml:space="preserve">
|
||||
<value>Unpin from dock</value>
|
||||
<value>Unpin from Dock</value>
|
||||
<comment>Command name for unpinning an item from the dock</comment>
|
||||
</data>
|
||||
<data name="FiltersDropDown.[using:Microsoft.UI.Xaml.Automation]AutomationProperties.Name" xml:space="preserve">
|
||||
@@ -949,4 +937,50 @@ Right-click to remove the key combination, thereby deactivating the shortcut.</v
|
||||
<data name="FiltersDropDown_NoResults.Text" xml:space="preserve">
|
||||
<value>No results</value>
|
||||
</data>
|
||||
<data name="Dock_EditMode_Labels.Text" xml:space="preserve">
|
||||
<value>Labels</value>
|
||||
</data>
|
||||
<data name="Dock_EditMode_ShowTitles.Text" xml:space="preserve">
|
||||
<value>Show titles</value>
|
||||
</data>
|
||||
<data name="Dock_EditMode_ShowSubtitles.Text" xml:space="preserve">
|
||||
<value>Show subtitles</value>
|
||||
</data>
|
||||
<data name="Dock_EditMode_Unpin.Text" xml:space="preserve">
|
||||
<value>Unpin</value>
|
||||
</data>
|
||||
<data name="Dock_AddBand_NoCommandsAvailable.Text" xml:space="preserve">
|
||||
<value>All available bands are already pinned.</value>
|
||||
</data>
|
||||
<data name="Dock_Pin_Instruction.Text" xml:space="preserve">
|
||||
<value>To pin commands, extensions or apps, use the Pin to Dock command in Command Palette.</value>
|
||||
</data>
|
||||
<data name="Dock_Bands_Header.Text" xml:space="preserve">
|
||||
<value>Bands</value>
|
||||
</data>
|
||||
<data name="Dock_AddBand_StartTooltip.[using:Microsoft.UI.Xaml.Controls]ToolTipService.ToolTip" xml:space="preserve">
|
||||
<value>Add band to start</value>
|
||||
</data>
|
||||
<data name="Dock_AddBand_CenterTooltip.[using:Microsoft.UI.Xaml.Controls]ToolTipService.ToolTip" xml:space="preserve">
|
||||
<value>Add band to center</value>
|
||||
</data>
|
||||
<data name="Dock_AddBand_EndTooltip.[using:Microsoft.UI.Xaml.Controls]ToolTipService.ToolTip" xml:space="preserve">
|
||||
<value>Add band to end</value>
|
||||
</data>
|
||||
<data name="Dock_EditMode_Save.Content" xml:space="preserve">
|
||||
<value>Save</value>
|
||||
</data>
|
||||
<data name="Dock_EditMode_Discard.Content" xml:space="preserve">
|
||||
<value>Discard</value>
|
||||
</data>
|
||||
<data name="CmdPal_LearnMore.Content" xml:space="preserve">
|
||||
<value>Learn more about Command Palette</value>
|
||||
</data>
|
||||
<data name="CmdPalDock_LearnMore.Content" xml:space="preserve">
|
||||
<value>Learn more about Command Palette Dock</value>
|
||||
</data>
|
||||
<data name="SettingsPage_NewInfoBadge.Text" xml:space="preserve">
|
||||
<value>NEW</value>
|
||||
<comment>Must be all caps</comment>
|
||||
</data>
|
||||
</root>
|
||||
@@ -22,4 +22,25 @@
|
||||
Orientation="Vertical"
|
||||
Spacing="{StaticResource SettingsCardSpacing}" />
|
||||
|
||||
<Style x:Key="NewInfoBadge" TargetType="InfoBadge">
|
||||
<Setter Property="Padding" Value="5,1,5,2" />
|
||||
<Setter Property="Template">
|
||||
<Setter.Value>
|
||||
<ControlTemplate TargetType="InfoBadge">
|
||||
<Border
|
||||
x:Name="RootGrid"
|
||||
Padding="{TemplateBinding Padding}"
|
||||
Background="{TemplateBinding Background}"
|
||||
CornerRadius="{Binding RelativeSource={RelativeSource TemplatedParent}, Path=TemplateSettings.InfoBadgeCornerRadius}">
|
||||
<TextBlock
|
||||
x:Uid="SettingsPage_NewInfoBadge"
|
||||
HorizontalAlignment="Center"
|
||||
VerticalAlignment="Center"
|
||||
FontSize="10" />
|
||||
</Border>
|
||||
</ControlTemplate>
|
||||
</Setter.Value>
|
||||
</Setter>
|
||||
</Style>
|
||||
|
||||
</ResourceDictionary>
|
||||
|
||||
@@ -17,12 +17,12 @@ namespace Microsoft.CmdPal.Ext.System.UnitTests;
|
||||
public class QueryTests : CommandPaletteUnitTestBase
|
||||
{
|
||||
[DataTestMethod]
|
||||
[DataRow("shutdown", "Shutdown")]
|
||||
[DataRow("restart", "Restart")]
|
||||
[DataRow("sign out", "Sign out")]
|
||||
[DataRow("lock", "Lock")]
|
||||
[DataRow("sleep", "Sleep")]
|
||||
[DataRow("hibernate", "Hibernate")]
|
||||
[DataRow("shutdown", "Shutdown computer")]
|
||||
[DataRow("restart", "Restart computer")]
|
||||
[DataRow("sign out", "Sign out of computer")]
|
||||
[DataRow("lock", "Lock computer")]
|
||||
[DataRow("sleep", "Put computer to sleep")]
|
||||
[DataRow("hibernate", "Hibernate computer")]
|
||||
[DataRow("open recycle", "Open Recycle Bin")]
|
||||
[DataRow("empty recycle", "Empty Recycle Bin")]
|
||||
[DataRow("uefi", "UEFI firmware settings")]
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
// Copyright (c) Microsoft Corporation
|
||||
// The Microsoft Corporation licenses this file to you under the MIT license.
|
||||
// See the LICENSE file in the project root for more information.
|
||||
|
||||
using System;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.CmdPal.UI.ViewModels.Models;
|
||||
using Microsoft.CommandPalette.Extensions.Toolkit;
|
||||
using Microsoft.VisualStudio.TestTools.UnitTesting;
|
||||
|
||||
namespace Microsoft.CmdPal.UI.ViewModels.UnitTests;
|
||||
|
||||
[TestClass]
|
||||
public class CommandItemViewModelTests
|
||||
{
|
||||
private sealed class TestPageContext : IPageContext
|
||||
{
|
||||
public TaskScheduler Scheduler => TaskScheduler.Default;
|
||||
|
||||
public ICommandProviderContext ProviderContext => CommandProviderContext.Empty;
|
||||
|
||||
public void ShowException(Exception ex, string? extensionHint = null)
|
||||
{
|
||||
throw new AssertFailedException($"Unexpected exception from view model: {ex}");
|
||||
}
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void MoreCommandsAndAllCommands_ReturnSnapshots()
|
||||
{
|
||||
// The public getters should return cached read-only snapshots, so
|
||||
// repeated reads don't allocate a new list when the backing data hasn't
|
||||
// changed.
|
||||
var pageContext = new TestPageContext();
|
||||
var item = new CommandItem(new NoOpCommand { Name = "Primary" })
|
||||
{
|
||||
Title = "Primary",
|
||||
MoreCommands =
|
||||
[
|
||||
new CommandContextItem(new NoOpCommand { Name = "Secondary" }),
|
||||
],
|
||||
};
|
||||
|
||||
var viewModel = new CommandItemViewModel(new(item), new(pageContext), DefaultContextMenuFactory.Instance);
|
||||
viewModel.SlowInitializeProperties();
|
||||
|
||||
var moreCommands = viewModel.MoreCommands;
|
||||
var allCommands = viewModel.AllCommands;
|
||||
|
||||
Assert.AreSame(moreCommands, viewModel.MoreCommands);
|
||||
Assert.AreSame(allCommands, viewModel.AllCommands);
|
||||
Assert.AreEqual(1, moreCommands.Count);
|
||||
Assert.AreEqual(2, allCommands.Count);
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void SecondaryCommand_IgnoresLeadingSeparators()
|
||||
{
|
||||
// SecondaryCommand/HasMoreCommands should be derived from the first actual command item,
|
||||
// not from the raw first entry in MoreCommands.
|
||||
var pageContext = new TestPageContext();
|
||||
var item = new CommandItem(new NoOpCommand { Name = "Primary" })
|
||||
{
|
||||
Title = "Primary",
|
||||
MoreCommands =
|
||||
[
|
||||
new Separator("Group"),
|
||||
new CommandContextItem(new NoOpCommand { Name = "Secondary" }),
|
||||
],
|
||||
};
|
||||
|
||||
var viewModel = new CommandItemViewModel(new(item), new(pageContext), DefaultContextMenuFactory.Instance);
|
||||
viewModel.SlowInitializeProperties();
|
||||
|
||||
Assert.IsTrue(viewModel.HasMoreCommands);
|
||||
Assert.IsNotNull(viewModel.SecondaryCommand);
|
||||
Assert.AreEqual("Secondary", viewModel.SecondaryCommand.Name);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
// 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.Threading.Tasks;
|
||||
using Microsoft.CommandPalette.Extensions;
|
||||
using Microsoft.CommandPalette.Extensions.Toolkit;
|
||||
using Microsoft.VisualStudio.TestTools.UnitTesting;
|
||||
|
||||
namespace Microsoft.CmdPal.UI.ViewModels.UnitTests;
|
||||
|
||||
[TestClass]
|
||||
public partial class ContentPageViewModelTests
|
||||
{
|
||||
private sealed partial class TestAppExtensionHost : AppExtensionHost
|
||||
{
|
||||
public override string? GetExtensionDisplayName() => "Test Host";
|
||||
}
|
||||
|
||||
private sealed partial class TestContentPage : ContentPage
|
||||
{
|
||||
public override IContent[] GetContent() => [];
|
||||
}
|
||||
|
||||
private static CommandContextItem Command(string name) => new(new NoOpCommand { Name = name });
|
||||
|
||||
private static ContentPageViewModel CreateViewModel(TestContentPage page) =>
|
||||
new(page, TaskScheduler.Default, new TestAppExtensionHost(), CommandProviderContext.Empty);
|
||||
|
||||
[TestMethod]
|
||||
public void AllCommandsAndMoreCommands_ReturnCachedSnapshots()
|
||||
{
|
||||
// Content pages should expose stable snapshots, not the live Commands
|
||||
// list, so repeated reads don't allocate and callers can't observe
|
||||
// in-place list mutations.
|
||||
var page = new TestContentPage
|
||||
{
|
||||
Id = "content.page",
|
||||
Name = "Content Page",
|
||||
Title = "Content Page",
|
||||
Commands =
|
||||
[
|
||||
Command("Primary"),
|
||||
Command("Secondary"),
|
||||
],
|
||||
};
|
||||
|
||||
var viewModel = CreateViewModel(page);
|
||||
viewModel.InitializeProperties();
|
||||
|
||||
var allCommands = viewModel.AllCommands;
|
||||
var moreCommands = viewModel.MoreCommands;
|
||||
|
||||
Assert.AreSame(allCommands, viewModel.AllCommands);
|
||||
Assert.AreSame(moreCommands, viewModel.MoreCommands);
|
||||
Assert.AreEqual(2, allCommands.Count);
|
||||
Assert.AreEqual(1, moreCommands.Count);
|
||||
Assert.AreEqual("Primary", viewModel.PrimaryCommand?.Name);
|
||||
Assert.AreEqual("Secondary", viewModel.SecondaryCommand?.Name);
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void CommandsUpdate_RefreshesSnapshotsConsistently()
|
||||
{
|
||||
// Updating the model commands should swap in a new coherent snapshot.
|
||||
// The old snapshots stay intact, and the new cached values agree on
|
||||
// counts, primary/secondary commands, and "has more" state.
|
||||
var page = new TestContentPage
|
||||
{
|
||||
Id = "content.page",
|
||||
Name = "Content Page",
|
||||
Title = "Content Page",
|
||||
Commands =
|
||||
[
|
||||
Command("Primary"),
|
||||
Command("Secondary"),
|
||||
],
|
||||
};
|
||||
|
||||
var viewModel = CreateViewModel(page);
|
||||
viewModel.InitializeProperties();
|
||||
|
||||
var oldAllCommands = viewModel.AllCommands;
|
||||
var oldMoreCommands = viewModel.MoreCommands;
|
||||
|
||||
page.Commands =
|
||||
[
|
||||
Command("Updated Primary"),
|
||||
new Separator("Group"),
|
||||
Command("Updated Secondary"),
|
||||
];
|
||||
|
||||
Assert.AreEqual(2, oldAllCommands.Count);
|
||||
Assert.AreEqual(1, oldMoreCommands.Count);
|
||||
|
||||
Assert.AreEqual(3, viewModel.AllCommands.Count);
|
||||
Assert.AreEqual(2, viewModel.MoreCommands.Count);
|
||||
Assert.IsTrue(viewModel.HasCommands);
|
||||
Assert.IsTrue(viewModel.HasMoreCommands);
|
||||
Assert.AreEqual("Updated Primary", viewModel.PrimaryCommand?.Name);
|
||||
Assert.AreEqual("Updated Secondary", viewModel.SecondaryCommand?.Name);
|
||||
Assert.AreEqual("Updated Secondary", viewModel.SecondaryCommandName);
|
||||
}
|
||||
}
|
||||
@@ -124,6 +124,6 @@ public class BasicTests : CommandPaletteTestBase
|
||||
|
||||
SetSearchBox("Sleep");
|
||||
|
||||
Assert.IsNotNull(this.Find<NavigationViewItem>("Sleep"));
|
||||
Assert.IsNotNull(this.Find<NavigationViewItem>("Put computer to sleep"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
# Local PowerToys Extension Development
|
||||
|
||||
This guide is for iterating on `src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.PowerToys/Microsoft.CmdPal.Ext.PowerToys.csproj`.
|
||||
|
||||
The extension is registered through the shared sparse package defined in `src/PackageIdentity/AppxManifest.xml`. That manifest declares `Microsoft.CmdPal.Ext.PowerToys.exe` at the sparse package root, so the sparse package and the extension must be built for the same platform and configuration, for example `x64\Debug`.
|
||||
|
||||
## Local development loop
|
||||
|
||||
1. Build `src/PackageIdentity/PackageIdentity.vcxproj`.
|
||||
|
||||
This creates `PowerToysSparse.msix` in the repo output root for the selected platform and configuration, and prints the `Add-AppxPackage` command you should run next.
|
||||
|
||||
2. Trust the development certificate before running `Add-AppxPackage`.
|
||||
|
||||
The `PackageIdentity` build creates or reuses `src/PackageIdentity/.user/PowerToysSparse.certificate.sample.cer`.
|
||||
|
||||
Import it into `CurrentUser\TrustedPeople`:
|
||||
|
||||
```powershell
|
||||
$repoRoot = "C:/git/PowerToys"
|
||||
Import-Certificate -FilePath "$repoRoot/src/PackageIdentity/.user/PowerToysSparse.certificate.sample.cer" -CertStoreLocation Cert:\CurrentUser\TrustedPeople
|
||||
```
|
||||
|
||||
If Windows still reports a trust failure such as `0x800B0109`, also import the same certificate into `Cert:\CurrentUser\TrustedRoot`.
|
||||
|
||||
3. Run the `Add-AppxPackage` command printed by the `PackageIdentity` build.
|
||||
|
||||
That registers `Microsoft.PowerToys.SparseApp` as a sparse package and points it at the matching output root through `-ExternalLocation`.
|
||||
|
||||
The command will look like this:
|
||||
|
||||
```powershell
|
||||
Add-AppxPackage -Path "<repo>\<Platform>\<Configuration>\PowerToysSparse.msix" -ExternalLocation "<repo>\<Platform>\<Configuration>"
|
||||
```
|
||||
|
||||
4. Build `src/modules/cmdpal/ext/Microsoft.CmdPal.Ext.PowerToys/Microsoft.CmdPal.Ext.PowerToys.csproj` in the same platform and configuration.
|
||||
|
||||
This project writes `Microsoft.CmdPal.Ext.PowerToys.exe` directly into the sparse package root, such as `x64\Debug` or `ARM64\Debug`. That matches the `Executable="Microsoft.CmdPal.Ext.PowerToys.exe"` entry in `src/PackageIdentity/AppxManifest.xml`.
|
||||
|
||||
5. Restart Command Palette.
|
||||
|
||||
Close any running CmdPal instance and launch it again so it reloads app extensions and picks up the rebuilt `Microsoft.CmdPal.Ext.PowerToys` binaries.
|
||||
|
||||
## When to repeat each step
|
||||
|
||||
- Rebuild and re-register `PackageIdentity` when the sparse package manifest changes, the signing certificate changes, or you switch to a different output root such as `ARM64\Debug`.
|
||||
- For normal code changes in `Microsoft.CmdPal.Ext.PowerToys`, rebuilding the extension project and restarting CmdPal is enough.
|
||||
@@ -19,7 +19,6 @@ public sealed partial class AppListItem : ListItem, IPrecomputedListItem
|
||||
{
|
||||
private readonly AppCommand _appCommand;
|
||||
private readonly AppItem _app;
|
||||
|
||||
private readonly Lazy<Task<IconInfo?>> _iconLoadTask;
|
||||
private readonly Lazy<Task<Details>> _detailsLoadTask;
|
||||
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 2.8 KiB |
@@ -0,0 +1 @@
|
||||
<?xml version="1.0" encoding="UTF-8" standalone="no"?><!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd"><svg width="100%" height="100%" viewBox="0 0 125 125" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" xml:space="preserve" xmlns:serif="http://www.serif.com/" style="fill-rule:evenodd;clip-rule:evenodd;stroke-linejoin:round;stroke-miterlimit:2;"><path d="M28.739,124.062c-2.866,2.349 -6.864,0.016 -6.864,-4.006l0,-98.671c0,-11.811 8.407,-21.385 18.778,-21.385l43.326,0c10.371,0 18.778,9.574 18.778,21.385l0,98.671c0,4.022 -3.998,6.355 -6.864,4.006l-33.577,-27.509l-33.577,27.509Z" style="fill:url(#_Linear1);fill-rule:nonzero;"/><defs><linearGradient id="_Linear1" x1="0" y1="0" x2="1" y2="-0.0236994" gradientUnits="userSpaceOnUse" gradientTransform="matrix(66.4934,143.035,-138.828,68.5085,31.9724,-9.86988)"><stop offset="0" style="stop-color:#0a7acc;stop-opacity:1"/><stop offset="1" style="stop-color:#0e5497;stop-opacity:1"/></linearGradient></defs></svg>
|
||||
|
After Width: | Height: | Size: 1.0 KiB |
@@ -54,7 +54,7 @@ public sealed partial class BookmarksCommandProvider : CommandProvider
|
||||
|
||||
Id = "Bookmarks";
|
||||
DisplayName = Resources.bookmarks_display_name;
|
||||
Icon = Icons.PinIcon;
|
||||
Icon = Icons.BookmarksExtensionIcon;
|
||||
|
||||
var addBookmarkPage = new AddBookmarkPage(null);
|
||||
addBookmarkPage.AddedCommand += (_, e) => _bookmarksManager.Add(e.Name, e.Bookmark);
|
||||
|
||||
@@ -6,7 +6,9 @@ namespace Microsoft.CmdPal.Ext.Bookmarks;
|
||||
|
||||
internal static class Icons
|
||||
{
|
||||
internal static IconInfo BookmarkIcon { get; } = IconHelpers.FromRelativePath("Assets\\Bookmark.svg");
|
||||
internal static IconInfo BookmarksExtensionIcon { get; } = IconHelpers.FromRelativePath("Assets\\Bookmarks.svg");
|
||||
|
||||
internal static IconInfo AddBookmarkIcon { get; } = IconHelpers.FromRelativePath("Assets\\Bookmark.svg");
|
||||
|
||||
internal static IconInfo DeleteIcon { get; } = new("\uE74D"); // Delete
|
||||
|
||||
|
||||
@@ -23,7 +23,7 @@ internal sealed partial class AddBookmarkPage : ContentPage
|
||||
var name = bookmark?.Name ?? string.Empty;
|
||||
var url = bookmark?.Bookmark ?? string.Empty;
|
||||
|
||||
Icon = Icons.BookmarkIcon;
|
||||
Icon = Icons.AddBookmarkIcon;
|
||||
var isAdd = string.IsNullOrEmpty(name) && string.IsNullOrEmpty(url);
|
||||
Title = isAdd ? Resources.bookmarks_add_title : Resources.bookmarks_edit_name;
|
||||
Name = isAdd ? Resources.bookmarks_add_name : Resources.bookmarks_edit_name;
|
||||
|
||||
@@ -151,7 +151,7 @@ namespace Microsoft.CmdPal.Ext.ClipboardHistory.Properties {
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Looks up a localized string similar to Clipboard History.
|
||||
/// Looks up a localized string similar to Clipboard history.
|
||||
/// </summary>
|
||||
public static string list_item_title {
|
||||
get {
|
||||
@@ -439,7 +439,7 @@ namespace Microsoft.CmdPal.Ext.ClipboardHistory.Properties {
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Looks up a localized string similar to Clipboard History.
|
||||
/// Looks up a localized string similar to Clipboard history.
|
||||
/// </summary>
|
||||
public static string provider_display_name {
|
||||
get {
|
||||
|
||||
@@ -130,13 +130,13 @@
|
||||
<value>Copied to clipboard</value>
|
||||
</data>
|
||||
<data name="list_item_title" xml:space="preserve">
|
||||
<value>Clipboard History</value>
|
||||
<value>Clipboard history</value>
|
||||
</data>
|
||||
<data name="list_item_subtitle" xml:space="preserve">
|
||||
<value>Copy, paste, and search items on the clipboard</value>
|
||||
</data>
|
||||
<data name="provider_display_name" xml:space="preserve">
|
||||
<value>Clipboard History</value>
|
||||
<value>Clipboard history</value>
|
||||
</data>
|
||||
<data name="clipboard_history_page_name" xml:space="preserve">
|
||||
<value>Open</value>
|
||||
|
||||
@@ -38,9 +38,6 @@ internal static class DataPackageHelper
|
||||
},
|
||||
};
|
||||
|
||||
// Cheap + immediate.
|
||||
dataPackage.SetText(capturedPath);
|
||||
|
||||
// Expensive + only computed if the consumer asks for StorageItems.
|
||||
dataPackage.SetDataProvider(StandardDataFormats.StorageItems, async void (request) =>
|
||||
{
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 3.5 KiB |
@@ -0,0 +1 @@
|
||||
<?xml version="1.0" encoding="UTF-8" standalone="no"?><!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd"><svg width="100%" height="100%" viewBox="0 0 125 125" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" xml:space="preserve" xmlns:serif="http://www.serif.com/" style="fill-rule:evenodd;clip-rule:evenodd;stroke-linejoin:round;stroke-miterlimit:2;"><path d="M125,28.125l0,42.187l-125,0l0,-42.187c0,-7.761 4.739,-12.5 12.5,-12.5l100,0c7.761,0 12.5,4.739 12.5,12.5Z" style="fill:url(#_Linear1);"/><path d="M103.125,43.75c8.851,0 21.875,-16.647 21.875,-14.062l0,65.624l-125,0l0,-39.868l20.338,-11.874c1.502,-0.503 3.236,-0.437 4.662,0.18c0,0 12.5,9.375 21.724,9.373c0.051,-0 0.102,0.002 0.151,0.002c9.375,0 21.875,-21.875 31.25,-21.875c9.375,0 17.411,12.5 25,12.5Z" style="fill:url(#_Linear2);"/><path d="M125,53.018l0,43.857c0,7.761 -4.739,12.5 -12.5,12.5l-100,-0c-7.761,-0 -12.5,-4.739 -12.5,-12.5l0,-18.403c0,0 13.665,9.991 21.875,9.028c9.375,-1.1 23.438,-14.583 34.375,-15.625c10.938,-1.042 20.833,12.5 31.25,9.375c10.417,-3.125 31.25,-28.125 31.25,-28.125c0.916,-0.553 2.053,-0.854 3.225,-0.854c1.126,0 2.172,0.278 3.025,0.747Z" style="fill:url(#_Linear3);"/><path d="M0,96.875l0,-68.75c0,-7.761 4.739,-12.5 12.5,-12.5l100,-0c7.761,-0 12.5,4.739 12.5,12.5l0,68.75c0,7.761 -4.739,12.5 -12.5,12.5l-100,0c-7.761,0 -12.5,-4.739 -12.5,-12.5Z" style="fill:url(#_Radial4);"/><path d="M0,96.875l0,-68.75c0,-7.761 4.739,-12.5 12.5,-12.5l100,-0c7.761,-0 12.5,4.739 12.5,12.5l0,68.75c0,7.761 -4.739,12.5 -12.5,12.5l-100,0c-7.761,0 -12.5,-4.739 -12.5,-12.5Z" style="fill:url(#_Linear5);"/><clipPath id="_clip6"><path d="M0,96.875l0,-68.75c0,-7.761 4.739,-12.5 12.5,-12.5l100,-0c7.761,-0 12.5,4.739 12.5,12.5l0,68.75c0,7.761 -4.739,12.5 -12.5,12.5l-100,0c-7.761,0 -12.5,-4.739 -12.5,-12.5Z"/></clipPath><g clip-path="url(#_clip6)"><use xlink:href="#_Image7" x="100" y="15.625" width="25px" height="94px"/></g><defs><linearGradient id="_Linear1" x1="0" y1="0" x2="1" y2="0" gradientUnits="userSpaceOnUse" gradientTransform="matrix(6.25,43.75,-43.75,6.25,40.625,15.625)"><stop offset="0" style="stop-color:#99dbff;stop-opacity:1"/><stop offset="1" style="stop-color:#66c9ff;stop-opacity:1"/></linearGradient><linearGradient id="_Linear2" x1="0" y1="0" x2="1" y2="0" gradientUnits="userSpaceOnUse" gradientTransform="matrix(12.5,43.75,-43.75,12.5,68.75,34.375)"><stop offset="0" style="stop-color:#24b5f4;stop-opacity:1"/><stop offset="1" style="stop-color:#007cb3;stop-opacity:1"/></linearGradient><linearGradient id="_Linear3" x1="0" y1="0" x2="1" y2="0" gradientUnits="userSpaceOnUse" gradientTransform="matrix(9.375,34.375,-34.375,9.375,62.5,75)"><stop offset="0" style="stop-color:#2366a9;stop-opacity:1"/><stop offset="1" style="stop-color:#0d2659;stop-opacity:1"/></linearGradient><radialGradient id="_Radial4" cx="0" cy="0" r="1" gradientUnits="userSpaceOnUse" gradientTransform="matrix(116.279,90.6304,-59.6899,88.5109,5.81395,12.4998)"><stop offset="0" style="stop-color:#0fafff;stop-opacity:0"/><stop offset="0.64" style="stop-color:#0fafff;stop-opacity:0"/><stop offset="0.96" style="stop-color:#0067bf;stop-opacity:0.3"/><stop offset="1" style="stop-color:#0067bf;stop-opacity:0.3"/></radialGradient><linearGradient id="_Linear5" x1="0" y1="0" x2="1" y2="0" gradientUnits="userSpaceOnUse" gradientTransform="matrix(1.17754e-15,15.1051,-19.2308,9.24918e-16,62.5,91.1449)"><stop offset="0" style="stop-color:#163697;stop-opacity:0"/><stop offset="1" style="stop-color:#163697;stop-opacity:0.3"/></linearGradient><image id="_Image7" width="25px" height="94px" xlink:href="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABkAAABeCAYAAADWtL+3AAAACXBIWXMAAA7EAAAOxAGVKw4bAAAB/klEQVRogd2WbW6EMAxEh23P0KP0qr1uf5SussYkM2PTj10JIQ1sHo5fCBuM39v7xwbgZT9ew/mQ3RzI8NvCOc0qEAoAYHMhNADoqyQCtvHoqmQLx8O1CiQDpQ8hQ3Z908HOsn9v131KHYjUdJiVLAeNWXW6IiDNKtNFAWSIoy8uWPFZJkPkpgN1hRmQDJFe8TCnSwUA8CthAVolg77LTSpeq/Tkkv3EaTpUSByA0hfidFn6Ar12tewn9CYVMwoS9B3PVPan7LIBKPaEzWiINGi8pkDoTSpmS4i7r4/np7CLbnyp6QpEGjRmVbvAZMp0WQBgUUmHvkuIOJi9n5SbzkCcQeP15XQ5fbArqUDpSmwAZpUs9JWEYO2iTcoebAaxF1/MFLssfVeQFn1BrngFKq8Ty6QsSyEnn6WUSVn2q3a1AWaQeLOy4g9ZpRJaCKUS+9VygOxm2SZl2dPYlVbS2nSAq2TV9KUQZ5XYJmXZbLrkwc6yB0jXZ2nMuu1KpzRC1PcUdf+s8eygWUXLSmYg9j6qEuoJ2eyskjYAMFRi6Ev3TrHLfgvcYsD+UcgOjZesYaFnlbAZ9SBX2DVdJ+oTskJ8QYTPUkuIak+Y7A65DDBC4s2uvlO7XH1XQliVOELgNvks7Xi1TCvpzH7OLtcaqunfEOaPzKDTnnSalII+AYNMEJ8kDKkcAAAAAElFTkSuQmCC"/></defs></svg>
|
||||
|
After Width: | Height: | Size: 4.4 KiB |
@@ -6,16 +6,41 @@ using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Diagnostics;
|
||||
using System.Globalization;
|
||||
using System.Linq;
|
||||
using System.Text;
|
||||
|
||||
namespace CoreWidgetProvider.Helpers;
|
||||
|
||||
internal sealed partial class GPUStats : IDisposable
|
||||
{
|
||||
// GPU counters
|
||||
private readonly Dictionary<int, List<PerformanceCounter>> _gpuCounters = new();
|
||||
// Performance counter category & counter names
|
||||
private const string GpuEngineCategoryName = "GPU Engine";
|
||||
private const string UtilizationPercentageCounter = "Utilization Percentage";
|
||||
|
||||
private readonly List<Data> _stats = new();
|
||||
private static readonly CompositeFormat TemperatureFormat = CompositeFormat.Parse("{0:0.} \u00B0C");
|
||||
|
||||
// Instance-name key tokens
|
||||
private const string KeyPid = "pid";
|
||||
private const string KeyLuid = "luid";
|
||||
private const string KeyPhys = "phys";
|
||||
private const string KeyEngineType = "engtype";
|
||||
|
||||
// Engine type filter
|
||||
private const string EngineType3D = "3D";
|
||||
|
||||
// Display strings
|
||||
private const string GpuNamePrefix = "GPU ";
|
||||
private const string TemperatureUnavailable = "--";
|
||||
|
||||
// Batch read via category - single kernel transition per tick
|
||||
private readonly PerformanceCounterCategory _gpuEngineCategory = new(GpuEngineCategoryName);
|
||||
|
||||
// Discovered physical GPU IDs
|
||||
private readonly HashSet<int> _knownPhysIds = [];
|
||||
|
||||
private readonly List<Data> _stats = [];
|
||||
|
||||
// Previous raw samples for computing cooked (delta-based) values
|
||||
private Dictionary<string, CounterSample> _previousSamples = [];
|
||||
|
||||
public sealed class Data
|
||||
{
|
||||
@@ -27,7 +52,7 @@ internal sealed partial class GPUStats : IDisposable
|
||||
|
||||
public float Temperature { get; set; }
|
||||
|
||||
public List<float> GpuChartValues { get; set; } = new();
|
||||
public List<float> GpuChartValues { get; set; } = [];
|
||||
}
|
||||
|
||||
public GPUStats()
|
||||
@@ -51,48 +76,26 @@ internal sealed partial class GPUStats : IDisposable
|
||||
// set. That's what we should do, so that we can report the sum of those
|
||||
// numbers as the total utilization, and then have them broken out in
|
||||
// the card template and in the details metadata.
|
||||
_gpuCounters.Clear();
|
||||
_knownPhysIds.Clear();
|
||||
|
||||
var perfCounterCategory = new PerformanceCounterCategory("GPU Engine");
|
||||
var instanceNames = perfCounterCategory.GetInstanceNames();
|
||||
var instanceNames = _gpuEngineCategory.GetInstanceNames();
|
||||
|
||||
foreach (var instanceName in instanceNames)
|
||||
{
|
||||
if (!instanceName.EndsWith("3D", StringComparison.InvariantCulture))
|
||||
if (!instanceName.EndsWith(EngineType3D, StringComparison.InvariantCulture))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var utilizationCounters = perfCounterCategory.GetCounters(instanceName)
|
||||
.Where(x => x.CounterName.StartsWith("Utilization Percentage", StringComparison.InvariantCulture));
|
||||
var counterKey = instanceName;
|
||||
|
||||
foreach (var counter in utilizationCounters)
|
||||
// skip these values
|
||||
GetKeyValueFromCounterKey(KeyPid, ref counterKey);
|
||||
GetKeyValueFromCounterKey(KeyLuid, ref counterKey);
|
||||
|
||||
if (int.TryParse(GetKeyValueFromCounterKey(KeyPhys, ref counterKey), out var phys))
|
||||
{
|
||||
var counterKey = counter.InstanceName;
|
||||
|
||||
// skip these values
|
||||
GetKeyValueFromCounterKey("pid", ref counterKey);
|
||||
GetKeyValueFromCounterKey("luid", ref counterKey);
|
||||
|
||||
int phys;
|
||||
var success = int.TryParse(GetKeyValueFromCounterKey("phys", ref counterKey), out phys);
|
||||
if (success)
|
||||
{
|
||||
GetKeyValueFromCounterKey("eng", ref counterKey);
|
||||
var engtype = GetKeyValueFromCounterKey("engtype", ref counterKey);
|
||||
if (engtype != "3D")
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!_gpuCounters.TryGetValue(phys, out var value))
|
||||
{
|
||||
value = new();
|
||||
_gpuCounters.Add(phys, value);
|
||||
}
|
||||
|
||||
value.Add(counter);
|
||||
}
|
||||
_knownPhysIds.Add(phys);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -108,70 +111,87 @@ internal sealed partial class GPUStats : IDisposable
|
||||
//
|
||||
// For now, we'll just use the indices as the GPU names.
|
||||
_stats.Clear();
|
||||
foreach (var (k, v) in _gpuCounters)
|
||||
foreach (var id in _knownPhysIds)
|
||||
{
|
||||
var id = k;
|
||||
var counters = v;
|
||||
_stats.Add(new Data() { PhysId = id, Name = "GPU " + id });
|
||||
_stats.Add(new Data() { PhysId = id, Name = GpuNamePrefix + id });
|
||||
}
|
||||
}
|
||||
|
||||
public void GetData()
|
||||
{
|
||||
foreach (var gpu in _stats)
|
||||
try
|
||||
{
|
||||
List<PerformanceCounter>? counters;
|
||||
var success = _gpuCounters.TryGetValue(gpu.PhysId, out counters);
|
||||
// Single batch read - one kernel transition for ALL GPU Engine instances
|
||||
var categoryData = _gpuEngineCategory.ReadCategory();
|
||||
|
||||
if (success && counters != null)
|
||||
if (!categoryData.Contains(UtilizationPercentageCounter))
|
||||
{
|
||||
// TODO: This outer try/catch should be replaced with more secure locking around shared resources.
|
||||
try
|
||||
return;
|
||||
}
|
||||
|
||||
var utilizationData = categoryData[UtilizationPercentageCounter];
|
||||
|
||||
// Accumulate usage per physical GPU
|
||||
var gpuUsage = new Dictionary<int, float>();
|
||||
var currentSamples = new Dictionary<string, CounterSample>();
|
||||
|
||||
foreach (InstanceData instance in utilizationData.Values)
|
||||
{
|
||||
var instanceName = instance.InstanceName;
|
||||
if (!instanceName.EndsWith(EngineType3D, StringComparison.InvariantCulture))
|
||||
{
|
||||
var sum = 0.0f;
|
||||
var countersToRemove = new List<PerformanceCounter>();
|
||||
foreach (var counter in counters)
|
||||
{
|
||||
try
|
||||
{
|
||||
// NextValue() can throw an InvalidOperationException if the counter is no longer there.
|
||||
sum += counter.NextValue();
|
||||
}
|
||||
catch (InvalidOperationException)
|
||||
{
|
||||
// We can't modify the list during the loop, so save it to remove at the end.
|
||||
// _log.Information(ex, "Failed to get next value, remove");
|
||||
countersToRemove.Add(counter);
|
||||
}
|
||||
catch (Exception)
|
||||
{
|
||||
// _log.Error(ex, "Error going through process counters.");
|
||||
}
|
||||
}
|
||||
|
||||
foreach (var counter in countersToRemove)
|
||||
{
|
||||
counters.Remove(counter);
|
||||
counter.Dispose();
|
||||
}
|
||||
|
||||
gpu.Usage = sum / 100;
|
||||
lock (gpu.GpuChartValues)
|
||||
{
|
||||
ChartHelper.AddNextChartValue(sum, gpu.GpuChartValues);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
catch (Exception)
|
||||
|
||||
var counterKey = instanceName;
|
||||
GetKeyValueFromCounterKey(KeyPid, ref counterKey);
|
||||
GetKeyValueFromCounterKey(KeyLuid, ref counterKey);
|
||||
|
||||
if (!int.TryParse(GetKeyValueFromCounterKey(KeyPhys, ref counterKey), out var phys))
|
||||
{
|
||||
// _log.Error(ex, "Error summing process counters.");
|
||||
continue;
|
||||
}
|
||||
|
||||
var sample = instance.Sample;
|
||||
currentSamples[instanceName] = sample;
|
||||
|
||||
if (_previousSamples.TryGetValue(instanceName, out var prevSample))
|
||||
{
|
||||
try
|
||||
{
|
||||
var cookedValue = CounterSampleCalculator.ComputeCounterValue(prevSample, sample);
|
||||
gpuUsage[phys] = gpuUsage.GetValueOrDefault(phys) + cookedValue;
|
||||
}
|
||||
catch (Exception)
|
||||
{
|
||||
// Skip this instance on calculation error.
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Swap samples - stale entries are automatically cleaned up
|
||||
_previousSamples = currentSamples;
|
||||
|
||||
// Update stats
|
||||
foreach (var gpu in _stats)
|
||||
{
|
||||
var sum = gpuUsage.TryGetValue(gpu.PhysId, out var usage) ? usage : 0f;
|
||||
gpu.Usage = sum / 100;
|
||||
lock (gpu.GpuChartValues)
|
||||
{
|
||||
ChartHelper.AddNextChartValue(sum, gpu.GpuChartValues);
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (Exception)
|
||||
{
|
||||
// Ignore errors from ReadCategory (e.g., category not available).
|
||||
}
|
||||
}
|
||||
|
||||
internal string CreateGPUImageUrl(int gpuChartIndex)
|
||||
{
|
||||
return ChartHelper.CreateImageUrl(_stats.ElementAt(gpuChartIndex).GpuChartValues, ChartHelper.ChartType.GPU);
|
||||
return ChartHelper.CreateImageUrl(_stats[gpuChartIndex].GpuChartValues, ChartHelper.ChartType.GPU);
|
||||
}
|
||||
|
||||
internal string GetGPUName(int gpuActiveIndex)
|
||||
@@ -234,16 +254,16 @@ internal sealed partial class GPUStats : IDisposable
|
||||
// removed.
|
||||
if (_stats.Count <= gpuActiveIndex)
|
||||
{
|
||||
return "--";
|
||||
return TemperatureUnavailable;
|
||||
}
|
||||
|
||||
var temperature = _stats[gpuActiveIndex].Temperature;
|
||||
if (temperature == 0)
|
||||
{
|
||||
return "--";
|
||||
return TemperatureUnavailable;
|
||||
}
|
||||
|
||||
return temperature.ToString("0.", CultureInfo.InvariantCulture) + " \x00B0C";
|
||||
return string.Format(CultureInfo.InvariantCulture, TemperatureFormat.Format, temperature);
|
||||
}
|
||||
|
||||
private string GetKeyValueFromCounterKey(string key, ref string counterKey)
|
||||
@@ -254,13 +274,13 @@ internal sealed partial class GPUStats : IDisposable
|
||||
}
|
||||
|
||||
counterKey = counterKey.Substring(key.Length + 1);
|
||||
if (key.Equals("engtype", StringComparison.Ordinal))
|
||||
if (key.Equals(KeyEngineType, StringComparison.Ordinal))
|
||||
{
|
||||
return counterKey;
|
||||
}
|
||||
|
||||
var pos = counterKey.IndexOf('_');
|
||||
if (key.Equals("luid", StringComparison.Ordinal))
|
||||
if (key.Equals(KeyLuid, StringComparison.Ordinal))
|
||||
{
|
||||
pos = counterKey.IndexOf('_', pos + 1);
|
||||
}
|
||||
@@ -272,12 +292,6 @@ internal sealed partial class GPUStats : IDisposable
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
foreach (var counterPair in _gpuCounters)
|
||||
{
|
||||
foreach (var counter in counterPair.Value)
|
||||
{
|
||||
counter.Dispose();
|
||||
}
|
||||
}
|
||||
_previousSamples.Clear();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,8 +6,10 @@ using Microsoft.CommandPalette.Extensions.Toolkit;
|
||||
|
||||
namespace Microsoft.CmdPal.Ext.PerformanceMonitor;
|
||||
|
||||
internal sealed class Icons
|
||||
internal static class Icons
|
||||
{
|
||||
internal static IconInfo PerformanceMonitorIcon => IconHelpers.FromRelativePath("Assets\\PerformanceMonitorExtension.svg");
|
||||
|
||||
internal static IconInfo CpuIcon => new("\uE9D9"); // CPU icon
|
||||
|
||||
internal static IconInfo MemoryIcon => new("\uE964"); // Memory icon
|
||||
@@ -26,6 +28,3 @@ internal sealed class Icons
|
||||
|
||||
internal static IconInfo NavigateForwardIcon => new("\uE72A"); // Next icon
|
||||
}
|
||||
|
||||
|
||||
#pragma warning restore SA1402 // File may only contain a single type
|
||||
|
||||
@@ -54,5 +54,10 @@
|
||||
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
|
||||
</None>
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<Content Update="Assets\**\*.*">
|
||||
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
|
||||
</Content>
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
// See the LICENSE file in the project root for more information.
|
||||
|
||||
using System;
|
||||
using System.Threading;
|
||||
using Microsoft.CommandPalette.Extensions;
|
||||
using Microsoft.CommandPalette.Extensions.Toolkit;
|
||||
using Windows.Foundation;
|
||||
@@ -70,6 +71,7 @@ internal abstract partial class OnLoadContentPage : OnLoadBasePage, IContentPage
|
||||
|
||||
internal abstract partial class OnLoadBasePage : Page
|
||||
{
|
||||
private readonly Lock _loadLock = new();
|
||||
private int _loadCount;
|
||||
|
||||
#pragma warning disable CS0067 // The event is never used
|
||||
@@ -82,22 +84,28 @@ internal abstract partial class OnLoadBasePage : Page
|
||||
add
|
||||
{
|
||||
InternalItemsChanged += value;
|
||||
if (_loadCount == 0)
|
||||
lock (_loadLock)
|
||||
{
|
||||
Loaded();
|
||||
}
|
||||
if (_loadCount == 0)
|
||||
{
|
||||
Loaded();
|
||||
}
|
||||
|
||||
_loadCount++;
|
||||
_loadCount++;
|
||||
}
|
||||
}
|
||||
|
||||
remove
|
||||
{
|
||||
InternalItemsChanged -= value;
|
||||
_loadCount--;
|
||||
_loadCount = Math.Max(0, _loadCount);
|
||||
if (_loadCount == 0)
|
||||
lock (_loadLock)
|
||||
{
|
||||
Unloaded();
|
||||
_loadCount--;
|
||||
_loadCount = Math.Max(0, _loadCount);
|
||||
if (_loadCount == 0)
|
||||
{
|
||||
Unloaded();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17,7 +17,7 @@ public partial class PerformanceMonitorCommandsProvider : CommandProvider
|
||||
{
|
||||
DisplayName = Resources.GetResource("Performance_Monitor_Title");
|
||||
Id = "PerformanceMonitor";
|
||||
Icon = Icons.StackedAreaIcon;
|
||||
Icon = Icons.PerformanceMonitorIcon;
|
||||
|
||||
var page = new PerformanceWidgetsPage(false);
|
||||
var band = new PerformanceWidgetsPage(true);
|
||||
|
||||
@@ -9,6 +9,7 @@ using System.Globalization;
|
||||
using System.IO;
|
||||
using System.Text;
|
||||
using System.Text.Json.Nodes;
|
||||
using System.Threading;
|
||||
using CoreWidgetProvider.Helpers;
|
||||
using CoreWidgetProvider.Widgets.Enums;
|
||||
using Microsoft.CmdPal.Common;
|
||||
@@ -32,7 +33,7 @@ internal sealed partial class PerformanceWidgetsPage : OnLoadStaticListPage, IDi
|
||||
|
||||
public override string Title => Resources.GetResource("Performance_Monitor_Title");
|
||||
|
||||
public override IconInfo Icon => Icons.StackedAreaIcon;
|
||||
public override IconInfo Icon => Icons.PerformanceMonitorIcon;
|
||||
|
||||
private readonly bool _isBandPage;
|
||||
|
||||
@@ -262,17 +263,17 @@ internal abstract partial class WidgetPage : OnLoadContentPage
|
||||
/// </summary>
|
||||
internal virtual void PushActivate()
|
||||
{
|
||||
_loadCount++;
|
||||
Interlocked.Increment(ref _loadCount);
|
||||
}
|
||||
|
||||
internal virtual void PopActivate()
|
||||
{
|
||||
_loadCount--;
|
||||
Interlocked.Decrement(ref _loadCount);
|
||||
}
|
||||
|
||||
private int _loadCount;
|
||||
|
||||
protected bool IsActive => _loadCount > 0;
|
||||
protected bool IsActive => Volatile.Read(ref _loadCount) > 0;
|
||||
|
||||
protected override void Loaded()
|
||||
{
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">
|
||||
<rect x="2.25" y="4.25" width="19.5" height="12.5" rx="2.5" fill="#5F5F5F"/>
|
||||
<rect x="4.5" y="6.75" width="1.9" height="1.75" rx="0.35" fill="#FFFFFF"/>
|
||||
<rect x="7.1" y="6.75" width="1.9" height="1.75" rx="0.35" fill="#FFFFFF"/>
|
||||
<rect x="9.7" y="6.75" width="1.9" height="1.75" rx="0.35" fill="#FFFFFF"/>
|
||||
<rect x="12.3" y="6.75" width="1.9" height="1.75" rx="0.35" fill="#FFFFFF"/>
|
||||
<rect x="14.9" y="6.75" width="1.9" height="1.75" rx="0.35" fill="#FFFFFF"/>
|
||||
<rect x="17.5" y="6.75" width="1.9" height="1.75" rx="0.35" fill="#FFFFFF"/>
|
||||
<rect x="4.5" y="9.5" width="2.4" height="1.75" rx="0.35" fill="#FFFFFF"/>
|
||||
<rect x="7.6" y="9.5" width="2.4" height="1.75" rx="0.35" fill="#FFFFFF"/>
|
||||
<rect x="10.7" y="9.5" width="2.4" height="1.75" rx="0.35" fill="#FFFFFF"/>
|
||||
<rect x="13.8" y="9.5" width="2.4" height="1.75" rx="0.35" fill="#FFFFFF"/>
|
||||
<rect x="16.9" y="9.5" width="2.4" height="1.75" rx="0.35" fill="#FFFFFF"/>
|
||||
<rect x="4.5" y="12.25" width="10.9" height="1.9" rx="0.4" fill="#FFFFFF"/>
|
||||
<rect x="16.1" y="12.25" width="3.3" height="1.9" rx="0.4" fill="#FFFFFF"/>
|
||||
<circle cx="18.5" cy="18.5" r="4.5" fill="#C50F1F"/>
|
||||
<path d="M16.35 18.5h4.3" fill="none" stroke="#FFFFFF" stroke-linecap="round" stroke-width="1.5"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.3 KiB |
@@ -0,0 +1,18 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">
|
||||
<rect x="2.25" y="4.25" width="19.5" height="12.5" rx="2.5" fill="#0078D4"/>
|
||||
<rect x="4.5" y="6.75" width="1.9" height="1.75" rx="0.35" fill="#FFFFFF"/>
|
||||
<rect x="7.1" y="6.75" width="1.9" height="1.75" rx="0.35" fill="#FFFFFF"/>
|
||||
<rect x="9.7" y="6.75" width="1.9" height="1.75" rx="0.35" fill="#FFFFFF"/>
|
||||
<rect x="12.3" y="6.75" width="1.9" height="1.75" rx="0.35" fill="#FFFFFF"/>
|
||||
<rect x="14.9" y="6.75" width="1.9" height="1.75" rx="0.35" fill="#FFFFFF"/>
|
||||
<rect x="17.5" y="6.75" width="1.9" height="1.75" rx="0.35" fill="#FFFFFF"/>
|
||||
<rect x="4.5" y="9.5" width="2.4" height="1.75" rx="0.35" fill="#FFFFFF"/>
|
||||
<rect x="7.6" y="9.5" width="2.4" height="1.75" rx="0.35" fill="#FFFFFF"/>
|
||||
<rect x="10.7" y="9.5" width="2.4" height="1.75" rx="0.35" fill="#FFFFFF"/>
|
||||
<rect x="13.8" y="9.5" width="2.4" height="1.75" rx="0.35" fill="#FFFFFF"/>
|
||||
<rect x="16.9" y="9.5" width="2.4" height="1.75" rx="0.35" fill="#FFFFFF"/>
|
||||
<rect x="4.5" y="12.25" width="10.9" height="1.9" rx="0.4" fill="#FFFFFF"/>
|
||||
<rect x="16.1" y="12.25" width="3.3" height="1.9" rx="0.4" fill="#FFFFFF"/>
|
||||
<circle cx="18.5" cy="18.5" r="4.5" fill="#107C10"/>
|
||||
<path d="M16.55 18.4l1.35 1.35 2.6-3.05" fill="none" stroke="#FFFFFF" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.45"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.3 KiB |
@@ -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.Toolkit;
|
||||
using PowerToysExtension.Helpers;
|
||||
using PowerToysExtension.Properties;
|
||||
|
||||
namespace PowerToysExtension.Commands;
|
||||
|
||||
internal sealed partial class ToggleKeyboardManagerListeningCommand : InvokableCommand
|
||||
{
|
||||
public ToggleKeyboardManagerListeningCommand()
|
||||
{
|
||||
Name = "Toggle Keyboard Manager active state";
|
||||
}
|
||||
|
||||
public override CommandResult Invoke()
|
||||
{
|
||||
return KeyboardManagerStateService.TryToggleListening()
|
||||
? CommandResult.KeepOpen()
|
||||
: CommandResult.ShowToast(Resources.ResourceManager.GetString("KeyboardManager_ToggleListening_Error", Resources.Culture) ?? "Keyboard Manager is unavailable. Try enabling it in PowerToys settings.");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
// Copyright (c) Microsoft Corporation
|
||||
// The Microsoft Corporation licenses this file to you under the MIT license.
|
||||
// See the LICENSE file in the project root for more information.
|
||||
|
||||
using System;
|
||||
using System.Threading;
|
||||
using Microsoft.CommandPalette.Extensions.Toolkit;
|
||||
using PowerToys.Interop;
|
||||
|
||||
namespace PowerToysExtension.Commands;
|
||||
|
||||
/// <summary>
|
||||
/// Triggers a reconnection attempt in Mouse Without Borders via the shared event.
|
||||
/// </summary>
|
||||
internal sealed partial class MWBReconnectCommand : InvokableCommand
|
||||
{
|
||||
public MWBReconnectCommand()
|
||||
{
|
||||
Name = "Mouse Without Borders: Reconnect";
|
||||
}
|
||||
|
||||
public override CommandResult Invoke()
|
||||
{
|
||||
try
|
||||
{
|
||||
using var evt = new EventWaitHandle(false, EventResetMode.AutoReset, Constants.MWBReconnectEvent());
|
||||
evt.Set();
|
||||
return CommandResult.Dismiss();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return CommandResult.ShowToast($"Failed to reconnect Mouse Without Borders: {ex.Message}");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
// Copyright (c) Microsoft Corporation
|
||||
// The Microsoft Corporation licenses this file to you under the MIT license.
|
||||
// See the LICENSE file in the project root for more information.
|
||||
|
||||
using System;
|
||||
using System.Threading;
|
||||
using Microsoft.CommandPalette.Extensions.Toolkit;
|
||||
using PowerToys.Interop;
|
||||
|
||||
namespace PowerToysExtension.Commands;
|
||||
|
||||
/// <summary>
|
||||
/// Toggles Easy Mouse feature in Mouse Without Borders via the shared event.
|
||||
/// </summary>
|
||||
internal sealed partial class ToggleMWBEasyMouseCommand : InvokableCommand
|
||||
{
|
||||
public ToggleMWBEasyMouseCommand()
|
||||
{
|
||||
Name = "Mouse Without Borders: Toggle Easy Mouse";
|
||||
}
|
||||
|
||||
public override CommandResult Invoke()
|
||||
{
|
||||
try
|
||||
{
|
||||
using var evt = new EventWaitHandle(false, EventResetMode.AutoReset, Constants.MWBToggleEasyMouseEvent());
|
||||
evt.Set();
|
||||
return CommandResult.Dismiss();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return CommandResult.ShowToast($"Failed to toggle Easy Mouse: {ex.Message}");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
// Copyright (c) Microsoft Corporation
|
||||
// The Microsoft Corporation licenses this file to you under the MIT license.
|
||||
// See the LICENSE file in the project root for more information.
|
||||
|
||||
using System;
|
||||
using System.Threading;
|
||||
using PowerToys.Interop;
|
||||
|
||||
namespace PowerToysExtension.Helpers;
|
||||
|
||||
internal static class KeyboardManagerStateService
|
||||
{
|
||||
private static readonly object Sync = new();
|
||||
private static readonly Timer PollingTimer;
|
||||
private static bool _lastKnownListeningState = IsListening();
|
||||
|
||||
internal static event Action? StatusChanged;
|
||||
|
||||
static KeyboardManagerStateService()
|
||||
{
|
||||
PollingTimer = new Timer(
|
||||
static _ => PollStatus(),
|
||||
null,
|
||||
TimeSpan.FromMilliseconds(500),
|
||||
TimeSpan.FromMilliseconds(500));
|
||||
}
|
||||
|
||||
internal static bool IsListening()
|
||||
{
|
||||
try
|
||||
{
|
||||
if (Mutex.TryOpenExisting(Constants.KeyboardManagerEngineInstanceMutex(), out var mutex))
|
||||
{
|
||||
mutex.Dispose();
|
||||
return true;
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
// The engine mutex is best-effort state. Treat failures as not listening.
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
internal static bool TryToggleListening()
|
||||
{
|
||||
try
|
||||
{
|
||||
using var evt = EventWaitHandle.OpenExisting(Constants.ToggleKeyboardManagerActiveEvent());
|
||||
var signaled = evt.Set();
|
||||
PollStatus();
|
||||
return signaled;
|
||||
}
|
||||
catch
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private static void PollStatus()
|
||||
{
|
||||
var isListening = IsListening();
|
||||
var raiseChanged = false;
|
||||
|
||||
lock (Sync)
|
||||
{
|
||||
if (isListening != _lastKnownListeningState)
|
||||
{
|
||||
_lastKnownListeningState = isListening;
|
||||
raiseChanged = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (raiseChanged)
|
||||
{
|
||||
StatusChanged?.Invoke();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -9,11 +9,21 @@ namespace PowerToysExtension.Helpers;
|
||||
|
||||
internal static class PowerToysResourcesHelper
|
||||
{
|
||||
private const string AssetsRoot = "Assets\\";
|
||||
private const string SettingsIconRoot = "WinUI3Apps\\Assets\\Settings\\Icons\\";
|
||||
|
||||
internal static IconInfo IconFromSettingsIcon(string fileName) => IconHelpers.FromRelativePath($"{SettingsIconRoot}{fileName}");
|
||||
|
||||
internal static IconInfo KeyboardManagerListeningIcon(bool isListening) => IconHelpers.FromRelativePath(
|
||||
isListening
|
||||
? $"{AssetsRoot}KeyboardManager\\KeyboardManagerListeningOn.svg"
|
||||
: $"{AssetsRoot}KeyboardManager\\KeyboardManagerListeningOff.svg");
|
||||
|
||||
#if DEBUG
|
||||
public static IconInfo ProviderIcon() => IconFromSettingsIcon("PowerToys.dark.png");
|
||||
#else
|
||||
public static IconInfo ProviderIcon() => IconFromSettingsIcon("PowerToys.png");
|
||||
#endif
|
||||
|
||||
public static IconInfo ModuleIcon(this SettingsWindow module)
|
||||
{
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user