Compare commits

...

8 Commits

Author SHA1 Message Date
Niels Laute
96f89906e4 Small settings UI tweaks 2025-09-05 16:09:55 +02:00
Mike Hall
7697876174 add settle time and progress arc 2025-09-05 13:47:47 +01:00
Mike Hall
6593ec69d5 add dwell cursor functionality 2025-09-04 15:57:33 +01:00
Jiří Polášek
f760ed9d34 CmdPal: Add option to Clipboard History extension to keep item after pasting (#41444)
<!-- Enter a brief description/summary of your PR here. What does it
fix/what does it change/how was it tested (even manually, if necessary)?
-->
## Summary of the Pull Request

This PR introduces a new toggle in the Clipboard History extension
settings that allows items to remain in history after being pasted into
another application.

It also adds a separate menu item to remove items from history manually.
Item deletion is protected by a confirmation prompt, which can be
disabled in the settings.

Additionally, it introduces a shared `ConfirmableCommand` that can wrap
any command with a confirmation prompt.

Pictures? Pictures!

<img width="1541" height="981" alt="image"
src="https://github.com/user-attachments/assets/ed046f6e-f2dd-494c-b393-36add6b77346"
/>

<img width="1482" height="930" alt="image"
src="https://github.com/user-attachments/assets/fea89e55-ade0-4b6d-8fe2-d9a2b861bb49"
/>

<img width="1526" height="948" alt="image"
src="https://github.com/user-attachments/assets/a1041ce8-ae44-4b1f-8ed4-ec464580092a"
/>



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

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

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

<!-- Describe how you validated the behavior. Add automated tests
wherever possible, but list manual validation steps taken as well -->
## Validation Steps Performed
2025-09-03 15:37:38 -05:00
Jiří Polášek
7d8f64cf3c CmdPal: Update WebSearch extension history immediately (#41398)
## Summary of the Pull Request

This PR ensures that the list of recent searches in the Web Search
extension is updated immediately after a new item is added or when
settings controlling the number of items are changed.

- Refactors the Web Search extension history to keep it in memory after
being loaded at startup
- Adds an event to notify subscribers when the history changes  
- Implements `IDisposable` to ensure that `WebSearchListPage`
unsubscribes from the event
- Moves responsibility for creating all list items to single class
(`WebSearchListPage`)
- Updated unit tests
- 
## PR Checklist

- [x] Closes: #40548
- [ ] **Communication:** I've discussed this with core contributors
already. If the work hasn't been agreed, this work might be rejected
- [x] **Tests:** Added/updated and all pass
- [x] **Localization:** All end-user-facing strings can be localized
- [x] **Dev docs:** nothing
- [x] **New binaries:** none
- [x] **Documentation updated:** nope

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

<!-- Describe how you validated the behavior. Add automated tests
wherever possible, but list manual validation steps taken as well -->
## Validation Steps Performed
2025-09-03 13:47:33 -05:00
Jiří Polášek
347c3f1efa CmdPal: Enhance font icon classification and visuals (#41573)
## Summary of the Pull Request

- Introduces `FontIconGlyphClassifier` for classifying emojis and
symbols.
- Correctly recognizes multi-codepoint glyphs (e.g., 🧙🏼‍♀️ *woman mage
with medium-light skin tone*).
- Explicitly disallows multi-glyph icons (they would overflow anyway).
- Distinguishes between emojis and regular text characters (letters,
numbers, symbols), since emojis are slightly larger and require
different padding.
- Recognizes Unicode [Variation
Selectors](https://en.wikipedia.org/wiki/Variation_Selectors_(Unicode_block))
to enforce specific styles: VS15 (U+FE0E) for text style (monochrome)
and VS16 (U+FE0F) for emoji style (color). This lets developers choose
which variant to display. By default, characters with both
representations render as text/monochrome (e.g., ▶ `\u25B6`):
<img width="428" height="39" alt="image"
src="https://github.com/user-attachments/assets/c5e6865f-61de-4f45-9f3a-4e15e5e5ceb8"
/>
- Invalid icons are displayed as a dashed circle so extension developers
can spot issues, without being overly distracting if they slip into
production.

- Updates `IconPathConverter` to use the new classifier for improved
icon handling.
- Adds `SampleIconPage` to demonstrate various icon usages and
classifications.
- Adjusts icon alignment in `IconBox` so icons are centered.  
- Scales negative padding for emojis in `IconBox` with control size,
fixing misalignment and clipping (noticeable in tags and the details
pane hero image).
- Applies negative padding to all font icons. This removes the need for
classification in these cases and ensures symbols rendered below the
baseline remain visible.

Based on
[microsoft/terminal#19143](https://github.com/microsoft/terminal/pull/19143):
Co-authored-by: Dustin L. Howett <duhowett@microsoft.com>

Pictures? Pictures!

<img width="1912" height="2394" alt="image"
src="https://github.com/user-attachments/assets/05a16309-b658-4f21-8f9d-9a3f20db6ad8"
/>

Keyboard and flag/country emojis may look a bit off, but that’s how
they’re actually rendered:
<img width="482" height="95" alt="image"
src="https://github.com/user-attachments/assets/dc7d4d0d-3dc8-4df5-9b9f-9e977e7e989f"
/>


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

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

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

<!-- Describe how you validated the behavior. Add automated tests
wherever possible, but list manual validation steps taken as well -->
## Validation Steps Performed
2025-09-03 13:17:52 -05:00
Kayla Cinnamon
b71bbf89ce Fix the dedup workflow so it actually works (#41580)
<!-- Enter a brief description/summary of your PR here. What does it
fix/what does it change/how was it tested (even manually, if necessary)?
-->
## Summary of the Pull Request
Had some bad syntax in the first iteration, tested this on my fork and
it adds the "duplicate" label and comments which issues it's a dup of.
<img width="952" height="333" alt="image"
src="https://github.com/user-attachments/assets/71645d79-5fa0-4c66-a560-32a033d14dc9"
/>


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

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

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

<!-- Describe how you validated the behavior. Add automated tests
wherever possible, but list manual validation steps taken as well -->
## Validation Steps Performed
2025-09-03 17:25:43 +00:00
Jiří Polášek
caa7114e6f CmdPal: Add keyboard shortcuts to items in File Search extension (#41413)
## Summary of the Pull Request

Adds keyboard shortcuts to `IndexerListItem` commands to mirror those in
**All Apps**.

- Introduces a new `KeyChords` class that defines and manages key chords
for all actions within individual projects, improving code organization
and maintainability (similar to the `Icons` class).
- Adds a `WellKnownKeyChords` class in the shared project that defines
common shortcuts to be used consistently across the entire app.
- Updates `IndexerListItem` to include new command context items with
`RequestedShortcut` properties for:
  - Show in folder (`Ctrl+Shift+E`)  
  - Copy path (`Ctrl+Shift+C`)  
  - Open path in console (`Ctrl+Shift+R`)  
- Updates `AppListItem`, `UWPApplication`, and `Win32Program` to use the
new key chord properties from the `KeyChords` class, ensuring
consistency and maintainability.

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

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

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

<!-- Describe how you validated the behavior. Add automated tests
wherever possible, but list manual validation steps taken as well -->
## Validation Steps Performed
2025-09-03 11:30:57 -05:00
61 changed files with 3446 additions and 333 deletions

View File

@@ -306,6 +306,7 @@ CXVIRTUALSCREEN
CYSCREEN
CYSMICON
CYVIRTUALSCREEN
Czechia
cziplib
Dac
dacl
@@ -330,6 +331,7 @@ Deact
debugbreak
decryptor
Dedup
Deduplicator
Deeplink
DEFAULTBOOTSTRAPPERINSTALLFOLDER
DEFAULTCOLOR
@@ -435,6 +437,7 @@ EDITSHORTCUTS
EDITTEXT
EFile
ekus
emojis
ENABLEDELAYEDEXPANSION
ENABLEDPOPUP
ENABLETAB
@@ -799,6 +802,7 @@ KEYBOARDMANAGEREDITORLIBRARYWRAPPER
keyboardmanagerstate
keyboardmanagerui
keyboardtester
keycap
KEYEVENTF
KEYIMAGE
keynum
@@ -1780,10 +1784,13 @@ UACUI
UAL
uap
UBR
UBreak
ubrk
UCallback
ucrt
ucrtd
uefi
UError
uesc
UFlags
UHash
@@ -1853,6 +1860,7 @@ VFT
vget
vgetq
viewmodels
virama
VIRTKEY
VIRTUALDESK
VISEGRADRELAY

View File

@@ -1,20 +1,38 @@
name: Manual Batch Issue Deduplication
on:
workflow_dispatch: # Only runs when manually triggered
workflow_dispatch:
inputs:
issue_numbers:
description: "JSON array of issue numbers to deduplicate (e.g. [101,102,103])"
required: true
since:
description: "Only compare against issues created after this date (ISO 8601, e.g. 2019-05-05T00:00:00Z)"
required: false
default: "2019-05-05T00:00:00Z"
label_as_duplicate:
description: "Apply duplicate label if duplicates are found (true/false)"
required: false
default: "true"
permissions:
models: read
issues: write
jobs:
batch-deduplicate:
deduplicate:
runs-on: ubuntu-latest
strategy:
matrix:
issue: ${{ fromJson(github.event.inputs.issue_numbers) }}
steps:
- name: Batch Deduplicate Issues
- name: Checkout
uses: actions/checkout@v3
- name: Run GenAI Issue Deduplicator
uses: pelikhan/action-genai-issue-dedup@v0
with:
github-token: ${{ secrets.GITHUB_TOKEN }}
label-duplicate: "potential duplicate"
comment-duplicate: true
close-duplicate: false
batch-size: 100
since: '2019-05-05T00:00:00Z' # Process issues dating back to 2019
duplicate-comment-template: "This issue appears to be a duplicate of #{duplicate_issue_number}."
# Add other action-specific inputs if needed
github_token: ${{ secrets.GITHUB_TOKEN }}
github_issue: ${{ matrix.issue }}
label_as_duplicate: ${{ github.event.inputs.label_as_duplicate }}

View File

@@ -262,7 +262,6 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "utils", "utils", "{B39DC643
src\common\utils\EventLocker.h = src\common\utils\EventLocker.h
src\common\utils\EventWaiter.h = src\common\utils\EventWaiter.h
src\common\utils\excluded_apps.h = src\common\utils\excluded_apps.h
src\common\utils\shell_ext_registration.h = src\common\utils\shell_ext_registration.h
src\common\utils\exec.h = src\common\utils\exec.h
src\common\utils\game_mode.h = src\common\utils\game_mode.h
src\common\utils\gpo.h = src\common\utils\gpo.h
@@ -282,6 +281,7 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "utils", "utils", "{B39DC643
src\common\utils\registry.h = src\common\utils\registry.h
src\common\utils\resources.h = src\common\utils\resources.h
src\common\utils\serialized.h = src\common\utils\serialized.h
src\common\utils\shell_ext_registration.h = src\common\utils\shell_ext_registration.h
src\common\utils\string_utils.h = src\common\utils\string_utils.h
src\common\utils\timeutil.h = src\common\utils\timeutil.h
src\common\utils\UnhandledExceptionHandler.h = src\common\utils\UnhandledExceptionHandler.h
@@ -801,6 +801,8 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Microsoft.CmdPal.Ext.WebSea
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Microsoft.CmdPal.Ext.Shell.UnitTests", "src\modules\cmdpal\Tests\Microsoft.CmdPal.Ext.Shell.UnitTests\Microsoft.CmdPal.Ext.Shell.UnitTests.csproj", "{E816D7B4-4688-4ECB-97CC-3D8E798F3833}"
EndProject
Project("{8BC9CEB8-8B4A-11D0-8D11-00A0C91BC942}") = "DwellCursor", "src\modules\MouseUtils\DwellCursor\DwellCursor.vcxproj", "{6C2AD2F7-8BDF-4C5E-B3F5-7E1B7A0A8A61}"
EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|ARM64 = Debug|ARM64
@@ -2695,22 +2697,6 @@ Global
{61CBF221-9452-4934-B685-146285E080D7}.Release|ARM64.Build.0 = Release|ARM64
{61CBF221-9452-4934-B685-146285E080D7}.Release|x64.ActiveCfg = Release|x64
{61CBF221-9452-4934-B685-146285E080D7}.Release|x64.Build.0 = Release|x64
{4E0AE3A4-2EE0-44D7-A2D0-8769977254A1}.Debug|ARM64.ActiveCfg = Debug|ARM64
{4E0AE3A4-2EE0-44D7-A2D0-8769977254A1}.Debug|ARM64.Build.0 = Debug|ARM64
{4E0AE3A4-2EE0-44D7-A2D0-8769977254A1}.Debug|x64.ActiveCfg = Debug|x64
{4E0AE3A4-2EE0-44D7-A2D0-8769977254A1}.Debug|x64.Build.0 = Debug|x64
{4E0AE3A4-2EE0-44D7-A2D0-8769977254A1}.Release|ARM64.ActiveCfg = Release|ARM64
{4E0AE3A4-2EE0-44D7-A2D0-8769977254A1}.Release|ARM64.Build.0 = Release|ARM64
{4E0AE3A4-2EE0-44D7-A2D0-8769977254A1}.Release|x64.ActiveCfg = Release|x64
{4E0AE3A4-2EE0-44D7-A2D0-8769977254A1}.Release|x64.Build.0 = Release|x64
{43E779F3-D83C-48B1-BA8D-1912DBD76FC9}.Debug|ARM64.ActiveCfg = Debug|ARM64
{43E779F3-D83C-48B1-BA8D-1912DBD76FC9}.Debug|ARM64.Build.0 = Debug|ARM64
{43E779F3-D83C-48B1-BA8D-1912DBD76FC9}.Debug|x64.ActiveCfg = Debug|x64
{43E779F3-D83C-48B1-BA8D-1912DBD76FC9}.Debug|x64.Build.0 = Debug|x64
{43E779F3-D83C-48B1-BA8D-1912DBD76FC9}.Release|ARM64.ActiveCfg = Release|ARM64
{43E779F3-D83C-48B1-BA8D-1912DBD76FC9}.Release|ARM64.Build.0 = Release|ARM64
{43E779F3-D83C-48B1-BA8D-1912DBD76FC9}.Release|x64.ActiveCfg = Release|x64
{43E779F3-D83C-48B1-BA8D-1912DBD76FC9}.Release|x64.Build.0 = Release|x64
{38F187B2-6638-5A40-072F-DBE5E54070A0}.Debug|ARM64.ActiveCfg = Debug|ARM64
{38F187B2-6638-5A40-072F-DBE5E54070A0}.Debug|ARM64.Build.0 = Debug|ARM64
{38F187B2-6638-5A40-072F-DBE5E54070A0}.Debug|x64.ActiveCfg = Debug|x64
@@ -2727,14 +2713,22 @@ Global
{DA0744BC-E822-680E-9CEB-D0FBA903A8EE}.Release|ARM64.Build.0 = Release|ARM64
{DA0744BC-E822-680E-9CEB-D0FBA903A8EE}.Release|x64.ActiveCfg = Release|x64
{DA0744BC-E822-680E-9CEB-D0FBA903A8EE}.Release|x64.Build.0 = Release|x64
{0217E86E-3476-9946-DE8E-9D200CEBD47A}.Debug|ARM64.ActiveCfg = Debug|ARM64
{0217E86E-3476-9946-DE8E-9D200CEBD47A}.Debug|ARM64.Build.0 = Debug|ARM64
{0217E86E-3476-9946-DE8E-9D200CEBD47A}.Debug|x64.ActiveCfg = Debug|x64
{0217E86E-3476-9946-DE8E-9D200CEBD47A}.Debug|x64.Build.0 = Debug|x64
{0217E86E-3476-9946-DE8E-9D200CEBD47A}.Release|ARM64.ActiveCfg = Release|ARM64
{0217E86E-3476-9946-DE8E-9D200CEBD47A}.Release|ARM64.Build.0 = Release|ARM64
{0217E86E-3476-9946-DE8E-9D200CEBD47A}.Release|x64.ActiveCfg = Release|x64
{0217E86E-3476-9946-DE8E-9D200CEBD47A}.Release|x64.Build.0 = Release|x64
{4E0AE3A4-2EE0-44D7-A2D0-8769977254A1}.Debug|ARM64.ActiveCfg = Debug|ARM64
{4E0AE3A4-2EE0-44D7-A2D0-8769977254A1}.Debug|ARM64.Build.0 = Debug|ARM64
{4E0AE3A4-2EE0-44D7-A2D0-8769977254A1}.Debug|x64.ActiveCfg = Debug|x64
{4E0AE3A4-2EE0-44D7-A2D0-8769977254A1}.Debug|x64.Build.0 = Debug|x64
{4E0AE3A4-2EE0-44D7-A2D0-8769977254A1}.Release|ARM64.ActiveCfg = Release|ARM64
{4E0AE3A4-2EE0-44D7-A2D0-8769977254A1}.Release|ARM64.Build.0 = Release|ARM64
{4E0AE3A4-2EE0-44D7-A2D0-8769977254A1}.Release|x64.ActiveCfg = Release|x64
{4E0AE3A4-2EE0-44D7-A2D0-8769977254A1}.Release|x64.Build.0 = Release|x64
{43E779F3-D83C-48B1-BA8D-1912DBD76FC9}.Debug|ARM64.ActiveCfg = Debug|ARM64
{43E779F3-D83C-48B1-BA8D-1912DBD76FC9}.Debug|ARM64.Build.0 = Debug|ARM64
{43E779F3-D83C-48B1-BA8D-1912DBD76FC9}.Debug|x64.ActiveCfg = Debug|x64
{43E779F3-D83C-48B1-BA8D-1912DBD76FC9}.Debug|x64.Build.0 = Debug|x64
{43E779F3-D83C-48B1-BA8D-1912DBD76FC9}.Release|ARM64.ActiveCfg = Release|ARM64
{43E779F3-D83C-48B1-BA8D-1912DBD76FC9}.Release|ARM64.Build.0 = Release|ARM64
{43E779F3-D83C-48B1-BA8D-1912DBD76FC9}.Release|x64.ActiveCfg = Release|x64
{43E779F3-D83C-48B1-BA8D-1912DBD76FC9}.Release|x64.Build.0 = Release|x64
{2CF78CF7-8FEB-4BE1-9591-55FA25B48FC6}.Debug|ARM64.ActiveCfg = Debug|ARM64
{2CF78CF7-8FEB-4BE1-9591-55FA25B48FC6}.Debug|ARM64.Build.0 = Debug|ARM64
{2CF78CF7-8FEB-4BE1-9591-55FA25B48FC6}.Debug|x64.ActiveCfg = Debug|x64
@@ -2919,6 +2913,14 @@ Global
{E816D7B4-4688-4ECB-97CC-3D8E798F3833}.Release|ARM64.Build.0 = Release|ARM64
{E816D7B4-4688-4ECB-97CC-3D8E798F3833}.Release|x64.ActiveCfg = Release|x64
{E816D7B4-4688-4ECB-97CC-3D8E798F3833}.Release|x64.Build.0 = Release|x64
{6C2AD2F7-8BDF-4C5E-B3F5-7E1B7A0A8A61}.Debug|ARM64.ActiveCfg = Debug|ARM64
{6C2AD2F7-8BDF-4C5E-B3F5-7E1B7A0A8A61}.Debug|ARM64.Build.0 = Debug|ARM64
{6C2AD2F7-8BDF-4C5E-B3F5-7E1B7A0A8A61}.Debug|x64.ActiveCfg = Debug|x64
{6C2AD2F7-8BDF-4C5E-B3F5-7E1B7A0A8A61}.Debug|x64.Build.0 = Debug|x64
{6C2AD2F7-8BDF-4C5E-B3F5-7E1B7A0A8A61}.Release|ARM64.ActiveCfg = Release|ARM64
{6C2AD2F7-8BDF-4C5E-B3F5-7E1B7A0A8A61}.Release|ARM64.Build.0 = Release|ARM64
{6C2AD2F7-8BDF-4C5E-B3F5-7E1B7A0A8A61}.Release|x64.ActiveCfg = Release|x64
{6C2AD2F7-8BDF-4C5E-B3F5-7E1B7A0A8A61}.Release|x64.Build.0 = Release|x64
EndGlobalSection
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE
@@ -3194,10 +3196,10 @@ Global
{9BC1C986-1E97-4D07-A7B1-CE226C239EFA} = {2F305555-C296-497E-AC20-5FA1B237996A}
{99CA1509-FB73-456E-AFAF-AB89C017BD72} = {6B01F1CF-F4DB-48B5-BFE7-0BF576C1D704}
{61CBF221-9452-4934-B685-146285E080D7} = {6B01F1CF-F4DB-48B5-BFE7-0BF576C1D704}
{4E0AE3A4-2EE0-44D7-A2D0-8769977254A1} = {2C318EC3-BA86-4372-B1BC-DB0F33C208B2}
{43E779F3-D83C-48B1-BA8D-1912DBD76FC9} = {68328142-5B31-4715-BCBB-7B6345EE0971}
{38F187B2-6638-5A40-072F-DBE5E54070A0} = {1AFB6476-670D-4E80-A464-657E01DFF482}
{DA0744BC-E822-680E-9CEB-D0FBA903A8EE} = {C3081D9A-1586-441A-B5F4-ED815B3719C1}
{4E0AE3A4-2EE0-44D7-A2D0-8769977254A1} = {2C318EC3-BA86-4372-B1BC-DB0F33C208B2}
{43E779F3-D83C-48B1-BA8D-1912DBD76FC9} = {68328142-5B31-4715-BCBB-7B6345EE0971}
{2CF78CF7-8FEB-4BE1-9591-55FA25B48FC6} = {1AFB6476-670D-4E80-A464-657E01DFF482}
{14AFD976-B4D2-49D0-9E6C-AA93CC061B8A} = {1AFB6476-670D-4E80-A464-657E01DFF482}
{9D3F3793-EFE3-4525-8782-238015DABA62} = {66E1534A-1587-42B2-912F-45C994D32904}
@@ -3237,6 +3239,7 @@ Global
{E816D7B3-4688-4ECB-97CC-3D8E798F3832} = {8EF25507-2575-4ADE-BF7E-D23376903AB8}
{E816D7B2-4688-4ECB-97CC-3D8E798F3831} = {8EF25507-2575-4ADE-BF7E-D23376903AB8}
{E816D7B4-4688-4ECB-97CC-3D8E798F3833} = {8EF25507-2575-4ADE-BF7E-D23376903AB8}
{6C2AD2F7-8BDF-4C5E-B3F5-7E1B7A0A8A61} = {322566EF-20DC-43A6-B9F8-616AF942579A}
EndGlobalSection
GlobalSection(ExtensibilityGlobals) = postSolution
SolutionGuid = {C3A2F9D1-7930-4EF4-A6FC-7EE0A99821D0}

View File

@@ -0,0 +1,7 @@
#include <windows.h>
#include "resource.h"
STRINGTABLE
BEGIN
IDS_MODULE_NAME "DwellCursor"
END

View File

@@ -0,0 +1,125 @@
<?xml version="1.0" encoding="utf-8"?>
<Project DefaultTargets="Build" ToolsVersion="15.0" xmlns="http://schemas.microsoft.com/developer/msbuild/2003">
<Import Project="..\..\..\..\packages\Microsoft.Windows.CppWinRT.2.0.240111.5\build\native\Microsoft.Windows.CppWinRT.props" Condition="Exists('..\..\..\..\packages\Microsoft.Windows.CppWinRT.2.0.240111.5\build\native\Microsoft.Windows.CppWinRT.props')" />
<PropertyGroup Label="Globals">
<VCProjectVersion>15.0</VCProjectVersion>
<ProjectGuid>{6C2AD2F7-8BDF-4C5E-B3F5-7E1B7A0A8A61}</ProjectGuid>
<Keyword>Win32Proj</Keyword>
<RootNamespace>DwellCursor</RootNamespace>
<ProjectName>DwellCursor</ProjectName>
</PropertyGroup>
<Import Project="$(VCTargetsPath)\Microsoft.Cpp.Default.props" />
<Import Project="..\..\..\..\deps\spdlog.props" />
<PropertyGroup Condition="'$(Configuration)'=='Debug'" Label="Configuration">
<ConfigurationType>DynamicLibrary</ConfigurationType>
<UseDebugLibraries>true</UseDebugLibraries>
<PlatformToolset>v143</PlatformToolset>
<CharacterSet>Unicode</CharacterSet>
</PropertyGroup>
<PropertyGroup Condition="'$(Configuration)'=='Release'" Label="Configuration">
<ConfigurationType>DynamicLibrary</ConfigurationType>
<UseDebugLibraries>false</UseDebugLibraries>
<PlatformToolset>v143</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">
<Import Project="$(UserRootDir)\Microsoft.Cpp.$(Platform).user.props" Condition="exists('$(UserRootDir)\Microsoft.Cpp.$(Platform).user.props')" Label="LocalAppDataPlatform" />
</ImportGroup>
<PropertyGroup Label="UserMacros" />
<PropertyGroup>
<OutDir>..\..\..\..\$(Platform)\$(Configuration)\</OutDir>
<TargetName>PowerToys.DwellCursor</TargetName>
</PropertyGroup>
<PropertyGroup Condition="'$(Configuration)'=='Debug'">
<LinkIncremental>true</LinkIncremental>
</PropertyGroup>
<PropertyGroup Condition="'$(Configuration)'=='Release'">
<LinkIncremental>false</LinkIncremental>
</PropertyGroup>
<ItemDefinitionGroup Condition="'$(Configuration)'=='Debug'">
<ClCompile>
<WarningLevel>Level3</WarningLevel>
<Optimization>Disabled</Optimization>
<SDLCheck>true</SDLCheck>
<PreprocessorDefinitions>_DEBUG;_WINDOWS;_USRDLL;%(PreprocessorDefinitions)</PreprocessorDefinitions>
<ConformanceMode>true</ConformanceMode>
<RuntimeLibrary>MultiThreadedDebug</RuntimeLibrary>
<LanguageStandard>stdcpplatest</LanguageStandard>
</ClCompile>
<Link>
<SubSystem>Windows</SubSystem>
<GenerateDebugInformation>true</GenerateDebugInformation>
<OutputFile>$(OutDir)$(TargetName)$(TargetExt)</OutputFile>
</Link>
</ItemDefinitionGroup>
<ItemDefinitionGroup Condition="'$(Configuration)'=='Release'">
<ClCompile>
<WarningLevel>Level3</WarningLevel>
<Optimization>MaxSpeed</Optimization>
<FunctionLevelLinking>true</FunctionLevelLinking>
<IntrinsicFunctions>true</IntrinsicFunctions>
<SDLCheck>true</SDLCheck>
<PreprocessorDefinitions>NDEBUG;_WINDOWS;_USRDLL;%(PreprocessorDefinitions)</PreprocessorDefinitions>
<ConformanceMode>true</ConformanceMode>
<RuntimeLibrary>MultiThreaded</RuntimeLibrary>
<LanguageStandard>stdcpplatest</LanguageStandard>
</ClCompile>
<Link>
<SubSystem>Windows</SubSystem>
<EnableCOMDATFolding>true</EnableCOMDATFolding>
<OptimizeReferences>true</OptimizeReferences>
<GenerateDebugInformation>true</GenerateDebugInformation>
<OutputFile>$(OutDir)$(TargetName)$(TargetExt)</OutputFile>
</Link>
</ItemDefinitionGroup>
<ItemDefinitionGroup>
<ClCompile>
<AdditionalIncludeDirectories>$(SolutionDir)src\;$(SolutionDir)src\modules;$(SolutionDir)src\common\Telemetry;%(AdditionalIncludeDirectories)</AdditionalIncludeDirectories>
</ClCompile>
</ItemDefinitionGroup>
<ItemGroup>
<ClInclude Include="DwellIndicator.h" />
<ClInclude Include="pch.h" />
<ClInclude Include="trace.h" />
<ClInclude Include="resource.h" />
</ItemGroup>
<ItemGroup>
<ClCompile Include="dllmain.cpp" />
<ClCompile Include="DwellIndicator.cpp" />
<ClCompile Include="pch.cpp">
<PrecompiledHeader Condition="'$(UsePrecompiledHeaders)' != 'false'">Create</PrecompiledHeader>
</ClCompile>
<ClCompile Include="trace.cpp" />
</ItemGroup>
<ItemGroup>
<ResourceCompile Include="DwellCursor.rc" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\..\common\logger\logger.vcxproj">
<Project>{d9b8fc84-322a-4f9f-bbb9-20915c47ddfd}</Project>
</ProjectReference>
<ProjectReference Include="..\..\..\common\SettingsAPI\SettingsAPI.vcxproj">
<Project>{6955446d-23f7-4023-9bb3-8657f904af99}</Project>
</ProjectReference>
</ItemGroup>
<ItemGroup>
<None Include="packages.config" />
</ItemGroup>
<Import Project="$(VCTargetsPath)\Microsoft.Cpp.targets" />
<ImportGroup Label="ExtensionTargets">
<Import Project="..\..\..\..\packages\Microsoft.Windows.CppWinRT.2.0.240111.5\build\native\Microsoft.Windows.CppWinRT.targets" Condition="Exists('..\..\..\..\packages\Microsoft.Windows.CppWinRT.2.0.240111.5\build\native\Microsoft.Windows.CppWinRT.targets')" />
</ImportGroup>
<Target Name="EnsureNuGetPackageBuildImports" BeforeTargets="PrepareForBuild">
<PropertyGroup>
<ErrorText>This project references NuGet package(s) that are missing on this computer. Use NuGet Package Restore to download them. For more information, see http://go.microsoft.com/fwlink/?LinkID=322105. The missing file is {0}.</ErrorText>
</PropertyGroup>
<Error Condition="!Exists('..\..\..\..\packages\Microsoft.Windows.CppWinRT.2.0.240111.5\build\native\Microsoft.Windows.CppWinRT.props')" Text="$([System.String]::Format('$(ErrorText)', '..\..\..\..\packages\Microsoft.Windows.CppWinRT.2.0.240111.5\build\native\Microsoft.Windows.CppWinRT.props'))" />
<Error Condition="!Exists('..\..\..\..\packages\Microsoft.Windows.CppWinRT.2.0.240111.5\build\native\Microsoft.Windows.CppWinRT.targets')" Text="$([System.String]::Format('$(ErrorText)', '..\..\..\..\packages\Microsoft.Windows.CppWinRT.2.0.240111.5\build\native\Microsoft.Windows.CppWinRT.targets'))" />
</Target>
</Project>

View File

@@ -0,0 +1,830 @@
#include "pch.h"
#include "DwellIndicator.h"
#include <gdiplus.h>
#include <cmath>
#pragma comment(lib, "gdiplus.lib")
#pragma comment(lib, "dwmapi.lib")
using namespace Gdiplus;
/**
* @brief Implementation class for the dwell indicator using the Pimpl idiom
*
* This class handles all the visual indicator functionality:
* - Creates a transparent, topmost window at cursor position
* - Draws a circular progress arc using GDI+
* - Updates progress smoothly during countdown
* - Uses system accent color for theming
*/
class DwellIndicatorImpl
{
public:
DwellIndicatorImpl() = default;
~DwellIndicatorImpl() = default;
// Public interface methods
bool Initialize();
void Show(int x, int y);
void UpdateProgress(float progress);
void Hide();
void Cleanup();
private:
// Window management
static LRESULT CALLBACK WndProc(HWND hWnd, UINT message, WPARAM wParam, LPARAM lParam) noexcept;
bool CreateIndicatorWindow();
void DrawIndicator(HDC hdc);
float GetDpiScale() const;
// Window class and visual constants
static constexpr auto m_className = L"DwellCursorIndicator";
static constexpr auto m_windowTitle = L"PowerToys Dwell Cursor Indicator";
static constexpr float kIndicatorRadius = 20.0f; // Circle radius in pixels
static constexpr float kStrokeWidth = 3.0f; // Arc stroke width in pixels
// Window and positioning state
HWND m_hwnd = NULL; // Handle to the indicator window
HINSTANCE m_hinstance = NULL; // Module instance handle
bool m_isVisible = false; // Current visibility state
int m_currentX = 0; // Last shown X position
int m_currentY = 0; // Last shown Y position
float m_progress = 0.0f; // Current progress (0.0 to 1.0)
// GDI+ resources
ULONG_PTR m_gdiplusToken = 0; // GDI+ initialization token
friend class DwellIndicator;
};
/**
* @brief Window procedure for the indicator window
*
* Handles window messages for the transparent indicator overlay:
* - WM_PAINT: Triggers redraw of the progress arc
* - WM_NCHITTEST: Returns HTTRANSPARENT to allow mouse events to pass through
* - WM_DESTROY: Standard cleanup
*
* @param hWnd Window handle
* @param message Windows message ID
* @param wParam Message parameter
* @param lParam Message parameter
* @return Message handling result
*/
LRESULT CALLBACK DwellIndicatorImpl::WndProc(HWND hWnd, UINT message, WPARAM wParam, LPARAM lParam) noexcept
{
DwellIndicatorImpl* pThis = nullptr;
// Retrieve the instance pointer stored during window creation
if (message == WM_NCCREATE)
{
// During window creation, extract the 'this' pointer from creation params
CREATESTRUCT* pcs = reinterpret_cast<CREATESTRUCT*>(lParam);
pThis = static_cast<DwellIndicatorImpl*>(pcs->lpCreateParams);
SetWindowLongPtr(hWnd, GWLP_USERDATA, reinterpret_cast<LONG_PTR>(pThis));
}
else
{
// For all other messages, retrieve the stored 'this' pointer
pThis = reinterpret_cast<DwellIndicatorImpl*>(GetWindowLongPtr(hWnd, GWLP_USERDATA));
}
switch (message)
{
case WM_PAINT:
// Redraw the indicator - this is where our visual progress arc gets drawn
if (pThis)
{
PAINTSTRUCT ps;
HDC hdc = BeginPaint(hWnd, &ps);
pThis->DrawIndicator(hdc); // Draw the circular progress indicator
EndPaint(hWnd, &ps);
}
return 0;
case WM_NCHITTEST:
// Restore transparent mouse behavior - allow clicks to pass through
return HTTRANSPARENT;
case WM_DESTROY:
// DO NOT call PostQuitMessage(0) for overlay windows!
// This was interfering with PowerToys main message loop and causing settings menu issues
// Just let the window be destroyed normally
break;
default:
return DefWindowProc(hWnd, message, wParam, lParam);
}
return 0;
}
/**
* @brief Initialize the indicator system
*
* Sets up GDI+ graphics system and creates the indicator window.
* This must be called before any Show/Update operations.
*
* @return true if initialization successful, false on failure
*/
bool DwellIndicatorImpl::Initialize()
{
m_hinstance = GetModuleHandle(NULL);
// Initialize GDI+ graphics system for smooth drawing
GdiplusStartupInput gdiplusStartupInput;
Gdiplus::Status status = GdiplusStartup(&m_gdiplusToken, &gdiplusStartupInput, NULL);
if (status != Gdiplus::Ok)
{
// GDI+ initialization failed - no visual indicator will work
return false;
}
// Create the transparent overlay window
bool windowCreated = CreateIndicatorWindow();
if (!windowCreated)
{
// Clean up GDI+ if window creation failed
if (m_gdiplusToken != 0)
{
GdiplusShutdown(m_gdiplusToken);
m_gdiplusToken = 0;
}
}
return windowCreated;
}
/**
* @brief Create the transparent indicator window
*
* Creates a layered, transparent, topmost window that:
* - Appears above all other windows
* - Allows mouse events to pass through
* - Has no border, title bar, or decorations
* - Is positioned and sized later when shown
*
* @return true if window created successfully, false on failure
*/
bool DwellIndicatorImpl::CreateIndicatorWindow()
{
WNDCLASS wc{};
// Set DPI awareness for proper scaling on high-DPI displays
SetThreadDpiAwarenessContext(DPI_AWARENESS_CONTEXT_PER_MONITOR_AWARE_V2);
// Register window class only if not already registered
if (!GetClassInfoW(m_hinstance, m_className, &wc))
{
wc.lpfnWndProc = WndProc; // Our window procedure
wc.hInstance = m_hinstance; // Module instance
wc.hIcon = LoadIcon(m_hinstance, IDI_APPLICATION); // Default icon
wc.hCursor = LoadCursor(nullptr, IDC_ARROW); // Default cursor
wc.hbrBackground = static_cast<HBRUSH>(GetStockObject(NULL_BRUSH)); // Transparent background
wc.lpszClassName = m_className; // Class name for window
if (!RegisterClassW(&wc))
{
// Failed to register window class
DWORD error = GetLastError();
// Note: Can't use Logger here as it might not be available in all contexts
return false;
}
}
// Create window with transparency and mouse pass-through restored
DWORD exStyle = WS_EX_TRANSPARENT | WS_EX_LAYERED | WS_EX_TOOLWINDOW | WS_EX_TOPMOST;
m_hwnd = CreateWindowExW(
exStyle, // Extended window styles with transparency restored
m_className, // Window class name
m_windowTitle, // Window title (not visible)
WS_POPUP, // Window style - popup with no decorations
0, 0, 100, 100, // Initial position and size (will be adjusted in Show())
nullptr, // No parent window
nullptr, // No menu
m_hinstance, // Module instance
this); // Pass 'this' pointer for WndProc to access
if (!m_hwnd)
{
DWORD error = GetLastError();
// Note: Can't use Logger here as it might not be available in all contexts
}
else
{
OutputDebugStringA("DwellIndicator: Created transparent layered window with mouse pass-through\n");
}
return m_hwnd != nullptr;
}
/**
* @brief Show the indicator at specified cursor position
*
* Positions the window centered on the cursor location and makes it visible.
* The window size is calculated based on indicator radius and DPI scaling.
*
* @param x Cursor X coordinate in screen pixels
* @param y Cursor Y coordinate in screen pixels
*/
void DwellIndicatorImpl::Show(int x, int y)
{
// Check if window handle is valid before proceeding
if (!m_hwnd)
{
OutputDebugStringA("DwellIndicator: ERROR - Window handle is NULL, cannot show indicator\n");
return;
}
// **CRITICAL FIX: Reset progress state immediately when showing at new position**
float oldProgress = m_progress;
m_progress = 0.0f;
// Store current position for reference
m_currentX = x;
m_currentY = y;
// Calculate window size based on indicator radius and DPI scaling
const float dpiScale = GetDpiScale();
const int windowSize = static_cast<int>((kIndicatorRadius * 2 + kStrokeWidth * 2 + 10) * dpiScale);
// Calculate final window position (centered on cursor)
const int windowX = x - windowSize / 2;
const int windowY = y - windowSize / 2;
// Log detailed positioning information for debugging
char debugMsg[512];
sprintf_s(debugMsg, sizeof(debugMsg),
"DwellIndicator: SHOW - Cursor:(%d,%d) Window:(%d,%d) Size:%dx%d DPI:%.2f Progress: %.3f->0.0\n",
x, y, windowX, windowY, windowSize, windowSize, dpiScale, oldProgress);
OutputDebugStringA(debugMsg);
// **GDI+ EXPERT FIX: Use UpdateLayeredWindow for proper transparency reset**
// This is the correct way to handle layered windows with transparency
// First hide the window to ensure clean state
if (m_isVisible)
{
ShowWindow(m_hwnd, SW_HIDE);
m_isVisible = false;
}
// Position window (while hidden for clean transition)
BOOL setWindowPosResult = SetWindowPos(m_hwnd, HWND_TOPMOST,
windowX, windowY, // Calculated position
windowSize, windowSize, // Square window to contain circle
SWP_NOACTIVATE | SWP_HIDEWINDOW); // Position but keep hidden for now
if (!setWindowPosResult)
{
DWORD error = GetLastError();
sprintf_s(debugMsg, sizeof(debugMsg),
"DwellIndicator: ERROR - SetWindowPos failed with error %lu\n", error);
OutputDebugStringA(debugMsg);
}
// **GDI+ EXPERT FIX: Create clean bitmap and use UpdateLayeredWindow**
// This completely clears any previous drawing artifacts
HDC screenDC = GetDC(NULL);
HDC memoryDC = CreateCompatibleDC(screenDC);
// Create 32-bit bitmap with alpha channel for proper transparency
BITMAPINFO bmi = {};
bmi.bmiHeader.biSize = sizeof(BITMAPINFOHEADER);
bmi.bmiHeader.biWidth = windowSize;
bmi.bmiHeader.biHeight = -windowSize; // Top-down DIB
bmi.bmiHeader.biPlanes = 1;
bmi.bmiHeader.biBitCount = 32; // 32-bit with alpha
bmi.bmiHeader.biCompression = BI_RGB;
void* pvBits = nullptr;
HBITMAP hBitmap = CreateDIBSection(screenDC, &bmi, DIB_RGB_COLORS, &pvBits, NULL, 0);
if (hBitmap && memoryDC)
{
HBITMAP oldBitmap = static_cast<HBITMAP>(SelectObject(memoryDC, hBitmap));
// **CRITICAL: Clear the entire bitmap with transparent pixels**
// This ensures no artifacts from previous drawings
// Fix for C26451: Use safe arithmetic to prevent overflow
const size_t bitmapSizeBytes = static_cast<size_t>(windowSize) * static_cast<size_t>(windowSize) * 4ULL;
memset(pvBits, 0, bitmapSizeBytes); // Clear to transparent
// Create GDI+ Graphics object from memory DC
Graphics graphics(memoryDC);
graphics.SetSmoothingMode(SmoothingModeAntiAlias);
graphics.SetCompositingMode(CompositingModeSourceOver);
graphics.SetCompositingQuality(CompositingQualityHighQuality);
// **GDI+ EXPERT: Use Graphics::Clear with transparent color**
// This properly clears the alpha channel
graphics.Clear(Color(0, 0, 0, 0)); // Fully transparent
// Draw only the background circle (no progress arc yet since progress = 0.0)
const float centerX = windowSize / 2.0f;
const float centerY = windowSize / 2.0f;
const float radius = kIndicatorRadius * dpiScale;
const float strokeWidth = kStrokeWidth * dpiScale;
// Get system accent color
DWORD accentColor = 0;
BOOL isOpaque = FALSE;
Color backgroundCircleColor;
if (SUCCEEDED(DwmGetColorizationColor(&accentColor, &isOpaque)))
{
const BYTE r = (accentColor >> 16) & 0xFF;
const BYTE g = (accentColor >> 8) & 0xFF;
const BYTE b = accentColor & 0xFF;
const BYTE bgR = static_cast<BYTE>((r + 128) / 2);
const BYTE bgG = static_cast<BYTE>((g + 128) / 2);
const BYTE bgB = static_cast<BYTE>((b + 128) / 2);
backgroundCircleColor = Color(80, bgR, bgG, bgB);
}
else
{
backgroundCircleColor = Color(80, 160, 160, 160);
}
// Draw background circle
RectF ellipseRect(centerX - radius, centerY - radius, radius * 2, radius * 2);
Pen bgPen(backgroundCircleColor, strokeWidth * 0.6f);
graphics.DrawEllipse(&bgPen, ellipseRect);
// **GDI+ EXPERT: Use UpdateLayeredWindow for artifact-free display**
POINT ptSrc = {0, 0};
POINT ptDst = {windowX, windowY};
SIZE size = {windowSize, windowSize};
BLENDFUNCTION blend = {};
blend.BlendOp = AC_SRC_OVER;
blend.SourceConstantAlpha = 255;
blend.AlphaFormat = AC_SRC_ALPHA; // Use per-pixel alpha
BOOL updateResult = UpdateLayeredWindow(m_hwnd, screenDC, &ptDst, &size,
memoryDC, &ptSrc, 0, &blend, ULW_ALPHA);
// Cleanup
SelectObject(memoryDC, oldBitmap);
DeleteObject(hBitmap);
sprintf_s(debugMsg, sizeof(debugMsg),
"DwellIndicator: UpdateLayeredWindow result: %s\n",
updateResult ? "SUCCESS" : "FAILED");
OutputDebugStringA(debugMsg);
}
DeleteDC(memoryDC);
ReleaseDC(NULL, screenDC);
// Now show the window with clean, artifact-free display
ShowWindow(m_hwnd, SW_SHOWNOACTIVATE);
m_isVisible = true;
OutputDebugStringA("DwellIndicator: SHOW Complete - Clean display with no artifacts\n");
}
/**
* @brief Update the progress of the countdown indicator
*
* Updates the internal progress value and triggers a redraw.
* Progress is clamped to [0.0, 1.0] range.
*
* @param progress Progress value from 0.0 (start) to 1.0 (complete)
*/
void DwellIndicatorImpl::UpdateProgress(float progress)
{
// Clamp progress to valid range [0.0, 1.0]
if (progress < 0.0f) progress = 0.0f;
if (progress > 1.0f) progress = 1.0f;
// Log progress updates for debugging
char debugMsg[256];
sprintf_s(debugMsg, sizeof(debugMsg),
"DwellIndicator: UPDATE Progress %.3f -> %.3f - Window: %s, Visible: %s\n",
m_progress, progress,
m_hwnd ? "VALID" : "NULL",
m_isVisible ? "TRUE" : "FALSE");
OutputDebugStringA(debugMsg);
float oldProgress = m_progress;
m_progress = progress;
// **GDI+ EXPERT FIX: Use UpdateLayeredWindow for artifact-free updates**
if (m_hwnd && m_isVisible)
{
// Get window dimensions
RECT rect;
GetClientRect(m_hwnd, &rect);
int windowSize = rect.right - rect.left;
// Create memory DC and bitmap for off-screen rendering
HDC screenDC = GetDC(NULL);
HDC memoryDC = CreateCompatibleDC(screenDC);
// Create 32-bit bitmap with alpha channel
BITMAPINFO bmi = {};
bmi.bmiHeader.biSize = sizeof(BITMAPINFOHEADER);
bmi.bmiHeader.biWidth = windowSize;
bmi.bmiHeader.biHeight = -windowSize; // Top-down DIB
bmi.bmiHeader.biPlanes = 1;
bmi.bmiHeader.biBitCount = 32; // 32-bit with alpha
bmi.bmiHeader.biCompression = BI_RGB;
void* pvBits = nullptr;
HBITMAP hBitmap = CreateDIBSection(screenDC, &bmi, DIB_RGB_COLORS, &pvBits, NULL, 0);
if (hBitmap && memoryDC)
{
HBITMAP oldBitmap = static_cast<HBITMAP>(SelectObject(memoryDC, hBitmap));
// **CRITICAL: Clear entire bitmap to transparent**
const size_t bitmapSizeBytes = static_cast<size_t>(windowSize) * static_cast<size_t>(windowSize) * 4ULL;
memset(pvBits, 0, bitmapSizeBytes);
// Create GDI+ Graphics object
Graphics graphics(memoryDC);
graphics.SetSmoothingMode(SmoothingModeAntiAlias);
graphics.SetCompositingMode(CompositingModeSourceOver);
graphics.SetCompositingQuality(CompositingQualityHighQuality);
// **GDI+ EXPERT: Proper alpha channel clearing**
graphics.Clear(Color(0, 0, 0, 0));
// Calculate drawing parameters
const float dpiScale = GetDpiScale();
const float centerX = windowSize / 2.0f;
const float centerY = windowSize / 2.0f;
const float radius = kIndicatorRadius * dpiScale;
const float strokeWidth = kStrokeWidth * dpiScale;
// Get system colors
DWORD accentColor = 0;
BOOL isOpaque = FALSE;
Color progressColor;
Color backgroundCircleColor;
if (SUCCEEDED(DwmGetColorizationColor(&accentColor, &isOpaque)))
{
const BYTE a = 255;
const BYTE r = (accentColor >> 16) & 0xFF;
const BYTE g = (accentColor >> 8) & 0xFF;
const BYTE b = accentColor & 0xFF;
progressColor = Color(a, r, g, b);
const BYTE bgR = static_cast<BYTE>((r + 128) / 2);
const BYTE bgG = static_cast<BYTE>((g + 128) / 2);
const BYTE bgB = static_cast<BYTE>((b + 128) / 2);
backgroundCircleColor = Color(80, bgR, bgG, bgB);
}
else
{
progressColor = Color(255, 0, 120, 215);
backgroundCircleColor = Color(80, 160, 160, 160);
}
// Create bounding rectangle
RectF ellipseRect(centerX - radius, centerY - radius, radius * 2, radius * 2);
// Draw background circle
Pen bgPen(backgroundCircleColor, strokeWidth * 0.6f);
graphics.DrawEllipse(&bgPen, ellipseRect);
// Draw progress arc if we have progress
if (m_progress > 0.0f)
{
Pen progressPen(progressColor, strokeWidth);
progressPen.SetStartCap(LineCapRound);
progressPen.SetEndCap(LineCapRound);
const float startAngle = -90.0f; // 12 o'clock
const float sweepAngle = m_progress * 360.0f;
graphics.DrawArc(&progressPen, ellipseRect, startAngle, sweepAngle);
sprintf_s(debugMsg, sizeof(debugMsg),
"DwellIndicator: Drew arc - Progress %.3f, SweepAngle %.1f degrees\n",
m_progress, sweepAngle);
OutputDebugStringA(debugMsg);
}
// **GDI+ EXPERT: Update layered window with new content**
POINT ptSrc = {0, 0};
POINT ptDst = {m_currentX - windowSize/2, m_currentY - windowSize/2};
SIZE size = {windowSize, windowSize};
BLENDFUNCTION blend = {};
blend.BlendOp = AC_SRC_OVER;
blend.SourceConstantAlpha = 255;
blend.AlphaFormat = AC_SRC_ALPHA;
BOOL updateResult = UpdateLayeredWindow(m_hwnd, screenDC, &ptDst, &size,
memoryDC, &ptSrc, 0, &blend, ULW_ALPHA);
// Cleanup
SelectObject(memoryDC, oldBitmap);
DeleteObject(hBitmap);
sprintf_s(debugMsg, sizeof(debugMsg),
"DwellIndicator: UPDATE Complete - UpdateLayeredWindow: %s (%.3f->%.3f)\n",
updateResult ? "SUCCESS" : "FAILED", oldProgress, progress);
OutputDebugStringA(debugMsg);
}
DeleteDC(memoryDC);
ReleaseDC(NULL, screenDC);
}
else
{
OutputDebugStringA("DwellIndicator: UPDATE Skipped - window not ready or not visible\n");
}
}
/**
* @brief Draw the circular progress indicator
*
* **NOTE: This method is now primarily for fallback WM_PAINT handling**
* The main rendering is done through UpdateLayeredWindow in Show() and UpdateProgress()
* for artifact-free display on layered windows.
*
* @param hdc Device context to draw into
*/
void DwellIndicatorImpl::DrawIndicator(HDC hdc)
{
// Log drawing calls for debugging
char debugMsg[256];
sprintf_s(debugMsg, sizeof(debugMsg),
"DwellIndicator: DRAW (Fallback WM_PAINT) - Progress %.3f, Visible: %s\n",
m_progress, m_isVisible ? "TRUE" : "FALSE");
OutputDebugStringA(debugMsg);
// **GDI+ EXPERT: For WM_PAINT on layered windows, we need special handling**
// Generally, UpdateLayeredWindow bypasses WM_PAINT, but this provides fallback
// Set up GDI+ graphics object with optimal settings for layered windows
Graphics graphics(hdc);
graphics.SetSmoothingMode(SmoothingModeAntiAlias);
graphics.SetCompositingMode(CompositingModeSourceOver);
graphics.SetCompositingQuality(CompositingQualityHighQuality);
graphics.SetPixelOffsetMode(PixelOffsetModeHighQuality);
// Get window client area dimensions
RECT rect;
GetClientRect(m_hwnd, &rect);
const float centerX = (rect.right - rect.left) / 2.0f;
const float centerY = (rect.bottom - rect.top) / 2.0f;
// **GDI+ EXPERT: Proper clearing for layered windows**
// Use Graphics::Clear instead of FillRectangle for proper alpha handling
graphics.Clear(Color(0, 0, 0, 0)); // Fully transparent background
// Apply DPI scaling for high-resolution displays
const float dpiScale = GetDpiScale();
const float radius = kIndicatorRadius * dpiScale;
const float strokeWidth = kStrokeWidth * dpiScale;
// Get system accent color for theming consistency
DWORD accentColor = 0;
BOOL isOpaque = FALSE;
Color progressColor;
Color backgroundCircleColor;
if (SUCCEEDED(DwmGetColorizationColor(&accentColor, &isOpaque)))
{
// Extract RGB components from system accent color
const BYTE a = 255;
const BYTE r = (accentColor >> 16) & 0xFF;
const BYTE g = (accentColor >> 8) & 0xFF;
const BYTE b = accentColor & 0xFF;
progressColor = Color(a, r, g, b);
// Create subtle background color
const BYTE bgR = static_cast<BYTE>((r + 128) / 2);
const BYTE bgG = static_cast<BYTE>((g + 128) / 2);
const BYTE bgB = static_cast<BYTE>((b + 128) / 2);
backgroundCircleColor = Color(80, bgR, bgG, bgB);
}
else
{
// Fallback colors
progressColor = Color(255, 0, 120, 215);
backgroundCircleColor = Color(80, 160, 160, 160);
}
// Create bounding rectangle for the circle
RectF ellipseRect(
centerX - radius,
centerY - radius,
radius * 2,
radius * 2
);
// Draw background circle
Pen bgPen(backgroundCircleColor, strokeWidth * 0.6f);
graphics.DrawEllipse(&bgPen, ellipseRect);
// Draw progress arc only if we have measurable progress
if (m_progress > 0.0f)
{
Pen progressPen(progressColor, strokeWidth);
progressPen.SetStartCap(LineCapRound);
progressPen.SetEndCap(LineCapRound);
const float startAngle = -90.0f; // 12 o'clock position
const float sweepAngle = m_progress * 360.0f;
graphics.DrawArc(&progressPen, ellipseRect, startAngle, sweepAngle);
sprintf_s(debugMsg, sizeof(debugMsg),
"DwellIndicator: DRAW Arc (Fallback) - Progress %.3f, SweepAngle %.1f degrees\n",
m_progress, sweepAngle);
OutputDebugStringA(debugMsg);
}
else
{
OutputDebugStringA("DwellIndicator: DRAW (Fallback) - No arc (progress 0.0)\n");
}
}
/**
* @brief Hide the indicator window
*
* Makes the window invisible but keeps it alive for potential re-showing.
* Also resets the progress state to ensure clean restart on next show.
*/
void DwellIndicatorImpl::Hide()
{
if (m_hwnd && m_isVisible)
{
char debugMsg[256];
sprintf_s(debugMsg, sizeof(debugMsg),
"DwellIndicator: HIDE - Progress %.3f->0.0, Visible: %s->FALSE\n",
m_progress, m_isVisible ? "TRUE" : "FALSE");
OutputDebugStringA(debugMsg);
// **GDI+ EXPERT FIX: Proper layered window hiding**
// Clear the layered window content before hiding to prevent artifacts
RECT rect;
GetClientRect(m_hwnd, &rect);
int windowSize = rect.right - rect.left;
if (windowSize > 0)
{
HDC screenDC = GetDC(NULL);
HDC memoryDC = CreateCompatibleDC(screenDC);
// Create transparent bitmap
BITMAPINFO bmi = {};
bmi.bmiHeader.biSize = sizeof(BITMAPINFOHEADER);
bmi.bmiHeader.biWidth = windowSize;
bmi.bmiHeader.biHeight = -windowSize;
bmi.bmiHeader.biPlanes = 1;
bmi.bmiHeader.biBitCount = 32;
bmi.bmiHeader.biCompression = BI_RGB;
void* pvBits = nullptr;
HBITMAP hBitmap = CreateDIBSection(screenDC, &bmi, DIB_RGB_COLORS, &pvBits, NULL, 0);
if (hBitmap && memoryDC)
{
HBITMAP oldBitmap = static_cast<HBITMAP>(SelectObject(memoryDC, hBitmap));
// Clear to fully transparent
// Fix for C26451: Use safe arithmetic to prevent overflow
const size_t bitmapSizeBytes = static_cast<size_t>(windowSize) * static_cast<size_t>(windowSize) * 4ULL;
memset(pvBits, 0, bitmapSizeBytes);
// Update layered window with transparent content
POINT ptSrc = {0, 0};
POINT ptDst = {m_currentX - windowSize/2, m_currentY - windowSize/2};
SIZE size = {windowSize, windowSize};
BLENDFUNCTION blend = {};
blend.BlendOp = AC_SRC_OVER;
blend.SourceConstantAlpha = 0; // Make completely transparent
blend.AlphaFormat = AC_SRC_ALPHA;
UpdateLayeredWindow(m_hwnd, screenDC, &ptDst, &size,
memoryDC, &ptSrc, 0, &blend, ULW_ALPHA);
SelectObject(memoryDC, oldBitmap);
DeleteObject(hBitmap);
}
DeleteDC(memoryDC);
ReleaseDC(NULL, screenDC);
}
// Now hide the window
ShowWindow(m_hwnd, SW_HIDE);
m_isVisible = false;
// **CRITICAL: Reset progress when hiding to ensure clean state for next show**
m_progress = 0.0f;
OutputDebugStringA("DwellIndicator: HIDE Complete - Layered window cleared and hidden\n");
}
else
{
OutputDebugStringA("DwellIndicator: HIDE Skipped - already hidden or invalid window\n");
}
}
/**
* @brief Clean up all indicator resources
*
* Hides and destroys the window, shuts down GDI+.
* Called during module shutdown or when indicator is no longer needed.
*/
void DwellIndicatorImpl::Cleanup()
{
Hide(); // Hide window first
// Destroy the window and clean up Windows resources
if (m_hwnd)
{
DestroyWindow(m_hwnd);
m_hwnd = NULL;
}
// Shutdown GDI+ graphics system
if (m_gdiplusToken != 0)
{
GdiplusShutdown(m_gdiplusToken);
m_gdiplusToken = 0;
}
}
/**
* @brief Get DPI scaling factor for the current display
*
* @return DPI scale factor (1.0 = 96 DPI, 1.25 = 120 DPI, etc.)
*/
float DwellIndicatorImpl::GetDpiScale() const
{
if (!m_hwnd) return 1.0f; // Default scale if no window
return static_cast<float>(GetDpiForWindow(m_hwnd)) / 96.0f;
}
// ============================================================================
// DwellIndicator Public Interface Implementation
// ============================================================================
/**
* @brief Constructor - creates the implementation instance
*/
DwellIndicator::DwellIndicator() : m_impl(std::make_unique<DwellIndicatorImpl>())
{
}
/**
* @brief Destructor - ensures cleanup of resources
*/
DwellIndicator::~DwellIndicator()
{
if (m_impl)
{
m_impl->Cleanup();
}
}
/**
* @brief Initialize the indicator system
* @return true if successful, false on failure
*/
bool DwellIndicator::Initialize()
{
return m_impl ? m_impl->Initialize() : false;
}
/**
* @brief Show indicator at cursor position
* @param x Cursor X coordinate
* @param y Cursor Y coordinate
*/
void DwellIndicator::Show(int x, int y)
{
if (m_impl) m_impl->Show(x, y);
}
/**
* @brief Update countdown progress
* @param progress Progress from 0.0 to 1.0
*/
void DwellIndicator::UpdateProgress(float progress)
{
if (m_impl) m_impl->UpdateProgress(progress);
}
/**
* @brief Hide the indicator
*/
void DwellIndicator::Hide()
{
if (m_impl) m_impl->Hide();
}
/**
* @brief Clean up all resources
*/
void DwellIndicator::Cleanup()
{
if (m_impl) m_impl->Cleanup();
}

View File

@@ -0,0 +1,125 @@
/**
* @file DwellIndicator.h
* @brief Visual countdown indicator for DwellCursor module
*
* This header defines the interface for the visual feedback system that shows
* users when a dwell click is about to occur. The indicator appears as a
* circular progress arc that fills clockwise during the countdown period.
*
* Key Features:
* - Transparent overlay window that doesn't interfere with normal interaction
* - System accent color theming for consistency with Windows
* - DPI-aware rendering for high-resolution displays
* - Smooth progress animation updated at 30 FPS
* - Automatic positioning centered on cursor location
*/
#pragma once
#include <memory>
// Forward declaration to hide implementation details (Pimpl idiom)
class DwellIndicatorImpl;
/**
* @brief Visual countdown indicator for dwell cursor functionality
*
* This class provides a clean interface for showing a circular progress
* indicator during dwell cursor countdown. It uses the Pimpl (Pointer to
* Implementation) idiom to hide all Windows/GDI+ dependencies from the header.
*
* Usage Pattern:
* 1. Create instance: DwellIndicator indicator;
* 2. Initialize: indicator.Initialize();
* 3. Show at cursor: indicator.Show(x, y);
* 4. Update progress: indicator.UpdateProgress(0.5f); // 50% complete
* 5. Hide when done: indicator.Hide();
* 6. Cleanup: indicator.Cleanup(); // or let destructor handle it
*
* Thread Safety:
* - All methods must be called from the same thread (UI thread)
* - Progress updates can be called frequently (30ms intervals recommended)
* - Hide/Show calls are safe to call multiple times
*/
class DwellIndicator
{
public:
/**
* @brief Constructor - creates implementation instance
*
* Note: This only creates the object, call Initialize() before use.
*/
DwellIndicator();
/**
* @brief Destructor - ensures proper cleanup
*
* Automatically calls Cleanup() if not already called.
*/
~DwellIndicator();
/**
* @brief Initialize the indicator system
*
* Must be called before any other operations. Sets up:
* - GDI+ graphics system
* - Transparent overlay window
* - DPI awareness
*
* @return true if initialization successful, false on failure
*/
bool Initialize();
/**
* @brief Show the indicator at specified screen coordinates
*
* Displays the circular indicator centered on the given position.
* If already visible, moves to new position. Window is sized
* automatically based on DPI and indicator radius.
*
* @param x Screen X coordinate in pixels
* @param y Screen Y coordinate in pixels
*/
void Show(int x, int y);
/**
* @brief Update the countdown progress
*
* Updates the progress arc to show how much of the dwell delay
* has elapsed. Can be called frequently for smooth animation.
*
* @param progress Progress value from 0.0 (start) to 1.0 (complete)
* Values outside this range are automatically clamped
*/
void UpdateProgress(float progress);
/**
* @brief Hide the indicator
*
* Makes the indicator invisible but keeps resources allocated
* for potential re-showing. Safe to call multiple times.
*/
void Hide();
/**
* @brief Clean up all resources
*
* Destroys the window, shuts down GDI+, releases all resources.
* Called automatically by destructor if not called explicitly.
* After calling this, Initialize() must be called again before reuse.
*/
void Cleanup();
// Disable copy constructor and assignment operator
// The indicator manages Windows resources that shouldn't be copied
DwellIndicator(const DwellIndicator&) = delete;
DwellIndicator& operator=(const DwellIndicator&) = delete;
private:
/**
* @brief Pointer to implementation (Pimpl idiom)
*
* This hides all Windows/GDI+ implementation details from the header,
* reducing compile dependencies and keeping the interface clean.
*/
std::unique_ptr<DwellIndicatorImpl> m_impl;
};

View File

@@ -0,0 +1,595 @@
#include "pch.h"
#include <common/SettingsAPI/settings_objects.h>
#include <interface/powertoy_module_interface.h>
#include "trace.h"
#include <atomic>
#include <thread>
#include <common/utils/logger_helper.h>
#include "DwellIndicator.h"
extern "C" IMAGE_DOS_HEADER __ImageBase;
namespace
{
// JSON configuration keys for settings persistence
const wchar_t JSON_KEY_PROPERTIES[] = L"properties";
const wchar_t JSON_KEY_VALUE[] = L"value";
const wchar_t JSON_KEY_ACTIVATION_SHORTCUT[] = L"activation_shortcut";
const wchar_t JSON_KEY_DELAY_TIME_MS[] = L"delay_time_ms";
const wchar_t JSON_KEY_SETTLE_TIME_SECONDS[] = L"settle_time_seconds";
// Update interval for the visual indicator (in milliseconds)
// 30ms gives ~33 FPS for smooth animation without excessive CPU usage
constexpr DWORD kIndicatorUpdateIntervalMs = 30;
}
/**
* @brief Send a left mouse click via Windows input system
*
* Simulates a complete left click (down + up) at the current cursor position.
* This is the core functionality that gets triggered after the dwell delay.
*/
static void SendLeftClick()
{
INPUT inputs[2]{};
// First input: Left mouse button down
inputs[0].type = INPUT_MOUSE;
inputs[0].mi.dwFlags = MOUSEEVENTF_LEFTDOWN;
// Second input: Left mouse button up
inputs[1].type = INPUT_MOUSE;
inputs[1].mi.dwFlags = MOUSEEVENTF_LEFTUP;
// Send both inputs to simulate a complete click
SendInput(2, inputs, sizeof(INPUT));
}
/**
* @brief Main DwellCursor PowerToy module implementation
*
* This class implements the dwell cursor functionality:
* - Monitors mouse movement continuously
* - Detects when mouse becomes stationary
* - Shows visual countdown indicator
* - Triggers left click after configured delay
* - Provides hotkey toggle for enable/disable
*
* State Management:
* - m_enabled: Whether module is active (controlled by PowerToys settings)
* - m_armed: Whether dwell clicking is currently armed (toggled by hotkey)
* - firedForThisStationary: Prevents multiple clicks during one stationary period
*/
class DwellCursorModule : public PowertoyModuleIface
{
private:
// Core module state - SINGLE DECLARATIONS ONLY
bool m_enabled{ false }; // Module enabled/disabled state
Hotkey m_activationHotkey{}; // Hotkey for toggling armed state
// Configuration settings - SINGLE DECLARATIONS ONLY
std::atomic<int> m_delayMs{ 1000 }; // Dwell delay in milliseconds (500-10000ms)
std::atomic<int> m_settleTimeSeconds{ 1 }; // Settle time in seconds (1-5s)
// Runtime state management - SINGLE DECLARATIONS ONLY
std::atomic<bool> m_armed{ true }; // Whether dwell clicking is armed
std::atomic<bool> m_stop{ false }; // Signal to stop the worker thread
std::thread m_worker; // Background thread for mouse monitoring
// Visual feedback system
std::unique_ptr<DwellIndicator> m_indicator;
// Progress tracking - Use member variable instead of static
float m_lastProgress{ -1.0f }; // Last progress value for change detection
// Mouse movement sensitivity (pixels) - SINGLE DECLARATION ONLY
// Movement within this threshold is considered "stationary"
static constexpr int kMoveThresholdPx = 5;
public:
/**
* @brief Constructor - Initialize the DwellCursor module
*
* Sets up logging, loads settings, configures default hotkey,
* and creates the visual indicator instance.
*/
DwellCursorModule()
{
// Initialize logging system for debugging and telemetry
LoggerHelpers::init_logger(L"DwellCursor", L"ModuleInterface", "dwell-cursor");
Logger::trace(L"DwellCursor: Constructor called");
// Load saved settings from PowerToys configuration
init_settings();
// Set default hotkey if not configured: Win+Alt+D
if (m_activationHotkey.key == 0)
{
m_activationHotkey.win = true; // Windows key required
m_activationHotkey.alt = true; // Alt key required
m_activationHotkey.key = 'D'; // D key
}
// Create visual indicator instance (but don't initialize yet)
m_indicator = std::make_unique<DwellIndicator>();
Logger::trace(L"DwellCursor: Constructor completed");
}
/**
* @brief Destructor cleanup
*/
virtual void destroy() override
{
disable(); // Stop all activity
delete this;
}
// PowerToy identification methods
virtual const wchar_t* get_name() override { return L"DwellCursor"; }
virtual const wchar_t* get_key() override { return L"DwellCursor"; }
/**
* @brief Get module configuration for PowerToys settings UI
*/
virtual bool get_config(wchar_t* buffer, int* buffer_size) override
{
HINSTANCE hinstance = reinterpret_cast<HINSTANCE>(&__ImageBase);
PowerToysSettings::Settings settings(hinstance, get_name());
return settings.serialize_to_buffer(buffer, buffer_size);
}
/**
* @brief Apply new configuration from PowerToys settings UI
*/
virtual void set_config(const wchar_t* config) override
{
try
{
PowerToysSettings::PowerToyValues values = PowerToysSettings::PowerToyValues::from_json_string(config, get_key());
parse_settings(values);
}
catch (...)
{
// Ignore configuration errors to prevent crashes
}
}
virtual void call_custom_action(const wchar_t* /*action*/) override {}
/**
* @brief Enable the DwellCursor module
*
* This is called when:
* 1. PowerToys starts up (if module is enabled in settings)
* 2. User enables the module via PowerToys settings UI
*
* Actions performed:
* 1. Initialize visual indicator system
* 2. Start background mouse monitoring thread
* 3. Begin dwell detection
*/
virtual void enable() override
{
if (m_enabled)
{
Logger::trace(L"DwellCursor: Already enabled");
return;
}
Logger::trace(L"DwellCursor: Enabling module");
m_enabled = true;
m_stop = false;
// Initialize the visual indicator system (GDI+, window creation)
if (m_indicator)
{
if (!m_indicator->Initialize())
{
Logger::trace(L"DwellCursor: Failed to initialize visual indicator");
// Continue without visual indicator - core functionality still works
}
else
{
Logger::trace(L"DwellCursor: Visual indicator initialized successfully");
}
}
else
{
Logger::trace(L"DwellCursor: No indicator instance available");
}
// Start the mouse monitoring thread
m_worker = std::thread([this]() { this->RunLoop(); });
Logger::trace(L"DwellCursor: Module enabled and worker thread started");
}
/**
* @brief Disable the DwellCursor module
*
* This is called when:
* 1. PowerToys shuts down
* 2. User disables the module via PowerToys settings UI
*
* Actions performed:
* 1. Stop mouse monitoring thread
* 2. Hide any visible indicator
* 3. Clean up visual indicator resources
*/
virtual void disable() override
{
if (!m_enabled)
{
Logger::trace(L"DwellCursor: Already disabled");
return;
}
Logger::trace(L"DwellCursor: Disabling module");
m_enabled = false;
m_stop = true;
// Wait for worker thread to finish
if (m_worker.joinable()) m_worker.join();
// Clean up visual indicator resources
if (m_indicator)
{
m_indicator->Cleanup();
}
Logger::trace(L"DwellCursor: Module disabled");
}
virtual bool is_enabled() override { return m_enabled; }
virtual bool is_enabled_by_default() const override { return false; } // User must explicitly enable
/**
* @brief Report hotkeys to PowerToys for registration
*/
virtual size_t get_hotkeys(Hotkey* buffer, size_t buffer_size) override
{
if (buffer && buffer_size >= 1)
{
buffer[0] = m_activationHotkey;
}
return 1; // We have exactly one hotkey
}
/**
* @brief Handle hotkey press events
*
* The hotkey toggles the "armed" state:
* - Armed: Dwell clicking is active, countdown indicator shows
* - Disarmed: No dwell clicking, indicator hidden
*
* This allows users to temporarily disable dwell clicking without
* going into settings (e.g., when typing or doing precise work).
*
* @param hotkeyId Index of the pressed hotkey (we only have one)
* @return true if handled, false otherwise
*/
virtual bool on_hotkey(size_t hotkeyId) override
{
// Handle our single registered hotkey
if (hotkeyId == 0)
{
// Toggle armed state (enabled/disabled functionality)
m_armed = !m_armed.load();
// Hide indicator immediately when disarming or when module disabled
if ((!m_armed || !m_enabled) && m_indicator)
{
m_indicator->Hide();
}
Logger::trace(L"DwellCursor: Hotkey pressed, armed={}, enabled={}", m_armed.load(), m_enabled);
return true;
}
return false;
}
private:
/**
* @brief Load settings from PowerToys configuration files
*/
void init_settings()
{
try
{
PowerToysSettings::PowerToyValues settings = PowerToysSettings::PowerToyValues::load_from_settings_file(get_key());
parse_settings(settings);
}
catch (...)
{
// Use default settings if loading fails
}
}
/**
* @brief Parse and apply settings from JSON configuration
*
* Extracts:
* - Activation hotkey configuration
* - Dwell delay time (with validation)
*/
void parse_settings(PowerToysSettings::PowerToyValues& settings)
{
auto obj = settings.get_raw_json();
if (!obj.GetView().Size()) return;
// Parse hotkey configuration
try
{
auto jsonHotkey = obj.GetNamedObject(JSON_KEY_PROPERTIES).GetNamedObject(JSON_KEY_ACTIVATION_SHORTCUT);
auto hk = PowerToysSettings::HotkeyObject::from_json(jsonHotkey);
m_activationHotkey = {};
m_activationHotkey.win = hk.win_pressed();
m_activationHotkey.ctrl = hk.ctrl_pressed();
m_activationHotkey.shift = hk.shift_pressed();
m_activationHotkey.alt = hk.alt_pressed();
m_activationHotkey.key = static_cast<unsigned char>(hk.get_code());
}
catch (...)
{
// Keep default hotkey if parsing fails
}
// Parse dwell delay setting
try
{
auto jsonDelay = obj.GetNamedObject(JSON_KEY_PROPERTIES).GetNamedObject(JSON_KEY_DELAY_TIME_MS);
int v = static_cast<int>(jsonDelay.GetNamedNumber(JSON_KEY_VALUE));
// Validate delay range: 0.5 seconds to 10 seconds
if (v < 500) v = 500; // Minimum 0.5 seconds
if (v > 10000) v = 10000; // Maximum 10 seconds
m_delayMs = v;
}
catch (...)
{
// Keep default delay if parsing fails
}
// Parse settle time setting
try
{
auto jsonSettleTime = obj.GetNamedObject(JSON_KEY_PROPERTIES).GetNamedObject(JSON_KEY_SETTLE_TIME_SECONDS);
int v = static_cast<int>(jsonSettleTime.GetNamedNumber(JSON_KEY_VALUE));
// Validate settle time range: 1 to 5 seconds
if (v < 1) v = 1; // Minimum 1 second
if (v > 5) v = 5; // Maximum 5 seconds
m_settleTimeSeconds = v;
}
catch (...)
{
// Keep default settle time if parsing fails
}
}
/**
* @brief Check if two points are within movement threshold
*
* @param a First coordinate
* @param b Second coordinate
* @param thr Threshold in pixels
* @return true if coordinates are within threshold (considered "near")
*/
static bool Near(int a, int b, int thr) { return (abs(a - b) <= thr); }
/**
* @brief Main mouse monitoring loop (runs in background thread)
*
* This is the core logic that runs continuously while the module is enabled:
*
* State Machine:
* 1. Monitor mouse position every 50ms (20 Hz)
* 2. If mouse moves > threshold: Reset timer, hide indicator
* 3. If mouse stationary for SETTLE_TIME: Show indicator
* 4. If mouse stationary for SETTLE_TIME + dwell delay: Send click
*
* CRITICAL: This method handles ALL progress reset logic
*/
void RunLoop()
{
constexpr DWORD ACTIVE_POLL_INTERVAL = 50; // 50ms = 20 Hz monitoring when active
constexpr DWORD INACTIVE_POLL_INTERVAL = 200; // 200ms when disabled
// Initialize tracking variables
POINT last{}; // Last recorded mouse position
GetCursorPos(&last); // Get initial position
DWORD lastMove = GetTickCount(); // Time of last movement
bool firedForThisStationary = false; // Prevents multiple clicks during one stationary period
bool indicatorShown = false; // Current indicator visibility state
DWORD lastIndicatorUpdate = 0; // Last time we updated indicator progress
Logger::trace(L"DwellCursor: RunLoop started with 50ms polling and {}s configurable settle time, enabled={}, armed={}",
m_settleTimeSeconds.load(), m_enabled, m_armed.load());
// Main monitoring loop - continues until module shutdown
while (!m_stop)
{
// Performance optimization: When module disabled, sleep longer and skip processing
if (!m_enabled)
{
// Hide any visible indicator when disabled
if (indicatorShown && m_indicator)
{
m_indicator->Hide();
indicatorShown = false;
m_lastProgress = -1.0f; // Reset progress tracking
}
Sleep(INACTIVE_POLL_INTERVAL); // Sleep 200ms when disabled
continue;
}
// Get current mouse position and time
POINT p{};
GetCursorPos(&p);
DWORD currentTime = GetTickCount();
// Check if mouse has moved beyond our threshold
if (!Near(p.x, last.x, kMoveThresholdPx) || !Near(p.y, last.y, kMoveThresholdPx))
{
// MOUSE MOVEMENT DETECTED - RESET ALL STATE
Logger::trace(L"DwellCursor: Mouse movement detected, resetting state");
// Update tracking variables
last = p; // Record new position
lastMove = currentTime; // Record movement time
firedForThisStationary = false; // Re-arm for next stationary period
// CRITICAL: Hide indicator and reset progress immediately on movement
if (indicatorShown && m_indicator)
{
m_indicator->Hide();
indicatorShown = false;
}
// Reset progress tracking for next stationary period
m_lastProgress = -1.0f;
}
else
{
// MOUSE IS STATIONARY - Process dwell logic
// Check if we should process dwell logic
if (m_enabled && m_armed && !firedForThisStationary)
{
// Calculate how long mouse has been stationary
DWORD elapsed = currentTime - lastMove;
DWORD delayMs = static_cast<DWORD>(m_delayMs.load());
DWORD settleTimeMs = static_cast<DWORD>(m_settleTimeSeconds.load() * 1000); // Convert seconds to milliseconds
DWORD totalTimeRequired = settleTimeMs + delayMs; // Settle time + dwell delay
if (elapsed >= totalTimeRequired)
{
// SETTLE TIME + DWELL DELAY COMPLETED - TRIGGER CLICK
Logger::trace(L"DwellCursor: Triggering click after {}ms total ({}ms settle + {}ms dwell)", elapsed, settleTimeMs, delayMs);
// Hide indicator before clicking
if (indicatorShown && m_indicator)
{
m_indicator->Hide();
indicatorShown = false;
}
// Reset progress tracking
m_lastProgress = -1.0f;
SendLeftClick(); // Send the mouse click
firedForThisStationary = true; // Prevent additional clicks
}
else if (elapsed >= settleTimeMs)
{
// SETTLE TIME COMPLETED - START/UPDATE COUNTDOWN INDICATOR
DWORD dwellElapsed = elapsed - settleTimeMs; // Time since settle completed
// Show indicator if not already visible
if (!indicatorShown && m_indicator)
{
Logger::trace(L"DwellCursor: Settle time ({}ms) completed, showing NEW indicator at ({}, {}) - dwellElapsed={}ms, delayMs={}",
settleTimeMs, p.x, p.y, dwellElapsed, delayMs);
// CRITICAL: Force complete reset before showing
m_lastProgress = -1.0f;
m_indicator->Show(p.x, p.y); // This internally resets indicator progress to 0.0
indicatorShown = true;
lastIndicatorUpdate = currentTime; // Reset update timer when showing
}
// Update indicator progress ONLY at throttled intervals
if (indicatorShown && (currentTime - lastIndicatorUpdate >= ACTIVE_POLL_INTERVAL))
{
// Calculate progress as percentage: 0.0 = just started, 1.0 = almost complete
float newProgress = static_cast<float>(dwellElapsed) / static_cast<float>(delayMs);
if (newProgress > 1.0f) newProgress = 1.0f; // Clamp to prevent over-draw
// Only update if progress changed significantly (at least 3% or 0.03) OR forced reset
if (abs(newProgress - m_lastProgress) >= 0.03f || m_lastProgress < 0.0f)
{
Logger::trace(L"DwellCursor: Updating progress from {:.2f} to {:.2f} (dwellElapsed={}ms)",
m_lastProgress, newProgress, dwellElapsed);
if (m_indicator)
{
m_indicator->UpdateProgress(newProgress);
}
m_lastProgress = newProgress;
lastIndicatorUpdate = currentTime;
}
}
}
// else: Still in settle time, do nothing (no indicator shown)
}
else if (indicatorShown && m_indicator)
{
// STATIONARY BUT CONDITIONS NOT MET - HIDE INDICATOR
// This happens when: not enabled, not armed, or already fired
Logger::trace(L"DwellCursor: Hiding indicator - conditions not met (enabled={}, armed={}, fired={})",
m_enabled, m_armed.load(), firedForThisStationary);
m_indicator->Hide();
indicatorShown = false;
// Reset progress tracking
m_lastProgress = -1.0f;
}
}
// Sleep appropriate interval based on activity (20 Hz when active)
Sleep(ACTIVE_POLL_INTERVAL);
}
// THREAD SHUTDOWN CLEANUP
Logger::trace(L"DwellCursor: RunLoop shutdown - cleaning up");
// Hide indicator when stopping
if (indicatorShown && m_indicator)
{
m_indicator->Hide();
}
// Final progress reset
m_lastProgress = -1.0f;
Logger::trace(L"DwellCursor: RunLoop ended");
}
};
// ============================================================================
// DLL Entry Points
// ============================================================================
/**
* @brief DLL entry point for Windows module loading
*/
BOOL APIENTRY DllMain(HMODULE /*hModule*/, DWORD ul_reason_for_call, LPVOID /*lpReserved*/)
{
switch (ul_reason_for_call)
{
case DLL_PROCESS_ATTACH:
Trace::RegisterProvider(); // Initialize ETW tracing
break;
case DLL_PROCESS_DETACH:
Trace::UnregisterProvider(); // Cleanup ETW tracing
break;
}
return TRUE;
}
/**
* @brief PowerToys module factory function
*
* This is the entry point called by PowerToys runner to create an instance
* of our module. The runner will call this once during startup.
*
* @return New instance of DwellCursorModule
*/
extern "C" __declspec(dllexport) PowertoyModuleIface* __cdecl powertoy_create()
{
return new DwellCursorModule();
}

View File

@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<packages>
<package id="Microsoft.Windows.CppWinRT" version="2.0.240111.5" targetFramework="native" />
</packages>

View File

@@ -0,0 +1 @@
#include "pch.h"

View File

@@ -0,0 +1,20 @@
#pragma once
#define NOMINMAX
#define WIN32_LEAN_AND_MEAN
#include <windows.h>
#include <windowsx.h>
#include <ShellScalingApi.h>
#include <dwmapi.h>
#include <stdint.h>
#include <atomic>
#include <thread>
#include <chrono>
#include <optional>
#include <string>
#include <memory>
#include <algorithm>
#include <cmath>
#include <common/SettingsAPI/settings_objects.h>
#include <interface/powertoy_module_interface.h>
#include <common/logger/logger.h>
#include <common/utils/logger_helper.h>

View File

@@ -0,0 +1,2 @@
#pragma once
#define IDS_MODULE_NAME 1001

View File

@@ -0,0 +1,2 @@
#include "pch.h"
#include "trace.h"

View File

@@ -0,0 +1,2 @@
#pragma once
namespace Trace { inline void RegisterProvider(){} inline void UnregisterProvider(){} }

View File

@@ -0,0 +1,155 @@
// 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.Diagnostics.CodeAnalysis;
using Microsoft.CommandPalette.Extensions;
using Microsoft.CommandPalette.Extensions.Toolkit;
namespace Microsoft.CmdPal.Common.Commands;
public sealed partial class ConfirmableCommand : InvokableCommand
{
private readonly IInvokableCommand? _command;
public Func<bool>? IsConfirmationRequired { get; init; }
public required string ConfirmationTitle { get; init; }
public required string ConfirmationMessage { get; init; }
public required IInvokableCommand Command
{
get => _command!;
init
{
if (_command is INotifyPropChanged oldNotifier)
{
oldNotifier.PropChanged -= InnerCommand_PropChanged;
}
_command = value;
if (_command is INotifyPropChanged notifier)
{
notifier.PropChanged += InnerCommand_PropChanged;
}
OnPropertyChanged(nameof(Name));
OnPropertyChanged(nameof(Id));
OnPropertyChanged(nameof(Icon));
}
}
public override string Name
{
get => (_command as Command)?.Name ?? base.Name;
set
{
if (_command is Command cmd)
{
cmd.Name = value;
}
else
{
base.Name = value;
}
}
}
public override string Id
{
get => (_command as Command)?.Id ?? base.Id;
set
{
var previous = Id;
if (_command is Command cmd)
{
cmd.Id = value;
}
else
{
base.Id = value;
}
if (previous != Id)
{
OnPropertyChanged(nameof(Id));
}
}
}
public override IconInfo Icon
{
get => (_command as Command)?.Icon ?? base.Icon;
set
{
if (_command is Command cmd)
{
cmd.Icon = value;
}
else
{
base.Icon = value;
}
}
}
public ConfirmableCommand()
{
// Allow init-only construction
}
[SetsRequiredMembers]
public ConfirmableCommand(IInvokableCommand command, string confirmationTitle, string confirmationMessage, Func<bool>? isConfirmationRequired = null)
{
ArgumentNullException.ThrowIfNull(command);
ArgumentException.ThrowIfNullOrWhiteSpace(confirmationMessage);
ArgumentNullException.ThrowIfNull(confirmationMessage);
IsConfirmationRequired = isConfirmationRequired;
ConfirmationTitle = confirmationTitle;
ConfirmationMessage = confirmationMessage;
Command = command;
}
private void InnerCommand_PropChanged(object sender, IPropChangedEventArgs args)
{
var property = args.PropertyName;
if (string.IsNullOrEmpty(property) || property == nameof(Name))
{
OnPropertyChanged(nameof(Name));
}
if (string.IsNullOrEmpty(property) || property == nameof(Id))
{
OnPropertyChanged(nameof(Id));
}
if (string.IsNullOrEmpty(property) || property == nameof(Icon))
{
OnPropertyChanged(nameof(Icon));
}
}
public override ICommandResult Invoke()
{
var showConfirmationDialog = IsConfirmationRequired?.Invoke() ?? true;
if (showConfirmationDialog)
{
return CommandResult.Confirm(new ConfirmationArgs
{
Title = ConfirmationTitle,
Description = ConfirmationMessage,
PrimaryCommand = Command,
IsPrimaryCommandCritical = true,
});
}
else
{
return Command.Invoke(this) ?? CommandResult.Dismiss();
}
}
}

View File

@@ -0,0 +1,51 @@
// Copyright (c) Microsoft Corporation
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
using Microsoft.CommandPalette.Extensions;
using Microsoft.CommandPalette.Extensions.Toolkit;
using Windows.System;
namespace Microsoft.CmdPal.Common.Helpers;
/// <summary>
/// Well-known key chords used in the Command Palette and extensions.
/// </summary>
/// <remarks>
/// Assigned key chords should not conflict with system or application shortcuts.
/// However, the key chords in this class are not guaranteed to be unique and may conflict
/// with each other, especially when commands appear together in the same menu.
/// </remarks>
public static class WellKnownKeyChords
{
/// <summary>
/// Gets the well-known key chord for opening the file location. Shortcut: Ctrl+Shift+E.
/// </summary>
public static KeyChord OpenFileLocation { get; } = KeyChordHelpers.FromModifiers(ctrl: true, shift: true, vkey: (int)VirtualKey.E);
/// <summary>
/// Gets the well-known key chord for copying the file path. Shortcut: Ctrl+Shift+C.
/// </summary>
public static KeyChord CopyFilePath { get; } = KeyChordHelpers.FromModifiers(ctrl: true, shift: true, vkey: (int)VirtualKey.C);
/// <summary>
/// Gets the well-known key chord for opening the current location in a console. Shortcut: Ctrl+Shift+R.
/// </summary>
public static KeyChord OpenInConsole { get; } = KeyChordHelpers.FromModifiers(ctrl: true, shift: true, vkey: (int)VirtualKey.R);
/// <summary>
/// Gets the well-known key chord for running the selected item as administrator. Shortcut: Ctrl+Shift+Enter.
/// </summary>
public static KeyChord RunAsAdministrator { get; } = KeyChordHelpers.FromModifiers(ctrl: true, shift: true, vkey: (int)VirtualKey.Enter);
/// <summary>
/// Gets the well-known key chord for running the selected item as a different user. Shortcut: Ctrl+Shift+U.
/// </summary>
public static KeyChord RunAsDifferentUser { get; } = KeyChordHelpers.FromModifiers(ctrl: true, shift: true, vkey: (int)VirtualKey.U);
/// <summary>
/// Gets the well-known key chord for toggling the pin state. Shortcut: Ctrl+P.
/// </summary>
public static KeyChord TogglePin { get; } = KeyChordHelpers.FromModifiers(ctrl: true, vkey: (int)VirtualKey.P);
}

View File

@@ -2,8 +2,10 @@
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
using ManagedCommon;
using Microsoft.CmdPal.Core.ViewModels;
using Microsoft.CmdPal.UI.Deferred;
using Microsoft.Terminal.UI;
using Microsoft.UI.Dispatching;
using Microsoft.UI.Xaml;
using Microsoft.UI.Xaml.Controls;
@@ -55,6 +57,8 @@ public partial class IconBox : ContentControl
{
TabFocusNavigation = KeyboardNavigationMode.Once;
IsTabStop = false;
HorizontalContentAlignment = HorizontalAlignment.Center;
VerticalContentAlignment = VerticalAlignment.Center;
}
private static void OnSourcePropertyChanged(DependencyObject d, DependencyPropertyChangedEventArgs e)
@@ -75,6 +79,8 @@ public partial class IconBox : ContentControl
IconSourceElement elem = new()
{
IconSource = fontIco,
HorizontalAlignment = HorizontalAlignment.Center,
VerticalAlignment = VerticalAlignment.Center,
};
@this.Content = elem;
break;
@@ -98,14 +104,20 @@ public partial class IconBox : ContentControl
else
{
// TODO GH #239 switch back when using the new MD text block
// Switching back to EnqueueAsync has broken icons in tags (they don't show)
// _ = @this._queue.EnqueueAsync(() =>
@this._queue.TryEnqueue(new(async () =>
@this._queue.TryEnqueue(async void () =>
{
var requestedTheme = @this.ActualTheme;
var eventArgs = new SourceRequestedEventArgs(e.NewValue, requestedTheme);
if (@this.SourceRequested is not null)
try
{
if (@this.SourceRequested is null)
{
return;
}
var requestedTheme = @this.ActualTheme;
var eventArgs = new SourceRequestedEventArgs(e.NewValue, requestedTheme);
await @this.SourceRequested.InvokeAsync(@this, eventArgs);
// After the await:
@@ -130,37 +142,35 @@ public partial class IconBox : ContentControl
// So, if the icon we get back was a font icon,
// and the glyph for that icon is NOT in the range of
// Segoe icons, then let's give the icon some extra space
@this.Padding = new Thickness(0);
IconDataViewModel? iconData = null;
if (eventArgs.Key is IconDataViewModel)
var iconData = eventArgs.Key switch
{
iconData = eventArgs.Key as IconDataViewModel;
IconDataViewModel key => key,
IconInfoViewModel info => requestedTheme == ElementTheme.Light ? info.Light : info.Dark,
_ => null,
};
if (iconData?.Icon is not null && @this.Source is FontIconSource)
{
var iconSize =
!double.IsNaN(@this.Width) ? @this.Width :
!double.IsNaN(@this.Height) ? @this.Height :
@this.ActualWidth > 0 ? @this.ActualWidth :
@this.ActualHeight;
@this.Padding = new Thickness(Math.Round(iconSize * -0.2));
}
else if (eventArgs.Key is IconInfoViewModel info)
else
{
iconData = requestedTheme == ElementTheme.Light ? info.Light : info.Dark;
}
if (iconData is not null &&
@this.Source is FontIconSource)
{
if (!string.IsNullOrEmpty(iconData.Icon) && iconData.Icon.Length <= 2)
{
var ch = iconData.Icon[0];
// The range of MDL2 Icons isn't explicitly defined, but
// we're using this based off the table on:
// https://docs.microsoft.com/en-us/windows/uwp/design/style/segoe-ui-symbol-font
var isMDL2Icon = ch is >= '\uE700' and <= '\uF8FF';
if (!isMDL2Icon)
{
@this.Padding = new Thickness(-4);
}
}
@this.Padding = default;
}
}
}));
catch (Exception ex)
{
// Exception from TryEnqueue bypasses the global error handler,
// and crashes the app.
Logger.LogError("Failed to set icon", ex);
}
});
}
}
}

View File

@@ -0,0 +1,177 @@
#include "pch.h"
#include "FontIconGlyphClassifier.h"
#include "FontIconGlyphClassifier.g.cpp"
#include <icu.h>
#include <utility>
namespace winrt::Microsoft::Terminal::UI::implementation
{
namespace
{
// Check if the code point is in the Private Use Area range used by Fluent UI icons.
[[nodiscard]] constexpr bool _isFluentIconPua(const UChar32 cp) noexcept
{
static constexpr UChar32 _fluentIconsPrivateUseAreaStart = 0xE700;
static constexpr UChar32 _fluentIconsPrivateUseAreaEnd = 0xF8FF;
return cp >= _fluentIconsPrivateUseAreaStart && cp <= _fluentIconsPrivateUseAreaEnd;
}
// Determine if the given text (as a sequence of UChar code units) is emoji
[[nodiscard]] bool _isEmoji(const UChar* p, const int32_t length) noexcept
{
if (!p || length < 1)
{
return false;
}
// https://www.unicode.org/reports/tr51/#Emoji_Variation_Selector_Notes
constexpr UChar32 vs15CodePoint = 0xFE0E; // Variation Selectors 15: text variation selector
constexpr UChar32 vs16CodePoint = 0xFE0F; // Variation Selectors: 16 emoji variation selector
// Decode the first code point correctly (surrogate-safe)
int32_t i0{ 0 };
UChar32 first{ 0 };
U16_NEXT(p, i0, length, first);
for (int32_t i = 0; i < length;)
{
UChar32 cp{ 0 };
U16_NEXT(p, i, length, cp);
if (cp == vs16CodePoint) { return true; }
if (cp == vs15CodePoint) { return false; }
}
return !U_IS_SURROGATE(first) && u_hasBinaryProperty(first, UCHAR_EMOJI_PRESENTATION);
}
}
bool FontIconGlyphClassifier::IsLikelyToBeEmojiOrSymbolIcon(const hstring& text)
{
if (text.empty())
{
return false;
}
if (text.size() == 1 && !IS_HIGH_SURROGATE(text[0]))
{
// If it's a single code unit, it's definitely either zero or one grapheme clusters.
// If it turns out to be illegal Unicode, we don't really care.
return true;
}
if (text.size() >= 2 && text[0] <= 0x7F && text[1] <= 0x7F)
{
// Two adjacent ASCII characters (as seen in most file paths) aren't a single
// grapheme cluster.
return false;
}
// Use ICU to determine whether text is composed of a single grapheme cluster.
int32_t off{ 0 };
UErrorCode status{ U_ZERO_ERROR };
UBreakIterator* const bi{ ubrk_open(UBRK_CHARACTER,
nullptr,
reinterpret_cast<const UChar*>(text.data()),
static_cast<int>(text.size()),
&status) };
if (bi)
{
if (U_SUCCESS(status))
{
off = ubrk_next(bi);
}
ubrk_close(bi);
}
return std::cmp_equal(off, text.size());
}
FontIconGlyphKind FontIconGlyphClassifier::Classify(hstring const& text) noexcept
{
if (text.empty())
{
return FontIconGlyphKind::None;
}
const size_t textSize{ text.size() };
const auto* buffer{ reinterpret_cast<const UChar*>(text.c_str()) };
// Fast path 1: Single UTF-16 code unit (most common case)
if (textSize == 1)
{
const UChar ch{ buffer[0] };
if (IS_HIGH_SURROGATE(ch))
{
return FontIconGlyphKind::Invalid;
}
if (_isFluentIconPua(ch))
{
return FontIconGlyphKind::FluentSymbol;
}
if (_isEmoji(&ch, 1))
{
return FontIconGlyphKind::Emoji;
}
return FontIconGlyphKind::Other;
}
// Fast path 2: Common file path pattern - two ASCII printable characters
if (textSize >= 2 && buffer[0] <= 0x7F && buffer[1] <= 0x7F)
{
// Definitely multiple graphemes
return FontIconGlyphKind::Invalid;
}
// Expensive path: Use ICU to determine grapheme boundaries
UErrorCode status{ U_ZERO_ERROR };
UBreakIterator* bi{ ubrk_open(UBRK_CHARACTER,
nullptr,
buffer,
static_cast<int32_t>(textSize),
&status) };
if (U_FAILURE(status) || !bi)
{
return FontIconGlyphKind::Invalid;
}
const int32_t start{ ubrk_first(bi) };
const int32_t end{ ubrk_next(bi) }; // end of first grapheme
ubrk_close(bi);
// No graphemes found
if (end == UBRK_DONE || end <= start)
{
return FontIconGlyphKind::None;
}
// If there's more than one grapheme, it's not a valid icon glyph
if (std::cmp_not_equal(end, textSize))
{
return FontIconGlyphKind::Invalid;
}
// Exactly one grapheme: classify
const UChar* grapheme = buffer + start;
const int32_t graphemeLength = end - start;
if (graphemeLength == 1 && _isFluentIconPua(grapheme[0]))
{
return FontIconGlyphKind::FluentSymbol;
}
if (_isEmoji(grapheme, graphemeLength))
{
return FontIconGlyphKind::Emoji;
}
return FontIconGlyphKind::Other;
}
}

View File

@@ -0,0 +1,18 @@
#pragma once
#include "FontIconGlyphClassifier.g.h"
namespace winrt::Microsoft::Terminal::UI::implementation
{
struct FontIconGlyphClassifier
{
[[nodiscard]] static bool IsLikelyToBeEmojiOrSymbolIcon(const winrt::hstring& text);
[[nodiscard]] static FontIconGlyphKind Classify(winrt::hstring const& text) noexcept;
};
}
namespace winrt::Microsoft::Terminal::UI::factory_implementation
{
BASIC_FACTORY(FontIconGlyphClassifier);
}

View File

@@ -0,0 +1,62 @@
// Copyright (c) Microsoft Corporation.
// Licensed under the MIT license.
namespace Microsoft.Terminal.UI
{
/// <summary>
/// Categorizes the type of a single grapheme cluster or input text.
/// Used to determine how the input should be handled or rendered (for example,
/// whether it should be treated as an emoji, an icon from a symbol font, plain text, etc.).
/// </summary>
enum FontIconGlyphKind
{
/// <summary>
/// Input is invalid or contains more than one grapheme cluster and therefore cannot be
/// treated as a single symbol. Typical for multi-character text like file paths
/// or composed strings that include separators.
/// </summary>
Invalid = -1,
/// <summary>
/// No grapheme present (empty string). Indicates absence of a symbol.
/// </summary>
None = 0,
/// <summary>
/// A single emoji grapheme cluster. This may consist of multiple Unicode code
/// points combined into one visible glyph (e.g., emoji with modifiers or ZWJ sequences).
/// </summary>
Emoji = 1,
/// <summary>
/// A single glyph from the Segoe Fluent Icons / MDL2 Assets Private Use Area (PUA),
/// typically in the Unicode range U+E700U+F8FF. These are font-based icons (Fluent/MDL2).
/// </summary>
FluentSymbol = 2,
/// <summary>
/// A single non-emoji grapheme that is not a Fluent/MDL2 PUA symbol.
/// Covers ordinary characters, letters, numbers, or other single glyph symbols.
/// </summary>
Other = 3,
};
/// <summary>
/// Static utility class for text and icon analysis
/// </summary>
static runtimeclass FontIconGlyphClassifier
{
/// <summary>
/// Determines if text represents a single grapheme cluster (emoji/symbol icon).
/// Uses ICU for Unicode boundary detection to distinguish icons from file paths.
/// </summary>
/// <param name="text">Text to analyze</param>
/// <returns>True if single grapheme cluster, false for multi-character text or paths</returns>
static Boolean IsLikelyToBeEmojiOrSymbolIcon(String text);
/// <summary>
/// Classifies the input into a glyph kind suitable for icon or text rendering.
/// </summary>
static FontIconGlyphKind Classify(String text);
};
}

View File

@@ -2,7 +2,7 @@
#include "IconPathConverter.h"
#include "IconPathConverter.g.cpp"
// #include "Utils.h"
#include "FontIconGlyphClassifier.h"
#include <Shlobj.h>
#include <Shlobj_core.h>
@@ -110,7 +110,7 @@ namespace winrt::Microsoft::Terminal::UI::implementation
if (til::equals_insensitive_ascii(iconUri.Extension(), L".svg"))
{
typename ImageIconSource<TIconSource>::type iconSource;
winrt::Microsoft::UI::Xaml::Media::Imaging::SvgImageSource source{ iconUri };
winrt::Microsoft::UI::Xaml::Media::Imaging::SvgImageSource source{ iconUri };
iconSource.ImageSource(source);
return iconSource;
}
@@ -169,41 +169,46 @@ namespace winrt::Microsoft::Terminal::UI::implementation
// If we fail to set the icon source using the "icon" as a path,
// let's try it as a symbol/emoji.
//
// Anything longer than 2 wchar_t's _isn't_ an emoji or symbol, so
// don't do this if it's just an invalid path.
if (!iconSource && iconPath.size() <= 2)
if (!iconSource)
{
try
{
typename FontIconSource<TIconSource>::type icon;
const auto ch = til::at(iconPath, 0);
const auto glyph_kind = FontIconGlyphClassifier::Classify(iconPath);
// The range of MDL2 Icons isn't explicitly defined, but
// we're using this based off the table on:
// https://docs.microsoft.com/en-us/windows/uwp/design/style/segoe-ui-symbol-font
const auto isMDL2Icon = ch >= L'\uE700' && ch <= L'\uF8FF';
if (isMDL2Icon)
winrt::hstring family;
if (glyph_kind == FontIconGlyphKind::Invalid)
{
icon.FontFamily(winrt::Microsoft::UI::Xaml::Media::FontFamily{ L"Segoe Fluent Icons, Segoe MDL2 Assets" });
family = L"Segoe UI";
}
else if (!fontFamily.empty())
{
icon.FontFamily(winrt::Microsoft::UI::Xaml::Media::FontFamily{ fontFamily });
family = fontFamily;
}
else if (glyph_kind == FontIconGlyphKind::FluentSymbol)
{
family = L"Segoe Fluent Icons, Segoe MDL2 Assets";
}
else if (glyph_kind == FontIconGlyphKind::Emoji)
{
// Emoji and other symbols go in the Segoe UI Emoji font.
// Some emojis (e.g. 2⃣) would be rendered as emoji glyphs otherwise.
family = L"Segoe UI Emoji, Segoe UI";
}
else
{
// Note: you _do_ need to manually set the font here.
icon.FontFamily(winrt::Microsoft::UI::Xaml::Media::FontFamily{ L"Segoe UI" });
family = L"Segoe UI";
}
typename FontIconSource<TIconSource>::type icon;
icon.FontFamily(winrt::Microsoft::UI::Xaml::Media::FontFamily{ family });
icon.FontSize(targetSize);
icon.Glyph(iconPath);
icon.Glyph(glyph_kind == FontIconGlyphKind::Invalid ? L"\u25CC" : iconPath);
iconSource = icon;
}
CATCH_LOG();
}
}
if (!iconSource)
{
// Set the default IconSource to a BitmapIconSource with a null source
@@ -326,7 +331,7 @@ namespace winrt::Microsoft::Terminal::UI::implementation
}
static winrt::Microsoft::UI::Xaml::Media::Imaging::SoftwareBitmapSource _getImageIconSourceForBinary(std::wstring_view iconPathWithoutIndex,
int index,
int index,
int targetSize)
{
// Try:

View File

@@ -159,6 +159,9 @@
<ClInclude Include="ResourceString.h">
<DependentUpon>ResourceString.idl</DependentUpon>
</ClInclude>
<ClInclude Include="FontIconGlyphClassifier.h">
<DependentUpon>FontIconGlyphClassifier.idl</DependentUpon>
</ClInclude>
</ItemGroup>
<ItemGroup>
<ClCompile Include="init.cpp" />
@@ -178,6 +181,9 @@
<DependentUpon>ResourceString.idl</DependentUpon>
</ClCompile>
<ClCompile Include="$(GeneratedFilesDir)module.g.cpp" />
<ClCompile Include="FontIconGlyphClassifier.cpp">
<DependentUpon>FontIconGlyphClassifier.idl</DependentUpon>
</ClCompile>
</ItemGroup>
<ItemGroup>
<Midl Include="Converters.idl" />
@@ -185,6 +191,7 @@
<Midl Include="RunHistory.idl" />
<Midl Include="IDirectKeyListener.idl" />
<Midl Include="ResourceString.idl" />
<Midl Include="FontIconGlyphClassifier.idl" />
</ItemGroup>
<ItemGroup>
<None Include="packages.config" />

View File

@@ -2,10 +2,9 @@
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
using System;
using System.Collections.Generic;
using Microsoft.CmdPal.Ext.WebSearch.Commands;
using Microsoft.CmdPal.Ext.WebSearch.Helpers;
using Microsoft.CommandPalette.Extensions.Toolkit;
namespace Microsoft.CmdPal.Ext.WebSearch.UnitTests;
@@ -13,34 +12,22 @@ public class MockSettingsInterface : ISettingsInterface
{
private readonly List<HistoryItem> _historyItems;
public event EventHandler HistoryChanged;
public bool GlobalIfURI { get; set; }
public uint HistoryItemCount { get; set; }
public int HistoryItemCount { get; set; }
public MockSettingsInterface(uint historyItemCount = 0, bool globalIfUri = true, List<HistoryItem> mockHistory = null)
public IReadOnlyList<HistoryItem> HistoryItems => _historyItems;
public MockSettingsInterface(int historyItemCount = 0, bool globalIfUri = true, List<HistoryItem> mockHistory = null)
{
_historyItems = mockHistory ?? new List<HistoryItem>();
GlobalIfURI = globalIfUri;
HistoryItemCount = historyItemCount;
}
public List<ListItem> LoadHistory()
{
var listItems = new List<ListItem>();
foreach (var historyItem in _historyItems)
{
listItems.Add(new ListItem(new SearchWebCommand(historyItem.SearchString, this))
{
Title = historyItem.SearchString,
Subtitle = historyItem.Timestamp.ToString("g", System.Globalization.CultureInfo.InvariantCulture),
});
}
listItems.Reverse();
return listItems;
}
public void SaveHistory(HistoryItem historyItem)
public void AddHistoryItem(HistoryItem historyItem)
{
if (historyItem is null)
{
@@ -54,15 +41,18 @@ public class MockSettingsInterface : ISettingsInterface
{
while (_historyItems.Count > HistoryItemCount)
{
_historyItems.RemoveAt(0); // Remove the oldest item
_historyItems.RemoveAt(0);
}
}
HistoryChanged?.Invoke(this, EventArgs.Empty);
}
// Helper method for testing
public void ClearHistory()
{
_historyItems.Clear();
HistoryChanged?.Invoke(this, EventArgs.Empty);
}
// Helper method for testing

View File

@@ -45,7 +45,7 @@ public class QueryTests : CommandPaletteUnitTestBase
}
[TestMethod]
public async Task LoadHistoryReturnsExpectedItems()
public async Task HistoryReturnsExpectedItems()
{
// Setup
var mockHistoryItems = new List<HistoryItem>
@@ -77,7 +77,7 @@ public class QueryTests : CommandPaletteUnitTestBase
}
[TestMethod]
public async Task LoadHistoryMoreThanLimitation()
public async Task HistoryExceedingLimitReturnsMaxItems()
{
// Setup
var mockHistoryItems = new List<HistoryItem>
@@ -109,7 +109,7 @@ public class QueryTests : CommandPaletteUnitTestBase
}
[TestMethod]
public async Task LoadHistoryWithDisableSetting()
public async Task HistoryWhenSetToNoneReturnEmptyList()
{
// Setup
var mockHistoryItems = new List<HistoryItem>

View File

@@ -0,0 +1,48 @@
// Copyright (c) Microsoft Corporation
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
using System;
using System.Threading.Tasks;
using Microsoft.CmdPal.Ext.UnitTestBase;
using Microsoft.CmdPal.Ext.WebSearch.Helpers;
using Microsoft.CmdPal.Ext.WebSearch.Pages;
using Microsoft.VisualStudio.TestTools.UnitTesting;
namespace Microsoft.CmdPal.Ext.WebSearch.UnitTests;
[TestClass]
public class SettingsManagerTests : CommandPaletteUnitTestBase
{
[TestMethod]
public async Task HistoryChangedEventIsRaisedWhenItemIsAdded()
{
// Setup
var settings = new MockSettingsInterface(historyItemCount: 5);
var page = new WebSearchListPage(settings);
var eventRaised = false;
try
{
settings.HistoryChanged += Handler;
// Act
settings.AddHistoryItem(new HistoryItem("test event", DateTime.UtcNow));
await Task.Delay(50);
// Assert
Assert.IsTrue(eventRaised, "Expected HistoryChanged to be raised when saving history.");
}
finally
{
settings.HistoryChanged -= Handler;
page.Dispose();
}
return;
void Handler(object s, EventArgs e) => eventRaised = true;
}
}

View File

@@ -133,17 +133,13 @@ internal sealed partial class AppListItem : ListItem
newCommands.Add(new Separator());
// 0x50 = P
// Full key chord would be Ctrl+P
var pinKeyChord = KeyChordHelpers.FromModifiers(true, false, false, false, 0x50, 0);
if (isPinned)
{
newCommands.Add(
new CommandContextItem(
new UnpinAppCommand(this.AppIdentifier))
{
RequestedShortcut = pinKeyChord,
RequestedShortcut = KeyChords.TogglePin,
});
}
else
@@ -152,7 +148,7 @@ internal sealed partial class AppListItem : ListItem
new CommandContextItem(
new PinAppCommand(this.AppIdentifier))
{
RequestedShortcut = pinKeyChord,
RequestedShortcut = KeyChords.TogglePin,
});
}

View File

@@ -0,0 +1,23 @@
// Copyright (c) Microsoft Corporation
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
using Microsoft.CmdPal.Common.Helpers;
using Microsoft.CommandPalette.Extensions;
namespace Microsoft.CmdPal.Ext.Apps;
internal static class KeyChords
{
internal static KeyChord OpenFileLocation { get; } = WellKnownKeyChords.OpenFileLocation;
internal static KeyChord CopyFilePath { get; } = WellKnownKeyChords.CopyFilePath;
internal static KeyChord OpenInConsole { get; } = WellKnownKeyChords.OpenInConsole;
internal static KeyChord RunAsAdministrator { get; } = WellKnownKeyChords.RunAsAdministrator;
internal static KeyChord RunAsDifferentUser { get; } = WellKnownKeyChords.RunAsDifferentUser;
internal static KeyChord TogglePin { get; } = WellKnownKeyChords.TogglePin;
}

View File

@@ -25,6 +25,7 @@
<ProjectReference Include="..\..\..\..\common\ManagedCommon\ManagedCommon.csproj" />
<ProjectReference Include="..\..\..\..\common\ManagedCsWin32\ManagedCsWin32.csproj" />
<ProjectReference Include="..\..\Microsoft.CmdPal.Common\Microsoft.CmdPal.Common.csproj" />
<!-- CmdPal Toolkit reference now included via Common.ExtDependencies.props -->
</ItemGroup>

View File

@@ -87,7 +87,7 @@ public class UWPApplication : IUWPApplication
new CommandContextItem(
new RunAsAdminCommand(UniqueIdentifier, string.Empty, true))
{
RequestedShortcut = KeyChordHelpers.FromModifiers(ctrl: true, shift: true, vkey: VirtualKey.Enter),
RequestedShortcut = KeyChords.RunAsAdministrator,
});
// We don't add context menu to 'run as different user', because UWP applications normally installed per user and not for all users.
@@ -97,7 +97,7 @@ public class UWPApplication : IUWPApplication
new CommandContextItem(
new CopyTextCommand(Location) { Name = Resources.copy_path })
{
RequestedShortcut = KeyChordHelpers.FromModifiers(ctrl: true, shift: true, vkey: VirtualKey.C),
RequestedShortcut = KeyChords.CopyFilePath,
});
commands.Add(
@@ -107,14 +107,14 @@ public class UWPApplication : IUWPApplication
Name = Resources.open_containing_folder,
})
{
RequestedShortcut = KeyChordHelpers.FromModifiers(ctrl: true, shift: true, vkey: VirtualKey.E),
RequestedShortcut = KeyChords.OpenFileLocation,
});
commands.Add(
new CommandContextItem(
new OpenInConsoleCommand(Package.Location))
{
RequestedShortcut = KeyChordHelpers.FromModifiers(ctrl: true, shift: true, vkey: VirtualKey.R),
RequestedShortcut = KeyChords.OpenInConsole,
});
commands.Add(

View File

@@ -191,32 +191,32 @@ public class Win32Program : IProgram
commands.Add(new CommandContextItem(
new RunAsAdminCommand(!string.IsNullOrEmpty(LnkFilePath) ? LnkFilePath : FullPath, ParentDirectory, false))
{
RequestedShortcut = KeyChordHelpers.FromModifiers(ctrl: true, shift: true, vkey: VirtualKey.Enter),
RequestedShortcut = KeyChords.RunAsAdministrator,
});
commands.Add(new CommandContextItem(
new RunAsUserCommand(!string.IsNullOrEmpty(LnkFilePath) ? LnkFilePath : FullPath, ParentDirectory))
{
RequestedShortcut = KeyChordHelpers.FromModifiers(ctrl: true, shift: true, vkey: VirtualKey.U),
RequestedShortcut = KeyChords.RunAsDifferentUser,
});
}
commands.Add(new CommandContextItem(
new CopyTextCommand(FullPath) { Name = Resources.copy_path })
{
RequestedShortcut = KeyChordHelpers.FromModifiers(ctrl: true, shift: true, vkey: VirtualKey.C),
RequestedShortcut = KeyChords.CopyFilePath,
});
commands.Add(new CommandContextItem(
new OpenPathCommand(ParentDirectory))
{
RequestedShortcut = KeyChordHelpers.FromModifiers(ctrl: true, shift: true, vkey: VirtualKey.E),
RequestedShortcut = KeyChords.OpenFileLocation,
});
commands.Add(new CommandContextItem(
new OpenInConsoleCommand(ParentDirectory))
{
RequestedShortcut = KeyChordHelpers.FromModifiers(ctrl: true, shift: true, vkey: VirtualKey.R),
RequestedShortcut = KeyChords.OpenInConsole,
});
if (AppType == ApplicationType.ShortcutApplication || AppType == ApplicationType.ApprefApplication || AppType == ApplicationType.Win32Application)

View File

@@ -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.Ext.ClipboardHistory.Helpers;
using Microsoft.CmdPal.Ext.ClipboardHistory.Pages;
using Microsoft.CommandPalette.Extensions;
using Microsoft.CommandPalette.Extensions.Toolkit;
@@ -11,19 +12,25 @@ namespace Microsoft.CmdPal.Ext.ClipboardHistory;
public partial class ClipboardHistoryCommandsProvider : CommandProvider
{
private readonly ListItem _clipboardHistoryListItem;
private readonly SettingsManager _settingsManager = new();
public ClipboardHistoryCommandsProvider()
{
_clipboardHistoryListItem = new ListItem(new ClipboardHistoryListPage())
_clipboardHistoryListItem = new ListItem(new ClipboardHistoryListPage(_settingsManager))
{
Title = Properties.Resources.list_item_title,
Subtitle = Properties.Resources.list_item_subtitle,
Icon = Icons.ClipboardListIcon,
MoreCommands = [
new CommandContextItem(_settingsManager.Settings.SettingsPage),
],
};
DisplayName = Properties.Resources.provider_display_name;
Icon = Icons.ClipboardListIcon;
Id = "Windows.ClipboardHistory";
Settings = _settingsManager.Settings;
}
public override IListItem[] TopLevelCommands()

View File

@@ -0,0 +1,31 @@
// Copyright (c) Microsoft Corporation
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
using Microsoft.CmdPal.Ext.ClipboardHistory.Models;
using Microsoft.CommandPalette.Extensions.Toolkit;
using Windows.ApplicationModel.DataTransfer;
namespace Microsoft.CmdPal.Ext.ClipboardHistory.Commands;
internal sealed partial class DeleteItemCommand : InvokableCommand
{
private readonly ClipboardItem _clipboardItem;
internal DeleteItemCommand(ClipboardItem clipboardItem)
{
_clipboardItem = clipboardItem;
Name = Properties.Resources.delete_command_name;
Icon = Icons.DeleteIcon;
}
public override CommandResult Invoke()
{
Clipboard.DeleteItemFromHistory(_clipboardItem.Item);
return CommandResult.ShowToast(new ToastArgs
{
Message = Properties.Resources.delete_toast_text,
Result = CommandResult.KeepOpen(),
});
}
}

View File

@@ -4,6 +4,7 @@
using CommunityToolkit.Mvvm.Messaging;
using Microsoft.CmdPal.Common.Messages;
using Microsoft.CmdPal.Ext.ClipboardHistory.Helpers;
using Microsoft.CmdPal.Ext.ClipboardHistory.Models;
using Microsoft.CommandPalette.Extensions.Toolkit;
using Windows.ApplicationModel.DataTransfer;
@@ -14,11 +15,13 @@ internal sealed partial class PasteCommand : InvokableCommand
{
private readonly ClipboardItem _clipboardItem;
private readonly ClipboardFormat _clipboardFormat;
private readonly ISettingOptions _settings;
internal PasteCommand(ClipboardItem clipboardItem, ClipboardFormat clipboardFormat)
internal PasteCommand(ClipboardItem clipboardItem, ClipboardFormat clipboardFormat, ISettingOptions settings)
{
_clipboardItem = clipboardItem;
_clipboardFormat = clipboardFormat;
_settings = settings;
Name = Properties.Resources.paste_command_name;
Icon = Icons.PasteIcon;
}
@@ -39,7 +42,11 @@ internal sealed partial class PasteCommand : InvokableCommand
ClipboardHelper.SendPasteKeyCombination();
Clipboard.DeleteItemFromHistory(_clipboardItem.Item);
if (!_settings.KeepAfterPaste)
{
Clipboard.DeleteItemFromHistory(_clipboardItem.Item);
}
return CommandResult.ShowToast(Properties.Resources.paste_toast_text);
}
}

View File

@@ -0,0 +1,12 @@
// Copyright (c) Microsoft Corporation
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
namespace Microsoft.CmdPal.Ext.ClipboardHistory.Helpers;
public interface ISettingOptions
{
bool KeepAfterPaste { get; }
bool DeleteFromHistoryRequiresConfirmation { get; }
}

View File

@@ -0,0 +1,54 @@
// 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.IO;
using Microsoft.CmdPal.Ext.ClipboardHistory.Properties;
using Microsoft.CommandPalette.Extensions.Toolkit;
namespace Microsoft.CmdPal.Ext.ClipboardHistory.Helpers;
internal sealed class SettingsManager : JsonSettingsManager, ISettingOptions
{
private const string Namespace = "clipboardHistory";
private static string Namespaced(string propertyName) => $"{Namespace}.{propertyName}";
private readonly ToggleSetting _keepAfterPaste = new(
Namespaced(nameof(KeepAfterPaste)),
Resources.settings_keep_after_paste_title!,
Resources.settings_keep_after_paste_description!,
false);
private readonly ToggleSetting _confirmDelete = new(
Namespaced(nameof(DeleteFromHistoryRequiresConfirmation)),
Resources.settings_confirm_delete_title!,
Resources.settings_confirm_delete_description!,
true);
public bool KeepAfterPaste => _keepAfterPaste.Value;
public bool DeleteFromHistoryRequiresConfirmation => _confirmDelete.Value;
private static string SettingsJsonPath()
{
var directory = Utilities.BaseSettingsPath("Microsoft.CmdPal");
Directory.CreateDirectory(directory);
// now, the state is just next to the exe
return Path.Combine(directory, "settings.json");
}
public SettingsManager()
{
FilePath = SettingsJsonPath();
Settings.Add(_keepAfterPaste);
Settings.Add(_confirmDelete);
// Load settings from file upon initialization
LoadSettings();
Settings.SettingsChanged += (_, _) => SaveSettings();
}
}

View File

@@ -14,5 +14,7 @@ internal sealed class Icons
internal static IconInfo PasteIcon { get; } = new("\uE77F");
internal static IconInfo DeleteIcon { get; } = new("\uE74D");
internal static IconInfo ClipboardListIcon { get; } = IconHelpers.FromRelativePath("Assets\\ClipboardHistory.svg");
}

View File

@@ -0,0 +1,19 @@
// Copyright (c) Microsoft Corporation
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
using Microsoft.CommandPalette.Extensions;
using Microsoft.CommandPalette.Extensions.Toolkit;
using Windows.System;
namespace Microsoft.CmdPal.Ext.ClipboardHistory;
internal static class KeyChords
{
internal static KeyChord DeleteEntry { get; } = KeyChordHelpers.FromModifiers(ctrl: true, shift: true, vkey: VirtualKey.Delete);
}

View File

@@ -7,7 +7,9 @@ using System.Collections.Generic;
using System.Diagnostics.CodeAnalysis;
using System.Globalization;
using System.Linq;
using Microsoft.CmdPal.Common.Commands;
using Microsoft.CmdPal.Ext.ClipboardHistory.Commands;
using Microsoft.CmdPal.Ext.ClipboardHistory.Helpers;
using Microsoft.CommandPalette.Extensions.Toolkit;
using Windows.ApplicationModel.DataTransfer;
using Windows.Storage.Streams;
@@ -16,9 +18,11 @@ namespace Microsoft.CmdPal.Ext.ClipboardHistory.Models;
public class ClipboardItem
{
public string? Content { get; set; }
public string? Content { get; init; }
public required ClipboardHistoryItem Item { get; set; }
public required ClipboardHistoryItem Item { get; init; }
public required ISettingOptions Settings { get; init; }
public DateTimeOffset Timestamp => Item?.Timestamp ?? DateTimeOffset.MinValue;
@@ -87,6 +91,19 @@ public class ClipboardItem
Data = new DetailsLink(Item.Timestamp.DateTime.ToString(DateTimeFormatInfo.CurrentInfo)),
});
var deleteConfirmationCommand = new ConfirmableCommand()
{
Command = new DeleteItemCommand(this),
ConfirmationTitle = Properties.Resources.delete_confirmation_title!,
ConfirmationMessage = Properties.Resources.delete_confirmation_message!,
IsConfirmationRequired = () => Settings.DeleteFromHistoryRequiresConfirmation,
};
var deleteContextMenuItem = new CommandContextItem(deleteConfirmationCommand)
{
IsCritical = true,
RequestedShortcut = KeyChords.DeleteEntry,
};
if (IsImage)
{
var iconData = new IconData(ImageData);
@@ -103,7 +120,9 @@ public class ClipboardItem
Metadata = metadata.ToArray(),
},
MoreCommands = [
new CommandContextItem(new PasteCommand(this, ClipboardFormat.Image))
new CommandContextItem(new PasteCommand(this, ClipboardFormat.Image, Settings)),
new Separator(),
deleteContextMenuItem,
],
};
}
@@ -126,8 +145,10 @@ public class ClipboardItem
Metadata = metadata.ToArray(),
},
MoreCommands = [
new CommandContextItem(new PasteCommand(this, ClipboardFormat.Text)),
],
new CommandContextItem(new PasteCommand(this, ClipboardFormat.Text, Settings)),
new Separator(),
deleteContextMenuItem,
],
};
}
else

View File

@@ -7,6 +7,7 @@ using System.Collections.Generic;
using System.Collections.ObjectModel;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.CmdPal.Ext.ClipboardHistory.Helpers;
using Microsoft.CmdPal.Ext.ClipboardHistory.Models;
using Microsoft.CommandPalette.Extensions;
using Microsoft.CommandPalette.Extensions.Toolkit;
@@ -17,11 +18,15 @@ namespace Microsoft.CmdPal.Ext.ClipboardHistory.Pages;
internal sealed partial class ClipboardHistoryListPage : ListPage
{
private readonly SettingsManager _settingsManager;
private readonly ObservableCollection<ClipboardItem> clipboardHistory;
private readonly string _defaultIconPath;
public ClipboardHistoryListPage()
public ClipboardHistoryListPage(SettingsManager settingsManager)
{
ArgumentNullException.ThrowIfNull(settingsManager);
_settingsManager = settingsManager;
clipboardHistory = [];
_defaultIconPath = string.Empty;
Icon = Icons.ClipboardListIcon;
@@ -84,11 +89,11 @@ internal sealed partial class ClipboardHistoryListPage : ListPage
if (item.Content.Contains(StandardDataFormats.Text))
{
var text = await item.Content.GetTextAsync();
items.Add(new ClipboardItem { Content = text, Item = item });
items.Add(new ClipboardItem { Settings = _settingsManager, Content = text, Item = item });
}
else if (item.Content.Contains(StandardDataFormats.Bitmap))
{
items.Add(new ClipboardItem { Item = item });
items.Add(new ClipboardItem { Settings = _settingsManager, Item = item });
}
}

View File

@@ -96,6 +96,42 @@ namespace Microsoft.CmdPal.Ext.ClipboardHistory.Properties {
}
}
/// <summary>
/// Looks up a localized string similar to Delete.
/// </summary>
public static string delete_command_name {
get {
return ResourceManager.GetString("delete_command_name", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to Are you sure you want to delete this item from clipboard history? This action cannot be undone..
/// </summary>
public static string delete_confirmation_message {
get {
return ResourceManager.GetString("delete_confirmation_message", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to Delete item?.
/// </summary>
public static string delete_confirmation_title {
get {
return ResourceManager.GetString("delete_confirmation_title", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to Deleted from clipboard history.
/// </summary>
public static string delete_toast_text {
get {
return ResourceManager.GetString("delete_toast_text", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to Copy, paste, and search items on the clipboard.
/// </summary>
@@ -140,5 +176,41 @@ namespace Microsoft.CmdPal.Ext.ClipboardHistory.Properties {
return ResourceManager.GetString("provider_display_name", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to .
/// </summary>
public static string settings_confirm_delete_description {
get {
return ResourceManager.GetString("settings_confirm_delete_description", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to Show a confirmation dialog when manually deleting an item.
/// </summary>
public static string settings_confirm_delete_title {
get {
return ResourceManager.GetString("settings_confirm_delete_title", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to .
/// </summary>
public static string settings_keep_after_paste_description {
get {
return ResourceManager.GetString("settings_keep_after_paste_description", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to Keep items in clipboard history after pasting.
/// </summary>
public static string settings_keep_after_paste_title {
get {
return ResourceManager.GetString("settings_keep_after_paste_title", resourceCulture);
}
}
}
}

View File

@@ -144,4 +144,28 @@
<data name="clipboard_failed_to_load" xml:space="preserve">
<value>Loading clipboard history failed</value>
</data>
<data name="delete_command_name" xml:space="preserve">
<value>Delete</value>
</data>
<data name="delete_toast_text" xml:space="preserve">
<value>Deleted from clipboard history</value>
</data>
<data name="settings_keep_after_paste_description" xml:space="preserve">
<value />
</data>
<data name="settings_keep_after_paste_title" xml:space="preserve">
<value>Keep items in clipboard history after pasting</value>
</data>
<data name="settings_confirm_delete_title" xml:space="preserve">
<value>Show a confirmation dialog when manually deleting an item</value>
</data>
<data name="settings_confirm_delete_description" xml:space="preserve">
<value />
</data>
<data name="delete_confirmation_title" xml:space="preserve">
<value>Delete item?</value>
</data>
<data name="delete_confirmation_message" xml:space="preserve">
<value>Are you sure you want to delete this item from clipboard history? This action cannot be undone.</value>
</data>
</root>

View File

@@ -91,9 +91,9 @@ internal sealed partial class IndexerListItem : ListItem
}
commands.Add(new CommandContextItem(new OpenWithCommand(fullPath)));
commands.Add(new CommandContextItem(new ShowFileInFolderCommand(fullPath) { Name = Resources.Indexer_Command_ShowInFolder }));
commands.Add(new CommandContextItem(new CopyPathCommand(fullPath) { Name = Resources.Indexer_Command_CopyPath }));
commands.Add(new CommandContextItem(new OpenInConsoleCommand(fullPath)));
commands.Add(new CommandContextItem(new ShowFileInFolderCommand(fullPath) { Name = Resources.Indexer_Command_ShowInFolder }) { RequestedShortcut = KeyChords.OpenFileLocation });
commands.Add(new CommandContextItem(new CopyPathCommand(fullPath) { Name = Resources.Indexer_Command_CopyPath }) { RequestedShortcut = KeyChords.CopyFilePath });
commands.Add(new CommandContextItem(new OpenInConsoleCommand(fullPath)) { RequestedShortcut = KeyChords.OpenInConsole });
commands.Add(new CommandContextItem(new OpenPropertiesCommand(fullPath)));
if (IsActionsFeatureEnabled && ApiInformation.IsApiContractPresent("Windows.AI.Actions.ActionsContract", 4))

View File

@@ -0,0 +1,17 @@
// Copyright (c) Microsoft Corporation
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
using Microsoft.CmdPal.Common.Helpers;
using Microsoft.CommandPalette.Extensions;
namespace Microsoft.CmdPal.Ext.Indexer;
internal static class KeyChords
{
internal static KeyChord OpenFileLocation { get; } = WellKnownKeyChords.OpenFileLocation;
internal static KeyChord CopyFilePath { get; } = WellKnownKeyChords.CopyFilePath;
internal static KeyChord OpenInConsole { get; } = WellKnownKeyChords.OpenInConsole;
}

View File

@@ -36,7 +36,7 @@ internal sealed partial class SearchWebCommand : InvokableCommand
if (_settingsManager.HistoryItemCount != 0)
{
_settingsManager.SaveHistory(new HistoryItem(Arguments, DateTime.Now));
_settingsManager.AddHistoryItem(new HistoryItem(Arguments, DateTime.Now));
}
return CommandResult.Dismiss();

View File

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

View File

@@ -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 System;
using System.Collections.Generic;
using Microsoft.CommandPalette.Extensions.Toolkit;
@@ -9,11 +10,13 @@ namespace Microsoft.CmdPal.Ext.WebSearch.Helpers;
public interface ISettingsInterface
{
event EventHandler? HistoryChanged;
public bool GlobalIfURI { get; }
public uint HistoryItemCount { get; }
public int HistoryItemCount { get; }
public List<ListItem> LoadHistory();
public IReadOnlyList<HistoryItem> HistoryItems { get; }
public void SaveHistory(HistoryItem historyItem);
public void AddHistoryItem(HistoryItem historyItem);
}

View File

@@ -4,11 +4,8 @@
using System;
using System.Collections.Generic;
using System.Globalization;
using System.IO;
using System.Linq;
using System.Text.Json;
using Microsoft.CmdPal.Ext.WebSearch.Commands;
using ManagedCommon;
using Microsoft.CmdPal.Ext.WebSearch.Properties;
using Microsoft.CommandPalette.Extensions.Toolkit;
@@ -17,10 +14,16 @@ namespace Microsoft.CmdPal.Ext.WebSearch.Helpers;
public class SettingsManager : JsonSettingsManager, ISettingsInterface
{
private const string HistoryItemCountLegacySettingsKey = "ShowHistory";
private readonly string _historyPath;
private static readonly string _namespace = "websearch";
public event EventHandler? HistoryChanged
{
add => _history.Changed += value;
remove => _history.Changed -= value;
}
private readonly HistoryStore _history;
private static string Namespaced(string propertyName) => $"{_namespace}.{propertyName}";
private static readonly List<ChoiceSetSetting.Choice> _choices =
@@ -46,9 +49,26 @@ public class SettingsManager : JsonSettingsManager, ISettingsInterface
public bool GlobalIfURI => _globalIfURI.Value;
public uint HistoryItemCount => uint.TryParse(_historyItemCount.Value, out var value) ? value : 0;
public int HistoryItemCount => int.TryParse(_historyItemCount.Value, out var value) && value >= 0 ? value : 0;
internal static string SettingsJsonPath()
public IReadOnlyList<HistoryItem> HistoryItems => _history.HistoryItems;
public SettingsManager()
{
FilePath = SettingsJsonPath();
Settings.Add(_globalIfURI);
Settings.Add(_historyItemCount);
LoadSettings();
// Initialize history store after loading settings to get the correct capacity
_history = new HistoryStore(HistoryStateJsonPath(), HistoryItemCount);
Settings.SettingsChanged += (_, _) => SaveSettings();
}
private static string SettingsJsonPath()
{
var directory = Utilities.BaseSettingsPath("Microsoft.CmdPal");
Directory.CreateDirectory(directory);
@@ -57,7 +77,7 @@ public class SettingsManager : JsonSettingsManager, ISettingsInterface
return Path.Combine(directory, "settings.json");
}
internal static string HistoryStateJsonPath()
private static string HistoryStateJsonPath()
{
var directory = Utilities.BaseSettingsPath("Microsoft.CmdPal");
Directory.CreateDirectory(directory);
@@ -66,156 +86,30 @@ public class SettingsManager : JsonSettingsManager, ISettingsInterface
return Path.Combine(directory, "websearch_history.json");
}
public void SaveHistory(HistoryItem historyItem)
public void AddHistoryItem(HistoryItem historyItem)
{
if (historyItem is null)
{
return;
}
try
{
List<HistoryItem> historyItems;
// Check if the file exists and load existing history
if (File.Exists(_historyPath))
{
var existingContent = File.ReadAllText(_historyPath);
historyItems = JsonSerializer.Deserialize<List<HistoryItem>>(existingContent, WebSearchJsonSerializationContext.Default.ListHistoryItem) ?? [];
}
else
{
historyItems = [];
}
// Add the new history item
historyItems.Add(historyItem);
// Determine the maximum number of items to keep based on HistoryItemCount
if (HistoryItemCount > 0)
{
// Keep only the most recent `maxHistoryItems` items
while (historyItems.Count > HistoryItemCount)
{
historyItems.RemoveAt(0); // Remove the oldest item
}
}
// Serialize the updated list back to JSON and save it
var historyJson = JsonSerializer.Serialize(historyItems, WebSearchJsonSerializationContext.Default.ListHistoryItem);
File.WriteAllText(_historyPath, historyJson);
_history.Add(historyItem);
}
catch (Exception ex)
{
Logger.LogError("Failed to add item to the search history", ex);
ExtensionHost.LogMessage(new LogMessage() { Message = ex.ToString() });
}
}
public List<ListItem> LoadHistory()
{
try
{
if (!File.Exists(_historyPath))
{
return [];
}
// Read and deserialize JSON into a list of HistoryItem objects
var fileContent = File.ReadAllText(_historyPath);
var historyItems = JsonSerializer.Deserialize<List<HistoryItem>>(fileContent, WebSearchJsonSerializationContext.Default.ListHistoryItem) ?? [];
// Convert each HistoryItem to a ListItem
var listItems = new List<ListItem>();
foreach (var historyItem in historyItems)
{
listItems.Add(new ListItem(new SearchWebCommand(historyItem.SearchString, this))
{
Title = historyItem.SearchString,
Subtitle = historyItem.Timestamp.ToString("g", CultureInfo.InvariantCulture), // Ensures consistent formatting
});
}
listItems.Reverse();
return listItems;
}
catch (Exception ex)
{
ExtensionHost.LogMessage(new LogMessage() { Message = ex.ToString() });
return [];
}
}
public SettingsManager()
{
FilePath = SettingsJsonPath();
_historyPath = HistoryStateJsonPath();
Settings.Add(_globalIfURI);
Settings.Add(_historyItemCount);
// Load settings from file upon initialization
LoadSettings();
Settings.SettingsChanged += (s, a) => this.SaveSettings();
}
private void ClearHistory()
{
try
{
if (File.Exists(_historyPath))
{
// Delete the history file
File.Delete(_historyPath);
// Log that the history was successfully cleared
ExtensionHost.LogMessage(new LogMessage() { Message = "History cleared successfully." });
}
else
{
// Log that there was no history file to delete
ExtensionHost.LogMessage(new LogMessage() { Message = "No history file found to clear." });
}
}
catch (Exception ex)
{
// Log any exception that occurs
ExtensionHost.LogMessage(new LogMessage() { Message = $"Failed to clear history: {ex}" });
}
}
public override void SaveSettings()
{
base.SaveSettings();
try
{
if (HistoryItemCount == 0)
{
ClearHistory();
}
else if (HistoryItemCount > 0)
{
// Trim the history file if there are more items than the new limit
if (File.Exists(_historyPath))
{
var existingContent = File.ReadAllText(_historyPath);
var historyItems = JsonSerializer.Deserialize<List<HistoryItem>>(existingContent, WebSearchJsonSerializationContext.Default.ListHistoryItem) ?? [];
// Check if trimming is needed
if (historyItems.Count > HistoryItemCount)
{
// Trim the list to keep only the most recent `HistoryItemCount` items
historyItems = historyItems.Skip((int)(historyItems.Count - HistoryItemCount)).ToList();
// Save the trimmed history back to the file
var trimmedHistoryJson = JsonSerializer.Serialize(historyItems, WebSearchJsonSerializationContext.Default.ListHistoryItem);
File.WriteAllText(_historyPath, trimmedHistoryJson);
}
}
}
_history.SetCapacity(HistoryItemCount);
}
catch (Exception ex)
{
Logger.LogError("Failed to save the search history", ex);
ExtensionHost.LogMessage(new LogMessage() { Message = ex.ToString() });
}
}

View File

@@ -5,8 +5,8 @@
using System;
using System.Collections.Generic;
using System.Globalization;
using System.Linq;
using System.Text;
using System.Threading;
using Microsoft.CmdPal.Ext.WebSearch.Commands;
using Microsoft.CmdPal.Ext.WebSearch.Helpers;
using Microsoft.CmdPal.Ext.WebSearch.Properties;
@@ -16,31 +16,30 @@ using BrowserInfo = Microsoft.CmdPal.Ext.WebSearch.Helpers.DefaultBrowserInfo;
namespace Microsoft.CmdPal.Ext.WebSearch.Pages;
internal sealed partial class WebSearchListPage : DynamicListPage
internal sealed partial class WebSearchListPage : DynamicListPage, IDisposable
{
private readonly string _iconPath = string.Empty;
private readonly List<ListItem>? _historyItems;
private readonly IconInfo _newSearchIcon = new(string.Empty);
private readonly ISettingsInterface _settingsManager;
private readonly Lock _sync = new();
private static readonly CompositeFormat PluginInBrowserName = System.Text.CompositeFormat.Parse(Properties.Resources.plugin_in_browser_name);
private static readonly CompositeFormat PluginOpen = System.Text.CompositeFormat.Parse(Properties.Resources.plugin_open);
private List<ListItem> _allItems;
private IListItem[] _allItems = [];
private List<ListItem> _historyItems = [];
public WebSearchListPage(ISettingsInterface settingsManager)
{
ArgumentNullException.ThrowIfNull(settingsManager);
Name = Resources.command_item_title;
Title = Resources.command_item_title;
Icon = IconHelpers.FromRelativePath("Assets\\WebSearch.png");
_allItems = [];
Id = "com.microsoft.cmdpal.websearch";
_settingsManager = settingsManager;
_historyItems = _settingsManager.HistoryItemCount != 0 ? _settingsManager.LoadHistory() : null;
if (_historyItems is not null)
{
_allItems.AddRange(_historyItems);
}
_settingsManager.HistoryChanged += SettingsManagerOnHistoryChanged;
// It just looks viewer to have string twice on the page, and default placeholder is good enough
PlaceholderText = _allItems.Count > 0 ? Resources.plugin_description : string.Empty;
PlaceholderText = _allItems.Length > 0 ? Resources.plugin_description : string.Empty;
EmptyContent = new CommandItem(new NoOpCommand())
{
@@ -48,45 +47,102 @@ internal sealed partial class WebSearchListPage : DynamicListPage
Title = Properties.Resources.plugin_description,
Subtitle = string.Format(CultureInfo.CurrentCulture, PluginInBrowserName, BrowserInfo.Name ?? BrowserInfo.MSEdgeName),
};
UpdateHistory();
RequeryAndUpdateItems(SearchText);
}
public List<ListItem> Query(string query)
private void SettingsManagerOnHistoryChanged(object? sender, EventArgs e)
{
ArgumentNullException.ThrowIfNull(query);
IEnumerable<ListItem>? filteredHistoryItems = null;
UpdateHistory();
RequeryAndUpdateItems(SearchText);
}
if (_historyItems is not null)
private void UpdateHistory()
{
List<ListItem> history = [];
if (_settingsManager.HistoryItemCount > 0)
{
filteredHistoryItems = _settingsManager.HistoryItemCount != 0 ? ListHelpers.FilterList(_historyItems, query).OfType<ListItem>() : null;
var items = _settingsManager.HistoryItems;
for (var index = items.Count - 1; index >= 0; index--)
{
var historyItem = items[index];
history.Add(new ListItem(new SearchWebCommand(historyItem.SearchString, _settingsManager))
{
Title = historyItem.SearchString,
Subtitle = historyItem.Timestamp.ToString("g", CultureInfo.InvariantCulture),
});
}
}
var results = new List<ListItem>();
lock (_sync)
{
_historyItems = history;
}
}
private static IListItem[] Query(string query, List<ListItem> historySnapshot, ISettingsInterface settingsManager, IconInfo newSearchIcon)
{
ArgumentNullException.ThrowIfNull(query);
var filteredHistoryItems = settingsManager.HistoryItemCount > 0
? ListHelpers.FilterList(historySnapshot, query)
: [];
var results = new List<IListItem>();
if (!string.IsNullOrEmpty(query))
{
var searchTerm = query;
var result = new ListItem(new SearchWebCommand(searchTerm, _settingsManager))
var result = new ListItem(new SearchWebCommand(searchTerm, settingsManager))
{
Title = searchTerm,
Subtitle = string.Format(CultureInfo.CurrentCulture, PluginOpen, BrowserInfo.Name ?? BrowserInfo.MSEdgeName),
Icon = new IconInfo(_iconPath),
Icon = newSearchIcon,
};
results.Add(result);
}
if (filteredHistoryItems is not null)
results.AddRange(filteredHistoryItems);
return [.. results];
}
private void RequeryAndUpdateItems(string search)
{
List<ListItem> historySnapshot;
lock (_sync)
{
results.AddRange(filteredHistoryItems);
historySnapshot = _historyItems;
}
return results;
var items = Query(search ?? string.Empty, historySnapshot, _settingsManager, _newSearchIcon);
lock (_sync)
{
_allItems = items;
}
RaiseItemsChanged();
}
public override void UpdateSearchText(string oldSearch, string newSearch)
{
_allItems = [.. Query(newSearch)];
RaiseItemsChanged(0);
RequeryAndUpdateItems(newSearch);
}
public override IListItem[] GetItems() => [.. _allItems];
public override IListItem[] GetItems()
{
lock (_sync)
{
return _allItems;
}
}
public void Dispose()
{
_settingsManager.HistoryChanged -= SettingsManagerOnHistoryChanged;
GC.SuppressFinalize(this);
}
}

View File

@@ -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 System;
using Microsoft.CmdPal.Ext.WebSearch.Commands;
using Microsoft.CmdPal.Ext.WebSearch.Helpers;
using Microsoft.CmdPal.Ext.WebSearch.Properties;
@@ -15,6 +16,9 @@ public partial class WebSearchCommandsProvider : CommandProvider
private readonly SettingsManager _settingsManager = new();
private readonly FallbackExecuteSearchItem _fallbackItem;
private readonly FallbackOpenURLItem _openUrlFallbackItem;
private readonly WebSearchTopLevelCommandItem _webSearchTopLevelItem;
private readonly ICommandItem[] _topLevelItems;
private readonly IFallbackCommandItem[] _fallbackCommands;
public WebSearchCommandsProvider()
{
@@ -25,18 +29,27 @@ public partial class WebSearchCommandsProvider : CommandProvider
_fallbackItem = new FallbackExecuteSearchItem(_settingsManager);
_openUrlFallbackItem = new FallbackOpenURLItem(_settingsManager);
}
public override ICommandItem[] TopLevelCommands()
{
return [new WebSearchTopLevelCommandItem(_settingsManager)
_webSearchTopLevelItem = new WebSearchTopLevelCommandItem(_settingsManager)
{
MoreCommands = [
MoreCommands =
[
new CommandContextItem(Settings!.SettingsPage),
],
}
];
};
_topLevelItems = [_webSearchTopLevelItem];
_fallbackCommands = [_openUrlFallbackItem, _fallbackItem];
}
public override IFallbackCommandItem[]? FallbackCommands() => [_openUrlFallbackItem, _fallbackItem];
public override ICommandItem[] TopLevelCommands() => _topLevelItems;
public override IFallbackCommandItem[]? FallbackCommands() => _fallbackCommands;
public override void Dispose()
{
_webSearchTopLevelItem?.Dispose();
base.Dispose();
GC.SuppressFinalize(this);
}
}

View File

@@ -3,7 +3,6 @@
// See the LICENSE file in the project root for more information.
using System;
using System.IO;
using Microsoft.CmdPal.Ext.WebSearch.Commands;
using Microsoft.CmdPal.Ext.WebSearch.Helpers;
using Microsoft.CmdPal.Ext.WebSearch.Pages;
@@ -13,7 +12,7 @@ using Microsoft.CommandPalette.Extensions.Toolkit;
namespace Microsoft.CmdPal.Ext.WebSearch;
public partial class WebSearchTopLevelCommandItem : CommandItem, IFallbackHandler
public partial class WebSearchTopLevelCommandItem : CommandItem, IFallbackHandler, IDisposable
{
private readonly SettingsManager _settingsManager;
@@ -27,17 +26,29 @@ public partial class WebSearchTopLevelCommandItem : CommandItem, IFallbackHandle
private void SetDefaultTitle() => Title = Resources.command_item_title;
private void ReplaceCommand(ICommand newCommand)
{
(Command as IDisposable)?.Dispose();
Command = newCommand;
}
public void UpdateQuery(string query)
{
if (string.IsNullOrEmpty(query))
{
SetDefaultTitle();
Command = new WebSearchListPage(_settingsManager);
ReplaceCommand(new WebSearchListPage(_settingsManager));
}
else
{
Title = query;
Command = new SearchWebCommand(query, _settingsManager);
ReplaceCommand(new SearchWebCommand(query, _settingsManager));
}
}
public void Dispose()
{
(Command as IDisposable)?.Dispose();
GC.SuppressFinalize(this);
}
}

View File

@@ -0,0 +1,189 @@
// 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.Linq;
using Microsoft.CommandPalette.Extensions;
using Microsoft.CommandPalette.Extensions.Toolkit;
namespace SamplePagesExtension.Pages;
internal sealed partial class SampleIconPage : ListPage
{
private readonly IListItem[] _items =
[
/*
* Quick intro to Unicode in source code:
* - Every character has a code point (e.g., U+0041 = 'A').
* - Code points up to U+FFFF use \u1234 (4 hex digits and lowercase u).
* - Code points above that (up to U+10FFFF) use \U12345678 (8 hex digits and capital letter U).
* - If your source file is UTF-8, you can type the character directly, but it may not display properly in editors,
* and it's harder to see the actual code point.
* - Some symbols (like many emojis) are built from multiple code points
* joined together (e.g., 👋🏻 = U+1F44B + U+1F3FB).
*
* Examples:
* 😍 = "😍" or "\U0001F60D"
* 👋🏻 = "👋🏻" or "\U0001F44B\U0001F3FB"
* 🧙‍♂️ = "🧙‍♂️" or "\U0001F9D9\u200D\u2642\U0000FE0F" (male mage)
* 🧙🏿‍♀️ = "🧙🏿‍♀️" or "\U0001F9D9\U0001F3FF\u200D\u2640\U0000FE0F" (dark-skinned woman mage)
*
*/
// Emoji Smiling Face with Heart-Eyes
// Unicode: \U0001F60D
BuildIconItem("😍", "Standard emoji icon", "Basic emoji character rendered as an icon"),
// Emoji Smiling Face with Heart-Eyes
// Unicode: \U0001F60D\U0001F643\U0001F622
BuildIconItem("😍🙃😢", "Multiple emojis", "Use of multiple emojis for icon is not allowed"),
// Emoji Smiling Face with Sunglasses
// Unicode: \U0001F60E
BuildIconItem("\U0001F60E", "Unicode escape sequence emoji", "Emoji defined using Unicode escape sequence notation"),
// Segoe Fluent Icons font icon
// Unicode: \uE8D4
BuildIconItem("\uE8D4", "Segoe Fluent icon demonstration", "Segoe Fluent/MDL2 icon from system font\nWorks as an icon but won't display properly in button text"),
// Extended pictographic symbol for keyboard
BuildIconItem("\u2328", "Extended pictographic symbol", "Pictographic symbol representing a keyboard"),
// Capital letter A
BuildIconItem("A", "Simple text character as icon", "Basic letter character used as an icon demonstration"),
// Letter 1
// Unicode: \U00000031
BuildIconItem("1", "Simple text character as icon", "Basic letter character used as an icon demonstration"),
// Emoji Keycap Digit Two ... 2
// Unicode: \U00000032\U000020E3
// This is a sequence of three code points: the digit '2' (U+0032), and a combining enclosing keycap (U+20E3). No variation selector is used here.
BuildIconItem("\U00000032\U000020E3", "Emoji without variation selector", "Emoji character doesn't have VS16 variation selector to render as text"),
// Emoji Keycap Digit Three ... 3
// Unicode: \U00000033\U0000FE0F\U000020E3
// This is a sequence of three code points: the digit '3' (U+0033), a variation selector (U+FE0F) to specify emoji presentation, and a combining enclosing keycap (U+20E3).
BuildIconItem("3⃣", "Emoji with variation selector", "Emoji character using a variation selector to specify emoji presentation"),
// Symbol #
// Unicode: \u0023
BuildIconItem("#", "Simple text character as icon", "Basic letter character used as an icon demonstration"),
// Symbol # keycap
// Unicode: \u0023\ufe0f\u20e3
// Sequence of 3 code points: symbol #, a variation selector (U+FE0F) to specify emoji presentation, and a combining enclosing keycap (U+20E3).
BuildIconItem("\u0023\ufe0f\u20e3", "Simple text character as icon", "Basic letter character used as an icon demonstration"),
// Capital letter WM
// This is two characters, which is not a valid icon representation. It will be replaced by a placeholder signalizing an invalid icon.
BuildIconItem("WM", "Invalid icon representation", "String with multiple characters that does not correspond to a valid single icon"),
// Emoji Mage
// Unicode: \U0001F9D9
BuildIconItem("🧙", "Single code-point emoji example", "Simple emoji character using a single Unicode code point"),
// Emoji Male Mage (Mage with gender modifier)
// Unicode: \U0001F9D9\u200D\u2642\uFE0F
BuildIconItem("🧙‍♂️", "Complex emoji with gender modifier", "Composite emoji using Zero-Width Joiner (ZWJ) sequence for male variant"),
// Emoji Woman Mage (Mage with gender modifier)
// Unicode: \U0001F9D9\u200D\u2640\uFE0F
BuildIconItem("\U0001F9D9\u200D\u2640\uFE0F", "Complex emoji with gender modifier", "Composite emoji using Zero-Width Joiner (ZWJ) sequence for female variant"),
// Emoji Waving Hand
// Unicode: \U0001F44B
BuildIconItem("👋", "Basic hand gesture emoji", "Standard emoji character representing a waving hand"),
// Emoji Waving Hand + Light Skin Tone
// Unicode: \U0001F44B\U0001F3FB
BuildIconItem("👋🏻", "Emoji with light skin tone modifier", "Emoji enhanced with Unicode skin tone modifier (light)"),
// Emoji Waving Hand + Dark Skin Tone
// Unicode: \U0001F44B\U0001F3FF
BuildIconItem("\U0001F44B\U0001F3FF", "Emoji with dark skin tone modifier", "Emoji enhanced with Unicode skin tone modifier (dark)"),
// Flag of Czechia (Czech Republic)
// Unicode: \U0001F1E8\U0001F1FF
BuildIconItem("\U0001F1E8\U0001F1FF", "Flag emoji using regional indicators", "Emoji flag constructed from regional indicator symbols for Czechia"),
// Use of ZWJ without emojis
// KA (\u0995) + VIRAMA (\u09CD) + ZWJ (\u200D) - shows the half-form KA
// Unicode: \u0995\u09CD\u200D
BuildIconItem("\u0995\u09CD\u200D", "Use of ZWJ in non-emoji context", "Shows the half-form KA"),
// Use of ZWJ without emojis
// KA (\u0995) + VIRAMA (\u09CD) + Shows full KA with an explicit virama mark (not half-form).
// Unicode: \u0995\u09CD
BuildIconItem("\u0995\u09CD", "Use of ZWJ in non-emoji context", "Shows full KA with an explicit virama mark"),
// mahjong tile red dragon (using Unicode escape sequence)
// https://en.wikipedia.org/wiki/Mahjong_Tiles_(Unicode_block)
// Unicode: \U0001F004
BuildIconItem("\U0001F004", "Mahjong tile emoji (red dragon)", "Mahjong tile red dragon emoji character using Unicode escape sequence"),
// mahjong tile green dragon (non-emoji)
// https://en.wikipedia.org/wiki/Mahjong_Tiles_(Unicode_block)
// Unicode: \U0001F005
BuildIconItem("\U0001F005", "Mahjong tile non-emoji (green dragon)", "Mahjong tile character that is not classified as an emoji"),
// Play, PlayPause, Stop
BuildIconItem("\u25B6", "Play symbol (standalone)", "Play symbol"),
BuildIconItem("\u25B6\uFE0E", "Play symbol + VS15 (request text)", "Play symbol with variation specifier requesting rendering as text"),
BuildIconItem("\u25B6\uFE0F", "Play symbol + VS16 (request emoji)", "Play symbol with variation specifier requesting rendering as emoji "),
BuildIconItem("⏯️", "Play/Pause keycap emoji", "Play/Pause keycap emoji doesn't have plain text variant"),
BuildIconItem("⏸️", "Pause keycap emoji", "Pause keycap emoji doesn't have plain text variant"),
// Copyright and emoji copyright:
BuildIconItem("\u00a9", "Copyright symbol (standalone)", "Copyright symbol that is not classified as an emoji"),
BuildIconItem("\u00a9\uFE0E", "Copyright symbol + VS15 (request text)", "Copyright symbol that is not classified as an emoji"),
BuildIconItem("\u00a9\uFE0F", "Copyright symbol + VS16 (request emoji)", "Copyright symbol that is not classified as an emoji"),
// Tag flags
BuildIconItem("🏳️", "White Flag", "White Flag"),
BuildIconItem("\U0001F3F4\u200D\u2620\uFE0F", "Pirate Flag", "Pirate Flag"),
];
public SampleIconPage()
{
Icon = new IconInfo("\uE8BA");
Name = "Sample Icon Page";
ShowDetails = true;
}
public override IListItem[] GetItems() => _items;
private static ListItem BuildIconItem(string icon, string title, string description)
{
var iconInfo = new IconInfo(icon);
return new ListItem(new CopyTextCommand(icon) { Name = "Action with " + icon })
{
Title = title,
Subtitle = description,
Icon = iconInfo,
Tags = [
new Tag("Tag") { Icon = iconInfo },
],
Details = new Details
{
HeroImage = iconInfo,
Title = title,
Body = description,
Metadata = [
new DetailsElement
{
Key = "Unicode Code Points",
Data = new DetailsTags
{
Tags = icon.EnumerateRunes()
.Select(rune => rune.Value <= 0xFFFF ? $"\\u{rune.Value:X4}" : $"\\U{rune.Value:X8}")
.Select(t => new Tag(t))
.ToArray<ITag>(),
},
}
],
},
};
}
}

View File

@@ -4,6 +4,7 @@
using Microsoft.CommandPalette.Extensions;
using Microsoft.CommandPalette.Extensions.Toolkit;
using SamplePagesExtension.Pages;
namespace SamplePagesExtension;
@@ -37,6 +38,11 @@ public partial class SamplesListPage : ListPage
Title = "Demo of OnLoad/OnUnload",
Subtitle = "Changes the list of items every time the page is opened / closed",
},
new ListItem(new SampleIconPage())
{
Title = "Sample Icon Page",
Subtitle = "A demo of using icons in various ways",
},
// Content pages
new ListItem(new SampleContentPage())

View File

@@ -177,6 +177,7 @@ int runner(bool isProcessElevated, bool openSettings, std::string settingsWindow
L"PowerToys.WorkspacesModuleInterface.dll",
L"PowerToys.CmdPalModuleInterface.dll",
L"PowerToys.ZoomItModuleInterface.dll",
L"PowerToys.DwellCursor.dll",
};
for (auto moduleSubdir : knownModules)

View File

@@ -0,0 +1,42 @@
// Copyright (c) Microsoft Corporation
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
using System.Text.Json.Serialization;
using ManagedCommon;
using Microsoft.PowerToys.Settings.UI.Library.Helpers;
using Microsoft.PowerToys.Settings.UI.Library.Interfaces;
namespace Microsoft.PowerToys.Settings.UI.Library
{
public class DwellCursorSettings : BasePTModuleSettings, ISettingsConfig, IHotkeyConfig
{
public const string ModuleName = "DwellCursor";
[JsonPropertyName("properties")]
public DwellCursorSettingsProperties Properties { get; set; } = new DwellCursorSettingsProperties();
public DwellCursorSettings()
{
Name = ModuleName;
Version = "1.0";
}
public string GetModuleName() => Name;
public ModuleType GetModuleType() => ModuleType.MouseJump; // grouped under Mouse Utils UI
public HotkeyAccessor[] GetAllHotkeyAccessors()
{
return new HotkeyAccessor[]
{
new HotkeyAccessor(
() => Properties.ActivationShortcut,
value => Properties.ActivationShortcut = value ?? DwellCursorSettingsProperties.DefaultActivationShortcut,
"MouseUtils_DwellCursor_ActivationShortcut"),
};
}
public bool UpgradeSettingsConfiguration() => false;
}
}

View File

@@ -0,0 +1,22 @@
// Copyright (c) Microsoft Corporation
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
using System.Text.Json.Serialization;
namespace Microsoft.PowerToys.Settings.UI.Library
{
public class DwellCursorSettingsProperties
{
[JsonPropertyName("activation_shortcut")]
public HotkeySettings ActivationShortcut { get; set; } = DefaultActivationShortcut;
public static HotkeySettings DefaultActivationShortcut => new HotkeySettings(true, false, true, false, 0x44); // Win + Alt + D
[JsonPropertyName("delay_time_ms")]
public IntProperty DelayTimeMs { get; set; } = new IntProperty() { Value = 1000 }; // 0.5s-10s
[JsonPropertyName("settle_time_seconds")]
public IntProperty SettleTimeSeconds { get; set; } = new IntProperty() { Value = 1 }; // 1-5 seconds
}
}

View File

@@ -282,6 +282,22 @@ namespace Microsoft.PowerToys.Settings.UI.Library
}
}
private bool dwellCursor; // defaulting to off
[JsonPropertyName("DwellCursor")]
public bool DwellCursor
{
get => dwellCursor;
set
{
if (dwellCursor != value)
{
LogTelemetryEvent(value);
dwellCursor = value;
}
}
}
private bool powerAccent; // defaulting to off
[JsonPropertyName("QuickAccent")]

View File

@@ -0,0 +1,25 @@
// Copyright (c) Microsoft Corporation
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
using System.Text.Json.Serialization;
namespace Microsoft.PowerToys.Settings.UI.Library
{
public class SndDwellCursorSettings
{
[JsonPropertyName("DwellCursor")]
public DwellCursorSettings DwellCursor { get; set; }
public SndDwellCursorSettings()
{
}
public SndDwellCursorSettings(DwellCursorSettings s)
{
DwellCursor = s;
}
public string ToJsonString() => System.Text.Json.JsonSerializer.Serialize(this);
}
}

View File

@@ -169,7 +169,7 @@
Severity="Informational"
Visibility="{x:Bind ViewModel.IsAnimationEnabledBySystem, Mode=OneWay, Converter={StaticResource ReverseBoolToVisibilityConverter}}">
<InfoBar.ActionButton>
<HyperlinkButton x:Uid="OpenSettings" Click="OpenAnimationsSettings_Click" />
<HyperlinkButton x:Uid="OpenAnimationsSettings" Click="OpenAnimationsSettings_Click" />
</InfoBar.ActionButton>
</InfoBar>
<tkcontrols:SettingsExpander
@@ -413,6 +413,42 @@
</tkcontrols:SettingsExpander.Items>
</tkcontrols:SettingsExpander>
</controls:SettingsGroup>
<!-- Dwell Cursor -->
<controls:SettingsGroup x:Uid="MouseUtils_DwellCursor">
<tkcontrols:SettingsCard
Name="MouseUtilsEnableDwellCursor"
x:Uid="MouseUtils_Enable_DwellCursor"
HeaderIcon="{ui:BitmapIcon Source=/Assets/Settings/Icons/MouseCrosshairs.png}">
<ToggleSwitch x:Uid="ToggleSwitch" IsOn="{x:Bind ViewModel.IsDwellCursorEnabled, Mode=TwoWay}" />
</tkcontrols:SettingsCard>
<tkcontrols:SettingsExpander
Name="MouseUtilsDwellCursorActivationShortcut"
x:Uid="MouseUtils_DwellCursor_ActivationShortcut"
HeaderIcon="{ui:FontIcon Glyph=&#xEDA7;}"
IsEnabled="{x:Bind ViewModel.IsDwellCursorEnabled, Mode=OneWay}"
IsExpanded="True">
<controls:ShortcutControl MinWidth="{StaticResource SettingActionControlMinWidth}" HotkeySettings="{x:Bind Path=ViewModel.DwellCursorActivationShortcut, Mode=TwoWay}" />
<tkcontrols:SettingsExpander.Items>
<tkcontrols:SettingsCard Name="MouseUtilsDwellCursorDelayMs" x:Uid="MouseUtils_DwellCursor_DelayMs">
<Slider
MinWidth="{StaticResource SettingActionControlMinWidth}"
Maximum="10"
Minimum="1"
Value="{x:Bind ViewModel.DwellCursorDelayTimeSeconds, Mode=TwoWay}" />
</tkcontrols:SettingsCard>
<tkcontrols:SettingsCard Name="MouseUtilsDwellCursorSettleTimeSeconds" x:Uid="MouseUtils_DwellCursor_SettleTimeSeconds">
<Slider
MinWidth="{StaticResource SettingActionControlMinWidth}"
Maximum="5"
Minimum="1"
Value="{x:Bind ViewModel.DwellCursorSettleTimeSeconds, Mode=TwoWay}" />
</tkcontrols:SettingsCard>
</tkcontrols:SettingsExpander.Items>
</tkcontrols:SettingsExpander>
</controls:SettingsGroup>
</StackPanel>
</controls:SettingsPageControl.ModuleContent>
<controls:SettingsPageControl.PrimaryLinks>

View File

@@ -42,6 +42,7 @@ namespace Microsoft.PowerToys.Settings.UI.Views
SettingsRepository<MouseHighlighterSettings>.GetInstance(settingsUtils),
SettingsRepository<MouseJumpSettings>.GetInstance(settingsUtils),
SettingsRepository<MousePointerCrosshairsSettings>.GetInstance(settingsUtils),
SettingsRepository<DwellCursorSettings>.GetInstance(settingsUtils),
ShellPage.SendDefaultIPCMessage);
DataContext = ViewModel;

View File

@@ -5299,4 +5299,33 @@ To record a specific window, enter the hotkey with the Alt key in the opposite m
<data name="UtilitiesHeader.Title" xml:space="preserve">
<value>Utilities</value>
</data>
<data name="MouseUtils_DwellCursor.Header" xml:space="preserve">
<value>Dwell Cursor (placeholder)</value>
<comment>Dwell Cursor description</comment>
</data>
<data name="MouseUtils_Enable_DwellCursor.Header" xml:space="preserve">
<value>Enable Dwell Cursor</value>
<comment>"Dwell Cursor" is the name of the utility.</comment>
</data>
<data name="MouseUtils_GlidingCursor_InitialSpeed.Header" xml:space="preserve">
<value>Initial line speed</value>
</data>
<data name="MouseUtils_DwellCursor_DelayMs.Header" xml:space="preserve">
<value>Delay time (in seconds)</value>
</data>
<data name="MouseUtils_DwellCursor_DelayMs.Description" xml:space="preserve">
<value>Seconds to wait before automatically clicking when the cursor is still (110)</value>
</data>
<data name="MouseUtils_DwellCursor_ActivationShortcut.Header" xml:space="preserve">
<value>Activation shortcut</value>
</data>
<data name="MouseUtils_DwellCursor_ActivationShortcut.Description" xml:space="preserve">
<value>Customize the shortcut to turn on or off this mode</value>
</data>
<data name="MouseUtils_DwellCursor_SettleTimeSeconds.Header" xml:space="preserve">
<value>Cursor settle time (seconds)</value>
</data>
<data name="MouseUtils_DwellCursor_SettleTimeSeconds.Description" xml:space="preserve">
<value>Time the cursor must remain still before the countdown starts</value>
</data>
</root>

View File

@@ -29,13 +29,22 @@ namespace Microsoft.PowerToys.Settings.UI.ViewModels
private MousePointerCrosshairsSettings MousePointerCrosshairsSettingsConfig { get; set; }
public MouseUtilsViewModel(ISettingsUtils settingsUtils, ISettingsRepository<GeneralSettings> settingsRepository, ISettingsRepository<FindMyMouseSettings> findMyMouseSettingsRepository, ISettingsRepository<MouseHighlighterSettings> mouseHighlighterSettingsRepository, ISettingsRepository<MouseJumpSettings> mouseJumpSettingsRepository, ISettingsRepository<MousePointerCrosshairsSettings> mousePointerCrosshairsSettingsRepository, Func<string, int> ipcMSGCallBackFunc)
private DwellCursorSettings DwellCursorSettingsConfig { get; set; }
public MouseUtilsViewModel(
ISettingsUtils settingsUtils,
ISettingsRepository<GeneralSettings> settingsRepository,
ISettingsRepository<FindMyMouseSettings> findMyMouseSettingsRepository,
ISettingsRepository<MouseHighlighterSettings> mouseHighlighterSettingsRepository,
ISettingsRepository<MouseJumpSettings> mouseJumpSettingsRepository,
ISettingsRepository<MousePointerCrosshairsSettings> mousePointerCrosshairsSettingsRepository,
ISettingsRepository<DwellCursorSettings> dwellCursorSettingsRepository,
Func<string, int> ipcMSGCallBackFunc)
{
SettingsUtils = settingsUtils;
// To obtain the general settings configurations of PowerToys Settings.
ArgumentNullException.ThrowIfNull(settingsRepository);
GeneralSettingsConfig = settingsRepository.SettingsConfig;
InitializeEnabledValues();
@@ -43,7 +52,6 @@ namespace Microsoft.PowerToys.Settings.UI.ViewModels
// To obtain the find my mouse settings, if the file exists.
// If not, to create a file with the default settings and to return the default configurations.
ArgumentNullException.ThrowIfNull(findMyMouseSettingsRepository);
FindMyMouseSettingsConfig = findMyMouseSettingsRepository.SettingsConfig;
_findMyMouseActivationMethod = FindMyMouseSettingsConfig.Properties.ActivationMethod.Value < 4 ? FindMyMouseSettingsConfig.Properties.ActivationMethod.Value : 0;
_findMyMouseIncludeWinKey = FindMyMouseSettingsConfig.Properties.IncludeWinKey.Value;
@@ -65,7 +73,6 @@ namespace Microsoft.PowerToys.Settings.UI.ViewModels
_findMyMouseShakingFactor = FindMyMouseSettingsConfig.Properties.ShakingFactor.Value;
ArgumentNullException.ThrowIfNull(mouseHighlighterSettingsRepository);
MouseHighlighterSettingsConfig = mouseHighlighterSettingsRepository.SettingsConfig;
string leftClickColor = MouseHighlighterSettingsConfig.Properties.LeftButtonClickColor.Value;
_highlighterLeftButtonClickColor = !string.IsNullOrEmpty(leftClickColor) ? leftClickColor : "#a6FFFF00";
@@ -85,7 +92,6 @@ namespace Microsoft.PowerToys.Settings.UI.ViewModels
this.InitializeMouseJumpSettings(mouseJumpSettingsRepository);
ArgumentNullException.ThrowIfNull(mousePointerCrosshairsSettingsRepository);
MousePointerCrosshairsSettingsConfig = mousePointerCrosshairsSettingsRepository.SettingsConfig;
string crosshairsColor = MousePointerCrosshairsSettingsConfig.Properties.CrosshairsColor.Value;
@@ -103,8 +109,13 @@ namespace Microsoft.PowerToys.Settings.UI.ViewModels
_mousePointerCrosshairsFixedLength = MousePointerCrosshairsSettingsConfig.Properties.CrosshairsFixedLength.Value;
_mousePointerCrosshairsAutoActivate = MousePointerCrosshairsSettingsConfig.Properties.AutoActivate.Value;
int isEnabled = 0;
// Dwell Cursor
ArgumentNullException.ThrowIfNull(dwellCursorSettingsRepository);
DwellCursorSettingsConfig = dwellCursorSettingsRepository.SettingsConfig;
_dwellCursorDelayTimeSeconds = DwellCursorSettingsConfig.Properties.DelayTimeMs.Value / 1000;
_dwellCursorSettleTimeSeconds = DwellCursorSettingsConfig.Properties.SettleTimeSeconds.Value;
int isEnabled = 0;
Utilities.NativeMethods.SystemParametersInfo(Utilities.NativeMethods.SPI_GETCLIENTAREAANIMATION, 0, ref isEnabled, 0);
_isAnimationEnabledBySystem = isEnabled != 0;
@@ -151,6 +162,9 @@ namespace Microsoft.PowerToys.Settings.UI.ViewModels
{
_isMousePointerCrosshairsEnabled = GeneralSettingsConfig.Enabled.MousePointerCrosshairs;
}
// Dwell Cursor enabled state
_isDwellCursorEnabled = GeneralSettingsConfig.Enabled.DwellCursor;
}
public override Dictionary<string, HotkeySettings[]> GetAllHotkeySettings()
@@ -163,6 +177,7 @@ namespace Microsoft.PowerToys.Settings.UI.ViewModels
MousePointerCrosshairsActivationShortcut,
GlidingCursorActivationShortcut],
[MouseJumpSettings.ModuleName] = [MouseJumpActivationShortcut],
[DwellCursorSettings.ModuleName] = [DwellCursorActivationShortcut],
};
return hotkeysDict;
@@ -959,6 +974,84 @@ namespace Microsoft.PowerToys.Settings.UI.ViewModels
SettingsUtils.SaveSettings(MousePointerCrosshairsSettingsConfig.ToJsonString(), MousePointerCrosshairsSettings.ModuleName);
}
// Dwell Cursor properties
private bool _isDwellCursorEnabled;
private int _dwellCursorDelayTimeSeconds;
private int _dwellCursorSettleTimeSeconds;
public bool IsDwellCursorEnabled
{
get => _isDwellCursorEnabled;
set
{
if (_isDwellCursorEnabled != value)
{
_isDwellCursorEnabled = value;
GeneralSettingsConfig.Enabled.DwellCursor = value;
OnPropertyChanged(nameof(IsDwellCursorEnabled));
OutGoingGeneralSettings outgoing = new OutGoingGeneralSettings(GeneralSettingsConfig);
SendConfigMSG(outgoing.ToString());
NotifyDwellCursorPropertyChanged();
}
}
}
public HotkeySettings DwellCursorActivationShortcut
{
get => DwellCursorSettingsConfig.Properties.ActivationShortcut;
set
{
if (DwellCursorSettingsConfig.Properties.ActivationShortcut != value)
{
DwellCursorSettingsConfig.Properties.ActivationShortcut = value ?? DwellCursorSettingsProperties.DefaultActivationShortcut;
NotifyDwellCursorPropertyChanged();
}
}
}
public int DwellCursorDelayTimeSeconds
{
get => _dwellCursorDelayTimeSeconds;
set
{
if (value != _dwellCursorDelayTimeSeconds)
{
_dwellCursorDelayTimeSeconds = value;
// Convert seconds to milliseconds for storage
DwellCursorSettingsConfig.Properties.DelayTimeMs.Value = value * 1000;
NotifyDwellCursorPropertyChanged();
}
}
}
public int DwellCursorSettleTimeSeconds
{
get => _dwellCursorSettleTimeSeconds;
set
{
if (value != _dwellCursorSettleTimeSeconds)
{
_dwellCursorSettleTimeSeconds = value;
DwellCursorSettingsConfig.Properties.SettleTimeSeconds.Value = value;
NotifyDwellCursorPropertyChanged();
}
}
}
public void NotifyDwellCursorPropertyChanged([CallerMemberName] string propertyName = null)
{
OnPropertyChanged(propertyName);
var outsettings = new SndDwellCursorSettings(DwellCursorSettingsConfig);
var ipcMessage = new SndModuleSettings<SndDwellCursorSettings>(outsettings);
SendConfigMSG(ipcMessage.ToJsonString());
SettingsUtils.SaveSettings(DwellCursorSettingsConfig.ToJsonString(), DwellCursorSettings.ModuleName);
}
public void RefreshEnabledState()
{
InitializeEnabledValues();
@@ -966,6 +1059,7 @@ namespace Microsoft.PowerToys.Settings.UI.ViewModels
OnPropertyChanged(nameof(IsMouseHighlighterEnabled));
OnPropertyChanged(nameof(IsMouseJumpEnabled));
OnPropertyChanged(nameof(IsMousePointerCrosshairsEnabled));
OnPropertyChanged(nameof(IsDwellCursorEnabled));
}
private Func<string, int> SendConfigMSG { get; }