mirror of
https://github.com/microsoft/PowerToys.git
synced 2026-07-01 07:59:36 +02:00
Compare commits
86 Commits
shawn/Pyth
...
feature/41
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
aa058bbf13 | ||
|
|
87b24afa23 | ||
|
|
74c53c14e6 | ||
|
|
77173cd075 | ||
|
|
149e7b1efe | ||
|
|
0c2d24c3f6 | ||
|
|
6c5a415fd4 | ||
|
|
e3761ee8dc | ||
|
|
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 | ||
|
|
f651d1a611 | ||
|
|
d20ae940d5 | ||
|
|
86860df314 | ||
|
|
d28f312b81 | ||
|
|
f6309ac549 | ||
|
|
c23ba227b4 | ||
|
|
ce2e72832c | ||
|
|
c066cc3deb | ||
|
|
9089ca2ede | ||
|
|
798564eea4 | ||
|
|
738b78c406 | ||
|
|
1cb99e32ef | ||
|
|
95835a4cfa | ||
|
|
4146876d88 | ||
|
|
a6e49c941d | ||
|
|
734c738751 | ||
|
|
22b4dda3aa | ||
|
|
fd399045f7 | ||
|
|
7e3f9f0c3f | ||
|
|
9e4bf1e3e0 | ||
|
|
cc3c3c0367 | ||
|
|
637b58b136 | ||
|
|
6c691f59e8 | ||
|
|
7dfe6c0159 | ||
|
|
543399b62b | ||
|
|
90e81cbfd5 | ||
|
|
e4d4d5bf8b | ||
|
|
54d8015c3a | ||
|
|
a993b494cf | ||
|
|
44ac526e15 | ||
|
|
900ed8e453 | ||
|
|
15419254ad | ||
|
|
6df72d878e | ||
|
|
2801d2123b | ||
|
|
f879844790 | ||
|
|
1baecd8754 | ||
|
|
8d54d24b8f | ||
|
|
bfd510abc8 | ||
|
|
79dec5832f | ||
|
|
644c60220a |
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:
|
||||
|
||||
83
.github/actions/spell-check/expect.txt
vendored
83
.github/actions/spell-check/expect.txt
vendored
@@ -16,6 +16,7 @@ adaptivecards
|
||||
ADDSTRING
|
||||
ADDUNDORECORD
|
||||
ADifferent
|
||||
ADMINS
|
||||
adml
|
||||
admx
|
||||
advfirewall
|
||||
@@ -67,6 +68,7 @@ ARPINSTALLLOCATION
|
||||
ARPPRODUCTICON
|
||||
ARRAYSIZE
|
||||
ARROWKEYS
|
||||
arrowshape
|
||||
asf
|
||||
AShortcut
|
||||
ASingle
|
||||
@@ -128,6 +130,7 @@ bthprops
|
||||
bti
|
||||
BTNFACE
|
||||
bugreport
|
||||
bugreportfile
|
||||
BUILDARCH
|
||||
BUILDNUMBER
|
||||
buildtransitive
|
||||
@@ -167,7 +170,11 @@ cim
|
||||
CImage
|
||||
cla
|
||||
CLASSDC
|
||||
classguid
|
||||
classmethod
|
||||
CLASSNOTAVAILABLE
|
||||
claude
|
||||
CLEARTYPE
|
||||
clickable
|
||||
clickonce
|
||||
clientside
|
||||
@@ -199,11 +206,13 @@ colorformat
|
||||
colorhistory
|
||||
colorhistorylimit
|
||||
COLORKEY
|
||||
colorref
|
||||
comctl
|
||||
comdlg
|
||||
comexp
|
||||
cominterop
|
||||
commandpalette
|
||||
commoncontrols
|
||||
compmgmt
|
||||
COMPOSITIONFULL
|
||||
CONFIGW
|
||||
@@ -215,6 +224,8 @@ CONTEXTHELP
|
||||
CONTEXTMENUHANDLER
|
||||
contractversion
|
||||
CONTROLPARENT
|
||||
Convs
|
||||
cooldown
|
||||
copiedcolorrepresentation
|
||||
COPYPEN
|
||||
COREWINDOW
|
||||
@@ -225,6 +236,8 @@ cpcontrols
|
||||
cph
|
||||
cplusplus
|
||||
CPower
|
||||
cpptools
|
||||
cppvsdbg
|
||||
cppwinrt
|
||||
createdump
|
||||
CREATEPROCESS
|
||||
@@ -247,6 +260,8 @@ CTLCOLORSTATIC
|
||||
CURRENTDIR
|
||||
CURSORINFO
|
||||
cursorpos
|
||||
CURSORSHOWING
|
||||
cursorwrap
|
||||
customaction
|
||||
CUSTOMACTIONTEST
|
||||
CVal
|
||||
@@ -263,12 +278,14 @@ dacl
|
||||
datareader
|
||||
datatracker
|
||||
Dayof
|
||||
dbcc
|
||||
DBID
|
||||
DBLCLKS
|
||||
DBLEPSILON
|
||||
DBPROP
|
||||
DBPROPIDSET
|
||||
DBPROPSET
|
||||
DBT
|
||||
DCBA
|
||||
DCOM
|
||||
DComposition
|
||||
@@ -284,6 +301,8 @@ DEFAULTFLAGS
|
||||
DEFAULTICON
|
||||
defaultlib
|
||||
DEFAULTONLY
|
||||
DEFAULTSIZE
|
||||
defaulttonearest
|
||||
DEFAULTTONULL
|
||||
DEFAULTTOPRIMARY
|
||||
DEFERERASE
|
||||
@@ -303,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
|
||||
@@ -421,6 +450,12 @@ eyetracker
|
||||
FANCYZONESDRAWLAYOUTTEST
|
||||
FANCYZONESEDITOR
|
||||
FARPROC
|
||||
fdw
|
||||
fdx
|
||||
FErase
|
||||
fesf
|
||||
FFFF
|
||||
Figma
|
||||
FILEEXPLORER
|
||||
FILEFLAGS
|
||||
FILEFLAGSMASK
|
||||
@@ -437,6 +472,7 @@ FILESYSPATH
|
||||
Filetime
|
||||
FILEVERSION
|
||||
FILTERMODE
|
||||
FInc
|
||||
findfast
|
||||
FIXEDFILEINFO
|
||||
FIXEDSYS
|
||||
@@ -492,6 +528,7 @@ GPOCA
|
||||
gpp
|
||||
gpu
|
||||
gradians
|
||||
GRGX
|
||||
GSM
|
||||
gtm
|
||||
guiddata
|
||||
@@ -522,11 +559,13 @@ HCRYPTPROV
|
||||
hcursor
|
||||
hcwhite
|
||||
hdc
|
||||
HDEVNOTIFY
|
||||
hdr
|
||||
hdrop
|
||||
hdwwiz
|
||||
Helpline
|
||||
helptext
|
||||
hgdiobj
|
||||
HGFE
|
||||
hglobal
|
||||
hhk
|
||||
@@ -537,6 +576,8 @@ HIBYTE
|
||||
hicon
|
||||
HIDEWINDOW
|
||||
Hif
|
||||
highlightbackground
|
||||
highlightthickness
|
||||
HIMAGELIST
|
||||
himl
|
||||
hinst
|
||||
@@ -627,6 +668,7 @@ inetcpl
|
||||
Infobar
|
||||
INFOEXAMPLE
|
||||
Infotip
|
||||
initialfile
|
||||
INITDIALOG
|
||||
INITGUID
|
||||
INITTOLOGFONTSTRUCT
|
||||
@@ -668,6 +710,7 @@ jfif
|
||||
jgeosdfsdsgmkedfgdfgdfgbkmhcgcflmi
|
||||
jjw
|
||||
jobject
|
||||
JOBOBJECT
|
||||
jpe
|
||||
jpnime
|
||||
Jsons
|
||||
@@ -701,6 +744,7 @@ Ldone
|
||||
Ldr
|
||||
LEFTSCROLLBAR
|
||||
LEFTTEXT
|
||||
leftclick
|
||||
LError
|
||||
LEVELID
|
||||
LExit
|
||||
@@ -732,6 +776,8 @@ lowlevel
|
||||
LOWORD
|
||||
lparam
|
||||
LPBITMAPINFOHEADER
|
||||
LPCFHOOKPROC
|
||||
lpch
|
||||
LPCITEMIDLIST
|
||||
LPCLSID
|
||||
lpcmi
|
||||
@@ -749,6 +795,7 @@ LPMONITORINFO
|
||||
LPOSVERSIONINFOEXW
|
||||
LPQUERY
|
||||
lprc
|
||||
LPrivate
|
||||
LPSAFEARRAY
|
||||
lpstr
|
||||
lpsz
|
||||
@@ -790,10 +837,13 @@ MAPPEDTOSAMEKEY
|
||||
MAPTOSAMESHORTCUT
|
||||
MAPVK
|
||||
MARKDOWNPREVIEWHANDLERCPP
|
||||
MAXDWORD
|
||||
MAXSHORTCUTSIZE
|
||||
maxversiontested
|
||||
MBM
|
||||
MBR
|
||||
Mbuttondown
|
||||
mcp
|
||||
MDICHILD
|
||||
MDL
|
||||
mdtext
|
||||
@@ -805,7 +855,10 @@ MENUITEMINFO
|
||||
MENUITEMINFOW
|
||||
MERGECOPY
|
||||
MERGEPAINT
|
||||
Metacharacter
|
||||
metadatamatters
|
||||
Metadatas
|
||||
Metacharacter
|
||||
metafile
|
||||
metapackage
|
||||
mfc
|
||||
@@ -831,6 +884,7 @@ mmsys
|
||||
mobileredirect
|
||||
mockapi
|
||||
MODALFRAME
|
||||
modelcontextprotocol
|
||||
MODESPRUNED
|
||||
MONITORENUMPROC
|
||||
MONITORINFO
|
||||
@@ -865,7 +919,9 @@ MSLLHOOKSTRUCT
|
||||
Mso
|
||||
msrc
|
||||
msstore
|
||||
mstsc
|
||||
msvcp
|
||||
MT
|
||||
MTND
|
||||
MULTIPLEUSE
|
||||
multizone
|
||||
@@ -874,6 +930,8 @@ mvvm
|
||||
MVVMTK
|
||||
MWBEx
|
||||
MYICON
|
||||
myorg
|
||||
myrepo
|
||||
NAMECHANGE
|
||||
namespaceanddescendants
|
||||
nao
|
||||
@@ -976,6 +1034,7 @@ NTAPI
|
||||
ntdll
|
||||
NTSTATUS
|
||||
NTSYSAPI
|
||||
nullability
|
||||
NULLCURSOR
|
||||
nullonfailure
|
||||
numberbox
|
||||
@@ -987,6 +1046,8 @@ OEMCONVERT
|
||||
officehubintl
|
||||
OFN
|
||||
ofs
|
||||
OICI
|
||||
OICIIO
|
||||
oldcolor
|
||||
olditem
|
||||
oldpath
|
||||
@@ -997,6 +1058,7 @@ openas
|
||||
opencode
|
||||
OPENFILENAME
|
||||
opensource
|
||||
openurl
|
||||
openxmlformats
|
||||
OPTIMIZEFORINVOKE
|
||||
ORPHANEDDIALOGTITLE
|
||||
@@ -1017,6 +1079,9 @@ OWNDC
|
||||
OWNERDRAWFIXED
|
||||
Packagemanager
|
||||
PACL
|
||||
padx
|
||||
pady
|
||||
PAI
|
||||
PAINTSTRUCT
|
||||
PALETTEWINDOW
|
||||
PARENTNOTIFY
|
||||
@@ -1189,6 +1254,7 @@ RAWPATH
|
||||
rbhid
|
||||
rclsid
|
||||
RCZOOMIT
|
||||
rdp
|
||||
RDW
|
||||
READMODE
|
||||
READOBJECTS
|
||||
@@ -1216,6 +1282,7 @@ remappings
|
||||
REMAPSUCCESSFUL
|
||||
REMAPUNSUCCESSFUL
|
||||
Remotable
|
||||
remotedesktop
|
||||
remoteip
|
||||
Removelnk
|
||||
renamable
|
||||
@@ -1246,6 +1313,7 @@ RIGHTSCROLLBAR
|
||||
riid
|
||||
RKey
|
||||
RNumber
|
||||
rollups
|
||||
rop
|
||||
ROUNDSMALL
|
||||
rpcrt
|
||||
@@ -1278,7 +1346,7 @@ SCREENFONTS
|
||||
screensaver
|
||||
screenshots
|
||||
scrollviewer
|
||||
SDDL
|
||||
sddl
|
||||
SDKDDK
|
||||
sdns
|
||||
searchterm
|
||||
@@ -1448,6 +1516,7 @@ STYLECHANGING
|
||||
subkeys
|
||||
sublang
|
||||
SUBMODULEUPDATE
|
||||
sug
|
||||
Superbar
|
||||
sut
|
||||
svchost
|
||||
@@ -1456,6 +1525,9 @@ SVGIO
|
||||
svgz
|
||||
SVSI
|
||||
SWFO
|
||||
swp
|
||||
SWPNOSIZE
|
||||
SWPNOZORDER
|
||||
SWRESTORE
|
||||
symbolrequestprod
|
||||
SYMCACHE
|
||||
@@ -1472,6 +1544,8 @@ SYSKEY
|
||||
syskeydown
|
||||
SYSKEYUP
|
||||
SYSLIB
|
||||
sysmenu
|
||||
systemai
|
||||
SYSTEMAPPS
|
||||
SYSTEMMODAL
|
||||
SYSTEMTIME
|
||||
@@ -1520,6 +1594,7 @@ tlc
|
||||
TPMLEFTALIGN
|
||||
TPMRETURNCMD
|
||||
TNP
|
||||
Toggleable
|
||||
Toolhelp
|
||||
toolwindow
|
||||
TOPDOWNDIB
|
||||
@@ -1557,6 +1632,9 @@ UHash
|
||||
UIA
|
||||
UIEx
|
||||
ULONGLONG
|
||||
Ultrawide
|
||||
UMax
|
||||
UMin
|
||||
ums
|
||||
uncompilable
|
||||
UNCPRIORITY
|
||||
@@ -2032,6 +2110,7 @@ metadatamatters
|
||||
middleclickaction
|
||||
MIIM
|
||||
mikeclayton
|
||||
mikehall
|
||||
minimizebox
|
||||
modelcontextprotocol
|
||||
mousehighlighter
|
||||
@@ -2152,6 +2231,7 @@ taskbar
|
||||
TESTONLY
|
||||
TEXTBOXNEWLINE
|
||||
textextractor
|
||||
textvariable
|
||||
tgamma
|
||||
THEMECHANGED
|
||||
thickframe
|
||||
@@ -2191,6 +2271,7 @@ wft
|
||||
wikimedia
|
||||
wikipedia
|
||||
windowedge
|
||||
WINDOWSAPPRUNTIME
|
||||
windowsml
|
||||
winexe
|
||||
winforms
|
||||
|
||||
3
.github/actions/spell-check/patterns.txt
vendored
3
.github/actions/spell-check/patterns.txt
vendored
@@ -289,3 +289,6 @@ St&yle
|
||||
|
||||
# Microsoft Store URLs and product IDs
|
||||
ms-windows-store://\S+
|
||||
|
||||
# ANSI color codes
|
||||
(?:\\(?:u00|x)1[Bb]|\\03[1-7]|\x1b|\\u\{1[Bb]\})\[\d+(?:;\d+)*m
|
||||
|
||||
2
.github/copilot-instructions.md
vendored
2
.github/copilot-instructions.md
vendored
@@ -33,4 +33,4 @@ These are auto-applied based on file location:
|
||||
## Detailed Documentation
|
||||
|
||||
- [Architecture](../doc/devdocs/core/architecture.md)
|
||||
- [Coding Style](../doc/devdocs/development/style.md)
|
||||
- [Coding Style](../doc/devdocs/development/style.md)
|
||||
@@ -33,7 +33,7 @@ Generated Files/ReleaseNotes/
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- GitHub CLI (`gh`) installed and authenticated
|
||||
- **GitHub CLI (`gh`) installed and authenticated** — The collection script uses `gh pr view` and `gh api graphql` to fetch PR metadata and co-author information. Run `gh auth status` to verify; if not logged in, run `gh auth login` first. See [Step 1.0.0](./references/step1-collection.md) for details.
|
||||
- MCP Server: github-mcp-server installed
|
||||
- GitHub Copilot code review enabled for the org/repo
|
||||
|
||||
@@ -49,6 +49,10 @@ Generated Files/ReleaseNotes/
|
||||
|
||||
```
|
||||
┌────────────────────────────────┐
|
||||
│ 1.0 Verify gh auth + MemberList │
|
||||
└────────────────────────────────┘
|
||||
↓
|
||||
┌────────────────────────────────┐
|
||||
│ 1.1 Collect PRs (stable range) │
|
||||
└────────────────────────────────┘
|
||||
↓
|
||||
@@ -85,6 +89,7 @@ Generated Files/ReleaseNotes/
|
||||
|
||||
| Step | Action | Details |
|
||||
|------|--------|---------|
|
||||
| 1.0 | Verify prerequisites | `gh auth status` must pass; generate MemberList.md |
|
||||
| 1.1 | Collect PRs | From previous release tag on `stable` branch → `sorted_prs.csv` |
|
||||
| 1.2 | Assign Milestones | Ensure all PRs have correct milestone |
|
||||
| 2.1–2.4 | Label PRs | Auto-suggest + human label low-confidence |
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
- Added mouse button actions so you can choose what left, right, or middle click does. Thanks [@PesBandi](https://github.com/PesBandi)!
|
||||
- Added mouse button actions so you can choose what left, right, or middle click does in [#1234](https://github.com/microsoft/PowerToys/pull/1234) by [@PesBandi](https://github.com/PesBandi)
|
||||
|
||||
- Aligned window styling with current Windows theme for a cleaner look. Thanks [@sadirano](https://github.com/sadirano)!
|
||||
- Aligned window styling with current Windows theme for a cleaner look in [#1235](https://github.com/microsoft/PowerToys/pull/1235) by [@sadirano](https://github.com/sadirano)
|
||||
|
||||
- Ensured screen readers are notified when the selected item in the list changes for better accessibility.
|
||||
- Ensured screen readers are notified when the selected item in the list changes for better accessibility in [#1236](https://github.com/microsoft/PowerToys/pull/1236)
|
||||
|
||||
- Implemented configurable UI test pipeline that can use pre-built official releases instead of building everything from scratch, reducing test execution time from 2+ hours.
|
||||
- Implemented configurable UI test pipeline that can use pre-built official releases instead of building everything from scratch, reducing test execution time from 2+ hours in [#1237](https://github.com/microsoft/PowerToys/pull/1237)
|
||||
|
||||
- Fixed Alt+Left Arrow navigation not working when search box contains text. Thanks [@jiripolasek](https://github.com/jiripolasek)!
|
||||
- Fixed Alt+Left Arrow navigation not working when search box contains text in [#1238](https://github.com/microsoft/PowerToys/pull/1238) by [@jiripolasek](https://github.com/jiripolasek)
|
||||
@@ -1,6 +1,7 @@
|
||||
# Step 1: Collection and Milestones
|
||||
|
||||
## 1.0 To-do
|
||||
- 1.0.0 Verify GitHub CLI authentication (REQUIRED)
|
||||
- 1.0.1 Generate MemberList.md (REQUIRED)
|
||||
- 1.1 Collect PRs
|
||||
- 1.2 Assign Milestones (REQUIRED)
|
||||
@@ -20,6 +21,34 @@
|
||||
|
||||
---
|
||||
|
||||
## 1.0.0 Verify GitHub CLI Authentication (REQUIRED)
|
||||
|
||||
⚠️ **BLOCKING:** The collection script requires an authenticated `gh` CLI to fetch PR metadata and co-author information via GitHub's GraphQL API. Without authentication, PR data and `NeedThanks` attribution will be incomplete.
|
||||
|
||||
### Check authentication status
|
||||
|
||||
```powershell
|
||||
gh auth status
|
||||
```
|
||||
|
||||
**If authenticated:** You'll see `Logged in to github.com account <username>`. Proceed to 1.0.1.
|
||||
|
||||
**If NOT authenticated:** Run the login flow before continuing:
|
||||
|
||||
```powershell
|
||||
# Interactive login (opens browser for OAuth)
|
||||
gh auth login --hostname github.com --web
|
||||
|
||||
# Or use a personal access token
|
||||
gh auth login --with-token <<< "YOUR_GITHUB_TOKEN"
|
||||
```
|
||||
|
||||
**Required scopes:** `repo` (for reading PR data and assigning milestones)
|
||||
|
||||
After login, verify again with `gh auth status` and confirm exit code 0.
|
||||
|
||||
---
|
||||
|
||||
## 1.0.1 Generate MemberList.md (REQUIRED)
|
||||
|
||||
Create `Generated Files/ReleaseNotes/MemberList.md` from the **PowerToys core team** section in [COMMUNITY.md](../../../COMMUNITY.md).
|
||||
@@ -80,6 +109,8 @@ The script detects both merge commits (`Merge pull request #12345`) and squash c
|
||||
**Output** (in `Generated Files/ReleaseNotes/`):
|
||||
- `milestone_prs.json` - raw PR data
|
||||
- `sorted_prs.csv` - sorted PR list with columns: Id, Title, Labels, Author, Url, Body, CopilotSummary, NeedThanks
|
||||
- `Author`: Comma-separated list of all contributors (PR opener + co-authors from commit trailers)
|
||||
- `NeedThanks`: Comma-separated list of external contributors to thank (excludes core team members from MemberList.md). Empty string means no thanks needed.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -16,7 +16,7 @@ For each CSV in `Generated Files/ReleaseNotes/grouped_csv/`, create a markdown f
|
||||
- Use the “Verb-ed + Scenario + Impact” sentence structure—make readers think, “That’s exactly what I need” or “Yes, that’s an awesome fix.”; The "impact" can be end-user focused (written to convey user excitement) or technical (performance/stability) when user-facing impact is minimal.
|
||||
- If nothing special on impact or unclear impact, mark as needing human summary
|
||||
- Source from Title, Body, and CopilotSummary (prefer CopilotSummary when available)
|
||||
- If the column `NeedThanks` in CSV is `True`, append: `Thanks [@Author](https://github.com/Author)!`
|
||||
- The `NeedThanks` column contains a comma-separated list of external contributor usernames who should be thanked (empty = no thanks needed, all authors are core team). For each non-empty `NeedThanks` value, append thanks for **every** listed contributor: `Thanks [@user1](https://github.com/user1)!` for a single contributor, or `Thanks [@user1](https://github.com/user1) and [@user2](https://github.com/user2)!` for two, or `Thanks [@user1](https://github.com/user1), [@user2](https://github.com/user2), and [@user3](https://github.com/user3)!` for three or more.
|
||||
- Do NOT include PR numbers in bullet lines
|
||||
- Do NOT mention “security” or “privacy” issues, since these are not known and could be leveraged by attackers in earlier versions. Instead, describe the user-facing scenario, usage, or impact.
|
||||
- If confidence < 70%, write: `Human Summary Needed: <PR full link>`
|
||||
@@ -72,13 +72,13 @@ Some items in the Development section may overlap and should be moved to the Mod
|
||||
|
||||
## Advanced Paste
|
||||
|
||||
- Wrapped paste option lists in a single ScrollViewer
|
||||
- Added image input handling for AI-powered transformations
|
||||
- Wrapped paste option lists in a single ScrollViewer in [#5678](https://github.com/microsoft/PowerToys/pull/5678)
|
||||
- Added image input handling for AI-powered transformations in [#5679](https://github.com/microsoft/PowerToys/pull/5679)
|
||||
...
|
||||
|
||||
## Awake
|
||||
|
||||
- Fixed timed mode expiration. Thanks [@daverayment](https://github.com/daverayment)!
|
||||
- Fixed timed mode expiration in [#5680](https://github.com/microsoft/PowerToys/pull/5680) by [@daverayment](https://github.com/daverayment)
|
||||
...
|
||||
|
||||
---
|
||||
|
||||
@@ -42,30 +42,7 @@ param(
|
||||
[string]$OutputJson = "milestone_prs.json"
|
||||
)
|
||||
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Dump merged PR information whose merge commits are reachable from EndCommit but not from StartCommit.
|
||||
.DESCRIPTION
|
||||
Uses git rev-list to compute commits in the (StartCommit, EndCommit] range, extracts PR numbers from merge commit messages,
|
||||
queries GitHub (gh CLI) for details, then outputs a CSV.
|
||||
|
||||
PR merge commit messages in PowerToys generally contain patterns like:
|
||||
Merge pull request #12345 from ...
|
||||
|
||||
.EXAMPLE
|
||||
pwsh ./dump-prs-since-commit.ps1 -StartCommit 0123abcd -Branch stable
|
||||
|
||||
.EXAMPLE
|
||||
pwsh ./dump-prs-since-commit.ps1 -StartCommit 0123abcd -EndCommit 89ef7654 -OutputCsv changes.csv
|
||||
|
||||
.NOTES
|
||||
Requires: gh CLI authenticated; git available in working directory (must be inside PowerToys repo clone).
|
||||
CopilotSummary behavior:
|
||||
- Attempts to locate the latest GitHub Copilot authored review (preferred).
|
||||
- If no review is found, lazily fetches PR comments to look for a Copilot-authored comment.
|
||||
- Normalizes whitespace and strips newlines. Empty when no Copilot activity detected.
|
||||
- Run with -Verbose to see whether the summary came from a 'review' or 'comment' source.
|
||||
#>
|
||||
# (See top-level synopsis above for full documentation)
|
||||
|
||||
function Write-Info($msg) { Write-Host $msg -ForegroundColor Cyan }
|
||||
function Write-Warn($msg) { Write-Host $msg -ForegroundColor Yellow }
|
||||
@@ -151,11 +128,11 @@ catch {
|
||||
}
|
||||
|
||||
Write-Info "Collecting commits between $startSha..$endSha (excluding start, including end)."
|
||||
# Get list of commits reachable from end but not from start.
|
||||
# IMPORTANT: In PowerShell, the .. operator creates a numeric/char range. If $startSha and $endSha look like hex strings,
|
||||
# `$startSha..$endSha` must be passed as a single string argument.
|
||||
$rangeArg = "$startSha..$endSha"
|
||||
$commitList = git rev-list $rangeArg
|
||||
# Get list of commits reachable from end but not from start.
|
||||
# IMPORTANT: In PowerShell, the .. operator creates a numeric/char range. If $startSha and $endSha look like hex strings,
|
||||
# `$startSha..$endSha` must be passed as a single string argument.
|
||||
$rangeArg = "$startSha..$endSha"
|
||||
$commitList = git rev-list $rangeArg
|
||||
|
||||
# Normalize list (filter out empty strings)
|
||||
$normalizedCommits = $commitList | Where-Object { $_ -and $_.Trim() -ne '' }
|
||||
@@ -210,6 +187,63 @@ $prNumbers = $mergeCommits | Select-Object -ExpandProperty Pr -Unique | Sort-Obj
|
||||
Write-Info ("Found {0} unique PRs: {1}" -f $prNumbers.Count, ($prNumbers -join ', '))
|
||||
Write-DebugMsg ("Total merge commits examined: {0}" -f $mergeCommits.Count)
|
||||
|
||||
# Build a map of PR number → list of commit SHAs (for co-author extraction)
|
||||
$prToCommits = @{}
|
||||
foreach ($mc in $mergeCommits) {
|
||||
if (-not $prToCommits.ContainsKey($mc.Pr)) {
|
||||
$prToCommits[$mc.Pr] = @()
|
||||
}
|
||||
$prToCommits[$mc.Pr] += $mc.Sha
|
||||
}
|
||||
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Get all authors (including co-authors) for a set of commits via GitHub GraphQL API.
|
||||
.DESCRIPTION
|
||||
Uses the Commit.authors field in GitHub's GraphQL API which natively includes
|
||||
co-authors (from Co-authored-by trailers). Returns GitHub usernames (login)
|
||||
without any email parsing — GitHub resolves the association for us.
|
||||
|
||||
NOTE: For squash merges this captures all co-authors correctly because GitHub
|
||||
preserves Co-authored-by trailers in the squash commit. For traditional merge
|
||||
commits, only the merger's author is returned — co-authors on individual PR
|
||||
commits are not traversed. This is acceptable because PowerToys primarily uses
|
||||
squash merging.
|
||||
#>
|
||||
function Get-CommitAuthors {
|
||||
param(
|
||||
[string[]]$CommitShas,
|
||||
[string]$RepoFullName = "microsoft/PowerToys"
|
||||
)
|
||||
$parts = $RepoFullName -split '/'
|
||||
$owner = $parts[0]
|
||||
$repoName = $parts[1]
|
||||
$allAuthors = @()
|
||||
|
||||
foreach ($sha in $CommitShas) {
|
||||
try {
|
||||
$query = "{ repository(owner: `"$owner`", name: `"$repoName`") { object(expression: `"$sha`") { ... on Commit { authors(first: 20) { nodes { user { login } name } } } } } }"
|
||||
$result = gh api graphql -f query="$query" 2>$null | ConvertFrom-Json
|
||||
$nodes = $result.data.repository.object.authors.nodes
|
||||
if ($nodes) {
|
||||
foreach ($node in $nodes) {
|
||||
if ($node.user -and $node.user.login) {
|
||||
$allAuthors += $node.user.login
|
||||
} else {
|
||||
# User without a GitHub account (rare) — use display name as fallback
|
||||
Write-DebugMsg "Commit $sha has an author without GitHub account: $($node.name)"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
catch {
|
||||
Write-DebugMsg "GraphQL authors query failed for commit ${sha}: $_"
|
||||
}
|
||||
}
|
||||
|
||||
return $allAuthors | Select-Object -Unique
|
||||
}
|
||||
|
||||
# Query GitHub for each PR
|
||||
$prDetails = @()
|
||||
function Get-CopilotSummaryFromPrJson {
|
||||
@@ -307,22 +341,45 @@ foreach ($pr in $prNumbers) {
|
||||
$bodyValue = if ($json.body) { ($json.body -replace "`r", '') -replace "`n", ' ' } else { '' }
|
||||
$bodyValue = $bodyValue -replace '\s+', ' '
|
||||
|
||||
# Determine if author needs thanks (not in member list)
|
||||
# Collect all contributors: PR author + co-authors from commit messages
|
||||
$authorLogin = $json.author.login
|
||||
$needThanks = $true
|
||||
if ($memberList.Count -gt 0 -and $authorLogin) {
|
||||
$needThanks = -not ($memberList -contains $authorLogin)
|
||||
$allContributors = @($authorLogin)
|
||||
|
||||
# Extract all authors (including co-authors) from associated commits via GitHub GraphQL API
|
||||
if ($prToCommits.ContainsKey([int]$pr)) {
|
||||
$commitAuthors = Get-CommitAuthors -CommitShas $prToCommits[[int]$pr] -RepoFullName $Repo
|
||||
if ($commitAuthors) {
|
||||
$allContributors += $commitAuthors
|
||||
}
|
||||
}
|
||||
|
||||
# Deduplicate contributors (case-insensitive)
|
||||
$allContributors = $allContributors | Where-Object { $_ } | Sort-Object -Unique
|
||||
|
||||
# Filter to only external contributors (not in member list) for thanks
|
||||
$externalContributors = @()
|
||||
if ($memberList.Count -gt 0) {
|
||||
$externalContributors = $allContributors | Where-Object { -not ($memberList -contains $_) }
|
||||
} else {
|
||||
$externalContributors = $allContributors
|
||||
}
|
||||
|
||||
# Author column: all contributors (comma-separated)
|
||||
$authorField = ($allContributors -join ', ')
|
||||
|
||||
# NeedThanks column: comma-separated list of external contributors who
|
||||
# deserve thanks attribution. Empty string means no thanks needed.
|
||||
$needThanksField = ($externalContributors -join ', ')
|
||||
|
||||
$prDetails += [PSCustomObject]@{
|
||||
Id = $json.number
|
||||
Title = $json.title
|
||||
Labels = $labelNames
|
||||
Author = $authorLogin
|
||||
Author = $authorField
|
||||
Url = $json.url
|
||||
Body = $bodyValue
|
||||
CopilotSummary = $copilot.Summary
|
||||
NeedThanks = $needThanks
|
||||
NeedThanks = $needThanksField
|
||||
}
|
||||
}
|
||||
catch {
|
||||
|
||||
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
@@ -106,7 +106,13 @@
|
||||
"PowerToys.SvgThumbnailProvider.dll",
|
||||
"PowerToys.SvgThumbnailProvider.exe",
|
||||
"PowerToys.SvgThumbnailProviderCpp.dll",
|
||||
"PowerToys.KeyboardManager.dll",
|
||||
|
||||
"KeyboardManagerEditor\\PowerToys.KeyboardManagerEditor.exe",
|
||||
"WinUI3Apps\\PowerToys.KeyboardManagerEditorUI.exe",
|
||||
"WinUI3Apps\\PowerToys.KeyboardManagerEditorUI.dll",
|
||||
"KeyboardManagerEngine\\PowerToys.KeyboardManagerEngine.exe",
|
||||
"PowerToys.KeyboardManagerEditorLibraryWrapper.dll",
|
||||
"WinUI3Apps\\PowerToys.HostsModuleInterface.dll",
|
||||
"WinUI3Apps\\PowerToys.HostsUILib.dll",
|
||||
"WinUI3Apps\\PowerToys.Hosts.dll",
|
||||
|
||||
@@ -9,7 +9,7 @@ schedules:
|
||||
always: false # only run if there's code changes!
|
||||
|
||||
pool:
|
||||
vmImage: windows-2019
|
||||
vmImage: windows-latest
|
||||
|
||||
resources:
|
||||
repositories:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -17,10 +17,10 @@ $nonDirectoryAssetsItems = Get-ChildItem $targetAssetsDir -Attributes !Directory
|
||||
$directoryAssetsItems = Get-ChildItem $targetAssetsDir -Attributes Directory
|
||||
|
||||
if ($directoryAssetsItems.Count -le 0) {
|
||||
Write-Host -ForegroundColor Red "No directories detected in " $nonDirectoryAssetsItems ". Are you sure this is the right path?`r`n"
|
||||
Write-Host -ForegroundColor Red "ERROR: No directories detected in " $nonDirectoryAssetsItems ". Are you sure this is the right path?`r`n"
|
||||
$totalFailures++;
|
||||
} elseif ($nonDirectoryAssetsItems.Count -gt 0) {
|
||||
Write-Host -ForegroundColor Red "Detected " $nonDirectoryAssetsItems " files in " $targetAssetsDir "`r`n"
|
||||
Write-Host -ForegroundColor Red "ERROR: Detected " $nonDirectoryAssetsItems " files in " $targetAssetsDir ". Each application should use a named subdirectory for assets.`r`n"
|
||||
$totalFailures++;
|
||||
} else {
|
||||
Write-Host -ForegroundColor Green "Only directories detected in " $targetAssetsDir "`r`n"
|
||||
@@ -29,7 +29,7 @@ if ($directoryAssetsItems.Count -le 0) {
|
||||
# Make sure there's no resources.pri file. Each application should use a different name for their own resources file path.
|
||||
$resourcesPriFiles = Get-ChildItem $targetDir -Filter resources.pri
|
||||
if ($resourcesPriFiles.Count -gt 0) {
|
||||
Write-Host -ForegroundColor Red "Detected a resources.pri file in " $targetDir "`r`n"
|
||||
Write-Host -ForegroundColor Red "ERROR: Detected a resources.pri file in " $targetDir ". Each application should use a unique name for its resources file.`r`n"
|
||||
$totalFailures++;
|
||||
} else {
|
||||
Write-Host -ForegroundColor Green "No resources.pri file detected in " $targetDir "`r`n"
|
||||
@@ -38,7 +38,7 @@ if ($resourcesPriFiles.Count -gt 0) {
|
||||
# Each application should have their XAML files in their own paths to avoid these conflicts.
|
||||
$resourcesPriFiles = Get-ChildItem $targetDir -Filter *.xbf
|
||||
if ($resourcesPriFiles.Count -gt 0) {
|
||||
Write-Host -ForegroundColor Red "Detected a .xbf file in " $targetDir "`r`n"
|
||||
Write-Host -ForegroundColor Red "ERROR: Detected a .xbf file in " $targetDir ". Ensure all XAML files are placed in a subdirectory in each application.`r`n"
|
||||
$totalFailures++;
|
||||
} else {
|
||||
Write-Host -ForegroundColor Green "No .xbf files detected in " $targetDir "`r`n"
|
||||
|
||||
@@ -20,6 +20,7 @@
|
||||
<NuGetAuditMode>direct</NuGetAuditMode>
|
||||
<IncludeSourceRevisionInInformationalVersion>false</IncludeSourceRevisionInInformationalVersion> <!-- Don't add source revision hash to the product version of binaries. -->
|
||||
<PlatformTarget>$(Platform)</PlatformTarget>
|
||||
<RestoreEnablePackagePruning Condition=" '$(VisualStudioVersion)' == '17.0'">false </RestoreEnablePackagePruning>
|
||||
|
||||
<!-- Enable Microsoft.Testing.Platform -->
|
||||
<EnableMSTestRunner>true</EnableMSTestRunner>
|
||||
|
||||
@@ -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.5.250829002" />
|
||||
<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,6 +497,31 @@
|
||||
<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/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" />
|
||||
<Project Path="src/modules/MouseUtils/MouseJump.Common/MouseJump.Common.csproj">
|
||||
<Platform Solution="*|ARM64" Project="ARM64" />
|
||||
<Platform Solution="*|x64" Project="x64" />
|
||||
</Project>
|
||||
<Project Path="src/modules/MouseUtils/MouseJump/MouseJump.vcxproj" Id="8a08d663-4995-40e3-b42c-3f910625f284" />
|
||||
<Project Path="src/modules/MouseUtils/MouseJumpUI/MouseJumpUI.csproj">
|
||||
<Platform Solution="*|ARM64" Project="ARM64" />
|
||||
<Platform Solution="*|x64" Project="x64" />
|
||||
</Project>
|
||||
<Project Path="src/modules/MouseUtils/MousePointerCrosshairs/MousePointerCrosshairs.vcxproj" Id="eae14c0e-7a6b-45da-9080-a7d8c077ba6e" />
|
||||
</Folder>
|
||||
<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" />
|
||||
</Project>
|
||||
<Project Path="src/modules/MouseUtils/MouseUtils.UITests/MouseUtils.UITests.csproj">
|
||||
<Platform Solution="*|ARM64" Project="ARM64" />
|
||||
<Platform Solution="*|x64" Project="x64" />
|
||||
</Project>
|
||||
</Folder>
|
||||
<Folder Name="/modules/keyboardmanager/Tests/">
|
||||
<Project Path="src/modules/keyboardmanager/KeyboardManagerEditorTest/KeyboardManagerEditorTest.vcxproj" Id="62173d9a-6724-4c00-a1c8-fb646480a9ec" />
|
||||
<Project Path="src/modules/keyboardmanager/KeyboardManagerEngineTest/KeyboardManagerEngineTest.vcxproj" Id="7f4b3a60-bc27-45a7-8000-68b0b6ea7466" />
|
||||
@@ -720,31 +745,6 @@
|
||||
<Platform Solution="*|x64" Project="x64" />
|
||||
</Project>
|
||||
</Folder>
|
||||
<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" />
|
||||
<Project Path="src/modules/MouseUtils/MouseJump.Common/MouseJump.Common.csproj">
|
||||
<Platform Solution="*|ARM64" Project="ARM64" />
|
||||
<Platform Solution="*|x64" Project="x64" />
|
||||
</Project>
|
||||
<Project Path="src/modules/MouseUtils/MouseJump/MouseJump.vcxproj" Id="8a08d663-4995-40e3-b42c-3f910625f284" />
|
||||
<Project Path="src/modules/MouseUtils/MouseJumpUI/MouseJumpUI.csproj">
|
||||
<Platform Solution="*|ARM64" Project="ARM64" />
|
||||
<Platform Solution="*|x64" Project="x64" />
|
||||
</Project>
|
||||
<Project Path="src/modules/MouseUtils/MousePointerCrosshairs/MousePointerCrosshairs.vcxproj" Id="eae14c0e-7a6b-45da-9080-a7d8c077ba6e" />
|
||||
</Folder>
|
||||
<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" />
|
||||
</Project>
|
||||
<Project Path="src/modules/MouseUtils/MouseUtils.UITests/MouseUtils.UITests.csproj">
|
||||
<Platform Solution="*|ARM64" Project="ARM64" />
|
||||
<Platform Solution="*|x64" Project="x64" />
|
||||
</Project>
|
||||
</Folder>
|
||||
<Folder Name="/modules/MouseWithoutBorders/">
|
||||
<Project Path="src/modules/MouseWithoutBorders/App/Helper/MouseWithoutBordersHelper.csproj">
|
||||
<Platform Solution="*|ARM64" Project="ARM64" />
|
||||
|
||||
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>
|
||||
|
||||
|
||||
@@ -141,3 +141,10 @@ Note: The DllHost process loads the DLL only when the context menu is triggered
|
||||
- A signature issue with the MSIX package
|
||||
|
||||
- For development and testing, using the Windows 10 handler can be easier since it doesn't require signing.
|
||||
|
||||
## Restoring Built-in Windows New context menu
|
||||
If the Windows 11 built-in New context menu doesn't reappear on uninstalling PowerToys, some issue with settings etc. here's how to restore the built-in New context menu.
|
||||
|
||||
1. Open Registry Editor
|
||||
1. Go to the key "Computer\HKEY_CURRENT_USER\Software\Classes\Directory\background\ShellEx\ContextMenuHandlers"
|
||||
1. Delete the "New" subkey (i.e. fullpath "Computer\HKEY_CURRENT_USER\Software\Classes\Directory\background\ShellEx\ContextMenuHandlers\New")
|
||||
@@ -1119,6 +1119,35 @@ LExit:
|
||||
return WcaFinalize(er);
|
||||
}
|
||||
|
||||
UINT __stdcall RestoreBuiltInNewContextMenuCA(MSIHANDLE hInstall)
|
||||
{
|
||||
HRESULT hr = S_OK;
|
||||
hr = WcaInitialize(hInstall, "RestoreBuiltInNewContextMenuCA");
|
||||
|
||||
constexpr wchar_t built_in_new_registry_path[] = LR"(Software\Classes\Directory\Background\ShellEx\ContextMenuHandlers\New)";
|
||||
|
||||
HKEY key{};
|
||||
|
||||
if (RegOpenKeyExW(HKEY_CURRENT_USER,
|
||||
built_in_new_registry_path,
|
||||
0,
|
||||
KEY_ALL_ACCESS,
|
||||
&key) != ERROR_SUCCESS)
|
||||
{
|
||||
return WcaFinalize(ERROR_SUCCESS);
|
||||
}
|
||||
|
||||
if (RegDeleteValueW(key, nullptr) != ERROR_SUCCESS)
|
||||
{
|
||||
RegCloseKey(key);
|
||||
return WcaFinalize(ERROR_SUCCESS);
|
||||
}
|
||||
|
||||
RegCloseKey(key);
|
||||
|
||||
return WcaFinalize(ERROR_SUCCESS);
|
||||
}
|
||||
|
||||
UINT __stdcall TelemetryLogInstallSuccessCA(MSIHANDLE hInstall)
|
||||
{
|
||||
HRESULT hr = S_OK;
|
||||
|
||||
@@ -7,6 +7,7 @@ EXPORTS
|
||||
ApplyModulesRegistryChangeSetsCA
|
||||
DetectPrevInstallPathCA
|
||||
RemoveScheduledTasksCA
|
||||
RestoreBuiltInNewContextMenuCA
|
||||
TelemetryLogInstallSuccessCA
|
||||
TelemetryLogInstallCancelCA
|
||||
TelemetryLogInstallFailCA
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -161,6 +161,9 @@
|
||||
<!-- Clean Video Conference Mute registry keys that might be around from previous installations. We've deprecated this utility since then. -->
|
||||
<Custom Action="CleanVideoConferenceRegistry" Before="InstallFinalize" Condition="NOT Installed" />
|
||||
|
||||
<!-- Restore built-in "New" context menu in case user disabled it via New+ -->
|
||||
<Custom Action="RestoreBuiltInNewContextMenu" Before="RemoveFiles" Condition="Installed AND (REMOVE="ALL")" />
|
||||
|
||||
</InstallExecuteSequence>
|
||||
|
||||
<CustomAction Id="SetLaunchPowerToysParam" Property="LaunchPowerToys" Value="[INSTALLFOLDER]" />
|
||||
@@ -262,6 +265,8 @@
|
||||
|
||||
<CustomAction Id="SetBundleInstallLocation" Return="ignore" Impersonate="no" Execute="deferred" DllEntry="SetBundleInstallLocationCA" BinaryRef="PTCustomActions" />
|
||||
|
||||
<CustomAction Id="RestoreBuiltInNewContextMenu" Return="ignore" Impersonate="yes" Execute="deferred" DllEntry="RestoreBuiltInNewContextMenuCA" BinaryRef="PTCustomActions" />
|
||||
|
||||
<!-- Close 'PowerToys.exe' before uninstall-->
|
||||
<Property Id="MSIRESTARTMANAGERCONTROL" Value="DisableShutdown" />
|
||||
<Property Id="MSIFASTINSTALL" Value="DisableShutdown" />
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
<ResourceDictionary
|
||||
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
xmlns:local="using:Microsoft.PowerToys.Common.UI.Controls">
|
||||
xmlns:commoncontrols="using:Microsoft.PowerToys.Common.UI.Controls">
|
||||
|
||||
<Style BasedOn="{StaticResource DefaultKeyVisualStyle}" TargetType="local:KeyVisual" />
|
||||
<Style BasedOn="{StaticResource DefaultKeyVisualStyle}" TargetType="commoncontrols:KeyVisual" />
|
||||
|
||||
<Style x:Key="DefaultKeyVisualStyle" TargetType="local:KeyVisual">
|
||||
<Style x:Key="DefaultKeyVisualStyle" TargetType="commoncontrols:KeyVisual">
|
||||
<Setter Property="MinWidth" Value="16" />
|
||||
<Setter Property="AutomationProperties.AccessibilityView" Value="Raw" />
|
||||
<Setter Property="IsTabStop" Value="False" />
|
||||
@@ -25,7 +25,7 @@
|
||||
<Setter Property="VerticalAlignment" Value="Center" />
|
||||
<Setter Property="Template">
|
||||
<Setter.Value>
|
||||
<ControlTemplate TargetType="local:KeyVisual">
|
||||
<ControlTemplate TargetType="commoncontrols:KeyVisual">
|
||||
<Grid
|
||||
x:Name="KeyHolder"
|
||||
MinWidth="{TemplateBinding MinWidth}"
|
||||
@@ -40,7 +40,7 @@
|
||||
<Grid.BackgroundTransition>
|
||||
<BrushTransition Duration="0:0:0.083" />
|
||||
</Grid.BackgroundTransition>
|
||||
<local:KeyCharPresenter
|
||||
<commoncontrols:KeyCharPresenter
|
||||
x:Name="KeyPresenter"
|
||||
Margin="{TemplateBinding Padding}"
|
||||
HorizontalAlignment="{TemplateBinding HorizontalContentAlignment}"
|
||||
@@ -87,12 +87,12 @@
|
||||
<Style
|
||||
x:Key="SubtleKeyVisualStyle"
|
||||
BasedOn="{StaticResource DefaultKeyVisualStyle}"
|
||||
TargetType="local:KeyVisual">
|
||||
TargetType="commoncontrols:KeyVisual">
|
||||
<Setter Property="Background" Value="{ThemeResource SubtleFillColorTransparentBrush}" />
|
||||
<Setter Property="BorderBrush" Value="{ThemeResource SubtleFillColorTransparentBrush}" />
|
||||
<Setter Property="Template">
|
||||
<Setter.Value>
|
||||
<ControlTemplate TargetType="local:KeyVisual">
|
||||
<ControlTemplate TargetType="commoncontrols:KeyVisual">
|
||||
<Grid
|
||||
x:Name="KeyHolder"
|
||||
MinWidth="{TemplateBinding MinWidth}"
|
||||
@@ -106,7 +106,7 @@
|
||||
<Grid.BackgroundTransition>
|
||||
<BrushTransition Duration="0:0:0.083" />
|
||||
</Grid.BackgroundTransition>
|
||||
<local:KeyCharPresenter
|
||||
<commoncontrols:KeyCharPresenter
|
||||
x:Name="KeyPresenter"
|
||||
Margin="{TemplateBinding Padding}"
|
||||
HorizontalAlignment="{TemplateBinding HorizontalContentAlignment}"
|
||||
@@ -145,14 +145,14 @@
|
||||
<Style
|
||||
x:Key="AccentKeyVisualStyle"
|
||||
BasedOn="{StaticResource DefaultKeyVisualStyle}"
|
||||
TargetType="local:KeyVisual">
|
||||
TargetType="commoncontrols:KeyVisual">
|
||||
<Setter Property="Background" Value="{ThemeResource AccentFillColorDefaultBrush}" />
|
||||
<Setter Property="Foreground" Value="{ThemeResource TextOnAccentFillColorPrimaryBrush}" />
|
||||
<Setter Property="BorderBrush" Value="{ThemeResource AccentControlElevationBorderBrush}" />
|
||||
<Setter Property="BackgroundSizing" Value="OuterBorderEdge" />
|
||||
<Setter Property="Template">
|
||||
<Setter.Value>
|
||||
<ControlTemplate TargetType="local:KeyVisual">
|
||||
<ControlTemplate TargetType="commoncontrols:KeyVisual">
|
||||
<Grid
|
||||
x:Name="KeyHolder"
|
||||
MinWidth="{TemplateBinding MinWidth}"
|
||||
@@ -168,7 +168,7 @@
|
||||
<Grid.BackgroundTransition>
|
||||
<BrushTransition Duration="0:0:0.083" />
|
||||
</Grid.BackgroundTransition>
|
||||
<local:KeyCharPresenter
|
||||
<commoncontrols:KeyCharPresenter
|
||||
x:Name="KeyPresenter"
|
||||
Margin="{TemplateBinding Padding}"
|
||||
HorizontalAlignment="{TemplateBinding HorizontalContentAlignment}"
|
||||
|
||||
@@ -292,4 +292,8 @@ namespace winrt::PowerToys::GPOWrapper::implementation
|
||||
{
|
||||
return static_cast<GpoRuleConfigured>(powertoys_gpo::getConfiguredRunAtStartupValue());
|
||||
}
|
||||
GpoRuleConfigured GPOWrapper::GetConfiguredNewPlusHideBuiltInNewContextMenuValue()
|
||||
{
|
||||
return static_cast<GpoRuleConfigured>(powertoys_gpo::getConfiguredNewPlusHideBuiltInNewContextMenuValue());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -78,6 +78,7 @@ namespace winrt::PowerToys::GPOWrapper::implementation
|
||||
static GpoRuleConfigured GetAllowDataDiagnosticsValue();
|
||||
static GpoRuleConfigured GetConfiguredRunAtStartupValue();
|
||||
static GpoRuleConfigured GetConfiguredNewPlusReplaceVariablesValue();
|
||||
static GpoRuleConfigured GetConfiguredNewPlusHideBuiltInNewContextMenuValue();
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -82,6 +82,7 @@ namespace PowerToys
|
||||
static GpoRuleConfigured GetAllowDataDiagnosticsValue();
|
||||
static GpoRuleConfigured GetConfiguredRunAtStartupValue();
|
||||
static GpoRuleConfigured GetConfiguredNewPlusReplaceVariablesValue();
|
||||
static GpoRuleConfigured GetConfiguredNewPlusHideBuiltInNewContextMenuValue();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -287,4 +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,6 +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();
|
||||
};
|
||||
}
|
||||
|
||||
@@ -84,3 +89,4 @@ namespace winrt::PowerToys::Interop::factory_implementation
|
||||
{
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -72,6 +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();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -170,10 +170,20 @@ namespace CommonSharedConstants
|
||||
const wchar_t LIGHT_SWITCH_LIGHT_THEME_EVENT[] = L"Local\\PowerToysLightSwitch-LightThemeEvent-50077121-2ffc-4841-9c86-ab1bd3f9baca";
|
||||
const wchar_t LIGHT_SWITCH_DARK_THEME_EVENT[] = L"Local\\PowerToysLightSwitch-DarkThemeEvent-b3a835c0-eaa2-49b0-b8eb-f793e3df3368";
|
||||
|
||||
// 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;
|
||||
}
|
||||
|
||||
|
||||
@@ -103,6 +103,7 @@ namespace powertoys_gpo
|
||||
const std::wstring POLICY_MWB_POLICY_DEFINED_IP_MAPPING_RULES = L"MwbPolicyDefinedIpMappingRules";
|
||||
const std::wstring POLICY_NEW_PLUS_HIDE_TEMPLATE_FILENAME_EXTENSION = L"NewPlusHideTemplateFilenameExtension";
|
||||
const std::wstring POLICY_NEW_PLUS_REPLACE_VARIABLES = L"NewPlusReplaceVariablesInTemplateFilenames";
|
||||
const std::wstring POLICY_NEW_PLUS_HIDE_BUILT_IN_NEW_CONTEXT_MENU = L"NewPlusHideBuiltInNewContextMenu";
|
||||
|
||||
// Methods used for reading the registry
|
||||
#pragma region ReadRegistryMethods
|
||||
@@ -700,5 +701,10 @@ namespace powertoys_gpo
|
||||
return getConfiguredValue(POLICY_NEW_PLUS_REPLACE_VARIABLES);
|
||||
}
|
||||
|
||||
inline gpo_rule_configured_t getConfiguredNewPlusHideBuiltInNewContextMenuValue()
|
||||
{
|
||||
return getConfiguredValue(POLICY_NEW_PLUS_HIDE_BUILT_IN_NEW_CONTEXT_MENU);
|
||||
}
|
||||
|
||||
#pragma endregion IndividualModuleSettingPolicies
|
||||
}
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!-- Copyright (c) Microsoft Corporation.
|
||||
Licensed under the MIT License. -->
|
||||
<policyDefinitions xmlns:xsd="http://www.w3.org/2001/XMLSchema" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" revision="1.19" schemaVersion="1.0" xmlns="http://schemas.microsoft.com/GroupPolicy/2006/07/PolicyDefinitions">
|
||||
<policyDefinitions xmlns:xsd="http://www.w3.org/2001/XMLSchema" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" revision="1.20" schemaVersion="1.0" xmlns="http://schemas.microsoft.com/GroupPolicy/2006/07/PolicyDefinitions">
|
||||
<policyNamespaces>
|
||||
<target prefix="powertoys" namespace="Microsoft.Policies.PowerToys" />
|
||||
</policyNamespaces>
|
||||
<resources minRequiredRevision="1.19"/><!-- Last changed with PowerToys v0.97.0 -->
|
||||
<resources minRequiredRevision="1.20"/><!-- Last changed with PowerToys v0.98.0 -->
|
||||
<supportedOn>
|
||||
<definitions>
|
||||
<definition name="SUPPORTED_POWERTOYS_0_64_0" displayName="$(string.SUPPORTED_POWERTOYS_0_64_0)"/>
|
||||
@@ -28,6 +28,7 @@
|
||||
<definition name="SUPPORTED_POWERTOYS_0_90_0" displayName="$(string.SUPPORTED_POWERTOYS_0_90_0)"/>
|
||||
<definition name="SUPPORTED_POWERTOYS_0_96_0" displayName="$(string.SUPPORTED_POWERTOYS_0_96_0)"/>
|
||||
<definition name="SUPPORTED_POWERTOYS_0_97_0" displayName="$(string.SUPPORTED_POWERTOYS_0_97_0)"/>
|
||||
<definition name="SUPPORTED_POWERTOYS_0_98_0" displayName="$(string.SUPPORTED_POWERTOYS_0_98_0)"/>
|
||||
<definition name="SUPPORTED_POWERTOYS_0_64_0_TO_0_87_1" displayName="$(string.SUPPORTED_POWERTOYS_0_64_0_TO_0_87_1)"/>
|
||||
</definitions>
|
||||
</supportedOn>
|
||||
@@ -826,5 +827,15 @@
|
||||
<decimal value="0" />
|
||||
</disabledValue>
|
||||
</policy>
|
||||
<policy name="NewPlusHideBuiltInNewContextMenu" class="Both" displayName="$(string.NewPlusHideBuiltInNewContextMenu)" explainText="$(string.NewPlusHideBuiltInNewContextMenuDescription)" key="Software\Policies\PowerToys" valueName="NewPlusHideBuiltInNewContextMenu">
|
||||
<parentCategory ref="NewPlus" />
|
||||
<supportedOn ref="SUPPORTED_POWERTOYS_0_98_0" />
|
||||
<enabledValue>
|
||||
<decimal value="1" />
|
||||
</enabledValue>
|
||||
<disabledValue>
|
||||
<decimal value="0" />
|
||||
</disabledValue>
|
||||
</policy>
|
||||
</policies>
|
||||
</policyDefinitions>
|
||||
|
||||
@@ -35,6 +35,7 @@
|
||||
<string id="SUPPORTED_POWERTOYS_0_90_0">PowerToys version 0.90.0 or later</string>
|
||||
<string id="SUPPORTED_POWERTOYS_0_96_0">PowerToys version 0.96.0 or later</string>
|
||||
<string id="SUPPORTED_POWERTOYS_0_97_0">PowerToys version 0.97.0 or later</string>
|
||||
<string id="SUPPORTED_POWERTOYS_0_98_0">PowerToys version 0.98.0 or later</string>
|
||||
<string id="SUPPORTED_POWERTOYS_0_64_0_TO_0_87_1">From PowerToys version 0.64.0 until PowerToys version 0.87.1</string>
|
||||
|
||||
<string id="ConfigureAllUtilityGlobalEnabledStateDescription">This policy configures the enabled state for all PowerToys utilities.
|
||||
@@ -238,7 +239,7 @@ If you disable this policy, the setting is disabled and variables in filenames w
|
||||
|
||||
If you don't configure this policy, the user will be able to control the setting and can enable or disable it.
|
||||
</string>
|
||||
|
||||
|
||||
<string id="ConfigureAllUtilityGlobalEnabledState">Configure global utility enabled state</string>
|
||||
<string id="ConfigureEnabledUtilityAdvancedPaste">Advanced Paste: Configure enabled state</string>
|
||||
<string id="ConfigureEnabledUtilityAlwaysOnTop">Always On Top: Configure enabled state</string>
|
||||
@@ -356,6 +357,15 @@ If you disable this policy, users will not be able to select or use Foundry Loca
|
||||
<string id="AllowDiagnosticData">Allow sending diagnostic data</string>
|
||||
<string id="ConfigureRunAtStartup">Configure the run at startup setting</string>
|
||||
<string id="NewPlusReplaceVariablesInTemplateFilenames">Replace variables in template filenames</string>
|
||||
<string id="NewPlusHideBuiltInNewContextMenu">Hide the built-in "New" context menu</string>
|
||||
<string id="NewPlusHideBuiltInNewContextMenuDescription">This policy configures if Windows' built-in New context menu should be hidden on the context menu.
|
||||
|
||||
If you enable this policy, then the built-in New context menu will be hidden, and user can only create new files and folders using New+ and the explorer toolbar New button.
|
||||
|
||||
If you disable this policy, then the build-in New context menu will be displayed as normal in Windows.
|
||||
|
||||
If you don't configure this policy, the user will be able to control the setting and can enable or disable it.
|
||||
</string>
|
||||
</stringTable>
|
||||
|
||||
<presentationTable>
|
||||
@@ -369,4 +379,3 @@ If you disable this policy, users will not be able to select or use Foundry Loca
|
||||
|
||||
</resources>
|
||||
</policyDefinitionResources>
|
||||
|
||||
|
||||
@@ -13,13 +13,11 @@
|
||||
<PropertyGroup Condition="'$(Configuration)'=='Debug'" Label="Configuration">
|
||||
<ConfigurationType>DynamicLibrary</ConfigurationType>
|
||||
<UseDebugLibraries>true</UseDebugLibraries>
|
||||
|
||||
<CharacterSet>Unicode</CharacterSet>
|
||||
</PropertyGroup>
|
||||
<PropertyGroup Condition="'$(Configuration)'=='Release'" Label="Configuration">
|
||||
<ConfigurationType>DynamicLibrary</ConfigurationType>
|
||||
<UseDebugLibraries>false</UseDebugLibraries>
|
||||
|
||||
<WholeProgramOptimization>true</WholeProgramOptimization>
|
||||
<CharacterSet>Unicode</CharacterSet>
|
||||
</PropertyGroup>
|
||||
@@ -112,12 +110,8 @@
|
||||
</ProjectReference>
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<None Include="COMPLETE_REWRITE_SUMMARY.md" />
|
||||
<None Include="CRITICAL_BUG_ANALYSIS.md" />
|
||||
<None Include="CURSOR_WRAP_FIX_ANALYSIS.md" />
|
||||
<None Include="DEBUG_GUIDE.md" />
|
||||
<None Include="CursorWrapTests\WrapSimulator\test_new_algorithm.py" />
|
||||
<None Include="packages.config" />
|
||||
<None Include="VERTICAL_WRAP_BUG_FIX.md" />
|
||||
</ItemGroup>
|
||||
<Import Project="$(VCTargetsPath)\Microsoft.Cpp.targets" />
|
||||
<ImportGroup Label="ExtensionTargets">
|
||||
@@ -130,4 +124,4 @@
|
||||
<Error Condition="!Exists('$(RepoRoot)packages\Microsoft.Windows.CppWinRT.2.0.250303.1\build\native\Microsoft.Windows.CppWinRT.props')" Text="$([System.String]::Format('$(ErrorText)', '$(RepoRoot)packages\Microsoft.Windows.CppWinRT.2.0.250303.1\build\native\Microsoft.Windows.CppWinRT.props'))" />
|
||||
<Error Condition="!Exists('$(RepoRoot)packages\Microsoft.Windows.CppWinRT.2.0.250303.1\build\native\Microsoft.Windows.CppWinRT.targets')" Text="$([System.String]::Format('$(ErrorText)', '$(RepoRoot)packages\Microsoft.Windows.CppWinRT.2.0.250303.1\build\native\Microsoft.Windows.CppWinRT.targets'))" />
|
||||
</Target>
|
||||
</Project>
|
||||
</Project>
|
||||
@@ -163,6 +163,39 @@ void CursorWrapCore::UpdateMonitorInfo()
|
||||
Logger::info(L"======= UPDATE MONITOR INFO END =======");
|
||||
}
|
||||
|
||||
void CursorWrapCore::ResetWrapState()
|
||||
{
|
||||
m_hasPreviousPosition = false;
|
||||
m_hasLastWrapDestination = false;
|
||||
m_previousPosition = { LONG_MIN, LONG_MIN };
|
||||
m_lastWrapDestination = { LONG_MIN, LONG_MIN };
|
||||
}
|
||||
|
||||
CursorDirection CursorWrapCore::CalculateDirection(const POINT& currentPos) const
|
||||
{
|
||||
CursorDirection dir = { 0, 0 };
|
||||
if (m_hasPreviousPosition)
|
||||
{
|
||||
dir.dx = currentPos.x - m_previousPosition.x;
|
||||
dir.dy = currentPos.y - m_previousPosition.y;
|
||||
}
|
||||
return dir;
|
||||
}
|
||||
|
||||
bool CursorWrapCore::IsWithinWrapThreshold(const POINT& currentPos) const
|
||||
{
|
||||
if (!m_hasLastWrapDestination)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
int dx = currentPos.x - m_lastWrapDestination.x;
|
||||
int dy = currentPos.y - m_lastWrapDestination.y;
|
||||
int distanceSquared = dx * dx + dy * dy;
|
||||
|
||||
return distanceSquared <= (WRAP_DISTANCE_THRESHOLD * WRAP_DISTANCE_THRESHOLD);
|
||||
}
|
||||
|
||||
POINT CursorWrapCore::HandleMouseMove(const POINT& currentPos, bool disableWrapDuringDrag, int wrapMode, bool disableOnSingleMonitor)
|
||||
{
|
||||
// Check if wrapping should be disabled on single monitor
|
||||
@@ -176,6 +209,8 @@ POINT CursorWrapCore::HandleMouseMove(const POINT& currentPos, bool disableWrapD
|
||||
loggedOnce = true;
|
||||
}
|
||||
#endif
|
||||
m_previousPosition = currentPos;
|
||||
m_hasPreviousPosition = true;
|
||||
return currentPos;
|
||||
}
|
||||
|
||||
@@ -185,9 +220,31 @@ POINT CursorWrapCore::HandleMouseMove(const POINT& currentPos, bool disableWrapD
|
||||
#ifdef _DEBUG
|
||||
OutputDebugStringW(L"[CursorWrap] [DRAG] Left mouse button down - skipping wrap\n");
|
||||
#endif
|
||||
m_previousPosition = currentPos;
|
||||
m_hasPreviousPosition = true;
|
||||
return currentPos;
|
||||
}
|
||||
|
||||
// Check distance threshold to prevent rapid oscillation
|
||||
if (IsWithinWrapThreshold(currentPos))
|
||||
{
|
||||
#ifdef _DEBUG
|
||||
OutputDebugStringW(L"[CursorWrap] [THRESHOLD] Cursor within wrap threshold - skipping wrap\n");
|
||||
#endif
|
||||
m_previousPosition = currentPos;
|
||||
m_hasPreviousPosition = true;
|
||||
return currentPos;
|
||||
}
|
||||
|
||||
// Clear wrap destination threshold once cursor moves away
|
||||
if (m_hasLastWrapDestination && !IsWithinWrapThreshold(currentPos))
|
||||
{
|
||||
m_hasLastWrapDestination = false;
|
||||
}
|
||||
|
||||
// Calculate cursor movement direction
|
||||
CursorDirection direction = CalculateDirection(currentPos);
|
||||
|
||||
// Convert int wrapMode to WrapMode enum
|
||||
WrapMode mode = static_cast<WrapMode>(wrapMode);
|
||||
|
||||
@@ -195,6 +252,7 @@ POINT CursorWrapCore::HandleMouseMove(const POINT& currentPos, bool disableWrapD
|
||||
{
|
||||
std::wostringstream oss;
|
||||
oss << L"[CursorWrap] [MOVE] Cursor at (" << currentPos.x << L", " << currentPos.y << L")";
|
||||
oss << L" direction=(" << direction.dx << L", " << direction.dy << L")";
|
||||
|
||||
// Get current monitor and identify which one
|
||||
HMONITOR currentMonitor = MonitorFromPoint(currentPos, MONITOR_DEFAULTTONEAREST);
|
||||
@@ -229,9 +287,9 @@ POINT CursorWrapCore::HandleMouseMove(const POINT& currentPos, bool disableWrapD
|
||||
// Get current monitor
|
||||
HMONITOR currentMonitor = MonitorFromPoint(currentPos, MONITOR_DEFAULTTONEAREST);
|
||||
|
||||
// Check if cursor is on an outer edge (filtered by wrap mode)
|
||||
// Check if cursor is on an outer edge (filtered by wrap mode and direction)
|
||||
EdgeType edgeType;
|
||||
if (!m_topology.IsOnOuterEdge(currentMonitor, currentPos, edgeType, mode))
|
||||
if (!m_topology.IsOnOuterEdge(currentMonitor, currentPos, edgeType, mode, &direction))
|
||||
{
|
||||
#ifdef _DEBUG
|
||||
static bool lastWasNotOuter = false;
|
||||
@@ -241,6 +299,8 @@ POINT CursorWrapCore::HandleMouseMove(const POINT& currentPos, bool disableWrapD
|
||||
lastWasNotOuter = true;
|
||||
}
|
||||
#endif
|
||||
m_previousPosition = currentPos;
|
||||
m_hasPreviousPosition = true;
|
||||
return currentPos; // Not on an outer edge
|
||||
}
|
||||
|
||||
@@ -278,5 +338,16 @@ POINT CursorWrapCore::HandleMouseMove(const POINT& currentPos, bool disableWrapD
|
||||
}
|
||||
#endif
|
||||
|
||||
// Update tracking state
|
||||
m_previousPosition = currentPos;
|
||||
m_hasPreviousPosition = true;
|
||||
|
||||
// Store wrap destination for threshold checking
|
||||
if (newPos.x != currentPos.x || newPos.y != currentPos.y)
|
||||
{
|
||||
m_lastWrapDestination = newPos;
|
||||
m_hasLastWrapDestination = true;
|
||||
}
|
||||
|
||||
return newPos;
|
||||
}
|
||||
|
||||
@@ -8,6 +8,24 @@
|
||||
#include <string>
|
||||
#include "MonitorTopology.h"
|
||||
|
||||
// Distance threshold to prevent rapid back-and-forth wrapping (in pixels)
|
||||
constexpr int WRAP_DISTANCE_THRESHOLD = 50;
|
||||
|
||||
// Cursor movement direction
|
||||
struct CursorDirection
|
||||
{
|
||||
int dx; // Horizontal movement (positive = right, negative = left)
|
||||
int dy; // Vertical movement (positive = down, negative = up)
|
||||
|
||||
bool IsMovingLeft() const { return dx < 0; }
|
||||
bool IsMovingRight() const { return dx > 0; }
|
||||
bool IsMovingUp() const { return dy < 0; }
|
||||
bool IsMovingDown() const { return dy > 0; }
|
||||
|
||||
// Returns true if horizontal movement is dominant
|
||||
bool IsPrimarilyHorizontal() const { return abs(dx) >= abs(dy); }
|
||||
};
|
||||
|
||||
// Core cursor wrapping engine
|
||||
class CursorWrapCore
|
||||
{
|
||||
@@ -25,11 +43,28 @@ public:
|
||||
size_t GetMonitorCount() const { return m_monitors.size(); }
|
||||
const MonitorTopology& GetTopology() const { return m_topology; }
|
||||
|
||||
// Reset wrap state (call when disabling/re-enabling)
|
||||
void ResetWrapState();
|
||||
|
||||
private:
|
||||
#ifdef _DEBUG
|
||||
std::wstring GenerateTopologyJSON() const;
|
||||
#endif
|
||||
|
||||
// Calculate movement direction from previous position
|
||||
CursorDirection CalculateDirection(const POINT& currentPos) const;
|
||||
|
||||
// Check if cursor is within threshold distance of last wrap position
|
||||
bool IsWithinWrapThreshold(const POINT& currentPos) const;
|
||||
|
||||
std::vector<MonitorInfo> m_monitors;
|
||||
MonitorTopology m_topology;
|
||||
|
||||
// Movement tracking for direction-based edge priority
|
||||
POINT m_previousPosition = { LONG_MIN, LONG_MIN };
|
||||
bool m_hasPreviousPosition = false;
|
||||
|
||||
// Wrap stability: prevent rapid oscillation
|
||||
POINT m_lastWrapDestination = { LONG_MIN, LONG_MIN };
|
||||
bool m_hasLastWrapDestination = false;
|
||||
};
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
<Solution>
|
||||
<Configurations>
|
||||
<Platform Name="x64" />
|
||||
<Platform Name="x86" />
|
||||
</Configurations>
|
||||
<Project Path="CursorLog/CursorLog.vcxproj" Id="646f6684-9f11-42cd-8b35-b2954404f985" />
|
||||
</Solution>
|
||||
@@ -0,0 +1,196 @@
|
||||
// CursorLog.cpp : Monitors mouse position and logs to file with monitor/DPI info
|
||||
//
|
||||
|
||||
#include <iostream>
|
||||
#include <fstream>
|
||||
#include <string>
|
||||
#include <filesystem>
|
||||
#include <Windows.h>
|
||||
#include <ShellScalingApi.h>
|
||||
|
||||
#pragma comment(lib, "Shcore.lib")
|
||||
|
||||
// Global variables
|
||||
std::ofstream g_outputFile;
|
||||
HHOOK g_mouseHook = nullptr;
|
||||
POINT g_lastPosition = { LONG_MIN, LONG_MIN };
|
||||
DWORD g_mainThreadId = 0;
|
||||
|
||||
// Get monitor information for a given point
|
||||
std::string GetMonitorInfo(POINT pt, UINT* dpiX, UINT* dpiY)
|
||||
{
|
||||
HMONITOR hMonitor = MonitorFromPoint(pt, MONITOR_DEFAULTTONEAREST);
|
||||
if (!hMonitor)
|
||||
return "Unknown";
|
||||
|
||||
MONITORINFOEX monitorInfo = {};
|
||||
monitorInfo.cbSize = sizeof(MONITORINFOEX);
|
||||
GetMonitorInfo(hMonitor, &monitorInfo);
|
||||
|
||||
// Get DPI for this monitor
|
||||
if (SUCCEEDED(GetDpiForMonitor(hMonitor, MDT_EFFECTIVE_DPI, dpiX, dpiY)))
|
||||
{
|
||||
// DPI retrieved successfully
|
||||
}
|
||||
else
|
||||
{
|
||||
*dpiX = 96;
|
||||
*dpiY = 96;
|
||||
}
|
||||
|
||||
// Convert device name to string using proper wide-to-narrow conversion
|
||||
std::wstring deviceName(monitorInfo.szDevice);
|
||||
int sizeNeeded = WideCharToMultiByte(CP_UTF8, 0, deviceName.c_str(), static_cast<int>(deviceName.length()), nullptr, 0, nullptr, nullptr);
|
||||
std::string result(sizeNeeded, 0);
|
||||
WideCharToMultiByte(CP_UTF8, 0, deviceName.c_str(), static_cast<int>(deviceName.length()), &result[0], sizeNeeded, nullptr, nullptr);
|
||||
return result;
|
||||
}
|
||||
|
||||
// Calculate scale factor from DPI
|
||||
constexpr double GetScaleFactor(UINT dpi)
|
||||
{
|
||||
return static_cast<double>(dpi) / 96.0;
|
||||
}
|
||||
|
||||
// Low-level mouse hook callback
|
||||
LRESULT CALLBACK LowLevelMouseProc(int nCode, WPARAM wParam, LPARAM lParam)
|
||||
{
|
||||
if (nCode == HC_ACTION && wParam == WM_MOUSEMOVE)
|
||||
{
|
||||
MSLLHOOKSTRUCT* mouseStruct = reinterpret_cast<MSLLHOOKSTRUCT*>(lParam);
|
||||
POINT pt = mouseStruct->pt;
|
||||
|
||||
// Only log if position changed
|
||||
if (pt.x != g_lastPosition.x || pt.y != g_lastPosition.y)
|
||||
{
|
||||
g_lastPosition = pt;
|
||||
|
||||
UINT dpiX = 96, dpiY = 96;
|
||||
std::string monitorName = GetMonitorInfo(pt, &dpiX, &dpiY);
|
||||
double scale = GetScaleFactor(dpiX);
|
||||
|
||||
if (g_outputFile.is_open())
|
||||
{
|
||||
g_outputFile << monitorName
|
||||
<< "," << pt.x
|
||||
<< "," << pt.y
|
||||
<< "," << dpiX
|
||||
<< "," << static_cast<int>(scale * 100) << "%"
|
||||
<< "\n";
|
||||
g_outputFile.flush();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return CallNextHookEx(g_mouseHook, nCode, wParam, lParam);
|
||||
}
|
||||
|
||||
// Console control handler for clean shutdown
|
||||
BOOL WINAPI ConsoleHandler(DWORD ctrlType)
|
||||
{
|
||||
if (ctrlType == CTRL_C_EVENT || ctrlType == CTRL_CLOSE_EVENT)
|
||||
{
|
||||
std::cout << "\nShutting down..." << std::endl;
|
||||
|
||||
if (g_mouseHook)
|
||||
{
|
||||
UnhookWindowsHookEx(g_mouseHook);
|
||||
g_mouseHook = nullptr;
|
||||
}
|
||||
|
||||
if (g_outputFile.is_open())
|
||||
{
|
||||
g_outputFile.close();
|
||||
}
|
||||
|
||||
// Post quit message to the main thread to exit the message loop
|
||||
PostThreadMessage(g_mainThreadId, WM_QUIT, 0, 0);
|
||||
|
||||
return TRUE;
|
||||
}
|
||||
return FALSE;
|
||||
}
|
||||
|
||||
int main(int argc, char* argv[])
|
||||
{
|
||||
// Set DPI awareness FIRST, before any other Windows API calls
|
||||
SetProcessDpiAwarenessContext(DPI_AWARENESS_CONTEXT_PER_MONITOR_AWARE_V2);
|
||||
|
||||
// Store main thread ID for clean shutdown
|
||||
g_mainThreadId = GetCurrentThreadId();
|
||||
|
||||
// Check command line arguments
|
||||
if (argc != 2)
|
||||
{
|
||||
std::cerr << "Usage: CursorLog.exe <output_path_and_filename>" << std::endl;
|
||||
return 1;
|
||||
}
|
||||
|
||||
std::filesystem::path outputPath(argv[1]);
|
||||
std::filesystem::path parentPath = outputPath.parent_path();
|
||||
|
||||
// Validate the directory exists
|
||||
if (!parentPath.empty() && !std::filesystem::exists(parentPath))
|
||||
{
|
||||
std::cerr << "Error: The directory '" << parentPath.string() << "' does not exist." << std::endl;
|
||||
return 1;
|
||||
}
|
||||
|
||||
// Check if file exists and prompt for overwrite
|
||||
if (std::filesystem::exists(outputPath))
|
||||
{
|
||||
std::cout << "File '" << outputPath.string() << "' already exists. Overwrite? (y/n): ";
|
||||
char response;
|
||||
std::cin >> response;
|
||||
|
||||
if (response != 'y' && response != 'Y')
|
||||
{
|
||||
std::cout << "Operation cancelled." << std::endl;
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
// Open output file
|
||||
g_outputFile.open(outputPath, std::ios::out | std::ios::trunc);
|
||||
if (!g_outputFile.is_open())
|
||||
{
|
||||
std::cerr << "Error: Unable to create or open file '" << outputPath.string() << "'." << std::endl;
|
||||
return 1;
|
||||
}
|
||||
|
||||
std::cout << "Logging mouse position to: " << outputPath.string() << std::endl;
|
||||
std::cout << "Press Ctrl+C to stop..." << std::endl;
|
||||
|
||||
// Set up console control handler
|
||||
SetConsoleCtrlHandler(ConsoleHandler, TRUE);
|
||||
|
||||
// Install low-level mouse hook
|
||||
g_mouseHook = SetWindowsHookEx(WH_MOUSE_LL, LowLevelMouseProc, nullptr, 0);
|
||||
if (!g_mouseHook)
|
||||
{
|
||||
std::cerr << "Error: Failed to install mouse hook. Error code: " << GetLastError() << std::endl;
|
||||
g_outputFile.close();
|
||||
return 1;
|
||||
}
|
||||
|
||||
// Message loop - required for low-level hooks
|
||||
MSG msg;
|
||||
while (GetMessage(&msg, nullptr, 0, 0))
|
||||
{
|
||||
TranslateMessage(&msg);
|
||||
DispatchMessage(&msg);
|
||||
}
|
||||
|
||||
// Cleanup
|
||||
if (g_mouseHook)
|
||||
{
|
||||
UnhookWindowsHookEx(g_mouseHook);
|
||||
}
|
||||
|
||||
if (g_outputFile.is_open())
|
||||
{
|
||||
g_outputFile.close();
|
||||
}
|
||||
|
||||
return 0;
|
||||
}
|
||||
@@ -0,0 +1,135 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<Project DefaultTargets="Build" xmlns="http://schemas.microsoft.com/developer/msbuild/2003">
|
||||
<ItemGroup Label="ProjectConfigurations">
|
||||
<ProjectConfiguration Include="Debug|Win32">
|
||||
<Configuration>Debug</Configuration>
|
||||
<Platform>Win32</Platform>
|
||||
</ProjectConfiguration>
|
||||
<ProjectConfiguration Include="Release|Win32">
|
||||
<Configuration>Release</Configuration>
|
||||
<Platform>Win32</Platform>
|
||||
</ProjectConfiguration>
|
||||
<ProjectConfiguration Include="Debug|x64">
|
||||
<Configuration>Debug</Configuration>
|
||||
<Platform>x64</Platform>
|
||||
</ProjectConfiguration>
|
||||
<ProjectConfiguration Include="Release|x64">
|
||||
<Configuration>Release</Configuration>
|
||||
<Platform>x64</Platform>
|
||||
</ProjectConfiguration>
|
||||
</ItemGroup>
|
||||
<PropertyGroup Label="Globals">
|
||||
<VCProjectVersion>18.0</VCProjectVersion>
|
||||
<Keyword>Win32Proj</Keyword>
|
||||
<ProjectGuid>{646f6684-9f11-42cd-8b35-b2954404f985}</ProjectGuid>
|
||||
<RootNamespace>CursorLog</RootNamespace>
|
||||
<WindowsTargetPlatformVersion>10.0</WindowsTargetPlatformVersion>
|
||||
</PropertyGroup>
|
||||
<Import Project="$(VCTargetsPath)\Microsoft.Cpp.Default.props" />
|
||||
<PropertyGroup Condition="'$(Configuration)|$(Platform)'=='Debug|Win32'" Label="Configuration">
|
||||
<ConfigurationType>Application</ConfigurationType>
|
||||
<UseDebugLibraries>true</UseDebugLibraries>
|
||||
<PlatformToolset>v145</PlatformToolset>
|
||||
<CharacterSet>Unicode</CharacterSet>
|
||||
</PropertyGroup>
|
||||
<PropertyGroup Condition="'$(Configuration)|$(Platform)'=='Release|Win32'" Label="Configuration">
|
||||
<ConfigurationType>Application</ConfigurationType>
|
||||
<UseDebugLibraries>false</UseDebugLibraries>
|
||||
<PlatformToolset>v145</PlatformToolset>
|
||||
<WholeProgramOptimization>true</WholeProgramOptimization>
|
||||
<CharacterSet>Unicode</CharacterSet>
|
||||
</PropertyGroup>
|
||||
<PropertyGroup Condition="'$(Configuration)|$(Platform)'=='Debug|x64'" Label="Configuration">
|
||||
<ConfigurationType>Application</ConfigurationType>
|
||||
<UseDebugLibraries>true</UseDebugLibraries>
|
||||
<PlatformToolset>v145</PlatformToolset>
|
||||
<CharacterSet>Unicode</CharacterSet>
|
||||
</PropertyGroup>
|
||||
<PropertyGroup Condition="'$(Configuration)|$(Platform)'=='Release|x64'" Label="Configuration">
|
||||
<ConfigurationType>Application</ConfigurationType>
|
||||
<UseDebugLibraries>false</UseDebugLibraries>
|
||||
<PlatformToolset>v145</PlatformToolset>
|
||||
<WholeProgramOptimization>true</WholeProgramOptimization>
|
||||
<CharacterSet>Unicode</CharacterSet>
|
||||
</PropertyGroup>
|
||||
<Import Project="$(VCTargetsPath)\Microsoft.Cpp.props" />
|
||||
<ImportGroup Label="ExtensionSettings">
|
||||
</ImportGroup>
|
||||
<ImportGroup Label="Shared">
|
||||
</ImportGroup>
|
||||
<ImportGroup Label="PropertySheets" Condition="'$(Configuration)|$(Platform)'=='Debug|Win32'">
|
||||
<Import Project="$(UserRootDir)\Microsoft.Cpp.$(Platform).user.props" Condition="exists('$(UserRootDir)\Microsoft.Cpp.$(Platform).user.props')" Label="LocalAppDataPlatform" />
|
||||
</ImportGroup>
|
||||
<ImportGroup Label="PropertySheets" Condition="'$(Configuration)|$(Platform)'=='Release|Win32'">
|
||||
<Import Project="$(UserRootDir)\Microsoft.Cpp.$(Platform).user.props" Condition="exists('$(UserRootDir)\Microsoft.Cpp.$(Platform).user.props')" Label="LocalAppDataPlatform" />
|
||||
</ImportGroup>
|
||||
<ImportGroup Label="PropertySheets" Condition="'$(Configuration)|$(Platform)'=='Debug|x64'">
|
||||
<Import Project="$(UserRootDir)\Microsoft.Cpp.$(Platform).user.props" Condition="exists('$(UserRootDir)\Microsoft.Cpp.$(Platform).user.props')" Label="LocalAppDataPlatform" />
|
||||
</ImportGroup>
|
||||
<ImportGroup Label="PropertySheets" Condition="'$(Configuration)|$(Platform)'=='Release|x64'">
|
||||
<Import Project="$(UserRootDir)\Microsoft.Cpp.$(Platform).user.props" Condition="exists('$(UserRootDir)\Microsoft.Cpp.$(Platform).user.props')" Label="LocalAppDataPlatform" />
|
||||
</ImportGroup>
|
||||
<PropertyGroup Label="UserMacros" />
|
||||
<ItemDefinitionGroup Condition="'$(Configuration)|$(Platform)'=='Debug|Win32'">
|
||||
<ClCompile>
|
||||
<WarningLevel>Level3</WarningLevel>
|
||||
<SDLCheck>true</SDLCheck>
|
||||
<PreprocessorDefinitions>WIN32;_DEBUG;_CONSOLE;%(PreprocessorDefinitions)</PreprocessorDefinitions>
|
||||
<ConformanceMode>true</ConformanceMode>
|
||||
<LanguageStandard>stdcpp20</LanguageStandard><PrecompiledHeader>NotUsing</PrecompiledHeader>
|
||||
</ClCompile>
|
||||
<Link>
|
||||
<SubSystem>Console</SubSystem>
|
||||
<GenerateDebugInformation>true</GenerateDebugInformation>
|
||||
</Link>
|
||||
</ItemDefinitionGroup>
|
||||
<ItemDefinitionGroup Condition="'$(Configuration)|$(Platform)'=='Release|Win32'">
|
||||
<ClCompile>
|
||||
<WarningLevel>Level3</WarningLevel>
|
||||
<FunctionLevelLinking>true</FunctionLevelLinking>
|
||||
<IntrinsicFunctions>true</IntrinsicFunctions>
|
||||
<SDLCheck>true</SDLCheck>
|
||||
<PreprocessorDefinitions>WIN32;NDEBUG;_CONSOLE;%(PreprocessorDefinitions)</PreprocessorDefinitions>
|
||||
<ConformanceMode>true</ConformanceMode>
|
||||
<LanguageStandard>stdcpp20</LanguageStandard><PrecompiledHeader>NotUsing</PrecompiledHeader>
|
||||
</ClCompile>
|
||||
<Link>
|
||||
<SubSystem>Console</SubSystem>
|
||||
<GenerateDebugInformation>true</GenerateDebugInformation>
|
||||
</Link>
|
||||
</ItemDefinitionGroup>
|
||||
<ItemDefinitionGroup Condition="'$(Configuration)|$(Platform)'=='Debug|x64'">
|
||||
<ClCompile>
|
||||
<WarningLevel>Level3</WarningLevel>
|
||||
<SDLCheck>true</SDLCheck>
|
||||
<PreprocessorDefinitions>_DEBUG;_CONSOLE;%(PreprocessorDefinitions)</PreprocessorDefinitions>
|
||||
<ConformanceMode>true</ConformanceMode>
|
||||
<LanguageStandard>stdcpp20</LanguageStandard><PrecompiledHeader>NotUsing</PrecompiledHeader>
|
||||
</ClCompile>
|
||||
<Link>
|
||||
<SubSystem>Console</SubSystem>
|
||||
<GenerateDebugInformation>true</GenerateDebugInformation>
|
||||
</Link>
|
||||
</ItemDefinitionGroup>
|
||||
<ItemDefinitionGroup Condition="'$(Configuration)|$(Platform)'=='Release|x64'">
|
||||
<ClCompile>
|
||||
<WarningLevel>Level3</WarningLevel>
|
||||
<FunctionLevelLinking>true</FunctionLevelLinking>
|
||||
<IntrinsicFunctions>true</IntrinsicFunctions>
|
||||
<SDLCheck>true</SDLCheck>
|
||||
<PreprocessorDefinitions>NDEBUG;_CONSOLE;%(PreprocessorDefinitions)</PreprocessorDefinitions>
|
||||
<ConformanceMode>true</ConformanceMode>
|
||||
<LanguageStandard>stdcpp20</LanguageStandard><PrecompiledHeader>NotUsing</PrecompiledHeader>
|
||||
</ClCompile>
|
||||
<Link>
|
||||
<SubSystem>Console</SubSystem>
|
||||
<GenerateDebugInformation>true</GenerateDebugInformation>
|
||||
</Link>
|
||||
</ItemDefinitionGroup>
|
||||
<ItemGroup>
|
||||
<ClCompile Include="CursorLog.cpp" />
|
||||
</ItemGroup>
|
||||
<Import Project="$(VCTargetsPath)\Microsoft.Cpp.targets" />
|
||||
<ImportGroup Label="ExtensionTargets">
|
||||
</ImportGroup>
|
||||
</Project>
|
||||
@@ -0,0 +1,22 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<Project ToolsVersion="4.0" xmlns="http://schemas.microsoft.com/developer/msbuild/2003">
|
||||
<ItemGroup>
|
||||
<Filter Include="Source Files">
|
||||
<UniqueIdentifier>{4FC737F1-C7A5-4376-A066-2A32D752A2FF}</UniqueIdentifier>
|
||||
<Extensions>cpp;c;cc;cxx;c++;cppm;ixx;def;odl;idl;hpj;bat;asm;asmx</Extensions>
|
||||
</Filter>
|
||||
<Filter Include="Header Files">
|
||||
<UniqueIdentifier>{93995380-89BD-4b04-88EB-625FBE52EBFB}</UniqueIdentifier>
|
||||
<Extensions>h;hh;hpp;hxx;h++;hm;inl;inc;ipp;xsd</Extensions>
|
||||
</Filter>
|
||||
<Filter Include="Resource Files">
|
||||
<UniqueIdentifier>{67DA6AB6-F800-4c08-8B7A-83BB121AAD01}</UniqueIdentifier>
|
||||
<Extensions>rc;ico;cur;bmp;dlg;rc2;rct;bin;rgs;gif;jpg;jpeg;jpe;resx;tiff;tif;png;wav;mfcribbon-ms</Extensions>
|
||||
</Filter>
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<ClCompile Include="CursorLog.cpp">
|
||||
<Filter>Source Files</Filter>
|
||||
</ClCompile>
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
@@ -0,0 +1,287 @@
|
||||
# CursorWrap Simulator
|
||||
|
||||
A Python visualization tool that displays monitor layouts and shows which edges will wrap to other monitors using the exact same logic as the PowerToys CursorWrap implementation.
|
||||
|
||||
## Purpose
|
||||
|
||||
This tool helps you:
|
||||
- Visualize your multi-monitor setup
|
||||
- Identify which screen edges are "outer edges" (edges that don't connect to another monitor)
|
||||
- See where cursor wrapping will occur when you move the cursor to an outer edge
|
||||
- **Find problem areas** where edges have NO wrap destination (shown in red)
|
||||
|
||||
## Requirements
|
||||
|
||||
- Python 3.6+
|
||||
- Tkinter (included with standard Python on Windows)
|
||||
|
||||
## Usage
|
||||
|
||||
### Command Line
|
||||
|
||||
```bash
|
||||
python wrap_simulator.py <path_to_monitor_layout.json>
|
||||
```
|
||||
|
||||
### Without Arguments
|
||||
|
||||
```bash
|
||||
python wrap_simulator.py
|
||||
```
|
||||
|
||||
This opens the application with no layout loaded. Use the "Load JSON" button to select a file.
|
||||
|
||||
## JSON File Format
|
||||
|
||||
The monitor layout JSON file should have this structure:
|
||||
|
||||
```json
|
||||
{
|
||||
"captured_at": "2026-02-16T08:50:34+00:00",
|
||||
"computer_name": "MY-PC",
|
||||
"user_name": "User",
|
||||
"monitor_count": 3,
|
||||
"monitors": [
|
||||
{
|
||||
"left": 0,
|
||||
"top": 0,
|
||||
"right": 2560,
|
||||
"bottom": 1440,
|
||||
"width": 2560,
|
||||
"height": 1440,
|
||||
"dpi": 96,
|
||||
"scaling_percent": 100.0,
|
||||
"primary": true,
|
||||
"device_name": "DISPLAY1"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
## Understanding the Visualization
|
||||
|
||||
### Monitor Display
|
||||
- **Gray rectangles**: Individual monitors
|
||||
- **Orange border**: Primary monitor
|
||||
- **Labels**: Show monitor index, device name, and resolution
|
||||
|
||||
### Edge Bars (Outside Monitor Boundaries)
|
||||
|
||||
Colored bars are drawn outside each **outer edge** (edges not adjacent to another monitor):
|
||||
|
||||
| Color | Meaning |
|
||||
|-------|---------|
|
||||
| **Yellow** | Edge segment has a wrap destination ✓ |
|
||||
| **Red with stripes** | NO wrap destination - Problem area! ⚠️ |
|
||||
|
||||
The bar outline color indicates the edge type:
|
||||
- Red = Left edge
|
||||
- Teal = Right edge
|
||||
- Blue = Top edge
|
||||
- Green = Bottom edge
|
||||
|
||||
### Interactive Features
|
||||
|
||||
1. **Hover over edge segments**:
|
||||
- See wrap destination info in the status bar
|
||||
- Green arrow shows where the cursor would wrap to
|
||||
- Green dashed rectangle highlights the destination
|
||||
|
||||
2. **Click on edge segments**:
|
||||
- Detailed information appears in the info panel
|
||||
- Shows full problem analysis with reason codes
|
||||
- Explains why wrapping does/doesn't occur
|
||||
- Provides suggestions for fixing problems
|
||||
|
||||
|
||||
3. **Wrap Mode Selection**:
|
||||
- **Both**: Wrap in all directions (default)
|
||||
- **Vertical Only**: Only top/bottom edges wrap
|
||||
- **Horizontal Only**: Only left/right edges wrap
|
||||
|
||||
4. **Export Analysis**:
|
||||
- Click "Export Analysis" to save detailed diagnostic data
|
||||
- Exports to JSON format for use in algorithm development
|
||||
- Includes all problem segments with reason codes and suggestions
|
||||
|
||||
5. **Edge Test Simulation** (NEW):
|
||||
- Click "🧪 Test Edges" to start automated edge testing
|
||||
- Visually animates cursor movement along ALL outer edges
|
||||
- Shows wrap destination for each test point with colored lines:
|
||||
- **Red circle**: Source position on outer edge
|
||||
- **Green circle**: Wrap destination
|
||||
- **Green dashed line**: Connection showing wrap path
|
||||
- **Red X**: No wrap destination (problem area)
|
||||
- Use "New Algorithm" checkbox to toggle between:
|
||||
- **NEW**: Projection-based algorithm (eliminates dead zones)
|
||||
- **OLD**: Direct overlap only (may have dead zones)
|
||||
- Results summary shows per-edge coverage statistics
|
||||
|
||||
## Problem Analysis
|
||||
|
||||
When a segment has no wrap destination, the tool provides detailed analysis:
|
||||
|
||||
### Problem Reason Codes
|
||||
|
||||
| Code | Description |
|
||||
|------|-------------|
|
||||
| `WRAP_MODE_DISABLED` | Edge type disabled by current wrap mode setting |
|
||||
| `NO_OPPOSITE_OUTER_EDGES` | No outer edges of the opposite type exist at all |
|
||||
| `NO_OVERLAPPING_RANGE` | Opposite edges exist but don't cover this coordinate range |
|
||||
| `SINGLE_MONITOR` | Only one monitor - nowhere to wrap to |
|
||||
|
||||
### Diagnostic Details
|
||||
|
||||
For `NO_OVERLAPPING_RANGE` problems, the tool shows:
|
||||
- Distance to the nearest valid wrap destination
|
||||
- List of available opposite edges sorted by distance
|
||||
- Whether the gap is above/below or left/right of the segment
|
||||
- Suggested fixes (extend monitors or adjust positions)
|
||||
|
||||
## Sample Files
|
||||
|
||||
Included sample layouts:
|
||||
|
||||
- `sample_layout.json` - 3 monitors in a row with one offset
|
||||
- `sample_staggered.json` - 3 monitors with staggered vertical positions (shows problem areas)
|
||||
- `sample_with_gap.json` - 2 monitors with a gap between them
|
||||
|
||||
## Exported Analysis Format
|
||||
|
||||
The "Export Analysis" button generates a JSON file with this structure:
|
||||
|
||||
```json
|
||||
{
|
||||
"export_timestamp": "2026-02-16T08:50:34+00:00",
|
||||
"wrap_mode": "BOTH",
|
||||
"monitor_count": 3,
|
||||
"monitors": [...],
|
||||
"outer_edges": [...],
|
||||
"problem_segments": [
|
||||
{
|
||||
"source": {
|
||||
"monitor_index": 0,
|
||||
"monitor_name": "DISPLAY1",
|
||||
"edge_type": "TOP",
|
||||
"edge_position": 200,
|
||||
"segment_range": {"start": 0, "end": 200},
|
||||
"segment_length_px": 200
|
||||
},
|
||||
"analysis": {
|
||||
"reason_code": "NO_OVERLAPPING_RANGE",
|
||||
"description": "No BOTTOM outer edge overlaps...",
|
||||
"suggestion": "To fix: Either extend...",
|
||||
"details": {
|
||||
"gap_to_nearest": 200,
|
||||
"available_opposite_edges": [...]
|
||||
}
|
||||
}
|
||||
}
|
||||
],
|
||||
"summary": {
|
||||
"total_outer_edges": 8,
|
||||
"total_problem_segments": 4,
|
||||
"total_problem_pixels": 800,
|
||||
"problems_by_reason": {"NO_OVERLAPPING_RANGE": 4},
|
||||
"has_problems": true
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## How CursorWrap Logic Works
|
||||
|
||||
### Original Algorithm (v1)
|
||||
|
||||
1. **Outer Edge Detection**: An edge is "outer" if no other monitor's opposite edge is within 50 pixels AND has sufficient vertical/horizontal overlap
|
||||
|
||||
2. **Wrap Destination**: When cursor reaches an outer edge:
|
||||
- Find the opposite type outer edge (Left→Right, Top→Bottom, etc.)
|
||||
- The destination must overlap with the cursor's perpendicular position
|
||||
- Cursor warps to the furthest matching outer edge
|
||||
|
||||
3. **Problem Areas**: If no opposite outer edge overlaps with a portion of an outer edge, that segment has no wrap destination - the cursor will simply stop at that edge.
|
||||
|
||||
### Enhanced Algorithm (v2) - With Projection
|
||||
|
||||
The enhanced algorithm eliminates dead zones by projecting cursor positions to valid destinations:
|
||||
|
||||
1. **Direct Overlap**: If an opposite outer edge directly overlaps the cursor's perpendicular coordinate, use it (same as v1)
|
||||
|
||||
2. **Nearest Edge Projection**: If no direct overlap exists:
|
||||
- Find the nearest opposite outer edge by coordinate distance
|
||||
- Calculate a projected position using offset-from-boundary approach
|
||||
- The projection preserves relative position similar to how Windows handles monitor transitions
|
||||
|
||||
3. **No Dead Zones**: Every point on every outer edge will have a valid wrap destination
|
||||
|
||||
### Testing the Algorithm
|
||||
|
||||
Use the included test script to validate both algorithms:
|
||||
|
||||
```bash
|
||||
python test_new_algorithm.py [layout_file.json]
|
||||
```
|
||||
|
||||
This compares the old algorithm (with dead zones) against the new algorithm (with projection) and reports coverage.
|
||||
|
||||
## Cursor Log Playback
|
||||
|
||||
The simulator can play back recorded cursor movement logs to visualize how the cursor moves across monitors.
|
||||
|
||||
### Loading a Cursor Log
|
||||
|
||||
1. Click "Load Log" to select a cursor movement log file
|
||||
2. Use the playback controls:
|
||||
- **▶ Play / ⏸ Pause**: Start or pause playback
|
||||
- **⏹ Stop**: Stop and reset to beginning
|
||||
- **⏮ Reset**: Reset to beginning without stopping
|
||||
- **Speed slider**: Adjust playback speed (10-500ms between frames)
|
||||
|
||||
### Log File Format
|
||||
|
||||
The cursor log file is CSV format with the following columns:
|
||||
|
||||
```
|
||||
display_name,x,y,dpi,scaling%
|
||||
```
|
||||
|
||||
Example:
|
||||
```csv
|
||||
\\.\DISPLAY1,1234,567,96,100%
|
||||
\\.\DISPLAY2,2560,720,144,150%
|
||||
\\.\DISPLAY3,-500,800,96,100%
|
||||
```
|
||||
|
||||
- **display_name**: Windows display name (e.g., `\\.\DISPLAY1`)
|
||||
- **x, y**: Screen coordinates
|
||||
- **dpi**: Display DPI
|
||||
- **scaling%**: Display scaling percentage (with or without % sign)
|
||||
|
||||
Lines starting with `#` are treated as comments and ignored.
|
||||
|
||||
### Playback Visualization
|
||||
|
||||
- **Green cursor**: Normal movement within a monitor
|
||||
- **Red cursor with burst effect**: Monitor transition detected
|
||||
- **Blue trail**: Recent cursor movement path (fades over time)
|
||||
- **Dashed red arrow**: Shows transition path between monitors
|
||||
|
||||
The playback automatically slows down when a monitor transition is detected, making it easier to observe wrap behavior.
|
||||
|
||||
### Sample Log File
|
||||
|
||||
A sample cursor log file `sample_cursor_log.csv` is included that demonstrates cursor movement across a three-monitor setup.
|
||||
|
||||
## Architecture
|
||||
|
||||
The Python implementation mirrors the C++ code structure:
|
||||
|
||||
- `MonitorTopology` class: Manages edge-based monitor layout
|
||||
- `MonitorEdge` dataclass: Represents a single edge of a monitor
|
||||
- `EdgeSegment` dataclass: A portion of an edge with wrap info
|
||||
- `CursorLogEntry` dataclass: A single cursor movement log entry
|
||||
- `WrapSimulatorApp`: Tkinter GUI application
|
||||
|
||||
## Integration with PowerToys
|
||||
|
||||
This tool is designed to validate and debug the CursorWrap feature. The JSON files can be generated by the debug build of CursorWrap or created manually for testing specific configurations.
|
||||
@@ -0,0 +1,110 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Test script to validate the new projection-based wrapping algorithm.
|
||||
"""
|
||||
|
||||
import json
|
||||
import sys
|
||||
from wrap_simulator import MonitorTopology, MonitorInfo, WrapMode
|
||||
|
||||
def test_layout(layout_file: str):
|
||||
"""Test a monitor layout with both old and new algorithms."""
|
||||
|
||||
# Load the layout
|
||||
with open(layout_file, 'r') as f:
|
||||
layout = json.load(f)
|
||||
|
||||
# Create monitor info objects
|
||||
monitors = []
|
||||
for i, m in enumerate(layout['monitors']):
|
||||
monitors.append(MonitorInfo(
|
||||
left=m['left'], top=m['top'], right=m['right'], bottom=m['bottom'],
|
||||
width=m['width'], height=m['height'], dpi=m.get('dpi', 96),
|
||||
scaling_percent=m.get('scaling_percent', 100), primary=m.get('primary', False),
|
||||
device_name=m.get('device_name', f'DISPLAY{i+1}'), monitor_id=i
|
||||
))
|
||||
|
||||
# Initialize topology
|
||||
topology = MonitorTopology()
|
||||
topology.initialize(monitors)
|
||||
|
||||
print(f"Layout: {layout_file}")
|
||||
print(f"Monitors: {len(monitors)}")
|
||||
print(f"Outer edges: {len(topology.outer_edges)}")
|
||||
|
||||
# Validate with OLD algorithm
|
||||
print("\n--- OLD Algorithm (may have dead zones) ---")
|
||||
old_problems = 0
|
||||
old_problem_details = []
|
||||
for edge in topology.outer_edges:
|
||||
segments = topology.get_edge_segments_with_wrap_info(edge, WrapMode.BOTH)
|
||||
for seg in segments:
|
||||
if not seg.has_wrap_destination:
|
||||
length = seg.end - seg.start
|
||||
old_problems += length
|
||||
detail = f"Mon {edge.monitor_index} {edge.edge_type.name} [{seg.start}-{seg.end}] ({length}px)"
|
||||
old_problem_details.append(detail)
|
||||
print(f" PROBLEM: {detail}")
|
||||
print(f"Total problematic pixels: {old_problems}")
|
||||
|
||||
# Validate with NEW algorithm
|
||||
print("\n--- NEW Algorithm (with projection) ---")
|
||||
result = topology.validate_all_edges_have_destinations(WrapMode.BOTH)
|
||||
print(f"Total edge length: {result['total_edge_length']}px")
|
||||
print(f"Covered: {result['covered_length']}px ({result['coverage_percent']:.1f}%)")
|
||||
print(f"Uncovered: {result['uncovered_length']}px")
|
||||
print(f"Fully covered: {result['is_fully_covered']}")
|
||||
|
||||
if result['problem_areas']:
|
||||
for prob in result['problem_areas']:
|
||||
print(f" PROBLEM: {prob}")
|
||||
|
||||
# Summary
|
||||
print("\n--- COMPARISON ---")
|
||||
print(f"Old algorithm dead zones: {old_problems}px")
|
||||
print(f"New algorithm dead zones: {result['uncovered_length']}px")
|
||||
if old_problems > 0 and result['uncovered_length'] == 0:
|
||||
print("SUCCESS: New algorithm eliminates all dead zones!")
|
||||
elif result['uncovered_length'] > 0:
|
||||
print("WARNING: New algorithm still has dead zones")
|
||||
else:
|
||||
print("Both algorithms have no dead zones for this layout")
|
||||
|
||||
return result['is_fully_covered']
|
||||
|
||||
|
||||
def main():
|
||||
layout_files = [
|
||||
'mikehall_monitor_layout.json',
|
||||
'sample_layout.json',
|
||||
'sample_staggered.json',
|
||||
]
|
||||
|
||||
# Allow specifying layout on command line
|
||||
if len(sys.argv) > 1:
|
||||
layout_files = sys.argv[1:]
|
||||
|
||||
all_passed = True
|
||||
for layout_file in layout_files:
|
||||
try:
|
||||
print(f"\n{'='*60}")
|
||||
passed = test_layout(layout_file)
|
||||
if not passed:
|
||||
all_passed = False
|
||||
except FileNotFoundError:
|
||||
print(f"File not found: {layout_file}")
|
||||
except Exception as e:
|
||||
print(f"Error testing {layout_file}: {e}")
|
||||
all_passed = False
|
||||
|
||||
print(f"\n{'='*60}")
|
||||
if all_passed:
|
||||
print("ALL TESTS PASSED")
|
||||
else:
|
||||
print("SOME TESTS FAILED")
|
||||
|
||||
return 0 if all_passed else 1
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
File diff suppressed because it is too large
Load Diff
@@ -4,6 +4,7 @@
|
||||
|
||||
#include "pch.h"
|
||||
#include "MonitorTopology.h"
|
||||
#include "CursorWrapCore.h" // For CursorDirection struct
|
||||
#include "../../../common/logger/logger.h"
|
||||
#include <algorithm>
|
||||
#include <cmath>
|
||||
@@ -163,10 +164,80 @@ bool MonitorTopology::EdgesAreAdjacent(const MonitorEdge& edge1, const MonitorEd
|
||||
int overlapStart = max(edge1.start, edge2.start);
|
||||
int overlapEnd = min(edge1.end, edge2.end);
|
||||
|
||||
|
||||
return overlapEnd > overlapStart + tolerance;
|
||||
}
|
||||
|
||||
bool MonitorTopology::IsOnOuterEdge(HMONITOR monitor, const POINT& cursorPos, EdgeType& outEdgeType, WrapMode wrapMode) const
|
||||
EdgeType MonitorTopology::PrioritizeEdgeByDirection(const std::vector<EdgeType>& candidates,
|
||||
const CursorDirection* direction) const
|
||||
{
|
||||
if (candidates.empty())
|
||||
{
|
||||
return EdgeType::Left; // Should not happen, but return a default
|
||||
}
|
||||
|
||||
if (candidates.size() == 1 || direction == nullptr)
|
||||
{
|
||||
return candidates[0];
|
||||
}
|
||||
|
||||
// Prioritize based on movement direction
|
||||
// If moving primarily horizontally, prefer horizontal edges (Left/Right)
|
||||
// If moving primarily vertically, prefer vertical edges (Top/Bottom)
|
||||
|
||||
if (direction->IsPrimarilyHorizontal())
|
||||
{
|
||||
// Prefer Left if moving left, Right if moving right
|
||||
if (direction->IsMovingLeft())
|
||||
{
|
||||
for (EdgeType edge : candidates)
|
||||
{
|
||||
if (edge == EdgeType::Left) return edge;
|
||||
}
|
||||
}
|
||||
else if (direction->IsMovingRight())
|
||||
{
|
||||
for (EdgeType edge : candidates)
|
||||
{
|
||||
if (edge == EdgeType::Right) return edge;
|
||||
}
|
||||
}
|
||||
// Fall back to any horizontal edge
|
||||
for (EdgeType edge : candidates)
|
||||
{
|
||||
if (edge == EdgeType::Left || edge == EdgeType::Right) return edge;
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
// Prefer Top if moving up, Bottom if moving down
|
||||
if (direction->IsMovingUp())
|
||||
{
|
||||
for (EdgeType edge : candidates)
|
||||
{
|
||||
if (edge == EdgeType::Top) return edge;
|
||||
}
|
||||
}
|
||||
else if (direction->IsMovingDown())
|
||||
{
|
||||
for (EdgeType edge : candidates)
|
||||
{
|
||||
if (edge == EdgeType::Bottom) return edge;
|
||||
}
|
||||
}
|
||||
// Fall back to any vertical edge
|
||||
for (EdgeType edge : candidates)
|
||||
{
|
||||
if (edge == EdgeType::Top || edge == EdgeType::Bottom) return edge;
|
||||
}
|
||||
}
|
||||
|
||||
// Default to first candidate
|
||||
return candidates[0];
|
||||
}
|
||||
|
||||
bool MonitorTopology::IsOnOuterEdge(HMONITOR monitor, const POINT& cursorPos, EdgeType& outEdgeType,
|
||||
WrapMode wrapMode, const CursorDirection* direction) const
|
||||
{
|
||||
RECT monitorRect;
|
||||
if (!GetMonitorRect(monitor, monitorRect))
|
||||
@@ -248,13 +319,40 @@ bool MonitorTopology::IsOnOuterEdge(HMONITOR monitor, const POINT& cursorPos, Ed
|
||||
return false;
|
||||
}
|
||||
|
||||
// Try each candidate edge and return first with valid wrap destination
|
||||
// Prioritize candidates by movement direction at corners
|
||||
EdgeType prioritizedEdge = PrioritizeEdgeByDirection(candidateEdges, direction);
|
||||
|
||||
// Get the source edge info
|
||||
auto sourceIt = m_edgeMap.find({monitorIndex, prioritizedEdge});
|
||||
if (sourceIt == m_edgeMap.end())
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
// Use the new FindNearestOppositeEdge which handles non-overlapping regions
|
||||
int cursorCoord = (prioritizedEdge == EdgeType::Left || prioritizedEdge == EdgeType::Right)
|
||||
? cursorPos.y : cursorPos.x;
|
||||
OppositeEdgeResult result = FindNearestOppositeEdge(prioritizedEdge, cursorCoord, sourceIt->second);
|
||||
|
||||
if (result.found)
|
||||
{
|
||||
outEdgeType = prioritizedEdge;
|
||||
return true;
|
||||
}
|
||||
|
||||
// If prioritized edge didn't work, try other candidates
|
||||
for (EdgeType candidate : candidateEdges)
|
||||
{
|
||||
MonitorEdge oppositeEdge = FindOppositeOuterEdge(candidate,
|
||||
(candidate == EdgeType::Left || candidate == EdgeType::Right) ? cursorPos.y : cursorPos.x);
|
||||
|
||||
if (oppositeEdge.monitorIndex >= 0)
|
||||
if (candidate == prioritizedEdge) continue;
|
||||
|
||||
auto it = m_edgeMap.find({monitorIndex, candidate});
|
||||
if (it == m_edgeMap.end()) continue;
|
||||
|
||||
int coord = (candidate == EdgeType::Left || candidate == EdgeType::Right)
|
||||
? cursorPos.y : cursorPos.x;
|
||||
OppositeEdgeResult altResult = FindNearestOppositeEdge(candidate, coord, it->second);
|
||||
|
||||
if (altResult.found)
|
||||
{
|
||||
outEdgeType = candidate;
|
||||
return true;
|
||||
@@ -280,16 +378,14 @@ POINT MonitorTopology::GetWrapDestination(HMONITOR fromMonitor, const POINT& cur
|
||||
}
|
||||
|
||||
const MonitorEdge& fromEdge = it->second;
|
||||
|
||||
// Get cursor coordinate perpendicular to the edge
|
||||
int cursorCoord = (edgeType == EdgeType::Left || edgeType == EdgeType::Right) ? cursorPos.y : cursorPos.x;
|
||||
|
||||
// Calculate relative position on current edge (0.0 to 1.0)
|
||||
double relativePos = GetRelativePosition(fromEdge,
|
||||
(edgeType == EdgeType::Left || edgeType == EdgeType::Right) ? cursorPos.y : cursorPos.x);
|
||||
// Use the new FindNearestOppositeEdge which handles non-overlapping regions
|
||||
OppositeEdgeResult oppositeResult = FindNearestOppositeEdge(edgeType, cursorCoord, fromEdge);
|
||||
|
||||
// Find opposite outer edge
|
||||
MonitorEdge oppositeEdge = FindOppositeOuterEdge(edgeType,
|
||||
(edgeType == EdgeType::Left || edgeType == EdgeType::Right) ? cursorPos.y : cursorPos.x);
|
||||
|
||||
if (oppositeEdge.monitorIndex < 0)
|
||||
if (!oppositeResult.found)
|
||||
{
|
||||
// No opposite edge found, wrap within same monitor
|
||||
RECT monitorRect;
|
||||
@@ -321,15 +417,35 @@ POINT MonitorTopology::GetWrapDestination(HMONITOR fromMonitor, const POINT& cur
|
||||
|
||||
if (edgeType == EdgeType::Left || edgeType == EdgeType::Right)
|
||||
{
|
||||
// Horizontal edge -> vertical movement
|
||||
result.x = oppositeEdge.position;
|
||||
result.y = GetAbsolutePosition(oppositeEdge, relativePos);
|
||||
// Horizontal wrapping (Left<->Right edges)
|
||||
result.x = oppositeResult.edge.position;
|
||||
|
||||
if (oppositeResult.requiresProjection)
|
||||
{
|
||||
// Use the pre-calculated projected coordinate for non-overlapping regions
|
||||
result.y = oppositeResult.projectedCoordinate;
|
||||
}
|
||||
else
|
||||
{
|
||||
// Overlapping region - preserve Y coordinate
|
||||
result.y = cursorPos.y;
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
// Vertical edge -> horizontal movement
|
||||
result.y = oppositeEdge.position;
|
||||
result.x = GetAbsolutePosition(oppositeEdge, relativePos);
|
||||
// Vertical wrapping (Top<->Bottom edges)
|
||||
result.y = oppositeResult.edge.position;
|
||||
|
||||
if (oppositeResult.requiresProjection)
|
||||
{
|
||||
// Use the pre-calculated projected coordinate for non-overlapping regions
|
||||
result.x = oppositeResult.projectedCoordinate;
|
||||
}
|
||||
else
|
||||
{
|
||||
// Overlapping region - preserve X coordinate
|
||||
result.x = cursorPos.x;
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
@@ -387,6 +503,170 @@ MonitorEdge MonitorTopology::FindOppositeOuterEdge(EdgeType fromEdge, int relati
|
||||
return result;
|
||||
}
|
||||
|
||||
OppositeEdgeResult MonitorTopology::FindNearestOppositeEdge(EdgeType fromEdge, int cursorCoordinate,
|
||||
const MonitorEdge& sourceEdge) const
|
||||
{
|
||||
OppositeEdgeResult result;
|
||||
result.found = false;
|
||||
result.requiresProjection = false;
|
||||
result.projectedCoordinate = 0;
|
||||
result.edge.monitorIndex = -1;
|
||||
|
||||
EdgeType targetType;
|
||||
bool findMax; // true = find max position (furthest right/bottom), false = find min (furthest left/top)
|
||||
|
||||
switch (fromEdge)
|
||||
{
|
||||
case EdgeType::Left:
|
||||
targetType = EdgeType::Right;
|
||||
findMax = true;
|
||||
break;
|
||||
case EdgeType::Right:
|
||||
targetType = EdgeType::Left;
|
||||
findMax = false;
|
||||
break;
|
||||
case EdgeType::Top:
|
||||
targetType = EdgeType::Bottom;
|
||||
findMax = true;
|
||||
break;
|
||||
case EdgeType::Bottom:
|
||||
targetType = EdgeType::Top;
|
||||
findMax = false;
|
||||
break;
|
||||
default:
|
||||
return result; // Invalid edge type
|
||||
}
|
||||
|
||||
// First, try to find an edge that directly overlaps the cursor coordinate
|
||||
MonitorEdge directMatch = FindOppositeOuterEdge(fromEdge, cursorCoordinate);
|
||||
if (directMatch.monitorIndex >= 0)
|
||||
{
|
||||
result.found = true;
|
||||
result.requiresProjection = false;
|
||||
result.edge = directMatch;
|
||||
result.projectedCoordinate = cursorCoordinate; // Not used, but set for completeness
|
||||
return result;
|
||||
}
|
||||
|
||||
// No direct overlap - find the nearest opposite edge by coordinate distance
|
||||
// This handles the "dead zone" case where cursor is in a non-overlapping region
|
||||
|
||||
int bestDistance = INT_MAX;
|
||||
MonitorEdge bestEdge = { .monitorIndex = -1 };
|
||||
int bestProjectedCoord = 0;
|
||||
|
||||
for (const auto& edge : m_outerEdges)
|
||||
{
|
||||
if (edge.type != targetType)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
// Calculate distance from cursor coordinate to this edge's range
|
||||
int distance = 0;
|
||||
int projectedCoord = 0;
|
||||
|
||||
if (cursorCoordinate < edge.start)
|
||||
{
|
||||
// Cursor is before the edge's start - project to edge start with offset
|
||||
distance = edge.start - cursorCoordinate;
|
||||
projectedCoord = edge.start; // Clamp to edge start
|
||||
}
|
||||
else if (cursorCoordinate > edge.end)
|
||||
{
|
||||
// Cursor is after the edge's end - project to edge end with offset
|
||||
distance = cursorCoordinate - edge.end;
|
||||
projectedCoord = edge.end; // Clamp to edge end
|
||||
}
|
||||
else
|
||||
{
|
||||
// Cursor overlaps - this shouldn't happen since we checked direct match
|
||||
distance = 0;
|
||||
projectedCoord = cursorCoordinate;
|
||||
}
|
||||
|
||||
// Choose the best edge: prefer closer edges, and among equals prefer extreme position
|
||||
bool isBetter = false;
|
||||
if (distance < bestDistance)
|
||||
{
|
||||
isBetter = true;
|
||||
}
|
||||
else if (distance == bestDistance && bestEdge.monitorIndex >= 0)
|
||||
{
|
||||
// Same distance - prefer the extreme position (furthest in wrap direction)
|
||||
if ((findMax && edge.position > bestEdge.position) ||
|
||||
(!findMax && edge.position < bestEdge.position))
|
||||
{
|
||||
isBetter = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (isBetter)
|
||||
{
|
||||
bestDistance = distance;
|
||||
bestEdge = edge;
|
||||
bestProjectedCoord = projectedCoord;
|
||||
}
|
||||
}
|
||||
|
||||
if (bestEdge.monitorIndex >= 0)
|
||||
{
|
||||
result.found = true;
|
||||
result.requiresProjection = true;
|
||||
result.edge = bestEdge;
|
||||
|
||||
// Calculate projected position using offset-from-boundary approach
|
||||
result.projectedCoordinate = CalculateProjectedPosition(cursorCoordinate, sourceEdge, bestEdge);
|
||||
|
||||
Logger::trace(L"FindNearestOppositeEdge: Non-overlapping wrap from {} to Mon {} edge, cursor={}, projected={}",
|
||||
static_cast<int>(fromEdge), bestEdge.monitorIndex, cursorCoordinate, result.projectedCoordinate);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
int MonitorTopology::CalculateProjectedPosition(int cursorCoordinate, const MonitorEdge& sourceEdge,
|
||||
const MonitorEdge& targetEdge) const
|
||||
{
|
||||
// Windows behavior for non-overlapping regions:
|
||||
// When cursor is in a region that doesn't overlap with the target edge,
|
||||
// clamp to the nearest boundary of the target edge.
|
||||
// This matches observed Windows cursor transition behavior.
|
||||
|
||||
// Find the shared boundary region between source and target edges
|
||||
int sharedStart = max(sourceEdge.start, targetEdge.start);
|
||||
int sharedEnd = min(sourceEdge.end, targetEdge.end);
|
||||
|
||||
if (cursorCoordinate >= sharedStart && cursorCoordinate <= sharedEnd)
|
||||
{
|
||||
// Cursor is in shared region - preserve the coordinate exactly
|
||||
return cursorCoordinate;
|
||||
}
|
||||
|
||||
// For non-overlapping regions, clamp to the nearest boundary of the target edge
|
||||
// This matches Windows behavior where the cursor is projected to the closest
|
||||
// valid point on the destination edge
|
||||
int projectedCoord;
|
||||
|
||||
if (cursorCoordinate < sharedStart)
|
||||
{
|
||||
// Cursor is BEFORE the shared region (e.g., above shared area)
|
||||
// Clamp to the start of the target edge (with small offset to stay within bounds)
|
||||
projectedCoord = targetEdge.start + 1;
|
||||
}
|
||||
else
|
||||
{
|
||||
// Cursor is AFTER the shared region (e.g., below shared area)
|
||||
// Clamp to the end of the target edge (with small offset to stay within bounds)
|
||||
projectedCoord = targetEdge.end - 1;
|
||||
}
|
||||
|
||||
// Final bounds check
|
||||
projectedCoord = max(targetEdge.start, min(projectedCoord, targetEdge.end - 1));
|
||||
|
||||
return projectedCoord;
|
||||
}
|
||||
|
||||
double MonitorTopology::GetRelativePosition(const MonitorEdge& edge, int coordinate) const
|
||||
{
|
||||
if (edge.end == edge.start)
|
||||
|
||||
@@ -7,6 +7,9 @@
|
||||
#include <vector>
|
||||
#include <map>
|
||||
|
||||
// Forward declaration
|
||||
struct CursorDirection;
|
||||
|
||||
// Monitor information structure
|
||||
struct MonitorInfo
|
||||
{
|
||||
@@ -44,6 +47,15 @@ struct MonitorEdge
|
||||
bool isOuter; // True if no adjacent monitor touches this edge
|
||||
};
|
||||
|
||||
// Result of finding an opposite edge, including projection info for non-overlapping regions
|
||||
struct OppositeEdgeResult
|
||||
{
|
||||
MonitorEdge edge;
|
||||
bool found; // True if an opposite edge was found
|
||||
bool requiresProjection; // True if cursor position needs to be projected (non-overlapping region)
|
||||
int projectedCoordinate; // The calculated coordinate on the target edge
|
||||
};
|
||||
|
||||
// Monitor topology helper - manages edge-based monitor layout
|
||||
struct MonitorTopology
|
||||
{
|
||||
@@ -51,7 +63,9 @@ struct MonitorTopology
|
||||
|
||||
// Check if cursor is on an outer edge of the given monitor
|
||||
// wrapMode filters which edges are considered (Both, VerticalOnly, HorizontalOnly)
|
||||
bool IsOnOuterEdge(HMONITOR monitor, const POINT& cursorPos, EdgeType& outEdgeType, WrapMode wrapMode) const;
|
||||
// direction is used to prioritize edges at corners based on cursor movement
|
||||
bool IsOnOuterEdge(HMONITOR monitor, const POINT& cursorPos, EdgeType& outEdgeType,
|
||||
WrapMode wrapMode, const CursorDirection* direction = nullptr) const;
|
||||
|
||||
// Get the wrap destination point for a cursor on an outer edge
|
||||
POINT GetWrapDestination(HMONITOR fromMonitor, const POINT& cursorPos, EdgeType edgeType) const;
|
||||
@@ -95,12 +109,26 @@ private:
|
||||
// Check if two edges are adjacent (within tolerance)
|
||||
bool EdgesAreAdjacent(const MonitorEdge& edge1, const MonitorEdge& edge2, int tolerance = 50) const;
|
||||
|
||||
// Find the opposite outer edge for wrapping
|
||||
// Find the opposite outer edge for wrapping (original method - for overlapping regions)
|
||||
MonitorEdge FindOppositeOuterEdge(EdgeType fromEdge, int relativePosition) const;
|
||||
|
||||
// Find the nearest opposite outer edge, including projection for non-overlapping regions
|
||||
// This implements Windows-like behavior for cursor transitions
|
||||
OppositeEdgeResult FindNearestOppositeEdge(EdgeType fromEdge, int cursorCoordinate,
|
||||
const MonitorEdge& sourceEdge) const;
|
||||
|
||||
// Calculate projected position for cursor in non-overlapping region
|
||||
// Returns the coordinate on the destination edge using offset-from-boundary approach
|
||||
int CalculateProjectedPosition(int cursorCoordinate, const MonitorEdge& sourceEdge,
|
||||
const MonitorEdge& targetEdge) const;
|
||||
|
||||
// Calculate relative position along an edge (0.0 to 1.0)
|
||||
double GetRelativePosition(const MonitorEdge& edge, int coordinate) const;
|
||||
|
||||
// Convert relative position to absolute coordinate on target edge
|
||||
int GetAbsolutePosition(const MonitorEdge& edge, double relativePosition) const;
|
||||
|
||||
// Prioritize edge candidates based on cursor movement direction
|
||||
EdgeType PrioritizeEdgeByDirection(const std::vector<EdgeType>& candidates,
|
||||
const CursorDirection* direction) const;
|
||||
};
|
||||
|
||||
@@ -54,6 +54,7 @@ namespace
|
||||
const wchar_t JSON_KEY_AUTO_ACTIVATE[] = L"auto_activate";
|
||||
const wchar_t JSON_KEY_DISABLE_WRAP_DURING_DRAG[] = L"disable_wrap_during_drag";
|
||||
const wchar_t JSON_KEY_WRAP_MODE[] = L"wrap_mode";
|
||||
const wchar_t JSON_KEY_ACTIVATION_MODE[] = L"activation_mode";
|
||||
const wchar_t JSON_KEY_DISABLE_ON_SINGLE_MONITOR[] = L"disable_cursor_wrap_on_single_monitor";
|
||||
}
|
||||
|
||||
@@ -83,6 +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 (wraps only while held), 2=HoldingShift (wraps only while held)
|
||||
|
||||
// Mouse hook
|
||||
HHOOK m_mouseHook = nullptr;
|
||||
@@ -430,6 +432,21 @@ private:
|
||||
Logger::warn("Failed to initialize CursorWrap wrap mode from settings. Will use default value (0=Both)");
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
// Parse activation mode
|
||||
auto propertiesObject = settingsObject.GetNamedObject(JSON_KEY_PROPERTIES);
|
||||
if (propertiesObject.HasKey(JSON_KEY_ACTIVATION_MODE))
|
||||
{
|
||||
auto activationModeObject = propertiesObject.GetNamedObject(JSON_KEY_ACTIVATION_MODE);
|
||||
m_activationMode = static_cast<int>(activationModeObject.GetNamedNumber(JSON_KEY_VALUE));
|
||||
}
|
||||
}
|
||||
catch (...)
|
||||
{
|
||||
Logger::warn("Failed to initialize CursorWrap activation mode from settings. Will use default value (0=Always)");
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
// Parse disable on single monitor
|
||||
@@ -672,6 +689,26 @@ private:
|
||||
|
||||
if (g_cursorWrapInstance && g_cursorWrapInstance->m_hookActive)
|
||||
{
|
||||
// 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 shouldWrap = true;
|
||||
|
||||
if (activationMode == 1) // HoldingCtrl - wrap only when Ctrl is held
|
||||
{
|
||||
shouldWrap = (GetAsyncKeyState(VK_CONTROL) & 0x8000) != 0;
|
||||
}
|
||||
else if (activationMode == 2) // HoldingShift - wrap only when Shift is held
|
||||
{
|
||||
shouldWrap = (GetAsyncKeyState(VK_SHIFT) & 0x8000) != 0;
|
||||
}
|
||||
|
||||
if (!shouldWrap)
|
||||
{
|
||||
// Activation key is not held, do not wrap - let normal behavior happen.
|
||||
return CallNextHookEx(nullptr, nCode, wParam, lParam);
|
||||
}
|
||||
|
||||
POINT newPos = g_cursorWrapInstance->m_core.HandleMouseMove(
|
||||
currentPos,
|
||||
g_cursorWrapInstance->m_disableWrapDuringDrag,
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -18,6 +18,8 @@ namespace newplus::constants::non_localizable
|
||||
|
||||
constexpr WCHAR settings_json_key_template_location[] = L"TemplateLocation";
|
||||
|
||||
constexpr WCHAR settings_json_key_hide_built_in_new[] = L"BuiltInNewHidePreference";
|
||||
|
||||
constexpr WCHAR context_menu_package_name[] = L"NewPlusContextMenu";
|
||||
|
||||
constexpr WCHAR msix_package_name[] = L"NewPlusPackage.msix";
|
||||
|
||||
@@ -4,22 +4,6 @@
|
||||
|
||||
namespace newplus::helpers::filesystem
|
||||
{
|
||||
namespace constants::non_localizable
|
||||
{
|
||||
constexpr WCHAR desktop_ini_filename[] = L"desktop.ini";
|
||||
}
|
||||
|
||||
inline bool is_hidden(const std::filesystem::path path)
|
||||
{
|
||||
const std::filesystem::path::string_type name = path.filename();
|
||||
if (name == constants::non_localizable::desktop_ini_filename)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
inline bool is_directory(const std::filesystem::path path)
|
||||
{
|
||||
const auto entry = std::filesystem::directory_entry(path);
|
||||
|
||||
@@ -129,6 +129,18 @@ namespace newplus::helpers::variables
|
||||
return result;
|
||||
}
|
||||
|
||||
static bool exclude_item(const std::filesystem::path& path)
|
||||
{
|
||||
DWORD attrs = GetFileAttributesW(path.c_str());
|
||||
if (attrs == INVALID_FILE_ATTRIBUTES)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
// Exclude if hidden or system
|
||||
return (attrs & (FILE_ATTRIBUTE_HIDDEN | FILE_ATTRIBUTE_SYSTEM)) != 0;
|
||||
}
|
||||
|
||||
inline void resolve_variables_in_filename_and_rename_files(const std::filesystem::path& path, const bool do_rename = true)
|
||||
{
|
||||
// Depth first recursion, so that we start renaming the leaves, and avoid having to rescan
|
||||
@@ -143,7 +155,7 @@ namespace newplus::helpers::variables
|
||||
// Perform the actual rename
|
||||
for (const auto& current : std::filesystem::directory_iterator(path))
|
||||
{
|
||||
if (!newplus::helpers::filesystem::is_hidden(current))
|
||||
if (!exclude_item(current))
|
||||
{
|
||||
const std::filesystem::path resolved_path = resolve_variables_in_path(current.path());
|
||||
|
||||
|
||||
@@ -446,4 +446,69 @@ namespace newplus::utilities
|
||||
|
||||
return hr;
|
||||
}
|
||||
|
||||
constexpr wchar_t built_in_new_registry_path[] = LR"(Software\Classes\Directory\Background\ShellEx\ContextMenuHandlers\New)";
|
||||
constexpr wchar_t built_in_new_registry_disabled_value_prefix[] = L"disabled_";
|
||||
|
||||
inline bool disable_built_in_new_via_registry()
|
||||
{
|
||||
// This is implemented to support where New+ GPO is configured to
|
||||
// hide the built-in New context menu but Settings UI hasn't been launched
|
||||
// Mirrors the logic in DisableBuiltInNewViaRegistry in .cs
|
||||
|
||||
HKEY key{};
|
||||
|
||||
if (RegCreateKeyExW(HKEY_CURRENT_USER,
|
||||
built_in_new_registry_path,
|
||||
0,
|
||||
nullptr,
|
||||
REG_OPTION_NON_VOLATILE,
|
||||
KEY_ALL_ACCESS,
|
||||
nullptr,
|
||||
&key,
|
||||
nullptr) != ERROR_SUCCESS)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
const auto built_in_new_registry_disabled_value_prefix_len = lstrlenW(built_in_new_registry_disabled_value_prefix);
|
||||
|
||||
if (RegSetValueExW(key, nullptr, 0, REG_SZ, reinterpret_cast<const BYTE*>(&built_in_new_registry_disabled_value_prefix), built_in_new_registry_disabled_value_prefix_len) != ERROR_SUCCESS)
|
||||
{
|
||||
RegCloseKey(key);
|
||||
return true;
|
||||
}
|
||||
|
||||
RegCloseKey(key);
|
||||
return false;
|
||||
|
||||
}
|
||||
|
||||
inline bool enable_built_in_new_via_registry()
|
||||
{
|
||||
// This is implemented to support where New+ GPO is configured to
|
||||
// display the built-in New context menu but Settings UI hasn't been launched
|
||||
// Mirrors the logic in EnableBuiltInNewViaRegistry in .cs
|
||||
|
||||
HKEY key{};
|
||||
|
||||
if (RegOpenKeyExW(HKEY_CURRENT_USER,
|
||||
built_in_new_registry_path,
|
||||
0,
|
||||
KEY_ALL_ACCESS,
|
||||
&key) != ERROR_SUCCESS)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
if (RegDeleteValueW(key, nullptr) != ERROR_SUCCESS)
|
||||
{
|
||||
RegCloseKey(key);
|
||||
return true;
|
||||
}
|
||||
|
||||
RegCloseKey(key);
|
||||
return false;
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
@@ -172,7 +172,22 @@ private:
|
||||
void init_settings()
|
||||
{
|
||||
powertoy_new_enabled = NewSettingsInstance().GetEnabled();
|
||||
|
||||
UpdateRegistration(powertoy_new_enabled);
|
||||
|
||||
if (powertoy_new_enabled)
|
||||
{
|
||||
// NOTE: This requires that the runner is running and have loaded the new plus module.
|
||||
// It's not enough for user to just invoke the context menu.
|
||||
if (NewSettingsInstance().GetHideBuiltInNew())
|
||||
{
|
||||
newplus::utilities::disable_built_in_new_via_registry();
|
||||
}
|
||||
else
|
||||
{
|
||||
newplus::utilities::enable_built_in_new_via_registry();
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -45,6 +45,7 @@ void NewSettings::Save()
|
||||
values.add_property(newplus::constants::non_localizable::settings_json_key_hide_starting_digits, new_settings.hide_starting_digits);
|
||||
values.add_property(newplus::constants::non_localizable::settings_json_key_replace_variables, new_settings.replace_variables);
|
||||
values.add_property(newplus::constants::non_localizable::settings_json_key_template_location, new_settings.template_location);
|
||||
values.add_property(newplus::constants::non_localizable::settings_json_key_hide_built_in_new, new_settings.hide_built_in_new_preference);
|
||||
|
||||
values.save_to_settings_file();
|
||||
|
||||
@@ -75,6 +76,9 @@ void NewSettings::InitializeWithDefaultSettings()
|
||||
SetReplaceVariables(false);
|
||||
|
||||
SetTemplateLocation(GetTemplateLocationDefaultPath());
|
||||
|
||||
// By default we show the built-in New context menu
|
||||
SetHideBuiltInNew(false);
|
||||
}
|
||||
|
||||
void NewSettings::RefreshEnabledState()
|
||||
@@ -149,6 +153,12 @@ void NewSettings::ParseJson()
|
||||
new_settings.replace_variables = resolveVariables.value();
|
||||
}
|
||||
|
||||
const auto hideBuiltInNewValue = settings.get_bool_value(newplus::constants::non_localizable::settings_json_key_hide_built_in_new);
|
||||
if (hideBuiltInNewValue.has_value())
|
||||
{
|
||||
new_settings.hide_built_in_new_preference = hideBuiltInNewValue.value();
|
||||
}
|
||||
|
||||
GetSystemTimeAsFileTime(&new_settings_last_loaded_timestamp);
|
||||
}
|
||||
|
||||
@@ -239,6 +249,26 @@ std::wstring NewSettings::GetTemplateLocationDefaultPath() const
|
||||
return full_path;
|
||||
}
|
||||
|
||||
bool NewSettings::GetHideBuiltInNew()
|
||||
{
|
||||
const auto gpoSetting = powertoys_gpo::getConfiguredNewPlusHideBuiltInNewContextMenuValue();
|
||||
if (gpoSetting == powertoys_gpo::gpo_rule_configured_enabled)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
else if (gpoSetting == powertoys_gpo::gpo_rule_configured_disabled)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
return new_settings.hide_built_in_new_preference;
|
||||
}
|
||||
|
||||
void NewSettings::SetHideBuiltInNew(const bool hide_built_in_new)
|
||||
{
|
||||
new_settings.hide_built_in_new_preference = hide_built_in_new;
|
||||
}
|
||||
|
||||
NewSettings& NewSettingsInstance()
|
||||
{
|
||||
static NewSettings instance;
|
||||
|
||||
@@ -16,6 +16,8 @@ public:
|
||||
void SetReplaceVariables(const bool resolve_variables);
|
||||
std::wstring GetTemplateLocation() const;
|
||||
void SetTemplateLocation(const std::wstring template_location);
|
||||
bool GetHideBuiltInNew();
|
||||
void SetHideBuiltInNew(const bool hide_built_in_new);
|
||||
|
||||
void Save();
|
||||
void Load();
|
||||
@@ -29,6 +31,7 @@ private:
|
||||
bool hide_starting_digits{ true };
|
||||
bool replace_variables{ true };
|
||||
std::wstring template_location;
|
||||
bool hide_built_in_new_preference{ false };
|
||||
};
|
||||
|
||||
void RefreshEnabledState();
|
||||
|
||||
@@ -35,7 +35,7 @@ void template_folder::rescan_template_folder()
|
||||
}
|
||||
else
|
||||
{
|
||||
if (!helpers::filesystem::is_hidden(entry.path()))
|
||||
if (!newplus::helpers::variables::exclude_item(entry.path()))
|
||||
{
|
||||
files.push_back({ entry.path().wstring(), new template_item(entry) });
|
||||
}
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
|
||||
|
||||
#include "pch.h"
|
||||
#include "template_item.h"
|
||||
#include <shellapi.h>
|
||||
@@ -60,10 +58,91 @@ std::wstring template_item::get_target_filename(const bool include_starting_digi
|
||||
|
||||
std::wstring template_item::remove_starting_digits_from_filename(std::wstring filename) const
|
||||
{
|
||||
filename.erase(0, std::min(filename.find_first_not_of(L"0123456789"), filename.size()));
|
||||
filename.erase(0, std::min(filename.find_first_not_of(L" ."), filename.size()));
|
||||
// Filename cases to support
|
||||
// type | filename | result
|
||||
// [file] | 01. First entry.txt | First entry.txt
|
||||
// [folder] | 02. Second entry | Second entry
|
||||
// [folder] | 03 Third entry | Third entry
|
||||
// [file] | 04 Fourth entry.txt | Fourth entry.txt
|
||||
// [file] | 05.Fifth entry.txt | Fifth entry.txt
|
||||
// [folder] | 001231 | 001231
|
||||
// [file] | 001231.txt | 001231.txt
|
||||
// [file] | 13. 0123456789012345.txt | 0123456789012345.txt
|
||||
|
||||
return filename;
|
||||
std::filesystem::path filename_path(filename);
|
||||
const std::wstring stem = filename_path.stem().wstring();
|
||||
|
||||
bool stem_is_only_digits = !stem.empty();
|
||||
for (const wchar_t c : stem)
|
||||
{
|
||||
if (c < L'0' || c > L'9')
|
||||
{
|
||||
stem_is_only_digits = false;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (stem_is_only_digits)
|
||||
{
|
||||
// Edge cases where digits ARE the filename.
|
||||
// If it's a file, we always keep it (e.g. 001231.txt or 001231).
|
||||
// If it's a folder, we only strip if it looks like it has an extension (which is actually part of the name for folders).
|
||||
// e.g. "0123.Name" -> Strip. "001231" -> Keep.
|
||||
const bool is_folder = helpers::filesystem::is_directory(path);
|
||||
const bool has_extension = filename_path.has_extension();
|
||||
|
||||
if (!is_folder || !has_extension)
|
||||
{
|
||||
return filename;
|
||||
}
|
||||
}
|
||||
|
||||
// Find end of leading digits
|
||||
size_t digits_end_index = 0;
|
||||
while (digits_end_index < filename.length() && filename[digits_end_index] >= L'0' && filename[digits_end_index] <= L'9')
|
||||
{
|
||||
digits_end_index++;
|
||||
}
|
||||
|
||||
if (digits_end_index == 0)
|
||||
{
|
||||
// No leading digits
|
||||
return filename;
|
||||
}
|
||||
|
||||
// Determine if we should also strip a separator (dot or space)
|
||||
size_t strip_length = digits_end_index;
|
||||
|
||||
// Check patterns to strip separators:
|
||||
// 1. "01. Name" -> Strip "01. "
|
||||
// 2. "01 .Name" -> Strip "01 ."
|
||||
// 3. "01.Name" -> Strip "01."
|
||||
// 4. "01 Name" -> Strip "01 "
|
||||
// 5. "01Name" -> Strip "01" (No separator)
|
||||
|
||||
if (strip_length < filename.length())
|
||||
{
|
||||
if (filename[strip_length] == L'.')
|
||||
{
|
||||
strip_length++;
|
||||
// If dot is followed by space, strip that too (e.g. "01. Name")
|
||||
if (strip_length < filename.length() && filename[strip_length] == L' ')
|
||||
{
|
||||
strip_length++;
|
||||
}
|
||||
}
|
||||
else if (filename[strip_length] == L' ')
|
||||
{
|
||||
strip_length++;
|
||||
// If space is followed by dot, strip that too (e.g. "01 .Name")
|
||||
if (strip_length < filename.length() && filename[strip_length] == L'.')
|
||||
{
|
||||
strip_length++;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return filename.substr(strip_length);
|
||||
}
|
||||
|
||||
std::wstring template_item::get_explorer_icon() const
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -22,6 +22,7 @@ namespace NonLocalizable
|
||||
const static wchar_t* TOOL_WINDOW_CLASS_NAME = L"AlwaysOnTopWindow";
|
||||
const static wchar_t* WINDOW_IS_PINNED_PROP = L"AlwaysOnTop_Pinned";
|
||||
constexpr UINT SYSTEM_MENU_TOGGLE_ALWAYS_ON_TOP_COMMAND = 0xEFE0;
|
||||
constexpr ULONG_PTR SYSTEM_MENU_TOGGLE_ALWAYS_ON_TOP_COMMAND_OWNER_TAG = 0x414F5450;
|
||||
constexpr DWORD SYSTEM_EVENT_MENU_POPUP_START = 0x0006;
|
||||
constexpr DWORD SYSTEM_EVENT_MENU_POPUP_END = 0x0007;
|
||||
}
|
||||
@@ -40,6 +41,29 @@ namespace
|
||||
|
||||
hooks.clear();
|
||||
}
|
||||
|
||||
bool HasMenuCommand(HMENU menu, UINT commandId) noexcept
|
||||
{
|
||||
return menu && GetMenuState(menu, commandId, MF_BYCOMMAND) != static_cast<UINT>(-1);
|
||||
}
|
||||
|
||||
bool IsAlwaysOnTopMenuCommand(HMENU menu) noexcept
|
||||
{
|
||||
if (!HasMenuCommand(menu, NonLocalizable::SYSTEM_MENU_TOGGLE_ALWAYS_ON_TOP_COMMAND))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
MENUITEMINFOW menuItemInfo{};
|
||||
menuItemInfo.cbSize = sizeof(menuItemInfo);
|
||||
menuItemInfo.fMask = MIIM_DATA;
|
||||
|
||||
return GetMenuItemInfoW(menu,
|
||||
NonLocalizable::SYSTEM_MENU_TOGGLE_ALWAYS_ON_TOP_COMMAND,
|
||||
FALSE,
|
||||
&menuItemInfo) &&
|
||||
menuItemInfo.dwItemData == NonLocalizable::SYSTEM_MENU_TOGGLE_ALWAYS_ON_TOP_COMMAND_OWNER_TAG;
|
||||
}
|
||||
}
|
||||
|
||||
bool isExcluded(HWND window)
|
||||
@@ -47,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) :
|
||||
@@ -131,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)
|
||||
{
|
||||
@@ -170,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());
|
||||
}
|
||||
@@ -212,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;
|
||||
}
|
||||
@@ -252,7 +279,7 @@ void AlwaysOnTop::ProcessCommand(HWND window)
|
||||
}
|
||||
}
|
||||
|
||||
if (AlwaysOnTopSettings::settings().enableSound)
|
||||
if (AlwaysOnTopSettings::settings()->enableSound)
|
||||
{
|
||||
m_sound.Play(soundType);
|
||||
}
|
||||
@@ -299,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)
|
||||
@@ -328,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);
|
||||
}
|
||||
@@ -448,7 +477,7 @@ void AlwaysOnTop::SubscribeToEvents()
|
||||
}
|
||||
}
|
||||
|
||||
UpdateSystemMenuEventHooks(AlwaysOnTopSettings::settings().showInSystemMenu);
|
||||
UpdateSystemMenuEventHooks(AlwaysOnTopSettings::settings()->showInSystemMenu);
|
||||
}
|
||||
|
||||
void AlwaysOnTop::UpdateSystemMenuEventHooks(bool enable)
|
||||
@@ -501,9 +530,10 @@ void AlwaysOnTop::UpdateSystemMenuItem(HWND window) const noexcept
|
||||
return;
|
||||
}
|
||||
|
||||
if (!AlwaysOnTopSettings::settings().showInSystemMenu)
|
||||
const auto settings = AlwaysOnTopSettings::settings();
|
||||
if (!settings->showInSystemMenu)
|
||||
{
|
||||
if (GetMenuState(systemMenu, NonLocalizable::SYSTEM_MENU_TOGGLE_ALWAYS_ON_TOP_COMMAND, MF_BYCOMMAND) != static_cast<UINT>(-1))
|
||||
if (IsAlwaysOnTopMenuCommand(systemMenu))
|
||||
{
|
||||
RemoveMenu(systemMenu, NonLocalizable::SYSTEM_MENU_TOGGLE_ALWAYS_ON_TOP_COMMAND, MF_BYCOMMAND);
|
||||
}
|
||||
@@ -513,20 +543,26 @@ void AlwaysOnTop::UpdateSystemMenuItem(HWND window) const noexcept
|
||||
auto text = GET_RESOURCE_STRING(IDS_SYSTEM_MENU_TOGGLE_ALWAYS_ON_TOP);
|
||||
MENUITEMINFOW menuItemInfo{};
|
||||
menuItemInfo.cbSize = sizeof(menuItemInfo);
|
||||
menuItemInfo.fMask = MIIM_ID | MIIM_STATE | MIIM_STRING;
|
||||
menuItemInfo.fMask = MIIM_ID | MIIM_STATE | MIIM_STRING | MIIM_DATA;
|
||||
menuItemInfo.wID = NonLocalizable::SYSTEM_MENU_TOGGLE_ALWAYS_ON_TOP_COMMAND;
|
||||
menuItemInfo.fState = IsPinned(window) ? MFS_CHECKED : MFS_UNCHECKED;
|
||||
menuItemInfo.dwTypeData = text.data();
|
||||
menuItemInfo.dwItemData = NonLocalizable::SYSTEM_MENU_TOGGLE_ALWAYS_ON_TOP_COMMAND_OWNER_TAG;
|
||||
|
||||
if (GetMenuState(systemMenu, NonLocalizable::SYSTEM_MENU_TOGGLE_ALWAYS_ON_TOP_COMMAND, MF_BYCOMMAND) == static_cast<UINT>(-1))
|
||||
if (!HasMenuCommand(systemMenu, NonLocalizable::SYSTEM_MENU_TOGGLE_ALWAYS_ON_TOP_COMMAND))
|
||||
{
|
||||
InsertMenuItemW(systemMenu, SC_CLOSE, FALSE, &menuItemInfo);
|
||||
}
|
||||
else
|
||||
else if (IsAlwaysOnTopMenuCommand(systemMenu))
|
||||
{
|
||||
menuItemInfo.fMask = MIIM_STATE | MIIM_STRING;
|
||||
SetMenuItemInfoW(systemMenu, NonLocalizable::SYSTEM_MENU_TOGGLE_ALWAYS_ON_TOP_COMMAND, FALSE, &menuItemInfo);
|
||||
}
|
||||
else
|
||||
{
|
||||
Logger::warn(L"Skipping Always On Top system menu command registration because ID 0x{:X} is already in use by another item.",
|
||||
NonLocalizable::SYSTEM_MENU_TOGGLE_ALWAYS_ON_TOP_COMMAND);
|
||||
}
|
||||
}
|
||||
|
||||
void AlwaysOnTop::UnpinAll()
|
||||
@@ -614,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);
|
||||
}
|
||||
}
|
||||
@@ -629,7 +665,7 @@ void AlwaysOnTop::HandleWinHookEvent(WinHookEvent* data) noexcept
|
||||
return;
|
||||
case EVENT_OBJECT_INVOKED:
|
||||
{
|
||||
if (!AlwaysOnTopSettings::settings().showInSystemMenu)
|
||||
if (!AlwaysOnTopSettings::settings()->showInSystemMenu)
|
||||
{
|
||||
return;
|
||||
}
|
||||
@@ -652,8 +688,7 @@ void AlwaysOnTop::HandleWinHookEvent(WinHookEvent* data) noexcept
|
||||
}
|
||||
|
||||
const auto systemMenu = GetSystemMenu(window, false);
|
||||
return systemMenu &&
|
||||
GetMenuState(systemMenu, NonLocalizable::SYSTEM_MENU_TOGGLE_ALWAYS_ON_TOP_COMMAND, MF_BYCOMMAND) != static_cast<UINT>(-1);
|
||||
return systemMenu && IsAlwaysOnTopMenuCommand(systemMenu);
|
||||
};
|
||||
|
||||
HWND commandWindow = nullptr;
|
||||
@@ -681,7 +716,7 @@ void AlwaysOnTop::HandleWinHookEvent(WinHookEvent* data) noexcept
|
||||
break;
|
||||
}
|
||||
|
||||
if (!AlwaysOnTopSettings::settings().enableFrame || !data->hwnd)
|
||||
if (!AlwaysOnTopSettings::settings()->enableFrame || !data->hwnd)
|
||||
{
|
||||
return;
|
||||
}
|
||||
@@ -850,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();
|
||||
}
|
||||
}
|
||||
@@ -19,7 +19,7 @@ namespace Microsoft.CmdPal.Common.Properties {
|
||||
// class via a tool like ResGen or Visual Studio.
|
||||
// To add or remove a member, edit your .ResX file then rerun ResGen
|
||||
// with the /str option, or rebuild your VS project.
|
||||
[global::System.CodeDom.Compiler.GeneratedCodeAttribute("System.Resources.Tools.StronglyTypedResourceBuilder", "18.0.0.0")]
|
||||
[global::System.CodeDom.Compiler.GeneratedCodeAttribute("System.Resources.Tools.StronglyTypedResourceBuilder", "17.0.0.0")]
|
||||
[global::System.Diagnostics.DebuggerNonUserCodeAttribute()]
|
||||
[global::System.Runtime.CompilerServices.CompilerGeneratedAttribute()]
|
||||
internal class Resources {
|
||||
|
||||
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);
|
||||
|
||||
|
||||
@@ -2,31 +2,54 @@
|
||||
// The Microsoft Corporation licenses this file to you under the MIT license.
|
||||
// See the LICENSE file in the project root for more information.
|
||||
|
||||
using System.ComponentModel;
|
||||
using CommunityToolkit.Mvvm.ComponentModel;
|
||||
using CommunityToolkit.Mvvm.Messaging;
|
||||
using CommunityToolkit.WinUI;
|
||||
using Microsoft.CmdPal.UI.ViewModels.Messages;
|
||||
using Microsoft.CommandPalette.Extensions.Toolkit;
|
||||
using Windows.System;
|
||||
using DispatcherQueue = Microsoft.UI.Dispatching.DispatcherQueue;
|
||||
using DispatcherQueueTimer = Microsoft.UI.Dispatching.DispatcherQueueTimer;
|
||||
|
||||
namespace Microsoft.CmdPal.UI.ViewModels;
|
||||
|
||||
public partial class CommandBarViewModel : ObservableObject,
|
||||
public sealed partial class CommandBarViewModel : ObservableObject,
|
||||
IRecipient<UpdateCommandBarMessage>
|
||||
{
|
||||
private readonly DispatcherQueueTimer _debounceTimer;
|
||||
|
||||
private volatile ICommandBarContext? _pendingSelectedItem;
|
||||
|
||||
public ICommandBarContext? SelectedItem
|
||||
{
|
||||
get => field;
|
||||
get;
|
||||
set
|
||||
{
|
||||
if (field != null)
|
||||
// TODO: verify if we can safely return early
|
||||
// if (ReferenceEquals(field, value))
|
||||
// {
|
||||
// return;
|
||||
// }
|
||||
if (field is not null)
|
||||
{
|
||||
field.PropertyChanged -= SelectedItemPropertyChanged;
|
||||
}
|
||||
|
||||
field = value;
|
||||
SetSelectedItem(value);
|
||||
|
||||
OnPropertyChanged(nameof(SelectedItem));
|
||||
if (field is not null)
|
||||
{
|
||||
PrimaryCommand = field.PrimaryCommand;
|
||||
field.PropertyChanged += SelectedItemPropertyChanged;
|
||||
}
|
||||
else
|
||||
{
|
||||
PrimaryCommand = null;
|
||||
}
|
||||
|
||||
UpdateContextItems();
|
||||
OnPropertyChanged();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -34,6 +57,8 @@ public partial class CommandBarViewModel : ObservableObject,
|
||||
[NotifyPropertyChangedFor(nameof(HasPrimaryCommand))]
|
||||
public partial CommandItemViewModel? PrimaryCommand { get; set; }
|
||||
|
||||
// TODO: PrimaryCommand.ShouldBeVisible is not observed, if it changes the bar won't refresh;
|
||||
// but at this moment CommandItemViewModel won't raise INPC for ShouldBeVisible anyway.
|
||||
public bool HasPrimaryCommand => PrimaryCommand is not null && PrimaryCommand.ShouldBeVisible;
|
||||
|
||||
[ObservableProperty]
|
||||
@@ -50,29 +75,31 @@ public partial class CommandBarViewModel : ObservableObject,
|
||||
|
||||
public CommandBarViewModel()
|
||||
{
|
||||
var dispatcherQueue = DispatcherQueue.GetForCurrentThread();
|
||||
if (dispatcherQueue is null)
|
||||
{
|
||||
throw new InvalidOperationException("DispatcherQueue is not available for the current thread.");
|
||||
}
|
||||
|
||||
_debounceTimer = dispatcherQueue.CreateTimer();
|
||||
WeakReferenceMessenger.Default.Register<UpdateCommandBarMessage>(this);
|
||||
}
|
||||
|
||||
public void Receive(UpdateCommandBarMessage message) => SelectedItem = message.ViewModel;
|
||||
|
||||
private void SetSelectedItem(ICommandBarContext? value)
|
||||
public void Receive(UpdateCommandBarMessage message)
|
||||
{
|
||||
if (value is not null)
|
||||
{
|
||||
PrimaryCommand = value.PrimaryCommand;
|
||||
value.PropertyChanged += SelectedItemPropertyChanged;
|
||||
}
|
||||
else
|
||||
{
|
||||
if (SelectedItem is not null)
|
||||
{
|
||||
SelectedItem.PropertyChanged -= SelectedItemPropertyChanged;
|
||||
}
|
||||
_pendingSelectedItem = message.ViewModel;
|
||||
|
||||
PrimaryCommand = null;
|
||||
}
|
||||
// immediate: false is intentional — the timer tick always fires on the
|
||||
// dispatcher queue thread, which guarantees ApplyPendingSelectedItem
|
||||
// runs on the UI thread even if Receive is called from a background
|
||||
// thread. Using immediate: true would invoke the delegate synchronously
|
||||
// on the calling thread, bypassing the dispatcher.
|
||||
_debounceTimer.Debounce(ApplyPendingSelectedItem, TimeSpan.FromMilliseconds(50));
|
||||
}
|
||||
|
||||
UpdateContextItems();
|
||||
private void ApplyPendingSelectedItem()
|
||||
{
|
||||
SelectedItem = _pendingSelectedItem;
|
||||
}
|
||||
|
||||
private void SelectedItemPropertyChanged(object? sender, System.ComponentModel.PropertyChangedEventArgs e)
|
||||
@@ -95,11 +122,9 @@ public 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]
|
||||
|
||||
@@ -20,6 +20,8 @@ public partial class CommandPaletteContentPageViewModel : ContentPageViewModel
|
||||
IFormContent form => new ContentFormViewModel(form, context),
|
||||
IMarkdownContent markdown => new ContentMarkdownViewModel(markdown, context),
|
||||
ITreeContent tree => new ContentTreeViewModel(tree, context),
|
||||
IPlainTextContent plainText => new ContentPlainTextViewModel(plainText, context),
|
||||
IImageContent image => new ContentImageViewModel(image, context),
|
||||
_ => null,
|
||||
};
|
||||
return viewModel;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -215,9 +215,7 @@ public sealed class CommandProviderWrapper : ICommandProviderContext
|
||||
}
|
||||
catch (Exception e)
|
||||
{
|
||||
Logger.LogError("Failed to load commands from extension");
|
||||
Logger.LogError($"Extension was {Extension!.PackageFamilyName}");
|
||||
Logger.LogError(e.ToString());
|
||||
Logger.LogError($"Failed to load commands from extension {Extension!.PackageFamilyName}", e);
|
||||
|
||||
if (!displayInfoInitialized)
|
||||
{
|
||||
|
||||
@@ -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;
|
||||
@@ -39,6 +52,7 @@ public sealed partial class MainListPage : DynamicListPage,
|
||||
// recognise them across successive GetItems() calls
|
||||
private readonly Separator _resultsSeparator = new(Resources.results);
|
||||
private readonly Separator _fallbacksSeparator = new(Resources.fallbacks);
|
||||
private readonly Separator _commandsSeparator = new(Resources.home_sections_commands_title);
|
||||
|
||||
private RoScored<IListItem>[]? _filteredItems;
|
||||
private RoScored<IListItem>[]? _filteredApps;
|
||||
@@ -53,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,
|
||||
@@ -67,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;
|
||||
@@ -81,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);
|
||||
@@ -119,10 +174,20 @@ public sealed partial class MainListPage : DynamicListPage,
|
||||
}
|
||||
else
|
||||
{
|
||||
RaiseItemsChanged();
|
||||
RequestRefresh(fullRefresh: false);
|
||||
}
|
||||
}
|
||||
|
||||
private void RequestRefresh(bool fullRefresh, TimeSpan? interval = null)
|
||||
{
|
||||
if (fullRefresh)
|
||||
{
|
||||
_fullRefreshRequested.Set();
|
||||
}
|
||||
|
||||
_refreshThrottledDebouncedAction.Invoke(interval);
|
||||
}
|
||||
|
||||
private void ReapplySearchInBackground()
|
||||
{
|
||||
_refreshRequested.Set();
|
||||
@@ -150,7 +215,7 @@ public sealed partial class MainListPage : DynamicListPage,
|
||||
}
|
||||
|
||||
var currentSearchText = SearchText;
|
||||
UpdateSearchText(currentSearchText, currentSearchText);
|
||||
UpdateSearchTextCore(currentSearchText, currentSearchText, isUserInput: false);
|
||||
}
|
||||
while (_refreshRequested.Value);
|
||||
}
|
||||
@@ -196,7 +261,7 @@ public sealed partial class MainListPage : DynamicListPage,
|
||||
|
||||
// +1 for the separator
|
||||
var result = new IListItem[eligibleCount + 1];
|
||||
result[0] = _resultsSeparator;
|
||||
result[0] = _commandsSeparator;
|
||||
|
||||
// Second pass: populate
|
||||
var writeIndex = 1;
|
||||
@@ -242,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();
|
||||
|
||||
@@ -296,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)
|
||||
{
|
||||
@@ -315,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)
|
||||
{
|
||||
@@ -326,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(commands.Count);
|
||||
var wasAlreadyEmpty = string.IsNullOrWhiteSpace(oldSearch);
|
||||
RequestRefresh(fullRefresh: true, interval: wasAlreadyEmpty ? null : TimeSpan.Zero);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -465,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();
|
||||
}
|
||||
},
|
||||
token);
|
||||
}
|
||||
|
||||
private bool ActuallyLoading()
|
||||
{
|
||||
var allApps = AllAppsCommandProvider.Page;
|
||||
@@ -643,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);
|
||||
|
||||
@@ -653,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}";
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
// Copyright (c) Microsoft Corporation
|
||||
// The Microsoft Corporation licenses this file to you under the MIT license.
|
||||
// See the LICENSE file in the project root for more information.
|
||||
|
||||
using Microsoft.CmdPal.UI.ViewModels.Models;
|
||||
using Microsoft.CommandPalette.Extensions;
|
||||
|
||||
namespace Microsoft.CmdPal.UI.ViewModels;
|
||||
|
||||
public partial class ContentImageViewModel : ContentViewModel
|
||||
{
|
||||
public ExtensionObject<IImageContent> Model { get; }
|
||||
|
||||
public IconInfoViewModel Image { get; protected set; } = new(null);
|
||||
|
||||
public double MaxWidth { get; protected set; } = double.PositiveInfinity;
|
||||
|
||||
public double MaxHeight { get; protected set; } = double.PositiveInfinity;
|
||||
|
||||
public ContentImageViewModel(IImageContent content, WeakReference<IPageContext> context)
|
||||
: base(context)
|
||||
{
|
||||
Model = new ExtensionObject<IImageContent>(content);
|
||||
}
|
||||
|
||||
public override void InitializeProperties()
|
||||
{
|
||||
var model = Model.Unsafe;
|
||||
if (model is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
Image = new IconInfoViewModel(model.Image);
|
||||
Image.InitializeProperties();
|
||||
|
||||
MaxWidth = model.MaxWidth <= 0 ? double.PositiveInfinity : model.MaxWidth;
|
||||
MaxHeight = model.MaxHeight <= 0 ? double.PositiveInfinity : model.MaxHeight;
|
||||
|
||||
UpdateProperty(nameof(Image), nameof(MaxWidth), nameof(MaxHeight));
|
||||
model.PropChanged += Model_PropChanged;
|
||||
}
|
||||
|
||||
private void Model_PropChanged(object sender, IPropChangedEventArgs args)
|
||||
{
|
||||
try
|
||||
{
|
||||
var propName = args.PropertyName;
|
||||
FetchProperty(propName);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
ShowException(ex);
|
||||
}
|
||||
}
|
||||
|
||||
private void FetchProperty(string propertyName)
|
||||
{
|
||||
var model = Model.Unsafe;
|
||||
if (model is null)
|
||||
{
|
||||
return; // throw?
|
||||
}
|
||||
|
||||
switch (propertyName)
|
||||
{
|
||||
case nameof(Image):
|
||||
Image = new IconInfoViewModel(model.Image);
|
||||
Image.InitializeProperties();
|
||||
UpdateProperty(propertyName);
|
||||
break;
|
||||
|
||||
case nameof(IImageContent.MaxWidth):
|
||||
MaxWidth = model.MaxWidth <= 0 ? double.PositiveInfinity : model.MaxWidth;
|
||||
UpdateProperty(propertyName);
|
||||
break;
|
||||
|
||||
case nameof(IImageContent.MaxHeight):
|
||||
MaxHeight = model.MaxHeight <= 0 ? double.PositiveInfinity : model.MaxHeight;
|
||||
UpdateProperty(propertyName);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
protected override void UnsafeCleanup()
|
||||
{
|
||||
base.UnsafeCleanup();
|
||||
var model = Model.Unsafe;
|
||||
if (model is not null)
|
||||
{
|
||||
model.PropChanged -= Model_PropChanged;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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,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 Microsoft.CmdPal.UI.ViewModels.Models;
|
||||
using Microsoft.CommandPalette.Extensions;
|
||||
|
||||
namespace Microsoft.CmdPal.UI.ViewModels;
|
||||
|
||||
public partial class ContentPlainTextViewModel : ContentViewModel
|
||||
{
|
||||
private ExtensionObject<IPlainTextContent> Model { get; }
|
||||
|
||||
public string? Text { get; protected set; }
|
||||
|
||||
public bool WordWrapEnabled { get; protected set; }
|
||||
|
||||
public bool UseMonospace { get; protected set; }
|
||||
|
||||
public ContentPlainTextViewModel(IPlainTextContent content, WeakReference<IPageContext> context)
|
||||
: base(context)
|
||||
{
|
||||
Model = new ExtensionObject<IPlainTextContent>(content);
|
||||
}
|
||||
|
||||
public override void InitializeProperties()
|
||||
{
|
||||
var model = Model.Unsafe;
|
||||
if (model is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
Text = model.Text;
|
||||
WordWrapEnabled = model.WrapWords;
|
||||
UseMonospace = model.FontFamily == CommandPalette.Extensions.FontFamily.Monospace;
|
||||
UpdateProperty(nameof(Text), nameof(WordWrapEnabled), nameof(UseMonospace));
|
||||
model.PropChanged += Model_PropChanged;
|
||||
}
|
||||
|
||||
private void Model_PropChanged(object sender, IPropChangedEventArgs args)
|
||||
{
|
||||
try
|
||||
{
|
||||
var propName = args.PropertyName;
|
||||
FetchProperty(propName);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
ShowException(ex);
|
||||
}
|
||||
}
|
||||
|
||||
private void FetchProperty(string propertyName)
|
||||
{
|
||||
var model = Model.Unsafe;
|
||||
if (model is null)
|
||||
{
|
||||
return; // throw?
|
||||
}
|
||||
|
||||
switch (propertyName)
|
||||
{
|
||||
case nameof(IPlainTextContent.FontFamily):
|
||||
// RPC:
|
||||
var incomingUseMonospace = model.FontFamily == CommandPalette.Extensions.FontFamily.Monospace;
|
||||
|
||||
// local:
|
||||
if (incomingUseMonospace != UseMonospace)
|
||||
{
|
||||
UseMonospace = incomingUseMonospace;
|
||||
UpdateProperty(nameof(UseMonospace));
|
||||
}
|
||||
|
||||
break;
|
||||
|
||||
case nameof(IPlainTextContent.WrapWords):
|
||||
// RPC:
|
||||
var incomingWrap = model.WrapWords;
|
||||
|
||||
// local:
|
||||
if (WordWrapEnabled != incomingWrap)
|
||||
{
|
||||
WordWrapEnabled = model.WrapWords;
|
||||
UpdateProperty(nameof(WordWrapEnabled));
|
||||
}
|
||||
|
||||
break;
|
||||
|
||||
case nameof(IPlainTextContent.Text):
|
||||
// RPC:
|
||||
var incomingText = model.Text;
|
||||
|
||||
// local:
|
||||
if (incomingText != Text)
|
||||
{
|
||||
Text = incomingText;
|
||||
UpdateProperty(nameof(Text));
|
||||
}
|
||||
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
protected override void UnsafeCleanup()
|
||||
{
|
||||
base.UnsafeCleanup();
|
||||
var model = Model.Unsafe;
|
||||
if (model is not null)
|
||||
{
|
||||
model.PropChanged -= Model_PropChanged;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -58,6 +58,8 @@ public partial class ContentTreeViewModel(ITreeContent _tree, WeakReference<IPag
|
||||
IFormContent form => new ContentFormViewModel(form, context),
|
||||
IMarkdownContent markdown => new ContentMarkdownViewModel(markdown, context),
|
||||
ITreeContent tree => new ContentTreeViewModel(tree, context),
|
||||
IPlainTextContent plainText => new ContentPlainTextViewModel(plainText, context),
|
||||
IImageContent image => new ContentImageViewModel(image, context),
|
||||
_ => null,
|
||||
};
|
||||
return viewModel;
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
@@ -18,6 +18,8 @@ namespace Microsoft.CmdPal.UI.ViewModels;
|
||||
|
||||
public partial class ListViewModel : PageViewModel, IDisposable
|
||||
{
|
||||
public const int IncrementalRefresh = -2;
|
||||
|
||||
private readonly TaskFactory filterTaskFactory = new(new ConcurrentExclusiveSchedulerPair().ExclusiveScheduler);
|
||||
|
||||
private readonly Dictionary<IListItem, ListItemViewModel> _vmCache = new(new ProxyReferenceEqualityComparer());
|
||||
@@ -68,6 +70,8 @@ public partial class ListViewModel : PageViewModel, IDisposable
|
||||
|
||||
public bool IsMainPage { get; init; }
|
||||
|
||||
public bool HasCustomDebounceLogic => IsMainPage;
|
||||
|
||||
private bool _isDynamic;
|
||||
|
||||
private Task? _initializeItemsTask;
|
||||
@@ -83,6 +87,11 @@ public partial class ListViewModel : PageViewModel, IDisposable
|
||||
|
||||
private ListItemViewModel? _lastSelectedItem;
|
||||
|
||||
// Persists across cancelled FetchItems calls so a forceFirstItem=true
|
||||
// intent is never lost when FetchItems(false) is cancelled by a
|
||||
// subsequent FetchItems(true).
|
||||
private volatile bool _forceFirstItemPending;
|
||||
|
||||
// For cancelling a deferred SafeSlowInit when the user navigates rapidly
|
||||
private CancellationTokenSource? _selectedItemCts;
|
||||
|
||||
@@ -115,7 +124,7 @@ public partial class ListViewModel : PageViewModel, IDisposable
|
||||
}
|
||||
|
||||
// TODO: Does this need to hop to a _different_ thread, so that we don't block the extension while we're fetching?
|
||||
private void Model_ItemsChanged(object sender, IItemsChangedEventArgs args) => FetchItems();
|
||||
private void Model_ItemsChanged(object sender, IItemsChangedEventArgs args) => FetchItems(args.TotalItems == IncrementalRefresh);
|
||||
|
||||
protected override void OnSearchTextBoxUpdated(string searchTextBox)
|
||||
{
|
||||
@@ -191,8 +200,15 @@ public partial class ListViewModel : PageViewModel, IDisposable
|
||||
}
|
||||
|
||||
//// Run on background thread, from InitializeAsync or Model_ItemsChanged
|
||||
private void FetchItems()
|
||||
private void FetchItems(bool keepSelection)
|
||||
{
|
||||
// If this fetch should reset selection, remember that intent even if
|
||||
// a later incremental fetch cancels us.
|
||||
if (!keepSelection)
|
||||
{
|
||||
_forceFirstItemPending = true;
|
||||
}
|
||||
|
||||
// Cancel any previous FetchItems operation
|
||||
_fetchItemsCancellationTokenSource?.Cancel();
|
||||
_fetchItemsCancellationTokenSource?.Dispose();
|
||||
@@ -382,7 +398,12 @@ public partial class ListViewModel : PageViewModel, IDisposable
|
||||
UpdateEmptyContent();
|
||||
}
|
||||
|
||||
ItemsUpdated?.Invoke(this, new ItemsUpdatedEventArgs(IsRootPage));
|
||||
// Consume the pending flag on the UI thread so a
|
||||
// forceFirstItem=true intent survives cancellation.
|
||||
var forceFirst = _forceFirstItemPending;
|
||||
_forceFirstItemPending = false;
|
||||
|
||||
ItemsUpdated?.Invoke(this, new ItemsUpdatedEventArgs(forceFirstItem: IsRootPage && forceFirst));
|
||||
_isLoading.Clear();
|
||||
});
|
||||
}
|
||||
@@ -567,8 +588,12 @@ public partial class ListViewModel : PageViewModel, IDisposable
|
||||
WeakReferenceMessenger.Default.Send<HideDetailsMessage>();
|
||||
}
|
||||
|
||||
TextToSuggest = item.TextToSuggest;
|
||||
WeakReferenceMessenger.Default.Send<UpdateSuggestionMessage>(new(item.TextToSuggest));
|
||||
var suggestion = item.TextToSuggest;
|
||||
DoOnUiThread(() =>
|
||||
{
|
||||
TextToSuggest = suggestion;
|
||||
WeakReferenceMessenger.Default.Send<UpdateSuggestionMessage>(new(suggestion));
|
||||
});
|
||||
},
|
||||
ct);
|
||||
}
|
||||
@@ -657,7 +682,7 @@ public partial class ListViewModel : PageViewModel, IDisposable
|
||||
Filters?.InitializeProperties();
|
||||
UpdateProperty(nameof(Filters));
|
||||
|
||||
FetchItems();
|
||||
FetchItems(true);
|
||||
model.ItemsChanged += Model_ItemsChanged;
|
||||
}
|
||||
|
||||
|
||||
@@ -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 {
|
||||
@@ -528,6 +528,15 @@ namespace Microsoft.CmdPal.UI.ViewModels.Properties {
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Looks up a localized string similar to Commands.
|
||||
/// </summary>
|
||||
public static string home_sections_commands_title {
|
||||
get {
|
||||
return ResourceManager.GetString("home_sections_commands_title", resourceCulture);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Looks up a localized string similar to Pinned.
|
||||
/// </summary>
|
||||
@@ -536,7 +545,8 @@ namespace Microsoft.CmdPal.UI.ViewModels.Properties {
|
||||
return ResourceManager.GetString("PinnedItemSuffix", resourceCulture);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// Looks up a localized string similar to Results.
|
||||
/// </summary>
|
||||
public static string results {
|
||||
|
||||
@@ -277,13 +277,13 @@
|
||||
<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>
|
||||
<data name="ShowDetailsCommand" xml:space="preserve">
|
||||
<value>Show details</value>
|
||||
<comment>Name for the command that shows details of an item</comment>
|
||||
@@ -296,4 +296,7 @@
|
||||
<value>Results</value>
|
||||
<comment>Section title for list of all search results that doesn't fall into any other category</comment>
|
||||
</data>
|
||||
<data name="home_sections_commands_title" xml:space="preserve">
|
||||
<value>Commands</value>
|
||||
</data>
|
||||
</root>
|
||||
@@ -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;
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user