Compare commits

..

2 Commits

Author SHA1 Message Date
Gordon Lam (SH)
336f7d8c47 Fix spelling 2026-01-08 18:26:22 +08:00
Gordon Lam (SH)
045c33930e Docs: consolidate instructions and fix prompt frontmatter 2026-01-08 18:14:13 +08:00
134 changed files with 1707 additions and 4316 deletions

View File

@@ -209,14 +209,12 @@ changecursor
CHILDACTIVATE
CHILDWINDOW
CHOOSEFONT
CIBUILD
cidl
CIELCh
cim
CImage
cla
CLASSDC
classmethod
CLASSNOTAVAILABLE
CLEARTYPE
clickable
@@ -1604,7 +1602,7 @@ sharpfuzz
SHCNE
SHCNF
SHCONTF
shcore
Shcore
shellapi
SHELLDETAILS
SHELLDLL
@@ -1703,7 +1701,6 @@ srw
srwlock
sse
ssf
sszzz
STACKFRAME
stackoverflow
STARTF
@@ -1714,7 +1711,6 @@ STARTUPINFOW
startupscreen
STATFLAG
STATICEDGE
staticmethod
STATSTG
stdafx
STDAPI
@@ -1881,7 +1877,6 @@ uild
uitests
UITo
ULONGLONG
Ultrawide
ums
UMax
UMin

View File

@@ -6,45 +6,13 @@ description: 'Generate an 80-character git commit title for the local diff'
# Generate Commit Title
## Purpose
Provide a single-line, ready-to-paste git commit title (<= 80 characters) that reflects the most important local changes since `HEAD`.
**Goal:** Provide a ready-to-paste git commit title (<= 80 characters) that captures the most important local changes since `HEAD`.
## Input to collect
- Run exactly one command to view the local diff:
```@terminal
git diff HEAD
```
## How to decide the title
1. From the diff, find the dominant area (e.g., `src/modules/*`, `doc/devdocs/**`) and the change type (bug fix, docs update, config tweak).
2. Draft an imperative, plain-ASCII title that:
- Mentions the primary component when obvious (e.g., `FancyZones:` or `Docs:`)
- Stays within 80 characters and has no trailing punctuation
## Final output
- Reply with only the commit title on a single line—no extra text.
## PR title convention (when asked)
Use Conventional Commits style:
`<type>(<scope>): <summary>`
**Allowed types**
- feat, fix, docs, refactor, perf, test, build, ci, chore
**Scope rules**
- Use a short, PowerToys-focused scope (one word preferred). Common scopes:
- Core: `runner`, `settings-ui`, `common`, `docs`, `build`, `ci`, `installer`, `gpo`, `dsc`
- Modules: `fancyzones`, `powerrename`, `awake`, `colorpicker`, `imageresizer`, `keyboardmanager`, `mouseutils`, `peek`, `hosts`, `file-locksmith`, `screen-ruler`, `text-extractor`, `cropandlock`, `paste`, `powerlauncher`
- If unclear, pick the closest module or subsystem; omit only if unavoidable
**Summary rules**
- Imperative, present tense (“add”, “update”, “remove”, “fix”)
- Keep it <= 72 characters when possible; be specific, avoid “misc changes”
**Examples**
- `feat(fancyzones): add canvas template duplication`
- `fix(mouseutils): guard crosshair toggle when dpi info missing`
- `docs(runner): document tray icon states`
- `build(installer): align wix v5 suffix flag`
- `ci(ci): cache pipeline artifacts for x64`
**Workflow:**
1. Run a single command to view the local diff since the last commit:
```@terminal
git diff HEAD
```
2. From that diff, identify the dominant area (reference key paths like `src/modules/*`, `doc/devdocs/**`, etc.), the type of change (bug fix, docs update, config tweak), and any notable impact.
3. Draft a concise, imperative commit title summarizing the dominant change. Keep it plain ASCII, <= 80 characters, and avoid trailing punctuation. Mention the primary component when obvious (for example `FancyZones:` or `Docs:`).
4. Respond with only the final commit title on a single line so it can be pasted directly into `git commit`.

View File

@@ -22,4 +22,3 @@ description: 'Generate a PowerToys-ready pull request description from the local
5. Confirm validation: list tests executed with results or state why tests were skipped in line with repo guidance.
6. Load `.github/pull_request_template.md`, mirror its section order, and populate it with the gathered facts. Include only relevant checklist entries, marking them `[x]/[ ]` and noting any intentional omissions as "N/A".
7. Present the filled template inside a fenced ```markdown code block with no extra commentary so it is ready to paste into a PR, clearly flagging any placeholders that still need user input.
8. Prepend the PR title above the filled template, applying the Conventional Commit type/scope rules from `.github/prompts/create-commit-title.prompt.md`; pick the dominant component from the diff and keep the title concise and imperative.

View File

@@ -10,8 +10,8 @@ description: 'Resolve Code scanning / check-spelling comments on the active PR'
**Guardrails:**
- Update only discussion threads authored by `github-actions` or `github-actions[bot]` that mention `Code scanning results / check-spelling`.
- Prefer improving the wording in the originally flagged file when it clarifies intent without changing meaning; if the wording is already clear/standard for the context, handle it via `.github/actions/spell-check/expect.txt` and reuse existing entries.
- Limit edits to the flagged text and `.github/actions/spell-check/expect.txt`; leave all other files and topics untouched.
- Resolve findings solely by editing `.github/actions/spell-check/expect.txt`; reuse existing entries.
- Leave all other files and topics untouched.
**Prerequisites:**
- Install GitHub CLI if it is not present: `winget install GitHub.cli`.
@@ -20,6 +20,5 @@ description: 'Resolve Code scanning / check-spelling comments on the active PR'
**Workflow:**
1. Determine the active pull request with a single `gh pr view --json number` call (default to the current branch).
2. Fetch all PR discussion data once via `gh pr view --json comments,reviews` and filter to check-spelling comments authored by `github-actions` or `github-actions[bot]` that are not minimized; when several remain, process only the most recent comment body.
3. For each flagged token, first consider tightening or rephrasing the original text to avoid the false positive while keeping the meaning intact; if the existing wording is already normal and professional for the context, proceed to allowlisting instead of changing it.
4. When allowlisting, review `.github/actions/spell-check/expect.txt` for an equivalent term (for example an existing lowercase variant); when found, reuse that normalized term rather than adding a new entry, even if the flagged token differs only by casing. Only add a new entry after confirming no equivalent already exists.
5. Add any remaining missing token to `.github/actions/spell-check/expect.txt`, keeping surrounding formatting intact.
3. For each flagged token, review `.github/actions/spell-check/expect.txt` for an equivalent term (for example an existing lowercase variant); when found, reuse that normalized term rather than adding a new entry, even if the flagged token differs only by casing. Only add a new entry after confirming no equivalent already exists.
4. Add any remaining missing token to `.github/actions/spell-check/expect.txt`, keeping surrounding formatting intact.

View File

@@ -125,10 +125,6 @@
"WinUI3Apps\\Powertoys.Peek.UI.exe",
"WinUI3Apps\\Powertoys.Peek.dll",
"WinUI3Apps\\PowerToys.QuickAccess.dll",
"WinUI3Apps\\PowerToys.QuickAccess.exe",
"WinUI3Apps\\PowerToys.Settings.UI.Controls.dll",
"WinUI3Apps\\PowerToys.EnvironmentVariablesModuleInterface.dll",
"WinUI3Apps\\PowerToys.EnvironmentVariablesUILib.dll",
"WinUI3Apps\\PowerToys.EnvironmentVariables.dll",

View File

@@ -10,7 +10,7 @@ parameters:
default: {}
steps:
- task: EsrpCodeSigning@6
- task: EsrpCodeSigning@5
displayName: 🔏 ${{ parameters.displayName }}
inputs:
ConnectedServiceName: ${{ parameters.signingIdentity.serviceName }}

17
.vscode/settings.json vendored
View File

@@ -1,17 +0,0 @@
{
"github.copilot.chat.reviewSelection.instructions": [
{
"file": ".github/prompts/review-pr.prompt.md"
}
],
"github.copilot.chat.commitMessageGeneration.instructions": [
{
"file": ".github/prompts/create-commit-title.prompt.md"
}
],
"github.copilot.chat.pullRequestDescriptionGeneration.instructions": [
{
"file": ".github/prompts/create-pr-summary.prompt.md"
}
]
}

View File

@@ -13,8 +13,6 @@
<PackageVersion Include="Microsoft.Bot.AdaptiveExpressions.Core" Version="4.23.0" />
<PackageVersion Include="Appium.WebDriver" Version="4.4.5" />
<PackageVersion Include="CoenM.ImageSharp.ImageHash" Version="1.3.6" />
<!-- Pin the SixLabors.ImageSharp version (a transitive dependency of CoenM.ImageSharp.ImageHash) to restore functionality and apply patches. -->
<PackageVersion Include="SixLabors.ImageSharp" Version="2.1.12" />
<PackageVersion Include="CommunityToolkit.Common" Version="8.4.0" />
<PackageVersion Include="CommunityToolkit.Mvvm" Version="8.4.0" />
<PackageVersion Include="CommunityToolkit.WinUI.Animations" Version="8.2.250402" />
@@ -26,7 +24,7 @@
<PackageVersion Include="CommunityToolkit.WinUI.Converters" Version="8.2.250402" />
<PackageVersion Include="CommunityToolkit.WinUI.Extensions" Version="8.2.250402" />
<PackageVersion Include="CommunityToolkit.WinUI.UI.Controls.DataGrid" Version="7.1.2" />
<PackageVersion Include="CommunityToolkit.Labs.WinUI.Controls.MarkdownTextBlock" Version="0.1.260107-build.2454" />
<PackageVersion Include="CommunityToolkit.Labs.WinUI.Controls.MarkdownTextBlock" Version="0.1.251002-build.2316" />
<PackageVersion Include="ControlzEx" Version="6.0.0" />
<PackageVersion Include="HelixToolkit" Version="2.24.0" />
<PackageVersion Include="HelixToolkit.Core.Wpf" Version="2.24.0" />

View File

@@ -48,7 +48,7 @@ But to get started quickly, choose one of the installation methods below:
<details open>
<summary><strong>Download .exe from GitHub</strong></summary>
<br/>
Go to the <a href="https://aka.ms/installPowerToys">PowerToys GitHub releases</a>, click Assets to reveal the downloads, and choose the installer that matches your architecture and install scope. For most devices, that's the x64 per-user installer.
Go to the [PowerToys GitHub releases][github-release-link], click Assets to reveal the downloads, and choose the installer that matches your architecture and install scope. For most devices, that's the x64 per-user installer.
<!-- items that need to be updated release to release -->
[github-next-release-work]: https://github.com/microsoft/PowerToys/issues?q=is%3Aissue+milestone%3A%22PowerToys+0.97%22
@@ -83,7 +83,7 @@ You can easily install PowerToys from the Microsoft Store:
<details>
<summary><strong>WinGet</strong></summary>
<br/>
Download PowerToys from <a href="https://github.com/microsoft/winget-cli#installing-the-client">WinGet</a>. Updating PowerToys via winget will respect the current PowerToys installation scope. To install PowerToys, run the following command from the command line / PowerShell:
Download PowerToys from [WinGet][winget-link]. Updating PowerToys via winget will respect the current PowerToys installation scope. To install PowerToys, run the following command from the command line / PowerShell:
*User scope installer [default]*
```powershell
@@ -99,7 +99,7 @@ winget install --scope machine Microsoft.PowerToys -s winget
<details>
<summary><strong>Other methods</strong></summary>
<br/>
There are <a href="https://learn.microsoft.com/windows/powertoys/install#community-driven-install-tools">community driven install methods</a> such as Chocolatey and Scoop. If these are your preferred install solutions, you can find the install instructions there.
There are [community driven install methods](./doc/unofficialInstallMethods.md) such as Chocolatey and Scoop. If these are your preferred install solutions, you can find the install instructions there.
</details>
## ✨ What's new

View File

@@ -1549,7 +1549,7 @@ UINT __stdcall TerminateProcessesCA(MSIHANDLE hInstall)
}
processes.resize(bytes / sizeof(processes[0]));
std::array<std::wstring_view, 44> processesToTerminate = {
std::array<std::wstring_view, 42> processesToTerminate = {
L"PowerToys.PowerLauncher.exe",
L"PowerToys.Settings.exe",
L"PowerToys.AdvancedPaste.exe",
@@ -1584,14 +1584,12 @@ UINT __stdcall TerminateProcessesCA(MSIHANDLE hInstall)
L"PowerToys.MouseWithoutBordersService.exe",
L"PowerToys.CropAndLock.exe",
L"PowerToys.EnvironmentVariables.exe",
L"PowerToys.QuickAccess.exe",
L"PowerToys.WorkspacesSnapshotTool.exe",
L"PowerToys.WorkspacesLauncher.exe",
L"PowerToys.WorkspacesLauncherUI.exe",
L"PowerToys.WorkspacesEditor.exe",
L"PowerToys.WorkspacesWindowArranger.exe",
L"Microsoft.CmdPal.UI.exe",
L"Microsoft.CmdPal.Ext.PowerToys.exe",
L"PowerToys.ZoomIt.exe",
L"PowerToys.exe",
};

View File

@@ -61,16 +61,6 @@
</RegistryKey>
<File Source="$(var.RepoDir)\Notice.md" Id="Notice.md" />
</Component>
<Directory Id="SvgsFolder" Name="svgs">
<Component Id="svgs_icons" Guid="A9B7C5D3-E1F2-4A6B-8C9D-0E1F2A3B4C5D" Bitness="always64">
<RegistryKey Root="$(var.RegistryScope)" Key="Software\Classes\powertoys\components">
<RegistryValue Type="string" Name="svgs_icons" Value="" KeyPath="yes" />
</RegistryKey>
<File Id="icon.ico" Source="$(var.BinDir)svgs\icon.ico" />
<File Id="PowerToysWhite.ico" Source="$(var.BinDir)svgs\PowerToysWhite.ico" />
<File Id="PowerToysDark.ico" Source="$(var.BinDir)svgs\PowerToysDark.ico" />
</Component>
</Directory>
</DirectoryRef>
<?if $(var.PerUser) = "true" ?>
@@ -122,7 +112,6 @@
<RemoveFolder Id="RemoveBaseApplicationsAssetsFolder" Directory="BaseApplicationsAssetsFolder" On="uninstall" />
<RemoveFolder Id="RemoveWinUI3AppsInstallFolder" Directory="WinUI3AppsInstallFolder" On="uninstall" />
<RemoveFolder Id="RemoveWinUI3AppsAssetsFolder" Directory="WinUI3AppsAssetsFolder" On="uninstall" />
<RemoveFolder Id="RemoveSvgsFolder" Directory="SvgsFolder" On="uninstall" />
<RemoveFolder Id="RemoveINSTALLFOLDER" Directory="INSTALLFOLDER" On="uninstall" />
</Component>
<ComponentRef Id="powertoys_exe" />
@@ -131,7 +120,6 @@
<ComponentRef Id="powertoys_toast_clsid" />
<ComponentRef Id="License_rtf" />
<ComponentRef Id="Notice_md" />
<ComponentRef Id="svgs_icons" />
<ComponentRef Id="DesktopShortcut" />
<?if $(var.PerUser) = "true" ?>
<ComponentRef Id="powertoys_env_path_user" />

View File

@@ -66,10 +66,5 @@ namespace PowerToys.GPOWrapperProjection
{
return (GpoRuleConfigured)PowerToys.GPOWrapper.GPOWrapper.GetConfiguredWorkspacesEnabledValue();
}
public static GpoRuleConfigured GetConfiguredLightSwitchEnabledValue()
{
return (GpoRuleConfigured)PowerToys.GPOWrapper.GPOWrapper.GetConfiguredLightSwitchEnabledValue();
}
}
}

View File

@@ -10,7 +10,7 @@ namespace ManagedCommon
{
public static bool IsWindows10()
{
return Environment.OSVersion.Version.Major >= 10 && Environment.OSVersion.Version.Build < 22000;
return Environment.OSVersion.Version.Major >= 10 && Environment.OSVersion.Version.Minor < 22000;
}
public static bool IsWindows11()

View File

@@ -466,27 +466,39 @@
TextChanged="EditVariableDialogValueTxtBox_TextChanged"
TextWrapping="Wrap" />
<MenuFlyoutSeparator Visibility="{Binding ShowAsList, Converter={StaticResource BoolToVisibilityConverter}}" />
<ItemsControl
<ListView
x:Name="EditVariableValuesList"
Margin="0,-8,0,12"
HorizontalAlignment="Stretch"
AllowDrop="True"
CanDragItems="True"
CanReorderItems="True"
DragItemsCompleted="EditVariableValuesList_DragItemsCompleted"
ItemsSource="{Binding ValuesList, Mode=TwoWay}"
Visibility="{Binding ShowAsList, Converter={StaticResource BoolToVisibilityConverter}}">
<ItemsControl.ItemTemplate>
<ListView.ItemTemplate>
<DataTemplate>
<Grid>
<Grid.ColumnDefinitions>
<ColumnDefinition Width="32" />
<ColumnDefinition Width="*" />
<ColumnDefinition Width="40" />
</Grid.ColumnDefinitions>
<FontIcon
Grid.Column="0"
Margin="0,0,8,0"
FontSize="16"
Foreground="{ThemeResource TextFillColorSecondaryBrush}"
Glyph="&#xE759;" />
<TextBox
Grid.Column="1"
Background="Transparent"
BorderBrush="Transparent"
LostFocus="EditVariableValuesListTextBox_LostFocus"
Text="{Binding Text}" />
<Button
x:Uid="More_Options_Button"
Grid.Column="1"
Grid.Column="2"
VerticalAlignment="Center"
Content="&#xE712;"
FontFamily="{ThemeResource SymbolThemeFontFamily}"
@@ -523,8 +535,8 @@
</Button>
</Grid>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
</ListView.ItemTemplate>
</ListView>
</StackPanel>
</ScrollViewer>
</ContentDialog>

View File

@@ -16,6 +16,8 @@ namespace EnvironmentVariablesUILib
{
public sealed partial class EnvironmentVariablesMainPage : Page
{
private const string ValueListSeparator = ";";
private sealed class RelayCommandParameter
{
public RelayCommandParameter(Variable variable, VariablesSet set)
@@ -440,7 +442,7 @@ namespace EnvironmentVariablesUILib
variable.ValuesList.Move(index, index - 1);
}
var newValues = string.Join(";", variable.ValuesList?.Select(x => x.Text).ToArray());
var newValues = string.Join(ValueListSeparator, variable.ValuesList?.Select(x => x.Text).ToArray());
EditVariableDialogValueTxtBox.Text = newValues;
}
@@ -461,7 +463,7 @@ namespace EnvironmentVariablesUILib
variable.ValuesList.Move(index, index + 1);
}
var newValues = string.Join(";", variable.ValuesList?.Select(x => x.Text).ToArray());
var newValues = string.Join(ValueListSeparator, variable.ValuesList?.Select(x => x.Text).ToArray());
EditVariableDialogValueTxtBox.Text = newValues;
}
@@ -476,7 +478,7 @@ namespace EnvironmentVariablesUILib
var variable = EditVariableDialog.DataContext as Variable;
variable.ValuesList.Remove(listItem);
var newValues = string.Join(";", variable.ValuesList?.Select(x => x.Text).ToArray());
var newValues = string.Join(ValueListSeparator, variable.ValuesList?.Select(x => x.Text).ToArray());
EditVariableDialogValueTxtBox.Text = newValues;
}
@@ -492,7 +494,7 @@ namespace EnvironmentVariablesUILib
var index = variable.ValuesList.IndexOf(listItem);
variable.ValuesList.Insert(index, new Variable.ValuesListItem { Text = string.Empty });
var newValues = string.Join(";", variable.ValuesList?.Select(x => x.Text).ToArray());
var newValues = string.Join(ValueListSeparator, variable.ValuesList?.Select(x => x.Text).ToArray());
EditVariableDialogValueTxtBox.TextChanged -= EditVariableDialogValueTxtBox_TextChanged;
EditVariableDialogValueTxtBox.Text = newValues;
EditVariableDialogValueTxtBox.TextChanged += EditVariableDialogValueTxtBox_TextChanged;
@@ -510,7 +512,7 @@ namespace EnvironmentVariablesUILib
var index = variable.ValuesList.IndexOf(listItem);
variable.ValuesList.Insert(index + 1, new Variable.ValuesListItem { Text = string.Empty });
var newValues = string.Join(";", variable.ValuesList?.Select(x => x.Text).ToArray());
var newValues = string.Join(ValueListSeparator, variable.ValuesList?.Select(x => x.Text).ToArray());
EditVariableDialogValueTxtBox.TextChanged -= EditVariableDialogValueTxtBox_TextChanged;
EditVariableDialogValueTxtBox.Text = newValues;
EditVariableDialogValueTxtBox.TextChanged += EditVariableDialogValueTxtBox_TextChanged;
@@ -532,7 +534,7 @@ namespace EnvironmentVariablesUILib
listItem.Text = (sender as TextBox)?.Text;
var variable = EditVariableDialog.DataContext as Variable;
var newValues = string.Join(";", variable.ValuesList?.Select(x => x.Text).ToArray());
var newValues = string.Join(ValueListSeparator, variable.ValuesList?.Select(x => x.Text).ToArray());
EditVariableDialogValueTxtBox.TextChanged -= EditVariableDialogValueTxtBox_TextChanged;
EditVariableDialogValueTxtBox.Text = newValues;
EditVariableDialogValueTxtBox.TextChanged += EditVariableDialogValueTxtBox_TextChanged;
@@ -548,5 +550,16 @@ namespace EnvironmentVariablesUILib
CancelAddVariable();
ConfirmAddVariableBtn.IsEnabled = false;
}
private void EditVariableValuesList_DragItemsCompleted(ListViewBase sender, DragItemsCompletedEventArgs args)
{
if (EditVariableDialog.DataContext is Variable variable && variable.ValuesList != null)
{
var newValues = string.Join(ValueListSeparator, variable.ValuesList.Select(x => x.Text));
EditVariableDialogValueTxtBox.TextChanged -= EditVariableDialogValueTxtBox_TextChanged;
EditVariableDialogValueTxtBox.Text = newValues;
EditVariableDialogValueTxtBox.TextChanged += EditVariableDialogValueTxtBox_TextChanged;
}
}
}
}

View File

@@ -1,48 +1,5 @@
#include "resource.h"
#include <windows.h>
#include "../../../common/version/version.h"
#define APSTUDIO_READONLY_SYMBOLS
#include "winres.h"
#undef APSTUDIO_READONLY_SYMBOLS
/////////////////////////////////////////////////////////////////////////////
//
// Version
//
VS_VERSION_INFO VERSIONINFO
FILEVERSION FILE_VERSION
PRODUCTVERSION PRODUCT_VERSION
FILEFLAGSMASK 0x3fL
#ifdef _DEBUG
FILEFLAGS 0x1L
#else
FILEFLAGS 0x0L
#endif
FILEOS 0x40004L
FILETYPE 0x1L
FILESUBTYPE 0x0L
BEGIN
BLOCK "StringFileInfo"
BEGIN
BLOCK "040904b0"
BEGIN
VALUE "CompanyName", COMPANY_NAME
VALUE "FileDescription", "File Locksmith CLI"
VALUE "FileVersion", FILE_VERSION_STRING
VALUE "InternalName", "FileLocksmithCLI.exe"
VALUE "LegalCopyright", COPYRIGHT_NOTE
VALUE "OriginalFilename", "FileLocksmithCLI.exe"
VALUE "ProductName", PRODUCT_NAME
VALUE "ProductVersion", PRODUCT_VERSION_STRING
END
END
BLOCK "VarFileInfo"
BEGIN
VALUE "Translation", 0x409, 1200
END
END
STRINGTABLE
BEGIN

View File

@@ -1,66 +0,0 @@
# Validating/Testing Cursor Wrap.
If a user determines that CursorWrap isn't working on their PC there are some steps you can take to determine why CursorWrap functionality might not be working as expected.
Note that for a single monitor cursor wrap should always work since all monitor edges are not touching/overlapping with other monitors - the cursor should always wrap to the opposite edge of the same monitor.
Multi-monitor is supported through building a polygon shape for the outer edges of all monitors, inner monitor edges are ignored, movement of the cursor from one monitor to an adjacent monitor is handled by Windows - CursorWrap doesn't get involved in monitor-to-monitor movement, only outer-edges.
We have seen a couple of computer setups that have multi-monitors where CursorWrap doesn't work as expected, this appears to be due to a monitor not being 'snapped' to the edge of an adjacent monitor - If you use Display Settings in Windows you can move monitors around, these appear to 'snap' to an edge of an existing monitor.
What to do if Cursor Wrapping isn't working as expected ?
1. in the CursorWrapTests folder there's a PowerShell script called `Capture-MonitorLayout.ps1` - this will generate a .json file in the form `"$($env:USERNAME)_monitor_layout.json` - the .json file contains an array of monitors, their position, size, dpi, and scaling.
2. Use `CursorWrapTests/monitor_layout_tests.py` to validate the monitor layout/wrapping behavior (uses the json file from point 1 above).
3. Use `analyze_test_results.py` to analyze the monitor layout test output and provide information about why wrapping might not be working
To run `monitor_layout_tests.py` you will need Python installed on your PC.
Run `python monitor_layout_tests.py --layout-file <path to json file>` you can also add an optional `--verbose` to view verbose output.
monitor_layout_tests.py will produce an output file called `test_report.json` - the contents of the file will look like this (this is from a single monitor test).
```json
{
"summary": {
"total_configs": 1,
"passed": 1,
"failed": 0,
"total_issues": 0,
"pass_rate": "100.00%"
},
"failures": [],
"recommendations": [
"All tests passed - edge detection logic is working correctly!"
]
}
```
If there are failures (the failures array is not empty) you can run the second python application called `analyze_test_results.py`
Supported options include:
```text
-h, --help show this help message and exit
--report REPORT Path to test report JSON file
--detailed Show detailed failure listing
--copilot Generate GitHub Copilot-friendly fix prompt
```
Running the analyze_test_results.py script against our single monitor test results produces the following:
```text
python .\analyze_test_results.py --detailed
================================================================================
CURSORWRAP TEST RESULTS ANALYSIS
================================================================================
Total Configurations Tested: 1
Passed: 1 (100.00%)
Failed: 0
Total Issues: 0
✓ ALL TESTS PASSED! Edge detection logic is working correctly.
✓ No failures to analyze!
```

View File

@@ -1,266 +0,0 @@
#!/usr/bin/env pwsh
<#
.SYNOPSIS
Captures the current monitor layout configuration for CursorWrap testing.
.DESCRIPTION
Queries Windows for all connected monitors and saves their configuration
(position, size, DPI, primary status) to a JSON file that can be used
for testing the CursorWrap module.
.PARAMETER OutputPath
Path where the JSON file will be saved. Default: monitor_layout.json
.EXAMPLE
.\Capture-MonitorLayout.ps1
.EXAMPLE
.\Capture-MonitorLayout.ps1 -OutputPath "my_setup.json"
#>
param(
[Parameter(Mandatory=$false)]
[string]$OutputPath = "$($env:USERNAME)_monitor_layout.json"
)
# Add Windows Forms for screen enumeration
Add-Type -AssemblyName System.Windows.Forms
function Get-MonitorDPI {
param([System.Windows.Forms.Screen]$Screen)
# Try to get DPI using P/Invoke with multiple methods
Add-Type @"
using System;
using System.Runtime.InteropServices;
public class DisplayConfig {
[DllImport("user32.dll")]
public static extern IntPtr MonitorFromPoint(POINT pt, uint dwFlags);
[DllImport("shcore.dll")]
public static extern int GetDpiForMonitor(IntPtr hmonitor, int dpiType, out uint dpiX, out uint dpiY);
[DllImport("user32.dll")]
public static extern bool GetMonitorInfo(IntPtr hMonitor, ref MONITORINFOEX lpmi);
[DllImport("user32.dll")]
public static extern IntPtr MonitorFromWindow(IntPtr hwnd, uint dwFlags);
[DllImport("gdi32.dll")]
public static extern int GetDeviceCaps(IntPtr hdc, int nIndex);
[DllImport("user32.dll")]
public static extern IntPtr GetDC(IntPtr hWnd);
[DllImport("user32.dll")]
public static extern int ReleaseDC(IntPtr hWnd, IntPtr hDC);
[StructLayout(LayoutKind.Sequential)]
public struct POINT {
public int X;
public int Y;
}
[StructLayout(LayoutKind.Sequential, CharSet = CharSet.Auto)]
public struct MONITORINFOEX {
public int cbSize;
public RECT rcMonitor;
public RECT rcWork;
public uint dwFlags;
[MarshalAs(UnmanagedType.ByValTStr, SizeConst = 32)]
public string szDevice;
}
[StructLayout(LayoutKind.Sequential)]
public struct RECT {
public int Left;
public int Top;
public int Right;
public int Bottom;
}
public const uint MONITOR_DEFAULTTOPRIMARY = 1;
public const int MDT_EFFECTIVE_DPI = 0;
public const int MDT_ANGULAR_DPI = 1;
public const int MDT_RAW_DPI = 2;
public const int LOGPIXELSX = 88;
public const int LOGPIXELSY = 90;
}
"@ -ErrorAction SilentlyContinue
try {
$point = New-Object DisplayConfig+POINT
$point.X = $Screen.Bounds.Left + ($Screen.Bounds.Width / 2)
$point.Y = $Screen.Bounds.Top + ($Screen.Bounds.Height / 2)
$hMonitor = [DisplayConfig]::MonitorFromPoint($point, 1)
# Method 1: Try GetDpiForMonitor (Windows 8.1+)
[uint]$dpiX = 0
[uint]$dpiY = 0
$result = [DisplayConfig]::GetDpiForMonitor($hMonitor, 0, [ref]$dpiX, [ref]$dpiY)
if ($result -eq 0 -and $dpiX -gt 0) {
Write-Verbose "DPI detected via GetDpiForMonitor: $dpiX"
return $dpiX
}
# Method 2: Try RAW DPI
$result = [DisplayConfig]::GetDpiForMonitor($hMonitor, 2, [ref]$dpiX, [ref]$dpiY)
if ($result -eq 0 -and $dpiX -gt 0) {
Write-Verbose "DPI detected via RAW DPI: $dpiX"
return $dpiX
}
# Method 3: Try getting device context DPI (legacy method)
$hdc = [DisplayConfig]::GetDC([IntPtr]::Zero)
if ($hdc -ne [IntPtr]::Zero) {
$dpiValue = [DisplayConfig]::GetDeviceCaps($hdc, 88) # LOGPIXELSX
[DisplayConfig]::ReleaseDC([IntPtr]::Zero, $hdc)
if ($dpiValue -gt 0) {
Write-Verbose "DPI detected via GetDeviceCaps: $dpiValue"
return $dpiValue
}
}
}
catch {
Write-Verbose "DPI detection error: $($_.Exception.Message)"
}
Write-Warning "Could not detect DPI for $($Screen.DeviceName), using default 96 DPI"
return 96 # Standard 96 DPI (100% scaling)
}
function Capture-MonitorLayout {
Write-Host "Capturing monitor layout..." -ForegroundColor Cyan
Write-Host "=" * 80
$screens = [System.Windows.Forms.Screen]::AllScreens
$monitors = @()
foreach ($screen in $screens) {
$isPrimary = $screen.Primary
$bounds = $screen.Bounds
$dpi = Get-MonitorDPI -Screen $screen
$monitor = [ordered]@{
left = $bounds.Left
top = $bounds.Top
right = $bounds.Right
bottom = $bounds.Bottom
width = $bounds.Width
height = $bounds.Height
dpi = $dpi
scaling_percent = [math]::Round(($dpi / 96.0) * 100, 0)
primary = $isPrimary
device_name = $screen.DeviceName
}
$monitors += $monitor
# Display info
$primaryTag = if ($isPrimary) { " [PRIMARY]" } else { "" }
$scaling = [math]::Round(($dpi / 96.0) * 100, 0)
Write-Host "`nMonitor $($monitors.Count)$primaryTag" -ForegroundColor Green
Write-Host " Device: $($screen.DeviceName)"
Write-Host " Position: ($($bounds.Left), $($bounds.Top))"
Write-Host " Size: $($bounds.Width)x$($bounds.Height)"
Write-Host " DPI: $dpi ($scaling% scaling)"
Write-Host " Bounds: [$($bounds.Left), $($bounds.Top), $($bounds.Right), $($bounds.Bottom)]"
}
# Create output object
$output = [ordered]@{
captured_at = (Get-Date -Format "yyyy-MM-ddTHH:mm:sszzz")
computer_name = $env:COMPUTERNAME
user_name = $env:USERNAME
monitor_count = $monitors.Count
monitors = $monitors
}
# Save to JSON
$json = $output | ConvertTo-Json -Depth 10
Set-Content -Path $OutputPath -Value $json -Encoding UTF8
Write-Host "`n" + ("=" * 80)
Write-Host "Monitor layout saved to: $OutputPath" -ForegroundColor Green
Write-Host "Total monitors captured: $($monitors.Count)" -ForegroundColor Cyan
Write-Host "`nYou can now use this file with the test script:" -ForegroundColor Yellow
Write-Host " python monitor_layout_tests.py --layout-file $OutputPath" -ForegroundColor White
return $output
}
# Main execution
try {
$layout = Capture-MonitorLayout
# Display summary
Write-Host "`n" + ("=" * 80)
Write-Host "SUMMARY" -ForegroundColor Cyan
Write-Host ("=" * 80)
Write-Host "Configuration Name: $($layout.computer_name)"
Write-Host "Captured: $($layout.captured_at)"
Write-Host "Monitors: $($layout.monitor_count)"
# Calculate desktop dimensions
$widths = @($layout.monitors | ForEach-Object { $_.width })
$heights = @($layout.monitors | ForEach-Object { $_.height })
$totalWidth = ($widths | Measure-Object -Sum).Sum
$maxHeight = ($heights | Measure-Object -Maximum).Maximum
Write-Host "Total Desktop Width: $totalWidth pixels"
Write-Host "Max Desktop Height: $maxHeight pixels"
# Analyze potential coordinate issues
Write-Host "`n" + ("=" * 80)
Write-Host "COORDINATE ANALYSIS" -ForegroundColor Cyan
Write-Host ("=" * 80)
# Check for gaps between monitors
if ($layout.monitor_count -gt 1) {
$hasGaps = $false
for ($i = 0; $i -lt $layout.monitor_count - 1; $i++) {
$m1 = $layout.monitors[$i]
for ($j = $i + 1; $j -lt $layout.monitor_count; $j++) {
$m2 = $layout.monitors[$j]
# Check horizontal gap
$hGap = [Math]::Min([Math]::Abs($m1.right - $m2.left), [Math]::Abs($m2.right - $m1.left))
# Check vertical overlap
$vOverlapStart = [Math]::Max($m1.top, $m2.top)
$vOverlapEnd = [Math]::Min($m1.bottom, $m2.bottom)
$vOverlap = $vOverlapEnd - $vOverlapStart
if ($hGap -gt 50 -and $vOverlap -gt 0) {
Write-Host "⚠ Gap detected between Monitor $($i+1) and Monitor $($j+1): ${hGap}px horizontal gap" -ForegroundColor Yellow
Write-Host " Vertical overlap: ${vOverlap}px" -ForegroundColor Yellow
Write-Host " This may indicate a Windows coordinate bug if monitors appear snapped in Display Settings" -ForegroundColor Yellow
$hasGaps = $true
}
}
}
if (-not $hasGaps) {
Write-Host "✓ No unexpected gaps detected" -ForegroundColor Green
}
}
# DPI/Scaling notes
Write-Host "`nDPI/Scaling Impact on Coordinates:" -ForegroundColor Cyan
Write-Host "• Coordinate values (left, top, right, bottom) are in LOGICAL PIXELS"
Write-Host "• These are DPI-independent virtual coordinates"
Write-Host "• Physical pixels = Logical pixels × (DPI / 96)"
Write-Host "• Example: 1920 logical pixels at 150% scaling = 1920 × 1.5 = 2880 physical pixels"
Write-Host "• Windows snaps monitors using logical pixel coordinates"
Write-Host "• If monitors appear snapped but coordinates show gaps, this is a Windows bug"
exit 0
}
catch {
Write-Host "`nError capturing monitor layout:" -ForegroundColor Red
Write-Host $_.Exception.Message -ForegroundColor Red
Write-Host $_.ScriptStackTrace -ForegroundColor DarkGray
exit 1
}

View File

@@ -1,430 +0,0 @@
"""
Test Results Analyzer for CursorWrap Monitor Layout Tests
Analyzes test_report.json and provides detailed explanations of failures,
patterns, and recommendations.
"""
import json
import sys
from collections import defaultdict
from typing import Dict, List, Any
class TestResultAnalyzer:
"""Analyzes test results and provides insights"""
def __init__(self, report_path: str = "test_report.json"):
with open(report_path, 'r') as f:
self.report = json.load(f)
self.failures = self.report.get('failures', [])
self.summary = self.report.get('summary', {})
self.recommendations = self.report.get('recommendations', [])
def print_overview(self):
"""Print test overview"""
print("=" * 80)
print("CURSORWRAP TEST RESULTS ANALYSIS")
print("=" * 80)
print(f"\nTotal Configurations Tested: {self.summary.get('total_configs', 0)}")
print(f"Passed: {self.summary.get('passed', 0)} ({self.summary.get('pass_rate', 'N/A')})")
print(f"Failed: {self.summary.get('failed', 0)}")
print(f"Total Issues: {self.summary.get('total_issues', 0)}")
if self.summary.get('passed', 0) == self.summary.get('total_configs', 0):
print("\n✓ ALL TESTS PASSED! Edge detection logic is working correctly.")
return
print(f"\n{self.summary.get('total_issues', 0)} issues detected\n")
def analyze_failure_patterns(self):
"""Analyze and categorize failure patterns"""
print("=" * 80)
print("FAILURE PATTERN ANALYSIS")
print("=" * 80)
# Group by test type
by_test_type = defaultdict(list)
for failure in self.failures:
by_test_type[failure['test_name']].append(failure)
# Group by configuration
by_config = defaultdict(list)
for failure in self.failures:
by_config[failure['monitor_config']].append(failure)
print(f"\n1. Failures by Test Type:")
for test_type, failures in sorted(by_test_type.items(), key=lambda x: len(x[1]), reverse=True):
print(f"{test_type}: {len(failures)} failures")
print(f"\n2. Configurations with Failures:")
for config, failures in sorted(by_config.items(), key=lambda x: len(x[1]), reverse=True):
print(f"{config}")
print(f" {len(failures)} issues")
return by_test_type, by_config
def analyze_wrap_calculation_failures(self, failures: List[Dict[str, Any]]):
"""Detailed analysis of wrap calculation failures"""
print("\n" + "=" * 80)
print("WRAP CALCULATION FAILURE ANALYSIS")
print("=" * 80)
# Analyze cursor positions
positions = []
configs = set()
for failure in failures:
configs.add(failure['monitor_config'])
# Extract position from expected message
if 'test_point' in failure.get('details', {}):
pos = failure['details']['test_point']
positions.append(pos)
print(f"\nAffected Configurations: {len(configs)}")
for config in sorted(configs):
print(f"{config}")
if positions:
print(f"\nFailed Test Points: {len(positions)}")
# Analyze if failures are at edges
edge_positions = defaultdict(int)
for x, y in positions:
# Simplified edge detection
if x <= 10:
edge_positions['left edge'] += 1
elif y <= 10:
edge_positions['top edge'] += 1
else:
edge_positions['other'] += 1
if edge_positions:
print("\nPosition Distribution:")
for pos_type, count in edge_positions.items():
print(f"{pos_type}: {count}")
def explain_common_issues(self):
"""Explain common issues found in results"""
print("\n" + "=" * 80)
print("COMMON ISSUE EXPLANATIONS")
print("=" * 80)
has_wrap_failures = any(f['test_name'] == 'wrap_calculation' for f in self.failures)
has_edge_failures = any(f['test_name'] == 'single_monitor_edges' for f in self.failures)
has_touching_failures = any(f['test_name'] == 'touching_monitors' for f in self.failures)
if has_wrap_failures:
print("\n⚠ WRAP CALCULATION FAILURES")
print("-" * 80)
print("Issue: Cursor is on an outer edge but wrapping is not occurring.")
print("\nLikely Causes:")
print(" 1. Partial Overlap Problem:")
print(" • When monitors have different sizes (e.g., 4K + 1080p)")
print(" • Only part of an edge is actually adjacent to another monitor")
print(" • Current code marks the ENTIRE edge as non-outer if ANY part is adjacent")
print(" • This prevents wrapping even in regions where it should occur")
print("\n 2. Edge Detection Logic:")
print(" • Check IdentifyOuterEdges() in MonitorTopology.cpp")
print(" • Consider segmenting edges based on actual overlap regions")
print("\n 3. Test Point Selection:")
print(" • Failures may be at corners or quarter points")
print(" • Indicates edge behavior varies along its length")
if has_edge_failures:
print("\n⚠ SINGLE MONITOR EDGE FAILURES")
print("-" * 80)
print("Issue: Single monitor should have exactly 4 outer edges.")
print("\nThis indicates a fundamental problem in edge detection baseline.")
if has_touching_failures:
print("\n⚠ TOUCHING MONITORS FAILURES")
print("-" * 80)
print("Issue: Adjacent monitors not detected correctly.")
print("\nCheck EdgesAreAdjacent() logic and 50px tolerance settings.")
def print_recommendations(self):
"""Print recommendations from the report"""
if not self.recommendations:
return
print("\n" + "=" * 80)
print("RECOMMENDATIONS")
print("=" * 80)
for i, rec in enumerate(self.recommendations, 1):
print(f"\n{i}. {rec}")
def detailed_failure_dump(self):
"""Print all failure details"""
print("\n" + "=" * 80)
print("DETAILED FAILURE LISTING")
print("=" * 80)
for i, failure in enumerate(self.failures, 1):
print(f"\n[{i}] {failure['test_name']}")
print(f"Configuration: {failure['monitor_config']}")
print(f"Expected: {failure['expected']}")
print(f"Actual: {failure['actual']}")
if 'details' in failure:
details = failure['details']
if 'edge' in details:
edge = details['edge']
print(f"Edge: {edge.get('edge_type', 'N/A')} at position {edge.get('position', 'N/A')}, "
f"range [{edge.get('range_start', 'N/A')}, {edge.get('range_end', 'N/A')}]")
if 'test_point' in details:
print(f"Test Point: {details['test_point']}")
print("-" * 80)
def generate_github_copilot_prompt(self):
"""Generate a prompt suitable for GitHub Copilot to fix the issues"""
print("\n" + "=" * 80)
print("GITHUB COPILOT FIX PROMPT")
print("=" * 80)
print("\n```markdown")
print("# CursorWrap Edge Detection Bug Report")
print()
print("## Test Results Summary")
print(f"- Total Configurations Tested: {self.summary.get('total_configs', 0)}")
print(f"- Pass Rate: {self.summary.get('pass_rate', 'N/A')}")
print(f"- Failed Tests: {self.summary.get('failed', 0)}")
print(f"- Total Issues: {self.summary.get('total_issues', 0)}")
print()
# Group failures
by_test_type = defaultdict(list)
for failure in self.failures:
by_test_type[failure['test_name']].append(failure)
print("## Critical Issues Found")
print()
# Analyze wrap calculation failures
if 'wrap_calculation' in by_test_type:
failures = by_test_type['wrap_calculation']
configs = set(f['monitor_config'] for f in failures)
print("### 1. Wrap Calculation Failures (PARTIAL OVERLAP BUG)")
print()
print(f"**Count**: {len(failures)} failures across {len(configs)} configuration(s)")
print()
print("**Affected Configurations**:")
for config in sorted(configs):
print(f"- {config}")
print()
print("**Root Cause Analysis**:")
print()
print("The current implementation in `MonitorTopology::IdentifyOuterEdges()` marks an")
print("ENTIRE edge as non-outer if ANY portion of that edge is adjacent to another monitor.")
print()
print("**Problem Scenario**: 1080p monitor + 4K monitor at bottom-right")
print("```")
print("4K Monitor (3840x2160 at 0,0)")
print("┌────────────────────────────────────────┐")
print("│ │ <- Y: 0-1080 NO adjacent monitor")
print("│ │ RIGHT EDGE SHOULD BE OUTER")
print("│ │")
print("│ │┌──────────┐")
print("│ ││ 1080p │ <- Y: 1080-2160 HAS adjacent")
print("└────────────────────────────────────────┘│ at │ RIGHT EDGE NOT OUTER")
print(" │ (3840, │")
print(" │ 1080) │")
print(" └──────────┘")
print("```")
print()
print("**Current Behavior**: Right edge of 4K monitor is marked as NON-OUTER for entire")
print("range (Y: 0-2160) because it detects adjacency in the bottom portion (Y: 1080-2160).")
print()
print("**Expected Behavior**: Right edge should be:")
print("- OUTER from Y: 0 to Y: 1080 (no adjacent monitor)")
print("- NON-OUTER from Y: 1080 to Y: 2160 (adjacent to 1080p monitor)")
print()
print("**Failed Test Examples**:")
print()
for i, failure in enumerate(failures[:3], 1): # Show first 3
details = failure.get('details', {})
test_point = details.get('test_point', 'N/A')
edge = details.get('edge', {})
edge_type = edge.get('edge_type', 'N/A')
position = edge.get('position', 'N/A')
range_start = edge.get('range_start', 'N/A')
range_end = edge.get('range_end', 'N/A')
print(f"{i}. **Configuration**: {failure['monitor_config']}")
print(f" - Test Point: {test_point}")
print(f" - Edge: {edge_type} at X={position}, Y range=[{range_start}, {range_end}]")
print(f" - Expected: Cursor wraps to opposite edge")
print(f" - Actual: No wrap occurred (edge incorrectly marked as non-outer)")
print()
if len(failures) > 3:
print(f" ... and {len(failures) - 3} more similar failures")
print()
# Other failure types
if 'single_monitor_edges' in by_test_type:
print("### 2. Single Monitor Edge Detection Failures")
print()
print(f"**Count**: {len(by_test_type['single_monitor_edges'])} failures")
print()
print("Single monitor configurations should have exactly 4 outer edges.")
print("This indicates a fundamental problem in baseline edge detection.")
print()
if 'touching_monitors' in by_test_type:
print("### 3. Adjacent Monitor Detection Failures")
print()
print(f"**Count**: {len(by_test_type['touching_monitors'])} failures")
print()
print("Adjacent monitors not being detected correctly by EdgesAreAdjacent().")
print()
print("## Required Code Changes")
print()
print("### File: `MonitorTopology.cpp`")
print()
print("**Change 1**: Modify `IdentifyOuterEdges()` to support partial edge adjacency")
print()
print("Instead of marking entire edges as outer/non-outer, the code needs to:")
print()
print("1. **Segment edges** based on actual overlap regions with adjacent monitors")
print("2. Create **sub-edges** for portions of an edge that have different outer status")
print("3. Update `IsOnOuterEdge()` to check if the **cursor's specific position** is on an outer portion")
print()
print("**Proposed Approach**:")
print()
print("```cpp")
print("// Instead of: edge.isOuter = true/false for entire edge")
print("// Use: Store list of outer ranges for each edge")
print()
print("struct MonitorEdge {")
print(" // ... existing fields ...")
print(" std::vector<std::pair<int, int>> outerRanges; // Ranges where edge is outer")
print("};")
print()
print("// In IdentifyOuterEdges():")
print("// For each edge, find ALL adjacent opposite edges")
print("// Calculate which portions of the edge have NO adjacent opposite")
print("// Store these as outer ranges")
print()
print("// In IsOnOuterEdge():")
print("// Check if cursor position falls within any outer range")
print("if (edge.type == EdgeType::Left || edge.type == EdgeType::Right) {")
print(" // Check if cursorPos.y is in any outer range")
print("} else {")
print(" // Check if cursorPos.x is in any outer range")
print("}")
print("```")
print()
print("**Change 2**: Update `EdgesAreAdjacent()` validation")
print()
print("The 50px tolerance logic is correct but needs to return overlap range info:")
print()
print("```cpp")
print("struct AdjacencyResult {")
print(" bool isAdjacent;")
print(" int overlapStart; // Where the adjacency begins")
print(" int overlapEnd; // Where the adjacency ends")
print("};")
print()
print("AdjacencyResult CheckEdgeAdjacency(const MonitorEdge& edge1, ")
print(" const MonitorEdge& edge2, ")
print(" int tolerance);")
print("```")
print()
print("## Test Validation")
print()
print("After implementing changes, run:")
print("```bash")
print("python monitor_layout_tests.py --max-monitors 10")
print("```")
print()
print("Expected results:")
print("- All 21+ configurations should pass")
print("- Specifically, the 4K+1080p configuration should pass all 5 test points per edge")
print("- Wrap calculation should work correctly at partial overlap boundaries")
print()
print("## Files to Modify")
print()
print("1. `MonitorTopology.h` - Update MonitorEdge structure")
print("2. `MonitorTopology.cpp` - Implement segmented edge detection")
print(" - `IdentifyOuterEdges()` - Main logic change")
print(" - `IsOnOuterEdge()` - Check position against ranges")
print(" - `EdgesAreAdjacent()` - Optionally return range info")
print()
print("```")
def run_analysis(self, detailed: bool = False, copilot_mode: bool = False):
"""Run complete analysis"""
if copilot_mode:
self.generate_github_copilot_prompt()
return
self.print_overview()
if not self.failures:
print("\n✓ No failures to analyze!")
return
by_test_type, by_config = self.analyze_failure_patterns()
# Specific analysis for wrap calculation failures
if 'wrap_calculation' in by_test_type:
self.analyze_wrap_calculation_failures(by_test_type['wrap_calculation'])
self.explain_common_issues()
self.print_recommendations()
if detailed:
self.detailed_failure_dump()
def main():
"""Main entry point"""
import argparse
parser = argparse.ArgumentParser(
description="Analyze CursorWrap test results"
)
parser.add_argument(
"--report",
default="test_report.json",
help="Path to test report JSON file"
)
parser.add_argument(
"--detailed",
action="store_true",
help="Show detailed failure listing"
)
parser.add_argument(
"--copilot",
action="store_true",
help="Generate GitHub Copilot-friendly fix prompt"
)
args = parser.parse_args()
try:
analyzer = TestResultAnalyzer(args.report)
analyzer.run_analysis(detailed=args.detailed, copilot_mode=args.copilot)
# Exit with error code if there were failures
sys.exit(0 if not analyzer.failures else 1)
except FileNotFoundError:
print(f"Error: Could not find report file: {args.report}")
print("\nRun monitor_layout_tests.py first to generate the report.")
sys.exit(1)
except json.JSONDecodeError:
print(f"Error: Invalid JSON in report file: {args.report}")
sys.exit(1)
except Exception as e:
print(f"Error analyzing report: {e}")
sys.exit(1)
if __name__ == "__main__":
main()

View File

@@ -1,892 +0,0 @@
"""
Monitor Layout Edge Detection Test Suite for CursorWrap
This script validates the edge detection and wrapping logic across thousands of
monitor configurations without requiring the full PowerToys build environment.
Tests:
- 1-4 monitor configurations
- Common resolutions and DPI scales
- Various arrangements (horizontal, vertical, L-shape, grid)
- Edge detection (touching vs. gap)
- Wrap calculations
Output: JSON report with failures for GitHub Copilot analysis
"""
import json
from dataclasses import dataclass, asdict
from typing import List, Tuple, Dict, Optional
from enum import Enum
import sys
# ============================================================================
# Data Structures (mirrors C++ implementation)
# ============================================================================
@dataclass
class MonitorInfo:
"""Represents a physical monitor"""
left: int
top: int
right: int
bottom: int
dpi: int = 96
primary: bool = False
@property
def width(self) -> int:
return self.right - self.left
@property
def height(self) -> int:
return self.bottom - self.top
@property
def center_x(self) -> int:
return (self.left + self.right) // 2
@property
def center_y(self) -> int:
return (self.top + self.bottom) // 2
class EdgeType(Enum):
LEFT = "Left"
RIGHT = "Right"
TOP = "Top"
BOTTOM = "Bottom"
@dataclass
class Edge:
"""Represents a monitor edge"""
edge_type: EdgeType
position: int # x for vertical, y for horizontal
range_start: int
range_end: int
monitor_index: int
def overlaps(self, other: 'Edge', tolerance: int = 1) -> bool:
"""Check if two edges overlap in their perpendicular range"""
if self.edge_type != other.edge_type:
return False
if abs(self.position - other.position) > tolerance:
return False
return not (
self.range_end <= other.range_start or other.range_end <= self.range_start)
@dataclass
class TestFailure:
"""Records a test failure for analysis"""
test_name: str
monitor_config: str
expected: str
actual: str
details: Dict
# ============================================================================
# Edge Detection Logic (Python implementation of C++ logic)
# ============================================================================
class MonitorTopology:
"""Implements the edge detection logic to be validated"""
ADJACENCY_TOLERANCE = 50 # Pixels - tolerance for detecting adjacent edges (matches C++ implementation)
EDGE_THRESHOLD = 1 # Pixels - cursor must be within this distance to trigger wrap
def __init__(self, monitors: List[MonitorInfo]):
self.monitors = monitors
self.outer_edges: List[Edge] = []
self._detect_outer_edges()
def _detect_outer_edges(self):
"""Detect which edges are outer (can wrap)"""
all_edges = self._collect_all_edges()
for edge in all_edges:
if self._is_outer_edge(edge, all_edges):
self.outer_edges.append(edge)
def _collect_all_edges(self) -> List[Edge]:
"""Collect all edges from all monitors"""
edges = []
for idx, mon in enumerate(self.monitors):
edges.append(
Edge(
EdgeType.LEFT,
mon.left,
mon.top,
mon.bottom,
idx))
edges.append(
Edge(
EdgeType.RIGHT,
mon.right,
mon.top,
mon.bottom,
idx))
edges.append(Edge(EdgeType.TOP, mon.top, mon.left, mon.right, idx))
edges.append(
Edge(
EdgeType.BOTTOM,
mon.bottom,
mon.left,
mon.right,
idx))
return edges
def _is_outer_edge(self, edge: Edge, all_edges: List[Edge]) -> bool:
"""
Determine if an edge is "outer" (can wrap)
Rules:
1. If edge has an adjacent opposite edge (within 50px tolerance AND overlapping range), it's NOT outer
2. Otherwise, edge IS outer
Note: This matches C++ EdgesAreAdjacent() logic
"""
opposite_type = self._get_opposite_edge_type(edge.edge_type)
# Find opposite edges that overlap in perpendicular range
opposite_edges = [e for e in all_edges
if e.edge_type == opposite_type
and e.monitor_index != edge.monitor_index
and self._ranges_overlap(edge.range_start, edge.range_end,
e.range_start, e.range_end)]
if not opposite_edges:
return True # No opposite edges = outer edge
# Check if any opposite edge is adjacent (within tolerance)
for opp in opposite_edges:
distance = abs(edge.position - opp.position)
if distance <= self.ADJACENCY_TOLERANCE:
return False # Adjacent edge found = not outer
return True # No adjacent edges = outer
@staticmethod
def _get_opposite_edge_type(edge_type: EdgeType) -> EdgeType:
"""Get the opposite edge type"""
opposites = {
EdgeType.LEFT: EdgeType.RIGHT,
EdgeType.RIGHT: EdgeType.LEFT,
EdgeType.TOP: EdgeType.BOTTOM,
EdgeType.BOTTOM: EdgeType.TOP
}
return opposites[edge_type]
@staticmethod
def _ranges_overlap(
a_start: int,
a_end: int,
b_start: int,
b_end: int) -> bool:
"""Check if two 1D ranges overlap"""
return not (a_end <= b_start or b_end <= a_start)
def calculate_wrap_position(self, x: int, y: int) -> Tuple[int, int]:
"""Calculate where cursor should wrap to"""
# Find which outer edge was crossed and calculate wrap
# At corners, multiple edges may match - try all and return first successful wrap
for edge in self.outer_edges:
if self._is_on_edge(x, y, edge):
new_x, new_y = self._wrap_from_edge(x, y, edge)
if (new_x, new_y) != (x, y):
# Wrap succeeded
return (new_x, new_y)
return (x, y) # No wrap
def _is_on_edge(self, x: int, y: int, edge: Edge) -> bool:
"""Check if point is on the given edge"""
tolerance = 2 # Pixels
if edge.edge_type in (EdgeType.LEFT, EdgeType.RIGHT):
return (abs(x - edge.position) <= tolerance and
edge.range_start <= y <= edge.range_end)
else:
return (abs(y - edge.position) <= tolerance and
edge.range_start <= x <= edge.range_end)
def _wrap_from_edge(self, x: int, y: int, edge: Edge) -> Tuple[int, int]:
"""Calculate wrap destination from an outer edge"""
opposite_type = self._get_opposite_edge_type(edge.edge_type)
# Find opposite outer edges that overlap
opposite_edges = [e for e in self.outer_edges
if e.edge_type == opposite_type
and self._point_in_range(x, y, e)]
if not opposite_edges:
return (x, y) # No wrap destination
# Find closest opposite edge
target_edge = min(opposite_edges,
key=lambda e: abs(e.position - edge.position))
# Calculate new position
if edge.edge_type in (EdgeType.LEFT, EdgeType.RIGHT):
return (target_edge.position, y)
else:
return (x, target_edge.position)
@staticmethod
def _point_in_range(x: int, y: int, edge: Edge) -> bool:
"""Check if point's perpendicular coordinate is in edge's range"""
if edge.edge_type in (EdgeType.LEFT, EdgeType.RIGHT):
return edge.range_start <= y <= edge.range_end
else:
return edge.range_start <= x <= edge.range_end
# ============================================================================
# Test Configuration Generators
# ============================================================================
class TestConfigGenerator:
"""Generates comprehensive test configurations"""
# Common resolutions
RESOLUTIONS = [
(1920, 1080), # 1080p
(2560, 1440), # 1440p
(3840, 2160), # 4K
(3440, 1440), # Ultrawide
(1920, 1200), # 16:10
]
# DPI scales
DPI_SCALES = [96, 120, 144, 192] # 100%, 125%, 150%, 200%
@classmethod
def load_from_file(cls, filepath: str) -> List[List[MonitorInfo]]:
"""Load monitor configuration from captured JSON file"""
# Handle UTF-8 with BOM (PowerShell default)
with open(filepath, 'r', encoding='utf-8-sig') as f:
data = json.load(f)
monitors = []
for mon in data.get('monitors', []):
monitor = MonitorInfo(
left=mon['left'],
top=mon['top'],
right=mon['right'],
bottom=mon['bottom'],
dpi=mon.get('dpi', 96),
primary=mon.get('primary', False)
)
monitors.append(monitor)
return [monitors] if monitors else []
@classmethod
def generate_all_configs(cls,
max_monitors: int = 4) -> List[List[MonitorInfo]]:
"""Generate all test configurations"""
configs = []
# Single monitor (baseline)
configs.extend(cls._single_monitor_configs())
# Two monitors (most common)
if max_monitors >= 2:
configs.extend(cls._two_monitor_configs())
# Three monitors
if max_monitors >= 3:
configs.extend(cls._three_monitor_configs())
# Four monitors
if max_monitors >= 4:
configs.extend(cls._four_monitor_configs())
# Five+ monitors
if max_monitors >= 5:
configs.extend(cls._five_plus_monitor_configs(max_monitors))
return configs
@classmethod
def _single_monitor_configs(cls) -> List[List[MonitorInfo]]:
"""Single monitor configurations"""
configs = []
for width, height in cls.RESOLUTIONS[:3]: # Limit for single monitor
for dpi in cls.DPI_SCALES[:2]: # Limit DPI variations
mon = MonitorInfo(0, 0, width, height, dpi, True)
configs.append([mon])
return configs
@classmethod
def _two_monitor_configs(cls) -> List[List[MonitorInfo]]:
"""Two monitor configurations"""
configs = []
# Both 1080p for simplicity
res1, res2 = cls.RESOLUTIONS[0], cls.RESOLUTIONS[0]
# Horizontal (touching)
configs.append([
MonitorInfo(0, 0, res1[0], res1[1], primary=True),
MonitorInfo(res1[0], 0, res1[0] + res2[0], res2[1])
])
# Vertical (touching)
configs.append([
MonitorInfo(0, 0, res1[0], res1[1], primary=True),
MonitorInfo(0, res1[1], res2[0], res1[1] + res2[1])
])
# Different resolutions
res_big = cls.RESOLUTIONS[2] # 4K
configs.append([
MonitorInfo(0, 0, res1[0], res1[1], primary=True),
MonitorInfo(res1[0], 0, res1[0] + res_big[0], res_big[1])
])
# Offset alignment (common real-world scenario)
offset = 200
configs.append([
MonitorInfo(0, offset, res1[0], offset + res1[1], primary=True),
MonitorInfo(res1[0], 0, res1[0] + res2[0], res2[1])
])
return configs
@classmethod
def _three_monitor_configs(cls) -> List[List[MonitorInfo]]:
"""Three monitor configurations"""
configs = []
res = cls.RESOLUTIONS[0] # 1080p
# Linear horizontal
configs.append([
MonitorInfo(0, 0, res[0], res[1], primary=True),
MonitorInfo(res[0], 0, res[0] * 2, res[1]),
MonitorInfo(res[0] * 2, 0, res[0] * 3, res[1])
])
# L-shape (common gaming setup)
configs.append([
MonitorInfo(0, 0, res[0], res[1], primary=True),
MonitorInfo(res[0], 0, res[0] * 2, res[1]),
MonitorInfo(0, res[1], res[0], res[1] * 2)
])
# Vertical stack
configs.append([
MonitorInfo(0, 0, res[0], res[1], primary=True),
MonitorInfo(0, res[1], res[0], res[1] * 2),
MonitorInfo(0, res[1] * 2, res[0], res[1] * 3)
])
return configs
@classmethod
def _four_monitor_configs(cls) -> List[List[MonitorInfo]]:
"""Four monitor configurations"""
configs = []
res = cls.RESOLUTIONS[0] # 1080p
# 2x2 grid (classic)
configs.append([
MonitorInfo(0, 0, res[0], res[1], primary=True),
MonitorInfo(res[0], 0, res[0] * 2, res[1]),
MonitorInfo(0, res[1], res[0], res[1] * 2),
MonitorInfo(res[0], res[1], res[0] * 2, res[1] * 2)
])
# Linear horizontal
configs.append([
MonitorInfo(0, 0, res[0], res[1], primary=True),
MonitorInfo(res[0], 0, res[0] * 2, res[1]),
MonitorInfo(res[0] * 2, 0, res[0] * 3, res[1]),
MonitorInfo(res[0] * 3, 0, res[0] * 4, res[1])
])
return configs
@classmethod
def _five_plus_monitor_configs(cls, max_count: int) -> List[List[MonitorInfo]]:
"""Five to ten monitor configurations"""
configs = []
res = cls.RESOLUTIONS[0] # 1080p
# Linear horizontal (5-10 monitors)
for count in range(5, min(max_count + 1, 11)):
monitor_list = []
for i in range(count):
is_primary = (i == 0)
monitor_list.append(
MonitorInfo(res[0] * i, 0, res[0] * (i + 1), res[1], primary=is_primary)
)
configs.append(monitor_list)
return configs
# ============================================================================
# Test Validators
# ============================================================================
class EdgeDetectionValidator:
"""Validates edge detection logic"""
@staticmethod
def validate_single_monitor(
monitors: List[MonitorInfo]) -> Optional[TestFailure]:
"""Single monitor should have 4 outer edges"""
topology = MonitorTopology(monitors)
expected_count = 4
actual_count = len(topology.outer_edges)
if actual_count != expected_count:
return TestFailure(
test_name="single_monitor_edges",
monitor_config=EdgeDetectionValidator._describe_config(
monitors),
expected=f"{expected_count} outer edges",
actual=f"{actual_count} outer edges",
details={"edges": [asdict(e) for e in topology.outer_edges]}
)
return None
@staticmethod
def validate_touching_monitors(
monitors: List[MonitorInfo]) -> Optional[TestFailure]:
"""Touching monitors should have no gap between them"""
topology = MonitorTopology(monitors)
# For 2 touching monitors horizontally, expect 6 outer edges (not 8)
if len(monitors) == 2:
# Check if they're aligned horizontally and touching
m1, m2 = monitors
if m1.right == m2.left and m1.top == m2.top and m1.bottom == m2.bottom:
expected = 6 # 2 internal edges removed
actual = len(topology.outer_edges)
if actual != expected:
return TestFailure(
test_name="touching_monitors",
monitor_config=EdgeDetectionValidator._describe_config(
monitors),
expected=f"{expected} outer edges (2 touching edges removed)",
actual=f"{actual} outer edges",
details={"edges": [asdict(e)
for e in topology.outer_edges]}
)
return None
@staticmethod
def validate_wrap_calculation(
monitors: List[MonitorInfo]) -> List[TestFailure]:
"""Validate cursor wrap calculations"""
failures = []
topology = MonitorTopology(monitors)
# Test wrapping at each outer edge with multiple points
for edge in topology.outer_edges:
test_points = EdgeDetectionValidator._get_test_points_on_edge(
edge, monitors)
for test_point in test_points:
x, y = test_point
# Check if there's actually a valid wrap destination
# (some outer edges may not have opposite edges due to partial overlap)
opposite_type = topology._get_opposite_edge_type(edge.edge_type)
has_opposite = any(
e.edge_type == opposite_type and
topology._point_in_range(x, y, e)
for e in topology.outer_edges
)
if not has_opposite:
# No wrap destination available - this is OK for partial overlaps
continue
new_x, new_y = topology.calculate_wrap_position(x, y)
# Verify wrap happened (position changed)
if (new_x, new_y) == (x, y):
# Should have wrapped but didn't
failure = TestFailure(
test_name="wrap_calculation",
monitor_config=EdgeDetectionValidator._describe_config(
monitors),
expected=f"Cursor should wrap from ({x},{y})",
actual=f"No wrap occurred",
details={
"edge": asdict(edge),
"test_point": (x, y)
}
)
failures.append(failure)
return failures
@staticmethod
def _get_test_points_on_edge(
edge: Edge, monitors: List[MonitorInfo]) -> List[Tuple[int, int]]:
"""Get multiple test points on the given edge (5 points: top/left corner, quarter, center, three-quarter, bottom/right corner)"""
monitor = monitors[edge.monitor_index]
points = []
if edge.edge_type == EdgeType.LEFT:
x = monitor.left
for ratio in [0.0, 0.25, 0.5, 0.75, 1.0]:
y = int(monitor.top + (monitor.height - 1) * ratio)
points.append((x, y))
elif edge.edge_type == EdgeType.RIGHT:
x = monitor.right - 1
for ratio in [0.0, 0.25, 0.5, 0.75, 1.0]:
y = int(monitor.top + (monitor.height - 1) * ratio)
points.append((x, y))
elif edge.edge_type == EdgeType.TOP:
y = monitor.top
for ratio in [0.0, 0.25, 0.5, 0.75, 1.0]:
x = int(monitor.left + (monitor.width - 1) * ratio)
points.append((x, y))
elif edge.edge_type == EdgeType.BOTTOM:
y = monitor.bottom - 1
for ratio in [0.0, 0.25, 0.5, 0.75, 1.0]:
x = int(monitor.left + (monitor.width - 1) * ratio)
points.append((x, y))
return points
@staticmethod
def _describe_config(monitors: List[MonitorInfo]) -> str:
"""Generate human-readable config description"""
if len(monitors) == 1:
m = monitors[0]
return f"Single {m.width}x{m.height} @{m.dpi}DPI"
desc = f"{len(monitors)} monitors: "
for i, m in enumerate(monitors):
desc += f"M{i}({m.width}x{m.height} at {m.left},{m.top}) "
return desc.strip()
# ============================================================================
# Test Runner
# ============================================================================
class TestRunner:
"""Orchestrates the test execution"""
def __init__(self, max_monitors: int = 10, verbose: bool = False, layout_file: str = None):
self.max_monitors = max_monitors
self.verbose = verbose
self.layout_file = layout_file
self.failures: List[TestFailure] = []
self.test_count = 0
self.passed_count = 0
def _print_layout_diagram(self, monitors: List[MonitorInfo]):
"""Print a text-based diagram of the monitor layout"""
print("\n" + "=" * 80)
print("MONITOR LAYOUT DIAGRAM")
print("=" * 80)
# Find bounds of entire desktop
min_x = min(m.left for m in monitors)
min_y = min(m.top for m in monitors)
max_x = max(m.right for m in monitors)
max_y = max(m.bottom for m in monitors)
# Calculate scale to fit in ~70 chars wide
desktop_width = max_x - min_x
desktop_height = max_y - min_y
# Scale factor: target 70 chars width
scale = desktop_width / 70.0
if scale < 1:
scale = 1
# Create grid (70 chars wide, proportional height)
grid_width = 70
grid_height = max(10, int(desktop_height / scale))
grid_height = min(grid_height, 30) # Cap at 30 lines
# Initialize grid with spaces
grid = [[' ' for _ in range(grid_width)] for _ in range(grid_height)]
# Draw each monitor
for idx, mon in enumerate(monitors):
# Convert monitor coords to grid coords
x1 = int((mon.left - min_x) / scale)
y1 = int((mon.top - min_y) / scale)
x2 = int((mon.right - min_x) / scale)
y2 = int((mon.bottom - min_y) / scale)
# Clamp to grid
x1 = max(0, min(x1, grid_width - 1))
x2 = max(0, min(x2, grid_width))
y1 = max(0, min(y1, grid_height - 1))
y2 = max(0, min(y2, grid_height))
# Draw monitor border and fill
char = str(idx) if idx < 10 else chr(65 + idx - 10) # 0-9, then A-Z
for y in range(y1, y2):
for x in range(x1, x2):
if y < grid_height and x < grid_width:
# Draw borders
if y == y1 or y == y2 - 1:
grid[y][x] = ''
elif x == x1 or x == x2 - 1:
grid[y][x] = ''
else:
grid[y][x] = char
# Draw corners
if y1 < grid_height and x1 < grid_width:
grid[y1][x1] = ''
if y1 < grid_height and x2 - 1 < grid_width:
grid[y1][x2 - 1] = ''
if y2 - 1 < grid_height and x1 < grid_width:
grid[y2 - 1][x1] = ''
if y2 - 1 < grid_height and x2 - 1 < grid_width:
grid[y2 - 1][x2 - 1] = ''
# Print grid
print()
for row in grid:
print(''.join(row))
# Print legend
print("\n" + "-" * 80)
print("MONITOR DETAILS:")
print("-" * 80)
for idx, mon in enumerate(monitors):
char = str(idx) if idx < 10 else chr(65 + idx - 10)
primary = " [PRIMARY]" if mon.primary else ""
scaling = int((mon.dpi / 96.0) * 100)
print(f" [{char}] Monitor {idx}{primary}")
print(f" Position: ({mon.left}, {mon.top})")
print(f" Size: {mon.width}x{mon.height}")
print(f" DPI: {mon.dpi} ({scaling}% scaling)")
print(f" Bounds: [{mon.left}, {mon.top}, {mon.right}, {mon.bottom}]")
print("=" * 80 + "\n")
def run_all_tests(self):
"""Execute all test configurations"""
print("=" * 80)
print("CursorWrap Monitor Layout Edge Detection Test Suite")
print("=" * 80)
# Load or generate configs
if self.layout_file:
print(f"\nLoading monitor layout from {self.layout_file}...")
configs = TestConfigGenerator.load_from_file(self.layout_file)
# Show visual diagram for captured layouts
if configs:
self._print_layout_diagram(configs[0])
else:
print("\nGenerating test configurations...")
configs = TestConfigGenerator.generate_all_configs(self.max_monitors)
total_tests = len(configs)
print(f"Testing {total_tests} configuration(s)")
print("=" * 80)
# Run tests
for i, config in enumerate(configs, 1):
self._run_test_config(config, i, total_tests)
# Report results
self._print_summary()
self._save_report()
def _run_test_config(
self,
monitors: List[MonitorInfo],
iteration: int,
total: int):
"""Run all validators on a single configuration"""
desc = EdgeDetectionValidator._describe_config(monitors)
if not self.verbose:
# Minimal output: just progress
progress = (iteration / total) * 100
print(
f"\r[{iteration}/{total}] {progress:5.1f}% - Testing: {desc[:60]:<60}", end="", flush=True)
else:
print(f"\n[{iteration}/{total}] Testing: {desc}")
# Run validators
self.test_count += 1
config_passed = True
# Single monitor validation
if len(monitors) == 1:
failure = EdgeDetectionValidator.validate_single_monitor(monitors)
if failure:
self.failures.append(failure)
config_passed = False
# Touching monitors validation (2+ monitors)
if len(monitors) >= 2:
failure = EdgeDetectionValidator.validate_touching_monitors(monitors)
if failure:
self.failures.append(failure)
config_passed = False
# Wrap calculation validation
wrap_failures = EdgeDetectionValidator.validate_wrap_calculation(monitors)
if wrap_failures:
self.failures.extend(wrap_failures)
config_passed = False
if config_passed:
self.passed_count += 1
if self.verbose and not config_passed:
print(f" ? FAILED ({len([f for f in self.failures if desc in f.monitor_config])} issues)")
elif self.verbose:
print(" ? PASSED")
def _print_summary(self):
"""Print test summary"""
print("\n\n" + "=" * 80)
print("TEST SUMMARY")
print("=" * 80)
print(f"Total Configurations: {self.test_count}")
print(f"Passed: {self.passed_count} ({self.passed_count/self.test_count*100:.1f}%)")
print(f"Failed: {self.test_count - self.passed_count} ({(self.test_count - self.passed_count)/self.test_count*100:.1f}%)")
print(f"Total Issues Found: {len(self.failures)}")
print("=" * 80)
if self.failures:
print("\n?? FAILURES DETECTED - See test_report.json for details")
print("\nTop 5 Failure Types:")
failure_types = {}
for f in self.failures:
failure_types[f.test_name] = failure_types.get(f.test_name, 0) + 1
for test_name, count in sorted(failure_types.items(), key=lambda x: x[1], reverse=True)[:5]:
print(f" - {test_name}: {count} failures")
else:
print("\n? ALL TESTS PASSED!")
def _save_report(self):
"""Save detailed JSON report"""
# Helper to convert enums to strings
def convert_for_json(obj):
if isinstance(obj, dict):
return {k: convert_for_json(v) for k, v in obj.items()}
elif isinstance(obj, list):
return [convert_for_json(item) for item in obj]
elif isinstance(obj, Enum):
return obj.value
else:
return obj
report = {
"summary": {
"total_configs": self.test_count,
"passed": self.passed_count,
"failed": self.test_count - self.passed_count,
"total_issues": len(self.failures),
"pass_rate": f"{self.passed_count/self.test_count*100:.2f}%"
},
"failures": convert_for_json([asdict(f) for f in self.failures]),
"recommendations": self._generate_recommendations()
}
output_file = "test_report.json"
with open(output_file, "w") as f:
json.dump(report, f, indent=2)
print(f"\n?? Detailed report saved to: {output_file}")
def _generate_recommendations(self) -> List[str]:
"""Generate recommendations based on failures"""
recommendations = []
failure_types = {}
for f in self.failures:
failure_types[f.test_name] = failure_types.get(f.test_name, 0) + 1
if "single_monitor_edges" in failure_types:
recommendations.append(
"Single monitor edge detection failing - verify baseline case in MonitorTopology::_detect_outer_edges()"
)
if "touching_monitors" in failure_types:
recommendations.append(
f"Adjacent monitor detection failing ({failure_types['touching_monitors']} cases) - "
"review ADJACENCY_TOLERANCE (50px) and edge overlap logic in EdgesAreAdjacent()"
)
if "wrap_calculation" in failure_types:
recommendations.append(
f"Wrap calculation failing ({failure_types['wrap_calculation']} cases) - "
"review CursorWrapCore::HandleMouseMove() wrap destination logic"
)
if not recommendations:
recommendations.append("All tests passed - edge detection logic is working correctly!")
return recommendations
# ============================================================================
# Main Entry Point
# ============================================================================
# ============================================================================
# Main Entry Point
# ============================================================================
def main():
"""Main entry point"""
import argparse
parser = argparse.ArgumentParser(
description="CursorWrap Monitor Layout Edge Detection Test Suite"
)
parser.add_argument(
"--max-monitors",
type=int,
default=10,
help="Maximum number of monitors to test (1-10)"
)
parser.add_argument(
"--verbose",
action="store_true",
help="Enable verbose output"
)
parser.add_argument(
"--layout-file",
type=str,
help="Use captured monitor layout JSON file instead of generated configs"
)
args = parser.parse_args()
if not args.layout_file:
# Validate max_monitors only for generated configs
if args.max_monitors < 1 or args.max_monitors > 10:
print("Error: max-monitors must be between 1 and 10")
sys.exit(1)
runner = TestRunner(
max_monitors=args.max_monitors,
verbose=args.verbose,
layout_file=args.layout_file
)
runner.run_all_tests()
# Exit with error code if tests failed
sys.exit(0 if not runner.failures else 1)
if __name__ == "__main__":
main()

View File

@@ -506,58 +506,8 @@ public:
return CallNextHookEx(nullptr, nCode, wParam, lParam);
}
// Helper method to check if there's a monitor adjacent in coordinate space (not grid)
bool HasAdjacentMonitorInCoordinateSpace(const RECT& currentMonitorRect, int direction)
{
// direction: 0=left, 1=right, 2=top, 3=bottom
const int tolerance = 50; // Allow small gaps
for (const auto& monitor : m_monitors)
{
bool isAdjacent = false;
switch (direction)
{
case 0: // Left - check if another monitor's right edge touches/overlaps our left edge
isAdjacent = (abs(monitor.rect.right - currentMonitorRect.left) <= tolerance) &&
(monitor.rect.bottom > currentMonitorRect.top + tolerance) &&
(monitor.rect.top < currentMonitorRect.bottom - tolerance);
break;
case 1: // Right - check if another monitor's left edge touches/overlaps our right edge
isAdjacent = (abs(monitor.rect.left - currentMonitorRect.right) <= tolerance) &&
(monitor.rect.bottom > currentMonitorRect.top + tolerance) &&
(monitor.rect.top < currentMonitorRect.bottom - tolerance);
break;
case 2: // Top - check if another monitor's bottom edge touches/overlaps our top edge
isAdjacent = (abs(monitor.rect.bottom - currentMonitorRect.top) <= tolerance) &&
(monitor.rect.right > currentMonitorRect.left + tolerance) &&
(monitor.rect.left < currentMonitorRect.right - tolerance);
break;
case 3: // Bottom - check if another monitor's top edge touches/overlaps our bottom edge
isAdjacent = (abs(monitor.rect.top - currentMonitorRect.bottom) <= tolerance) &&
(monitor.rect.right > currentMonitorRect.left + tolerance) &&
(monitor.rect.left < currentMonitorRect.right - tolerance);
break;
}
if (isAdjacent)
{
#ifdef _DEBUG
Logger::info(L"CursorWrap DEBUG: Found adjacent monitor in coordinate space (direction {})", direction);
#endif
return true;
}
}
return false;
}
// *** COMPLETELY REWRITTEN CURSOR WRAPPING LOGIC ***
// Implements vertical scrolling to bottom/top of vertical stack as requested
// Only wraps when there's NO adjacent monitor in the coordinate space
POINT HandleMouseMove(const POINT& currentPos)
{
POINT newPos = currentPos;
@@ -596,22 +546,12 @@ public:
// *** VERTICAL WRAPPING LOGIC - CONFIRMED WORKING ***
// Move to bottom of vertical stack when hitting top edge
// Only wrap if there's NO adjacent monitor in the coordinate space
if (currentPos.y <= currentMonitorInfo.rcMonitor.top)
{
#ifdef _DEBUG
Logger::info(L"CursorWrap DEBUG: ======= VERTICAL WRAP: TOP EDGE DETECTED =======");
#endif
// Check if there's an adjacent monitor above in coordinate space
if (HasAdjacentMonitorInCoordinateSpace(currentMonitorInfo.rcMonitor, 2))
{
#ifdef _DEBUG
Logger::info(L"CursorWrap DEBUG: SKIPPING WRAP - Adjacent monitor exists above (Windows will handle)");
#endif
return currentPos; // Let Windows handle natural cursor movement
}
// Find the bottom-most monitor in the vertical stack (same column)
HMONITOR bottomMonitor = nullptr;
@@ -664,15 +604,6 @@ public:
Logger::info(L"CursorWrap DEBUG: ======= VERTICAL WRAP: BOTTOM EDGE DETECTED =======");
#endif
// Check if there's an adjacent monitor below in coordinate space
if (HasAdjacentMonitorInCoordinateSpace(currentMonitorInfo.rcMonitor, 3))
{
#ifdef _DEBUG
Logger::info(L"CursorWrap DEBUG: SKIPPING WRAP - Adjacent monitor exists below (Windows will handle)");
#endif
return currentPos; // Let Windows handle natural cursor movement
}
// Find the top-most monitor in the vertical stack (same column)
HMONITOR topMonitor = nullptr;
@@ -722,22 +653,13 @@ public:
// *** FIXED HORIZONTAL WRAPPING LOGIC ***
// Move to opposite end of horizontal stack when hitting left/right edge
// Only wrap if there's NO adjacent monitor in the coordinate space (let Windows handle natural transitions)
// Only handle horizontal wrapping if we haven't already wrapped vertically
if (!wrapped && currentPos.x <= currentMonitorInfo.rcMonitor.left)
{
#ifdef _DEBUG
Logger::info(L"CursorWrap DEBUG: ======= HORIZONTAL WRAP: LEFT EDGE DETECTED =======");
#endif
// Check if there's an adjacent monitor to the left in coordinate space
if (HasAdjacentMonitorInCoordinateSpace(currentMonitorInfo.rcMonitor, 0))
{
#ifdef _DEBUG
Logger::info(L"CursorWrap DEBUG: SKIPPING WRAP - Adjacent monitor exists to the left (Windows will handle)");
#endif
return currentPos; // Let Windows handle natural cursor movement
}
// Find the right-most monitor in the horizontal stack (same row)
HMONITOR rightMonitor = nullptr;
@@ -790,15 +712,6 @@ public:
Logger::info(L"CursorWrap DEBUG: ======= HORIZONTAL WRAP: RIGHT EDGE DETECTED =======");
#endif
// Check if there's an adjacent monitor to the right in coordinate space
if (HasAdjacentMonitorInCoordinateSpace(currentMonitorInfo.rcMonitor, 1))
{
#ifdef _DEBUG
Logger::info(L"CursorWrap DEBUG: SKIPPING WRAP - Adjacent monitor exists to the right (Windows will handle)");
#endif
return currentPos; // Let Windows handle natural cursor movement
}
// Find the left-most monitor in the horizontal stack (same row)
HMONITOR leftMonitor = nullptr;
@@ -1068,104 +981,45 @@ void MonitorTopology::Initialize(const std::vector<MonitorInfo>& monitors)
}
else
{
// For more than 2 monitors, use edge-based alignment algorithm
// This ensures monitors with aligned edges (e.g., top edges at same Y) are grouped in same row
// Helper lambda to check if two ranges overlap or are adjacent (with tolerance)
auto rangesOverlapOrTouch = [](int start1, int end1, int start2, int end2, int tolerance = 50) -> bool {
// Check if ranges overlap or are within tolerance distance
return (start1 <= end2 + tolerance) && (start2 <= end1 + tolerance);
};
// Sort monitors by horizontal position (left edge) for column assignment
std::vector<const MonitorInfo*> monitorsByX;
for (const auto& monitor : monitors) {
monitorsByX.push_back(&monitor);
}
std::sort(monitorsByX.begin(), monitorsByX.end(), [](const MonitorInfo* a, const MonitorInfo* b) {
return a->rect.left < b->rect.left;
});
// Sort monitors by vertical position (top edge) for row assignment
std::vector<const MonitorInfo*> monitorsByY;
for (const auto& monitor : monitors) {
monitorsByY.push_back(&monitor);
}
std::sort(monitorsByY.begin(), monitorsByY.end(), [](const MonitorInfo* a, const MonitorInfo* b) {
return a->rect.top < b->rect.top;
});
// Assign rows based on vertical overlap - monitors that overlap vertically should be in same row
std::map<const MonitorInfo*, int> monitorToRow;
int currentRow = 0;
for (size_t i = 0; i < monitorsByY.size(); i++) {
const auto* monitor = monitorsByY[i];
// Check if this monitor overlaps vertically with any monitor already assigned to current row
bool foundOverlap = false;
for (size_t j = 0; j < i; j++) {
const auto* other = monitorsByY[j];
if (monitorToRow[other] == currentRow) {
// Check vertical overlap
if (rangesOverlapOrTouch(monitor->rect.top, monitor->rect.bottom,
other->rect.top, other->rect.bottom)) {
monitorToRow[monitor] = currentRow;
foundOverlap = true;
break;
}
}
}
if (!foundOverlap) {
// Start new row if no overlap found and we have room
if (currentRow < 2 && i < monitorsByY.size() - 1) {
currentRow++;
}
monitorToRow[monitor] = currentRow;
}
// For more than 2 monitors, use the general algorithm
RECT totalBounds = monitors[0].rect;
for (const auto& monitor : monitors)
{
totalBounds.left = min(totalBounds.left, monitor.rect.left);
totalBounds.top = min(totalBounds.top, monitor.rect.top);
totalBounds.right = max(totalBounds.right, monitor.rect.right);
totalBounds.bottom = max(totalBounds.bottom, monitor.rect.bottom);
}
// Assign columns based on horizontal position (left-to-right order)
// Monitors are already sorted by X coordinate (left edge)
std::map<const MonitorInfo*, int> monitorToCol;
int totalWidth = totalBounds.right - totalBounds.left;
int totalHeight = totalBounds.bottom - totalBounds.top;
int gridWidth = max(1, totalWidth / 3);
int gridHeight = max(1, totalHeight / 3);
// For horizontal arrangement, distribute monitors evenly across columns
if (monitorsByX.size() == 1) {
// Single monitor - place in middle column
monitorToCol[monitorsByX[0]] = 1;
}
else if (monitorsByX.size() == 2) {
// Two monitors - place at opposite ends for wrapping
monitorToCol[monitorsByX[0]] = 0; // Leftmost monitor
monitorToCol[monitorsByX[1]] = 2; // Rightmost monitor
}
else {
// Three or more monitors - distribute across grid
for (size_t i = 0; i < monitorsByX.size() && i < 3; i++) {
monitorToCol[monitorsByX[i]] = static_cast<int>(i);
}
// If more than 3 monitors, place extras in rightmost column
for (size_t i = 3; i < monitorsByX.size(); i++) {
monitorToCol[monitorsByX[i]] = 2;
}
}
// Place monitors in grid using the computed row/column assignments
// Place monitors in the 3x3 grid based on their center points
for (const auto& monitor : monitors)
{
HMONITOR hMonitor = MonitorFromRect(&monitor.rect, MONITOR_DEFAULTTONEAREST);
int row = monitorToRow[&monitor];
int col = monitorToCol[&monitor];
// Calculate center point of monitor
int centerX = (monitor.rect.left + monitor.rect.right) / 2;
int centerY = (monitor.rect.top + monitor.rect.bottom) / 2;
// Map to grid position
int col = (centerX - totalBounds.left) / gridWidth;
int row = (centerY - totalBounds.top) / gridHeight;
// Ensure we stay within bounds
col = max(0, min(2, col));
row = max(0, min(2, row));
grid[row][col] = hMonitor;
monitorToPosition[hMonitor] = {row, col, true};
positionToMonitor[{row, col}] = hMonitor;
#ifdef _DEBUG
Logger::info(L"CursorWrap DEBUG: Monitor {} placed at grid[{}][{}] (left={}, top={}, right={}, bottom={})",
monitor.monitorId, row, col,
monitor.rect.left, monitor.rect.top, monitor.rect.right, monitor.rect.bottom);
Logger::info(L"CursorWrap DEBUG: Monitor {} placed at grid[{}][{}], center=({}, {})",
monitor.monitorId, row, col, centerX, centerY);
#endif
}
}

View File

@@ -10,10 +10,10 @@ public struct ApplicationWrapper
{
public struct WindowPositionWrapper
{
[JsonPropertyName("X")]
[JsonPropertyName("x")]
public int X { get; set; }
[JsonPropertyName("Y")]
[JsonPropertyName("y")]
public int Y { get; set; }
[JsonPropertyName("width")]

View File

@@ -21,7 +21,7 @@ public sealed partial class BuiltInsCommandProvider : CommandProvider
public override ICommandItem[] TopLevelCommands() =>
[
new CommandItem(openSettings) { },
new CommandItem(_newExtension) { Title = _newExtension.Title },
new CommandItem(_newExtension) { Title = _newExtension.Title, Subtitle = Properties.Resources.builtin_new_extension_subtitle },
];
public override IFallbackCommandItem[] FallbackCommands() =>

View File

@@ -547,15 +547,6 @@ public partial class MainListPage : DynamicListPage,
// above "git" from "whatever"
max = max + extensionTitleMatch;
// Apply a penalty to fallback items so they rank below direct matches.
// Fallbacks that dynamically match queries (like RDP connections) should
// appear after apps and direct command matches.
if (isFallback && max > 1)
{
// Reduce fallback scores by 50% to prioritize direct matches
max = max * 0.5;
}
var matchSomething = max
+ (isAliasMatch ? 9001 : (isAliasSubstringMatch ? 1 : 0));

View File

@@ -134,9 +134,6 @@
ItemsSource="{x:Bind ViewModel.FilteredItems, Mode=OneWay}"
PreviewKeyDown="CommandsDropdown_PreviewKeyDown"
SelectionMode="Single">
<ListView.Resources>
<x:Boolean x:Key="ListViewItemSelectionIndicatorVisualEnabled">False</x:Boolean>
</ListView.Resources>
<ListView.ItemContainerStyle>
<Style BasedOn="{StaticResource DefaultListViewItemStyle}" TargetType="ListViewItem">
<Setter Property="MinHeight" Value="0" />

View File

@@ -99,6 +99,8 @@ public sealed partial class WrapPanel : Panel
set { SetValue(HorizontalSpacingProperty, value); }
}
private bool IsSectionItem(UIElement element) => element is FrameworkElement fe && fe.DataContext is ListItemViewModel item && item.IsSectionOrSeparator;
/// <summary>
/// Identifies the <see cref="HorizontalSpacing"/> dependency property.
/// </summary>
@@ -348,7 +350,7 @@ public sealed partial class WrapPanel : Panel
return;
}
var isFullLine = GetIsFullLine(child);
var isFullLine = IsSectionItem(child);
var desiredMeasure = new UvMeasure(Orientation, child.DesiredSize);
if (isFullLine)

View File

@@ -18,19 +18,8 @@ internal sealed partial class GridItemContainerStyleSelector : StyleSelector
public Style? Gallery { get; set; }
public Style? Section { get; set; }
public Style? Separator { get; set; }
protected override Style? SelectStyleCore(object item, DependencyObject container)
{
if (item is ListItemViewModel { IsSectionOrSeparator: true } listItem)
{
return string.IsNullOrWhiteSpace(listItem.Title)
? Separator!
: Section;
}
return GridProperties switch
{
SmallGridPropertiesViewModel => Small,

View File

@@ -1,28 +0,0 @@
// Copyright (c) Microsoft Corporation
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
using Microsoft.CmdPal.Core.ViewModels;
using Microsoft.UI.Xaml;
using Microsoft.UI.Xaml.Controls;
namespace Microsoft.CmdPal.UI;
internal sealed partial class ListItemContainerStyleSelector : StyleSelector
{
public Style? Default { get; set; }
public Style? Section { get; set; }
public Style? Separator { get; set; }
protected override Style? SelectStyleCore(object item, DependencyObject container)
{
return item switch
{
ListItemViewModel { IsSectionOrSeparator: true } listItemViewModel when string.IsNullOrWhiteSpace(listItemViewModel.Title) => Separator!,
ListItemViewModel { IsSectionOrSeparator: true } => Section,
_ => Default,
};
}
}

View File

@@ -27,10 +27,6 @@
<CornerRadius x:Key="SmallGridViewItemCornerRadius">8</CornerRadius>
<CornerRadius x:Key="MediumGridViewItemCornerRadius">8</CornerRadius>
<x:Double x:Key="ListViewItemMinHeight">40</x:Double>
<x:Double x:Key="ListViewSectionMinHeight">0</x:Double>
<x:Double x:Key="ListViewSeparatorMinHeight">0</x:Double>
<Style x:Key="IconGridViewItemStyle" TargetType="GridViewItem">
<Setter Property="HorizontalContentAlignment" Value="Stretch" />
<Setter Property="VerticalContentAlignment" Value="Center" />
@@ -98,7 +94,6 @@
<Style x:Key="GalleryGridViewItemStyle" TargetType="GridViewItem">
<Setter Property="HorizontalContentAlignment" Value="Stretch" />
<Setter Property="VerticalContentAlignment" Value="Center" />
<Setter Property="Margin" Value="0" />
<Setter Property="Template">
<Setter.Value>
<ControlTemplate TargetType="GridViewItem">
@@ -160,70 +155,6 @@
</Setter>
</Style>
<Style
x:Key="GridViewSectionItemStyle"
BasedOn="{StaticResource DefaultGridViewItemStyle}"
TargetType="GridViewItem">
<Setter Property="IsHitTestVisible" Value="False" />
<Setter Property="IsTabStop" Value="False" />
<Setter Property="IsHoldingEnabled" Value="False" />
<Setter Property="Padding" Value="4,8,12,0" />
<Setter Property="Margin" Value="0" />
<Setter Property="HorizontalContentAlignment" Value="Stretch" />
<Setter Property="VerticalContentAlignment" Value="Bottom" />
<Setter Property="MinHeight" Value="{StaticResource ListViewSectionMinHeight}" />
<Setter Property="cpcontrols:WrapPanel.IsFullLine" Value="True" />
</Style>
<Style
x:Key="GridViewSeparatorItemStyle"
BasedOn="{StaticResource DefaultGridViewItemStyle}"
TargetType="GridViewItem">
<Setter Property="IsHitTestVisible" Value="False" />
<Setter Property="IsTabStop" Value="False" />
<Setter Property="IsHoldingEnabled" Value="False" />
<Setter Property="Padding" Value="4,4,12,4" />
<Setter Property="Margin" Value="0" />
<Setter Property="HorizontalContentAlignment" Value="Stretch" />
<Setter Property="VerticalContentAlignment" Value="Center" />
<Setter Property="MinHeight" Value="{StaticResource ListViewSeparatorMinHeight}" />
<Setter Property="cpcontrols:WrapPanel.IsFullLine" Value="True" />
</Style>
<Style
x:Key="ListDefaultContainerStyle"
BasedOn="{StaticResource DefaultListViewItemStyle}"
TargetType="ListViewItem">
<Setter Property="MinHeight" Value="{StaticResource ListViewItemMinHeight}" />
</Style>
<Style
x:Key="ListSectionContainerStyle"
BasedOn="{StaticResource DefaultListViewItemStyle}"
TargetType="ListViewItem">
<Setter Property="IsHitTestVisible" Value="False" />
<Setter Property="IsTabStop" Value="False" />
<Setter Property="IsHoldingEnabled" Value="False" />
<Setter Property="Padding" Value="16,8,12,0" />
<Setter Property="HorizontalContentAlignment" Value="Stretch" />
<Setter Property="VerticalContentAlignment" Value="Bottom" />
<Setter Property="MinHeight" Value="{StaticResource ListViewSectionMinHeight}" />
<Setter Property="AllowDrop" Value="False" />
</Style>
<Style
x:Key="ListSeparatorContainerStyle"
BasedOn="{StaticResource DefaultListViewItemStyle}"
TargetType="ListViewItem">
<Setter Property="IsHitTestVisible" Value="False" />
<Setter Property="IsTabStop" Value="False" />
<Setter Property="IsHoldingEnabled" Value="False" />
<Setter Property="Padding" Value="16,4,12,4" />
<Setter Property="HorizontalContentAlignment" Value="Stretch" />
<Setter Property="VerticalContentAlignment" Value="Center" />
<Setter Property="MinHeight" Value="{StaticResource ListViewSeparatorMinHeight}" />
</Style>
<DataTemplate x:Key="TagTemplate" x:DataType="coreViewModels:TagViewModel">
<cpcontrols:Tag
AutomationProperties.Name="{x:Bind Text, Mode=OneWay}"
@@ -235,19 +166,6 @@
ToolTipService.ToolTip="{x:Bind ToolTip, Mode=OneWay}" />
</DataTemplate>
<cmdpalUI:ListItemTemplateSelector
x:Key="ListItemTemplateSelector"
x:DataType="coreViewModels:ListItemViewModel"
ListItem="{StaticResource ListItemViewModelTemplate}"
Section="{StaticResource ListSectionViewModelTemplate}"
Separator="{StaticResource ListSeparatorViewModelTemplate}" />
<cmdpalUI:ListItemContainerStyleSelector
x:Key="ListItemContainerStyleSelector"
Default="{StaticResource ListDefaultContainerStyle}"
Section="{StaticResource ListSectionContainerStyle}"
Separator="{StaticResource ListSeparatorContainerStyle}" />
<cmdpalUI:GridItemTemplateSelector
x:Key="GridItemTemplateSelector"
x:DataType="coreViewModels:ListItemViewModel"
@@ -255,16 +173,21 @@
GridProperties="{x:Bind ViewModel.GridProperties, Mode=OneWay}"
Medium="{StaticResource MediumGridItemViewModelTemplate}"
Section="{StaticResource ListSectionViewModelTemplate}"
Separator="{StaticResource GridSeparatorViewModelTemplate}"
Separator="{StaticResource ListSeparatorViewModelTemplate}"
Small="{StaticResource SmallGridItemViewModelTemplate}" />
<cmdpalUI:ListItemTemplateSelector
x:Key="ListItemTemplateSelector"
x:DataType="coreViewModels:ListItemViewModel"
ListItem="{StaticResource ListItemViewModelTemplate}"
Section="{StaticResource ListSectionViewModelTemplate}"
Separator="{StaticResource ListSeparatorViewModelTemplate}" />
<cmdpalUI:GridItemContainerStyleSelector
x:Key="GridItemContainerStyleSelector"
Gallery="{StaticResource GalleryGridViewItemStyle}"
GridProperties="{x:Bind ViewModel.GridProperties, Mode=OneWay}"
Medium="{StaticResource IconGridViewItemStyle}"
Section="{StaticResource GridViewSectionItemStyle}"
Separator="{StaticResource GridViewSeparatorItemStyle}"
Small="{StaticResource IconGridViewItemStyle}" />
<!-- https://learn.microsoft.com/windows/apps/design/controls/itemsview#specify-the-look-of-the-items -->
@@ -332,21 +255,21 @@
</DataTemplate>
<DataTemplate x:Key="ListSeparatorViewModelTemplate" x:DataType="coreViewModels:ListItemViewModel">
<Grid ColumnSpacing="12">
<Grid>
<Grid.ColumnDefinitions>
<ColumnDefinition Width="28" />
<ColumnDefinition Width="*" />
</Grid.ColumnDefinitions>
<Rectangle
Grid.Column="1"
Height="1"
Margin="0,2,0,2"
Fill="{ThemeResource DividerStrokeColorDefaultBrush}" />
</Grid>
</DataTemplate>
<DataTemplate x:Key="ListSectionViewModelTemplate" x:DataType="coreViewModels:ListItemViewModel">
<Grid
Margin="0,8,0,0"
Margin="0"
VerticalAlignment="Center"
cpcontrols:WrapPanel.IsFullLine="True"
ColumnSpacing="8"
@@ -358,9 +281,13 @@
</Grid.ColumnDefinitions>
<TextBlock
Grid.Column="0"
Foreground="{ThemeResource TextFillColorSecondaryBrush}"
Foreground="{ThemeResource TextFillColorDisabled}"
Style="{ThemeResource CaptionTextBlockStyle}"
Text="{x:Bind Section}" />
<Rectangle
Grid.Column="1"
Height="1"
Fill="{ThemeResource DividerStrokeColorDefaultBrush}" />
</Grid>
</DataTemplate>
@@ -487,7 +414,7 @@
VerticalAlignment="Center"
CharacterSpacing="11"
FontSize="11"
Foreground="{ThemeResource TextFillColorSecondaryBrush}"
Foreground="{ThemeResource TextFillColorTertiary}"
Text="{x:Bind Subtitle, Mode=OneWay}"
TextAlignment="Center"
TextTrimming="WordEllipsis"
@@ -496,10 +423,6 @@
</StackPanel>
</StackPanel>
</DataTemplate>
<DataTemplate x:Key="GridSeparatorViewModelTemplate" x:DataType="coreViewModels:ListItemViewModel">
<Rectangle Height="1" Fill="{ThemeResource DividerStrokeColorDefaultBrush}" />
</DataTemplate>
</Page.Resources>
<Grid>
@@ -525,7 +448,6 @@
IsDoubleTapEnabled="True"
IsItemClickEnabled="True"
ItemClick="Items_ItemClick"
ItemContainerStyleSelector="{StaticResource ListItemContainerStyleSelector}"
ItemTemplateSelector="{StaticResource ListItemTemplateSelector}"
ItemsSource="{x:Bind ViewModel.FilteredItems, Mode=OneWay}"
RightTapped="Items_RightTapped"
@@ -538,7 +460,7 @@
<controls:Case Value="True">
<GridView
x:Name="ItemsGrid"
Padding="16,16"
Padding="16,0"
CanDragItems="True"
ContextCanceled="Items_OnContextCanceled"
ContextRequested="Items_OnContextRequested"
@@ -555,10 +477,7 @@
SelectionChanged="Items_SelectionChanged">
<GridView.ItemsPanel>
<ItemsPanelTemplate>
<cpcontrols:WrapPanel
HorizontalSpacing="8"
Orientation="Horizontal"
VerticalSpacing="8" />
<cpcontrols:WrapPanel HorizontalSpacing="8" Orientation="Horizontal" />
</ItemsPanelTemplate>
</GridView.ItemsPanel>
<GridView.ItemContainerTransitions>

View File

@@ -18,9 +18,6 @@ internal static class BindTransformers
public static Visibility EmptyOrWhitespaceToCollapsed(string? input)
=> string.IsNullOrWhiteSpace(input) ? Visibility.Collapsed : Visibility.Visible;
public static Visibility EmptyOrWhitespaceToVisible(string? input)
=> string.IsNullOrWhiteSpace(input) ? Visibility.Visible : Visibility.Collapsed;
public static Visibility VisibleWhenAny(bool value1, bool value2)
=> (value1 || value2) ? Visibility.Visible : Visibility.Collapsed;
}

View File

@@ -2,6 +2,7 @@
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
using System.Reflection;
using System.Runtime.CompilerServices;
namespace Microsoft.CmdPal.UI.Helpers;
@@ -17,41 +18,19 @@ internal static class BuildInfo
// Runtime AOT detection
public static bool IsNativeAot => !RuntimeFeature.IsDynamicCodeSupported;
// build-time values
public static bool PublishTrimmed
{
get
{
#if BUILD_INFO_PUBLISH_TRIMMED
return true;
#else
return false;
#endif
}
}
// From assembly metadata (build-time values)
public static bool PublishTrimmed => GetBoolMetadata("PublishTrimmed", false);
// build-time values
public static bool PublishAot
{
get
{
#if BUILD_INFO_PUBLISH_AOT
return true;
#else
return false;
#endif
}
}
// From assembly metadata (build-time values)
public static bool PublishAot => GetBoolMetadata("PublishAot", false);
public static bool IsCiBuild
{
get
{
#if BUILD_INFO_CIBUILD
return true;
#else
return false;
#endif
}
}
public static bool IsCiBuild => GetBoolMetadata("CIBuild", false);
private static string? GetMetadata(string key) =>
Assembly.GetExecutingAssembly()
.GetCustomAttributes<AssemblyMetadataAttribute>()
.FirstOrDefault(a => a.Key == key)?.Value;
private static bool GetBoolMetadata(string key, bool defaultValue) =>
bool.TryParse(GetMetadata(key), out var result) ? result : defaultValue;
}

View File

@@ -53,7 +53,7 @@
<PropertyGroup>
<!-- This disables the auto-generated main, so we can be single-instanced -->
<DefineConstants>$(DefineConstants);DISABLE_XAML_GENERATED_MAIN</DefineConstants>
<DefineConstants>DISABLE_XAML_GENERATED_MAIN</DefineConstants>
</PropertyGroup>
<!-- BODGY: XES Versioning and WinAppSDK get into a fight about the app manifest, which breaks WinAppSDK. -->
@@ -291,15 +291,24 @@
</ItemGroup>
<!-- </AdaptiveCardsWorkaround> -->
<!-- Build information -->
<PropertyGroup Condition=" '$(PublishAot)' == 'true' ">
<DefineConstants>$(DefineConstants);BUILD_INFO_PUBLISH_AOT</DefineConstants>
</PropertyGroup>
<PropertyGroup Condition=" '$(PublishTrimmed)' == 'true' ">
<DefineConstants>$(DefineConstants);BUILD_INFO_PUBLISH_TRIMMED</DefineConstants>
</PropertyGroup>
<PropertyGroup Condition=" '$(CIBuild)' == 'true' ">
<DefineConstants>$(DefineConstants);BUILD_INFO_CIBUILD</DefineConstants>
</PropertyGroup>
<!-- Metadata for build information -->
<ItemGroup>
<AssemblyAttribute Include="System.Reflection.AssemblyMetadataAttribute">
<_Parameter1>PublishTrimmed</_Parameter1>
<_Parameter2>$(PublishTrimmed)</_Parameter2>
</AssemblyAttribute>
<AssemblyAttribute Include="System.Reflection.AssemblyMetadataAttribute">
<_Parameter1>PublishAot</_Parameter1>
<_Parameter2>$(PublishAot)</_Parameter2>
</AssemblyAttribute>
<AssemblyAttribute Include="System.Reflection.AssemblyMetadataAttribute">
<_Parameter1>CIBuild</_Parameter1>
<_Parameter2>$(CIBuild)</_Parameter2>
</AssemblyAttribute>
<AssemblyAttribute Include="System.Reflection.AssemblyMetadataAttribute">
<_Parameter1>CommandPaletteBranding</_Parameter1>
<_Parameter2>$(CommandPaletteBranding)</_Parameter2>
</AssemblyAttribute>
</ItemGroup>
</Project>

View File

@@ -126,16 +126,16 @@
</DataTemplate>
<DataTemplate x:Key="DetailsSeparatorTemplate" x:DataType="coreViewModels:DetailsSeparatorViewModel">
<StackPanel Margin="0,8,8,0" Orientation="Vertical">
<TextBlock
Margin="0,8,0,0"
Style="{StaticResource SeparatorKeyTextBlockStyle}"
Text="{x:Bind Key, Mode=OneWay}"
Visibility="{x:Bind help:BindTransformers.EmptyOrWhitespaceToCollapsed(Key), Mode=OneWay, FallbackValue=Collapsed}" />
<Border
Margin="0,0,0,0"
BorderBrush="{ThemeResource DividerStrokeColorDefaultBrush}"
BorderThickness="0,0,0,1"
Visibility="{x:Bind help:BindTransformers.EmptyOrWhitespaceToVisible(Key), Mode=OneWay, FallbackValue=Collapsed}" />
BorderThickness="0,0,0,2">
<TextBlock
Margin="0,0,0,0"
Style="{StaticResource SeparatorKeyTextBlockStyle}"
Text="{x:Bind Key, Mode=OneWay}"
Visibility="{x:Bind help:BindTransformers.EmptyOrWhitespaceToCollapsed(Key), FallbackValue=Collapsed}" />
</Border>
</StackPanel>
</DataTemplate>
<DataTemplate x:Key="DetailsTagsTemplate" x:DataType="coreViewModels:DetailsTagsViewModel">

View File

@@ -2,10 +2,6 @@
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
using System.Diagnostics;
using System.Globalization;
using ManagedCommon;
using Microsoft.CmdPal.UI.Helpers;
using Microsoft.CmdPal.UI.ViewModels;
using Microsoft.CmdPal.UI.ViewModels.Services;
using Microsoft.Extensions.DependencyInjection;
@@ -34,56 +30,8 @@ public sealed partial class GeneralPage : Page
{
get
{
var versionNo = ResourceLoaderInstance.GetString("Settings_GeneralPage_VersionNo");
if (!TryGetPackagedVersion(out var version) && !TryGetAssemblyVersion(out version))
{
version = "?";
}
return string.Format(CultureInfo.CurrentCulture, versionNo, version);
}
}
private static bool TryGetPackagedVersion(out string version)
{
version = string.Empty;
try
{
// Package.Current throws InvalidOperationException if the app is not packaged
var v = Package.Current.Id.Version;
version = $"{v.Major}.{v.Minor}.{v.Build}.{v.Revision}";
return true;
}
catch (InvalidOperationException)
{
return false;
}
catch (Exception ex)
{
Logger.LogError("Failed to get version from the package", ex);
return false;
}
}
private static bool TryGetAssemblyVersion(out string version)
{
version = string.Empty;
try
{
var processPath = Environment.ProcessPath;
if (string.IsNullOrEmpty(processPath))
{
return false;
}
var info = FileVersionInfo.GetVersionInfo(processPath);
version = $"{info.FileMajorPart}.{info.FileMinorPart}.{info.FileBuildPart}.{info.FilePrivatePart}";
return true;
}
catch (Exception ex)
{
Logger.LogError("Failed to get version from the executable", ex);
return false;
var version = Package.Current.Id.Version;
return $"Version {version.Major}.{version.Minor}.{version.Build}.{version.Revision}";
}
}
}

View File

@@ -372,7 +372,7 @@ Right-click to remove the key combination, thereby deactivating the shortcut.</v
<value>Windows Command Palette</value>
</data>
<data name="Settings_GeneralPage_About_SettingsExpander.Description" xml:space="preserve">
<value>© 2026. All rights reserved.</value>
<value>© 2025. All rights reserved.</value>
</data>
<data name="Settings_GeneralPage_About_GithubLink_Hyperlink.Content" xml:space="preserve">
<value>View GitHub repository</value>
@@ -724,7 +724,4 @@ Right-click to remove the key combination, thereby deactivating the shortcut.</v
<data name="Settings_ExtensionsPage_More_ReorderFallbacks_MenuFlyoutItem.Text" xml:space="preserve">
<value>Manage fallback order</value>
</data>
<data name="Settings_GeneralPage_VersionNo" xml:space="preserve">
<value>Version {0}</value>
</data>
</root>

View File

@@ -90,5 +90,20 @@ namespace Microsoft.CmdPal.Ext.TimeDate.UnitTests
// Assert
Assert.IsFalse(string.IsNullOrEmpty(displayName));
}
[TestMethod]
public void GetTranslatedPluginDescriptionTest()
{
// Setup
var provider = new TimeDateCommandsProvider();
// Act
var commands = provider.TopLevelCommands();
var subtitle = commands[0].Subtitle;
// Assert
Assert.IsFalse(string.IsNullOrEmpty(subtitle));
Assert.IsTrue(subtitle.Contains("Show time and date values in different formats"));
}
}
}

View File

@@ -55,7 +55,7 @@ public class BasicTests : CommandPaletteTestBase
SetTimeAndDaterExtensionSearchBox("year");
Assert.IsNotNull(this.Find<NavigationViewItem>("2026"));
Assert.IsNotNull(this.Find<NavigationViewItem>("2025"));
}
[TestMethod]

View File

@@ -5,7 +5,7 @@
<XesUseOneStoreVersioning>true</XesUseOneStoreVersioning>
<XesBaseYearForStoreVersion>2025</XesBaseYearForStoreVersion>
<VersionMajor>0</VersionMajor>
<VersionMinor>8</VersionMinor>
<VersionMinor>7</VersionMinor>
<VersionInfoProductName>Microsoft Command Palette</VersionInfoProductName>
</PropertyGroup>
</Project>

View File

@@ -15,6 +15,7 @@ public partial class CalculatorCommandProvider : CommandProvider
private static ISettingsInterface settings = new SettingsManager();
private readonly ListItem _listItem = new(new CalculatorListPage(settings))
{
Subtitle = Resources.calculator_top_level_subtitle,
MoreCommands = [new CommandContextItem(((SettingsManager)settings).Settings.SettingsPage)],
};

View File

@@ -19,6 +19,7 @@ public partial class ClipboardHistoryCommandsProvider : CommandProvider
_clipboardHistoryListItem = new ListItem(new ClipboardHistoryListPage(_settingsManager))
{
Title = Properties.Resources.list_item_title,
Subtitle = Properties.Resources.list_item_subtitle,
Icon = Icons.ClipboardListIcon,
MoreCommands = [
new CommandContextItem(_settingsManager.Settings.SettingsPage),

View File

@@ -2,7 +2,6 @@
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
using System;
using System.Collections.Generic;
using System.Globalization;
using Microsoft.CommandPalette.Extensions;
@@ -42,19 +41,15 @@ internal sealed partial class FancyZonesMonitorListItem : ListItem
public static Details BuildMonitorDetails(FancyZonesMonitorDescriptor monitor)
{
var currentVirtualDesktop = FancyZonesVirtualDesktop.GetCurrentVirtualDesktopIdString();
// Calculate physical resolution from logical pixels and DPI
var scaleFactor = monitor.Data.Dpi > 0 ? monitor.Data.Dpi / 96.0 : 1.0;
var physicalWidth = (int)Math.Round(monitor.Data.MonitorWidth * scaleFactor);
var physicalHeight = (int)Math.Round(monitor.Data.MonitorHeight * scaleFactor);
var resolution = $"{physicalWidth}\u00D7{physicalHeight}";
var tags = new List<IDetailsElement>
{
DetailTag(Resources.FancyZones_Monitor, monitor.Data.Monitor),
DetailTag(Resources.FancyZones_Instance, monitor.Data.MonitorInstanceId),
DetailTag(Resources.FancyZones_Serial, monitor.Data.MonitorSerialNumber),
DetailTag(Resources.FancyZones_Number, monitor.Data.MonitorNumber.ToString(CultureInfo.InvariantCulture)),
DetailTag(Resources.FancyZones_VirtualDesktop, currentVirtualDesktop),
DetailTag(Resources.FancyZones_Resolution, resolution),
DetailTag(Resources.FancyZones_WorkArea, $"{monitor.Data.LeftCoordinate},{monitor.Data.TopCoordinate} {monitor.Data.WorkAreaWidth}\u00D7{monitor.Data.WorkAreaHeight}"),
DetailTag(Resources.FancyZones_Resolution, $"{monitor.Data.MonitorWidth}\u00D7{monitor.Data.MonitorHeight}"),
DetailTag(Resources.FancyZones_DPI, monitor.Data.Dpi.ToString(CultureInfo.InvariantCulture)),
};

View File

@@ -41,13 +41,16 @@ internal static class FancyZonesDataService
try
{
// Request FancyZones to save current monitor configuration.
// The editor-parameters.json file is only written when:
// 1. Opening the FancyZones Editor
// 2. Receiving the WM_PRIV_SAVE_EDITOR_PARAMETERS message
// Without this, monitor changes (plug/unplug) won't be reflected in the file.
var editorParams = ReadEditorParametersWithRefresh();
Logger.LogInfo($"TryGetMonitors: ReadEditorParametersWithRefreshWithRefresh returned. Monitors={editorParams.Monitors?.Count ?? -1}");
if (!File.Exists(FZPaths.EditorParameters))
{
error = Resources.FancyZones_MonitorDataNotFound;
Logger.LogWarning($"TryGetMonitors: File not found. Path={FZPaths.EditorParameters}");
return false;
}
Logger.LogInfo($"TryGetMonitors: File exists, reading...");
var editorParams = FancyZonesDataIO.ReadEditorParameters();
Logger.LogInfo($"TryGetMonitors: ReadEditorParameters returned. Monitors={editorParams.Monitors?.Count ?? -1}");
var editorMonitors = editorParams.Monitors;
if (editorMonitors is null || editorMonitors.Count == 0)
@@ -71,23 +74,6 @@ internal static class FancyZonesDataService
}
}
/// <summary>
/// Requests FancyZones to save the current monitor configuration and reads the file.
/// This is a best-effort approach for performance: we send the save request and immediately
/// read the file without waiting. If the file hasn't been updated yet, the next call will
/// see the updated data since FancyZones processes the message asynchronously.
/// </summary>
private static EditorParameters.ParamsWrapper ReadEditorParametersWithRefresh()
{
// Request FancyZones to save the current monitor configuration.
// This is fire-and-forget for performance - we don't wait for the save to complete.
// If this is the first call after a monitor change, we may read stale data, but the
// next call will see the updated file since FancyZones will have processed the message.
FancyZonesNotifier.NotifySaveEditorParameters();
return FancyZonesDataIO.ReadEditorParameters();
}
public static IReadOnlyList<FancyZonesLayoutDescriptor> GetLayouts()
{
Logger.LogInfo($"GetLayouts: Starting. LayoutTemplatesPath={FZPaths.LayoutTemplates} CustomLayoutsPath={FZPaths.CustomLayouts}");

View File

@@ -19,12 +19,8 @@ internal readonly record struct FancyZonesMonitorDescriptor(
{
get
{
// MonitorWidth/Height are logical (DPI-scaled) pixels, calculate physical resolution
var scaleFactor = Data.Dpi > 0 ? Data.Dpi / 96.0 : 1.0;
var physicalWidth = (int)Math.Round(Data.MonitorWidth * scaleFactor);
var physicalHeight = (int)Math.Round(Data.MonitorHeight * scaleFactor);
var size = $"{physicalWidth}×{physicalHeight}";
var scaling = Data.Dpi > 0 ? string.Format(CultureInfo.InvariantCulture, "{0}%", (int)Math.Round(scaleFactor * 100)) : "n/a";
var size = $"{Data.MonitorWidth}×{Data.MonitorHeight}";
var scaling = Data.Dpi > 0 ? string.Format(CultureInfo.InvariantCulture, "{0}%", (int)Math.Round(Data.Dpi * 100 / 96.0)) : "n/a";
return $"{size} \u2022 {scaling}";
}
}

View File

@@ -10,25 +10,13 @@ namespace PowerToysExtension.Helpers;
internal static class FancyZonesNotifier
{
private const string AppliedLayoutsFileUpdateMessage = "{2ef2c8a7-e0d5-4f31-9ede-52aade2d284d}";
private const string SaveEditorParametersMessage = "{d8f9c0e3-5d77-4e83-8a4f-7c704c2bfb4a}";
private static readonly uint WmPrivAppliedLayoutsFileUpdate = RegisterWindowMessageW(AppliedLayoutsFileUpdateMessage);
private static readonly uint WmPrivSaveEditorParameters = RegisterWindowMessageW(SaveEditorParametersMessage);
public static void NotifyAppliedLayoutsChanged()
{
_ = PostMessageW(new IntPtr(0xFFFF), WmPrivAppliedLayoutsFileUpdate, UIntPtr.Zero, IntPtr.Zero);
}
/// <summary>
/// Notifies FancyZones to save the current monitor configuration to editor-parameters.json.
/// This is needed because FancyZones only writes this file when opening the editor or when explicitly requested.
/// </summary>
public static void NotifySaveEditorParameters()
{
_ = PostMessageW(new IntPtr(0xFFFF), WmPrivSaveEditorParameters, UIntPtr.Zero, IntPtr.Zero);
}
[DllImport("user32.dll", SetLastError = true, CharSet = CharSet.Unicode)]
private static extern uint RegisterWindowMessageW(string lpString);

View File

@@ -485,21 +485,12 @@ internal static class FancyZonesThumbnailRenderer
private static List<NormalizedRect> GetFocusRects(int zoneCount)
{
// Focus layout parameters from FancyZonesEditor CanvasLayoutModel:
// - DefaultOffset = 100px from top-left (normalized: ~0.05 for typical screen)
// - OffsetShift = 50px per zone (normalized: ~0.025)
// - ZoneSizeMultiplier = 0.4 (zones are 40% of screen)
zoneCount = Math.Clamp(zoneCount, 1, 8);
var rects = new List<NormalizedRect>(zoneCount);
const float defaultOffset = 0.05f; // ~100px on 1920px screen
const float offsetShift = 0.025f; // ~50px on 1920px screen
const float zoneSize = 0.4f; // 40% of screen
for (var i = 0; i < zoneCount; i++)
{
var offset = i * offsetShift;
rects.Add(new NormalizedRect(defaultOffset + offset, defaultOffset + offset, zoneSize, zoneSize));
var offset = i * 0.06f;
rects.Add(new NormalizedRect(0.1f + offset, 0.1f + offset, 0.8f, 0.8f));
}
return rects;

View File

@@ -7,7 +7,6 @@
<OutputType>WinExe</OutputType>
<RootNamespace>PowerToysExtension</RootNamespace>
<ApplicationManifest>app.manifest</ApplicationManifest>
<ApplicationIcon>..\..\..\..\runner\svgs\icon.ico</ApplicationIcon>
<PublishProfile>win-$(Platform).pubxml</PublishProfile>
<EnableMsixTooling>false</EnableMsixTooling>
<WindowsPackageType>None</WindowsPackageType>

View File

@@ -31,6 +31,7 @@ public partial class RemoteDesktopCommandProvider : CommandProvider
listPageCommand = new CommandItem(listPage)
{
Subtitle = Resources.remotedesktop_subtitle,
Icon = Icons.RDPIcon,
MoreCommands = [
new CommandContextItem(settingsManager.Settings.SettingsPage),

View File

@@ -39,6 +39,7 @@ public partial class ShellCommandsProvider : CommandProvider
{
Icon = Icons.RunV2Icon,
Title = Resources.shell_command_name,
Subtitle = Resources.cmd_plugin_description,
MoreCommands = [
new CommandContextItem(Settings.SettingsPage),
],

View File

@@ -28,6 +28,7 @@ public sealed partial class TimeDateCommandsProvider : CommandProvider
{
Icon = _timeDateExtensionPage.Icon,
Title = Resources.Microsoft_plugin_timedate_plugin_name,
Subtitle = GetTranslatedPluginDescription(),
MoreCommands = [new CommandContextItem(_settingsManager.Settings.SettingsPage)],
};

View File

@@ -26,6 +26,7 @@ public partial class WindowWalkerCommandsProvider : CommandProvider
_windowWalkerPageItem = new CommandItem(new WindowWalkerListPage())
{
Title = Resources.window_walker_top_level_command_title,
Subtitle = Resources.windowwalker_name,
MoreCommands = [
new CommandContextItem(Settings.SettingsPage),
],

View File

@@ -30,6 +30,7 @@ public sealed partial class WindowsSettingsCommandsProvider : CommandProvider
_searchSettingsListItem = new CommandItem(new WindowsSettingsListPage(_windowsSettings))
{
Title = Resources.settings_title,
Subtitle = Resources.settings_subtitle,
};
_fallback = new(_windowsSettings);

View File

@@ -35,10 +35,6 @@ internal sealed partial class SectionsIndexPage : ListPage
{
Title = "A Gallery grid page with sections",
},
new ListItem(new SampleListPageWithSections(new GalleryGridLayout() { ShowTitle = false, ShowSubtitle = false }))
{
Title = "A Gallery grid page without labels with sections",
},
];
}
}

View File

@@ -2,7 +2,6 @@
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
using System.Runtime.CompilerServices;
using Windows.Foundation;
namespace Microsoft.CommandPalette.Extensions.Toolkit;
@@ -15,7 +14,7 @@ public partial class BaseObservable : INotifyPropChanged
{
public event TypedEventHandler<object, IPropChangedEventArgs>? PropChanged;
protected void OnPropertyChanged([CallerMemberName] string? propertyName = null)
protected void OnPropertyChanged(string propertyName)
{
try
{
@@ -23,37 +22,10 @@ public partial class BaseObservable : INotifyPropChanged
// this can crash as we try to invoke the handlers from that process.
// However, just catching it seems to still raise the event on the
// new host?
PropChanged?.Invoke(this, new PropChangedEventArgs(propertyName!));
PropChanged?.Invoke(this, new PropChangedEventArgs(propertyName));
}
catch
{
}
}
/// <summary>
/// Sets the backing field to the specified value and raises a property changed
/// notification if the value is different from the current one.
/// </summary>
/// <typeparam name="T">The type of the property.</typeparam>
/// <param name="field">A reference to the backing field for the property.</param>
/// <param name="value">The new value to assign to the property.</param>
/// <param name="propertyName">
/// The name of the property. This is optional and is usually supplied
/// automatically by the <see cref="CallerMemberNameAttribute"/>.
/// </param>
/// <returns>
/// <see langword="true"/> if the field was updated and a property changed
/// notification was raised; otherwise, <see langword="false"/>.
/// </returns>
protected bool SetProperty<T>(ref T field, T value, [CallerMemberName] string? propertyName = null)
{
if (EqualityComparer<T>.Default.Equals(field, value))
{
return false;
}
field = value;
OnPropertyChanged(propertyName!);
return true;
}
}

View File

@@ -6,11 +6,31 @@ namespace Microsoft.CommandPalette.Extensions.Toolkit;
public partial class Command : BaseObservable, ICommand
{
public virtual string Name { get; set => SetProperty(ref field, value); } = string.Empty;
public virtual string Name
{
get;
set
{
field = value;
OnPropertyChanged(nameof(Name));
}
}
= string.Empty;
public virtual string Id { get; set; } = string.Empty;
public virtual IconInfo Icon { get; set => SetProperty(ref field, value); } = new();
public virtual IconInfo Icon
{
get;
set
{
field = value;
OnPropertyChanged(nameof(Icon));
}
}
= new();
IIconInfo ICommand.Icon => Icon;
}

View File

@@ -6,9 +6,9 @@ namespace Microsoft.CommandPalette.Extensions.Toolkit;
public partial class CommandContextItem : CommandItem, ICommandContextItem
{
public virtual bool IsCritical { get; set => SetProperty(ref field, value); }
public virtual bool IsCritical { get; set; }
public virtual KeyChord RequestedShortcut { get; set => SetProperty(ref field, value); }
public virtual KeyChord RequestedShortcut { get; set; }
public CommandContextItem(ICommand command)
: base(command)

View File

@@ -19,36 +19,44 @@ public partial class CommandItem : BaseObservable, ICommandItem, IExtendedAttrib
private DataPackage? _dataPackage;
private DataPackageView? _dataPackageView;
public virtual IIconInfo? Icon { get; set => SetProperty(ref field, value); }
public virtual IIconInfo? Icon
{
get => field;
set
{
field = value;
OnPropertyChanged(nameof(Icon));
}
}
public virtual string Title
{
get => !string.IsNullOrEmpty(_title) ? _title : _command?.Name ?? string.Empty;
set
{
var oldTitle = Title;
_title = value;
if (Title != oldTitle)
{
OnPropertyChanged();
}
OnPropertyChanged(nameof(Title));
}
}
public virtual string Subtitle { get; set => SetProperty(ref field, value); } = string.Empty;
public virtual string Subtitle
{
get;
set
{
field = value;
OnPropertyChanged(nameof(Subtitle));
}
}
= string.Empty;
public virtual ICommand? Command
{
get => _command;
set
{
if (EqualityComparer<ICommand?>.Default.Equals(value, _command))
{
return;
}
var oldTitle = Title;
if (_commandListener is not null)
{
_commandListener.Detach();
@@ -63,8 +71,8 @@ public partial class CommandItem : BaseObservable, ICommandItem, IExtendedAttrib
value.PropChanged += _commandListener.OnEvent;
}
OnPropertyChanged();
if (string.IsNullOrEmpty(_title) && oldTitle != Title)
OnPropertyChanged(nameof(Command));
if (string.IsNullOrEmpty(_title))
{
OnPropertyChanged(nameof(Title));
}
@@ -80,7 +88,17 @@ public partial class CommandItem : BaseObservable, ICommandItem, IExtendedAttrib
}
}
public virtual IContextItem[] MoreCommands { get; set => SetProperty(ref field, value); } = [];
public virtual IContextItem[] MoreCommands
{
get;
set
{
field = value;
OnPropertyChanged(nameof(MoreCommands));
}
}
= [];
public DataPackage? DataPackage
{

View File

@@ -6,9 +6,9 @@ namespace Microsoft.CommandPalette.Extensions.Toolkit;
public partial class CommandResult : ICommandResult
{
public ICommandResultArgs? Args { get; private init; }
public ICommandResultArgs? Args { get; private set; }
public CommandResultKind Kind { get; private init; } = CommandResultKind.Dismiss;
public CommandResultKind Kind { get; private set; } = CommandResultKind.Dismiss;
public static CommandResult Dismiss()
{

View File

@@ -10,9 +10,17 @@ public abstract partial class ContentPage : Page, IContentPage
{
public event TypedEventHandler<object, IItemsChangedEventArgs>? ItemsChanged;
public virtual IDetails? Details { get; set => SetProperty(ref field, value); }
public virtual IDetails? Details
{
get => field;
set
{
field = value;
OnPropertyChanged(nameof(Details));
}
}
public virtual IContextItem[] Commands { get; set => SetProperty(ref field, value); } = [];
public virtual IContextItem[] Commands { get; set; } = [];
public abstract IContent[] GetContent();

View File

@@ -7,15 +7,65 @@ namespace Microsoft.CommandPalette.Extensions.Toolkit;
public partial class Details : BaseObservable, IDetails, IExtendedAttributesProvider
{
public virtual IIconInfo HeroImage { get; set => SetProperty(ref field, value); } = new IconInfo();
public virtual IIconInfo HeroImage
{
get => field;
set
{
field = value;
OnPropertyChanged(nameof(HeroImage));
}
}
public virtual string Title { get; set => SetProperty(ref field, value); } = string.Empty;
= new IconInfo();
public virtual string Body { get; set => SetProperty(ref field, value); } = string.Empty;
public virtual string Title
{
get;
set
{
field = value;
OnPropertyChanged(nameof(Title));
}
}
public virtual IDetailsElement[] Metadata { get; set => SetProperty(ref field, value); } = [];
= string.Empty;
public virtual ContentSize Size { get; set => SetProperty(ref field, value); } = ContentSize.Small;
public virtual string Body
{
get;
set
{
field = value;
OnPropertyChanged(nameof(Body));
}
}
= string.Empty;
public virtual IDetailsElement[] Metadata
{
get;
set
{
field = value;
OnPropertyChanged(nameof(Metadata));
}
}
= [];
public virtual ContentSize Size
{
get;
set
{
field = value;
OnPropertyChanged(nameof(Size));
}
}
= ContentSize.Small;
public IDictionary<string, object>? GetProperties() => new ValueSet()
{

View File

@@ -6,9 +6,39 @@ namespace Microsoft.CommandPalette.Extensions.Toolkit;
public partial class Filter : BaseObservable, IFilter
{
public virtual IIconInfo Icon { get; set => SetProperty(ref field, value); } = new IconInfo();
public virtual IIconInfo Icon
{
get => field;
set
{
field = value;
OnPropertyChanged(nameof(Icon));
}
}
public virtual string Id { get; set => SetProperty(ref field, value); } = string.Empty;
= new IconInfo();
public virtual string Name { get; set => SetProperty(ref field, value); } = string.Empty;
public virtual string Id
{
get;
set
{
field = value;
OnPropertyChanged(nameof(Id));
}
}
= string.Empty;
public virtual string Name
{
get;
set
{
field = value;
OnPropertyChanged(nameof(Name));
}
}
= string.Empty;
}

View File

@@ -6,7 +6,17 @@ namespace Microsoft.CommandPalette.Extensions.Toolkit;
public abstract partial class Filters : BaseObservable, IFilters
{
public string CurrentFilterId { get; set => SetProperty(ref field, value); } = string.Empty;
public string CurrentFilterId
{
get => field;
set
{
field = value;
OnPropertyChanged(nameof(CurrentFilterId));
}
}
= string.Empty;
// This method should be overridden in derived classes to provide the actual filters.
public abstract IFilterItem[] GetFilters();

View File

@@ -6,11 +6,41 @@ namespace Microsoft.CommandPalette.Extensions.Toolkit;
public partial class FormContent : BaseObservable, IFormContent
{
public virtual string DataJson { get; set => SetProperty(ref field, value); } = string.Empty;
public virtual string DataJson
{
get;
set
{
field = value;
OnPropertyChanged(nameof(DataJson));
}
}
public virtual string StateJson { get; set => SetProperty(ref field, value); } = string.Empty;
= string.Empty;
public virtual string TemplateJson { get; set => SetProperty(ref field, value); } = string.Empty;
public virtual string StateJson
{
get;
set
{
field = value;
OnPropertyChanged(nameof(StateJson));
}
}
= string.Empty;
public virtual string TemplateJson
{
get;
set
{
field = value;
OnPropertyChanged(nameof(TemplateJson));
}
}
= string.Empty;
public virtual ICommandResult SubmitForm(string inputs, string data) => SubmitForm(inputs);

View File

@@ -6,7 +6,27 @@ namespace Microsoft.CommandPalette.Extensions.Toolkit;
public partial class GalleryGridLayout : BaseObservable, IGalleryGridLayout
{
public virtual bool ShowTitle { get; set => SetProperty(ref field, value); } = true;
public virtual bool ShowTitle
{
get => field;
set
{
field = value;
OnPropertyChanged(nameof(ShowTitle));
}
}
public virtual bool ShowSubtitle { get; set => SetProperty(ref field, value); } = true;
= true;
public virtual bool ShowSubtitle
{
get => field;
set
{
field = value;
OnPropertyChanged(nameof(ShowSubtitle));
}
}
= true;
}

View File

@@ -2,6 +2,7 @@
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
using System.Diagnostics.CodeAnalysis;
using Windows.Storage.Streams;
namespace Microsoft.CommandPalette.Extensions.Toolkit;

View File

@@ -6,13 +6,51 @@ namespace Microsoft.CommandPalette.Extensions.Toolkit;
public partial class ListItem : CommandItem, IListItem
{
public virtual ITag[] Tags { get; set => SetProperty(ref field, value); } = [];
private ITag[] _tags = [];
private IDetails? _details;
public virtual IDetails? Details { get; set => SetProperty(ref field, value); }
private string _section = string.Empty;
private string _textToSuggest = string.Empty;
public virtual string Section { get; set => SetProperty(ref field, value); } = string.Empty;
public virtual ITag[] Tags
{
get => _tags;
set
{
_tags = value;
OnPropertyChanged(nameof(Tags));
}
}
public virtual string TextToSuggest { get; set => SetProperty(ref field, value); } = string.Empty;
public virtual IDetails? Details
{
get => _details;
set
{
_details = value;
OnPropertyChanged(nameof(Details));
}
}
public virtual string Section
{
get => _section;
set
{
_section = value;
OnPropertyChanged(nameof(Section));
}
}
public virtual string TextToSuggest
{
get => _textToSuggest;
set
{
_textToSuggest = value;
OnPropertyChanged(nameof(TextToSuggest));
}
}
public ListItem(ICommand command)
: base(command)

View File

@@ -8,23 +8,85 @@ namespace Microsoft.CommandPalette.Extensions.Toolkit;
public partial class ListPage : Page, IListPage
{
private string _placeholderText = string.Empty;
private string _searchText = string.Empty;
private bool _showDetails;
private bool _hasMore;
private IFilters? _filters;
private IGridProperties? _gridProperties;
private ICommandItem? _emptyContent;
public event TypedEventHandler<object, IItemsChangedEventArgs>? ItemsChanged;
private string _searchText = string.Empty;
public virtual string PlaceholderText
{
get => _placeholderText;
set
{
_placeholderText = value;
OnPropertyChanged(nameof(PlaceholderText));
}
}
public virtual string PlaceholderText { get; set => SetProperty(ref field, value); } = string.Empty;
public virtual string SearchText
{
get => _searchText;
set
{
_searchText = value;
OnPropertyChanged(nameof(SearchText));
}
}
public virtual string SearchText { get => _searchText; set => SetProperty(ref _searchText, value); }
public virtual bool ShowDetails
{
get => _showDetails;
set
{
_showDetails = value;
OnPropertyChanged(nameof(ShowDetails));
}
}
public virtual bool ShowDetails { get; set => SetProperty(ref field, value); }
public virtual bool HasMoreItems
{
get => _hasMore;
set
{
_hasMore = value;
OnPropertyChanged(nameof(HasMoreItems));
}
}
public virtual bool HasMoreItems { get; set => SetProperty(ref field, value); }
public virtual IFilters? Filters
{
get => _filters;
set
{
_filters = value;
OnPropertyChanged(nameof(Filters));
}
}
public virtual IFilters? Filters { get; set => SetProperty(ref field, value); }
public virtual IGridProperties? GridProperties
{
get => _gridProperties;
set
{
_gridProperties = value;
OnPropertyChanged(nameof(GridProperties));
}
}
public virtual IGridProperties? GridProperties { get; set => SetProperty(ref field, value); }
public virtual ICommandItem? EmptyContent { get; set => SetProperty(ref field, value); }
public virtual ICommandItem? EmptyContent
{
get => _emptyContent;
set
{
_emptyContent = value;
OnPropertyChanged(nameof(EmptyContent));
}
}
public virtual IListItem[] GetItems() => [];

View File

@@ -6,7 +6,17 @@ namespace Microsoft.CommandPalette.Extensions.Toolkit;
public partial class MarkdownContent : BaseObservable, IMarkdownContent
{
public virtual string Body { get; set => SetProperty(ref field, value); } = string.Empty;
public virtual string Body
{
get;
set
{
field = value;
OnPropertyChanged(nameof(Body));
}
}
= string.Empty;
public MarkdownContent()
{

View File

@@ -6,5 +6,15 @@ namespace Microsoft.CommandPalette.Extensions.Toolkit;
public partial class MediumGridLayout : BaseObservable, IMediumGridLayout
{
public virtual bool ShowTitle { get; set => SetProperty(ref field, value); } = true;
public virtual bool ShowTitle
{
get => field;
set
{
field = value;
OnPropertyChanged(nameof(ShowTitle));
}
}
= true;
}

View File

@@ -6,9 +6,37 @@ namespace Microsoft.CommandPalette.Extensions.Toolkit;
public partial class Page : Command, IPage
{
public virtual bool IsLoading { get; set => SetProperty(ref field, value); }
private bool _loading;
private string _title = string.Empty;
private OptionalColor _accentColor;
public virtual string Title { get; set => SetProperty(ref field, value); } = string.Empty;
public virtual bool IsLoading
{
get => _loading;
set
{
_loading = value;
OnPropertyChanged(nameof(IsLoading));
}
}
public virtual OptionalColor AccentColor { get; set => SetProperty(ref field, value); }
public virtual string Title
{
get => _title;
set
{
_title = value;
OnPropertyChanged(nameof(Title));
}
}
public virtual OptionalColor AccentColor
{
get => _accentColor;
set
{
_accentColor = value;
OnPropertyChanged(nameof(AccentColor));
}
}
}

View File

@@ -6,7 +6,27 @@ namespace Microsoft.CommandPalette.Extensions.Toolkit;
public partial class ProgressState : BaseObservable, IProgressState
{
public virtual bool IsIndeterminate { get; set => SetProperty(ref field, value); }
private bool _isIndeterminate;
public virtual uint ProgressPercent { get; set => SetProperty(ref field, value); }
private uint _progressPercent;
public virtual bool IsIndeterminate
{
get => _isIndeterminate;
set
{
_isIndeterminate = value;
OnPropertyChanged(nameof(IsIndeterminate));
}
}
public virtual uint ProgressPercent
{
get => _progressPercent;
set
{
_progressPercent = value;
OnPropertyChanged(nameof(ProgressPercent));
}
}
}

View File

@@ -2,6 +2,8 @@
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
using Windows.Foundation;
namespace Microsoft.CommandPalette.Extensions.Toolkit;
public partial class PropChangedEventArgs : IPropChangedEventArgs

View File

@@ -12,6 +12,11 @@ public sealed partial class Section : IEnumerable<IListItem>
public string SectionTitle { get; set; } = string.Empty;
private Separator CreateSectionListItem()
{
return new Separator(SectionTitle);
}
public Section(string sectionName, IListItem[] items)
{
SectionTitle = sectionName;
@@ -28,11 +33,6 @@ public sealed partial class Section : IEnumerable<IListItem>
{
}
private Separator CreateSectionListItem()
{
return new Separator(SectionTitle);
}
public IEnumerator<IListItem> GetEnumerator() => Items.ToList().GetEnumerator();
IEnumerator IEnumerable.GetEnumerator() => GetEnumerator();

View File

@@ -4,8 +4,15 @@
namespace Microsoft.CommandPalette.Extensions.Toolkit;
public partial class Separator : BaseObservable, IListItem, ISeparatorContextItem, ISeparatorFilterItem
public partial class Separator : IListItem, ISeparatorContextItem, ISeparatorFilterItem
{
public Separator(string? title = "")
: base()
{
Section = title ?? string.Empty;
Command = null;
}
public IDetails? Details => null;
public string? Section { get; private set; }
@@ -14,7 +21,7 @@ public partial class Separator : BaseObservable, IListItem, ISeparatorContextIte
public string? TextToSuggest => null;
public ICommand? Command => null;
public ICommand? Command { get; private set; }
public IIconInfo? Icon => null;
@@ -25,19 +32,12 @@ public partial class Separator : BaseObservable, IListItem, ISeparatorContextIte
public string? Title
{
get => Section;
set
{
if (Section != value)
{
Section = value;
OnPropertyChanged();
OnPropertyChanged(Section);
}
}
set => Section = value;
}
public Separator(string? title = "")
public event Windows.Foundation.TypedEventHandler<object, IPropChangedEventArgs>? PropChanged
{
Section = title ?? string.Empty;
add { }
remove { }
}
}

View File

@@ -6,9 +6,37 @@ namespace Microsoft.CommandPalette.Extensions.Toolkit;
public partial class StatusMessage : BaseObservable, IStatusMessage
{
public virtual string Message { get; set => SetProperty(ref field, value); } = string.Empty;
public virtual string Message
{
get;
set
{
field = value;
OnPropertyChanged(nameof(Message));
}
}
public virtual MessageState State { get; set => SetProperty(ref field, value); } = MessageState.Info;
= string.Empty;
public virtual IProgressState? Progress { get; set => SetProperty(ref field, value); }
public virtual MessageState State
{
get;
set
{
field = value;
OnPropertyChanged(nameof(State));
}
}
= MessageState.Info;
public virtual IProgressState? Progress
{
get;
set
{
field = value;
OnPropertyChanged(nameof(Progress));
}
}
}

View File

@@ -6,15 +6,63 @@ namespace Microsoft.CommandPalette.Extensions.Toolkit;
public partial class Tag : BaseObservable, ITag
{
public virtual OptionalColor Foreground { get; set => SetProperty(ref field, value); }
private OptionalColor _foreground;
private OptionalColor _background;
private string _text = string.Empty;
public virtual OptionalColor Background { get; set => SetProperty(ref field, value); }
public virtual OptionalColor Foreground
{
get => _foreground;
set
{
_foreground = value;
OnPropertyChanged(nameof(Foreground));
}
}
public virtual IIconInfo Icon { get; set => SetProperty(ref field, value); } = new IconInfo();
public virtual OptionalColor Background
{
get => _background;
set
{
_background = value;
OnPropertyChanged(nameof(Background));
}
}
public virtual string Text { get; set => SetProperty(ref field, value); } = string.Empty;
public virtual IIconInfo Icon
{
get;
set
{
field = value;
OnPropertyChanged(nameof(Icon));
}
}
public virtual string ToolTip { get; set => SetProperty(ref field, value); } = string.Empty;
= new IconInfo();
public virtual string Text
{
get => _text;
set
{
_text = value;
OnPropertyChanged(nameof(Text));
}
}
public virtual string ToolTip
{
get;
set
{
field = value;
OnPropertyChanged(nameof(ToolTip));
}
}
= string.Empty;
public Tag()
{
@@ -22,6 +70,6 @@ public partial class Tag : BaseObservable, ITag
public Tag(string text)
{
Text = text;
_text = text;
}
}

View File

@@ -8,11 +8,19 @@ namespace Microsoft.CommandPalette.Extensions.Toolkit;
public partial class TreeContent : BaseObservable, ITreeContent
{
public event TypedEventHandler<object, IItemsChangedEventArgs>? ItemsChanged;
public IContent[] Children { get; set; } = [];
public virtual IContent? RootContent { get; set => SetProperty(ref field, value); }
public virtual IContent? RootContent
{
get;
set
{
field = value;
OnPropertyChanged(nameof(RootContent));
}
}
public event TypedEventHandler<object, IItemsChangedEventArgs>? ItemsChanged;
public virtual IContent[] GetChildren() => Children;

View File

@@ -9,7 +9,6 @@ using System.CommandLine.Invocation;
using System.Globalization;
using System.Linq;
using FancyZonesCLI.Utils;
using FancyZonesEditorCommon.Data;
using FancyZonesEditorCommon.Utils;
@@ -36,19 +35,13 @@ internal sealed partial class SetHotkeyCommand : FancyZonesBaseCommand
{
// FancyZones running guard is handled by FancyZonesBaseCommand.
int key = context.ParseResult.GetValueForArgument(_key);
string layoutInput = context.ParseResult.GetValueForArgument(_layout);
string layout = context.ParseResult.GetValueForArgument(_layout);
if (key < 0 || key > 9)
{
throw new InvalidOperationException(Properties.Resources.set_hotkey_error_invalid_key);
}
// Normalize GUID to Windows format with braces (supports input with or without braces)
if (!GuidHelper.TryNormalizeGuid(layoutInput, out string layout))
{
throw new InvalidOperationException(string.Format(CultureInfo.InvariantCulture, Properties.Resources.set_hotkey_error_not_custom, layoutInput));
}
// Editor only allows assigning hotkeys to existing custom layouts.
var customLayouts = FancyZonesDataIO.ReadCustomLayouts();
@@ -67,7 +60,7 @@ internal sealed partial class SetHotkeyCommand : FancyZonesBaseCommand
if (!matchedLayout.HasValue)
{
throw new InvalidOperationException(string.Format(CultureInfo.InvariantCulture, Properties.Resources.set_hotkey_error_not_custom, layoutInput));
throw new InvalidOperationException(string.Format(CultureInfo.InvariantCulture, Properties.Resources.set_hotkey_error_not_custom, layout));
}
string layoutName = matchedLayout.Value.Name;

View File

@@ -140,12 +140,9 @@ internal sealed partial class SetLayoutCommand : FancyZonesBaseCommand
return null;
}
// Normalize GUID to Windows format with braces (supports input with or without braces)
string normalizedLayout = GuidHelper.NormalizeGuid(layout) ?? layout;
foreach (var customLayout in customLayouts.CustomLayouts)
{
if (customLayout.Uuid.Equals(normalizedLayout, StringComparison.OrdinalIgnoreCase))
if (customLayout.Uuid.Equals(layout, StringComparison.OrdinalIgnoreCase))
{
return customLayout;
}

View File

@@ -4,7 +4,6 @@
using System;
using System.CommandLine;
using System.Globalization;
using System.Linq;
namespace FancyZonesCLI.CommandLine;
@@ -14,15 +13,15 @@ internal static class FancyZonesCliUsage
public static void PrintUsage()
{
Console.OutputEncoding = System.Text.Encoding.UTF8;
Console.WriteLine(Properties.Resources.usage_title);
Console.WriteLine("FancyZones CLI - Command line interface for FancyZones");
Console.WriteLine();
var cmd = FancyZonesCliCommandFactory.CreateRootCommand();
Console.WriteLine(Properties.Resources.usage_syntax);
Console.WriteLine("Usage: FancyZonesCLI [command] [options]");
Console.WriteLine();
Console.WriteLine(Properties.Resources.usage_options);
Console.WriteLine("Options:");
foreach (var option in cmd.Options)
{
var aliases = string.Join(", ", option.Aliases);
@@ -31,7 +30,7 @@ internal static class FancyZonesCliUsage
}
Console.WriteLine();
Console.WriteLine(Properties.Resources.usage_commands);
Console.WriteLine("Commands:");
foreach (var command in cmd.Subcommands)
{
if (command.IsHidden)
@@ -52,7 +51,7 @@ internal static class FancyZonesCliUsage
}
Console.WriteLine();
Console.WriteLine(Properties.Resources.usage_examples);
Console.WriteLine("Examples:");
Console.WriteLine(" FancyZonesCLI --help");
Console.WriteLine(" FancyZonesCLI --version");
Console.WriteLine(" FancyZonesCLI get-monitors");
@@ -60,135 +59,4 @@ internal static class FancyZonesCliUsage
Console.WriteLine(" FancyZonesCLI set-layout <uuid> --monitor 1");
Console.WriteLine(" FancyZonesCLI get-hotkeys");
}
public static void PrintCommandUsage(string commandName)
{
Console.OutputEncoding = System.Text.Encoding.UTF8;
var rootCmd = FancyZonesCliCommandFactory.CreateRootCommand();
// Find matching subcommand by name or alias
var subcommand = rootCmd.Subcommands.FirstOrDefault(c =>
c.Aliases.Any(a => string.Equals(a, commandName, StringComparison.OrdinalIgnoreCase)));
if (subcommand == null)
{
Console.WriteLine(string.Format(CultureInfo.InvariantCulture, Properties.Resources.usage_unknown_command, commandName));
Console.WriteLine();
Console.WriteLine(Properties.Resources.usage_run_help);
return;
}
// Command name and description
Console.WriteLine($"{Properties.Resources.usage_command} {subcommand.Name}");
if (!string.IsNullOrEmpty(subcommand.Description))
{
Console.WriteLine($" {subcommand.Description}");
}
Console.WriteLine();
// Usage line
string argsLabel = string.Join(" ", subcommand.Arguments.Select(a => $"<{a.Name}>"));
string optionsLabel = subcommand.Options.Any() ? " [options]" : string.Empty;
Console.WriteLine($"Usage: FancyZonesCLI {subcommand.Name} {argsLabel}{optionsLabel}".TrimEnd());
Console.WriteLine();
// Aliases
var aliases = subcommand.Aliases.Where(a => !string.Equals(a, subcommand.Name, StringComparison.OrdinalIgnoreCase)).ToList();
if (aliases.Count > 0)
{
Console.WriteLine($"{Properties.Resources.usage_aliases} {string.Join(", ", aliases)}");
Console.WriteLine();
}
// Arguments
if (subcommand.Arguments.Any())
{
Console.WriteLine(Properties.Resources.usage_arguments);
foreach (var arg in subcommand.Arguments)
{
var argDescription = arg.Description ?? string.Empty;
Console.WriteLine($" <{arg.Name}>{(arg.Arity.MinimumNumberOfValues == 0 ? $" {Properties.Resources.usage_optional}" : string.Empty),-20} {argDescription}");
}
Console.WriteLine();
}
// Options
if (subcommand.Options.Any())
{
Console.WriteLine(Properties.Resources.usage_options);
foreach (var option in subcommand.Options)
{
var optAliases = string.Join(", ", option.Aliases);
var optDescription = option.Description ?? string.Empty;
Console.WriteLine($" {optAliases,-25} {optDescription}");
}
Console.WriteLine();
}
// Command-specific examples
PrintCommandExamples(subcommand.Name);
}
private static void PrintCommandExamples(string commandName)
{
Console.WriteLine(Properties.Resources.usage_examples);
switch (commandName.ToLowerInvariant())
{
case "get-monitors":
Console.WriteLine(" FancyZonesCLI get-monitors");
Console.WriteLine(" FancyZonesCLI m");
break;
case "get-layouts":
Console.WriteLine(" FancyZonesCLI get-layouts");
Console.WriteLine(" FancyZonesCLI ls");
break;
case "get-active-layout":
Console.WriteLine(" FancyZonesCLI get-active-layout");
Console.WriteLine(" FancyZonesCLI active");
break;
case "set-layout":
Console.WriteLine(" FancyZonesCLI set-layout focus");
Console.WriteLine(" FancyZonesCLI set-layout columns --monitor 1");
Console.WriteLine(" FancyZonesCLI set-layout {uuid} --all");
Console.WriteLine(" FancyZonesCLI s rows -m 2");
break;
case "open-editor":
Console.WriteLine(" FancyZonesCLI open-editor");
Console.WriteLine(" FancyZonesCLI e");
break;
case "open-settings":
Console.WriteLine(" FancyZonesCLI open-settings");
Console.WriteLine(" FancyZonesCLI settings");
break;
case "get-hotkeys":
Console.WriteLine(" FancyZonesCLI get-hotkeys");
Console.WriteLine(" FancyZonesCLI hk");
break;
case "set-hotkey":
Console.WriteLine(" FancyZonesCLI set-hotkey 1 {layout-uuid}");
Console.WriteLine(" FancyZonesCLI shk 2 0CEBCBA9-9C32-4395-B93E-DC77485AD6D0");
break;
case "remove-hotkey":
Console.WriteLine(" FancyZonesCLI remove-hotkey 1");
Console.WriteLine(" FancyZonesCLI rhk 2");
break;
default:
Console.WriteLine($" FancyZonesCLI {commandName}");
break;
}
}
}

View File

@@ -12,8 +12,6 @@ namespace FancyZonesCLI;
internal sealed class Program
{
private static readonly string[] HelpFlags = ["--help", "-h", "-?"];
private static async Task<int> Main(string[] args)
{
Logger.InitializeLogger();
@@ -23,17 +21,14 @@ internal sealed class Program
NativeMethods.InitializeWindowMessages();
// Intercept help requests early and print custom usage.
if (TryHandleHelpRequest(args))
if (args.Any(a => string.Equals(a, "--help", StringComparison.OrdinalIgnoreCase) ||
string.Equals(a, "-h", StringComparison.OrdinalIgnoreCase) ||
string.Equals(a, "-?", StringComparison.OrdinalIgnoreCase)))
{
FancyZonesCliUsage.PrintUsage();
return 0;
}
// Detect PowerShell script block expansion (when {} is interpreted as script block)
if (DetectPowerShellScriptBlockArgs(args))
{
return 1;
}
RootCommand rootCommand = FancyZonesCliCommandFactory.CreateRootCommand();
int exitCode = await rootCommand.InvokeAsync(args);
@@ -48,69 +43,4 @@ internal sealed class Program
return exitCode;
}
/// <summary>
/// Handles help requests for root command and subcommands.
/// </summary>
/// <returns>True if help was printed, false otherwise.</returns>
private static bool TryHandleHelpRequest(string[] args)
{
bool hasHelpFlag = args.Any(a => HelpFlags.Any(h => string.Equals(a, h, StringComparison.OrdinalIgnoreCase)));
if (!hasHelpFlag)
{
return false;
}
// Get non-help arguments to identify subcommand
var nonHelpArgs = args.Where(a => !HelpFlags.Any(h => string.Equals(a, h, StringComparison.OrdinalIgnoreCase))).ToArray();
if (nonHelpArgs.Length == 0)
{
// Root help: fancyzones cli --help
FancyZonesCliUsage.PrintUsage();
}
else
{
// Subcommand help: fancyzones cli <command> --help
string subcommandName = nonHelpArgs[0];
FancyZonesCliUsage.PrintCommandUsage(subcommandName);
}
return true;
}
/// <summary>
/// Detects when PowerShell interprets {GUID} as a script block and converts it to encoded command args.
/// This happens when users forget to quote GUIDs with braces in PowerShell.
/// </summary>
/// <returns>True if PowerShell script block args were detected, false otherwise.</returns>
private static bool DetectPowerShellScriptBlockArgs(string[] args)
{
// PowerShell converts {scriptblock} to: -encodedCommand <base64> -inputFormat xml -outputFormat text
bool hasEncodedCommand = args.Any(a => string.Equals(a, "-encodedCommand", StringComparison.OrdinalIgnoreCase));
bool hasInputFormat = args.Any(a => string.Equals(a, "-inputFormat", StringComparison.OrdinalIgnoreCase));
bool hasOutputFormat = args.Any(a => string.Equals(a, "-outputFormat", StringComparison.OrdinalIgnoreCase));
if (hasEncodedCommand || (hasInputFormat && hasOutputFormat))
{
Console.ForegroundColor = ConsoleColor.Red;
Console.WriteLine(Properties.Resources.error_powershell_scriptblock_title);
Console.ResetColor();
Console.WriteLine();
Console.WriteLine(Properties.Resources.error_powershell_scriptblock_explanation);
Console.WriteLine(Properties.Resources.error_powershell_scriptblock_hint);
Console.WriteLine();
Console.WriteLine($" {Properties.Resources.error_powershell_scriptblock_option1}");
Console.WriteLine($" {Properties.Resources.error_powershell_scriptblock_option1_example}");
Console.WriteLine();
Console.WriteLine($" {Properties.Resources.error_powershell_scriptblock_option2}");
Console.WriteLine($" {Properties.Resources.error_powershell_scriptblock_option2_example}");
Console.WriteLine();
Logger.LogWarning("PowerShell script block expansion detected - user needs to quote GUID or omit braces");
return true;
}
return false;
}
}

View File

@@ -349,113 +349,5 @@ namespace FancyZonesCLI.Properties {
return ResourceManager.GetString("editor_params_timeout", resourceCulture);
}
}
internal static string error_powershell_scriptblock_title {
get {
return ResourceManager.GetString("error_powershell_scriptblock_title", resourceCulture);
}
}
internal static string error_powershell_scriptblock_explanation {
get {
return ResourceManager.GetString("error_powershell_scriptblock_explanation", resourceCulture);
}
}
internal static string error_powershell_scriptblock_hint {
get {
return ResourceManager.GetString("error_powershell_scriptblock_hint", resourceCulture);
}
}
internal static string error_powershell_scriptblock_option1 {
get {
return ResourceManager.GetString("error_powershell_scriptblock_option1", resourceCulture);
}
}
internal static string error_powershell_scriptblock_option1_example {
get {
return ResourceManager.GetString("error_powershell_scriptblock_option1_example", resourceCulture);
}
}
internal static string error_powershell_scriptblock_option2 {
get {
return ResourceManager.GetString("error_powershell_scriptblock_option2", resourceCulture);
}
}
internal static string error_powershell_scriptblock_option2_example {
get {
return ResourceManager.GetString("error_powershell_scriptblock_option2_example", resourceCulture);
}
}
internal static string usage_title {
get {
return ResourceManager.GetString("usage_title", resourceCulture);
}
}
internal static string usage_syntax {
get {
return ResourceManager.GetString("usage_syntax", resourceCulture);
}
}
internal static string usage_options {
get {
return ResourceManager.GetString("usage_options", resourceCulture);
}
}
internal static string usage_commands {
get {
return ResourceManager.GetString("usage_commands", resourceCulture);
}
}
internal static string usage_examples {
get {
return ResourceManager.GetString("usage_examples", resourceCulture);
}
}
internal static string usage_arguments {
get {
return ResourceManager.GetString("usage_arguments", resourceCulture);
}
}
internal static string usage_aliases {
get {
return ResourceManager.GetString("usage_aliases", resourceCulture);
}
}
internal static string usage_command {
get {
return ResourceManager.GetString("usage_command", resourceCulture);
}
}
internal static string usage_optional {
get {
return ResourceManager.GetString("usage_optional", resourceCulture);
}
}
internal static string usage_unknown_command {
get {
return ResourceManager.GetString("usage_unknown_command", resourceCulture);
}
}
internal static string usage_run_help {
get {
return ResourceManager.GetString("usage_run_help", resourceCulture);
}
}
}
}

View File

@@ -230,62 +230,4 @@ Tip: For templates, use the type name (e.g., 'focus', 'columns', 'rows', 'grid',
<data name="editor_params_timeout" xml:space="preserve">
<value>Could not get current monitor information (timed out after {0}ms waiting for '{1}').</value>
</data>
<!-- PowerShell Script Block Detection -->
<data name="error_powershell_scriptblock_title" xml:space="preserve">
<value>Error: Invalid GUID format detected.</value>
</data>
<data name="error_powershell_scriptblock_explanation" xml:space="preserve">
<value>PowerShell interprets curly braces {} as script blocks.</value>
</data>
<data name="error_powershell_scriptblock_hint" xml:space="preserve">
<value>Please quote your GUID or use it without braces:</value>
</data>
<data name="error_powershell_scriptblock_option1" xml:space="preserve">
<value>Option 1 - Quote the GUID:</value>
</data>
<data name="error_powershell_scriptblock_option1_example" xml:space="preserve">
<value>FancyZonesCLI shk 1 '{xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx}'</value>
</data>
<data name="error_powershell_scriptblock_option2" xml:space="preserve">
<value>Option 2 - Omit the braces (recommended):</value>
</data>
<data name="error_powershell_scriptblock_option2_example" xml:space="preserve">
<value>FancyZonesCLI shk 1 xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx</value>
</data>
<!-- CLI Usage -->
<data name="usage_title" xml:space="preserve">
<value>FancyZones CLI - Command line interface for FancyZones</value>
</data>
<data name="usage_syntax" xml:space="preserve">
<value>Usage: FancyZonesCLI [command] [options]</value>
</data>
<data name="usage_options" xml:space="preserve">
<value>Options:</value>
</data>
<data name="usage_commands" xml:space="preserve">
<value>Commands:</value>
</data>
<data name="usage_examples" xml:space="preserve">
<value>Examples:</value>
</data>
<data name="usage_arguments" xml:space="preserve">
<value>Arguments:</value>
</data>
<data name="usage_aliases" xml:space="preserve">
<value>Aliases:</value>
</data>
<data name="usage_command" xml:space="preserve">
<value>Command:</value>
</data>
<data name="usage_optional" xml:space="preserve">
<value>(optional)</value>
</data>
<data name="usage_unknown_command" xml:space="preserve">
<value>Unknown command: {0}</value>
</data>
<data name="usage_run_help" xml:space="preserve">
<value>Run 'FancyZonesCLI --help' to see available commands.</value>
</data>
</root>

View File

@@ -1,52 +0,0 @@
// Copyright (c) Microsoft Corporation
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
using System;
using System.Diagnostics.CodeAnalysis;
#nullable enable
namespace FancyZonesCLI.Utils;
/// <summary>
/// Helper class for normalizing GUID strings to Windows format with braces.
/// Supports input with or without braces: both "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"
/// and "{xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx}" are accepted.
/// </summary>
internal static class GuidHelper
{
/// <summary>
/// Normalizes a GUID string to Windows format with braces.
/// Returns null if the input is not a valid GUID.
/// </summary>
/// <param name="input">GUID string with or without braces.</param>
/// <returns>GUID in "{xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx}" format, or null if invalid.</returns>
public static string? NormalizeGuid(string? input)
{
if (string.IsNullOrWhiteSpace(input))
{
return null;
}
if (Guid.TryParse(input, out Guid guid))
{
// "B" format includes braces: {xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx}
return guid.ToString("B").ToUpperInvariant();
}
return null;
}
/// <summary>
/// Tries to normalize a GUID string to Windows format with braces.
/// </summary>
/// <param name="input">GUID string with or without braces.</param>
/// <param name="normalizedGuid">The normalized GUID string, or the original input if normalization fails.</param>
/// <returns>True if the input was successfully normalized; otherwise, false.</returns>
public static bool TryNormalizeGuid(string? input, [NotNullWhen(true)] out string? normalizedGuid)
{
normalizedGuid = NormalizeGuid(input);
return normalizedGuid != null;
}
}

View File

@@ -4,7 +4,6 @@
using System.Collections.Generic;
using System.Text.Json;
using System.Text.Json.Serialization;
using static FancyZonesEditorCommon.Data.CustomLayouts;
@@ -24,10 +23,8 @@ namespace FancyZonesEditorCommon.Data
{
public struct CanvasZoneWrapper
{
[JsonPropertyName("X")]
public int X { get; set; }
[JsonPropertyName("Y")]
public int Y { get; set; }
public int Width { get; set; }

View File

@@ -191,22 +191,27 @@ bool EditorParameters::Save(const WorkAreaConfiguration& configuration, OnThread
monitorJson.dpi = dpi;
// Get DPI-unaware values for dimensions (virtual coordinates for WPF sizing)
MONITORINFOEX monitorInfoUnaware{};
MONITORINFOEX monitorInfo{};
dpiUnawareThread.submit(OnThreadExecutor::task_t{ [&] {
monitorInfoUnaware.cbSize = sizeof(monitorInfoUnaware);
GetMonitorInfo(monitor, &monitorInfoUnaware);
monitorInfo.cbSize = sizeof(monitorInfo);
if (!GetMonitorInfo(monitor, &monitorInfo))
{
return;
}
} }).wait();
// Dimensions in virtual coordinates (from DPI-unaware thread)
monitorJson.monitorWidth = monitorInfoUnaware.rcMonitor.right - monitorInfoUnaware.rcMonitor.left;
monitorJson.monitorHeight = monitorInfoUnaware.rcMonitor.bottom - monitorInfoUnaware.rcMonitor.top;
monitorJson.workAreaWidth = monitorInfoUnaware.rcWork.right - monitorInfoUnaware.rcWork.left;
monitorJson.workAreaHeight = monitorInfoUnaware.rcWork.bottom - monitorInfoUnaware.rcWork.top;
float width = static_cast<float>(monitorInfo.rcMonitor.right - monitorInfo.rcMonitor.left);
float height = static_cast<float>(monitorInfo.rcMonitor.bottom - monitorInfo.rcMonitor.top);
DPIAware::Convert(monitor, width, height);
// Position in virtual coordinates (matched by DPI-unaware context in WPF editor)
monitorJson.left = monitorInfoUnaware.rcWork.left;
monitorJson.top = monitorInfoUnaware.rcWork.top;
monitorJson.monitorWidth = static_cast<int>(std::roundf(width));
monitorJson.monitorHeight = static_cast<int>(std::roundf(height));
// use dpi-unaware values
monitorJson.top = monitorInfo.rcWork.top;
monitorJson.left = monitorInfo.rcWork.left;
monitorJson.workAreaWidth = monitorInfo.rcWork.right - monitorInfo.rcWork.left;
monitorJson.workAreaHeight = monitorInfo.rcWork.bottom - monitorInfo.rcWork.top;
argsJson.monitors.emplace_back(std::move(monitorJson));
}

View File

@@ -67,18 +67,10 @@ namespace FancyZonesEditor.Models
Window.KeyUp += ((App)Application.Current).App_KeyUp;
Window.KeyDown += ((App)Application.Current).App_KeyDown;
// Store for DPI-unaware positioning
_virtualWorkArea = workArea;
// Set initial WPF properties
Window.Left = workArea.X;
Window.Top = workArea.Y;
Window.Width = workArea.Width;
Window.Height = workArea.Height;
// After HWND is created, reposition using DPI-unaware context
// This matches the C++ backend which uses a DPI-unaware thread
Window.SourceInitialized += OnWindowSourceInitialized;
}
public Monitor(string monitorName, string monitorInstanceId, string monitorSerialNumber, string virtualDesktop, int dpi, Rect workArea, Size monitorSize)
@@ -88,33 +80,16 @@ namespace FancyZonesEditor.Models
}
private LayoutSettings _settings;
private Rect _virtualWorkArea;
private void OnWindowSourceInitialized(object sender, EventArgs e)
{
// Reposition window using DPI-unaware context to match the virtual coordinates
// from the FancyZones C++ backend (which uses a DPI-unaware thread)
Utils.NativeMethods.SetWindowPositionDpiUnaware(
Window,
(int)_virtualWorkArea.X,
(int)_virtualWorkArea.Y,
(int)_virtualWorkArea.Width,
(int)_virtualWorkArea.Height);
}
public void Scale(double scaleFactor)
{
Device.Scale(scaleFactor);
_virtualWorkArea = Device.WorkAreaRect;
// Use DPI-unaware positioning
Utils.NativeMethods.SetWindowPositionDpiUnaware(
Window,
(int)_virtualWorkArea.X,
(int)_virtualWorkArea.Y,
(int)_virtualWorkArea.Width,
(int)_virtualWorkArea.Height);
var workArea = Device.WorkAreaRect;
Window.Left = workArea.X;
Window.Top = workArea.Y;
Window.Width = workArea.Width;
Window.Height = workArea.Height;
}
public void SetLayoutSettings(LayoutModel model)

View File

@@ -69,11 +69,7 @@ namespace FancyZonesEditor.Utils
}
else
{
// Convert virtual coordinates to physical resolution by applying DPI scale
double scale = DPI / 96.0;
int physicalWidth = (int)Math.Round(ScreenBoundsWidth * scale);
int physicalHeight = (int)Math.Round(ScreenBoundsHeight * scale);
return physicalWidth + " × " + physicalHeight;
return ScreenBoundsWidth + " × " + ScreenBoundsHeight;
}
}
}

View File

@@ -1,4 +1,4 @@
// Copyright (c) Microsoft Corporation
// Copyright (c) Microsoft Corporation
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
@@ -17,48 +17,14 @@ namespace FancyZonesEditor.Utils
[DllImport("user32.dll")]
private static extern int SetWindowLong(IntPtr hWnd, int nIndex, int dwNewLong);
[DllImport("user32.dll", SetLastError = true)]
private static extern bool SetWindowPos(IntPtr hWnd, IntPtr hWndInsertAfter, int X, int Y, int cx, int cy, uint uFlags);
[DllImport("user32.dll")]
private static extern IntPtr SetThreadDpiAwarenessContext(IntPtr dpiContext);
private const int GWL_EX_STYLE = -20;
private const int WS_EX_APPWINDOW = 0x00040000;
private const int WS_EX_TOOLWINDOW = 0x00000080;
private const uint SWP_NOZORDER = 0x0004;
private const uint SWP_NOACTIVATE = 0x0010;
private static readonly IntPtr DPI_AWARENESS_CONTEXT_UNAWARE = new IntPtr(-1);
public static void SetWindowStyleToolWindow(Window hwnd)
{
var helper = new WindowInteropHelper(hwnd).Handle;
_ = SetWindowLong(helper, GWL_EX_STYLE, (GetWindowLong(helper, GWL_EX_STYLE) | WS_EX_TOOLWINDOW) & ~WS_EX_APPWINDOW);
}
/// <summary>
/// Positions a WPF window using DPI-unaware context to match the virtual coordinates
/// from the FancyZones C++ backend (which uses a DPI-unaware thread).
/// This fixes overlay positioning on mixed-DPI multi-monitor setups.
/// </summary>
public static void SetWindowPositionDpiUnaware(Window window, int x, int y, int width, int height)
{
var helper = new WindowInteropHelper(window).Handle;
if (helper != IntPtr.Zero)
{
// Temporarily switch to DPI-unaware context to position window.
// This matches how the C++ backend gets coordinates via dpiUnawareThread.
IntPtr oldContext = SetThreadDpiAwarenessContext(DPI_AWARENESS_CONTEXT_UNAWARE);
try
{
SetWindowPos(helper, IntPtr.Zero, x, y, width, height, SWP_NOZORDER | SWP_NOACTIVATE);
}
finally
{
SetThreadDpiAwarenessContext(oldContext);
}
}
}
}
}

View File

@@ -31,7 +31,7 @@ internal static class Program
Console.InputEncoding = Encoding.Unicode;
// Initialize logger to file (same as other modules)
CliLogger.Initialize("\\Image Resizer\\CLI");
CliLogger.Initialize("\\ImageResizer\\Logs");
CliLogger.Info($"ImageResizerCLI started with {args.Length} argument(s)");
try

View File

@@ -126,10 +126,13 @@ namespace ImageResizer.Properties
h => ncc.CollectionChanged -= h,
() => settings.CustomSize = new CustomSize());
// Reset is used instead of Replace to avoid ArgumentOutOfRangeException
// when notifying changes for virtual items (CustomSize/AiSize) that exist
// outside the bounds of the underlying _sizes collection.
Assert.AreEqual(NotifyCollectionChangedAction.Reset, result.Arguments.Action);
Assert.AreEqual(NotifyCollectionChangedAction.Replace, result.Arguments.Action);
Assert.AreEqual(1, result.Arguments.NewItems.Count);
Assert.AreEqual(settings.CustomSize, result.Arguments.NewItems[0]);
Assert.AreEqual(0, result.Arguments.NewStartingIndex);
Assert.AreEqual(1, result.Arguments.OldItems.Count);
Assert.AreEqual(originalCustomSize, result.Arguments.OldItems[0]);
Assert.AreEqual(0, result.Arguments.OldStartingIndex);
}
[TestMethod]

View File

@@ -20,7 +20,7 @@ namespace ImageResizer
{
public partial class App : Application, IDisposable
{
private const string LogSubFolder = "\\Image Resizer\\Logs";
private const string LogSubFolder = "\\ImageResizer\\Logs";
/// <summary>
/// Gets cached AI availability state, checked at app startup.
@@ -67,27 +67,12 @@ namespace ImageResizer
// Fix for .net 3.1.19 making Image Resizer not adapt to DPI changes.
NativeMethods.SetProcessDPIAware();
// TODO: Re-enable AI Super Resolution in next release by removing this #if block
// Temporarily disable AI Super Resolution feature (hide from UI but keep code)
#if true // Set to false to re-enable AI Super Resolution
AiAvailabilityState = AiAvailabilityState.NotSupported;
ResizeBatch.SetAiSuperResolutionService(Services.NoOpAiSuperResolutionService.Instance);
// Skip AI detection mode as well
if (e?.Args?.Length > 0 && e.Args[0] == "--detect-ai")
{
Services.AiAvailabilityCacheService.SaveCache(AiAvailabilityState.NotSupported);
Environment.Exit(0);
return;
}
#else
// Check for AI detection mode (called by Runner in background)
if (e?.Args?.Length > 0 && e.Args[0] == "--detect-ai")
{
RunAiDetectionMode();
return;
}
#endif
if (PowerToys.GPOWrapperProjection.GPOWrapper.GetConfiguredImageResizerEnabledValue() == PowerToys.GPOWrapperProjection.GpoRuleConfigured.Disabled)
{

View File

@@ -216,15 +216,27 @@ namespace ImageResizer.Properties
{
if (e.PropertyName == nameof(Models.CustomSize))
{
var oldCustomSize = _customSize;
_customSize = settings.CustomSize;
OnCollectionChanged(new NotifyCollectionChangedEventArgs(NotifyCollectionChangedAction.Reset));
OnCollectionChanged(
new NotifyCollectionChangedEventArgs(
NotifyCollectionChangedAction.Replace,
_customSize,
oldCustomSize,
_sizes.Count));
}
else if (e.PropertyName == nameof(Models.AiSize))
{
var oldAiSize = _aiSize;
_aiSize = settings.AiSize;
OnCollectionChanged(new NotifyCollectionChangedEventArgs(NotifyCollectionChangedAction.Reset));
OnCollectionChanged(
new NotifyCollectionChangedEventArgs(
NotifyCollectionChangedAction.Replace,
_aiSize,
oldAiSize,
_sizes.Count + 1));
}
else if (e.PropertyName == nameof(Sizes))
{

View File

@@ -388,13 +388,6 @@ namespace Peek.UI
IsErrorVisible = true;
}
public void ShowError(string message)
{
IsErrorVisible = false;
ErrorMessage = message;
IsErrorVisible = true;
}
private void NavigationThrottleTimer_Tick(object? sender, object e)
{
if (sender == null)

View File

@@ -50,8 +50,7 @@
Item="{x:Bind ViewModel.CurrentItem, Mode=OneWay}"
NumberOfFiles="{x:Bind ViewModel.DisplayItemCount, Mode=OneWay}"
PreviewSizeChanged="FilePreviewer_PreviewSizeChanged"
ScalingFactor="{x:Bind ViewModel.ScalingFactor, Mode=OneWay}"
Visibility="{x:Bind ContentVisibility(ViewModel.IsErrorVisible), Mode=OneWay}" />
ScalingFactor="{x:Bind ViewModel.ScalingFactor, Mode=OneWay}" />
<InfoBar
x:Name="ErrorInfoBar"
@@ -60,7 +59,6 @@
Grid.RowSpan="2"
Margin="4,0,4,6"
VerticalAlignment="Bottom"
Closed="ErrorInfoBar_Closed"
IsOpen="{x:Bind ViewModel.IsErrorVisible, Mode=TwoWay}"
Message="{x:Bind ViewModel.ErrorMessage, Mode=OneWay}"
Severity="Error" />

View File

@@ -14,7 +14,6 @@ using Microsoft.UI.Xaml.Controls;
using Microsoft.UI.Xaml.Input;
using Peek.Common.Constants;
using Peek.Common.Extensions;
using Peek.Common.Helpers;
using Peek.FilePreviewer.Models;
using Peek.FilePreviewer.Previewers;
using Peek.UI.Extensions;
@@ -196,20 +195,6 @@ namespace Peek.UI
bootTime.Start();
ViewModel.Initialize(selectedItem);
// If no files were found (e.g., in virtual folders like Home/Recent), show an error
if (ViewModel.CurrentItem == null)
{
Logger.LogInfo("Peek: No files found to preview, showing error.");
var errorMessage = ResourceLoaderInstance.ResourceLoader.GetString("NoFilesSelected");
ViewModel.ShowError(errorMessage);
// Still show the window so user can see the warning
this.Show();
WindowHelpers.BringToForeground(this.GetWindowHandle());
return;
}
ViewModel.ScalingFactor = this.GetMonitorScale();
this.Content.KeyUp += Content_KeyUp;
@@ -317,24 +302,5 @@ namespace Peek.UI
{
themeListener?.Dispose();
}
/// <summary>
/// Returns Visibility.Collapsed when error is showing, Visibility.Visible when not.
/// </summary>
public Visibility ContentVisibility(bool isErrorVisible)
{
return isErrorVisible ? Visibility.Collapsed : Visibility.Visible;
}
/// <summary>
/// Handle InfoBar closed - if there's no current item, close the window.
/// </summary>
private void ErrorInfoBar_Closed(InfoBar sender, InfoBarClosedEventArgs args)
{
if (ViewModel.CurrentItem == null)
{
Uninitialize();
}
}
}
}

View File

@@ -341,10 +341,6 @@
<value>No more files to preview.</value>
<comment>The message to show when there are no files remaining to preview.</comment>
</data>
<data name="NoFilesSelected" xml:space="preserve">
<value>No files selected or this folder is not supported for preview.</value>
<comment>Displayed when Peek is activated in a virtual folder (like Home or Recent) where file selection cannot be retrieved.</comment>
</data>
<data name="DeleteFileError_NotFound" xml:space="preserve">
<value>The file cannot be found. Please check if the file has been moved, renamed, or deleted.</value>
<comment>Displayed if the file or path was not found</comment>

View File

@@ -193,102 +193,6 @@ GeneralSettings get_general_settings()
return settings;
}
void apply_module_status_update(const json::JsonObject& module_config, bool save)
{
Logger::info(L"apply_module_status_update: {}", std::wstring{ module_config.ToString() });
// Expected format: {"ModuleName": true/false} - only one module per update
auto iter = module_config.First();
if (!iter.HasCurrent())
{
Logger::warn(L"apply_module_status_update: Empty module config");
return;
}
const auto& element = iter.Current();
const auto value = element.Value();
if (value.ValueType() != json::JsonValueType::Boolean)
{
Logger::warn(L"apply_module_status_update: Invalid value type for module status");
return;
}
const std::wstring name{ element.Key().c_str() };
if (modules().find(name) == modules().end())
{
Logger::warn(L"apply_module_status_update: Module {} not found", name);
return;
}
PowertoyModule& powertoy = modules().at(name);
const bool module_inst_enabled = powertoy->is_enabled();
bool target_enabled = value.GetBoolean();
auto gpo_rule = powertoy->gpo_policy_enabled_configuration();
if (gpo_rule == powertoys_gpo::gpo_rule_configured_enabled || gpo_rule == powertoys_gpo::gpo_rule_configured_disabled)
{
// Apply the GPO Rule.
target_enabled = gpo_rule == powertoys_gpo::gpo_rule_configured_enabled;
}
if (module_inst_enabled == target_enabled)
{
Logger::info(L"apply_module_status_update: Module {} already in target state {}", name, target_enabled);
return;
}
if (target_enabled)
{
Logger::info(L"apply_module_status_update: Enabling powertoy {}", name);
powertoy->enable();
auto& hkmng = HotkeyConflictDetector::HotkeyConflictManager::GetInstance();
hkmng.EnableHotkeyByModule(name);
// Trigger AI capability detection when ImageResizer is enabled
if (name == L"Image Resizer")
{
Logger::info(L"ImageResizer enabled, triggering AI capability detection");
DetectAiCapabilitiesAsync(true); // Skip settings check since we know it's being enabled
}
}
else
{
Logger::info(L"apply_module_status_update: Disabling powertoy {}", name);
powertoy->disable();
auto& hkmng = HotkeyConflictDetector::HotkeyConflictManager::GetInstance();
hkmng.DisableHotkeyByModule(name);
}
// Sync the hotkey state with the module state, so it can be removed for disabled modules.
powertoy.UpdateHotkeyEx();
if (save)
{
// Load existing settings and only update the specific module's enabled state
json::JsonObject current_settings = PTSettingsHelper::load_general_settings();
json::JsonObject enabled;
if (current_settings.HasKey(L"enabled"))
{
enabled = current_settings.GetNamedObject(L"enabled");
}
// Check if the saved state is different from the requested state
bool current_saved = enabled.HasKey(name) ? enabled.GetNamedBoolean(name, true) : true;
if (current_saved != target_enabled)
{
// Update only this module's enabled state
enabled.SetNamedValue(name, json::value(target_enabled));
current_settings.SetNamedValue(L"enabled", enabled);
PTSettingsHelper::save_general_settings(current_settings);
GeneralSettings settings_for_trace = get_general_settings();
Trace::SettingsChanged(settings_for_trace);
}
}
}
void apply_general_settings(const json::JsonObject& general_configs, bool save)
{
std::wstring old_settings_json_string;
@@ -463,21 +367,11 @@ void apply_general_settings(const json::JsonObject& general_configs, bool save)
if (json::has(general_configs, L"show_theme_adaptive_tray_icon", json::JsonValueType::Boolean))
{
bool new_theme_adaptive = general_configs.GetNamedBoolean(L"show_theme_adaptive_tray_icon");
Logger::info(L"apply_general_settings: show_theme_adaptive_tray_icon current={}, new={}",
show_theme_adaptive_tray_icon, new_theme_adaptive);
if (show_theme_adaptive_tray_icon != new_theme_adaptive)
{
show_theme_adaptive_tray_icon = new_theme_adaptive;
set_tray_icon_theme_adaptive(show_theme_adaptive_tray_icon);
}
else
{
Logger::info(L"apply_general_settings: show_theme_adaptive_tray_icon unchanged, skipping update");
}
}
else
{
Logger::warn(L"apply_general_settings: show_theme_adaptive_tray_icon not found in config");
}
if (json::has(general_configs, L"ignored_conflict_properties", json::JsonValueType::Object))

View File

@@ -38,5 +38,4 @@ struct GeneralSettings
json::JsonObject load_general_settings();
GeneralSettings get_general_settings();
void apply_general_settings(const json::JsonObject& general_configs, bool save = true);
void apply_module_status_update(const json::JsonObject& module_config, bool save = true);
void start_enabled_powertoys();

View File

@@ -215,12 +215,6 @@ void dispatch_received_json(const std::wstring& json_to_parse)
// current_settings_ipc->send(settings_string);
// }
}
else if (name == L"module_status")
{
// Handle single module enable/disable update
// Expected format: {"module_status": {"ModuleName": true/false}}
apply_module_status_update(value.GetObjectW());
}
else if (name == L"powertoys")
{
dispatch_json_config_to_modules(value.GetObjectW());

View File

@@ -273,19 +273,12 @@ static HICON get_icon(Theme theme)
{
std::wstring icon_path = get_module_folderpath();
icon_path += theme == Theme::Dark ? L"\\svgs\\PowerToysWhite.ico" : L"\\svgs\\PowerToysDark.ico";
Logger::trace(L"get_icon: Loading icon from path: {}", icon_path);
HICON icon = static_cast<HICON>(LoadImage(NULL,
return static_cast<HICON>(LoadImage(NULL,
icon_path.c_str(),
IMAGE_ICON,
0,
0,
LR_LOADFROMFILE | LR_DEFAULTSIZE | LR_SHARED));
if (!icon)
{
Logger::warn(L"get_icon: Failed to load icon from {}, error: {}", icon_path, GetLastError());
}
return icon;
}
@@ -381,45 +374,13 @@ void set_tray_icon_visible(bool shouldIconBeVisible)
void set_tray_icon_theme_adaptive(bool theme_adaptive)
{
Logger::info(L"set_tray_icon_theme_adaptive: Called with theme_adaptive={}, current theme_adaptive_enabled={}",
theme_adaptive, theme_adaptive_enabled);
auto h_instance = reinterpret_cast<HINSTANCE>(&__ImageBase);
HICON icon = nullptr;
if (theme_adaptive)
{
icon = get_icon(theme_listener.AppTheme);
if (!icon)
{
Logger::warn(L"set_tray_icon_theme_adaptive: Failed to load theme adaptive icon, falling back to default");
}
}
// If not requesting adaptive icon, or if adaptive icon failed to load, use default icon
if (!icon)
{
icon = LoadIcon(h_instance, MAKEINTRESOURCE(APPICON));
if (theme_adaptive && icon)
{
// We requested adaptive but had to fall back, so update the flag
theme_adaptive = false;
Logger::info(L"set_tray_icon_theme_adaptive: Using default icon as fallback");
}
}
theme_adaptive_enabled = theme_adaptive;
auto h_instance = reinterpret_cast<HINSTANCE>(&__ImageBase);
HICON const icon = theme_adaptive ? get_icon(theme_listener.AppTheme) : LoadIcon(h_instance, MAKEINTRESOURCE(APPICON));
if (icon)
{
tray_icon_data.hIcon = icon;
BOOL result = Shell_NotifyIcon(NIM_MODIFY, &tray_icon_data);
Logger::info(L"set_tray_icon_theme_adaptive: Icon updated, theme_adaptive_enabled={}, Shell_NotifyIcon result={}",
theme_adaptive_enabled, result);
}
else
{
Logger::error(L"set_tray_icon_theme_adaptive: Failed to load any icon");
Shell_NotifyIcon(NIM_MODIFY, &tray_icon_data);
}
}

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