Compare commits

..

10 Commits

Author SHA1 Message Date
Gordon Lam (SH)
0371b4134c Add correct header ps1 comment 2025-09-30 13:20:04 +08:00
Gordon Lam (SH)
e963125210 Fix the instruction and dump prs 2025-09-30 11:51:30 +08:00
Gordon Lam (SH)
235c3ef3e6 Change the instruction for step 5 to explicitly using SampleOutput.md as sample 2025-09-29 12:19:15 +08:00
Gordon Lam (SH)
bb67ae4068 Instruct better for agent doing 2025-09-29 12:19:15 +08:00
Gordon Lam (SH)
39646748a0 Initial draft 2025-09-29 12:19:14 +08:00
Mike Hall
a8596fed3d Add key to cancel gliding cursor (#41985)
## Summary of the Pull Request
Add low level keyboard hook to Gliding Cursor, this checks for 'Esc'
being pressed when gliding is active and cancels gliding, the mouse hook
passed keys down the hook chain.

## PR Checklist

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

## Detailed Description of the Pull Request / Additional comments
No changes to the list of binaries, no new strings for localization. The
Gliding Cursor functionality has 5 stages, these are: fast horizontal,
slow horizontal, fast vertical, slow vertical, and mouse click - adding
the keyboard hook and checking for 'Esc' allows this sequence to be
interrupted and reset to ready state.

## Validation Steps Performed
Validated Mouse Pointer Crosshairs (which Gliding Cursor is based on),
confirmed that Gliding Cursor functionality is unchanged and that the
'Esc' key cancels/resets the gliding cursor state.

---------

Co-authored-by: Gordon Lam (SH) <yeelam@microsoft.com>
2025-09-29 12:14:16 +08:00
Mike Hall
faf7c7f1a1 add horizontal and vertical options for MousePointerCrosshairs (#41789)
## Summary of the Pull Request
This PR addresses two logged issues for MousePointerCrosshairs, these
are:
https://github.com/microsoft/PowerToys/issues/24944 
https://github.com/microsoft/PowerToys/issues/31817

## PR Checklist

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

## Detailed Description of the Pull Request / Additional comments
The PR adds a new combo box to MousePointerCrosshairs XAML options, this
gives the option for 'both', 'vertical only' or 'horizontal only'. The
default option is 'both' which mirrors the existing behavior.

## Validation Steps Performed
Validation has been completed on two separate PCs, a Surface laptop 7
Pro and a Dell Workstation.

---------

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2025-09-29 10:32:07 +08:00
Dave Rayment
3b4007d299 [PowerRename] Fix issue where counter does not update if the filename and replacement result matched (#42006)
This PR fixes an issue which has been reported a number of times under
slightly different guises. The bug manifests as a counter "stall" or
"skip" under certain circumstances, not advancing the counter if the
result of the rename operation happens to match the original filename.

<!-- 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 issue occurs if all the following are true:
- Enumeration features are enabled
- Regular expression matching is enabled
- The replacement string includes a counter, for example `${}` or
`${start=1}` or `${start=10,increment=10}` etc.
- There are one or more original filenames which coincide with the
result of the renaming operations

Previously, the counter was not updated when the renaming operation
result was the same as the original filename. For example, here the
first rename result matches the original filename and the counter
remains at `1` for the second file, whereas it should be `2`:

<img width="1002" height="759" alt="image"
src="https://github.com/user-attachments/assets/2766f448-adc3-4fe7-9c13-f4c5505ae1d9"
/>

This fix increments the counter irrespective of these coincidences.

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

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

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

**Before discussing the detail of the fix, I'd like to acknowledge the
incredible coincidence that it resolves two issues (31950 and 41950)
_which are exactly 10,000 issues (and 18 months) apart_.**

Now, back to the issue at hand, if you'll forgive the pun...

The original flawed code is here:

```cpp
    bool replacedSomething = false;
    if (m_flags & UseRegularExpressions)
    {
        replaceTerm = regex_replace(replaceTerm, zeroGroupRegex, L"$1$$$0");
        replaceTerm = regex_replace(replaceTerm, otherGroupsRegex, L"$1$0$4");

        res = RegexReplaceDispatch[_useBoostLib](source, m_searchTerm, replaceTerm, m_flags & MatchAllOccurrences, !(m_flags & CaseSensitive));
        replacedSomething = originalSource != res;
    }
```

Here `replacedSomething` controls whether the counter variable is
incremented later in the code. The problem lies in the assumption that
only a replacement result which differs from the original string implies
a match. This is incorrect, as a successful match and replace operation
can still produce a result which coincides with `originalSource` (the
source filename), i.e. `originalSource == res`.

The solution is to separate the regex matching from the replacement
operation, and to advance the counter when the match is successful
irrespective of whether the result is the same as the filename. This is
what the fix does:

```cpp
    bool shouldIncrementCounter = false;
    if (m_flags & UseRegularExpressions)
    {
        replaceTerm = regex_replace(replaceTerm, zeroGroupRegex, L"$1$$$0");
        replaceTerm = regex_replace(replaceTerm, otherGroupsRegex, L"$1$0$4");

        res = RegexReplaceDispatch[_useBoostLib](source, m_searchTerm, replaceTerm, m_flags & MatchAllOccurrences, !(m_flags & CaseSensitive));

        // Use regex search to determine if a match exists. This is the basis for incrementing
        // the counter.
        if (_useBoostLib)
        {
            boost::wregex pattern(m_searchTerm, boost::wregex::ECMAScript | (!(m_flags & CaseSensitive) ? boost::wregex::icase : boost::wregex::normal));
            shouldIncrementCounter = boost::regex_search(sourceToUse, pattern);
        }
        else
        {
            auto regexFlags = std::wregex::ECMAScript;
            if (!(m_flags & CaseSensitive))
            {
                regexFlags |= std::wregex::icase;
            }
            std::wregex pattern(m_searchTerm, regexFlags);
            shouldIncrementCounter = std::regex_search(sourceToUse, pattern);
        }
    }
```

The `regex_search()` call on both the boost and std paths tests whether
the regex pattern can be found in the original filename (`sourceToUse`).
`shouldIncrementCounter` tracks whether the counter will be incremented
later, renamed from `replacedSomething` to reflect the change in
behaviour.

## Validation Steps Performed

The fix includes an additional unit test for both the std and boost
regex paths. Without the fix, the test fails:

<img width="1509" height="506" alt="Screenshot 2025-09-25 063611"
src="https://github.com/user-attachments/assets/14dbf817-b1d3-456d-80f2-abcd28266b8d"
/>

and with the fix, all tests pass:

<img width="897" height="492" alt="Screenshot 2025-09-25 063749"
src="https://github.com/user-attachments/assets/9a587495-d54c-47d3-bc55-ccc64a805a88"
/>
2025-09-29 10:05:21 +08:00
Copilot
296d8f87b6 Fix: Prevent backup directory creation during dry runs (#41460)
## Summary

This PR fixes an issue where the backup folder was being created
unnecessarily when users navigated to the General tab in PowerToys
Settings, even when no actual backup had been triggered.

## Problem

When opening PowerToys Settings and navigating to the General tab, the
backup directory (default: `~/Documents/PowerToys/Backup`) was
automatically created, even though no backup operation had been
performed. This caused confusion for users setting up PowerToys on new
devices, as they would always need to manually clean up the unwanted
default folder when configuring a custom backup path.

## Root Cause

The issue occurred because:
1. Loading the General tab triggers `RefreshBackupRestoreStatus()` 
2. This calls `DryRunBackup()` to check backup status
3. `DryRunBackup()` executes `BackupSettingsInternal()` with
`dryRun=true`
4. However, the directory creation logic (`TryCreateDirectory`) was
running regardless of the dry run flag

## Solution

The fix ensures that directory creation only happens during actual
backup operations:

**Primary Change**: Wrapped backup directory creation in a dry run
check:
```csharp
// Only create the backup directory if this is not a dry run
if (!dryRun)
{
    var dirExists = TryCreateDirectory(settingsBackupAndRestoreDir);
    if (!dirExists)
    {
        Logger.LogError($"Failed to create dir {settingsBackupAndRestoreDir}");
        return (false, $"General_SettingsBackupAndRestore_BackupError", "Error", lastBackupExists, "\n" + settingsBackupAndRestoreDir);
    }
}
```

**Consistency Change**: Also moved temporary directory creation inside
dry run checks to maintain consistent behavior throughout the backup
process.

## Impact

-  **General tab loading**: No longer creates unwanted backup
directories
-  **Actual backup functionality**: Remains completely unchanged
-  **User experience**: Clean setup without unwanted default folders
-  **No breaking changes**: All existing backup/restore features work
as before

## Testing

Created comprehensive tests to validate:
- Dry runs (General tab loading) don't create directories
- Actual backup operations create directories as expected
- No regression in existing backup/restore functionality

Fixes #38620.

> [!WARNING]
>
> <details>
> <summary>Firewall rules blocked me from connecting to one or more
addresses (expand for details)</summary>
>
> #### I tried to connect to the following addresses, but was blocked by
firewall rules:
>
> - `i1qvsblobprodcus353.vsblob.vsassets.io`
> - Triggering command: `dotnet build
src/settings-ui/Settings.UI.Library/Settings.UI.Library.csproj -c Debug
--nologo` (dns block)
>
> If you need me to access, download, or install something from one of
these locations, you can either:
>
> - Configure [Actions setup
steps](https://gh.io/copilot/actions-setup-steps) to set up my
environment, which run before the firewall is enabled
> - Add the appropriate URLs or hosts to the custom allowlist in this
repository's [Copilot coding agent
settings](https://github.com/microsoft/PowerToys/settings/copilot/coding_agent)
(admins only)
>
> </details>



<!-- START COPILOT CODING AGENT TIPS -->
---

 Let Copilot coding agent [set things up for
you](https://github.com/microsoft/PowerToys/issues/new?title=+Set+up+Copilot+instructions&body=Configure%20instructions%20for%20this%20repository%20as%20documented%20in%20%5BBest%20practices%20for%20Copilot%20coding%20agent%20in%20your%20repository%5D%28https://gh.io/copilot-coding-agent-tips%29%2E%0A%0A%3COnboard%20this%20repo%3E&assignees=copilot)
— coding agent works faster and does higher quality work when set up for
your repo.

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: yeelam-gordon <73506701+yeelam-gordon@users.noreply.github.com>
2025-09-29 09:11:28 +08:00
Shawn Yuan
8d4ed04f1a ignore holtkey conflict (#41729)
<!-- 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 implements functionality to ignore specific hotkey conflicts in
PowerToys settings. The primary purpose is to allow users to suppress
individual shortcut conflict warnings if they find their configurations
work correctly despite the detected conflicts.


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

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

<!-- Provide a more detailed description of the PR, other things fixed,
or any additional comments/features here -->
## Detailed Description of the Pull Request / Additional comments
- Added hotkey conflict ignore functionality with user-controllable
settings
- Updated shortcut control UI to support ignore states and clearer
conflict messaging
- Enhanced conflict detection to respect ignored shortcuts when counting
conflicts

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

---------

Signed-off-by: Shawn Yuan <shuaiyuan@microsoft.com>
Signed-off-by: Shuai Yuan <shuai.yuan.zju@gmail.com>
Signed-off-by: Shawn Yuan (from Dev Box) <shuaiyuan@microsoft.com>
Co-authored-by: Niels Laute <niels.laute@live.nl>
2025-09-29 08:53:07 +08:00
56 changed files with 2429 additions and 1226 deletions

View File

@@ -580,7 +580,6 @@ GETSCREENSAVERRUNNING
GETSECKEY
GETSTICKYKEYS
GETTEXTLENGTH
gitmodules
GHND
GMEM
GNumber
@@ -1344,7 +1343,6 @@ PRTL
prvpane
psapi
pscid
pscustomobject
PSECURITY
psfgao
psfi
@@ -1999,7 +1997,6 @@ WORKSPACESEDITOR
WORKSPACESLAUNCHER
WORKSPACESSNAPSHOTTOOL
WORKSPACESWINDOWARRANGER
Worktree
wox
wparam
wpf

View File

@@ -94,6 +94,21 @@ public:
}
}
static void SetCrosshairsOrientation(CrosshairsOrientation orientation)
{
if (instance != nullptr)
{
auto dispatcherQueue = instance->m_dispatcherQueueController.DispatcherQueue();
dispatcherQueue.TryEnqueue([orientation]() {
if (instance != nullptr)
{
instance->m_crosshairs_orientation = orientation;
instance->UpdateCrosshairsPosition();
}
});
}
}
private:
enum class MouseButton
{
@@ -147,6 +162,7 @@ private:
int m_crosshairs_border_size = INCLUSIVE_MOUSE_DEFAULT_CROSSHAIRS_BORDER_SIZE;
bool m_crosshairs_is_fixed_length_enabled = INCLUSIVE_MOUSE_DEFAULT_CROSSHAIRS_IS_FIXED_LENGTH_ENABLED;
int m_crosshairs_fixed_length = INCLUSIVE_MOUSE_DEFAULT_CROSSHAIRS_FIXED_LENGTH;
CrosshairsOrientation m_crosshairs_orientation = static_cast<CrosshairsOrientation>(INCLUSIVE_MOUSE_DEFAULT_CROSSHAIRS_ORIENTATION);
float m_crosshairs_opacity = max(0.f, min(1.f, (float)INCLUSIVE_MOUSE_DEFAULT_CROSSHAIRS_OPACITY / 100.0f));
bool m_crosshairs_auto_hide = INCLUSIVE_MOUSE_DEFAULT_AUTO_HIDE;
};
@@ -286,6 +302,8 @@ void InclusiveCrosshairs::UpdateCrosshairsPosition()
float halfPixelAdjustment = m_crosshairs_thickness % 2 == 1 ? 0.5f : 0.0f;
float borderSizePadding = m_crosshairs_border_size * 2.f;
// Left and Right crosshairs (horizontal line)
if (m_crosshairs_orientation == CrosshairsOrientation::Both || m_crosshairs_orientation == CrosshairsOrientation::HorizontalOnly)
{
float leftCrosshairsFullScreenLength = ptCursor.x - ptMonitorUpperLeft.x - m_crosshairs_radius + halfPixelAdjustment * 2.f;
float leftCrosshairsLength = m_crosshairs_is_fixed_length_enabled ? m_crosshairs_fixed_length : leftCrosshairsFullScreenLength;
@@ -294,9 +312,7 @@ void InclusiveCrosshairs::UpdateCrosshairsPosition()
m_left_crosshairs_border.Size({ leftCrosshairsBorderLength, m_crosshairs_thickness + borderSizePadding });
m_left_crosshairs.Offset({ ptCursor.x - m_crosshairs_radius + halfPixelAdjustment * 2.f, ptCursor.y + halfPixelAdjustment, .0f });
m_left_crosshairs.Size({ leftCrosshairsLength, static_cast<float>(m_crosshairs_thickness) });
}
{
float rightCrosshairsFullScreenLength = static_cast<float>(ptMonitorBottomRight.x) - ptCursor.x - m_crosshairs_radius;
float rightCrosshairsLength = m_crosshairs_is_fixed_length_enabled ? m_crosshairs_fixed_length : rightCrosshairsFullScreenLength;
float rightCrosshairsBorderLength = m_crosshairs_is_fixed_length_enabled ? m_crosshairs_fixed_length + borderSizePadding : rightCrosshairsFullScreenLength + m_crosshairs_border_size;
@@ -305,7 +321,17 @@ void InclusiveCrosshairs::UpdateCrosshairsPosition()
m_right_crosshairs.Offset({ static_cast<float>(ptCursor.x) + m_crosshairs_radius, ptCursor.y + halfPixelAdjustment, .0f });
m_right_crosshairs.Size({ rightCrosshairsLength, static_cast<float>(m_crosshairs_thickness) });
}
else
{
// Hide horizontal crosshairs by setting size to 0
m_left_crosshairs_border.Size({ 0.0f, 0.0f });
m_left_crosshairs.Size({ 0.0f, 0.0f });
m_right_crosshairs_border.Size({ 0.0f, 0.0f });
m_right_crosshairs.Size({ 0.0f, 0.0f });
}
// Top and Bottom crosshairs (vertical line)
if (m_crosshairs_orientation == CrosshairsOrientation::Both || m_crosshairs_orientation == CrosshairsOrientation::VerticalOnly)
{
float topCrosshairsFullScreenLength = ptCursor.y - ptMonitorUpperLeft.y - m_crosshairs_radius + halfPixelAdjustment * 2.f;
float topCrosshairsLength = m_crosshairs_is_fixed_length_enabled ? m_crosshairs_fixed_length : topCrosshairsFullScreenLength;
@@ -314,9 +340,7 @@ void InclusiveCrosshairs::UpdateCrosshairsPosition()
m_top_crosshairs_border.Size({ m_crosshairs_thickness + borderSizePadding, topCrosshairsBorderLength });
m_top_crosshairs.Offset({ ptCursor.x + halfPixelAdjustment, ptCursor.y - m_crosshairs_radius + halfPixelAdjustment * 2.f, .0f });
m_top_crosshairs.Size({ static_cast<float>(m_crosshairs_thickness), topCrosshairsLength });
}
{
float bottomCrosshairsFullScreenLength = static_cast<float>(ptMonitorBottomRight.y) - ptCursor.y - m_crosshairs_radius;
float bottomCrosshairsLength = m_crosshairs_is_fixed_length_enabled ? m_crosshairs_fixed_length : bottomCrosshairsFullScreenLength;
float bottomCrosshairsBorderLength = m_crosshairs_is_fixed_length_enabled ? m_crosshairs_fixed_length + borderSizePadding : bottomCrosshairsFullScreenLength + m_crosshairs_border_size;
@@ -325,6 +349,14 @@ void InclusiveCrosshairs::UpdateCrosshairsPosition()
m_bottom_crosshairs.Offset({ ptCursor.x + halfPixelAdjustment, static_cast<float>(ptCursor.y) + m_crosshairs_radius, .0f });
m_bottom_crosshairs.Size({ static_cast<float>(m_crosshairs_thickness), bottomCrosshairsLength });
}
else
{
// Hide vertical crosshairs by setting size to 0
m_top_crosshairs_border.Size({ 0.0f, 0.0f });
m_top_crosshairs.Size({ 0.0f, 0.0f });
m_bottom_crosshairs_border.Size({ 0.0f, 0.0f });
m_bottom_crosshairs.Size({ 0.0f, 0.0f });
}
}
LRESULT CALLBACK InclusiveCrosshairs::MouseHookProc(int nCode, WPARAM wParam, LPARAM lParam) noexcept
@@ -398,6 +430,7 @@ void InclusiveCrosshairs::ApplySettings(InclusiveCrosshairsSettings& settings, b
m_crosshairs_auto_hide = settings.crosshairsAutoHide;
m_crosshairs_is_fixed_length_enabled = settings.crosshairsIsFixedLengthEnabled;
m_crosshairs_fixed_length = settings.crosshairsFixedLength;
m_crosshairs_orientation = settings.crosshairsOrientation;
if (applyToRunTimeObjects)
{
@@ -618,6 +651,11 @@ void InclusiveCrosshairsSetExternalControl(bool enabled)
InclusiveCrosshairs::SetExternalControl(enabled);
}
void InclusiveCrosshairsSetOrientation(CrosshairsOrientation orientation)
{
InclusiveCrosshairs::SetCrosshairsOrientation(orientation);
}
int InclusiveCrosshairsMain(HINSTANCE hInstance, InclusiveCrosshairsSettings& settings)
{
Logger::info("Starting a crosshairs instance.");

View File

@@ -10,8 +10,16 @@ constexpr int INCLUSIVE_MOUSE_DEFAULT_CROSSHAIRS_BORDER_SIZE = 1;
constexpr bool INCLUSIVE_MOUSE_DEFAULT_AUTO_HIDE = false;
constexpr bool INCLUSIVE_MOUSE_DEFAULT_CROSSHAIRS_IS_FIXED_LENGTH_ENABLED = false;
constexpr int INCLUSIVE_MOUSE_DEFAULT_CROSSHAIRS_FIXED_LENGTH = 1;
constexpr int INCLUSIVE_MOUSE_DEFAULT_CROSSHAIRS_ORIENTATION = 0; // 0=Both, 1=Vertical, 2=Horizontal
constexpr bool INCLUSIVE_MOUSE_DEFAULT_AUTO_ACTIVATE = false;
enum struct CrosshairsOrientation : int
{
Both = 0,
VerticalOnly = 1,
HorizontalOnly = 2,
};
struct InclusiveCrosshairsSettings
{
winrt::Windows::UI::Color crosshairsColor = INCLUSIVE_MOUSE_DEFAULT_CROSSHAIRS_COLOR;
@@ -23,6 +31,7 @@ struct InclusiveCrosshairsSettings
bool crosshairsAutoHide = INCLUSIVE_MOUSE_DEFAULT_AUTO_HIDE;
bool crosshairsIsFixedLengthEnabled = INCLUSIVE_MOUSE_DEFAULT_CROSSHAIRS_IS_FIXED_LENGTH_ENABLED;
int crosshairsFixedLength = INCLUSIVE_MOUSE_DEFAULT_CROSSHAIRS_FIXED_LENGTH;
CrosshairsOrientation crosshairsOrientation = static_cast<CrosshairsOrientation>(INCLUSIVE_MOUSE_DEFAULT_CROSSHAIRS_ORIENTATION);
bool autoActivate = INCLUSIVE_MOUSE_DEFAULT_AUTO_ACTIVATE;
};
@@ -35,3 +44,4 @@ void InclusiveCrosshairsRequestUpdatePosition();
void InclusiveCrosshairsEnsureOn();
void InclusiveCrosshairsEnsureOff();
void InclusiveCrosshairsSetExternalControl(bool enabled);
void InclusiveCrosshairsSetOrientation(CrosshairsOrientation orientation);

View File

@@ -80,7 +80,7 @@
</ItemDefinitionGroup>
<ItemDefinitionGroup>
<ClCompile>
<AdditionalIncludeDirectories>$(SolutionDir)src\;$(SolutionDir)src\modules;$(SolutionDir)src\common\Telemetry;%(AdditionalIncludeDirectories)</AdditionalIncludeDirectories>
<AdditionalIncludeDirectories>..\..\..\;..\..\..\modules;..\..\..\common\Telemetry;%(AdditionalIncludeDirectories)</AdditionalIncludeDirectories>
</ClCompile>
</ItemDefinitionGroup>
<ItemGroup>

View File

@@ -8,6 +8,7 @@
#include <thread>
#include <chrono>
#include <memory>
#include <algorithm>
extern void InclusiveCrosshairsRequestUpdatePosition();
extern void InclusiveCrosshairsEnsureOn();
@@ -30,6 +31,7 @@ namespace
const wchar_t JSON_KEY_CROSSHAIRS_AUTO_HIDE[] = L"crosshairs_auto_hide";
const wchar_t JSON_KEY_CROSSHAIRS_IS_FIXED_LENGTH_ENABLED[] = L"crosshairs_is_fixed_length_enabled";
const wchar_t JSON_KEY_CROSSHAIRS_FIXED_LENGTH[] = L"crosshairs_fixed_length";
const wchar_t JSON_KEY_CROSSHAIRS_ORIENTATION[] = L"crosshairs_orientation";
const wchar_t JSON_KEY_AUTO_ACTIVATE[] = L"auto_activate";
const wchar_t JSON_KEY_GLIDE_TRAVEL_SPEED[] = L"gliding_travel_speed";
const wchar_t JSON_KEY_GLIDE_DELAY_SPEED[] = L"gliding_delay_speed";
@@ -62,6 +64,9 @@ const static wchar_t* MODULE_NAME = L"MousePointerCrosshairs";
// Add a description that will we shown in the module settings page.
const static wchar_t* MODULE_DESC = L"<no description>";
class MousePointerCrosshairs; // fwd
static std::atomic<MousePointerCrosshairs*> g_instance{ nullptr }; // for hook callback
// Implement the PowerToy Module Interface and all the required methods.
class MousePointerCrosshairs : public PowertoyModuleIface
{
@@ -70,8 +75,11 @@ private:
bool m_enabled = false;
// Additional hotkeys (legacy API) to support multiple shortcuts
Hotkey m_activationHotkey{}; // Crosshairs toggle
Hotkey m_glidingHotkey{}; // Gliding cursor state machine
Hotkey m_activationHotkey{}; // Crosshairs toggle
Hotkey m_glidingHotkey{}; // Gliding cursor state machine
// Low-level keyboard hook (Escape to cancel gliding)
HHOOK m_keyboardHook = nullptr;
// Shared state for worker threads (decoupled from this lifetime)
struct State
@@ -84,7 +92,7 @@ private:
int currentYPos{ 0 };
int currentXSpeed{ 0 }; // pixels per base window
int currentYSpeed{ 0 }; // pixels per base window
int xPosSnapshot{ 0 }; // xPos captured at end of horizontal scan
int xPosSnapshot{ 0 }; // xPos captured at end of horizontal scan
// Fractional accumulators to spread movement across 10ms ticks
double xFraction{ 0.0 };
@@ -92,9 +100,9 @@ private:
// Speeds represent pixels per 200ms (min 5, max 60 enforced by UI/settings)
int fastHSpeed{ 30 }; // pixels per base window
int slowHSpeed{ 5 }; // pixels per base window
int slowHSpeed{ 5 }; // pixels per base window
int fastVSpeed{ 30 }; // pixels per base window
int slowVSpeed{ 5 }; // pixels per base window
int slowVSpeed{ 5 }; // pixels per base window
};
std::shared_ptr<State> m_state;
@@ -120,13 +128,16 @@ public:
LoggerHelpers::init_logger(MODULE_NAME, L"ModuleInterface", LogSettings::mousePointerCrosshairsLoggerName);
m_state = std::make_shared<State>();
init_settings();
g_instance.store(this, std::memory_order_release);
};
// Destroy the powertoy and free memory
virtual void destroy() override
{
UninstallKeyboardHook();
StopXTimer();
StopYTimer();
g_instance.store(nullptr, std::memory_order_release);
// Release shared state so worker threads (if any) exit when weak_ptr lock fails
m_state.reset();
delete this;
@@ -196,6 +207,7 @@ public:
{
m_enabled = false;
Trace::EnableMousePointerCrosshairs(false);
UninstallKeyboardHook();
StopXTimer();
StopYTimer();
m_glideState = 0;
@@ -220,7 +232,7 @@ public:
if (buffer && buffer_size >= 2)
{
buffer[0] = m_activationHotkey; // Crosshairs toggle
buffer[1] = m_glidingHotkey; // Gliding cursor toggle
buffer[1] = m_glidingHotkey; // Gliding cursor toggle
}
return 2;
}
@@ -256,6 +268,27 @@ private:
SendInput(2, inputs, sizeof(INPUT));
}
// Cancel gliding without performing the final click (Escape handling)
void CancelGliding()
{
int state = m_glideState.load();
if (state == 0)
{
return; // nothing to cancel
}
StopXTimer();
StopYTimer();
m_glideState = 0;
InclusiveCrosshairsEnsureOff();
InclusiveCrosshairsSetExternalControl(false);
if (auto s = m_state)
{
s->xFraction = 0.0;
s->yFraction = 0.0;
}
Logger::debug("Gliding cursor cancelled via Escape key");
}
// Stateless helpers operating on shared State
static void PositionCursorX(const std::shared_ptr<State>& s)
{
@@ -398,10 +431,14 @@ private:
{
case 0:
{
// For detect for cancel key
InstallKeyboardHook();
// Ensure crosshairs on (do not toggle off if already on)
InclusiveCrosshairsEnsureOn();
// Disable internal mouse hook so we control position updates explicitly
InclusiveCrosshairsSetExternalControl(true);
// Override crosshairs to show both for Gliding Cursor
InclusiveCrosshairsSetOrientation(CrosshairsOrientation::Both);
s->currentXPos = 0;
s->currentXSpeed = s->fastHSpeed;
@@ -444,12 +481,15 @@ private:
case 4:
default:
{
UninstallKeyboardHook();
// Stop vertical, click, turn crosshairs off, re-enable internal tracking, reset state
StopYTimer();
m_glideState = 0;
LeftClick();
InclusiveCrosshairsEnsureOff();
InclusiveCrosshairsSetExternalControl(false);
// Restore original crosshairs orientation setting
InclusiveCrosshairsSetOrientation(m_inclusiveCrosshairsSettings.crosshairsOrientation);
s->xFraction = 0.0;
s->yFraction = 0.0;
break;
@@ -457,6 +497,51 @@ private:
}
}
// Low-level keyboard hook procedures
static LRESULT CALLBACK LowLevelKeyboardProc(int nCode, WPARAM wParam, LPARAM lParam)
{
if (nCode == HC_ACTION)
{
const KBDLLHOOKSTRUCT* kb = reinterpret_cast<KBDLLHOOKSTRUCT*>(lParam);
if (kb && kb->vkCode == VK_ESCAPE && (wParam == WM_KEYDOWN || wParam == WM_SYSKEYDOWN))
{
if (auto inst = g_instance.load(std::memory_order_acquire))
{
if (inst->m_enabled && inst->m_glideState.load() != 0)
{
inst->UninstallKeyboardHook();
inst->CancelGliding();
}
}
}
}
// Do not swallow Escape; pass it through
return CallNextHookEx(nullptr, nCode, wParam, lParam);
}
void InstallKeyboardHook()
{
if (m_keyboardHook)
{
return; // already installed
}
m_keyboardHook = SetWindowsHookEx(WH_KEYBOARD_LL, LowLevelKeyboardProc, m_hModule, 0);
if (!m_keyboardHook)
{
Logger::error("Failed to install low-level keyboard hook for MousePointerCrosshairs (Escape cancel). GetLastError={}.", GetLastError());
}
}
void UninstallKeyboardHook()
{
if (m_keyboardHook)
{
UnhookWindowsHookEx(m_keyboardHook);
m_keyboardHook = nullptr;
}
}
// Load the settings file.
void init_settings()
{
@@ -475,264 +560,287 @@ private:
void parse_settings(PowerToysSettings::PowerToyValues& settings)
{
// TODO: refactor to use common/utils/json.h instead
// Refactored JSON parsing: uses inline try-catch blocks for each property for clarity and error handling
auto settingsObject = settings.get_raw_json();
InclusiveCrosshairsSettings inclusiveCrosshairsSettings;
if (settingsObject.GetView().Size())
{
try
{
// Parse primary activation HotKey (for centralized hook)
auto jsonPropertiesObject = settingsObject.GetNamedObject(JSON_KEY_PROPERTIES).GetNamedObject(JSON_KEY_ACTIVATION_SHORTCUT);
auto hotkey = PowerToysSettings::HotkeyObject::from_json(jsonPropertiesObject);
auto propertiesObject = settingsObject.GetNamedObject(JSON_KEY_PROPERTIES);
// Parse activation hotkey
try
{
auto jsonHotkeyObject = propertiesObject.GetNamedObject(JSON_KEY_ACTIVATION_SHORTCUT);
auto hotkey = PowerToysSettings::HotkeyObject::from_json(jsonHotkeyObject);
m_activationHotkey.win = hotkey.win_pressed();
m_activationHotkey.ctrl = hotkey.ctrl_pressed();
m_activationHotkey.shift = hotkey.shift_pressed();
m_activationHotkey.alt = hotkey.alt_pressed();
m_activationHotkey.key = static_cast<unsigned char>(hotkey.get_code());
}
catch (...)
{
Logger::warn("Failed to initialize Mouse Pointer Crosshairs activation shortcut");
}
// Map to legacy Hotkey for multi-hotkey API
m_activationHotkey.win = hotkey.win_pressed();
m_activationHotkey.ctrl = hotkey.ctrl_pressed();
m_activationHotkey.shift = hotkey.shift_pressed();
m_activationHotkey.alt = hotkey.alt_pressed();
m_activationHotkey.key = static_cast<unsigned char>(hotkey.get_code());
}
catch (...)
{
Logger::warn("Failed to initialize Mouse Pointer Crosshairs activation shortcut");
}
try
{
// Parse Gliding Cursor HotKey
auto jsonPropertiesObject = settingsObject.GetNamedObject(JSON_KEY_PROPERTIES).GetNamedObject(JSON_KEY_GLIDING_ACTIVATION_SHORTCUT);
auto hotkey = PowerToysSettings::HotkeyObject::from_json(jsonPropertiesObject);
m_glidingHotkey.win = hotkey.win_pressed();
m_glidingHotkey.ctrl = hotkey.ctrl_pressed();
m_glidingHotkey.shift = hotkey.shift_pressed();
m_glidingHotkey.alt = hotkey.alt_pressed();
m_glidingHotkey.key = static_cast<unsigned char>(hotkey.get_code());
}
catch (...)
{
// note that this is also defined in src\settings-ui\Settings.UI.Library\MousePointerCrosshairsProperties.cs, DefaultGlidingCursorActivationShortcut
// both need to be kept in sync!
Logger::warn("Failed to initialize Gliding Cursor activation shortcut. Using default Win+Alt+.");
m_glidingHotkey.win = true;
m_glidingHotkey.alt = true;
m_glidingHotkey.ctrl = false;
m_glidingHotkey.shift = false;
m_glidingHotkey.key = VK_OEM_PERIOD;
}
try
{
// Parse Opacity
auto jsonPropertiesObject = settingsObject.GetNamedObject(JSON_KEY_PROPERTIES).GetNamedObject(JSON_KEY_CROSSHAIRS_OPACITY);
int value = static_cast<uint8_t>(jsonPropertiesObject.GetNamedNumber(JSON_KEY_VALUE));
if (value >= 0)
// Parse gliding cursor hotkey
try
{
inclusiveCrosshairsSettings.crosshairsOpacity = value;
auto jsonHotkeyObject = propertiesObject.GetNamedObject(JSON_KEY_GLIDING_ACTIVATION_SHORTCUT);
auto hotkey = PowerToysSettings::HotkeyObject::from_json(jsonHotkeyObject);
m_glidingHotkey.win = hotkey.win_pressed();
m_glidingHotkey.ctrl = hotkey.ctrl_pressed();
m_glidingHotkey.shift = hotkey.shift_pressed();
m_glidingHotkey.alt = hotkey.alt_pressed();
m_glidingHotkey.key = static_cast<unsigned char>(hotkey.get_code());
}
else
catch (...)
{
throw std::runtime_error("Invalid Opacity value");
Logger::warn("Failed to initialize Gliding Cursor activation shortcut. Using default Win+Alt+.");
m_glidingHotkey.win = true;
m_glidingHotkey.alt = true;
m_glidingHotkey.ctrl = false;
m_glidingHotkey.shift = false;
m_glidingHotkey.key = VK_OEM_PERIOD;
}
// Parse individual properties with error handling and defaults
try
{
if (propertiesObject.HasKey(L"crosshairs_opacity"))
{
auto propertyObj = propertiesObject.GetNamedObject(L"crosshairs_opacity");
if (propertyObj.HasKey(L"value"))
{
inclusiveCrosshairsSettings.crosshairsOpacity = static_cast<int>(propertyObj.GetNamedNumber(L"value"));
}
}
}
catch (...) { /* Use default value */ }
try
{
if (propertiesObject.HasKey(L"crosshairs_radius"))
{
auto propertyObj = propertiesObject.GetNamedObject(L"crosshairs_radius");
if (propertyObj.HasKey(L"value"))
{
inclusiveCrosshairsSettings.crosshairsRadius = static_cast<int>(propertyObj.GetNamedNumber(L"value"));
}
}
}
catch (...) { /* Use default value */ }
try
{
if (propertiesObject.HasKey(L"crosshairs_thickness"))
{
auto propertyObj = propertiesObject.GetNamedObject(L"crosshairs_thickness");
if (propertyObj.HasKey(L"value"))
{
inclusiveCrosshairsSettings.crosshairsThickness = static_cast<int>(propertyObj.GetNamedNumber(L"value"));
}
}
}
catch (...) { /* Use default value */ }
try
{
if (propertiesObject.HasKey(L"crosshairs_border_size"))
{
auto propertyObj = propertiesObject.GetNamedObject(L"crosshairs_border_size");
if (propertyObj.HasKey(L"value"))
{
inclusiveCrosshairsSettings.crosshairsBorderSize = static_cast<int>(propertyObj.GetNamedNumber(L"value"));
}
}
}
catch (...) { /* Use default value */ }
try
{
if (propertiesObject.HasKey(L"crosshairs_fixed_length"))
{
auto propertyObj = propertiesObject.GetNamedObject(L"crosshairs_fixed_length");
if (propertyObj.HasKey(L"value"))
{
inclusiveCrosshairsSettings.crosshairsFixedLength = static_cast<int>(propertyObj.GetNamedNumber(L"value"));
}
}
}
catch (...) { /* Use default value */ }
try
{
if (propertiesObject.HasKey(L"crosshairs_auto_hide"))
{
auto propertyObj = propertiesObject.GetNamedObject(L"crosshairs_auto_hide");
if (propertyObj.HasKey(L"value"))
{
inclusiveCrosshairsSettings.crosshairsAutoHide = propertyObj.GetNamedBoolean(L"value");
}
}
}
catch (...) { /* Use default value */ }
try
{
if (propertiesObject.HasKey(L"crosshairs_is_fixed_length_enabled"))
{
auto propertyObj = propertiesObject.GetNamedObject(L"crosshairs_is_fixed_length_enabled");
if (propertyObj.HasKey(L"value"))
{
inclusiveCrosshairsSettings.crosshairsIsFixedLengthEnabled = propertyObj.GetNamedBoolean(L"value");
}
}
}
catch (...) { /* Use default value */ }
try
{
if (propertiesObject.HasKey(L"auto_activate"))
{
auto propertyObj = propertiesObject.GetNamedObject(L"auto_activate");
if (propertyObj.HasKey(L"value"))
{
inclusiveCrosshairsSettings.autoActivate = propertyObj.GetNamedBoolean(L"value");
}
}
}
catch (...) { /* Use default value */ }
// Parse orientation with validation - this fixes the original issue!
try
{
if (propertiesObject.HasKey(L"crosshairs_orientation"))
{
auto propertyObj = propertiesObject.GetNamedObject(L"crosshairs_orientation");
if (propertyObj.HasKey(L"value"))
{
int orientationValue = static_cast<int>(propertyObj.GetNamedNumber(L"value"));
if (orientationValue >= 0 && orientationValue <= 2)
{
inclusiveCrosshairsSettings.crosshairsOrientation = static_cast<CrosshairsOrientation>(orientationValue);
}
}
}
}
catch (...) { /* Use default value (Both = 0) */ }
// Parse colors with validation
try
{
if (propertiesObject.HasKey(L"crosshairs_color"))
{
auto propertyObj = propertiesObject.GetNamedObject(L"crosshairs_color");
if (propertyObj.HasKey(L"value"))
{
std::wstring crosshairsColorValue = std::wstring(propertyObj.GetNamedString(L"value").c_str());
uint8_t r, g, b;
if (checkValidRGB(crosshairsColorValue, &r, &g, &b))
{
inclusiveCrosshairsSettings.crosshairsColor = winrt::Windows::UI::ColorHelper::FromArgb(255, r, g, b);
}
}
}
}
catch (...) { /* Use default color */ }
try
{
if (propertiesObject.HasKey(L"crosshairs_border_color"))
{
auto propertyObj = propertiesObject.GetNamedObject(L"crosshairs_border_color");
if (propertyObj.HasKey(L"value"))
{
std::wstring borderColorValue = std::wstring(propertyObj.GetNamedString(L"value").c_str());
uint8_t r, g, b;
if (checkValidRGB(borderColorValue, &r, &g, &b))
{
inclusiveCrosshairsSettings.crosshairsBorderColor = winrt::Windows::UI::ColorHelper::FromArgb(255, r, g, b);
}
}
}
}
catch (...) { /* Use default border color */ }
// Parse speed settings with validation
try
{
if (propertiesObject.HasKey(L"gliding_travel_speed"))
{
auto propertyObj = propertiesObject.GetNamedObject(L"gliding_travel_speed");
if (propertyObj.HasKey(L"value") && m_state)
{
int travelSpeedValue = static_cast<int>(propertyObj.GetNamedNumber(L"value"));
if (travelSpeedValue >= 5 && travelSpeedValue <= 60)
{
m_state->fastHSpeed = travelSpeedValue;
m_state->fastVSpeed = travelSpeedValue;
}
else
{
// Clamp to valid range
int clampedValue = travelSpeedValue;
if (clampedValue < 5) clampedValue = 5;
if (clampedValue > 60) clampedValue = 60;
m_state->fastHSpeed = clampedValue;
m_state->fastVSpeed = clampedValue;
Logger::warn("Travel speed value out of range, clamped to valid range");
}
}
}
}
catch (...)
{
if (m_state)
{
m_state->fastHSpeed = 25;
m_state->fastVSpeed = 25;
}
}
try
{
if (propertiesObject.HasKey(L"gliding_delay_speed"))
{
auto propertyObj = propertiesObject.GetNamedObject(L"gliding_delay_speed");
if (propertyObj.HasKey(L"value") && m_state)
{
int delaySpeedValue = static_cast<int>(propertyObj.GetNamedNumber(L"value"));
if (delaySpeedValue >= 5 && delaySpeedValue <= 60)
{
m_state->slowHSpeed = delaySpeedValue;
m_state->slowVSpeed = delaySpeedValue;
}
else
{
// Clamp to valid range
int clampedValue = delaySpeedValue;
if (clampedValue < 5) clampedValue = 5;
if (clampedValue > 60) clampedValue = 60;
m_state->slowHSpeed = clampedValue;
m_state->slowVSpeed = clampedValue;
Logger::warn("Delay speed value out of range, clamped to valid range");
}
}
}
}
catch (...)
{
if (m_state)
{
m_state->slowHSpeed = 5;
m_state->slowVSpeed = 5;
}
}
}
catch (...)
{
Logger::warn("Failed to initialize Opacity from settings. Will use default value");
}
try
{
// Parse crosshairs color
auto jsonPropertiesObject = settingsObject.GetNamedObject(JSON_KEY_PROPERTIES).GetNamedObject(JSON_KEY_CROSSHAIRS_COLOR);
auto crosshairsColor = (std::wstring)jsonPropertiesObject.GetNamedString(JSON_KEY_VALUE);
uint8_t r, g, b;
if (!checkValidRGB(crosshairsColor, &r, &g, &b))
{
Logger::error("Crosshairs color RGB value is invalid. Will use default value");
}
else
{
inclusiveCrosshairsSettings.crosshairsColor = winrt::Windows::UI::ColorHelper::FromArgb(255, r, g, b);
}
}
catch (...)
{
Logger::warn("Failed to initialize crosshairs color from settings. Will use default value");
}
try
{
// Parse Radius
auto jsonPropertiesObject = settingsObject.GetNamedObject(JSON_KEY_PROPERTIES).GetNamedObject(JSON_KEY_CROSSHAIRS_RADIUS);
int value = static_cast<int>(jsonPropertiesObject.GetNamedNumber(JSON_KEY_VALUE));
if (value >= 0)
{
inclusiveCrosshairsSettings.crosshairsRadius = value;
}
else
{
throw std::runtime_error("Invalid Radius value");
}
}
catch (...)
{
Logger::warn("Failed to initialize Radius from settings. Will use default value");
}
try
{
// Parse Thickness
auto jsonPropertiesObject = settingsObject.GetNamedObject(JSON_KEY_PROPERTIES).GetNamedObject(JSON_KEY_CROSSHAIRS_THICKNESS);
int value = static_cast<int>(jsonPropertiesObject.GetNamedNumber(JSON_KEY_VALUE));
if (value >= 0)
{
inclusiveCrosshairsSettings.crosshairsThickness = value;
}
else
{
throw std::runtime_error("Invalid Thickness value");
}
}
catch (...)
{
Logger::warn("Failed to initialize Thickness from settings. Will use default value");
}
try
{
// Parse crosshairs border color
auto jsonPropertiesObject = settingsObject.GetNamedObject(JSON_KEY_PROPERTIES).GetNamedObject(JSON_KEY_CROSSHAIRS_BORDER_COLOR);
auto crosshairsBorderColor = (std::wstring)jsonPropertiesObject.GetNamedString(JSON_KEY_VALUE);
uint8_t r, g, b;
if (!checkValidRGB(crosshairsBorderColor, &r, &g, &b))
{
Logger::error("Crosshairs border color RGB value is invalid. Will use default value");
}
else
{
inclusiveCrosshairsSettings.crosshairsBorderColor = winrt::Windows::UI::ColorHelper::FromArgb(255, r, g, b);
}
}
catch (...)
{
Logger::warn("Failed to initialize crosshairs border color from settings. Will use default value");
}
try
{
// Parse border size
auto jsonPropertiesObject = settingsObject.GetNamedObject(JSON_KEY_PROPERTIES).GetNamedObject(JSON_KEY_CROSSHAIRS_BORDER_SIZE);
int value = static_cast<int>(jsonPropertiesObject.GetNamedNumber(JSON_KEY_VALUE));
if (value >= 0)
{
inclusiveCrosshairsSettings.crosshairsBorderSize = value;
}
else
{
throw std::runtime_error("Invalid Border Color value");
}
}
catch (...)
{
Logger::warn("Failed to initialize border color from settings. Will use default value");
}
try
{
// Parse auto hide
auto jsonPropertiesObject = settingsObject.GetNamedObject(JSON_KEY_PROPERTIES).GetNamedObject(JSON_KEY_CROSSHAIRS_AUTO_HIDE);
inclusiveCrosshairsSettings.crosshairsAutoHide = jsonPropertiesObject.GetNamedBoolean(JSON_KEY_VALUE);
}
catch (...)
{
Logger::warn("Failed to initialize auto hide from settings. Will use default value");
}
try
{
// Parse whether the fixed length is enabled
auto jsonPropertiesObject = settingsObject.GetNamedObject(JSON_KEY_PROPERTIES).GetNamedObject(JSON_KEY_CROSSHAIRS_IS_FIXED_LENGTH_ENABLED);
bool value = jsonPropertiesObject.GetNamedBoolean(JSON_KEY_VALUE);
inclusiveCrosshairsSettings.crosshairsIsFixedLengthEnabled = value;
}
catch (...)
{
Logger::warn("Failed to initialize fixed length enabled from settings. Will use default value");
}
try
{
// Parse fixed length
auto jsonPropertiesObject = settingsObject.GetNamedObject(JSON_KEY_PROPERTIES).GetNamedObject(JSON_KEY_CROSSHAIRS_FIXED_LENGTH);
int value = static_cast<int>(jsonPropertiesObject.GetNamedNumber(JSON_KEY_VALUE));
if (value >= 0)
{
inclusiveCrosshairsSettings.crosshairsFixedLength = value;
}
else
{
throw std::runtime_error("Invalid Fixed Length value");
}
}
catch (...)
{
Logger::warn("Failed to initialize fixed length from settings. Will use default value");
}
try
{
// Parse auto activate
auto jsonPropertiesObject = settingsObject.GetNamedObject(JSON_KEY_PROPERTIES).GetNamedObject(JSON_KEY_AUTO_ACTIVATE);
inclusiveCrosshairsSettings.autoActivate = jsonPropertiesObject.GetNamedBoolean(JSON_KEY_VALUE);
}
catch (...)
{
Logger::warn("Failed to initialize auto activate from settings. Will use default value");
}
try
{
// Parse Travel speed (fast speed mapping)
auto jsonPropertiesObject = settingsObject.GetNamedObject(JSON_KEY_PROPERTIES).GetNamedObject(JSON_KEY_GLIDE_TRAVEL_SPEED);
int value = static_cast<int>(jsonPropertiesObject.GetNamedNumber(JSON_KEY_VALUE));
if (value >= 5 && value <= 60)
{
m_state->fastHSpeed = value;
m_state->fastVSpeed = value;
}
else if (value < 5)
{
m_state->fastHSpeed = 5; m_state->fastVSpeed = 5;
}
else
{
m_state->fastHSpeed = 60; m_state->fastVSpeed = 60;
}
}
catch (...)
{
Logger::warn("Failed to initialize gliding travel speed from settings. Using default 25.");
if (m_state)
{
m_state->fastHSpeed = 25;
m_state->fastVSpeed = 25;
}
}
try
{
// Parse Delay speed (slow speed mapping)
auto jsonPropertiesObject = settingsObject.GetNamedObject(JSON_KEY_PROPERTIES).GetNamedObject(JSON_KEY_GLIDE_DELAY_SPEED);
int value = static_cast<int>(jsonPropertiesObject.GetNamedNumber(JSON_KEY_VALUE));
if (value >= 5 && value <= 60)
{
m_state->slowHSpeed = value;
m_state->slowVSpeed = value;
}
else if (value < 5)
{
m_state->slowHSpeed = 5; m_state->slowVSpeed = 5;
}
else
{
m_state->slowHSpeed = 60; m_state->slowVSpeed = 60;
}
}
catch (...)
{
Logger::warn("Failed to initialize gliding delay speed from settings. Using default 5.");
if (m_state)
{
m_state->slowHSpeed = 5;
m_state->slowVSpeed = 5;
}
Logger::warn("Error parsing some MousePointerCrosshairs properties. Using defaults for failed properties.");
}
}
else
@@ -740,6 +848,7 @@ private:
Logger::info("Mouse Pointer Crosshairs settings are empty");
}
// Set default hotkeys if not configured
if (m_activationHotkey.key == 0)
{
m_activationHotkey.win = true;
@@ -756,6 +865,7 @@ private:
m_glidingHotkey.shift = false;
m_glidingHotkey.key = VK_OEM_PERIOD;
}
m_inclusiveCrosshairsSettings = inclusiveCrosshairsSettings;
}
};

View File

@@ -258,16 +258,6 @@ private:
{
Logger::info("AlwaysOnTop settings are empty");
}
if (!m_hotkey.key)
{
Logger::info("AlwaysOnTop is going to use default shortcut");
m_hotkey.win = true;
m_hotkey.alt = false;
m_hotkey.shift = false;
m_hotkey.ctrl = true;
m_hotkey.key = 'T';
}
}
bool is_process_running()

View File

@@ -154,8 +154,7 @@ HRESULT CPowerRenameRegEx::_OnEnumerateOrRandomizeItemsChanged()
std::find_if(
m_randomizer.begin(),
m_randomizer.end(),
[option](const Randomizer& r) -> bool { return r.options.replaceStrSpan.offset == option.replaceStrSpan.offset; }
))
[option](const Randomizer& r) -> bool { return r.options.replaceStrSpan.offset == option.replaceStrSpan.offset; }))
{
// Only add as enumerator if we didn't find a randomizer already at this offset.
// Every randomizer will also be a valid enumerator according to the definition of enumerators, which allows any string to mean the default enumerator, so it should be interpreted that the user wanted a randomizer if both were found at the same offset of the replace string.
@@ -395,11 +394,8 @@ HRESULT CPowerRenameRegEx::Replace(_In_ PCWSTR source, _Outptr_ PWSTR* result, u
}
std::wstring sourceToUse;
std::wstring originalSource;
sourceToUse.reserve(MAX_PATH);
originalSource.reserve(MAX_PATH);
sourceToUse = source;
originalSource = sourceToUse;
std::wstring searchTerm(m_searchTerm);
std::wstring replaceTerm;
@@ -487,27 +483,46 @@ HRESULT CPowerRenameRegEx::Replace(_In_ PCWSTR source, _Outptr_ PWSTR* result, u
}
}
bool replacedSomething = false;
bool shouldIncrementCounter = false;
const bool isCaseInsensitive = !(m_flags & CaseSensitive);
if (m_flags & UseRegularExpressions)
{
replaceTerm = regex_replace(replaceTerm, zeroGroupRegex, L"$1$$$0");
replaceTerm = regex_replace(replaceTerm, otherGroupsRegex, L"$1$0$4");
res = RegexReplaceDispatch[_useBoostLib](source, m_searchTerm, replaceTerm, m_flags & MatchAllOccurrences, !(m_flags & CaseSensitive));
replacedSomething = originalSource != res;
res = RegexReplaceDispatch[_useBoostLib](source, m_searchTerm, replaceTerm, m_flags & MatchAllOccurrences, isCaseInsensitive);
// Use regex search to determine if a match exists. This is the basis for incrementing
// the counter.
if (_useBoostLib)
{
boost::wregex pattern(m_searchTerm, boost::wregex::ECMAScript | (isCaseInsensitive ? boost::wregex::icase : boost::wregex::normal));
shouldIncrementCounter = boost::regex_search(sourceToUse, pattern);
}
else
{
auto regexFlags = std::wregex::ECMAScript;
if (isCaseInsensitive)
{
regexFlags |= std::wregex::icase;
}
std::wregex pattern(m_searchTerm, regexFlags);
shouldIncrementCounter = std::regex_search(sourceToUse, pattern);
}
}
else
{
// Simple search and replace
// Simple search and replace.
size_t pos = 0;
do
{
pos = _Find(sourceToUse, searchTerm, (!(m_flags & CaseSensitive)), pos);
pos = _Find(sourceToUse, searchTerm, isCaseInsensitive, pos);
if (pos != std::string::npos)
{
res = sourceToUse.replace(pos, searchTerm.length(), replaceTerm);
pos += replaceTerm.length();
replacedSomething = true;
shouldIncrementCounter = true;
}
if (!(m_flags & MatchAllOccurrences))
{
@@ -516,7 +531,8 @@ HRESULT CPowerRenameRegEx::Replace(_In_ PCWSTR source, _Outptr_ PWSTR* result, u
} while (pos != std::string::npos);
}
hr = SHStrDup(res.c_str(), result);
if (replacedSomething)
if (shouldIncrementCounter)
enumIndex++;
}
catch (regex_error e)

View File

@@ -611,6 +611,42 @@ TEST_METHOD (VerifyRandomizerRegExAllBackToBack)
CoTaskMemFree(result);
}
TEST_METHOD(VerifyCounterIncrementsWhenResultIsUnchanged)
{
CComPtr<IPowerRenameRegEx> renameRegEx;
Assert::IsTrue(CPowerRenameRegEx::s_CreateInstance(&renameRegEx) == S_OK);
DWORD flags = EnumerateItems | UseRegularExpressions;
Assert::IsTrue(renameRegEx->PutFlags(flags) == S_OK);
renameRegEx->PutSearchTerm(L"(.*)");
renameRegEx->PutReplaceTerm(L"NewFile-${start=1}");
PWSTR result = nullptr;
unsigned long index = 0;
renameRegEx->Replace(L"DocA", &result, index);
Assert::AreEqual(1ul, index, L"Counter should advance to 1 on first match.");
Assert::AreEqual(L"NewFile-1", result, L"First file should be renamed correctly.");
CoTaskMemFree(result);
renameRegEx->Replace(L"DocB", &result, index);
Assert::AreEqual(2ul, index, L"Counter should advance to 2 on second match.");
Assert::AreEqual(L"NewFile-2", result, L"Second file should be renamed correctly.");
CoTaskMemFree(result);
// The original term and the replacement are identical.
renameRegEx->Replace(L"NewFile-3", &result, index);
Assert::AreEqual(3ul, index, L"Counter must advance on a match, even if the new name is identical to the old one.");
Assert::AreEqual(L"NewFile-3", result, L"Filename should be unchanged on a coincidental match.");
CoTaskMemFree(result);
// Test that there wasn't a "stall" in the numbering.
renameRegEx->Replace(L"DocC", &result, index);
Assert::AreEqual(4ul, index, L"Counter should continue sequentially after the coincidental match.");
Assert::AreEqual(L"NewFile-4", result, L"The subsequent file should receive the correct next number.");
CoTaskMemFree(result);
}
#ifndef TESTS_PARTIAL
};
}

View File

@@ -14,6 +14,29 @@
#include <common/version/version.h>
#include <common/utils/resources.h>
namespace
{
json::JsonValue create_empty_shortcut_array_value()
{
return json::JsonValue::Parse(L"[]");
}
void ensure_ignored_conflict_properties_shape(json::JsonObject& obj)
{
if (!json::has(obj, L"ignored_shortcuts", json::JsonValueType::Array))
{
obj.SetNamedValue(L"ignored_shortcuts", create_empty_shortcut_array_value());
}
}
json::JsonObject create_default_ignored_conflict_properties()
{
json::JsonObject obj;
ensure_ignored_conflict_properties_shape(obj);
return obj;
}
}
// TODO: would be nice to get rid of these globals, since they're basically cached json settings
static std::wstring settings_theme = L"system";
static bool show_tray_icon = true;
@@ -23,11 +46,15 @@ static bool download_updates_automatically = true;
static bool show_whats_new_after_updates = true;
static bool enable_experimentation = true;
static bool enable_warnings_elevated_apps = true;
static json::JsonObject ignored_conflict_properties = create_default_ignored_conflict_properties();
json::JsonObject GeneralSettings::to_json()
{
json::JsonObject result;
auto ignoredProps = ignoredConflictProperties;
ensure_ignored_conflict_properties_shape(ignoredProps);
result.SetNamedValue(L"startup", json::value(isStartupEnabled));
if (!startupDisabledReason.empty())
{
@@ -53,6 +80,7 @@ json::JsonObject GeneralSettings::to_json()
result.SetNamedValue(L"theme", json::value(theme));
result.SetNamedValue(L"system_theme", json::value(systemTheme));
result.SetNamedValue(L"powertoys_version", json::value(powerToysVersion));
result.SetNamedValue(L"ignored_conflict_properties", json::value(ignoredProps));
return result;
}
@@ -72,6 +100,17 @@ json::JsonObject load_general_settings()
enable_experimentation = loaded.GetNamedBoolean(L"enable_experimentation", true);
enable_warnings_elevated_apps = loaded.GetNamedBoolean(L"enable_warnings_elevated_apps", true);
if (json::has(loaded, L"ignored_conflict_properties", json::JsonValueType::Object))
{
ignored_conflict_properties = loaded.GetNamedObject(L"ignored_conflict_properties");
}
else
{
ignored_conflict_properties = create_default_ignored_conflict_properties();
}
ensure_ignored_conflict_properties_shape(ignored_conflict_properties);
return loaded;
}
@@ -91,9 +130,12 @@ GeneralSettings get_general_settings()
.enableExperimentation = enable_experimentation,
.theme = settings_theme,
.systemTheme = WindowsColors::is_dark_mode() ? L"dark" : L"light",
.powerToysVersion = get_product_version()
.powerToysVersion = get_product_version(),
.ignoredConflictProperties = ignored_conflict_properties
};
ensure_ignored_conflict_properties_shape(settings.ignoredConflictProperties);
settings.isStartupEnabled = is_auto_start_task_active_for_this_user();
for (auto& [name, powertoy] : modules())
@@ -232,6 +274,12 @@ void apply_general_settings(const json::JsonObject& general_configs, bool save)
set_tray_icon_visible(show_tray_icon);
}
if (json::has(general_configs, L"ignored_conflict_properties", json::JsonValueType::Object))
{
ignored_conflict_properties = general_configs.GetNamedObject(L"ignored_conflict_properties");
ensure_ignored_conflict_properties_shape(ignored_conflict_properties);
}
if (save)
{
GeneralSettings save_settings = get_general_settings();

View File

@@ -19,6 +19,7 @@ struct GeneralSettings
std::wstring theme;
std::wstring systemTheme;
std::wstring powerToysVersion;
json::JsonObject ignoredConflictProperties;
json::JsonObject to_json();
};

View File

@@ -76,6 +76,9 @@ namespace Microsoft.PowerToys.Settings.UI.Library
[JsonPropertyName("enable_experimentation")]
public bool EnableExperimentation { get; set; }
[JsonPropertyName("ignored_conflict_properties")]
public ShortcutConflictProperties IgnoredConflictProperties { get; set; }
public GeneralSettings()
{
Startup = false;
@@ -100,6 +103,7 @@ namespace Microsoft.PowerToys.Settings.UI.Library
Enabled = new EnabledModules();
CustomActionName = string.Empty;
IgnoredConflictProperties = new ShortcutConflictProperties();
}
// converts the current to a json string.
@@ -137,6 +141,13 @@ namespace Microsoft.PowerToys.Settings.UI.Library
// If there is an issue with the version number format, don't migrate settings.
}
// Ensure IgnoredConflictProperties is initialized (for backward compatibility)
if (IgnoredConflictProperties == null)
{
IgnoredConflictProperties = new ShortcutConflictProperties();
return true; // Indicate that settings were upgraded
}
return false;
}

View File

@@ -2,11 +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 System.Linq;
using System.Text;
using System.Threading.Tasks;
namespace Microsoft.PowerToys.Settings.UI.Library.HotkeyConflicts
{
@@ -16,6 +12,12 @@ namespace Microsoft.PowerToys.Settings.UI.Library.HotkeyConflicts
public bool IsSystemConflict { get; set; }
public bool ConflictIgnored { get; set; }
public bool ConflictVisible => !ConflictIgnored;
public bool ShouldShowSysConflict => !ConflictIgnored && IsSystemConflict;
public List<ModuleHotkeyData> Modules { get; set; }
}
}

View File

@@ -20,6 +20,7 @@ namespace Microsoft.PowerToys.Settings.UI.Library
private bool _hasConflict;
private string _conflictDescription;
private bool _isSystemConflict;
private bool _ignoreConflict;
public event PropertyChangedEventHandler PropertyChanged;
@@ -57,6 +58,21 @@ namespace Microsoft.PowerToys.Settings.UI.Library
HasConflict = false;
}
[JsonIgnore]
public bool IgnoreConflict
{
get => _ignoreConflict;
set
{
if (_ignoreConflict != value)
{
_ignoreConflict = value;
OnPropertyChanged();
}
}
}
[JsonIgnore]
public bool HasConflict
{
get => _hasConflict;
@@ -70,9 +86,10 @@ namespace Microsoft.PowerToys.Settings.UI.Library
}
}
[JsonIgnore]
public string ConflictDescription
{
get => _conflictDescription ?? string.Empty;
get => _ignoreConflict ? null : _conflictDescription;
set
{
if (_conflictDescription != value)
@@ -83,6 +100,7 @@ namespace Microsoft.PowerToys.Settings.UI.Library
}
}
[JsonIgnore]
public bool IsSystemConflict
{
get => _isSystemConflict;

View File

@@ -40,6 +40,9 @@ namespace Microsoft.PowerToys.Settings.UI.Library
[JsonPropertyName("crosshairs_border_size")]
public IntProperty CrosshairsBorderSize { get; set; }
[JsonPropertyName("crosshairs_orientation")]
public IntProperty CrosshairsOrientation { get; set; }
[JsonPropertyName("crosshairs_auto_hide")]
public BoolProperty CrosshairsAutoHide { get; set; }
@@ -68,6 +71,7 @@ namespace Microsoft.PowerToys.Settings.UI.Library
CrosshairsThickness = new IntProperty(5);
CrosshairsBorderColor = new StringProperty("#FFFFFF");
CrosshairsBorderSize = new IntProperty(1);
CrosshairsOrientation = new IntProperty(0); // Default to both (0=Both, 1=Vertical, 2=Horizontal)
CrosshairsAutoHide = new BoolProperty(false);
CrosshairsIsFixedLengthEnabled = new BoolProperty(false);
CrosshairsFixedLength = new IntProperty(1);

View File

@@ -653,11 +653,15 @@ namespace Microsoft.PowerToys.Settings.UI.Library
return (false, "General_SettingsBackupAndRestore_InvalidBackupLocation", "Error", lastBackupExists, "\n" + appBasePath);
}
var dirExists = TryCreateDirectory(settingsBackupAndRestoreDir);
if (!dirExists)
// Only create the backup directory if this is not a dry run
if (!dryRun)
{
Logger.LogError($"Failed to create dir {settingsBackupAndRestoreDir}");
return (false, $"General_SettingsBackupAndRestore_BackupError", "Error", lastBackupExists, "\n" + settingsBackupAndRestoreDir);
var dirExists = TryCreateDirectory(settingsBackupAndRestoreDir);
if (!dirExists)
{
Logger.LogError($"Failed to create dir {settingsBackupAndRestoreDir}");
return (false, $"General_SettingsBackupAndRestore_BackupError", "Error", lastBackupExists, "\n" + settingsBackupAndRestoreDir);
}
}
// get data needed for process
@@ -717,12 +721,11 @@ namespace Microsoft.PowerToys.Settings.UI.Library
var relativePath = currentFile.Value.Substring(appBasePath.Length + 1);
var backupFullPath = Path.Combine(fullBackupDir, relativePath);
TryCreateDirectory(fullBackupDir);
TryCreateDirectory(Path.GetDirectoryName(backupFullPath));
Logger.LogInfo($"BackupSettings writing, {backupFullPath}, dryRun:{dryRun}.");
if (!dryRun)
{
TryCreateDirectory(fullBackupDir);
TryCreateDirectory(Path.GetDirectoryName(backupFullPath));
File.WriteAllText(backupFullPath, currentSettingsFileToBackup);
}
}

View File

@@ -0,0 +1,20 @@
// Copyright (c) Microsoft Corporation
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
using System.Collections.Generic;
using System.Text.Json.Serialization;
namespace Microsoft.PowerToys.Settings.UI.Library
{
public class ShortcutConflictProperties
{
[JsonPropertyName("ignored_shortcuts")]
public List<HotkeySettings> IgnoredShortcuts { get; set; }
public ShortcutConflictProperties()
{
IgnoredShortcuts = new List<HotkeySettings>();
}
}
}

View File

@@ -0,0 +1,45 @@
// 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.PowerToys.Settings.UI.Controls;
using Microsoft.UI.Xaml.Data;
namespace Microsoft.PowerToys.Settings.UI.Converters
{
public partial class BoolToKeyVisualStateConverter : IValueConverter
{
public object Convert(object value, Type targetType, object parameter, string language)
{
if (value is bool b && parameter is string param)
{
if (b && param == "Warning")
{
return State.Warning;
}
else if (b && param == "Error")
{
return State.Error;
}
else
{
return State.Normal;
}
}
else
{
return State.Normal;
}
}
public object ConvertBack(object value, Type targetType, object parameter, string language)
{
throw new NotImplementedException();
}
}
}

View File

@@ -0,0 +1,229 @@
// 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 ManagedCommon;
using Microsoft.PowerToys.Settings.UI.Library;
using Microsoft.PowerToys.Settings.UI.Library.Interfaces;
using Microsoft.PowerToys.Settings.UI.Views;
namespace Microsoft.PowerToys.Settings.UI.Helpers
{
/// <summary>
/// Static helper class to manage and check hotkey conflict ignore settings
/// </summary>
public static class HotkeyConflictIgnoreHelper
{
private static readonly ISettingsRepository<GeneralSettings> _generalSettingsRepository;
private static readonly ISettingsUtils _settingsUtils;
static HotkeyConflictIgnoreHelper()
{
_settingsUtils = new SettingsUtils();
_generalSettingsRepository = SettingsRepository<GeneralSettings>.GetInstance(_settingsUtils);
}
/// <summary>
/// Ensures ignored conflict properties are initialized
/// </summary>
private static void EnsureInitialized()
{
var settings = _generalSettingsRepository.SettingsConfig;
if (settings.IgnoredConflictProperties == null)
{
settings.IgnoredConflictProperties = new ShortcutConflictProperties();
SaveSettings();
}
}
/// <summary>
/// Checks if a specific hotkey setting is configured to ignore conflicts
/// </summary>
/// <param name="hotkeySettings">The hotkey settings to check</param>
/// <returns>True if the hotkey is set to ignore conflicts, false otherwise</returns>
public static bool IsIgnoringConflicts(HotkeySettings hotkeySettings)
{
if (hotkeySettings == null)
{
return false;
}
try
{
EnsureInitialized();
var settings = _generalSettingsRepository.SettingsConfig;
return settings.IgnoredConflictProperties.IgnoredShortcuts
.Any(h => AreHotkeySettingsEqual(h, hotkeySettings));
}
catch (Exception ex)
{
Logger.LogError($"Error checking if hotkey is ignoring conflicts: {ex.Message}");
return false;
}
}
/// <summary>
/// Adds a hotkey setting to the ignored shortcuts list
/// </summary>
/// <param name="hotkeySettings">The hotkey settings to add to the ignored list</param>
/// <returns>True if successfully added, false if it was already ignored or on error</returns>
public static bool AddToIgnoredList(HotkeySettings hotkeySettings)
{
if (hotkeySettings == null)
{
return false;
}
try
{
EnsureInitialized();
var settings = _generalSettingsRepository.SettingsConfig;
// Check if already ignored (avoid duplicates)
if (IsIgnoringConflicts(hotkeySettings))
{
Logger.LogInfo($"Hotkey already in ignored list: {hotkeySettings}");
return false;
}
// Add to ignored list
settings.IgnoredConflictProperties.IgnoredShortcuts.Add(hotkeySettings);
SaveSettings();
Logger.LogInfo($"Added hotkey to ignored list: {hotkeySettings}");
return true;
}
catch (Exception ex)
{
Logger.LogError($"Error adding hotkey to ignored list: {ex.Message}");
return false;
}
}
/// <summary>
/// Removes a hotkey setting from the ignored shortcuts list
/// </summary>
/// <param name="hotkeySettings">The hotkey settings to remove from the ignored list</param>
/// <returns>True if successfully removed, false if it wasn't in the list or on error</returns>
public static bool RemoveFromIgnoredList(HotkeySettings hotkeySettings)
{
if (hotkeySettings == null)
{
return false;
}
try
{
EnsureInitialized();
var settings = _generalSettingsRepository.SettingsConfig;
var ignoredShortcut = settings.IgnoredConflictProperties.IgnoredShortcuts
.FirstOrDefault(h => AreHotkeySettingsEqual(h, hotkeySettings));
if (ignoredShortcut != null)
{
settings.IgnoredConflictProperties.IgnoredShortcuts.Remove(ignoredShortcut);
SaveSettings();
Logger.LogInfo($"Removed hotkey from ignored list: {ignoredShortcut}");
return true;
}
Logger.LogInfo($"Hotkey not found in ignored list: {hotkeySettings}");
return false;
}
catch (Exception ex)
{
Logger.LogError($"Error removing hotkey from ignored list: {ex.Message}");
return false;
}
}
/// <summary>
/// Gets all hotkey settings that are currently being ignored
/// </summary>
/// <returns>List of ignored hotkey settings</returns>
public static List<HotkeySettings> GetAllIgnoredShortcuts()
{
try
{
EnsureInitialized();
var settings = _generalSettingsRepository.SettingsConfig;
return new List<HotkeySettings>(settings.IgnoredConflictProperties.IgnoredShortcuts);
}
catch (Exception ex)
{
Logger.LogError($"Error getting ignored shortcuts: {ex.Message}");
return new List<HotkeySettings>();
}
}
/// <summary>
/// Clears all ignored shortcuts from the list
/// </summary>
/// <returns>True if successfully cleared, false on error</returns>
public static bool ClearAllIgnoredShortcuts()
{
try
{
EnsureInitialized();
var settings = _generalSettingsRepository.SettingsConfig;
var count = settings.IgnoredConflictProperties.IgnoredShortcuts.Count;
settings.IgnoredConflictProperties.IgnoredShortcuts.Clear();
SaveSettings();
Logger.LogInfo($"Cleared all {count} ignored shortcuts");
return true;
}
catch (Exception ex)
{
Logger.LogError($"Error clearing ignored shortcuts: {ex.Message}");
return false;
}
}
/// <summary>
/// Compares two HotkeySettings for equality
/// </summary>
/// <param name="hotkey1">First hotkey settings</param>
/// <param name="hotkey2">Second hotkey settings</param>
/// <returns>True if they represent the same shortcut, false otherwise</returns>
private static bool AreHotkeySettingsEqual(HotkeySettings hotkey1, HotkeySettings hotkey2)
{
if (hotkey1 == null || hotkey2 == null)
{
return false;
}
return hotkey1.Win == hotkey2.Win &&
hotkey1.Ctrl == hotkey2.Ctrl &&
hotkey1.Alt == hotkey2.Alt &&
hotkey1.Shift == hotkey2.Shift &&
hotkey1.Code == hotkey2.Code;
}
/// <summary>
/// Saves the general settings using PowerToys standard settings persistence
/// </summary>
private static void SaveSettings()
{
try
{
var settings = _generalSettingsRepository.SettingsConfig;
// Send IPC message to notify runner of changes (this is thread-safe)
var outgoing = new OutGoingGeneralSettings(settings);
ShellPage.SendDefaultIPCMessage(outgoing.ToString());
ShellPage.ShellHandler?.SignalGeneralDataUpdate();
}
catch (Exception ex)
{
Logger.LogError($"Error saving shortcut conflict settings: {ex.Message}");
Logger.LogError($"Stack trace: {ex.StackTrace}");
throw;
}
}
}
}

View File

@@ -33,6 +33,7 @@ namespace Microsoft.PowerToys.Settings.UI.SerializationContext;
[JsonSerializable(typeof(PowerOcrSettings))]
[JsonSerializable(typeof(PowerOcrSettings))]
[JsonSerializable(typeof(RegistryPreviewSettings))]
[JsonSerializable(typeof(ShortcutConflictProperties))]
[JsonSerializable(typeof(ShortcutGuideSettings))]
[JsonSerializable(typeof(WINDOWPLACEMENT))]
[JsonSerializable(typeof(WorkspacesSettings))]

View File

@@ -8,7 +8,7 @@
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
mc:Ignorable="d">
<Grid Visibility="{x:Bind HasConflicts, Mode=OneWay, Converter={StaticResource BoolToVisibilityConverter}}">
<Grid>
<Button Click="ShortcutConflictBtn_Click" Style="{StaticResource SubtleButtonStyle}">
<Grid ColumnSpacing="16">
<Grid.ColumnDefinitions>
@@ -16,10 +16,10 @@
<ColumnDefinition Width="*" />
</Grid.ColumnDefinitions>
<FontIcon
x:Name="Icon"
AutomationProperties.AccessibilityView="Raw"
FontSize="20"
Foreground="{ThemeResource SystemFillColorCriticalBrush}"
Glyph="&#xE814;" />
Glyph="&#xEDA7;" />
<StackPanel Grid.Column="1" Orientation="Vertical">
<TextBlock x:Uid="ShortcutConflictControl_Title" FontWeight="SemiBold" />
<TextBlock
@@ -29,5 +29,16 @@
</StackPanel>
</Grid>
</Button>
<VisualStateManager.VisualStateGroups>
<VisualStateGroup x:Name="ConflictsStateGroup">
<VisualState x:Name="NoConflictState" />
<VisualState x:Name="ConflictState">
<VisualState.Setters>
<Setter Target="Icon.Glyph" Value="&#xE814;" />
<Setter Target="Icon.Foreground" Value="{ThemeResource SystemFillColorCautionBrush}" />
</VisualState.Setters>
</VisualState>
</VisualStateGroup>
</VisualStateManager.VisualStateGroups>
</Grid>
</UserControl>

View File

@@ -47,12 +47,24 @@ namespace Microsoft.PowerToys.Settings.UI.Controls
int count = 0;
if (AllHotkeyConflictsData.InAppConflicts != null)
{
count += AllHotkeyConflictsData.InAppConflicts.Count;
foreach (var inAppConflict in AllHotkeyConflictsData.InAppConflicts)
{
if (!inAppConflict.ConflictIgnored)
{
count++;
}
}
}
if (AllHotkeyConflictsData.SystemConflicts != null)
{
count += AllHotkeyConflictsData.SystemConflicts.Count;
foreach (var systemConflict in AllHotkeyConflictsData.SystemConflicts)
{
if (!systemConflict.ConflictIgnored)
{
count++;
}
}
}
return count;
@@ -95,7 +107,14 @@ namespace Microsoft.PowerToys.Settings.UI.Controls
OnPropertyChanged(nameof(HasConflicts));
// Update visibility based on conflict count
Visibility = HasConflicts ? Visibility.Visible : Visibility.Collapsed;
if (HasConflicts)
{
VisualStateManager.GoToState(this, "ConflictState", true);
}
else
{
VisualStateManager.GoToState(this, "NoConflictState", true);
}
if (!_telemetryEventSent && HasConflicts)
{
@@ -119,13 +138,12 @@ namespace Microsoft.PowerToys.Settings.UI.Controls
InitializeComponent();
DataContext = this;
// Initially hide the control if no conflicts
Visibility = HasConflicts ? Visibility.Visible : Visibility.Collapsed;
UpdateProperties();
}
private void ShortcutConflictBtn_Click(object sender, RoutedEventArgs e)
{
if (AllHotkeyConflictsData == null || !HasConflicts)
if (AllHotkeyConflictsData == null)
{
return;
}

View File

@@ -53,34 +53,22 @@
</Grid.RowDefinitions>
<!-- Title Bar Area -->
<Grid
x:Name="titleBar"
Height="48"
ColumnSpacing="16">
<Grid.ColumnDefinitions>
<ColumnDefinition x:Name="LeftPaddingColumn" Width="0" />
<ColumnDefinition x:Name="IconColumn" Width="Auto" />
<ColumnDefinition x:Name="TitleColumn" Width="Auto" />
<ColumnDefinition x:Name="RightPaddingColumn" Width="0" />
</Grid.ColumnDefinitions>
<Image
Grid.Column="1"
Width="16"
Height="16"
VerticalAlignment="Center"
Source="/Assets/Settings/icon.ico" />
<TextBlock
x:Uid="ShortcutConflictWindow_TitleTxt"
Grid.Column="2"
VerticalAlignment="Center"
Style="{StaticResource CaptionTextBlockStyle}" />
</Grid>
<TitleBar x:Name="titleBar" x:Uid="ShortcutConflictWindow_TitleTxt">
<!-- This is a workaround for https://github.com/microsoft/microsoft-ui-xaml/issues/10374, once fixed we should just be using IconSource -->
<TitleBar.LeftHeader>
<ImageIcon
Height="16"
Margin="16,0,0,0"
Source="/Assets/Settings/icon.ico" />
</TitleBar.LeftHeader>
</TitleBar>
<!-- Description text -->
<TextBlock
x:Uid="ShortcutConflictWindow_Description"
Grid.Row="1"
Margin="16,24,16,24"
Margin="16,8,16,24"
Foreground="{ThemeResource TextFillColorSecondaryBrush}"
Style="{StaticResource BodyTextBlockStyle}"
TextWrapping="Wrap" />
@@ -97,22 +85,40 @@
</ItemsControl.ItemsPanel>
<ItemsControl.ItemTemplate>
<DataTemplate x:DataType="hotkeyConflicts:HotkeyConflictGroupData">
<StackPanel Orientation="Vertical">
<StackPanel
Background="{ThemeResource CardBackgroundFillColorDefaultBrush}"
BorderBrush="{ThemeResource CardStrokeColorDefaultBrush}"
BorderThickness="1"
CornerRadius="{StaticResource OverlayCornerRadius}"
Orientation="Vertical">
<!-- Hotkey Header -->
<controls:ShortcutWithTextLabelControl
x:Uid="ShortcutConflictWindow_ModulesUsingShortcut"
Margin="0,0,0,8"
FontWeight="SemiBold"
Keys="{x:Bind Hotkey.GetKeysList()}"
LabelPlacement="Before" />
<Grid Margin="16,12,16,12">
<controls:ShortcutWithTextLabelControl
x:Uid="ShortcutConflictWindow_ModulesUsingShortcut"
VerticalAlignment="Center"
FontWeight="SemiBold"
Keys="{x:Bind Hotkey.GetKeysList()}"
LabelPlacement="Before" />
<CheckBox
HorizontalAlignment="Right"
VerticalAlignment="Center"
Click="OnIgnoreConflictClicked"
Content="Ignore shortcut"
IsChecked="{x:Bind ConflictIgnored, Mode=OneWay}" />
</Grid>
<!-- PowerToys Module Cards -->
<ItemsControl Grid.Row="1" ItemsSource="{x:Bind Modules}">
<ItemsControl
Grid.Row="1"
IsEnabled="{x:Bind ConflictVisible, Mode=OneWay}"
ItemsSource="{x:Bind Modules, Mode=OneWay}">
<ItemsControl.ItemTemplate>
<DataTemplate x:DataType="hotkeyConflicts:ModuleHotkeyData">
<tkcontrols:SettingsCard
Margin="0,0,0,4"
Background="Transparent"
BorderThickness="0,1,0,0"
Click="SettingsCard_Click"
CornerRadius="0"
Description="{x:Bind DisplayName}"
Header="{x:Bind Header}"
IsClickEnabled="True">
@@ -137,15 +143,15 @@
<tkcontrols:SettingsCard
x:Name="SystemConflictCard"
x:Uid="ShortcutConflictWindow_SystemCard"
Visibility="{x:Bind IsSystemConflict}">
Background="Transparent"
BorderThickness="0,1,0,0"
CornerRadius="0"
IsEnabled="{x:Bind ShouldShowSysConflict, Mode=OneWay}">
<tkcontrols:SettingsCard.HeaderIcon>
<PathIcon Data="M9 20H0V11H9V20ZM20 20H11V11H20V20ZM9 9H0V0H9V9ZM20 9H11V0H20V9Z" Foreground="{ThemeResource WindowsLogoGradient}" />
</tkcontrols:SettingsCard.HeaderIcon>
<!-- System shortcut message -->
<TextBlock
x:Uid="ShortcutConflictWindow_SystemShortcutMessage"
Foreground="{ThemeResource TextFillColorSecondaryBrush}"
TextWrapping="Wrap" />
<HyperlinkButton x:Uid="ShortcutConflictWindow_SystemShortcutLink" NavigateUri="https://support.microsoft.com/windows/keyboard-shortcuts-in-windows-dcc61a57-8ff0-cffe-9796-cb9706c75eec" />
</tkcontrols:SettingsCard>
</StackPanel>
</DataTemplate>

View File

@@ -14,6 +14,7 @@ using Microsoft.PowerToys.Settings.UI.Views;
using Microsoft.UI;
using Microsoft.UI.Windowing;
using Microsoft.UI.Xaml;
using Microsoft.UI.Xaml.Controls;
using Windows.Graphics;
using WinUIEx;
@@ -21,8 +22,6 @@ namespace Microsoft.PowerToys.Settings.UI.SettingsXAML.Controls.Dashboard
{
public sealed partial class ShortcutConflictWindow : WindowEx
{
public ShortcutConflictViewModel DataContext { get; }
public ShortcutConflictViewModel ViewModel { get; private set; }
public ShortcutConflictWindow()
@@ -33,14 +32,17 @@ namespace Microsoft.PowerToys.Settings.UI.SettingsXAML.Controls.Dashboard
SettingsRepository<GeneralSettings>.GetInstance(settingsUtils),
ShellPage.SendDefaultIPCMessage);
DataContext = ViewModel;
InitializeComponent();
// Set DataContext on the root Grid instead of the Window
RootGrid.DataContext = ViewModel;
this.Activated += Window_Activated_SetIcon;
// Set localized window title
var resourceLoader = ResourceLoaderInstance.ResourceLoader;
this.ExtendsContentIntoTitleBar = true;
ExtendsContentIntoTitleBar = true;
SetTitleBar(titleBar);
this.Title = resourceLoader.GetString("ShortcutConflictWindow_Title");
this.CenterOnScreen();
@@ -74,6 +76,54 @@ namespace Microsoft.PowerToys.Settings.UI.SettingsXAML.Controls.Dashboard
}
}
private void OnIgnoreConflictClicked(object sender, RoutedEventArgs e)
{
if (sender is CheckBox checkBox && checkBox.DataContext is HotkeyConflictGroupData conflictGroup)
{
// The Click event only fires from user interaction, not programmatic changes
if (checkBox.IsChecked == true)
{
IgnoreConflictGroup(conflictGroup);
}
else
{
UnignoreConflictGroup(conflictGroup);
}
}
}
private void IgnoreConflictGroup(HotkeyConflictGroupData conflictGroup)
{
try
{
// Ignore all hotkey settings in this conflict group
if (conflictGroup.Modules != null)
{
HotkeySettings hotkey = new(conflictGroup.Hotkey.Win, conflictGroup.Hotkey.Ctrl, conflictGroup.Hotkey.Alt, conflictGroup.Hotkey.Shift, conflictGroup.Hotkey.Key);
ViewModel.IgnoreShortcut(hotkey);
}
}
catch
{
}
}
private void UnignoreConflictGroup(HotkeyConflictGroupData conflictGroup)
{
try
{
// Unignore all hotkey settings in this conflict group
if (conflictGroup.Modules != null)
{
HotkeySettings hotkey = new(conflictGroup.Hotkey.Win, conflictGroup.Hotkey.Ctrl, conflictGroup.Hotkey.Alt, conflictGroup.Hotkey.Shift, conflictGroup.Hotkey.Key);
ViewModel.UnignoreShortcut(hotkey);
}
}
catch
{
}
}
private void WindowEx_Closed(object sender, WindowEventArgs args)
{
ViewModel?.Dispose();
@@ -82,10 +132,7 @@ namespace Microsoft.PowerToys.Settings.UI.SettingsXAML.Controls.Dashboard
private void Window_Activated_SetIcon(object sender, WindowActivatedEventArgs args)
{
// Set window icon
var hWnd = WinRT.Interop.WindowNative.GetWindowHandle(this);
WindowId windowId = Win32Interop.GetWindowIdFromWindow(hWnd);
AppWindow appWindow = AppWindow.GetFromWindowId(windowId);
appWindow.SetIcon("Assets\\Settings\\icon.ico");
AppWindow.SetIcon("Assets\\Settings\\icon.ico");
}
}
}

View File

@@ -63,10 +63,18 @@
<VisualState.Setters>
<Setter Target="KeyHolder.Background" Value="{ThemeResource SystemFillColorCriticalBackgroundBrush}" />
<Setter Target="KeyHolder.BorderBrush" Value="{ThemeResource SystemFillColorCriticalBrush}" />
<Setter Target="KeyHolder.BorderThickness" Value="2" />
<Setter Target="KeyHolder.BorderThickness" Value="1" />
<Setter Target="KeyPresenter.Foreground" Value="{ThemeResource SystemFillColorCriticalBrush}" />
</VisualState.Setters>
</VisualState>
<VisualState x:Name="Warning">
<VisualState.Setters>
<Setter Target="KeyHolder.Background" Value="{ThemeResource SystemFillColorCautionBackgroundBrush}" />
<Setter Target="KeyHolder.BorderBrush" Value="{ThemeResource SystemFillColorCautionBrush}" />
<Setter Target="KeyHolder.BorderThickness" Value="1" />
<Setter Target="KeyPresenter.Foreground" Value="{ThemeResource SystemFillColorCautionBrush}" />
</VisualState.Setters>
</VisualState>
</VisualStateGroup>
</VisualStateManager.VisualStateGroups>
</Grid>
@@ -120,6 +128,11 @@
<Setter Target="KeyPresenter.Foreground" Value="{ThemeResource SystemFillColorCriticalBrush}" />
</VisualState.Setters>
</VisualState>
<VisualState x:Name="Warning">
<VisualState.Setters>
<Setter Target="KeyPresenter.Foreground" Value="{ThemeResource SystemFillColorCautionBrush}" />
</VisualState.Setters>
</VisualState>
</VisualStateGroup>
</VisualStateManager.VisualStateGroups>
</Grid>
@@ -177,10 +190,18 @@
<VisualState.Setters>
<Setter Target="KeyHolder.Background" Value="{ThemeResource SystemFillColorCriticalBackgroundBrush}" />
<Setter Target="KeyHolder.BorderBrush" Value="{ThemeResource SystemFillColorCriticalBrush}" />
<Setter Target="KeyHolder.BorderThickness" Value="2" />
<Setter Target="KeyHolder.BorderThickness" Value="1" />
<Setter Target="KeyPresenter.Foreground" Value="{ThemeResource SystemFillColorCriticalBrush}" />
</VisualState.Setters>
</VisualState>
<VisualState x:Name="Warning">
<VisualState.Setters>
<Setter Target="KeyHolder.Background" Value="{ThemeResource SystemFillColorCautionBackgroundBrush}" />
<Setter Target="KeyHolder.BorderBrush" Value="{ThemeResource SystemFillColorCautionBrush}" />
<Setter Target="KeyHolder.BorderThickness" Value="1" />
<Setter Target="KeyPresenter.Foreground" Value="{ThemeResource SystemFillColorCautionBrush}" />
</VisualState.Setters>
</VisualState>
</VisualStateGroup>
</VisualStateManager.VisualStateGroups>
</Grid>

View File

@@ -12,12 +12,14 @@ namespace Microsoft.PowerToys.Settings.UI.Controls
[TemplateVisualState(Name = NormalState, GroupName = "CommonStates")]
[TemplateVisualState(Name = DisabledState, GroupName = "CommonStates")]
[TemplateVisualState(Name = InvalidState, GroupName = "CommonStates")]
[TemplateVisualState(Name = WarningState, GroupName = "CommonStates")]
public sealed partial class KeyVisual : Control
{
private const string KeyPresenter = "KeyPresenter";
private const string NormalState = "Normal";
private const string DisabledState = "Disabled";
private const string InvalidState = "Invalid";
private const string WarningState = "Warning";
private KeyCharPresenter _keyPresenter;
public object Content
@@ -28,13 +30,13 @@ namespace Microsoft.PowerToys.Settings.UI.Controls
public static readonly DependencyProperty ContentProperty = DependencyProperty.Register(nameof(Content), typeof(object), typeof(KeyVisual), new PropertyMetadata(default(string), OnContentChanged));
public bool IsInvalid
public State State
{
get => (bool)GetValue(IsInvalidProperty);
set => SetValue(IsInvalidProperty, value);
get => (State)GetValue(StateProperty);
set => SetValue(StateProperty, value);
}
public static readonly DependencyProperty IsInvalidProperty = DependencyProperty.Register(nameof(IsInvalid), typeof(bool), typeof(KeyVisual), new PropertyMetadata(false, OnIsInvalidChanged));
public static readonly DependencyProperty StateProperty = DependencyProperty.Register(nameof(State), typeof(State), typeof(KeyVisual), new PropertyMetadata(State.Normal, OnStateChanged));
public bool RenderKeyAsGlyph
{
@@ -64,7 +66,7 @@ namespace Microsoft.PowerToys.Settings.UI.Controls
((KeyVisual)d).SetVisualStates();
}
private static void OnIsInvalidChanged(DependencyObject d, DependencyPropertyChangedEventArgs e)
private static void OnStateChanged(DependencyObject d, DependencyPropertyChangedEventArgs e)
{
((KeyVisual)d).SetVisualStates();
}
@@ -73,10 +75,14 @@ namespace Microsoft.PowerToys.Settings.UI.Controls
{
if (this != null)
{
if (IsInvalid)
if (State == State.Error)
{
VisualStateManager.GoToState(this, InvalidState, true);
}
else if (State == State.Warning)
{
VisualStateManager.GoToState(this, WarningState, true);
}
else if (!IsEnabled)
{
VisualStateManager.GoToState(this, DisabledState, true);
@@ -177,4 +183,11 @@ namespace Microsoft.PowerToys.Settings.UI.Controls
SetVisualStates();
}
}
public enum State
{
Normal,
Error,
Warning,
}
}

View File

@@ -6,11 +6,14 @@
xmlns:converters="using:Microsoft.PowerToys.Settings.UI.Converters"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
xmlns:tkconverters="using:CommunityToolkit.WinUI.Converters"
x:Name="LayoutRoot"
d:DesignHeight="300"
d:DesignWidth="400"
mc:Ignorable="d">
<UserControl.Resources>
<converters:BoolToKeyVisualStateConverter x:Key="BoolToKeyVisualStateConverter" />
</UserControl.Resources>
<Grid HorizontalAlignment="Right">
<Button
x:Name="EditButton"
@@ -40,8 +43,8 @@
Content="{Binding}"
CornerRadius="{StaticResource ControlCornerRadius}"
FontWeight="SemiBold"
IsInvalid="{Binding ElementName=LayoutRoot, Path=HasConflict}"
IsTabStop="False"
State="{Binding ElementName=LayoutRoot, Path=KeyVisualShouldShowConflict, Mode=OneWay, Converter={StaticResource BoolToKeyVisualStateConverter}, ConverterParameter=Warning}"
Style="{StaticResource AccentKeyVisualStyle}" />
</DataTemplate>
</ItemsControl.ItemTemplate>
@@ -49,6 +52,7 @@
<StackPanel
x:Name="PlaceholderPanel"
Padding="8,4"
BorderBrush="{ThemeResource ControlStrokeColorDefaultBrush}"
BorderThickness="1"
CornerRadius="{StaticResource ControlCornerRadius}"
Orientation="Horizontal"
@@ -62,13 +66,15 @@
VerticalAlignment="Center"
Foreground="{ThemeResource TextFillColorSecondaryBrush}" />
</StackPanel>
<FontIcon
<controls:IsEnabledTextBlock
Margin="0,0,4,0"
VerticalAlignment="Center"
AutomationProperties.AccessibilityView="Raw"
AutomationProperties.Name=""
FontFamily="{ThemeResource SymbolThemeFontFamily}"
FontSize="14"
Foreground="{ThemeResource TextFillColorSecondaryBrush}"
Glyph="&#xE70F;" />
Text="&#xE70F;" />
</StackPanel>
</Button>
<VisualStateManager.VisualStateGroups>

View File

@@ -12,6 +12,7 @@ using Microsoft.PowerToys.Settings.UI.Library;
using Microsoft.PowerToys.Settings.UI.Library.HotkeyConflicts;
using Microsoft.PowerToys.Settings.UI.Library.Telemetry.Events;
using Microsoft.PowerToys.Settings.UI.Services;
using Microsoft.PowerToys.Settings.UI.SettingsXAML.Controls.Dashboard;
using Microsoft.PowerToys.Settings.UI.Views;
using Microsoft.PowerToys.Telemetry;
using Microsoft.UI.Xaml;
@@ -51,6 +52,8 @@ namespace Microsoft.PowerToys.Settings.UI.Controls
public static readonly DependencyProperty AllowDisableProperty = DependencyProperty.Register("AllowDisable", typeof(bool), typeof(ShortcutControl), new PropertyMetadata(false, OnAllowDisableChanged));
public static readonly DependencyProperty HasConflictProperty = DependencyProperty.Register("HasConflict", typeof(bool), typeof(ShortcutControl), new PropertyMetadata(false, OnHasConflictChanged));
public static readonly DependencyProperty TooltipProperty = DependencyProperty.Register("Tooltip", typeof(string), typeof(ShortcutControl), new PropertyMetadata(null, OnTooltipChanged));
public static readonly DependencyProperty KeyVisualShouldShowConflictProperty = DependencyProperty.Register("KeyVisualShouldShowConflict", typeof(bool), typeof(ShortcutControl), new PropertyMetadata(false));
public static readonly DependencyProperty IgnoreConflictProperty = DependencyProperty.Register("IgnoreConflict", typeof(bool), typeof(ShortcutControl), new PropertyMetadata(false));
// Dependency property to track the source/context of the ShortcutControl
public static readonly DependencyProperty SourceProperty = DependencyProperty.Register("Source", typeof(ShortcutControlSource), typeof(ShortcutControl), new PropertyMetadata(ShortcutControlSource.SettingsPage));
@@ -161,6 +164,18 @@ namespace Microsoft.PowerToys.Settings.UI.Controls
set => SetValue(TooltipProperty, value);
}
public bool KeyVisualShouldShowConflict
{
get => (bool)GetValue(KeyVisualShouldShowConflictProperty);
set => SetValue(KeyVisualShouldShowConflictProperty, value);
}
public bool IgnoreConflict
{
get => (bool)GetValue(IgnoreConflictProperty);
set => SetValue(IgnoreConflictProperty, value);
}
public ShortcutControlSource Source
{
get => (ShortcutControlSource)GetValue(SourceProperty);
@@ -241,6 +256,8 @@ namespace Microsoft.PowerToys.Settings.UI.Controls
// Update the ShortcutControl's conflict properties from HotkeySettings
HasConflict = hotkeySettings.HasConflict;
Tooltip = hotkeySettings.HasConflict ? hotkeySettings.ConflictDescription : null;
IgnoreConflict = HotkeyConflictIgnoreHelper.IsIgnoringConflicts(hotkeySettings);
KeyVisualShouldShowConflict = !IgnoreConflict && HasConflict;
}
else
{
@@ -257,6 +274,10 @@ namespace Microsoft.PowerToys.Settings.UI.Controls
this.Unloaded += ShortcutControl_Unloaded;
this.Loaded += ShortcutControl_Loaded;
c.ResetClick += C_ResetClick;
c.ClearClick += C_ClearClick;
c.LearnMoreClick += C_LearnMoreClick;
// We create the Dialog in C# because doing it in XAML is giving WinUI/XAML Island bugs when using dark theme.
shortcutDialog = new ContentDialog
{
@@ -264,11 +285,9 @@ namespace Microsoft.PowerToys.Settings.UI.Controls
Title = resourceLoader.GetString("Activation_Shortcut_Title"),
Content = c,
PrimaryButtonText = resourceLoader.GetString("Activation_Shortcut_Save"),
SecondaryButtonText = resourceLoader.GetString("Activation_Shortcut_Reset"),
CloseButtonText = resourceLoader.GetString("Activation_Shortcut_Cancel"),
DefaultButton = ContentDialogButton.Primary,
};
shortcutDialog.SecondaryButtonClick += ShortcutDialog_Reset;
shortcutDialog.RightTapped += ShortcutDialog_Disable;
AutomationProperties.SetName(EditButton, resourceLoader.GetString("Activation_Shortcut_Title"));
@@ -276,6 +295,16 @@ namespace Microsoft.PowerToys.Settings.UI.Controls
OnAllowDisableChanged(this, null);
}
private void C_LearnMoreClick(object sender, RoutedEventArgs e)
{
// Close the current shortcut dialog
shortcutDialog.Hide();
// Create and show the ShortcutConflictWindow
var conflictWindow = new ShortcutConflictWindow();
conflictWindow.Activate();
}
private void UpdateKeyVisualStyles()
{
if (PreviewKeysControl?.ItemsSource != null)
@@ -305,6 +334,8 @@ namespace Microsoft.PowerToys.Settings.UI.Controls
shortcutDialog.Opened -= ShortcutDialog_Opened;
shortcutDialog.Closing -= ShortcutDialog_Closing;
c.LearnMoreClick -= C_LearnMoreClick;
if (App.GetSettingsWindow() != null)
{
App.GetSettingsWindow().Activated -= ShortcutDialog_SettingsWindow_Activated;
@@ -510,6 +541,7 @@ namespace Microsoft.PowerToys.Settings.UI.Controls
else
{
EnableKeys();
if (lastValidSettings.IsValid())
{
if (string.Equals(lastValidSettings.ToString(), hotkeySettings.ToString(), StringComparison.OrdinalIgnoreCase))
@@ -578,16 +610,12 @@ namespace Microsoft.PowerToys.Settings.UI.Controls
{
shortcutDialog.IsPrimaryButtonEnabled = true;
c.IsError = false;
// WarningLabel.Style = (Style)App.Current.Resources["SecondaryTextStyle"];
}
private void DisableKeys()
{
shortcutDialog.IsPrimaryButtonEnabled = false;
c.IsError = true;
// WarningLabel.Style = (Style)App.Current.Resources["SecondaryWarningTextStyle"];
}
private void Hotkey_KeyUp(int key)
@@ -648,6 +676,7 @@ namespace Microsoft.PowerToys.Settings.UI.Controls
c.Keys = null;
c.Keys = HotkeySettings.GetKeysList();
c.IgnoreConflict = IgnoreConflict;
c.HasConflict = hotkeySettings.HasConflict;
c.ConflictMessage = hotkeySettings.ConflictDescription;
@@ -660,7 +689,7 @@ namespace Microsoft.PowerToys.Settings.UI.Controls
await shortcutDialog.ShowAsync();
}
private void ShortcutDialog_Reset(ContentDialog sender, ContentDialogButtonClickEventArgs args)
private void C_ResetClick(object sender, RoutedEventArgs e)
{
hotkeySettings = null;
@@ -674,6 +703,20 @@ namespace Microsoft.PowerToys.Settings.UI.Controls
GlobalHotkeyConflictManager.Instance?.RequestAllConflicts();
}
private void C_ClearClick(object sender, RoutedEventArgs e)
{
hotkeySettings = new HotkeySettings();
SetValue(HotkeySettingsProperty, hotkeySettings);
SetKeys();
lastValidSettings = hotkeySettings;
shortcutDialog.Hide();
// Send RequestAllConflicts IPC to update the UI after changed hotkey settings.
GlobalHotkeyConflictManager.Instance?.RequestAllConflicts();
}
private void ShortcutDialog_PrimaryButtonClick(ContentDialog sender, ContentDialogButtonClickEventArgs args)
{
if (ComboIsValid(lastValidSettings))
@@ -728,7 +771,7 @@ namespace Microsoft.PowerToys.Settings.UI.Controls
args.Handled = true;
if (args.WindowActivationState != WindowActivationState.Deactivated && (hook == null || hook.GetDisposedState() == true))
{
// If the PT settings window gets focussed/activated again, we enable the keyboard hook to catch the keyboard input.
// If the PT settings window gets focused/activated again, we enable the keyboard hook to catch the keyboard input.
hook = new HotkeySettingsControlHook(Hotkey_KeyDown, Hotkey_KeyUp, Hotkey_IsActive, FilterAccessibleKeyboardEvents);
}
else if (args.WindowActivationState == WindowActivationState.Deactivated && hook != null && hook.GetDisposedState() == false)
@@ -742,6 +785,7 @@ namespace Microsoft.PowerToys.Settings.UI.Controls
private void ShortcutDialog_Closing(ContentDialog sender, ContentDialogClosingEventArgs args)
{
_isActive = false;
lastValidSettings = hotkeySettings;
}
private void Dispose(bool disposing)

View File

@@ -3,78 +3,332 @@
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:controls="using:Microsoft.PowerToys.Settings.UI.Controls"
xmlns:converters="using:Microsoft.PowerToys.Settings.UI.Converters"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
xmlns:tkcontrols="using:CommunityToolkit.WinUI.Controls"
xmlns:tk7controls="using:CommunityToolkit.WinUI.Controls"
xmlns:tkconverters="using:CommunityToolkit.WinUI.Converters"
x:Name="ShortcutContentControl"
mc:Ignorable="d">
<Grid MinWidth="498" MinHeight="220">
<UserControl.Resources>
<converters:BoolToKeyVisualStateConverter x:Key="BoolToKeyVisualStateConverter" />
<Style x:Key="CondensedInfoBarStyle" TargetType="InfoBar">
<Setter Property="IsTabStop" Value="False" />
<Setter Property="Background" Value="Transparent" />
<Setter Property="BorderBrush" Value="{ThemeResource InfoBarBorderBrush}" />
<Setter Property="BorderThickness" Value="{ThemeResource InfoBarBorderThickness}" />
<Setter Property="AutomationProperties.LandmarkType" Value="Custom" />
<Setter Property="AutomationProperties.IsDialog" Value="True" />
<Setter Property="CornerRadius" Value="{ThemeResource ControlCornerRadius}" />
<Setter Property="Template">
<Setter.Value>
<ControlTemplate TargetType="InfoBar">
<Border
x:Name="ContentRoot"
VerticalAlignment="Top"
Background="{ThemeResource InfoBarInformationalSeverityBackgroundBrush}"
BorderBrush="{TemplateBinding BorderBrush}"
BorderThickness="{TemplateBinding BorderThickness}"
CornerRadius="{TemplateBinding CornerRadius}">
<!-- Background is used here so that it overrides the severity status color if set. -->
<Grid
MinHeight="0"
Padding="8"
HorizontalAlignment="Stretch"
Background="{TemplateBinding Background}"
CornerRadius="{TemplateBinding CornerRadius}">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="Auto" />
<!-- Icon -->
<ColumnDefinition Width="*" />
<!-- Title, message, and action -->
<ColumnDefinition Width="Auto" />
<!-- Close button -->
</Grid.ColumnDefinitions>
<Grid.RowDefinitions>
<RowDefinition Height="Auto" />
<RowDefinition Height="Auto" />
</Grid.RowDefinitions>
<Grid
x:Name="StandardIconArea"
Margin="0,0,8,0"
Visibility="Collapsed">
<TextBlock
x:Name="IconBackground"
Grid.Column="0"
VerticalAlignment="Top"
AutomationProperties.AccessibilityView="Raw"
FontFamily="{ThemeResource SymbolThemeFontFamily}"
FontSize="{StaticResource InfoBarIconFontSize}"
Foreground="{ThemeResource InfoBarInformationalSeverityIconBackground}"
Text="{StaticResource InfoBarIconBackgroundGlyph}" />
<TextBlock
x:Name="StandardIcon"
Grid.Column="0"
VerticalAlignment="Top"
FontFamily="{ThemeResource SymbolThemeFontFamily}"
FontSize="{StaticResource InfoBarIconFontSize}"
Foreground="{ThemeResource InfoBarInformationalSeverityIconForeground}"
Text="{StaticResource InfoBarInformationalIconGlyph}" />
</Grid>
<Viewbox
x:Name="UserIconBox"
Grid.Column="0"
MaxWidth="{ThemeResource InfoBarIconFontSize}"
MaxHeight="{ThemeResource InfoBarIconFontSize}"
Margin="0"
VerticalAlignment="Top"
Child="{Binding RelativeSource={RelativeSource TemplatedParent}, Path=TemplateSettings.IconElement}"
Visibility="Collapsed" />
<InfoBarPanel
Grid.Column="1"
Margin="0,0,0,0"
HorizontalOrientationPadding="0"
VerticalOrientationPadding="0">
<TextBlock
x:Name="Title"
Margin="0,-1,0,0"
FontSize="{StaticResource InfoBarTitleFontSize}"
FontWeight="{StaticResource InfoBarTitleFontWeight}"
Foreground="{ThemeResource InfoBarTitleForeground}"
InfoBarPanel.HorizontalOrientationMargin="0,0,8,0"
InfoBarPanel.VerticalOrientationMargin="0,8,0,0"
Text="{TemplateBinding Title}"
TextWrapping="WrapWholeWords" />
<TextBlock
x:Name="Message"
Margin="0,-1,0,0"
FontSize="{StaticResource InfoBarMessageFontSize}"
FontWeight="{StaticResource InfoBarMessageFontWeight}"
Foreground="{ThemeResource InfoBarMessageForeground}"
InfoBarPanel.HorizontalOrientationMargin="0"
InfoBarPanel.VerticalOrientationMargin="0"
Text="{TemplateBinding Message}"
TextWrapping="WrapWholeWords" />
<ContentPresenter
Content="{TemplateBinding ActionButton}"
InfoBarPanel.HorizontalOrientationMargin="16,-2,0,0"
InfoBarPanel.VerticalOrientationMargin="0,8,0,0" />
</InfoBarPanel>
<ContentPresenter
x:Name="ContentArea"
Grid.Row="1"
Grid.Column="1"
VerticalAlignment="Center"
Content="{TemplateBinding Content}"
ContentTemplate="{TemplateBinding ContentTemplate}" />
</Grid>
<VisualStateManager.VisualStateGroups>
<VisualStateGroup x:Name="SeverityLevels">
<VisualState x:Name="Informational" />
<VisualState x:Name="Error">
<VisualState.Setters>
<Setter Target="ContentRoot.Background" Value="{ThemeResource InfoBarErrorSeverityBackgroundBrush}" />
<Setter Target="IconBackground.Foreground" Value="{ThemeResource InfoBarErrorSeverityIconBackground}" />
<Setter Target="StandardIcon.Text" Value="{StaticResource InfoBarErrorIconGlyph}" />
<Setter Target="StandardIcon.Foreground" Value="{ThemeResource InfoBarErrorSeverityIconForeground}" />
</VisualState.Setters>
</VisualState>
<VisualState x:Name="Warning">
<VisualState.Setters>
<Setter Target="ContentRoot.Background" Value="{ThemeResource InfoBarWarningSeverityBackgroundBrush}" />
<Setter Target="IconBackground.Foreground" Value="{ThemeResource InfoBarWarningSeverityIconBackground}" />
<Setter Target="StandardIcon.Text" Value="{StaticResource InfoBarWarningIconGlyph}" />
<Setter Target="StandardIcon.Foreground" Value="{ThemeResource InfoBarWarningSeverityIconForeground}" />
</VisualState.Setters>
</VisualState>
<VisualState x:Name="Success">
<VisualState.Setters>
<Setter Target="ContentRoot.Background" Value="{ThemeResource InfoBarSuccessSeverityBackgroundBrush}" />
<Setter Target="IconBackground.Foreground" Value="{ThemeResource InfoBarSuccessSeverityIconBackground}" />
<Setter Target="StandardIcon.Text" Value="{StaticResource InfoBarSuccessIconGlyph}" />
<Setter Target="StandardIcon.Foreground" Value="{ThemeResource InfoBarSuccessSeverityIconForeground}" />
</VisualState.Setters>
</VisualState>
</VisualStateGroup>
<VisualStateGroup x:Name="IconStates">
<VisualState x:Name="StandardIconVisible">
<VisualState.Setters>
<Setter Target="UserIconBox.Visibility" Value="Collapsed" />
<Setter Target="StandardIconArea.Visibility" Value="Visible" />
</VisualState.Setters>
</VisualState>
<VisualState x:Name="UserIconVisible">
<VisualState.Setters>
<Setter Target="UserIconBox.Visibility" Value="Visible" />
<Setter Target="StandardIconArea.Visibility" Value="Collapsed" />
</VisualState.Setters>
</VisualState>
<VisualState x:Name="NoIconVisible" />
</VisualStateGroup>
<VisualStateGroup>
<VisualState x:Name="CloseButtonVisible" />
<VisualState x:Name="CloseButtonCollapsed" />
</VisualStateGroup>
<VisualStateGroup x:Name="InfoBarVisibility">
<VisualState x:Name="InfoBarVisible" />
<VisualState x:Name="InfoBarCollapsed">
<VisualState.Setters>
<Setter Target="ContentRoot.Visibility" Value="Collapsed" />
</VisualState.Setters>
</VisualState>
</VisualStateGroup>
<VisualStateGroup>
<VisualState x:Name="ForegroundNotSet" />
<VisualState x:Name="ForegroundSet">
<VisualState.Setters>
<Setter Target="Title.Foreground" Value="{Binding RelativeSource={RelativeSource TemplatedParent}, Path=Foreground}" />
<Setter Target="Message.Foreground" Value="{Binding RelativeSource={RelativeSource TemplatedParent}, Path=Foreground}" />
</VisualState.Setters>
</VisualState>
</VisualStateGroup>
<VisualStateGroup>
<VisualState x:Name="BannerContent" />
<VisualState x:Name="NoBannerContent">
<VisualState.Setters>
<Setter Target="ContentArea.(Grid.Row)" Value="0" />
</VisualState.Setters>
</VisualState>
</VisualStateGroup>
</VisualStateManager.VisualStateGroups>
</Border>
</ControlTemplate>
</Setter.Value>
</Setter>
</Style>
</UserControl.Resources>
<Grid
MinWidth="498"
MinHeight="220"
RowSpacing="8">
<Grid.RowDefinitions>
<RowDefinition Height="Auto" />
<RowDefinition MinHeight="110" />
<RowDefinition Height="Auto" MinHeight="104" />
<RowDefinition Height="Auto" />
<RowDefinition Height="Auto" />
</Grid.RowDefinitions>
<ItemsControl
x:Name="KeysControl"
<tk7controls:MarkdownTextBlock x:Uid="InvalidShortcutWarningLabel" Background="Transparent" />
<Grid
Grid.Row="1"
Height="56"
Margin="0,64,0,0"
HorizontalAlignment="Center"
VerticalAlignment="Top"
HorizontalContentAlignment="Center"
ItemsSource="{x:Bind Keys, Mode=OneWay}">
<ItemsControl.ItemsPanel>
<ItemsPanelTemplate>
<StackPanel Orientation="Horizontal" Spacing="8" />
</ItemsPanelTemplate>
</ItemsControl.ItemsPanel>
<ItemsControl.ItemTemplate>
<DataTemplate>
<controls:KeyVisual
Padding="20,16"
AutomationProperties.AccessibilityView="Raw"
Content="{Binding}"
CornerRadius="{StaticResource ControlCornerRadius}"
FontSize="16"
FontWeight="SemiBold"
IsInvalid="{Binding ElementName=ShortcutContentControl, Path=IsError, Mode=OneWay}"
IsTabStop="False"
Style="{StaticResource AccentKeyVisualStyle}" />
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
Margin="0,16,0,0"
Background="{ThemeResource SolidBackgroundFillColorTertiaryBrush}"
CornerRadius="{StaticResource OverlayCornerRadius}">
<ItemsControl
x:Name="KeysControl"
Height="56"
HorizontalAlignment="Center"
VerticalAlignment="Center"
HorizontalContentAlignment="Center"
ItemsSource="{x:Bind Keys, Mode=OneWay}">
<ItemsControl.ItemsPanel>
<ItemsPanelTemplate>
<StackPanel Orientation="Horizontal" Spacing="8" />
</ItemsPanelTemplate>
</ItemsControl.ItemsPanel>
<ItemsControl.ItemTemplate>
<DataTemplate>
<controls:KeyVisual
Padding="20,16"
AutomationProperties.AccessibilityView="Raw"
Content="{Binding}"
CornerRadius="{StaticResource ControlCornerRadius}"
FontSize="16"
FontWeight="SemiBold"
IsTabStop="False"
State="{Binding ElementName=ShortcutContentControl, Path=IsError, Mode=OneWay, Converter={StaticResource BoolToKeyVisualStateConverter}, ConverterParameter=Error}"
Style="{StaticResource AccentKeyVisualStyle}" />
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
<TextBlock
HorizontalAlignment="Center"
VerticalAlignment="Center"
FontSize="12"
Foreground="{ThemeResource TextFillColorSecondaryBrush}"
TextAlignment="Center"
Visibility="{x:Bind Keys.Count, Mode=OneWay, Converter={StaticResource DoubleToInvertedVisibilityConverter}}" />
</Grid>
<StackPanel
Grid.Row="2"
Margin="0,24,0,0"
VerticalAlignment="Top"
Orientation="Vertical"
Spacing="8">
<Grid Height="62">
<InfoBar
x:Uid="InvalidShortcut"
IsClosable="False"
IsOpen="{Binding ElementName=ShortcutContentControl, Path=IsError, Mode=OneWay}"
IsTabStop="{Binding ElementName=ShortcutContentControl, Path=IsError, Mode=OneWay}"
Severity="Error" />
<InfoBar
x:Uid="WarningShortcutAltGr"
IsClosable="False"
IsOpen="{Binding ElementName=ShortcutContentControl, Path=IsWarningAltGr, Mode=OneWay}"
IsTabStop="{Binding ElementName=ShortcutContentControl, Path=IsWarningAltGr, Mode=OneWay}"
Severity="Warning" />
<InfoBar
x:Uid="WarningShortcutConflict"
IsClosable="False"
IsOpen="{Binding ElementName=ShortcutContentControl, Path=HasConflict, Mode=OneWay}"
IsTabStop="{Binding ElementName=ShortcutContentControl, Path=HasConflict, Mode=OneWay}"
Message="{Binding ElementName=ShortcutContentControl, Path=ConflictMessage, Mode=OneWay}"
Severity="Warning" />
</Grid>
<tkcontrols:MarkdownTextBlock
x:Uid="InvalidShortcutWarningLabel"
FontSize="12"
Foreground="{ThemeResource TextFillColorSecondaryBrush}" />
HorizontalAlignment="Center"
Orientation="Horizontal"
Spacing="12">
<HyperlinkButton
x:Name="ResetBtn"
x:Uid="Shortcut_ResetBtn"
Click="ResetBtn_Click">
<ToolTipService.ToolTip>
<TextBlock x:Uid="Shortcut_ResetToolTip" TextWrapping="Wrap" />
</ToolTipService.ToolTip>
<StackPanel Orientation="Horizontal" Spacing="4">
<FontIcon FontSize="10" Glyph="&#xE777;" />
<TextBlock
x:Uid="Shortcut_Reset"
Margin="0,-1,0,0"
VerticalAlignment="Center"
FontSize="12" />
</StackPanel>
</HyperlinkButton>
<HyperlinkButton
x:Name="ClearBtn"
x:Uid="Shortcut_ClearBtn"
Click="ClearBtn_Click"
Visibility="{x:Bind Keys.Count, Mode=OneWay, Converter={StaticResource DoubleToVisibilityConverter}}">
<ToolTipService.ToolTip>
<TextBlock x:Uid="Shortcut_ClearToolTip" TextWrapping="Wrap" />
</ToolTipService.ToolTip>
<StackPanel Orientation="Horizontal" Spacing="4">
<FontIcon FontSize="12" Glyph="&#xE894;" />
<TextBlock
x:Uid="Shortcut_Clear"
Margin="0,-1,0,0"
VerticalAlignment="Center"
FontSize="12" />
</StackPanel>
</HyperlinkButton>
</StackPanel>
<Grid Grid.Row="3" Margin="0,12,0,0">
<InfoBar
x:Uid="InvalidShortcut"
BorderThickness="0"
IsClosable="False"
IsOpen="{Binding ElementName=ShortcutContentControl, Path=IsError, Mode=OneWay}"
IsTabStop="{Binding ElementName=ShortcutContentControl, Path=IsError, Mode=OneWay}"
Severity="Error"
Style="{StaticResource CondensedInfoBarStyle}" />
<InfoBar
x:Uid="WarningShortcutAltGr"
BorderThickness="0"
IsClosable="False"
IsOpen="{Binding ElementName=ShortcutContentControl, Path=IsWarningAltGr, Mode=OneWay}"
IsTabStop="{Binding ElementName=ShortcutContentControl, Path=IsWarningAltGr, Mode=OneWay}"
Severity="Warning"
Style="{StaticResource CondensedInfoBarStyle}" />
<InfoBar
BorderThickness="0"
IsClosable="False"
IsOpen="{Binding ElementName=ShortcutContentControl, Path=ShouldShowConflict, Mode=OneWay}"
IsTabStop="{Binding ElementName=ShortcutContentControl, Path=ShouldShowConflict, Mode=OneWay}"
Message="{Binding ElementName=ShortcutContentControl, Path=ConflictMessage, Mode=OneWay}"
Severity="Warning"
Style="{StaticResource CondensedInfoBarStyle}" />
<InfoBar
x:Uid="WarningPotentialShortcutConflict"
BorderThickness="0"
IsClosable="False"
IsOpen="{Binding ElementName=ShortcutContentControl, Path=ShouldShowPotentialConflict, Mode=OneWay}"
IsTabStop="{Binding ElementName=ShortcutContentControl, Path=ShouldShowPotentialConflict, Mode=OneWay}"
Message="{Binding ElementName=ShortcutContentControl, Path=ConflictMessage, Mode=OneWay}"
Severity="Warning"
Style="{StaticResource CondensedInfoBarStyle}">
<InfoBar.ActionButton>
<HyperlinkButton
x:Uid="Shortcut_Conflict_LearnMore"
Padding="0"
Click="LearnMoreBtn_Click" />
</InfoBar.ActionButton>
</InfoBar>
</Grid>
</Grid>
</UserControl>

View File

@@ -2,8 +2,10 @@
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
using System;
using System.Collections.Generic;
using System.Diagnostics.Eventing.Reader;
using Microsoft.PowerToys.Settings.UI.Views;
using Microsoft.UI.Xaml;
using Microsoft.UI.Xaml.Controls;
@@ -14,8 +16,22 @@ namespace Microsoft.PowerToys.Settings.UI.Controls
public static readonly DependencyProperty KeysProperty = DependencyProperty.Register("Keys", typeof(List<object>), typeof(ShortcutDialogContentControl), new PropertyMetadata(default(string)));
public static readonly DependencyProperty IsErrorProperty = DependencyProperty.Register("IsError", typeof(bool), typeof(ShortcutDialogContentControl), new PropertyMetadata(false));
public static readonly DependencyProperty IsWarningAltGrProperty = DependencyProperty.Register("IsWarningAltGr", typeof(bool), typeof(ShortcutDialogContentControl), new PropertyMetadata(false));
public static readonly DependencyProperty HasConflictProperty = DependencyProperty.Register("HasConflict", typeof(bool), typeof(ShortcutDialogContentControl), new PropertyMetadata(false));
public static readonly DependencyProperty HasConflictProperty = DependencyProperty.Register("HasConflict", typeof(bool), typeof(ShortcutDialogContentControl), new PropertyMetadata(false, OnConflictPropertyChanged));
public static readonly DependencyProperty ConflictMessageProperty = DependencyProperty.Register("ConflictMessage", typeof(string), typeof(ShortcutDialogContentControl), new PropertyMetadata(string.Empty));
public static readonly DependencyProperty IgnoreConflictProperty = DependencyProperty.Register("IgnoreConflict", typeof(bool), typeof(ShortcutDialogContentControl), new PropertyMetadata(false, OnIgnoreConflictChanged));
public static readonly DependencyProperty ShouldShowConflictProperty = DependencyProperty.Register("ShouldShowConflict", typeof(bool), typeof(ShortcutDialogContentControl), new PropertyMetadata(false));
public static readonly DependencyProperty ShouldShowPotentialConflictProperty = DependencyProperty.Register("ShouldShowPotentialConflict", typeof(bool), typeof(ShortcutDialogContentControl), new PropertyMetadata(false));
public event EventHandler<bool> IgnoreConflictChanged;
public event RoutedEventHandler LearnMoreClick;
public bool IgnoreConflict
{
get => (bool)GetValue(IgnoreConflictProperty);
set => SetValue(IgnoreConflictProperty, value);
}
public bool HasConflict
{
@@ -29,9 +45,22 @@ namespace Microsoft.PowerToys.Settings.UI.Controls
set => SetValue(ConflictMessageProperty, value);
}
public bool ShouldShowConflict
{
get => (bool)GetValue(ShouldShowConflictProperty);
private set => SetValue(ShouldShowConflictProperty, value);
}
public bool ShouldShowPotentialConflict
{
get => (bool)GetValue(ShouldShowPotentialConflictProperty);
private set => SetValue(ShouldShowPotentialConflictProperty, value);
}
public ShortcutDialogContentControl()
{
this.InitializeComponent();
UpdateShouldShowConflict();
}
public List<object> Keys
@@ -51,5 +80,54 @@ namespace Microsoft.PowerToys.Settings.UI.Controls
get => (bool)GetValue(IsWarningAltGrProperty);
set => SetValue(IsWarningAltGrProperty, value);
}
public event RoutedEventHandler ResetClick;
public event RoutedEventHandler ClearClick;
private static void OnIgnoreConflictChanged(DependencyObject d, DependencyPropertyChangedEventArgs e)
{
var control = d as ShortcutDialogContentControl;
if (control == null)
{
return;
}
control.UpdateShouldShowConflict();
control.IgnoreConflictChanged?.Invoke(control, (bool)e.NewValue);
}
private static void OnConflictPropertyChanged(DependencyObject d, DependencyPropertyChangedEventArgs e)
{
var control = d as ShortcutDialogContentControl;
if (control == null)
{
return;
}
control.UpdateShouldShowConflict();
}
private void UpdateShouldShowConflict()
{
ShouldShowConflict = !IgnoreConflict && HasConflict;
ShouldShowPotentialConflict = IgnoreConflict && HasConflict;
}
private void ResetBtn_Click(object sender, RoutedEventArgs e)
{
ResetClick?.Invoke(this, new RoutedEventArgs());
}
private void ClearBtn_Click(object sender, RoutedEventArgs e)
{
ClearClick?.Invoke(this, new RoutedEventArgs());
}
private void LearnMoreBtn_Click(object sender, RoutedEventArgs e)
{
LearnMoreClick?.Invoke(this, new RoutedEventArgs());
}
}
}

View File

@@ -14,7 +14,6 @@ using Microsoft.PowerToys.Settings.UI.Services;
using Microsoft.PowerToys.Settings.UI.SettingsXAML.Controls.Dashboard;
using Microsoft.PowerToys.Settings.UI.Views;
using Microsoft.PowerToys.Telemetry;
using Microsoft.UI;
using Microsoft.UI.Xaml.Controls;
using Microsoft.UI.Xaml.Media;
using Microsoft.UI.Xaml.Navigation;
@@ -29,6 +28,8 @@ namespace Microsoft.PowerToys.Settings.UI.OOBE.Views
private AllHotkeyConflictsData _allHotkeyConflictsData = new AllHotkeyConflictsData();
private Windows.ApplicationModel.Resources.ResourceLoader resourceLoader = Helpers.ResourceLoaderInstance.ResourceLoader;
private int _conflictCount;
public bool EnableDataDiagnostics
{
get
@@ -60,6 +61,9 @@ namespace Microsoft.PowerToys.Settings.UI.OOBE.Views
if (_allHotkeyConflictsData != value)
{
_allHotkeyConflictsData = value;
UpdateConflictCount();
OnPropertyChanged(nameof(AllHotkeyConflictsData));
OnPropertyChanged(nameof(ConflictCount));
OnPropertyChanged(nameof(ConflictText));
@@ -71,28 +75,43 @@ namespace Microsoft.PowerToys.Settings.UI.OOBE.Views
}
}
public int ConflictCount
public int ConflictCount => _conflictCount;
private void UpdateConflictCount()
{
get
int count = 0;
if (AllHotkeyConflictsData == null)
{
if (AllHotkeyConflictsData == null)
{
return 0;
}
int count = 0;
if (AllHotkeyConflictsData.InAppConflicts != null)
{
count += AllHotkeyConflictsData.InAppConflicts.Count;
}
if (AllHotkeyConflictsData.SystemConflicts != null)
{
count += AllHotkeyConflictsData.SystemConflicts.Count;
}
return count;
_conflictCount = count;
}
if (AllHotkeyConflictsData.InAppConflicts != null)
{
foreach (var inAppConflict in AllHotkeyConflictsData.InAppConflicts)
{
var hotkey = inAppConflict.Hotkey;
var hotkeySettings = new HotkeySettings(hotkey.Win, hotkey.Ctrl, hotkey.Alt, hotkey.Shift, hotkey.Key);
if (!HotkeyConflictIgnoreHelper.IsIgnoringConflicts(hotkeySettings))
{
count++;
}
}
}
if (AllHotkeyConflictsData.SystemConflicts != null)
{
foreach (var systemConflict in AllHotkeyConflictsData.SystemConflicts)
{
var hotkey = systemConflict.Hotkey;
var hotkeySettings = new HotkeySettings(hotkey.Win, hotkey.Ctrl, hotkey.Alt, hotkey.Shift, hotkey.Key);
if (!HotkeyConflictIgnoreHelper.IsIgnoringConflicts(hotkeySettings))
{
count++;
}
}
}
_conflictCount = count;
}
public string ConflictText

View File

@@ -16,7 +16,9 @@ using CommunityToolkit.WinUI.Controls;
using global::PowerToys.GPOWrapper;
using ManagedCommon;
using Microsoft.PowerToys.Settings.UI.Helpers;
using Microsoft.PowerToys.Settings.UI.Library;
using Microsoft.PowerToys.Settings.UI.Library.HotkeyConflicts;
using Microsoft.PowerToys.Settings.UI.Library.Interfaces;
using Microsoft.PowerToys.Settings.UI.OOBE.Enums;
using Microsoft.PowerToys.Settings.UI.OOBE.ViewModel;
using Microsoft.PowerToys.Settings.UI.SerializationContext;
@@ -39,6 +41,8 @@ namespace Microsoft.PowerToys.Settings.UI.OOBE.Views
public bool ShowDataDiagnosticsInfoBar => GetShowDataDiagnosticsInfoBar();
private int _conflictCount;
public AllHotkeyConflictsData AllHotkeyConflictsData
{
get => _allHotkeyConflictsData;
@@ -47,34 +51,48 @@ namespace Microsoft.PowerToys.Settings.UI.OOBE.Views
if (_allHotkeyConflictsData != value)
{
_allHotkeyConflictsData = value;
UpdateConflictCount();
OnPropertyChanged(nameof(AllHotkeyConflictsData));
OnPropertyChanged(nameof(HasConflicts));
}
}
}
public bool HasConflicts
public bool HasConflicts => _conflictCount > 0;
private void UpdateConflictCount()
{
get
int count = 0;
if (AllHotkeyConflictsData == null)
{
if (AllHotkeyConflictsData == null)
{
return false;
}
int count = 0;
if (AllHotkeyConflictsData.InAppConflicts != null)
{
count += AllHotkeyConflictsData.InAppConflicts.Count;
}
if (AllHotkeyConflictsData.SystemConflicts != null)
{
count += AllHotkeyConflictsData.SystemConflicts.Count;
}
return count > 0;
_conflictCount = count;
}
if (AllHotkeyConflictsData.InAppConflicts != null)
{
foreach (var inAppConflict in AllHotkeyConflictsData.InAppConflicts)
{
if (!inAppConflict.ConflictIgnored)
{
count++;
}
}
}
if (AllHotkeyConflictsData.SystemConflicts != null)
{
foreach (var systemConflict in AllHotkeyConflictsData.SystemConflicts)
{
if (!systemConflict.ConflictIgnored)
{
count++;
}
}
}
_conflictCount = count;
}
public event PropertyChangedEventHandler PropertyChanged;
@@ -100,6 +118,21 @@ namespace Microsoft.PowerToys.Settings.UI.OOBE.Views
{
this.DispatcherQueue.TryEnqueue(Microsoft.UI.Dispatching.DispatcherQueuePriority.Normal, () =>
{
var allConflictData = e.Conflicts;
foreach (var inAppConflict in allConflictData.InAppConflicts)
{
var hotkey = inAppConflict.Hotkey;
var hotkeySetting = new HotkeySettings(hotkey.Win, hotkey.Ctrl, hotkey.Alt, hotkey.Shift, hotkey.Key);
inAppConflict.ConflictIgnored = HotkeyConflictIgnoreHelper.IsIgnoringConflicts(hotkeySetting);
}
foreach (var systemConflict in allConflictData.SystemConflicts)
{
var hotkey = systemConflict.Hotkey;
var hotkeySetting = new HotkeySettings(hotkey.Win, hotkey.Ctrl, hotkey.Alt, hotkey.Shift, hotkey.Key);
systemConflict.ConflictIgnored = HotkeyConflictIgnoreHelper.IsIgnoringConflicts(hotkeySetting);
}
AllHotkeyConflictsData = e.Conflicts ?? new AllHotkeyConflictsData();
});
}

View File

@@ -162,7 +162,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
@@ -355,6 +355,14 @@
Value="{x:Bind ViewModel.MousePointerCrosshairsBorderSize, Mode=TwoWay}" />
</tkcontrols:SettingsCard>
<tkcontrols:SettingsCard Name="MouseUtilsMousePointerCrosshairsCrosshairsOrientation" x:Uid="MouseUtils_MousePointerCrosshairs_CrosshairsOrientation">
<ComboBox MinWidth="{StaticResource SettingActionControlMinWidth}" SelectedIndex="{x:Bind Path=ViewModel.MousePointerCrosshairsOrientation, Mode=TwoWay}">
<ComboBoxItem x:Uid="MouseUtils_MousePointerCrosshairs_CrosshairsOrientation_Both" />
<ComboBoxItem x:Uid="MouseUtils_MousePointerCrosshairs_CrosshairsOrientation_Vertical" />
<ComboBoxItem x:Uid="MouseUtils_MousePointerCrosshairs_CrosshairsOrientation_Horizontal" />
</ComboBox>
</tkcontrols:SettingsCard>
<tkcontrols:SettingsCard ContentAlignment="Left">
<CheckBox x:Uid="MouseUtils_MousePointerCrosshairs_CrosshairsAutoHide" IsChecked="{x:Bind ViewModel.MousePointerCrosshairsAutoHide, Mode=TwoWay}" />
</tkcontrols:SettingsCard>

View File

@@ -2667,23 +2667,20 @@ From there, simply click on one of the supported files in the File Explorer and
<value>Press a combination of keys to change this shortcut.
Right-click to remove the key combination, thereby deactivating the shortcut.</value>
</data>
<data name="Activation_Shortcut_Reset" xml:space="preserve">
<value>Reset</value>
</data>
<data name="Activation_Shortcut_Save" xml:space="preserve">
<value>Save</value>
</data>
<data name="Activation_Shortcut_Title" xml:space="preserve">
<value>Activation shortcut</value>
</data>
<data name="InvalidShortcut.Title" xml:space="preserve">
<data name="InvalidShortcut.Message" xml:space="preserve">
<value>Invalid shortcut</value>
</data>
<data name="InvalidShortcutWarningLabel.Text" xml:space="preserve">
<value>Only shortcuts that start with **Windows key**, **Ctrl**, **Alt** or **Shift** are valid.</value>
<value>A shortcut should start with **Windows key**, **Ctrl**, **Alt** or **Shift**.</value>
<comment>The ** sequences are used for text formatting of the key names. Don't remove them on translation.</comment>
</data>
<data name="WarningShortcutAltGr.Title" xml:space="preserve">
<data name="WarningShortcutAltGr.Message" xml:space="preserve">
<value>Possible shortcut interference with Alt Gr</value>
<comment>Alt Gr refers to the right alt key on some international keyboards</comment>
</data>
@@ -2691,8 +2688,8 @@ Right-click to remove the key combination, thereby deactivating the shortcut.</v
<value>Shortcuts with **Ctrl** and **Alt** may remove functionality from some international keyboards, because **Ctrl** + **Alt** = **Alt Gr** in those keyboards.</value>
<comment>The ** sequences are used for text formatting of the key names. Don't remove them on translation.</comment>
</data>
<data name="WarningShortcutConflict.Title" xml:space="preserve">
<value>Shortcut conflict</value>
<data name="WarningPotentialShortcutConflict.Message" xml:space="preserve">
<value>This shortcut has a potential conflict, but the warning is ignored.</value>
</data>
<data name="WarningShortcutConflict.ToolTipService.ToolTip" xml:space="preserve">
<value>A conflict has been detected for this shortcut.</value>
@@ -2895,6 +2892,18 @@ Right-click to remove the key combination, thereby deactivating the shortcut.</v
<data name="MouseUtils_MousePointerCrosshairs_CrosshairsFixedLength.Header" xml:space="preserve">
<value>Crosshairs fixed length (px)</value>
<comment>px = pixels</comment>
</data>
<data name="MouseUtils_MousePointerCrosshairs_CrosshairsOrientation" xml:space="preserve">
<value>Crosshairs orientation</value>
</data>
<data name="MouseUtils_MousePointerCrosshairs_CrosshairsOrientation_Both.Content" xml:space="preserve">
<value>Vertical and horizontal lines</value>
</data>
<data name="MouseUtils_MousePointerCrosshairs_CrosshairsOrientation_Vertical.Content" xml:space="preserve">
<value>Vertical only</value>
</data>
<data name="MouseUtils_MousePointerCrosshairs_CrosshairsOrientation_Horizontal.Content" xml:space="preserve">
<value>Horizontal only</value>
</data>
<data name="MouseUtils_GlidingCursor.Header" xml:space="preserve">
<value>Gliding cursor</value>
@@ -5256,23 +5265,23 @@ To record a specific window, enter the hotkey with the Alt key in the opposite m
<data name="ShortcutConflictWindow_Title" xml:space="preserve">
<value>PowerToys shortcut conflicts</value>
</data>
<data name="ShortcutConflictWindow_TitleTxt.Text" xml:space="preserve">
<data name="ShortcutConflictWindow_TitleTxt.Title" xml:space="preserve">
<value>PowerToys shortcut conflicts</value>
</data>
<data name="ShortcutConflictWindow_Description.Text" xml:space="preserve">
<value>Conflicting shortcuts may cause unexpected behavior. Edit them here or go to the module settings to update them.</value>
<value>If any shortcut conflicts are detected, theyll appear below. Conflicts can happen between PowerToys utilities or Windows system shortcuts, and may cause unexpected behavior. If everything works as expected, you can safely ignore the conflict.</value>
</data>
<data name="ShortcutConflictWindow_ModulesUsingShortcut.Text" xml:space="preserve">
<value>Conflicts found for</value>
</data>
<data name="ShortcutConflictWindow_SystemCard.Header" xml:space="preserve">
<value>System</value>
<value>System shortcut</value>
</data>
<data name="ShortcutConflictWindow_SystemCard.Description" xml:space="preserve">
<value>Windows system shortcut</value>
<value>This shortcut is reserved by Windows and can't be reassigned.</value>
</data>
<data name="ShortcutConflictWindow_SystemShortcutMessage.Text" xml:space="preserve">
<value>This shortcut can't be changed.</value>
<data name="ShortcutConflictWindow_SystemShortcutLink.Content" xml:space="preserve">
<value>See all Windows shortcuts</value>
</data>
<data name="ShortcutConflictWindow_SystemShortcutTooltip.Content" xml:space="preserve">
<value>This shortcut is used by Windows and can't be changed.</value>
@@ -5312,4 +5321,31 @@ 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="DismissConflictBtn.[using:Microsoft.UI.Xaml.Automation]AutomationProperties.Name" xml:space="preserve">
<value>Dismiss</value>
</data>
<data name="DismissText.Text" xml:space="preserve">
<value>Dismiss</value>
</data>
<data name="Shortcut_ResetBtn.[using:Microsoft.UI.Xaml.Automation]AutomationProperties.Name" xml:space="preserve">
<value>Reset shortcut</value>
</data>
<data name="Shortcut_ResetToolTip.Text" xml:space="preserve">
<value>Reset to the default shortcut</value>
</data>
<data name="Shortcut_Reset.Text" xml:space="preserve">
<value>Reset</value>
</data>
<data name="Shortcut_ClearBtn.[using:Microsoft.UI.Xaml.Automation]AutomationProperties.Name" xml:space="preserve">
<value>Clear shortcut</value>
</data>
<data name="Shortcut_ClearToolTip.Text" xml:space="preserve">
<value>Clear and unassign this shortcut</value>
</data>
<data name="Shortcut_Clear.Text" xml:space="preserve">
<value>Clear</value>
</data>
<data name="Shortcut_Conflict_LearnMore.Content" xml:space="preserve">
<value>Learn more</value>
</data>
</root>

View File

@@ -128,14 +128,7 @@ namespace Microsoft.PowerToys.Settings.UI.ViewModels
{
if (value != _hotkey)
{
if (value == null || value.IsEmpty())
{
_hotkey = AlwaysOnTopProperties.DefaultHotkeyValue;
}
else
{
_hotkey = value;
}
_hotkey = value ?? AlwaysOnTopProperties.DefaultHotkeyValue;
Settings.Properties.Hotkey.Value = _hotkey;
NotifyPropertyChanged();

View File

@@ -29,7 +29,6 @@ namespace Microsoft.PowerToys.Settings.UI.ViewModels
{
protected override string ModuleName => "Dashboard";
private const string JsonFileType = ".json";
private Dispatcher dispatcher;
public Func<string, int> SendConfigMSG { get; }
@@ -88,6 +87,21 @@ namespace Microsoft.PowerToys.Settings.UI.ViewModels
{
dispatcher.BeginInvoke(() =>
{
var allConflictData = e.Conflicts;
foreach (var inAppConflict in allConflictData.InAppConflicts)
{
var hotkey = inAppConflict.Hotkey;
var hotkeySetting = new HotkeySettings(hotkey.Win, hotkey.Ctrl, hotkey.Alt, hotkey.Shift, hotkey.Key);
inAppConflict.ConflictIgnored = HotkeyConflictIgnoreHelper.IsIgnoringConflicts(hotkeySetting);
}
foreach (var systemConflict in allConflictData.SystemConflicts)
{
var hotkey = systemConflict.Hotkey;
var hotkeySetting = new HotkeySettings(hotkey.Win, hotkey.Ctrl, hotkey.Alt, hotkey.Shift, hotkey.Key);
systemConflict.ConflictIgnored = HotkeyConflictIgnoreHelper.IsIgnoringConflicts(hotkeySetting);
}
AllHotkeyConflictsData = e.Conflicts ?? new AllHotkeyConflictsData();
});
}

View File

@@ -776,7 +776,7 @@ namespace Microsoft.PowerToys.Settings.UI.ViewModels
{
if (value != _editorHotkey)
{
if (value == null || value.IsEmpty())
if (value == null)
{
_editorHotkey = FZConfigProperties.DefaultEditorHotkeyValue;
}
@@ -822,7 +822,7 @@ namespace Microsoft.PowerToys.Settings.UI.ViewModels
{
if (value != _nextTabHotkey)
{
if (value == null || value.IsEmpty())
if (value == null)
{
_nextTabHotkey = FZConfigProperties.DefaultNextTabHotkeyValue;
}
@@ -848,7 +848,7 @@ namespace Microsoft.PowerToys.Settings.UI.ViewModels
{
if (value != _prevTabHotkey)
{
if (value == null || value.IsEmpty())
if (value == null)
{
_prevTabHotkey = FZConfigProperties.DefaultPrevTabHotkeyValue;
}

View File

@@ -100,6 +100,7 @@ namespace Microsoft.PowerToys.Settings.UI.ViewModels
_mousePointerCrosshairsAutoHide = MousePointerCrosshairsSettingsConfig.Properties.CrosshairsAutoHide.Value;
_mousePointerCrosshairsIsFixedLengthEnabled = MousePointerCrosshairsSettingsConfig.Properties.CrosshairsIsFixedLengthEnabled.Value;
_mousePointerCrosshairsFixedLength = MousePointerCrosshairsSettingsConfig.Properties.CrosshairsFixedLength.Value;
_mousePointerCrosshairsOrientation = MousePointerCrosshairsSettingsConfig.Properties.CrosshairsOrientation.Value;
_mousePointerCrosshairsAutoActivate = MousePointerCrosshairsSettingsConfig.Properties.AutoActivate.Value;
int isEnabled = 0;
@@ -869,6 +870,24 @@ namespace Microsoft.PowerToys.Settings.UI.ViewModels
}
}
public int MousePointerCrosshairsOrientation
{
get
{
return _mousePointerCrosshairsOrientation;
}
set
{
if (value != _mousePointerCrosshairsOrientation)
{
_mousePointerCrosshairsOrientation = value;
MousePointerCrosshairsSettingsConfig.Properties.CrosshairsOrientation.Value = value;
NotifyMousePointerCrosshairsPropertyChanged();
}
}
}
public bool MousePointerCrosshairsAutoActivate
{
get
@@ -991,6 +1010,7 @@ namespace Microsoft.PowerToys.Settings.UI.ViewModels
private bool _mousePointerCrosshairsAutoHide;
private bool _mousePointerCrosshairsIsFixedLengthEnabled;
private int _mousePointerCrosshairsFixedLength;
private int _mousePointerCrosshairsOrientation;
private bool _mousePointerCrosshairsAutoActivate;
private bool _isAnimationEnabledBySystem;
}

View File

@@ -12,12 +12,10 @@ using System.Reflection;
using System.Text.Json;
using System.Text.Json.Serialization;
using System.Text.Json.Serialization.Metadata;
using System.Windows;
using System.Windows.Threading;
using ManagedCommon;
using Microsoft.PowerToys.Settings.UI.Helpers;
using Microsoft.PowerToys.Settings.UI.Library;
using Microsoft.PowerToys.Settings.UI.Library.Helpers;
using Microsoft.PowerToys.Settings.UI.Library.HotkeyConflicts;
using Microsoft.PowerToys.Settings.UI.Library.Interfaces;
using Microsoft.PowerToys.Settings.UI.SerializationContext;
@@ -70,6 +68,36 @@ namespace Microsoft.PowerToys.Settings.UI.ViewModels
protected override string ModuleName => "ShortcutConflictsWindow";
/// <summary>
/// Ignore a specific HotkeySettings
/// </summary>
/// <param name="hotkeySettings">The HotkeySettings to ignore</param>
public void IgnoreShortcut(HotkeySettings hotkeySettings)
{
if (hotkeySettings == null)
{
return;
}
HotkeyConflictIgnoreHelper.AddToIgnoredList(hotkeySettings);
GlobalHotkeyConflictManager.Instance?.RequestAllConflicts();
}
/// <summary>
/// Remove a HotkeySettings from the ignored list
/// </summary>
/// <param name="hotkeySettings">The HotkeySettings to unignore</param>
public void UnignoreShortcut(HotkeySettings hotkeySettings)
{
if (hotkeySettings == null)
{
return;
}
HotkeyConflictIgnoreHelper.RemoveFromIgnoredList(hotkeySettings);
GlobalHotkeyConflictManager.Instance?.RequestAllConflicts();
}
private IHotkeyConfig GetModuleSettings(string moduleKey)
{
try
@@ -120,20 +148,24 @@ namespace Microsoft.PowerToys.Settings.UI.ViewModels
foreach (var conflict in conflicts)
{
ProcessConflictGroup(conflict, isSystemConflict);
HotkeySettings hotkey = new(conflict.Hotkey.Win, conflict.Hotkey.Ctrl, conflict.Hotkey.Alt, conflict.Hotkey.Shift, conflict.Hotkey.Key);
var isIgnored = HotkeyConflictIgnoreHelper.IsIgnoringConflicts(hotkey);
conflict.ConflictIgnored = isIgnored;
ProcessConflictGroup(conflict, isSystemConflict, isIgnored);
items.Add(conflict);
}
}
private void ProcessConflictGroup(HotkeyConflictGroupData conflict, bool isSystemConflict)
private void ProcessConflictGroup(HotkeyConflictGroupData conflict, bool isSystemConflict, bool isIgnored)
{
foreach (var module in conflict.Modules)
{
SetupModuleData(module, isSystemConflict);
SetupModuleData(module, isSystemConflict, isIgnored);
}
}
private void SetupModuleData(ModuleHotkeyData module, bool isSystemConflict)
private void SetupModuleData(ModuleHotkeyData module, bool isSystemConflict, bool isIgnored)
{
try
{
@@ -220,55 +252,6 @@ namespace Microsoft.PowerToys.Settings.UI.ViewModels
}
}
private void SaveModuleSettingsAndNotify(string moduleName)
{
try
{
var settings = GetModuleSettings(moduleName);
if (settings is ISettingsConfig settingsConfig)
{
// No need to save settings here, the runner will call module interface to save it
// SaveSettingsToFile(settings);
// Send IPC notification using the same format as other ViewModels
SendConfigMSG(settingsConfig, moduleName);
System.Diagnostics.Debug.WriteLine($"Saved settings and sent IPC notification for module: {moduleName}");
}
}
catch (Exception ex)
{
System.Diagnostics.Debug.WriteLine($"Error saving settings and notifying for {moduleName}: {ex.Message}");
}
}
private void SaveSettingsToFile(IHotkeyConfig settings)
{
try
{
// Get the repository for this settings type using reflection
var settingsType = settings.GetType();
var repositoryMethod = typeof(SettingsFactory).GetMethod("GetRepository");
if (repositoryMethod != null)
{
var genericMethod = repositoryMethod.MakeGenericMethod(settingsType);
var repository = genericMethod.Invoke(_settingsFactory, null);
if (repository != null)
{
var saveMethod = repository.GetType().GetMethod("SaveSettingsToFile");
saveMethod?.Invoke(repository, null);
System.Diagnostics.Debug.WriteLine($"Saved settings to file for type: {settingsType.Name}");
}
}
}
catch (Exception ex)
{
System.Diagnostics.Debug.WriteLine($"Error saving settings to file: {ex.Message}");
}
}
/// <summary>
/// Sends IPC notification using the same format as other ViewModels
/// </summary>

View File

@@ -127,7 +127,7 @@ namespace Microsoft.PowerToys.Settings.UI.ViewModels
{
if (value != _hotkey)
{
if (value == null || value.IsEmpty())
if (value == null)
{
_hotkey = WorkspacesProperties.DefaultHotkeyValue;
}

View File

@@ -0,0 +1,76 @@
## Background
This document describes how to collect pull requests for a milestone, request a GitHub Copilot code review for each, and produce releasenotes summaries grouped by label.
## Agentmode execution policy (important)
- By default, do NOT run terminal commands or PowerShell scripts beside the ps1 in this folder. Perform all collection, parsing, grouping, and summarization entirely in Agent mode using available files and MCP capabilities.
- Only execute existing scripts if the user explicitly asks you to (optin). Otherwise, assume the input artifacts (milestone_prs.json, sorted_prs.csv, grouped_csv/*) are present or will be provided.
- Do NOT create new scripts unless requested and justified.
## Prerequisites
- Windows with PowerShell 7+ (pwsh)
- GitHub CLI installed and authenticated to the target repo
- gh version that supports Copilot review requests
- Logged in: gh auth login (ensure repo scope)
- Access to the repository configured in the scripts (default: `microsoft/PowerToys`)
- GitHub Copilot code review enabled for the org/repo (required for requesting reviews)
- 'MCP Server: github-remote' is installed, please find it at [github-mcp-server](https://github.com/github/github-mcp-server)
## Files in this repo (overview)
- `dump-prs-information.ps1`: Fetches PRs for a milestone and outputs `milestone_prs.json` and `sorted_prs.csv`
- CSV columns: `Id, Title, Labels, Author, Url, Body, CopilotSummary`
- `diff_prs.ps1`: Creates an incremental CSV by diffing two CSVs (in case more PRs cherry pick to stable)
- `MemberList.md`: Internal contributors list (used to decide when to add external thanks)
- `SampleOutput.md`: Example formatting for summary content
## Step-by-step
1) run `dump-prs-information.ps1` to export PRs for the target milestone (initial run, CopilotSummary likely empty)
- Open `dump-prs-information.ps1` and set:
- `$repo` (e.g., `microsoft/PowerToys`)
- `$milestone` (milestone title exactly as in GitHub, e.g., `PowerToys 0.95`)
- run the script in PowerShell; it will generate `milestone_prs.json` and `sorted_prs.csv`.
2) Request Copilot reviews for each PR listed in the CSV in Agent mode (MUST NOT generate or run any ps1)
- Must use MCP tools "MCP Server: github-remote" in current Agent mode to request Copilot reviews for all PR Ids in `sorted_prs.csv`.
3) run `dump-prs-information.ps1` again
- This refresh collects the latest Copilot review body into the `CopilotSummary` column in `sorted_prs.csv`.
4) run `group-prs-by-label.ps1` to generate `grouped_csv/`
5) Summarize PRs into perlabel Markdown files in Agent mode (MUST NOT generate or run any script in terminal nor ps1)
- Read the the csv files in the folder grouped_csv one by one
- For each label group, create a markdown file under a new folder `grouped_md/` (create if missing). File name: sanitized label group name (same pattern as CSV) with `.md` extension. Example: `Area-Build.md`.
- Each markdown file content must follow the structure below (two sections) and preserve the PR order from the source CSV.
- Do not embed PR numbers in the bullet list lines; only link them in the table.
- If re-running, overwrite existing markdown files (idempotent generation).
- After generation, you should have a 1:1 correspondence between files in `grouped_csv/` and `grouped_md/` (excluding any intentionally skipped groups—document if skipped).
- Generate the summary md file as the following instruction in two parts:
1. Markdown list: one concise, userfacing line per PR (no deep technical jargon). Use "Verbed" + "Scenario" + "Impact" as setence structure. Use `Title`, `Body`, and `CopilotSummary` as sources.
- If `Author` is NOT in `**/MemberList.md`, append a "Thanks @handle!" see `**/SampleOutput.md` as example.
- Do NOT include PR numbers or IDs in the list line; keep the PR link only in the table mentioned in 2. below, please refer to `**/SampleOutput.md` as example.
- If confidence to have enough information for summarization according to guideline above is < 70%, write: `Human Summary Needed: <PR full link>` on that line.
2. Threecolumn table (in the same PR order):
- Column 1: The concise, userfacing summary (the "cut version")
- Column 2: PR link
- Column 3: Confidence (e.g., `High/Medium/Low`) and the reasoning if < 70%
6) According the generated grouped_md/*.md, update back the repo root's `Readme.md`. Here is the guideline:
a. Replace all versioned references in `README.md`:
- Bump current release heading (e.g. **Version 0.xx**) by +0.01.
- Shift link references: previous `[github-current-release-work]` becomes old version; increment `[github-next-release-work]` to point to the following milestone.
- Update download asset filenames (e.g. `PowerToysSetup-0.94.0-...``PowerToysSetup-0.95.0-...`).
b. Build the What's New content from `grouped_md`:
- Combine `Area-Build` and `Area-Tests` entries under a single `Development` subsection (keep bullet order from CSV).
- Each other `Product-*` group gets its own subsection titled by the module name.
- Order subsections alphabetically by their heading text, with **Highlights** always first and **Development** always last (e.g., Environment Variables, File Locksmith, Find My Mouse, ... , ZoomIt, Development).
- Copy bullet lines verbatim from the corresponding `grouped_md` files (preserve punctuation and any trailing `Thanks @handle!`). Do NOT add, remove, or reevaluate thanks in the README stage.
c. Highlights: choose up to 10 bullets focused on user-visible feature additions or impactful fixes (avoid purely internal refactors). Use pattern: `Module/Feature <past-tense verb> <scenario> <impact>`.
d. Keep wording concise (aim 1 line per bullet), no PR numbers, no deep implementation details.
e. After updating, verify total highlight count ≤ 10 and that all internal contributors are not thanked.
## Notes and conventions
- Terminal usage: Disabled by default. Do NOT run terminal commands or ps1 scripts unless the user explicitly instructs you to.
- Do NOT generate/add new ps1 until instructed (and explain why a new script is needed).
- Label filtering in `dump-prs-information.ps1` currently keeps labels matching: `Product-*`, `Area-*`, `Github*`, `*Plugin`, `Issue-*`.
- CSV columns are singleline (line breaks removed) for easier processing.
- Keep PRs in the same order as in `sorted_prs.csv` when building summaries.
- Sanitize filenames: replace spaces with `-`, strip or replace characters that are invalid on Windows (`<>:"/\\|?*`).

View File

@@ -0,0 +1,26 @@
cinnamon-msft
craigloewen-msft
niels9001
dhowett
yeelam-gordon
jamrobot
lei9444
shuaiyuanxx
moooyo
haoliuu
chenmy77
chemwolf6922
yaqingmi
zhaoqpcn
urnotdfs
zhaopy536
wang563681252
vanzue
zadjii-msft
khmyznikov
chatasweetie
michaeljolley
Jaylyn-Barbee
zateutsch
crutkas
app/copilot-swe-agent

View File

@@ -0,0 +1,9 @@
- Added mouse button actions so you can choose what left, right, or middle click does. Thanks [@PesBandi](https://github.com/PesBandi)!
- Aligned window styling with current Windows theme for a cleaner look. Thanks [@sadirano](https://github.com/sadirano)!
- Ensured screen readers are notified when the selected item in the list changes for better accessibility.
- Implemented configurable UI test pipeline that can use pre-built official releases instead of building everything from scratch, reducing test execution time from 2+ hours.
- Fixed Alt+Left Arrow navigation not working when search box contains text. Thanks [@jiripolasek](https://github.com/jiripolasek)!

View File

@@ -0,0 +1,100 @@
<#
.SYNOPSIS
Produce an incremental PR CSV containing rows present in a newer full export but absent from a baseline export.
.DESCRIPTION
Compares two previously generated sorted PR CSV files (same schema). Any row whose key column value
(defaults to 'Number') does not exist in the baseline file is emitted to a new incremental CSV, preserving
the original column order. If no new rows are found, an empty CSV (with headers when determinable) is written.
.PARAMETER BaseCsv
Path to the baseline (earlier) PR CSV.
.PARAMETER AllCsv
Path to the newer full PR CSV containing superset (or equal set) of rows.
.PARAMETER OutCsv
Path to write the incremental CSV containing only new rows.
.PARAMETER Key
Column name used as unique identifier (defaults to 'Number'). Must exist in both CSVs.
.EXAMPLE
pwsh ./diff_prs.ps1 -BaseCsv sorted_prs_prev.csv -AllCsv sorted_prs.csv -OutCsv sorted_prs_incremental.csv
.NOTES
Requires: PowerShell 7+, both CSVs with identical column schemas.
Exit code 0 on success (even if zero incremental rows). Throws on missing files.
#>
[CmdletBinding()] param(
[Parameter(Mandatory=$false)][string]$BaseCsv = "./sorted_prs_93_round1.csv",
[Parameter(Mandatory=$false)][string]$AllCsv = "./sorted_prs.csv",
[Parameter(Mandatory=$false)][string]$OutCsv = "./sorted_prs_93_incremental.csv",
[Parameter(Mandatory=$false)][string]$Key = "Number"
)
Set-StrictMode -Version Latest
$ErrorActionPreference = 'Stop'
function Write-Info($m) { Write-Host "[info] $m" -ForegroundColor Cyan }
function Write-Warn($m) { Write-Host "[warn] $m" -ForegroundColor Yellow }
if (-not (Test-Path -LiteralPath $BaseCsv)) { throw "Base CSV not found: $BaseCsv" }
if (-not (Test-Path -LiteralPath $AllCsv)) { throw "All CSV not found: $AllCsv" }
# Load CSVs
$baseRows = Import-Csv -LiteralPath $BaseCsv
$allRows = Import-Csv -LiteralPath $AllCsv
if (-not $baseRows) { Write-Warn "Base CSV has no rows." }
if (-not $allRows) { Write-Warn "All CSV has no rows." }
# Validate key presence
if ($baseRows -and -not ($baseRows[0].PSObject.Properties.Name -contains $Key)) { throw "Key column '$Key' not found in base CSV." }
if ($allRows -and -not ($allRows[0].PSObject.Properties.Name -contains $Key)) { throw "Key column '$Key' not found in all CSV." }
# Build a set of existing keys from base
$set = New-Object 'System.Collections.Generic.HashSet[string]'
foreach ($row in $baseRows) {
$val = [string]($row.$Key)
if ($null -ne $val) { [void]$set.Add($val) }
}
# Filter rows in AllCsv whose key is not in base (these are the new / incremental rows)
$incremental = @()
foreach ($row in $allRows) {
$val = [string]($row.$Key)
if (-not $set.Contains($val)) { $incremental += $row }
}
# Preserve column order from the All CSV
$columns = @()
if ($allRows.Count -gt 0) {
$columns = $allRows[0].PSObject.Properties.Name
}
try {
if ($incremental.Count -gt 0) {
if ($columns.Count -gt 0) {
$incremental | Select-Object -Property $columns | Export-Csv -LiteralPath $OutCsv -NoTypeInformation -Encoding UTF8
} else {
$incremental | Export-Csv -LiteralPath $OutCsv -NoTypeInformation -Encoding UTF8
}
} else {
# Write an empty CSV with headers if we know them (facilitates downstream tooling expecting header row)
if ($columns.Count -gt 0) {
$obj = [PSCustomObject]@{}
foreach ($c in $columns) { $obj | Add-Member -NotePropertyName $c -NotePropertyValue $null }
$obj | Select-Object -Property $columns | Export-Csv -LiteralPath $OutCsv -NoTypeInformation -Encoding UTF8
} else {
'' | Out-File -LiteralPath $OutCsv -Encoding UTF8
}
}
Write-Info ("Incremental rows: {0}" -f $incremental.Count)
Write-Info ("Output: {0}" -f (Resolve-Path -LiteralPath $OutCsv))
}
catch {
Write-Host "[error] Failed writing output CSV: $_" -ForegroundColor Red
exit 1
}

View File

@@ -0,0 +1,123 @@
<#
.SYNOPSIS
Export merged pull requests for a milestone into JSON and CSV (sorted) with optional Copilot review summarization.
.DESCRIPTION
Uses the GitHub CLI (gh) to list merged PRs for the specified milestone, captures basic metadata,
attempts to obtain a Copilot review summary (choosing the longest Copilot-authored review body),
filters labels to a predefined allow-list, and outputs:
* Raw JSON list (for traceability)
* Sorted CSV (first label alphabetical) used by downstream grouping scripts.
.PARAMETER Repo
GitHub repository in the form 'owner/name'. Default: 'microsoft/PowerToys'.
.PARAMETER Milestone
Exact milestone title (as it appears on GitHub), e.g. 'PowerToys 0.95'.
.PARAMETER OutputJson
Path for raw JSON output. Default: 'milestone_prs.json'.
.PARAMETER OutputCsv
Path for sorted CSV output. Default: 'sorted_prs.csv'.
.EXAMPLE
pwsh ./dump-prs-information.ps1 -Milestone 'PowerToys 0.95'
.EXAMPLE
pwsh ./dump-prs-information.ps1 -Repo microsoft/PowerToys -Milestone 'PowerToys 0.95' -OutputCsv m1.csv
.NOTES
Requires: gh CLI authenticated with repo read access.
This script intentionally does NOT use Set-StrictMode (per current repository guidance for release tooling).
#>
[CmdletBinding()] param(
[Parameter(Mandatory=$false)][string]$Repo = 'microsoft/PowerToys',
[Parameter(Mandatory=$true)][string]$Milestone,
[Parameter(Mandatory=$false)][string]$OutputJson = 'milestone_prs.json',
[Parameter(Mandatory=$false)][string]$OutputCsv = 'sorted_prs.csv'
)
$ErrorActionPreference = 'Stop'
function Write-Info($m){ Write-Host "[info] $m" -ForegroundColor Cyan }
function Write-Warn($m){ Write-Host "[warn] $m" -ForegroundColor Yellow }
function Write-Err($m){ Write-Host "[error] $m" -ForegroundColor Red }
if (-not (Get-Command gh -ErrorAction SilentlyContinue)) { Write-Err "GitHub CLI 'gh' not found in PATH."; exit 1 }
Write-Info "Fetching merged PRs for milestone '$Milestone' from $Repo ..."
$searchQuery = "milestone:`"$Milestone`""
$ghCommand = "gh pr list --repo $Repo --state merged --search '$searchQuery' --json number,title,labels,author,url,body --limit 200"
try {
Invoke-Expression $ghCommand | Out-File -Encoding UTF8 -FilePath $OutputJson
}
catch {
Write-Err "Failed querying PRs: $_"; exit 1
}
# === STEP 1: Query PRs from GitHub ===
if (-not (Test-Path -LiteralPath $OutputJson)) { Write-Err "JSON output not created: $OutputJson"; exit 1 }
Write-Info "Parsing JSON ..."
$prs = Get-Content $OutputJson | ConvertFrom-Json
if (-not $prs) { Write-Warn "No PRs returned for milestone '$Milestone'"; exit 0 }
$sorted = $prs | Sort-Object { $_.labels[0]?.name }
Write-Info "Fetching Copilot reviews for each PR (longest Copilot-authored body)."
$csvData = $sorted | ForEach-Object {
$prNumber = $_.number
Write-Info "Processing PR #$prNumber ..."
# Get Copilot review for this PR
$copilotOverview = ""
try {
$reviewsCommand = "gh pr view $prNumber --repo $repo --json reviews"
$reviewsJson = Invoke-Expression $reviewsCommand | ConvertFrom-Json
# Collect Copilot reviews (match various author logins). Choose the LONGEST body (more content) vs newest.
$copilotReviews = $reviewsJson.reviews | Where-Object {
($_.author.login -eq "github-copilot[bot]" -or
$_.author.login -eq "copilot" -or
$_.author.login -eq "github-copilot" -or
$_.author.login -like "*copilot*") -and
$_.body -and
$_.body.Trim() -ne ""
}
if ($copilotReviews -and $copilotReviews.Count -gt 0) {
$longest = $copilotReviews | Sort-Object { $_.body.Length } -Descending | Select-Object -First 1
$copilotOverview = $longest.body.Replace("`r", "").Replace("`n", " ") -replace '\s+', ' '
Write-Info " Copilot review selected (author=$($longest.author.login) length=$($longest.body.Length))"
} else {
Write-Warn " No Copilot reviews found for PR #$prNumber"
}
}
catch {
Write-Warn " Could not fetch reviews for PR #$prNumber"
}
# Filter labels to only include specific patterns
$filteredLabels = $_.labels | Where-Object {
($_.name -like "Product-*") -or
($_.name -like "Area-*") -or
($_.name -like "Github*") -or
($_.name -like "*Plugin") -or
($_.name -like "Issue-*")
}
$labelNames = ($filteredLabels | ForEach-Object { $_.name }) -join ", "
[PSCustomObject]@{
Id = $_.number
Title = $_.title
Labels = $labelNames
Author = $_.author.login
Url = $_.url
Body = $_.body.Replace("`r", "").Replace("`n", " ") -replace '\s+', ' ' # Make body single-line
CopilotSummary = $copilotOverview
}
}
# === STEP 3: Output CSV ===
Write-Info "Saving CSV to $OutputCsv ..."
$csvData | Export-Csv $OutputCsv -NoTypeInformation -Encoding UTF8
Write-Info "Done. Rows: $($csvData.Count). CSV: $(Resolve-Path -LiteralPath $OutputCsv)"

View File

@@ -0,0 +1,275 @@
<#
.SYNOPSIS
Export merged PR metadata between two commits (exclusive start, inclusive end) to JSON and CSV.
.DESCRIPTION
Identifies merge/squash commits reachable from EndCommit but not StartCommit, extracts PR numbers,
queries GitHub for metadata plus (optionally) Copilot review/comment summaries, filters labels, then
emits a JSON artifact and a sorted CSV (first label alphabetical) analogous to dump-prs-information.ps1.
.PARAMETER StartCommit
Exclusive starting commit (SHA, tag, or ref). Commits AFTER this one are considered.
.PARAMETER EndCommit
Inclusive ending commit (SHA, tag, or ref). Default: HEAD.
.PARAMETER Repo
GitHub repository (owner/name). Default: microsoft/PowerToys.
.PARAMETER OutputCsv
Destination CSV path. Default: sorted_prs.csv.
.PARAMETER OutputJson
Destination JSON path containing raw PR objects. Default: milestone_prs.json.
.EXAMPLE
pwsh ./dump-prs-since-commit.ps1 -StartCommit 0123abcd
.EXAMPLE
pwsh ./dump-prs-since-commit.ps1 -StartCommit 0123abcd -EndCommit 89ef7654 -OutputCsv delta.csv
.NOTES
Requires: git, gh (authenticated). No Set-StrictMode to keep parity with existing release scripts.
#>
[CmdletBinding()]
param(
[Parameter(Mandatory = $true)][string]$StartCommit, # exclusive start (commits AFTER this one)
[string]$EndCommit = "HEAD",
[string]$Repo = "microsoft/PowerToys",
[string]$OutputCsv = "sorted_prs.csv",
[string]$OutputJson = "milestone_prs.json"
)
<#
.SYNOPSIS
Dump merged PR information whose merge commits are reachable from EndCommit but not from StartCommit.
.DESCRIPTION
Uses git rev-list to compute commits in the (StartCommit, EndCommit] range, extracts PR numbers from merge commit messages,
queries GitHub (gh CLI) for details, then outputs a CSV similar to dump-prs-information.ps1.
PR merge commit messages in PowerToys generally contain patterns like:
Merge pull request #12345 from ...
.EXAMPLE
pwsh ./dump-prs-since-commit.ps1 -StartCommit 0123abcd
.EXAMPLE
pwsh ./dump-prs-since-commit.ps1 -StartCommit 0123abcd -EndCommit 89ef7654 -OutputCsv changes.csv
.NOTES
Requires: gh CLI authenticated; git available in working directory (must be inside PowerToys repo clone).
CopilotSummary behavior:
- Attempts to locate the latest GitHub Copilot authored review (preferred).
- If no review is found, lazily fetches PR comments to look for a Copilot-authored comment.
- Normalizes whitespace and strips newlines. Empty when no Copilot activity detected.
- Run with -Verbose to see whether the summary came from a 'review' or 'comment' source.
#>
function Write-Info($msg) { Write-Host $msg -ForegroundColor Cyan }
function Write-Warn($msg) { Write-Host $msg -ForegroundColor Yellow }
function Write-Err($msg) { Write-Host $msg -ForegroundColor Red }
function Write-DebugMsg($msg) { if ($PSBoundParameters.ContainsKey('Verbose') -or $VerbosePreference -eq 'Continue') { Write-Host "[VERBOSE] $msg" -ForegroundColor DarkGray } }
# Validate we are in a git repo
#if (-not (Test-Path .git)) {
# Write-Err "Current directory does not appear to be the root of a git repository."
# exit 1
#}
# Resolve commits
try {
$startSha = (git rev-parse --verify $StartCommit) 2>$null
if (-not $startSha) { throw "StartCommit '$StartCommit' not found" }
$endSha = (git rev-parse --verify $EndCommit) 2>$null
if (-not $endSha) { throw "EndCommit '$EndCommit' not found" }
}
catch {
Write-Err $_
exit 1
}
Write-Info "Collecting commits between $startSha..$endSha (excluding start, including end)."
# Get list of commits reachable from end but not from start.
# IMPORTANT: In PowerShell, the .. operator creates a numeric/char range. If $startSha and $endSha look like hex strings,
# `$startSha..$endSha` will expand unexpectedly (often to empty/undesired) instead of passing the literal "sha1..sha2".
# Therefore we build the range explicitly as a single string argument.
$rangeArg = "$startSha..$endSha"
$commitList = git rev-list $rangeArg
# Normalize list (filter out empty strings)
$normalizedCommits = $commitList | Where-Object { $_ -and $_.Trim() -ne '' }
$commitCount = ($normalizedCommits | Measure-Object).Count
Write-DebugMsg ("Raw commitList length (including blanks): {0}" -f (($commitList | Measure-Object).Count))
Write-DebugMsg ("Normalized commit count: {0}" -f $commitCount)
if ($commitCount -eq 0) {
Write-Warn "No commits found in specified range ($startSha..$endSha)."; exit 0
}
Write-DebugMsg ("First 5 commits: {0}" -f (($normalizedCommits | Select-Object -First 5) -join ', '))
<#
Extract PR numbers from commits.
Patterns handled:
1. Merge commits: 'Merge pull request #12345 from ...'
2. Squash commits: 'Some feature change (#12345)' (GitHub default squash format)
We collect both. If a commit matches both (unlikely), it's deduped later.
#>
# Extract PR numbers from merge or squash commits
$mergeCommits = @()
foreach ($c in $normalizedCommits) {
$subject = git show -s --format=%s $c
$matched = $false
# Pattern 1: Traditional merge commit
if ($subject -match 'Merge pull request #([0-9]+) ') {
$prNumber = [int]$matches[1]
$mergeCommits += [PSCustomObject]@{ Sha = $c; Pr = $prNumber; Subject = $subject; Pattern = 'merge' }
Write-DebugMsg "Matched merge PR #$prNumber in commit $c"
$matched = $true
}
# Pattern 2: Squash merge subject line with ' (#12345)' at end (allow possible whitespace before paren)
if ($subject -match '\(#([0-9]+)\)$') {
$prNumber2 = [int]$matches[1]
# Avoid duplicate object if pattern 1 already captured same number for same commit
if (-not ($mergeCommits | Where-Object { $_.Sha -eq $c -and $_.Pr -eq $prNumber2 })) {
$mergeCommits += [PSCustomObject]@{ Sha = $c; Pr = $prNumber2; Subject = $subject; Pattern = 'squash' }
Write-DebugMsg "Matched squash PR #$prNumber2 in commit $c"
}
$matched = $true
}
if (-not $matched) {
Write-DebugMsg "No PR pattern in commit $c : $subject"
}
}
if (-not $mergeCommits -or $mergeCommits.Count -eq 0) {
Write-Warn "No merge commits with PR numbers found in range."; exit 0
}
# Deduplicate PR numbers (in case of revert or merges across branches)
$prNumbers = $mergeCommits | Select-Object -ExpandProperty Pr -Unique | Sort-Object
Write-Info ("Found {0} unique PRs: {1}" -f $prNumbers.Count, ($prNumbers -join ', '))
Write-DebugMsg ("Total merge commits examined: {0}" -f $mergeCommits.Count)
# Query GitHub for each PR
$prDetails = @()
function Get-CopilotSummaryFromPrJson {
param(
[Parameter(Mandatory=$true)]$PrJson,
[switch]$VerboseMode
)
# Returns a hashtable with Summary and Source keys.
$result = @{ Summary = ""; Source = "" }
if (-not $PrJson) { return $result }
$candidateAuthors = @(
'github-copilot[bot]', 'github-copilot', 'copilot'
)
# 1. Reviews (preferred) pick the LONGEST valid Copilot body, not the most recent
$reviews = $PrJson.reviews
if ($reviews) {
$copilotReviews = $reviews | Where-Object {
($candidateAuthors -contains $_.author.login -or $_.author.login -like '*copilot*') -and $_.body -and $_.body.Trim() -ne ''
}
if ($copilotReviews) {
$longest = $copilotReviews | Sort-Object { $_.body.Length } -Descending | Select-Object -First 1
if ($longest) {
$body = $longest.body
$norm = ($body -replace "`r", '') -replace "`n", ' '
$norm = $norm -replace '\s+', ' '
$result.Summary = $norm
$result.Source = 'review'
if ($VerboseMode) { Write-DebugMsg "Selected Copilot review length=$($body.Length) (longest)." }
return $result
}
}
}
# 2. Comments fallback (some repos surface Copilot summaries as PR comments rather than review objects)
if ($null -eq $PrJson.comments) {
try {
# Lazy fetch comments only if needed
$commentsJson = gh pr view $PrJson.number --repo $Repo --json comments 2>$null | ConvertFrom-Json
if ($commentsJson -and $commentsJson.comments) {
$PrJson | Add-Member -NotePropertyName comments -NotePropertyValue $commentsJson.comments -Force
}
} catch {
if ($VerboseMode) { Write-DebugMsg "Failed to fetch comments for PR #$($PrJson.number): $_" }
}
}
if ($PrJson.comments) {
$copilotComments = $PrJson.comments | Where-Object {
($candidateAuthors -contains $_.author.login -or $_.author.login -like '*copilot*') -and $_.body -and $_.body.Trim() -ne ''
}
if ($copilotComments) {
$longestC = $copilotComments | Sort-Object { $_.body.Length } -Descending | Select-Object -First 1
if ($longestC) {
$body = $longestC.body
$norm = ($body -replace "`r", '') -replace "`n", ' '
$norm = $norm -replace '\s+', ' '
$result.Summary = $norm
$result.Source = 'comment'
if ($VerboseMode) { Write-DebugMsg "Selected Copilot comment length=$($body.Length) (longest)." }
return $result
}
}
}
return $result
}
foreach ($pr in $prNumbers) {
Write-Info "Fetching PR #$pr ..."
try {
# Include comments only if Verbose asked, otherwise we lazily pull if reviews missing
$fields = 'number,title,labels,author,url,body,reviews'
if ($PSBoundParameters.ContainsKey('Verbose')) { $fields += ',comments' }
$json = gh pr view $pr --repo $Repo --json $fields 2>$null | ConvertFrom-Json
if ($null -eq $json) { throw "Empty response" }
$copilot = Get-CopilotSummaryFromPrJson -PrJson $json -VerboseMode:($PSBoundParameters.ContainsKey('Verbose'))
if ($copilot.Summary -and $copilot.Source -and $PSBoundParameters.ContainsKey('Verbose')) {
Write-DebugMsg "Copilot summary source=$($copilot.Source) chars=$($copilot.Summary.Length)"
} elseif (-not $copilot.Summary) {
Write-DebugMsg "No Copilot summary found for PR #$pr"
}
# Filter labels
$filteredLabels = $json.labels | Where-Object {
($_.name -like "Product-*") -or
($_.name -like "Area-*") -or
($_.name -like "Github*") -or
($_.name -like "*Plugin") -or
($_.name -like "Issue-*")
}
$labelNames = ($filteredLabels | ForEach-Object { $_.name }) -join ", "
$bodyValue = if ($json.body) { ($json.body -replace "`r", '') -replace "`n", ' ' } else { '' }
$bodyValue = $bodyValue -replace '\s+', ' '
$prDetails += [PSCustomObject]@{
Id = $json.number
Title = $json.title
Labels = $labelNames
Author = $json.author.login
Url = $json.url
Body = $bodyValue
CopilotSummary = $copilot.Summary
}
}
catch {
$err = $_
Write-Warn ("Failed to fetch PR #{0}: {1}" -f $pr, $err)
}
}
if (-not $prDetails) { Write-Warn "No PR details fetched."; exit 0 }
# Sort by Labels like original script (first label alphabetical)
$sorted = $prDetails | Sort-Object { ($_.Labels -split ',')[0] }
# Output JSON raw (optional)
$sorted | ConvertTo-Json -Depth 6 | Out-File -Encoding UTF8 $OutputJson
Write-Info "Saving CSV to $OutputCsv ..."
$sorted | Export-Csv $OutputCsv -NoTypeInformation
Write-Host "✅ Done. Generated $($prDetails.Count) PR rows." -ForegroundColor Green

View File

@@ -0,0 +1,85 @@
<#
.SYNOPSIS
Group PR rows by their Labels column and emit per-label CSV files.
.DESCRIPTION
Reads a milestone PR CSV (usually produced by dump-prs-information / dump-prs-since-commit scripts),
splits rows by label list, normalizes/sorts individual labels, and writes one CSV per unique label combination.
Each output preserves the original row ordering within that subset and column order from the source.
.PARAMETER CsvPath
Input CSV containing PR rows with a 'Labels' column (comma-separated list).
.PARAMETER OutDir
Output directory to place grouped CSVs (created if missing). Default: 'grouped_csv'.
.NOTES
Label combinations are joined using ' | ' when multiple labels present. Filenames are sanitized (invalid characters,
whitespace collapsed) and truncated to <= 120 characters.
#>
param(
[string]$CsvPath = "sorted_prs.csv",
[string]$OutDir = "grouped_csv"
)
$ErrorActionPreference = 'Stop'
function Write-Info($msg) { Write-Host "[info] $msg" -ForegroundColor Cyan }
function Write-Warn($msg) { Write-Host "[warn] $msg" -ForegroundColor Yellow }
if (-not (Test-Path -LiteralPath $CsvPath)) { throw "CSV not found: $CsvPath" }
Write-Info "Reading CSV: $CsvPath"
$rows = Import-Csv -LiteralPath $CsvPath
Write-Info ("Loaded {0} rows" -f $rows.Count)
function ConvertTo-SafeFileName {
[CmdletBinding()]
param(
[Parameter(Mandatory=$true)][string]$Name
)
if ([string]::IsNullOrWhiteSpace($Name)) { return 'Unnamed' }
$s = $Name -replace '[<>:"/\\|?*]', '-' # invalid path chars
$s = $s -replace '\s+', '-' # spaces to dashes
$s = $s -replace '-{2,}', '-' # collapse dashes
$s = $s.Trim('-')
if ($s.Length -gt 120) { $s = $s.Substring(0,120).Trim('-') }
if ([string]::IsNullOrWhiteSpace($s)) { return 'Unnamed' }
return $s
}
# Build groups keyed by normalized, sorted label combinations. Preserve original CSV row order.
$groups = @{}
foreach ($row in $rows) {
$labelsRaw = $row.Labels
if ([string]::IsNullOrWhiteSpace($labelsRaw)) {
$labelParts = @('Unlabeled')
} else {
$parts = $labelsRaw -split ',' | ForEach-Object { $_.Trim() } | Where-Object { $_ }
if (-not $parts -or $parts.Count -eq 0) { $labelParts = @('Unlabeled') }
else { $labelParts = $parts | Sort-Object }
}
$key = ($labelParts -join ' | ')
if (-not $groups.ContainsKey($key)) { $groups[$key] = New-Object System.Collections.ArrayList }
[void]$groups[$key].Add($row)
}
if (-not (Test-Path -LiteralPath $OutDir)) {
Write-Info "Creating output directory: $OutDir"
New-Item -ItemType Directory -Path $OutDir | Out-Null
}
Write-Info ("Generating {0} grouped CSV file(s) into: {1}" -f $groups.Count, $OutDir)
foreach ($key in $groups.Keys) {
$labelParts = if ($key -eq 'Unlabeled') { @('Unlabeled') } else { $key -split '\s\|\s' }
$safeName = ($labelParts | ForEach-Object { ConvertTo-SafeFileName -Name $_ }) -join '-'
$filePath = Join-Path $OutDir ("$safeName.csv")
# Keep same columns and order
$groups[$key] | Export-Csv -LiteralPath $filePath -NoTypeInformation -Encoding UTF8
}
Write-Info "Done. Sample output files:"
Get-ChildItem -LiteralPath $OutDir | Select-Object -First 10 Name | Format-Table -HideTableHeaders

View File

@@ -1,4 +0,0 @@
@echo off
setlocal
set SCRIPT_DIR=%~dp0
pwsh -NoLogo -NoProfile -ExecutionPolicy Bypass -File "%SCRIPT_DIR%Delete-Worktree.ps1" %*

View File

@@ -1,130 +0,0 @@
<#!
.SYNOPSIS
Remove a git worktree (and optionally its local branch and orphan fork remote).
.DESCRIPTION
Locates a worktree by branch/path pattern (supports wildcards). Ensures the primary repository
root is never removed. Optionally discards local changes with -Force. Deletes associated branch
unless -KeepBranch. If the branch tracked a non-origin remote with no remaining tracking
branches, that remote is removed unless -KeepRemote.
.PARAMETER Pattern
Branch name or path fragment (wildcards * ? allowed). If multiple matches found they are listed
and no deletion occurs.
.PARAMETER Force
Discard uncommitted changes and attempt aggressive cleanup on failure.
.PARAMETER KeepBranch
Preserve the local branch (only remove the worktree directory entry).
.PARAMETER KeepRemote
Preserve any orphan fork remote even if no branches still track it.
.EXAMPLE
./Delete-Worktree.ps1 -Pattern feature/login
.EXAMPLE
./Delete-Worktree.ps1 -Pattern fork-user-featureX -Force
.EXAMPLE
./Delete-Worktree.ps1 -Pattern hotfix -KeepBranch
.NOTES
Manual recovery:
git worktree list --porcelain
git worktree prune
Remove-Item -LiteralPath <path> -Recurse -Force
git branch -D <branch>
git remote remove <remote>
git worktree prune
#>
param(
[string] $Pattern,
[switch] $Force,
[switch] $KeepBranch,
[switch] $KeepRemote,
[switch] $Help
)
. "$PSScriptRoot/WorktreeLib.ps1"
if ($Help -or -not $Pattern) { Show-FileEmbeddedHelp -ScriptPath $MyInvocation.MyCommand.Path; return }
try {
$repoRoot = Get-RepoRoot
$entries = Get-WorktreeEntries
if (-not $entries -or $entries.Count -eq 0) { throw 'No worktrees found.' }
$hasWildcard = $Pattern -match '[\*\?]'
$matchPattern = if ($hasWildcard) { $Pattern } else { "*${Pattern}*" }
$found = $entries | Where-Object { $_.Branch -and ( $_.Branch -like $matchPattern -or $_.Path -like $matchPattern ) }
if (-not $found -or $found.Count -eq 0) { throw "No worktree matches pattern '$Pattern'" }
if ($found.Count -gt 1) {
Warn 'Pattern matches multiple worktrees:'
$found | ForEach-Object { Info (" {0} {1}" -f $_.Branch, $_.Path) }
return
}
$target = $found | Select-Object -First 1
$branch = $target.Branch
$folder = $target.Path
if (-not $branch) { throw 'Resolved worktree has no branch (detached); refusing removal.' }
try { $folder = (Resolve-Path -LiteralPath $folder -ErrorAction Stop).ProviderPath } catch {}
$primary = (Resolve-Path -LiteralPath $repoRoot).ProviderPath
if ([IO.Path]::GetFullPath($folder).TrimEnd('\\/') -ieq [IO.Path]::GetFullPath($primary).TrimEnd('\\/')) { throw 'Refusing to remove the primary worktree (repository root).' }
$status = git -C $folder status --porcelain 2>$null
if ($LASTEXITCODE -ne 0) { throw "Unable to get git status for $folder" }
if (-not $Force -and $status) { throw 'Worktree has uncommitted changes. Use -Force to discard.' }
if ($Force -and $status) {
Warn '[Force] Discarding local changes'
git -C $folder reset --hard HEAD | Out-Null
git -C $folder clean -fdx | Out-Null
}
if ($Force) { git worktree remove --force $folder } else { git worktree remove $folder }
if ($LASTEXITCODE -ne 0) {
$exit1 = $LASTEXITCODE
$errMsg = "git worktree remove failed (exit $exit1)"
if ($Force) {
Warn 'Primary removal failed; performing aggressive fallback (Force implies brute).'
try { git -C $folder submodule deinit -f --all 2>$null | Out-Null } catch {}
try { git -C $folder clean -dfx 2>$null | Out-Null } catch {}
try { Get-ChildItem -LiteralPath $folder -Recurse -Force -ErrorAction SilentlyContinue | ForEach-Object { try { $_.IsReadOnly = $false } catch {} } } catch {}
if (Test-Path $folder) { try { Remove-Item -LiteralPath $folder -Recurse -Force -ErrorAction Stop } catch { Err "Manual directory removal failed: $($_.Exception.Message)" } }
git worktree prune 2>$null | Out-Null
if (Test-Path $folder) { throw "$errMsg and aggressive cleanup did not fully remove directory: $folder" } else { Info "Aggressive cleanup removed directory $folder." }
} else {
throw "$errMsg. Rerun with -Force to attempt aggressive cleanup."
}
}
# Determine upstream before potentially deleting branch
$upRemote = Get-BranchUpstreamRemote -Branch $branch
$looksForkName = $branch -like 'fork-*'
if (-not $KeepBranch) {
git branch -D $branch 2>$null | Out-Null
if (-not $KeepRemote -and $upRemote -and $upRemote -ne 'origin') {
$otherTracking = git for-each-ref --format='%(refname:short)|%(upstream:short)' refs/heads 2>$null |
Where-Object { $_ -and ($_ -notmatch "^$branch\|") } |
ForEach-Object { $parts = $_.Split('|',2); if ($parts[1] -match '^(?<r>[^/]+)/'){ $parts[0],$Matches.r } } |
Where-Object { $_[1] -eq $upRemote }
if (-not $otherTracking) {
Warn "Removing orphan remote '$upRemote' (no more tracking branches)"
git remote remove $upRemote 2>$null | Out-Null
if ($LASTEXITCODE -ne 0) { Warn "Failed to remove remote '$upRemote' (you may remove manually)." }
} else { Info "Remote '$upRemote' retained (other branches still track it)." }
} elseif ($looksForkName -and -not $KeepRemote -and -not $upRemote) {
Warn 'Branch looks like a fork branch (name pattern), but has no upstream remote; nothing to clean.'
}
}
Info "Removed worktree ($branch) at $folder."; if (-not $KeepBranch) { Info 'Branch deleted.' }
Show-WorktreeExecutionSummary -CurrentBranch $branch
} catch {
Err "Error: $($_.Exception.Message)"
Warn 'Manual cleanup guidelines:'
Info ' git worktree list --porcelain'
Info ' git worktree prune'
Info ' # If still present:'
Info ' Remove-Item -LiteralPath <path> -Recurse -Force'
Info ' git branch -D <branch> (if you also want to drop local branch)'
Info ' git remote remove <remote> (if orphan fork remote remains)'
Info ' git worktree prune'
exit 1
}

View File

@@ -1,4 +0,0 @@
@echo off
setlocal
set SCRIPT_DIR=%~dp0
pwsh -NoLogo -NoProfile -ExecutionPolicy Bypass -File "%SCRIPT_DIR%New-WorktreeFromBranch.ps1" %*

View File

@@ -1,78 +0,0 @@
<#!
.SYNOPSIS
Create (or reuse) a worktree for an existing local or remote (origin) branch.
.DESCRIPTION
Normalizes origin/<name> to <name>. If the branch does not exist locally (and -NoFetch is not
provided) it will fetch and create a tracking branch from origin. Reuses any existing worktree
bound to the branch; otherwise creates a new one adjacent to the repository root.
.PARAMETER Branch
Branch name (local or origin/<name> form) to materialize as a worktree.
.PARAMETER VSCodeProfile
VS Code profile to open (Default).
.PARAMETER NoFetch
Skip fetch if branch missing locally; script will error instead of creating it.
.EXAMPLE
./New-WorktreeFromBranch.ps1 -Branch feature/login
.EXAMPLE
./New-WorktreeFromBranch.ps1 -Branch origin/bugfix/nullref
.EXAMPLE
./New-WorktreeFromBranch.ps1 -Branch release/v1 -NoFetch
.NOTES
Manual recovery:
git fetch origin && git checkout <branch>
git worktree add ../RepoName-XX <branch>
code ../RepoName-XX --profile Default
#>
param(
[string] $Branch,
[Alias('Profile')][string] $VSCodeProfile = 'Default',
[switch] $NoFetch,
[switch] $Help
)
. "$PSScriptRoot/WorktreeLib.ps1"
if ($Help -or -not $Branch) { Show-FileEmbeddedHelp -ScriptPath $MyInvocation.MyCommand.Path; return }
# Normalize origin/<name> to <name>
if ($Branch -match '^(origin|upstream|main|master)/.+') {
if ($Branch -match '^(origin|upstream)/(.+)$') { $Branch = $Matches[2] }
}
try {
git show-ref --verify --quiet "refs/heads/$Branch"
if ($LASTEXITCODE -ne 0) {
if (-not $NoFetch) {
Warn "Local branch '$Branch' not found; attempting remote fetch..."
git fetch --all --prune 2>$null | Out-Null
$remoteRef = "origin/$Branch"
git show-ref --verify --quiet "refs/remotes/$remoteRef"
if ($LASTEXITCODE -eq 0) {
git branch --track $Branch $remoteRef 2>$null | Out-Null
if ($LASTEXITCODE -ne 0) { throw "Failed to create tracking branch '$Branch' from $remoteRef" }
Info "Created local tracking branch '$Branch' from $remoteRef."
} else { throw "Branch '$Branch' not found locally or on origin. Use git fetch or specify a valid branch." }
} else { throw "Branch '$Branch' does not exist locally (remote fetch disabled with -NoFetch)." }
}
New-WorktreeForExistingBranch -Branch $Branch -VSCodeProfile $VSCodeProfile
$after = Get-WorktreeEntries | Where-Object { $_.Branch -eq $Branch }
$path = ($after | Select-Object -First 1).Path
Show-WorktreeExecutionSummary -CurrentBranch $Branch -WorktreePath $path
} catch {
Err "Error: $($_.Exception.Message)"
Warn 'Manual steps:'
Info ' git fetch origin'
Info " git checkout $Branch (or: git branch --track $Branch origin/$Branch)"
Info ' git worktree add ../<Repo>-XX <branch>'
Info ' code ../<Repo>-XX'
exit 1
}

View File

@@ -1,4 +0,0 @@
@echo off
setlocal
set SCRIPT_DIR=%~dp0
pwsh -NoLogo -NoProfile -ExecutionPolicy Bypass -File "%SCRIPT_DIR%New-WorktreeFromFork.ps1" %*

View File

@@ -1,127 +0,0 @@
<#!
.SYNOPSIS
Create (or reuse) a worktree from a branch in a personal fork: <ForkUser>:<ForkBranch>.
.DESCRIPTION
Adds a transient uniquely named fork remote (fork-xxxxx) unless -RemoteName specified.
Fetches only the target branch (fallback full fetch once if needed), creates a local tracking
branch (fork-<user>-<sanitized-branch> or custom alias), and delegates worktree creation/reuse
to shared helpers in WorktreeLib.
.PARAMETER Spec
Fork spec in the form <ForkUser>:<ForkBranch>.
.PARAMETER ForkRepo
Repository name in the fork (default: PowerToys).
.PARAMETER RemoteName
Desired remote name; if left as 'fork' a unique suffix will be generated.
.PARAMETER BranchAlias
Optional local branch name override; defaults to fork-<user>-<sanitized-branch>.
.PARAMETER VSCodeProfile
VS Code profile to pass through to worktree opening (Default profile by default).
.EXAMPLE
./New-WorktreeFromFork.ps1 -Spec alice:feature/new-ui
.EXAMPLE
./New-WorktreeFromFork.ps1 -Spec bob:bugfix/crash -BranchAlias fork-bob-crash
.NOTES
Manual equivalent if this script fails:
git remote add fork-temp https://github.com/<user>/<repo>.git
git fetch fork-temp
git branch --track fork-<user>-<branch> fork-temp/<branch>
git worktree add ../Repo-XX fork-<user>-<branch>
code ../Repo-XX
#>
param(
[string] $Spec,
[string] $ForkRepo = 'PowerToys',
[string] $RemoteName = 'fork',
[string] $BranchAlias,
[Alias('Profile')][string] $VSCodeProfile = 'Default',
[switch] $Help
)
. "$PSScriptRoot/WorktreeLib.ps1"
if ($Help -or -not $Spec) { Show-FileEmbeddedHelp -ScriptPath $MyInvocation.MyCommand.Path; return }
$repoRoot = git rev-parse --show-toplevel 2>$null
if (-not $repoRoot) { throw 'Not inside a git repository.' }
# Parse spec
if ($Spec -notmatch '^[^:]+:.+$') { throw "Spec must be <ForkUser>:<ForkBranch>, got '$Spec'" }
$ForkUser,$ForkBranch = $Spec.Split(':',2)
$forkUrl = "https://github.com/$ForkUser/$ForkRepo.git"
# Auto-suffix remote name if user left default 'fork'
$allRemotes = @(git remote 2>$null)
if ($RemoteName -eq 'fork') {
$chars = 'abcdefghijklmnopqrstuvwxyz0123456789'
do {
$suffix = -join ((1..5) | ForEach-Object { $chars[(Get-Random -Max $chars.Length)] })
$candidate = "fork-$suffix"
} while ($allRemotes -contains $candidate)
$RemoteName = $candidate
Info "Assigned unique remote name: $RemoteName"
}
$existing = $allRemotes | Where-Object { $_ -eq $RemoteName }
if (-not $existing) {
Info "Adding remote $RemoteName -> $forkUrl"
git remote add $RemoteName $forkUrl | Out-Null
} else {
$currentUrl = git remote get-url $RemoteName 2>$null
if ($currentUrl -ne $forkUrl) { Warn "Remote $RemoteName points to $currentUrl (expected $forkUrl). Using existing." }
}
## Note: Verbose fetch & stale lock auto-clean removed for simplicity.
try {
Info "Fetching branch '$ForkBranch' from $RemoteName..."
& git fetch $RemoteName $ForkBranch 1>$null 2>$null
$fetchExit = $LASTEXITCODE
if ($fetchExit -ne 0) {
# Retry full fetch silently once (covers servers not supporting branch-only fetch syntax)
& git fetch $RemoteName 1>$null 2>$null
$fetchExit = $LASTEXITCODE
}
if ($fetchExit -ne 0) { throw "Fetch failed for remote $RemoteName (branch $ForkBranch)." }
$remoteRef = "refs/remotes/$RemoteName/$ForkBranch"
git show-ref --verify --quiet $remoteRef
if ($LASTEXITCODE -ne 0) { throw "Remote branch not found: $RemoteName/$ForkBranch" }
$sanitizedBranch = ($ForkBranch -replace '[\\/:*?"<>|]','-')
if ($BranchAlias) { $localBranch = $BranchAlias } else { $localBranch = "fork-$ForkUser-$sanitizedBranch" }
git show-ref --verify --quiet "refs/heads/$localBranch"
if ($LASTEXITCODE -ne 0) {
Info "Creating local tracking branch $localBranch from $RemoteName/$ForkBranch"
git branch --track $localBranch "$RemoteName/$ForkBranch" 2>$null | Out-Null
if ($LASTEXITCODE -ne 0) { throw "Failed to create local tracking branch $localBranch" }
} else { Info "Local branch $localBranch already exists." }
New-WorktreeForExistingBranch -Branch $localBranch -VSCodeProfile $VSCodeProfile
# Ensure upstream so future 'git push' works
Set-BranchUpstream -LocalBranch $localBranch -RemoteName $RemoteName -RemoteBranchPath $ForkBranch
$after = Get-WorktreeEntries | Where-Object { $_.Branch -eq $localBranch }
$path = ($after | Select-Object -First 1).Path
Show-WorktreeExecutionSummary -CurrentBranch $localBranch -WorktreePath $path
Warn "Remote $RemoteName ready (URL: $forkUrl)"
$hasUp = git rev-parse --abbrev-ref --symbolic-full-name "$localBranch@{upstream}" 2>$null
if ($hasUp) { Info "Push with: git push (upstream: $hasUp)" } else { Warn 'Upstream not set; run: git push -u <remote> <local>:<remoteBranch>' }
} catch {
Err "Error: $($_.Exception.Message)"
Warn 'Manual steps:'
Info " git remote add temp-fork $forkUrl"
Info " git fetch temp-fork"
Info " git branch --track fork-<user>-<branch> temp-fork/$ForkBranch"
Info ' git worktree add ../<Repo>-XX fork-<user>-<branch>'
Info ' code ../<Repo>-XX'
exit 1
}

View File

@@ -1,4 +0,0 @@
@echo off
setlocal
set SCRIPT_DIR=%~dp0
pwsh -NoLogo -NoProfile -ExecutionPolicy Bypass -File "%SCRIPT_DIR%New-WorktreeFromIssue.ps1" %*

View File

@@ -1,78 +0,0 @@
<#!
.SYNOPSIS
Create (or reuse) a worktree for a new issue branch derived from a base ref.
.DESCRIPTION
Composes a branch name as issue/<number> or issue/<number>-<slug> (slug from optional -Title).
If the branch does not already exist, it is created from -Base (default origin/main). Then a
worktree is created or reused.
.PARAMETER Number
Issue number used to construct the branch name.
.PARAMETER Title
Optional descriptive title; slug into the branch name.
.PARAMETER Base
Base ref to branch from (default origin/main).
.PARAMETER VSCodeProfile
VS Code profile to open (Default).
.EXAMPLE
./New-WorktreeFromIssue.ps1 -Number 1234 -Title "Crash on launch"
.EXAMPLE
./New-WorktreeFromIssue.ps1 -Number 42 -Base origin/develop
.NOTES
Manual recovery:
git fetch origin
git checkout -b issue/<num>-<slug> <base>
git worktree add ../Repo-XX issue/<num>-<slug>
code ../Repo-XX
#>
param(
[int] $Number,
[string] $Title,
[string] $Base = 'origin/main',
[Alias('Profile')][string] $VSCodeProfile = 'Default',
[switch] $Help
)
. "$PSScriptRoot/WorktreeLib.ps1"
$scriptPath = $MyInvocation.MyCommand.Path
if ($Help -or -not $Number) { Show-FileEmbeddedHelp -ScriptPath $scriptPath; return }
# Compose branch name
if ($Title) {
$slug = ($Title -replace '[^\w\- ]','').ToLower() -replace ' +','-'
$branch = "issue/$Number-$slug"
} else {
$branch = "issue/$Number"
}
try {
# Create branch if missing
git show-ref --verify --quiet "refs/heads/$branch"
if ($LASTEXITCODE -ne 0) {
Info "Creating branch $branch from $Base"
git branch $branch $Base 2>$null | Out-Null
if ($LASTEXITCODE -ne 0) { throw "Failed to create branch $branch from $Base" }
} else {
Info "Branch $branch already exists locally."
}
New-WorktreeForExistingBranch -Branch $branch -VSCodeProfile $VSCodeProfile
$after = Get-WorktreeEntries | Where-Object { $_.Branch -eq $branch }
$path = ($after | Select-Object -First 1).Path
Show-WorktreeExecutionSummary -CurrentBranch $branch -WorktreePath $path
} catch {
Err "Error: $($_.Exception.Message)"
Warn 'Manual steps:'
Info " git fetch origin"
Info " git checkout -b $branch $Base (if branch missing)"
Info " git worktree add ../<Repo>-XX $branch"
Info ' code ../<Repo>-XX'
exit 1
}

View File

@@ -1,94 +0,0 @@
# PowerToys Worktree Helper Scripts
This folder contains helper scripts to create and manage parallel Git worktree for developing multiple changes (including Copilot suggestions) concurrently without cloning the full repository each time.
## Why worktree?
Git worktree let you have several checkedout branches sharing a single `.git` object store. Benefits:
- Fast context switching: no re-clone, no duplicate large binary/object downloads.
- Lower disk usage versus multiple full clones.
- Keeps each change isolated in its own folder so you can run builds/tests independently.
- Enables working in parallel with Copilot generated branches (e.g., feature + quick fix + perf experiment) while the main clone stays clean.
Recommended: keep active parallel worktree(s) to **≤ 3** per developer to reduce cognitive load and avoid excessive incremental build invalidations.
## Scripts Overview
| Script | Purpose |
|--------|---------|
| `New-WorktreeFromFork.ps1/.cmd` | Create a worktree from a branch in a personal fork (`<User>:<branch>` spec). Adds a temporary unique remote (e.g. `fork-abc12`). |
| `New-WorktreeFromBranch.ps1/.cmd` | Create/reuse a worktree for an existing local or remote (origin) branch. Can normalize `origin/branch` to `branch`. |
| `New-WorktreeFromIssue.ps1/.cmd` | Start a new issue branch from a base (default `origin/main`) using naming `issue/<number>-<slug>`. |
| `Delete-Worktree.ps1/.cmd` | Remove a worktree and optionally its local branch / orphan fork remote. |
| `WorktreeLib.ps1` | Shared helpers: unique folder naming, worktree listing, upstream setup, summary output, logging helpers. |
## Typical Flows
### 1. Create from a fork branch
```
./New-WorktreeFromFork.ps1 -Spec alice:feature/perf-tweak
```
Creates remote `fork-xxxxx`, fetches just that branch, creates local branch `fork-alice-feature-perf-tweak`, makes a new worktree beside the repo root.
### 2. Create from an existing or remote branch
```
./New-WorktreeFromBranch.ps1 -Branch origin/feature/new-ui
```
Fetches if needed and creates a tracking branch if missing, then creates/reuses the worktree.
### 3. Start a new issue branch
```
./New-WorktreeFromIssue.ps1 -Number 1234 -Title "Crash on launch"
```
Creates branch `issue/1234-crash-on-launch` off `origin/main` (or `-Base`), then worktree.
### 4. Delete a worktree when done
```
./Delete-Worktree.ps1 -Pattern feature/perf-tweak
```
If only one match, removes the worktree directory. Add `-Force` to discard local changes. Use `-KeepBranch` if you still need the branch, `-KeepRemote` to retain a fork remote.
## After Creating a Worktree
Inside the new worktree directory:
1. Run the minimal build bootstrap in VSCode terminal:
```
tools\build\build-essentials.cmd
```
2. Build only the module(s) you need (e.g., open solution filter or run targeted project build) instead of a full PowerToys build. This speeds iteration and reduces noise.
3. Make changes, commit, push.
4. Finally delete the worktree when done.
## Naming & Locations
- Worktree is created as sibling folders of the repo root (e.g., `PowerToys` + `PowerToys-ab12`), using a hash/short pattern to avoid collisions.
- Fork-based branches get local names `fork-<user>-<sanitized-branch>`.
- Issue branches: `issue/<number>` or `issue/<number>-<slug>`.
## Scenarios Covered / Limitations
Covered scenarios:
1. From a fork branch (personal fork on GitHub).
2. From an existing local or origin remote branch.
3. Creating a new branch for an issue.
Not covered (manual steps needed):
- Creating from a non-origin upstream other than a fork (add remote manually then use branch script).
- Batch creation of multiple worktree in one command.
- Automatic rebase / sync of many worktree at once (do that manually or script separately).
## Best Practices
- Keep ≤ 3 active parallel worktree(s) (e.g., main dev, a long-lived feature, a quick fix / experiment) plus the root clone.
- Delete stale worktree early; each adds file watchers & potential incremental build churn.
- Avoid editing the same file across multiple worktree simultaneously to reduce merge friction.
- Run `git fetch --all --prune` periodically in the primary repo, not in every worktree.
## Troubleshooting
| Symptom | Hint |
|---------|------|
| Fetch failed for fork remote | Branch name typo or fork private without auth. Try manual `git fetch <remote> <branch>`.
| Cannot lock ref *.lock | Stale lock: run `git worktree prune` or manually delete the `.lock` file then retry.
| Worktree already exists error | Use `git worktree list` to locate existing path; open that folder instead of creating a duplicate.
| Local branch missing for remote | Use `git branch --track <name> origin/<name>` then re-run the branch script.
## Security & Safety Notes
- Scripts avoid force-deleting unless you pass `-Force` (Delete script).
- No network credentials are stored; they rely on your existing Git credential helper.
- Always review a new fork remote URL before pushing.
---
Maintainers: Keep the scripts lean; avoid adding heavy dependencies or global state. Update this doc if parameters or flows change.

View File

@@ -1,151 +0,0 @@
# WorktreeLib.ps1 - shared helpers
function Info { param([string]$Message) Write-Host $Message -ForegroundColor Cyan }
function Warn { param([string]$Message) Write-Host $Message -ForegroundColor Yellow }
function Err { param([string]$Message) Write-Host $Message -ForegroundColor Red }
function Get-RepoRoot {
$root = git rev-parse --show-toplevel 2>$null
if (-not $root) { throw 'Not inside a git repository.' }
return $root
}
function Get-WorktreeBasePath {
param([string]$RepoRoot)
# Always use parent of repo root (folder that contains the main repo directory)
$parent = Split-Path -Parent $RepoRoot
if (-not (Test-Path $parent)) { throw "Parent path for repo root not found: $parent" }
return (Resolve-Path $parent).ProviderPath
}
function Get-ShortHashFromString {
param([Parameter(Mandatory)][string]$Text)
$md5 = [System.Security.Cryptography.MD5]::Create()
try {
$bytes = [Text.Encoding]::UTF8.GetBytes($Text)
$digest = $md5.ComputeHash($bytes)
return -join ($digest[0..1] | ForEach-Object { $_.ToString('x2') })
} finally { $md5.Dispose() }
}
function Initialize-SubmodulesIfAny {
param([string]$RepoRoot,[string]$WorktreePath)
$hasGitmodules = Test-Path (Join-Path $RepoRoot '.gitmodules')
if ($hasGitmodules) {
git -C $WorktreePath submodule sync --recursive | Out-Null
git -C $WorktreePath submodule update --init --recursive | Out-Null
return $true
}
return $false
}
function New-WorktreeForExistingBranch {
param(
[Parameter(Mandatory)][string] $Branch,
[Parameter(Mandatory)][string] $VSCodeProfile
)
$repoRoot = Get-RepoRoot
git show-ref --verify --quiet "refs/heads/$Branch"; if ($LASTEXITCODE -ne 0) { throw "Branch '$Branch' does not exist locally." }
# Detect existing worktree for this branch
$entries = Get-WorktreeEntries
$match = $entries | Where-Object { $_.Branch -eq $Branch } | Select-Object -First 1
if ($match) {
Info "Reusing existing worktree for '$Branch': $($match.Path)"
code --new-window "$($match.Path)" --profile "$VSCodeProfile" | Out-Null
return
}
$safeBranch = ($Branch -replace '[\\/:*?"<>|]','-')
$hash = Get-ShortHashFromString -Text $safeBranch
$folderName = "$(Split-Path -Leaf $repoRoot)-$hash"
$base = Get-WorktreeBasePath -RepoRoot $repoRoot
$folder = Join-Path $base $folderName
git worktree add $folder $Branch
$inited = Initialize-SubmodulesIfAny -RepoRoot $repoRoot -WorktreePath $folder
code --new-window "$folder" --profile "$VSCodeProfile" | Out-Null
Info "Created worktree for branch '$Branch' at $folder."; if ($inited) { Info 'Submodules initialized.' }
}
function Get-WorktreeEntries {
# Returns objects with Path and Branch (branch without refs/heads/ prefix)
$lines = git worktree list --porcelain 2>$null
if (-not $lines) { return @() }
$entries = @(); $current=@{}
foreach($l in $lines){
if ($l -eq '') { if ($current.path -and $current.branch){ $entries += ,([pscustomobject]@{ Path=$current.path; Branch=($current.branch -replace '^refs/heads/','') }) }; $current=@{}; continue }
if ($l -like 'worktree *'){ $current.path = ($l -split ' ',2)[1] }
elseif ($l -like 'branch *'){ $current.branch = ($l -split ' ',2)[1].Trim() }
}
if ($current.path -and $current.branch){ $entries += ,([pscustomobject]@{ Path=$current.path; Branch=($current.branch -replace '^refs/heads/','') }) }
return ($entries | Sort-Object Path,Branch -Unique)
}
function Get-BranchUpstreamRemote {
param([Parameter(Mandatory)][string]$Branch)
# Returns remote name if branch has an upstream, else $null
$ref = git rev-parse --abbrev-ref --symbolic-full-name "$Branch@{upstream}" 2>$null
if ($LASTEXITCODE -ne 0 -or -not $ref) { return $null }
if ($ref -match '^(?<remote>[^/]+)/.+$') { return $Matches.remote }
return $null
}
function Show-IssueFarmCommonFooter {
Info '--- Common Manual Steps ---'
Info 'List worktree: git worktree list --porcelain'
Info 'List branches: git branch -vv'
Info 'List remotes: git remote -v'
Info 'Prune worktree: git worktree prune'
Info 'Remove worktree dir: Remove-Item -Recurse -Force <path>'
Info 'Reset branch: git reset --hard HEAD'
}
function Show-WorktreeExecutionSummary {
param(
[string]$CurrentBranch,
[string]$WorktreePath
)
Info '--- Summary ---'
if ($CurrentBranch) { Info "Branch: $CurrentBranch" }
if ($WorktreePath) { Info "Worktree path: $WorktreePath" }
$entries = Get-WorktreeEntries
if ($entries.Count -gt 0) {
Info 'Existing worktrees:'
$entries | ForEach-Object { Info (" {0} -> {1}" -f $_.Branch,$_.Path) }
}
Info 'Remotes:'
git remote -v 2>$null | Sort-Object | Get-Unique | ForEach-Object { Info " $_" }
}
function Show-FileEmbeddedHelp {
param([string]$ScriptPath)
if (-not (Test-Path $ScriptPath)) { throw "Cannot load help; file missing: $ScriptPath" }
$content = Get-Content -LiteralPath $ScriptPath -ErrorAction Stop
$inBlock=$false
foreach($line in $content){
if ($line -match '^<#!') { $inBlock=$true; continue }
if ($line -match '#>$') { break }
if ($inBlock) { Write-Host $line }
}
Show-IssueFarmCommonFooter
}
function Set-BranchUpstream {
param(
[Parameter(Mandatory)][string]$LocalBranch,
[Parameter(Mandatory)][string]$RemoteName,
[Parameter(Mandatory)][string]$RemoteBranchPath
)
$current = git rev-parse --abbrev-ref --symbolic-full-name "$LocalBranch@{upstream}" 2>$null
if (-not $current) {
Info "Setting upstream: $LocalBranch -> $RemoteName/$RemoteBranchPath"
git branch --set-upstream-to "$RemoteName/$RemoteBranchPath" $LocalBranch 2>$null | Out-Null
if ($LASTEXITCODE -ne 0) { Warn "Failed to set upstream automatically. Run: git branch --set-upstream-to $RemoteName/$RemoteBranchPath $LocalBranch" }
return
}
if ($current -ne "$RemoteName/$RemoteBranchPath") {
Warn "Upstream mismatch ($current != $RemoteName/$RemoteBranchPath); updating..."
git branch --set-upstream-to "$RemoteName/$RemoteBranchPath" $LocalBranch 2>$null | Out-Null
if ($LASTEXITCODE -ne 0) { Warn "Could not update upstream; manual fix: git branch --set-upstream-to $RemoteName/$RemoteBranchPath $LocalBranch" } else { Info 'Upstream corrected.' }
} else { Info "Upstream already: $current" }
}