Compare commits

..

8 Commits

Author SHA1 Message Date
Mike Griese
e83d6cb8f6 more ci fixes? 2026-05-13 06:38:59 -05:00
Mike Griese
47c66cea90 fix CI 2026-05-13 05:46:57 -05:00
Mike Griese
758f23ed7e PRE-MERGE List Parameters (not filed yet) 2026-05-12 16:53:52 -05:00
Mike Griese
dc3ce6a081 PRE-MERGE CmdPal: Add support for pages with parameters (redux) PR #47826 2026-05-12 16:51:13 -05:00
Mike Griese
1b63c3e554 PRE-MERGE CmdPal: Update the shell provider to be run PR #47642 2026-05-12 16:49:31 -05:00
Mike Griese
b76671adab PRE-MERGE PR #46915 CmdPal Dock: Multi-monitor support 2026-05-12 16:47:24 -05:00
Mike Griese
a2768b066f PRE-MERGE PR #46636 CmdPal: Extension Gallery 2026-05-12 16:46:03 -05:00
Mike Griese
23aa09ee2f cmdpal: bump to 0.11 2026-05-12 16:36:30 -05:00
156 changed files with 3406 additions and 7109 deletions

View File

@@ -186,12 +186,6 @@ xmlutil
# Prefix
pcs
# EXPRTK / C++ MATH
ifunction
isinf
isnan
# User32.SYSTEM_METRICS_INDEX.cs
CLEANBOOT
@@ -361,7 +355,6 @@ URLIS
WAITTIMEOUT
DEFAULTTONEAREST
# COM/WinRT interface prefixes and type fragments
BAlt
BShift

View File

@@ -143,4 +143,3 @@ ignore$
src/modules/cmdpal/ext/SamplePagesExtension/Pages/SampleMarkdownImagesPage.cs
^src/modules/powerrename/unittests/testdata/avif_test\.avif$
^src/modules/powerrename/unittests/testdata/heif_test\.heic$
^deps/spdlog-msvc-fix/

View File

@@ -215,7 +215,6 @@ clickable
clickonce
clientedge
clientside
cliextensions
CLIPBOARDUPDATE
CLIPCHILDREN
CLIPSIBLINGS
@@ -361,6 +360,7 @@ DEFAULTICON
defaultlib
DEFAULTONLY
DEFAULTSIZE
DEFAULTTONEAREST
DEFAULTTONULL
DEFAULTTOPRIMARY
DEFERERASE
@@ -440,7 +440,6 @@ DString
DSVG
dto
DUMMYUNIONNAME
dumpbin
dutil
DVASPECT
DVASPECTINFO
@@ -711,7 +710,6 @@ HOOKPROC
HORZRES
HORZSIZE
Hostbackdropbrush
hostfxr
hostsfileeditor
hotfixes
hotkeycontrol
@@ -1024,7 +1022,6 @@ Metadatas
metafile
metapackage
mfc
mfcm
Mgmt
Microwaved
middleclickaction
@@ -1452,7 +1449,6 @@ ptcontrols
ptd
PTOKEN
ptstr
ptsym
pui
pvct
PWAs
@@ -1592,6 +1588,7 @@ scrollviewer
sddl
SDKDDK
sdns
SDTVDONGLE
searchterm
SEARCHUI
secondaryclickaction
@@ -1899,7 +1896,6 @@ trx
tsa
tskill
tstoi
tsv
tweakable
TWF
tymed
@@ -1938,8 +1934,8 @@ unitconverter
unittests
UNLEN
UNORM
unparsable
unremapped
unsubscribes
untriaged
unvirtualized
unwide

View File

@@ -1,12 +1,12 @@
---
name: release-note-generation
description: Toolkit for generating PowerToys release notes from GitHub milestone PRs or commit ranges. Use when asked to create release notes, summarize milestone PRs, generate changelog, prepare release documentation, generate PR review summaries locally for release notes, update README for a new release, manage PR milestones, collect PRs between commits/tags, or prepare release assets (download installers and compute installer hashes).
description: Toolkit for generating PowerToys release notes from GitHub milestone PRs or commit ranges. Use when asked to create release notes, summarize milestone PRs, generate changelog, prepare release documentation, request Copilot reviews for PRs, update README for a new release, manage PR milestones, or collect PRs between commits/tags. Supports PR collection by milestone or commit range, milestone assignment, grouping by label, summarization with external contributor attribution, and README version bumping.
license: Complete terms in LICENSE.txt
---
# Release Note Generation Skill
Generate professional release notes for PowerToys milestones by collecting merged PRs, summarizing each PR with the local CLI agent, grouping by label, and producing user-facing summaries.
Generate professional release notes for PowerToys milestones by collecting merged PRs, requesting Copilot code reviews, grouping by label, and producing user-facing summaries.
## Output Directory
@@ -26,17 +26,16 @@ Generated Files/ReleaseNotes/
- Generate release notes for a milestone
- Summarize PRs merged in a release
- Generate per-PR review summaries locally for release-notes copy
- Request Copilot reviews for milestone PRs
- Assign milestones to PRs missing them
- Collect PRs between two commits/tags
- Update README.md for a new version
- Prepare GitHub release assets (download installers/symbols + compute hashes)
## Prerequisites
- **GitHub CLI (`gh`) installed and authenticated** — The collection script uses `gh pr view` and `gh api graphql` to fetch PR metadata and co-author information. Run `gh auth status` to verify; if not logged in, run `gh auth login` first. See [Step 1.0.0](./references/step1-collection.md) for details.
- MCP Server: github-mcp-server installed (used to fetch PR diffs/files for the local-agent review step)
- For [prepare-release-assets.ps1](./scripts/prepare-release-assets.ps1) only: **Azure CLI** authenticated against the Microsoft tenant (`az login`) with the `azure-devops` extension; access to the `microsoft/Dart` ADO project
- MCP Server: github-mcp-server installed
- GitHub Copilot code review enabled for the org/repo
## Required Variables
@@ -66,12 +65,12 @@ Generated Files/ReleaseNotes/
└────────────────────────────────┘
┌────────────────────────────────┐
│ 3.1 Local-agent PR summaries
│ (writes CopilotSummary) │
│ 3.1 Request Reviews (Copilot)
└────────────────────────────────┘
┌────────────────────────────────┐
│ 3.2 (Optional) Refresh PR data │
│ 3.2 Refresh PR data
│ (CopilotSummary) │
└────────────────────────────────┘
┌────────────────────────────────┐
@@ -94,7 +93,7 @@ Generated Files/ReleaseNotes/
| 1.1 | Collect PRs | From previous release tag on `stable` branch → `sorted_prs.csv` |
| 1.2 | Assign Milestones | Ensure all PRs have correct milestone |
| 2.12.4 | Label PRs | Auto-suggest + human label low-confidence |
| 3.13.3 | Reviews & Grouping | Local agent summarizes each PR diff into `CopilotSummary` → (optional refresh) → group by label |
| 3.13.3 | Reviews & Grouping | Request Copilot reviews → refresh → group by label |
| 4.14.2 | Summaries & Final | Generate grouped summaries, then consolidate |
## Detailed workflow docs
@@ -115,7 +114,6 @@ Do not read all steps at once—only read the step you are executing.
| [group-prs-by-label.ps1](./scripts/group-prs-by-label.ps1) | Group PRs into CSVs |
| [collect-or-apply-milestones.ps1](./scripts/collect-or-apply-milestones.ps1) | Assign milestones |
| [diff_prs.ps1](./scripts/diff_prs.ps1) | Incremental PR diff |
| [prepare-release-assets.ps1](./scripts/prepare-release-assets.ps1) | Download installers + symbols from an ADO build, compute SHA256, emit the "Installer Hashes" markdown table for the GitHub release page |
## References
@@ -135,6 +133,5 @@ Do not read all steps at once—only read the step you are executing.
|-------|----------|
| `gh` command not found | Install GitHub CLI and add to PATH |
| No PRs returned | Verify milestone title matches exactly |
| Empty `CopilotSummary` for many PRs | Run Step 3.1 (local-agent summaries). Do **not** use `mcp_github_request_copilot_review` from a CLI/coding agent — the GitHub API rejects bot-initiated review requests, so the column will stay empty. |
| Empty CopilotSummary | Request Copilot reviews first, then re-run dump |
| Many unlabeled PRs | Return to labeling step before grouping |
| `prepare-release-assets.ps1` fails with "Failed to acquire ADO access token" | Run `az login` and ensure you have access to the `microsoft/Dart` ADO project |

View File

@@ -1,40 +1,22 @@
# Step 3: Local Agent Reviews and Grouping
# Step 3: Copilot Reviews and Grouping
## 3.0 To-do
- 3.1 Generate PR Summaries with the Local Agent
- 3.2 (Optional) Refresh PR Data
- 3.1 Request Copilot Reviews (Agent Mode)
- 3.2 Refresh PR Data
- 3.3 Group PRs by Label
## 3.1 Generate PR Summaries with the Local Agent
## 3.1 Request Copilot Reviews (Agent Mode)
> ⚠️ **Do not use `mcp_github_request_copilot_review` (or any "request Copilot review" tool that calls the GitHub API).**
> When this skill is driven from a CLI / coding agent, the request is made from a bot identity and the GitHub API rejects it ("Bot reviewers cannot be requested"). The PR ends up with no Copilot review and `CopilotSummary` stays empty.
>
> Instead, **the local agent that is running this skill performs the review itself** and writes the summary directly into `sorted_prs.csv`.
Use MCP tools to request Copilot reviews for all PRs in `Generated Files/ReleaseNotes/sorted_prs.csv`:
For every PR listed in `Generated Files/ReleaseNotes/sorted_prs.csv` whose `CopilotSummary` is empty:
1. Fetch the PR diff using a tool that does **not** post anything back to GitHub. Any of these works:
- `mcp_github_pull_request_read` with `method: get_diff`
- `mcp_github_pull_request_read` with `method: get_files` (when the diff is large)
- `gh pr diff <PR_NUMBER> --repo microsoft/PowerToys`
2. Read the PR title, body, and diff. Produce a 13 sentence, user-facing summary in the same style as a Copilot PR review (focus on observable behavior change, not implementation details).
3. Write the summary into the `CopilotSummary` column for that PR row in `Generated Files/ReleaseNotes/sorted_prs.csv`. Preserve all other columns and the existing row order.
**Batching guidance**
- Process PRs in the order they appear in `sorted_prs.csv`.
- Generate summaries for **all** PRs in one pass before continuing to Step 3.3, so the human reviewer can validate them together.
- For very large diffs, summarize from `get_files` (filenames + per-file patches) rather than the full diff.
- Skip PRs that already have a non-empty `CopilotSummary` (e.g. PRs where a human reviewer already pasted one). Do not overwrite existing summaries.
**Why not post the summary back to the PR?** Posting a comment from the agent's identity would not be picked up by `dump-prs-since-commit.ps1` (which only matches Copilot bot authors), and it adds noise to the PR. Writing straight into the CSV keeps the artifact self-contained.
- Use `mcp_github_request_copilot_review` for each PR ID
- Do NOT generate or run scripts for this step
---
## 3.2 (Optional) Refresh PR Data
## 3.2 Refresh PR Data
Only re-run the collection script if PR metadata on GitHub has changed (new labels, retitled PRs, etc.) since Step 1.1. **Skip this step if you only want to preserve the locally generated `CopilotSummary` values from Step 3.1**, because re-running the dump will overwrite the CSV.
Re-run the collection script to capture Copilot review summaries into the `CopilotSummary` column:
```powershell
pwsh ./.github/skills/release-note-generation/scripts/dump-prs-since-commit.ps1 `
@@ -42,8 +24,6 @@ pwsh ./.github/skills/release-note-generation/scripts/dump-prs-since-commit.ps1
-OutputDir 'Generated Files/ReleaseNotes'
```
If you do refresh, redo Step 3.1 afterwards to repopulate `CopilotSummary`.
---
## 3.3 Group PRs by Label
@@ -55,4 +35,3 @@ pwsh ./.github/skills/release-note-generation/scripts/group-prs-by-label.ps1 -Cs
Creates `Generated Files/ReleaseNotes/grouped_csv/` with one CSV per label combination.
**Validation:** The `Unlabeled.csv` file should be minimal (ideally empty). If many PRs remain unlabeled, return to Step 2 (see [step2-labeling.md](./step2-labeling.md)).

View File

@@ -1,334 +0,0 @@
<#
.SYNOPSIS
Prepares the binary assets for a PowerToys GitHub release: downloads the
four installers (per-user/per-machine x x64/arm64) and the symbol archives
from an ADO pipeline build, computes SHA256 hashes, and emits the
"Installer Hashes" markdown table.
.DESCRIPTION
Given an ADO Dart pipeline build id (e.g. from
https://microsoft.visualstudio.com/Dart/_build/results?buildId=NNN),
downloads the four installer EXEs and the per-arch symbol zips into a
single per-version folder, then writes a hashes.md alongside them with a
markdown table ready to paste into the GitHub release notes.
Requires: az login (Azure CLI authenticated), az devops extension.
.EXAMPLE
.\prepare-release-assets.ps1 -BuildId 145505247
.\prepare-release-assets.ps1 -BuildId 145505247 -OutputFolder D:\Releases
#>
param(
[Parameter(Mandatory = $true)]
[int]$BuildId,
[string]$OutputFolder = "$env:USERPROFILE\Downloads",
[string]$Organization = "https://dev.azure.com/microsoft",
[string]$Project = "Dart",
[string]$GitHubRepo = "microsoft/PowerToys"
)
$ErrorActionPreference = "Stop"
$env:AZURE_CORE_NO_PROMPT = "true"
# --- Helpers -----------------------------------------------------------------
# Invoke an `az` CLI command and capture stderr in $script:LastAzError so
# callers can surface the underlying message (expired login, blocked extension,
# tenant policy, ...) instead of swallowing it with `2>$null`.
function Invoke-Az {
$tmpErr = [System.IO.Path]::GetTempFileName()
try {
$output = & az @args 2>$tmpErr
# Get-Content -Raw returns $null for an empty file, and calling .Trim()
# on $null throws under $ErrorActionPreference = 'Stop' -- which would
# turn every successful (no-stderr) az call into a fatal error. Guard
# explicitly so $script:LastAzError is always a (possibly empty) string.
$rawErr = Get-Content $tmpErr -Raw -ErrorAction SilentlyContinue
$script:LastAzError = if ($null -eq $rawErr) { '' } else { $rawErr.Trim() }
return $output
}
finally {
Remove-Item $tmpErr -Force -ErrorAction SilentlyContinue
}
}
# Build an ADO artifact download URL from scratch instead of regex-replacing
# the URL returned by `az pipelines runs artifact list`. Preserves any other
# query parameters and only swaps `format` and `subPath`, so we don't break if
# the upstream URL shape ever changes.
function Get-ArtifactDownloadUrl {
param(
[Parameter(Mandatory)][string]$BaseUrl,
[Parameter(Mandatory)][string]$SubPath,
[Parameter(Mandatory)][ValidateSet('file', 'zip')][string]$Format
)
$encodedSubPath = [Uri]::EscapeDataString($SubPath)
$idx = $BaseUrl.IndexOf('?')
if ($idx -lt 0) {
return "${BaseUrl}?format=${Format}&subPath=${encodedSubPath}"
}
$base = $BaseUrl.Substring(0, $idx)
$kept = $BaseUrl.Substring($idx + 1) -split '&' | Where-Object {
$_ -and -not ($_ -match '^(format|subPath)=')
}
$kept = @($kept) + @("format=$Format", "subPath=$encodedSubPath")
return "${base}?$($kept -join '&')"
}
# Download a single ADO artifact file with bearer auth and a small retry/backoff
# loop. A transient network blip on a ~200 MB installer or symbol zip otherwise
# aborts the entire release-prep run.
function Invoke-AdoDownload {
param(
[Parameter(Mandatory)][string]$Url,
[Parameter(Mandatory)][string]$DestPath,
[Parameter(Mandatory)][string]$Token,
[int]$MaxAttempts = 3
)
$lastError = $null
for ($attempt = 1; $attempt -le $MaxAttempts; $attempt++) {
$webClient = New-Object System.Net.WebClient
$webClient.Headers.Add("Authorization", "Bearer $Token")
try {
$webClient.DownloadFile($Url, $DestPath)
return
}
catch {
$lastError = $_
if (Test-Path $DestPath) {
Remove-Item $DestPath -Force -ErrorAction SilentlyContinue
}
if ($attempt -lt $MaxAttempts) {
$backoffSec = [int][Math]::Pow(2, $attempt) # 2, 4, 8 ...
Write-Host " Attempt $attempt failed: $($_.Exception.Message). Retrying in ${backoffSec}s..." -ForegroundColor Yellow
Start-Sleep -Seconds $backoffSec
}
}
finally {
$webClient.Dispose()
}
}
throw "Download failed after $MaxAttempts attempts. Last error: $($lastError.Exception.Message)`nURL: $Url"
}
# -----------------------------------------------------------------------------
# Work around broken az extensions: if the default extension dir has
# inaccessible files, redirect to a clean directory.
$defaultExtDir = "$env:USERPROFILE\.azure\cliextensions"
if (-not $env:AZURE_EXTENSION_DIR -and (Test-Path $defaultExtDir)) {
$broken = Get-ChildItem "$defaultExtDir\*\*.dist-info" -Directory -ErrorAction SilentlyContinue | Where-Object {
try { [System.IO.Directory]::GetFiles($_.FullName) | Out-Null; $false } catch { $true }
}
if ($broken) {
$cleanDir = "$env:USERPROFILE\.azure\cliextensions_clean"
Write-Host " Detected broken az extension, redirecting to $cleanDir" -ForegroundColor Yellow
$env:AZURE_EXTENSION_DIR = $cleanDir
if (-not (Test-Path $cleanDir)) { New-Item -ItemType Directory -Path $cleanDir -Force | Out-Null }
}
}
# Ensure azure-devops extension is installed
$ext = Invoke-Az extension list --query "[?name=='azure-devops']" -o tsv
if (-not $ext) {
Write-Host "Installing azure-devops extension..." -ForegroundColor Yellow
Invoke-Az extension add --name azure-devops --yes | Out-Null
if ($LASTEXITCODE -ne 0) {
Write-Error "Failed to install azure-devops extension. (az: $script:LastAzError)"
exit 1
}
}
# Configure az devops defaults
Invoke-Az devops configure --defaults organization=$Organization project=$Project | Out-Null
if ($LASTEXITCODE -ne 0) {
Write-Error "Failed to configure az devops defaults. (az: $script:LastAzError)"
exit 1
}
# --- Step 1: Get build info to determine version ---
Write-Host "Fetching build $BuildId info..." -ForegroundColor Cyan
$buildJson = Invoke-Az pipelines build show --id $BuildId --output json
if (-not $buildJson) {
Write-Error "Could not fetch build $BuildId. Are you logged in (az login)? (az: $script:LastAzError)"
exit 1
}
$build = $buildJson | ConvertFrom-Json
$versionParam = $build.templateParameters.VersionNumber
if (-not $versionParam) {
Write-Error "Could not determine version from build $BuildId"
exit 1
}
Write-Host " Version: $versionParam" -ForegroundColor DarkGray
# --- Step 2: Get artifact metadata once ---
Write-Host "Fetching artifact metadata..." -ForegroundColor Cyan
$artifactsJson = Invoke-Az pipelines runs artifact list --run-id $BuildId --output json
if (-not $artifactsJson) {
Write-Error "Could not list artifacts for build $BuildId. (az: $script:LastAzError)"
exit 1
}
$artifacts = $artifactsJson | ConvertFrom-Json
# --- Step 3: Prepare destination folder ---
$destFolder = Join-Path $OutputFolder "PowerToys-v$versionParam"
if (-not (Test-Path $destFolder)) {
New-Item -ItemType Directory -Path $destFolder -Force | Out-Null
}
Write-Host " Destination: $destFolder" -ForegroundColor DarkGray
# --- Step 4: Get an ADO access token once ---
$token = Invoke-Az account get-access-token --resource "499b84ac-1321-427f-aa17-267ca6975798" --query accessToken -o tsv
if (-not $token) {
Write-Error "Failed to acquire ADO access token. Run 'az login' first. (az: $script:LastAzError)"
exit 1
}
# --- Step 5: Define the four installers to download ---
$targets = @(
[pscustomobject]@{ Description = "Per user - x64"; Scope = "perUser"; Arch = "x64"; Artifact = "build-x64-Release"; FileName = "PowerToysUserSetup-$versionParam-x64.exe" }
[pscustomobject]@{ Description = "Per user - ARM64"; Scope = "perUser"; Arch = "arm64"; Artifact = "build-arm64-Release"; FileName = "PowerToysUserSetup-$versionParam-arm64.exe" }
[pscustomobject]@{ Description = "Machine wide - x64"; Scope = "perMachine"; Arch = "x64"; Artifact = "build-x64-Release"; FileName = "PowerToysSetup-$versionParam-x64.exe" }
[pscustomobject]@{ Description = "Machine wide - ARM64"; Scope = "perMachine"; Arch = "arm64"; Artifact = "build-arm64-Release"; FileName = "PowerToysSetup-$versionParam-arm64.exe" }
)
# --- Step 6: Download each installer (skip if already present) ---
foreach ($t in $targets) {
$destPath = Join-Path $destFolder $t.FileName
if (Test-Path $destPath) {
$sizeMB = [math]::Round((Get-Item $destPath).Length / 1MB, 1)
Write-Host "[skip] $($t.FileName) already exists ($sizeMB MB)" -ForegroundColor DarkGray
continue
}
$artifact = $artifacts | Where-Object { $_.name -eq $t.Artifact }
if (-not $artifact) {
Write-Error "Artifact '$($t.Artifact)' not found in build $BuildId. Available: $(($artifacts | ForEach-Object name) -join ', ')"
exit 1
}
$fileUrl = Get-ArtifactDownloadUrl -BaseUrl $artifact.resource.downloadUrl -SubPath "/$($t.FileName)" -Format file
Write-Host "Downloading $($t.FileName) ..." -ForegroundColor Cyan
try {
Invoke-AdoDownload -Url $fileUrl -DestPath $destPath -Token $token
}
catch {
Write-Error "Download failed for $($t.FileName): $_"
exit 1
}
$sizeMB = [math]::Round((Get-Item $destPath).Length / 1MB, 1)
Write-Host " Saved ($sizeMB MB)" -ForegroundColor Green
}
# --- Step 6b: Download symbols (one zip per arch) ---
$symbolTargets = @(
[pscustomobject]@{ Arch = "x64"; Artifact = "build-x64-Release"; SubPath = "/symbols-x64" }
[pscustomobject]@{ Arch = "arm64"; Artifact = "build-arm64-Release"; SubPath = "/symbols-arm64" }
)
foreach ($s in $symbolTargets) {
$finalZip = Join-Path $destFolder "symbols-$($s.Arch).zip"
if (Test-Path $finalZip) {
$sizeMB = [math]::Round((Get-Item $finalZip).Length / 1MB, 1)
Write-Host "[skip] symbols-$($s.Arch).zip already exists ($sizeMB MB)" -ForegroundColor DarkGray
continue
}
$artifact = $artifacts | Where-Object { $_.name -eq $s.Artifact }
if (-not $artifact) {
Write-Error "Artifact '$($s.Artifact)' not found in build $BuildId."
exit 1
}
# Symbols are downloaded as a folder => keep format=zip and append subPath
$symbolsUrl = Get-ArtifactDownloadUrl -BaseUrl $artifact.resource.downloadUrl -SubPath $s.SubPath -Format zip
$tmpZip = Join-Path ([System.IO.Path]::GetTempPath()) ("ptsym-$($s.Arch)-$([Guid]::NewGuid().ToString('N')).zip")
$tmpExtract = Join-Path ([System.IO.Path]::GetTempPath()) ("ptsym-$($s.Arch)-$([Guid]::NewGuid().ToString('N'))")
$stageRoot = Join-Path ([System.IO.Path]::GetTempPath()) ("ptsym-stage-$([Guid]::NewGuid().ToString('N'))")
try {
Write-Host "Downloading symbols-$($s.Arch).zip ..." -ForegroundColor Cyan
try {
Invoke-AdoDownload -Url $symbolsUrl -DestPath $tmpZip -Token $token
}
catch {
Write-Error "Symbols download failed for $($s.Arch): $_"
exit 1
}
Write-Host " Extracting..." -ForegroundColor DarkGray
Expand-Archive -Path $tmpZip -DestinationPath $tmpExtract -Force
# Walk down while the current dir holds exactly one subfolder and no files.
$current = Get-Item $tmpExtract
while ($true) {
$children = Get-ChildItem -LiteralPath $current.FullName -Force
$subDirs = @($children | Where-Object { $_.PSIsContainer })
$files = @($children | Where-Object { -not $_.PSIsContainer })
if ($subDirs.Count -eq 1 -and $files.Count -eq 0) {
$current = $subDirs[0]
}
else {
break
}
}
# Stage to a folder named symbols-<arch> so the zip extracts to that name.
$stageInner = Join-Path $stageRoot "symbols-$($s.Arch)"
New-Item -ItemType Directory -Path $stageInner -Force | Out-Null
Get-ChildItem -LiteralPath $current.FullName -Force | ForEach-Object {
Copy-Item -LiteralPath $_.FullName -Destination $stageInner -Recurse -Force
}
Write-Host " Repacking to $finalZip ..." -ForegroundColor DarkGray
if (Test-Path $finalZip) { Remove-Item $finalZip -Force }
Compress-Archive -Path "$stageInner\*" -DestinationPath $finalZip -CompressionLevel Optimal
$sizeMB = [math]::Round((Get-Item $finalZip).Length / 1MB, 1)
Write-Host " Saved symbols-$($s.Arch).zip ($sizeMB MB)" -ForegroundColor Green
}
catch {
# Don't leave a half-built zip behind if anything in the pipeline blew up.
if (Test-Path $finalZip) { Remove-Item $finalZip -Force -ErrorAction SilentlyContinue }
throw
}
finally {
Remove-Item $tmpZip -Force -ErrorAction SilentlyContinue
Remove-Item $tmpExtract -Recurse -Force -ErrorAction SilentlyContinue
Remove-Item $stageRoot -Recurse -Force -ErrorAction SilentlyContinue
}
}
# --- Step 7: Compute SHA256 and build markdown ---
Write-Host "`nComputing SHA256 hashes..." -ForegroundColor Cyan
$sb = [System.Text.StringBuilder]::new()
[void]$sb.AppendLine("## Installer Hashes")
[void]$sb.AppendLine("")
[void]$sb.AppendLine("| Description | Filename | sha256 hash |")
[void]$sb.AppendLine("| --- | --- | --- |")
foreach ($t in $targets) {
$destPath = Join-Path $destFolder $t.FileName
$hash = (Get-FileHash -Path $destPath -Algorithm SHA256).Hash.ToUpper()
[void]$sb.AppendLine("| $($t.Description) | $($t.FileName) | $hash |")
Write-Host " $($t.FileName) $hash" -ForegroundColor DarkGray
}
$markdown = $sb.ToString()
$mdPath = Join-Path $destFolder "hashes.md"
Set-Content -Path $mdPath -Value $markdown -Encoding UTF8
Write-Host "`nMarkdown written to: $mdPath" -ForegroundColor Green
Write-Host "`n----- Installer Hashes -----`n" -ForegroundColor Yellow
Write-Host $markdown
Write-Host "Draft a new GitHub release at: https://github.com/$GitHubRepo/releases/new?tag=v$versionParam" -ForegroundColor Green

View File

@@ -1,9 +1,9 @@
name: Automatic Triaging on Issue Creation
name: Auto-label Issues by Area
on:
issues:
types: [opened, reopened]
# Manual trigger: go to Actions → "Automatic Triaging on Issue Creation" → Run workflow.
# Manual trigger: go to Actions → "Auto-label Issues by Area" → Run workflow.
# Enter one or more comma-separated issue numbers (e.g. "1234" or "1234,1235,1236")
# to apply AI-generated area labels to existing untriaged issues.
workflow_dispatch:
@@ -28,12 +28,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Apply area labels with AI
uses: actions/github-script@v9
env:
# actions/github-script does not propagate `github-token` to
# process.env. Expose it explicitly so the inline script can
# authenticate against the GitHub Models inference endpoint.
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
uses: actions/github-script@v7
with:
github-token: ${{ secrets.GITHUB_TOKEN }}
script: |

275
.github/workflows/auto-label-product.yml vendored Normal file
View File

@@ -0,0 +1,275 @@
name: Auto Label Product on Issue Creation
on:
issues:
types: [opened]
workflow_dispatch:
inputs:
mode:
description: 'single: label one issue, batch: label all issues missing Product- labels'
required: true
type: choice
options:
- single
- batch
default: single
issue_number:
description: 'Issue number (only used in single mode)'
required: false
type: number
dry_run:
description: 'If true, only log what labels would be applied without applying them'
required: false
type: boolean
default: true
batch_limit:
description: 'Max issues to process in batch mode (default: 50)'
required: false
type: number
default: 50
permissions:
issues: write
models: read
concurrency:
group: ${{ github.workflow }}-${{ github.event.issue.number || github.event.inputs.issue_number || 'batch' }}
cancel-in-progress: true
jobs:
label-product:
runs-on: ubuntu-latest
steps:
- name: Auto-apply Product labels
uses: actions/github-script@v7
with:
script: |
const isManual = context.eventName === 'workflow_dispatch';
const dryRun = isManual ? (context.payload.inputs.dry_run === 'true') : false;
const batchMode = isManual && context.payload.inputs.mode === 'batch';
const batchLimit = isManual ? parseInt(context.payload.inputs.batch_limit || '50') : 1;
// Mapping from issue template "Area(s) with issue?" values to Product- labels
const AREA_TO_LABEL = {
'Advanced Paste': 'Product-Advanced Paste',
'Always on Top': 'Product-Always On Top',
'Awake': 'Product-Awake',
'ColorPicker': 'Product-Color Picker',
'Command not found': 'Product-CommandNotFound',
'Command Palette': 'Product-Command Palette',
'Crop and Lock': 'Product-CropAndLock',
'Environment Variables': 'Product-Environment Variables',
'FancyZones': 'Product-FancyZones',
'FancyZones Editor': 'Product-FancyZones',
'File Locksmith': 'Product-File Locksmith',
'File Explorer: Preview Pane': 'Product-File Explorer',
'File Explorer: Thumbnail preview': 'Product-File Explorer',
'Hosts File Editor': 'Product-Hosts File Editor',
'Image Resizer': 'Product-Image Resizer',
'Keyboard Manager': 'Product-Keyboard Shortcut Manager',
'Light Switch': 'Product-LightSwitch',
'Mouse Utilities': 'Product-Mouse Utilities',
'Mouse Without Borders': 'Product-Mouse Without Borders',
'New+': 'Product-New+',
'Peek': 'Product-Peek',
'Power Display': 'Product-PowerDisplay',
'PowerRename': 'Product-PowerRename',
'PowerToys Run': 'Product-PowerToys Run',
'Quick Accent': 'Product-Quick Accent',
'Registry Preview': 'Product-Registry Preview',
'Screen ruler': 'Product-Screen Ruler',
'Settings': 'Product-Settings',
'Shortcut Guide': 'Product-Shortcut Guide',
'TextExtractor': 'Product-Text Extractor',
'Workspaces': 'Product-Workspaces',
'ZoomIt': 'Product-ZoomIt',
'General': 'Product-General',
'Grab And Move': 'Product-Grab And Move',
};
const ALL_PRODUCT_LABELS = [...new Set(Object.values(AREA_TO_LABEL))].sort();
// ─── Collect issues to process ───
let issues = [];
if (batchMode) {
// Fetch open issues that have no Product-* label
core.info(`Batch mode: fetching up to ${batchLimit} issues without Product- labels...`);
let page = 1;
while (issues.length < batchLimit) {
const { data } = await github.rest.issues.listForRepo({
owner: context.repo.owner,
repo: context.repo.repo,
state: 'open',
per_page: 100,
page: page++,
sort: 'created',
direction: 'desc',
});
if (data.length === 0) break;
for (const issue of data) {
if (issue.pull_request) continue; // skip PRs
const hasProductLabel = issue.labels.some(l => l.name.startsWith('Product-'));
if (!hasProductLabel) {
issues.push(issue);
if (issues.length >= batchLimit) break;
}
}
}
core.info(`Found ${issues.length} issues to process.`);
} else if (isManual) {
const issueNumber = parseInt(context.payload.inputs.issue_number);
if (!issueNumber) { core.setFailed('issue_number is required in single mode'); return; }
const { data } = await github.rest.issues.get({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: issueNumber,
});
issues = [data];
} else {
issues = [context.payload.issue];
}
// ─── Process each issue ───
const summaryRows = [];
const labelExistsCache = new Map();
for (const issue of issues) {
const body = issue.body || '';
const title = issue.title || '';
// Parse the "Area(s) with issue?" field
const areaMatch = body.match(/### Area\(s\) with issue\?\s*\r?\n\r?\n([\s\S]*?)(?=\r?\n\r?\n###|\r?\n*$)/);
let selectedAreas = [];
if (areaMatch) {
const areaText = areaMatch[1].trim();
selectedAreas = areaText.split(',').map(s => s.trim()).filter(Boolean);
}
// Resolve labels from the structured field
const resolvedLabels = new Set();
for (const area of selectedAreas) {
if (AREA_TO_LABEL[area]) {
resolvedLabels.add(AREA_TO_LABEL[area]);
}
}
// AI fallback if no deterministic match
if (resolvedLabels.size === 0) {
core.info(`#${issue.number}: No deterministic match, trying AI inference...`);
try {
const prompt = `You are a GitHub issue triage assistant for the PowerToys project.
Given the following issue title and body, determine which PowerToys product(s) this issue is PRIMARILY about.
Rules:
- Only include products the issue is directly reporting a bug for or requesting a feature in.
- Do NOT include products that are merely mentioned as examples or comparisons.
- When in doubt, prefer fewer labels over more. One correct label is better than many guesses.
- If the issue is about general PowerToys infrastructure (installer, settings app, system tray), use "Product-General" or "Product-Settings" as appropriate.
Respond with ONLY a JSON array of label strings from this list:
${JSON.stringify(ALL_PRODUCT_LABELS)}
If you cannot determine the product, respond with an empty array: []
Issue title: ${title}
Issue body (first 2000 chars):
${body.substring(0, 2000)}`;
const response = await fetch('https://models.github.ai/inference/chat/completions', {
method: 'POST',
headers: {
'Authorization': `Bearer ${process.env.GITHUB_TOKEN}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
model: 'openai/gpt-4.1-mini',
messages: [{ role: 'user', content: prompt }],
temperature: 0,
}),
});
if (response.ok) {
const data = await response.json();
const content = data.choices?.[0]?.message?.content || '';
const jsonMatch = content.match(/\[[\s\S]*?\]/);
if (jsonMatch) {
const inferred = JSON.parse(jsonMatch[0]);
for (const label of inferred) {
if (ALL_PRODUCT_LABELS.includes(label)) {
resolvedLabels.add(label);
}
}
}
core.info(`#${issue.number}: AI inferred: ${[...resolvedLabels].join(', ') || '(none)'}`);
} else {
core.warning(`#${issue.number}: AI inference failed (${response.status})`);
}
} catch (err) {
core.warning(`#${issue.number}: AI error: ${err.message}`);
}
}
if (resolvedLabels.size === 0) {
summaryRows.push([`#${issue.number}`, title.substring(0, 60), '(none)', 'skipped']);
continue;
}
// Validate labels exist (cached to reduce API calls in batch mode)
const labelsToApply = [];
for (const label of resolvedLabels) {
let exists = labelExistsCache.get(label);
if (exists === undefined) {
try {
await github.rest.issues.getLabel({
owner: context.repo.owner,
repo: context.repo.repo,
name: label,
});
exists = true;
} catch (err) {
if (err.status === 404) {
exists = false;
} else {
throw err;
}
}
labelExistsCache.set(label, exists);
}
if (exists) {
labelsToApply.push(label);
} else {
core.warning(`Label "${label}" not found in repo, skipping.`);
}
}
if (labelsToApply.length === 0) {
summaryRows.push([`#${issue.number}`, title.substring(0, 60), '(labels not found)', 'skipped']);
continue;
}
// Apply or dry-run
if (dryRun) {
core.info(`[DRY RUN] #${issue.number}: would apply ${labelsToApply.join(', ')}`);
summaryRows.push([`#${issue.number}`, title.substring(0, 60), labelsToApply.join(', '), 'dry-run']);
} else {
await github.rest.issues.addLabels({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: issue.number,
labels: labelsToApply,
});
core.info(`#${issue.number}: applied ${labelsToApply.join(', ')}`);
summaryRows.push([`#${issue.number}`, title.substring(0, 60), labelsToApply.join(', '), 'applied']);
}
}
// Write job summary
if (summaryRows.length > 0) {
core.summary.addHeading(`Auto-Label Results (${dryRun ? 'Dry Run' : 'Applied'})`, 3);
core.summary.addTable([
[{data: 'Issue', header: true}, {data: 'Title', header: true}, {data: 'Labels', header: true}, {data: 'Status', header: true}],
...summaryRows,
]);
await core.summary.write();
}

View File

@@ -64,7 +64,7 @@ jobs:
- template: steps-ensure-dotnet-version.yml
parameters:
sdk: true
version: '10.0'
version: '9.0'
- template: .\steps-restore-nuget.yml

View File

@@ -27,7 +27,6 @@ stages:
name: SHINE-INT-L
${{ else }}:
name: SHINE-OSS-L
demands: ImageOverride -equals SHINE-VS18-Latest
buildPlatforms:
- ${{ parameters.platform }}
uiTestModules: ${{ parameters.uiTestModules }}

View File

@@ -65,20 +65,4 @@
<!-- Note: For C++ skipped test projects, build is effectively skipped by removing all compile items above.
We don't define empty Build/Rebuild/Clean targets here because MSBuild Target definitions with Condition
on the Target element still override the default targets even when condition is false. -->
<!-- Clean up unused VC++ runtime DLLs that CopyCppRuntimeToOutputDir copies from the full
VCRedist tree (MFC, C++ AMP, OpenMP). No PowerToys binary links against these — verified
with dumpbin /dependents across all installed binaries. -->
<Target Name="RemoveUnusedVCRuntimeDlls"
AfterTargets="Build"
Condition="'$(CopyCppRuntimeToOutputDir)' == 'true' and '$(MSBuildProjectExtension)' == '.vcxproj'">
<ItemGroup>
<_UnusedVCRuntimeDlls Include="$(OutDir)mfc140*.dll" />
<_UnusedVCRuntimeDlls Include="$(OutDir)mfcm140*.dll" />
<_UnusedVCRuntimeDlls Include="$(OutDir)vcamp140*.dll" />
<_UnusedVCRuntimeDlls Include="$(OutDir)vcomp140*.dll" />
</ItemGroup>
<Delete Files="@(_UnusedVCRuntimeDlls)" Condition="'@(_UnusedVCRuntimeDlls)' != ''" />
<Message Importance="normal" Text="Cleaned up unused VC runtime DLLs: @(_UnusedVCRuntimeDlls)" Condition="'@(_UnusedVCRuntimeDlls)' != ''" />
</Target>
</Project>

View File

@@ -110,6 +110,8 @@
<PackageVersion Include="System.ComponentModel.Composition" Version="10.0.7" />
<PackageVersion Include="System.Configuration.ConfigurationManager" Version="10.0.7" />
<PackageVersion Include="System.Data.OleDb" Version="10.0.7" />
<!-- Package System.Data.SqlClient added to force it as a dependency of Microsoft.Windows.Compatibility to the latest version available at this time. -->
<PackageVersion Include="System.Data.SqlClient" Version="4.9.1" />
<!-- Package System.Diagnostics.EventLog added as a hack for being able to exclude the runtime assets so they don't conflict with 8.0.1. This is a dependency of System.Data.OleDb but the 8.0.1 version wasn't published to nuget. -->
<PackageVersion Include="System.Diagnostics.EventLog" Version="10.0.7" />
<!-- Package System.Diagnostics.PerformanceCounter added as a hack for being able to exclude the runtime assets so they don't conflict with 8.0.11. -->

View File

@@ -1,94 +0,0 @@
// spdlog-msvc-fix.h
//
// Workaround for MSVC 14.51 (compiler version 19.51, _MSC_VER >= 1951) removing
// stdext::checked_array_iterator. Force-included for all spdlog consumers via
// deps/spdlog.props, because spdlog v1.8.5's bundled fmt format.h(357) still
// references this type inside #if defined(_SECURE_SCL) && _SECURE_SCL -- a
// branch entered in Debug builds where _ITERATOR_DEBUG_LEVEL > 0.
//
// On MSVC 14.50 and earlier, the type still exists in <iterator>, so this shim
// is a no-op via the _MSC_VER guard. On MSVC 14.51+, it provides a minimal
// pointer-backed substitute that satisfies the bundled fmt's usage:
//
// template <typename T> using checked_ptr = stdext::checked_array_iterator<T*>;
// template <typename T> checked_ptr<T> make_checked(T* p, size_t size) {
// return {p, size};
// }
// ... return make_checked(get_data(c) + size, n);
//
// When deps/spdlog is bumped past v1.14 (which ships fmt 10.2 and drops this
// dependency), this shim and its <ForcedIncludeFiles> entry in deps/spdlog.props
// can be deleted.
#pragma once
#if defined(__cplusplus) && defined(_MSC_VER) && _MSC_VER >= 1951
#include <cstddef>
#include <iterator>
#include <type_traits>
namespace stdext
{
template <typename _Ptr>
class checked_array_iterator
{
_Ptr _Myarray = nullptr;
std::size_t _Mysize = 0;
std::size_t _Myindex = 0;
public:
using iterator_category = std::random_access_iterator_tag;
using value_type = std::remove_cv_t<std::remove_pointer_t<_Ptr>>;
using difference_type = std::ptrdiff_t;
using pointer = _Ptr;
using reference = std::remove_pointer_t<_Ptr>&;
constexpr checked_array_iterator() = default;
constexpr checked_array_iterator(_Ptr arr, std::size_t size, std::size_t idx = 0) noexcept
: _Myarray(arr), _Mysize(size), _Myindex(idx)
{
}
constexpr reference operator*() const noexcept { return _Myarray[_Myindex]; }
constexpr pointer operator->() const noexcept { return _Myarray + _Myindex; }
constexpr reference operator[](difference_type n) const noexcept
{
return _Myarray[_Myindex + static_cast<std::size_t>(n)];
}
constexpr checked_array_iterator& operator++() noexcept { ++_Myindex; return *this; }
constexpr checked_array_iterator operator++(int) noexcept { auto t = *this; ++_Myindex; return t; }
constexpr checked_array_iterator& operator--() noexcept { --_Myindex; return *this; }
constexpr checked_array_iterator operator--(int) noexcept { auto t = *this; --_Myindex; return t; }
constexpr checked_array_iterator& operator+=(difference_type n) noexcept
{
_Myindex = static_cast<std::size_t>(static_cast<difference_type>(_Myindex) + n);
return *this;
}
constexpr checked_array_iterator& operator-=(difference_type n) noexcept
{
_Myindex = static_cast<std::size_t>(static_cast<difference_type>(_Myindex) - n);
return *this;
}
friend constexpr checked_array_iterator operator+(checked_array_iterator it, difference_type n) noexcept { it += n; return it; }
friend constexpr checked_array_iterator operator+(difference_type n, checked_array_iterator it) noexcept { return it + n; }
friend constexpr checked_array_iterator operator-(checked_array_iterator it, difference_type n) noexcept { it -= n; return it; }
friend constexpr difference_type operator-(checked_array_iterator a, checked_array_iterator b) noexcept
{
return static_cast<difference_type>(a._Myindex) - static_cast<difference_type>(b._Myindex);
}
friend constexpr bool operator==(checked_array_iterator a, checked_array_iterator b) noexcept { return a._Myindex == b._Myindex; }
friend constexpr bool operator!=(checked_array_iterator a, checked_array_iterator b) noexcept { return !(a == b); }
friend constexpr bool operator<(checked_array_iterator a, checked_array_iterator b) noexcept { return a._Myindex < b._Myindex; }
friend constexpr bool operator>(checked_array_iterator a, checked_array_iterator b) noexcept { return b < a; }
friend constexpr bool operator<=(checked_array_iterator a, checked_array_iterator b) noexcept { return !(b < a); }
friend constexpr bool operator>=(checked_array_iterator a, checked_array_iterator b) noexcept { return !(a < b); }
};
} // namespace stdext
#endif // __cplusplus && _MSC_VER >= 1951

1
deps/spdlog.props vendored
View File

@@ -3,7 +3,6 @@
<ClCompile>
<AdditionalIncludeDirectories>$(MSBuildThisFileDirectory)spdlog\include;%(AdditionalIncludeDirectories)</AdditionalIncludeDirectories>
<PreprocessorDefinitions>SPDLOG_WCHAR_TO_UTF8_SUPPORT;SPDLOG_COMPILED_LIB;SPDLOG_WCHAR_FILENAMES;%(PreprocessorDefinitions)</PreprocessorDefinitions>
<ForcedIncludeFiles>$(MSBuildThisFileDirectory)spdlog-msvc-fix\include\spdlog-msvc-fix.h;%(ForcedIncludeFiles)</ForcedIncludeFiles>
</ClCompile>
</ItemDefinitionGroup>
</Project>

View File

@@ -9,7 +9,7 @@ the in-app **Extension gallery** page.
HTTPS URL, parses it, and renders the entries.
- The default feed lives in the external repo
**`microsoft/CmdPal-Extensions`** at
`https://aka.ms/CmdPal-ExtensionsJson`.
`https://raw.githubusercontent.com/microsoft/CmdPal-Extensions/refs/heads/main/extensions.json`.
- Feed content + icon images are cached on disk so the page works offline and
survives short network hiccups.
- There is no WinGet discovery, no per-extension `manifest.json` fetch, and no
@@ -32,7 +32,7 @@ the in-app **Extension gallery** page.
exposed via the hidden `InternalPage` settings page). Any non-empty value
wins. Mostly used for local testing against a custom feed.
2. Otherwise, the built-in default
`https://aka.ms/CmdPal-ExtensionsJson`.
`https://raw.githubusercontent.com/microsoft/CmdPal-Extensions/refs/heads/main/extensions.json`.
Local `file://` URIs are allowed too — `FetchFeedDocumentAsync` reads the file
directly and bypasses the HTTP cache.
@@ -109,7 +109,7 @@ cacheable icon URLs are cached.
| Cache root | `{AppCache}\GalleryCache\` | `ExtensionGalleryHttpClient.CacheDirectoryName` |
| Feed TTL | 4 hours | `ExtensionGalleryHttpClient.DefaultTimeToLive` |
| Icon TTL | 24 hours | `ExtensionGalleryService.IconCacheTtl` |
| HTTP timeout | 15 s | `ExtensionGalleryHttpClient` |
| HTTP timeout | 30 s | `ExtensionGalleryHttpClient` |
| `User-Agent` | `PowerToys-CmdPal/1.0` | `ExtensionGalleryHttpClient` |
`{AppCache}` resolves to `ApplicationData.Current.LocalCacheFolder` when

View File

@@ -4,7 +4,6 @@
#include <ProjectTelemetry.h>
#include <spdlog/sinks/base_sink.h>
#include <filesystem>
#include <fstream>
#include <string_view>
#include "../../src/common/logger/logger.h"
@@ -1808,223 +1807,6 @@ void initSystemLogger()
} });
}
// Naming note: the *Hardlinks* names in this CA, the matching WiX CustomAction Ids
// in Product.wxs, and the manifest filename "hardlinks.txt" are kept for continuity
// with the original PR design. The implementation uses fs::copy_file -- not
// CreateHardLinkW -- because hard-links share an inode (and DACL) between root and
// WinUI3Apps, which lets MSIX sparse-package registration propagate a rich DACL onto
// the root copy of files like hostfxr.dll and break LOW-IL prevhost.exe loads,
// turning the Monaco preview pane blank. Copies create a fresh inode in WinUI3Apps so the root
// copy keeps its simple DACL. See the in-body comment for the full RCA reference.
UINT __stdcall CreateWinAppSDKHardlinksCA(MSIHANDLE hInstall)
{
HRESULT hr = S_OK;
UINT er = ERROR_SUCCESS;
std::wstring installationFolder;
hr = WcaInitialize(hInstall, "CreateWinAppSDKHardlinks");
ExitOnFailure(hr, "Failed to initialize");
hr = getInstallFolder(hInstall, installationFolder);
ExitOnFailure(hr, "Failed to get installFolder.");
{
namespace fs = std::filesystem;
const fs::path installDir(installationFolder);
const fs::path winui3Dir = installDir / L"WinUI3Apps";
const fs::path manifestPath = winui3Dir / L"hardlinks.txt";
if (!fs::exists(manifestPath))
{
WcaLog(LOGMSG_STANDARD, "CreateWinAppSDKHardlinks: No hardlinks.txt manifest found, skipping.");
goto LExit;
}
std::ifstream manifestFile(manifestPath); // Read as bytes, then convert UTF-8 -> wide explicitly.
std::string narrowLine;
int created = 0;
int failed = 0;
// INSTALLFOLDER from MSI typically arrives with a trailing backslash. lexically_normal
// preserves that as an empty trailing path component, which would later make the
// per-component std::mismatch containment check below reject every legitimate entry.
// Strip any trailing separators before normalizing.
auto stripTrailingSep = [](fs::path p) {
auto s = p.native();
while (s.size() > 1 && (s.back() == L'\\' || s.back() == L'/')) s.pop_back();
return fs::path(s);
};
// Normalize once so the per-line containment check below is cheap.
const fs::path installDirNorm = stripTrailingSep(installDir).lexically_normal();
const fs::path winui3DirNorm = stripTrailingSep(winui3Dir).lexically_normal();
while (std::getline(manifestFile, narrowLine))
{
if (narrowLine.empty())
{
continue;
}
// Strip CR if the manifest uses CRLF line endings.
if (narrowLine.back() == '\r')
{
narrowLine.pop_back();
if (narrowLine.empty()) continue;
}
// Manifest is written as UTF-8 (no BOM) -- convert to wide string explicitly
// rather than relying on the locale-default codecvt of std::wifstream, which is
// the ANSI code page on Windows and would silently mangle any non-ASCII path.
const int wideLen = MultiByteToWideChar(CP_UTF8, MB_ERR_INVALID_CHARS, narrowLine.c_str(), -1, nullptr, 0);
if (wideLen <= 0)
{
WcaLog(LOGMSG_STANDARD, "CreateWinAppSDKHardlinks: Skipping non-UTF-8 entry: %hs", narrowLine.c_str());
failed++;
continue;
}
std::wstring fileName(static_cast<size_t>(wideLen) - 1, L'\0');
MultiByteToWideChar(CP_UTF8, MB_ERR_INVALID_CHARS, narrowLine.c_str(), -1, fileName.data(), wideLen);
// Defense-in-depth: reject manifest entries that would escape the install root
// via "..", absolute paths, or alternate stream syntax. lexically_normal collapses
// any "." / ".." / repeated separators, then std::mismatch verifies the resolved
// path is still rooted at installDir / winui3Dir respectively.
const fs::path source = (installDir / fileName).lexically_normal();
const fs::path target = (winui3Dir / fileName).lexically_normal();
const auto sourceIn = std::mismatch(installDirNorm.begin(), installDirNorm.end(), source.begin(), source.end());
const auto targetIn = std::mismatch(winui3DirNorm.begin(), winui3DirNorm.end(), target.begin(), target.end());
if (sourceIn.first != installDirNorm.end() || targetIn.first != winui3DirNorm.end())
{
WcaLog(LOGMSG_STANDARD, "CreateWinAppSDKHardlinks: Rejecting entry outside install root: %ls", fileName.c_str());
failed++;
continue;
}
if (!fs::exists(source))
{
WcaLog(LOGMSG_STANDARD, "CreateWinAppSDKHardlinks: Source not found: %ls", source.c_str());
failed++;
continue;
}
// Remove existing file if present (leftover from previous install)
std::error_code ec;
fs::remove(target, ec);
// Use a regular file copy (not a hard-link). Hard-links share an
// NTFS inode -- and therefore one DACL -- between root and
// WinUI3Apps, which lets MSIX sparse-package registration
// propagate the WinUI3Apps parent's rich (Capability/Package SID)
// DACL onto the root path. That trips a kernel "stricter access
// evaluation" path that blocks LOW-IL prevhost.exe from loading
// hostfxr.dll, so File Explorer Monaco preview goes blank on
// Windows 11 23H2. Copying creates a fresh inode in WinUI3Apps,
// so the root copy keeps its simple DACL while the WinUI3Apps
// copy inherits the rich DACL from its parent (matches 0.99.1
// behaviour). See Documents\PR-47233-Handoff.md for full RCA.
fs::copy_file(source, target, fs::copy_options::overwrite_existing, ec);
if (ec)
{
WcaLog(LOGMSG_STANDARD, "CreateWinAppSDKHardlinks: Failed to copy: %ls (%hs)", fileName.c_str(), ec.message().c_str());
failed++;
}
else
{
created++;
}
}
WcaLog(LOGMSG_STANDARD, "CreateWinAppSDKHardlinks: Copied %d files, %d failures", created, failed);
// Catastrophic-case escalation: if every copy failed, the WinUI3Apps tree is
// unusable (Monaco preview / context-menu shells will break). Surface this rather
// than reporting install success. Per-file failures remain tolerated.
if (created == 0 && failed > 0)
{
hr = E_FAIL;
ExitOnFailure(hr, "All WinAppSDK file copies failed; aborting install.");
}
}
LExit:
er = SUCCEEDED(hr) ? ERROR_SUCCESS : ERROR_INSTALL_FAILURE;
return WcaFinalize(er);
}
UINT __stdcall DeleteWinAppSDKHardlinksCA(MSIHANDLE hInstall)
{
HRESULT hr = S_OK;
UINT er = ERROR_SUCCESS;
std::wstring installationFolder;
hr = WcaInitialize(hInstall, "DeleteWinAppSDKHardlinks");
ExitOnFailure(hr, "Failed to initialize");
hr = getInstallFolder(hInstall, installationFolder);
ExitOnFailure(hr, "Failed to get installFolder.");
{
namespace fs = std::filesystem;
const fs::path winui3Dir = fs::path(installationFolder) / L"WinUI3Apps";
const fs::path manifestPath = winui3Dir / L"hardlinks.txt";
if (!fs::exists(manifestPath))
{
goto LExit;
}
std::ifstream manifestFile(manifestPath); // Read as bytes; convert UTF-8 -> wide explicitly.
std::string narrowLine;
// INSTALLFOLDER from MSI typically arrives with a trailing backslash; strip it before
// normalizing so the per-line containment check doesn't false-reject every entry.
auto stripTrailingSep = [](fs::path p) {
auto s = p.native();
while (s.size() > 1 && (s.back() == L'\\' || s.back() == L'/')) s.pop_back();
return fs::path(s);
};
const fs::path winui3DirNorm = stripTrailingSep(winui3Dir).lexically_normal();
while (std::getline(manifestFile, narrowLine))
{
if (narrowLine.empty())
{
continue;
}
if (narrowLine.back() == '\r')
{
narrowLine.pop_back();
if (narrowLine.empty()) continue;
}
const int wideLen = MultiByteToWideChar(CP_UTF8, MB_ERR_INVALID_CHARS, narrowLine.c_str(), -1, nullptr, 0);
if (wideLen <= 0)
{
WcaLog(LOGMSG_STANDARD, "DeleteWinAppSDKHardlinks: Skipping non-UTF-8 entry: %hs", narrowLine.c_str());
continue;
}
std::wstring fileName(static_cast<size_t>(wideLen) - 1, L'\0');
MultiByteToWideChar(CP_UTF8, MB_ERR_INVALID_CHARS, narrowLine.c_str(), -1, fileName.data(), wideLen);
// Defense-in-depth: reject entries whose resolved target escapes WinUI3Apps.
const fs::path target = (winui3Dir / fileName).lexically_normal();
const auto inWinui3 = std::mismatch(winui3DirNorm.begin(), winui3DirNorm.end(), target.begin(), target.end());
if (inWinui3.first != winui3DirNorm.end())
{
WcaLog(LOGMSG_STANDARD, "DeleteWinAppSDKHardlinks: Rejecting entry outside WinUI3Apps: %ls", fileName.c_str());
continue;
}
std::error_code ec;
fs::remove(target, ec);
}
WcaLog(LOGMSG_STANDARD, "DeleteWinAppSDKHardlinks: Cleaned up deduplicated copy files");
}
LExit:
er = SUCCEEDED(hr) ? ERROR_SUCCESS : ERROR_INSTALL_FAILURE;
return WcaFinalize(er);
}
// DllMain - Initialize and cleanup WiX custom action utils.
extern "C" BOOL WINAPI DllMain(__in HINSTANCE hInst, __in ULONG ulReason, __in LPVOID)
{

View File

@@ -36,5 +36,3 @@ EXPORTS
SetBundleInstallLocationCA
InstallPackageIdentityMSIXCA
UninstallPackageIdentityMSIXCA
CreateWinAppSDKHardlinksCA
DeleteWinAppSDKHardlinksCA

View File

@@ -112,8 +112,6 @@
<Custom Action="SetInstallCmdPalPackageParam" Before="InstallCmdPalPackage" />
<Custom Action="SetUninstallCommandNotFoundParam" Before="UninstallCommandNotFound" />
<Custom Action="SetUpgradeCommandNotFoundParam" Before="UpgradeCommandNotFound" />
<Custom Action="SetCreateWinAppSDKHardlinksParam" Before="CreateWinAppSDKHardlinks" />
<Custom Action="SetDeleteWinAppSDKHardlinksParam" Before="DeleteWinAppSDKHardlinks" />
<Custom Action="SetApplyModulesRegistryChangeSetsParam" Before="ApplyModulesRegistryChangeSets" />
<Custom Action="SetInstallPackageIdentityMSIXParam" Before="InstallPackageIdentityMSIX" />
@@ -126,7 +124,6 @@
<Custom Action="SetBundleInstallLocationData" Before="SetBundleInstallLocation" Condition="NOT Installed OR WIX_UPGRADE_DETECTED" />
<Custom Action="SetBundleInstallLocation" After="InstallFiles" Condition="NOT Installed OR WIX_UPGRADE_DETECTED" />
<Custom Action="ApplyModulesRegistryChangeSets" After="InstallFiles" Condition="NOT Installed" />
<Custom Action="CreateWinAppSDKHardlinks" After="InstallFiles" Condition="NOT Installed OR WIX_UPGRADE_DETECTED OR REINSTALL" />
<Custom Action="InstallCmdPalPackage" After="InstallFiles" Condition="NOT Installed" />
<Custom Action="InstallPackageIdentityMSIX" After="InstallFiles" Condition="NOT Installed AND WINDOWSBUILDNUMBER &gt;= 22000" />
<Custom Action="override Wix4CloseApplications_$(sys.BUILDARCHSHORT)" Before="RemoveFiles" />
@@ -140,7 +137,6 @@
<?endif?>
<Custom Action="TelemetryLogInstallSuccess" After="InstallFinalize" Condition="NOT Installed" />
<Custom Action="TelemetryLogUninstallSuccess" After="InstallFinalize" Condition="Installed and (NOT UPGRADINGPRODUCTCODE) AND (REMOVE=&quot;ALL&quot;)" />
<Custom Action="DeleteWinAppSDKHardlinks" Before="RemoveFiles" Condition="Installed AND (REMOVE=&quot;ALL&quot;)" />
<Custom Action="UnApplyModulesRegistryChangeSets" Before="RemoveFiles" Condition="Installed AND (REMOVE=&quot;ALL&quot;)" />
<Custom Action="UnRegisterContextMenuPackages" Before="RemoveFiles" Condition="Installed AND (REMOVE=&quot;ALL&quot;)" />
<Custom Action="CleanImageResizerRuntimeRegistry" Before="RemoveFiles" Condition="Installed AND (REMOVE=&quot;ALL&quot;)" />
@@ -193,10 +189,8 @@
<CustomAction Id="SetUpgradeCommandNotFoundParam" Property="UpgradeCommandNotFound" Value="[INSTALLFOLDER]" />
<CustomAction Id="SetCreateWinAppSDKHardlinksParam" Property="CreateWinAppSDKHardlinks" Value="[INSTALLFOLDER]" />
<CustomAction Id="CreateWinAppSDKHardlinks" Return="check" Impersonate="yes" Execute="deferred" DllEntry="CreateWinAppSDKHardlinksCA" BinaryRef="PTCustomActions" />
<CustomAction Id="SetDeleteWinAppSDKHardlinksParam" Property="DeleteWinAppSDKHardlinks" Value="[INSTALLFOLDER]" />
<CustomAction Id="DeleteWinAppSDKHardlinks" Return="ignore" Impersonate="yes" Execute="deferred" DllEntry="DeleteWinAppSDKHardlinksCA" BinaryRef="PTCustomActions" />
<CustomAction Id="SetCreatePTInteropHardlinksParam" Property="CreatePTInteropHardlinks" Value="[INSTALLFOLDER]" />

View File

@@ -7,18 +7,11 @@
<Fragment>
<DirectoryRef Id="WinUI3AppsInstallFolder">
<Component Id="WinUI3Apps_Hardlinks_Manifest" Guid="F7A2C3D1-8E4B-4F6A-9D2E-1B3C5A7F8E90" Bitness="always64">
<RegistryKey Root="$(var.RegistryScope)" Key="Software\Classes\powertoys\components">
<RegistryValue Type="string" Name="WinUI3Apps_Hardlinks_Manifest" Value="" KeyPath="yes" />
</RegistryKey>
<File Id="WinUI3Apps_hardlinks_txt" Source="$(var.BinDir)\WinUI3Apps\hardlinks.txt" />
</Component>
<!-- Generated by generateFileComponents.ps1 -->
<!--WinUI3ApplicationsFiles_Component_Def-->
</DirectoryRef>
<ComponentGroup Id="WinUI3ApplicationsComponentGroup">
<ComponentRef Id="WinUI3Apps_Hardlinks_Manifest" />
</ComponentGroup>
</Fragment>

View File

@@ -30,10 +30,6 @@ Function Generate-FileList() {
$fileInclusionList = @("*.dll", "*.exe", "*.json", "*.msix", "*.png", "*.gif", "*.ico", "*.cur", "*.svg", "index.html", "reg.js", "gitignore.js", "srt.js", "monacoSpecialLanguages.js", "customTokenThemeRules.js", "*.pri")
# MFC DLLs leak into the output via WindowsAppSDKSelfContained but no PowerToys binary imports them.
# Verified with dumpbin /dependents across all 2176 binaries — zero consumers.
$fileExclusionList += @("mfc140.dll", "mfc140u.dll", "mfcm140.dll", "mfcm140u.dll")
$dllsToIgnore = @("System.CodeDom.dll", "WindowsBase.dll")
if ($fileDepsJson -eq [string]::Empty) {
@@ -89,16 +85,11 @@ Function Generate-FileComponents() {
[Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUseDeclaredVarsMoreThanAssignments', 'fileList',
Justification = 'variable is used in another scope')]
$fileList = $matches[2] -split ';' | Where-Object { $_ -ne '' }
$fileList = $matches[2] -split ';'
return
}
}
if ($null -eq $fileList -or $fileList.Count -eq 0) {
# No files to generate components for — leave placeholder intact
return
}
$componentId = "$($fileListName)_Component"
$componentDefs = "`r`n"
@@ -163,67 +154,6 @@ Generate-FileComponents -fileListName "BaseApplicationsFiles" -wxsFilePath $PSSc
#WinUI3Applications
Generate-FileList -fileDepsJson "" -fileListName WinUI3ApplicationsFiles -wxsFilePath $PSScriptRoot\WinUI3Applications.wxs -depsPath "$PSScriptRoot..\..\..\$platform\Release\WinUI3Apps"
# Deduplicate: Remove files from WinUI3Apps that are identical to root (same name + same hash).
# These will be re-created as plain file copies at install time by CreateWinAppSDKHardlinksCA.
# (The CA's name is historical: it now uses fs::copy_file rather than CreateHardLinkW to avoid
# DACL contamination across the shared inode -- see CustomAction.cpp for details.)
$rootPath = "$PSScriptRoot..\..\..\$platform\Release"
$winui3Path = "$PSScriptRoot..\..\..\$platform\Release\WinUI3Apps"
$winui3WxsPath = "$PSScriptRoot\WinUI3Applications.wxs"
$winui3Wxs = Get-Content $winui3WxsPath -Raw
$manifestPath = Join-Path $winui3Path "hardlinks.txt"
if ($winui3Wxs -match "\<\?define WinUI3ApplicationsFiles=([^?]*)\?\>") {
$winui3FileList = $matches[1] -split ';' | Where-Object { $_ -ne '' }
$hardlinkFiles = @()
# Read the BaseApplications WXS file list so we only deduplicate files that the MSI
# is actually deploying to the install root. If a file was stripped from BaseApplications
# by an earlier step (e.g., the ImageResizer leaked-apphost workaround above), the
# install-time CA's source would be missing and both copies would disappear.
$baseAppsWxs = Get-Content $baseAppWxsPath -Raw
$baseAppsFileList = @()
if ($baseAppsWxs -match "\<\?define BaseApplicationsFiles=([^?]*)\?\>") {
$baseAppsFileList = $matches[1] -split ';' | Where-Object { $_ -ne '' }
}
foreach ($file in $winui3FileList) {
# Skip files that were intentionally not deployed to root by the build
if ($baseAppsFileList -notcontains $file) { continue }
$rootFile = Join-Path $rootPath $file
$winui3File = Join-Path $winui3Path $file
if ((Test-Path $rootFile) -and (Test-Path $winui3File)) {
$rootHash = (Get-FileHash $rootFile -Algorithm SHA256).Hash
$winui3Hash = (Get-FileHash $winui3File -Algorithm SHA256).Hash
if ($rootHash -eq $winui3Hash) {
$hardlinkFiles += $file
}
}
}
if ($hardlinkFiles.Count -gt 0) {
# Remove deduplicated files from WinUI3Apps file list
$remainingFiles = $winui3FileList | Where-Object { $_ -notin $hardlinkFiles }
if ($remainingFiles.Count -eq 0) {
# All files are duplicates — keep at least a dummy entry won't be emitted
# Generate-FileComponents handles empty defines by producing no <File> entries
$winui3Wxs = $winui3Wxs -replace "\<\?define WinUI3ApplicationsFiles=[^?]*\?\>", "<?define WinUI3ApplicationsFiles=?>"
} else {
$winui3Wxs = $winui3Wxs -replace "\<\?define WinUI3ApplicationsFiles=[^?]*\?\>", "<?define WinUI3ApplicationsFiles=$($remainingFiles -join ';')?>"
}
Set-Content -Path $winui3WxsPath -Value $winui3Wxs
Write-Host "Deduplicated $($hardlinkFiles.Count) files from WinUI3Apps (will be copied at install time)"
}
# Always write hardlinks.txt (may be empty — CA handles that gracefully)
# Write as UTF-8 without BOM so the install-time CA can read it via std::ifstream
# + MultiByteToWideChar(CP_UTF8) without dealing with PS-version-dependent default
# encodings or a leading BOM.
[System.IO.File]::WriteAllLines($manifestPath, [string[]]$hardlinkFiles, (New-Object System.Text.UTF8Encoding($false)))
}
Generate-FileComponents -fileListName "WinUI3ApplicationsFiles" -wxsFilePath $PSScriptRoot\WinUI3Applications.wxs
#AdvancedPaste

View File

@@ -8,6 +8,9 @@
<!-- Suppress DynamicallyAccessedMemberTypes.PublicParameterlessConstructor in fallback code path of Windows SDK projection -->
<!-- Suppress CA1416 for Windows-specific APIs that are used in PowerToys which only runs on Windows 10.0.19041.0+ -->
<WarningsNotAsErrors>IL2081;CsWinRT1028;CA1416;$(WarningsNotAsErrors)</WarningsNotAsErrors>
<!-- IL2104 is the per-assembly roll-up warning emitted by ILLink ("Assembly X produced trim warnings"). The
Windows SDK projection assemblies (Microsoft.Windows.SDK.NET, WinRT.Runtime) ship with known trim warnings
that we can't fix, so we allow IL2104 through as a warning rather than failing the build. -->
<WarningsNotAsErrors>IL2081;IL2104;CsWinRT1028;CA1416;$(WarningsNotAsErrors)</WarningsNotAsErrors>
</PropertyGroup>
</Project>

View File

@@ -5,17 +5,11 @@
#include <sstream>
#include <cmath>
#include <limits>
#include <random>
namespace ExprtkCalculator::internal
{
static double factorial(const double n)
{
if (std::isnan(n) || std::isinf(n))
{
return std::numeric_limits<double>::quiet_NaN();
}
// Only allow non-negative integers
if (n < 0.0 || std::floor(n) != n)
{
@@ -26,80 +20,13 @@ namespace ExprtkCalculator::internal
static double sign(const double n)
{
// The sign of NaN is undefined.
if (std::isnan(n))
{
return std::numeric_limits<double>::quiet_NaN();
}
if (n > 0.0) return 1.0;
if (n < 0.0) return -1.0;
return 0.0;
}
// rand(): returns a uniformly distributed random double in [0, 1)
struct rand_func : public exprtk::ifunction<double>
{
std::mt19937_64 rng;
std::uniform_real_distribution<double> dist;
rand_func() :
exprtk::ifunction<double>(0),
rng(std::random_device{}()),
dist(0.0, 1.0)
{}
inline double operator()() override
{
return dist(rng);
}
};
// randi(n): returns a uniformly distributed random integer in [0, n-1]
struct randi_func : public exprtk::ifunction<double>
{
std::mt19937_64 rng;
randi_func() :
exprtk::ifunction<double>(1),
rng(std::random_device{}())
{}
inline double operator()(const double& n) override
{
if (std::isnan(n) || std::isinf(n))
{
return std::numeric_limits<double>::quiet_NaN();
}
constexpr double maxLongLongAsDouble = static_cast<double>(std::numeric_limits<long long>::max());
if (n < 1.0 || n >= maxLongLongAsDouble)
{
return std::numeric_limits<double>::quiet_NaN();
}
if (std::floor(n) != n)
{
return std::numeric_limits<double>::quiet_NaN();
}
std::uniform_int_distribution<long long> dist(0, static_cast<long long>(n) - 1);
return static_cast<double>(dist(rng));
}
};
std::wstring ToWStringFullPrecision(double value)
{
if (std::isnan(value))
{
return L"NaN";
}
if (std::isinf(value))
{
return value > 0 ? L"inf" : L"-inf";
}
std::wostringstream oss;
oss.imbue(std::locale::classic());
oss << std::fixed << std::setprecision(15) << value;
@@ -120,13 +47,6 @@ namespace ExprtkCalculator::internal
symbol_table.add_function("factorial", factorial);
symbol_table.add_function("sign", sign);
// thread_local ensures each thread has its own RNG instance (seeded once,
// state preserved across calls) without requiring locks.
static thread_local rand_func rand_fn;
static thread_local randi_func randi_fn;
symbol_table.add_function("rand", rand_fn);
symbol_table.add_function("randi", randi_fn);
exprtk::expression<double> expression;
expression.register_symbol_table(symbol_table);
@@ -145,7 +65,7 @@ namespace ExprtkCalculator::internal
parser.settings().disable_all_inequality_ops(); // Disable inequality operators like <, >, <=, >=, !=, etc.
if (!parser.compile(expressionText, expression))
return L"ParseError";
return L"NaN";
return ToWStringFullPrecision(expression.value());
}

View File

@@ -275,10 +275,6 @@ namespace winrt::PowerToys::Interop::implementation
{
return CommonSharedConstants::HOTKEY_UPDATED_POWER_DISPLAY_EVENT;
}
hstring Constants::RescanPowerDisplayMonitorsEvent()
{
return CommonSharedConstants::RESCAN_POWER_DISPLAY_MONITORS_EVENT;
}
hstring Constants::PowerDisplayToggleMessage()
{
return CommonSharedConstants::POWER_DISPLAY_TOGGLE_MESSAGE;

View File

@@ -72,7 +72,6 @@ namespace winrt::PowerToys::Interop::implementation
static hstring SettingsUpdatedPowerDisplayEvent();
static hstring PowerDisplaySendSettingsTelemetryEvent();
static hstring HotkeyUpdatedPowerDisplayEvent();
static hstring RescanPowerDisplayMonitorsEvent();
static hstring PowerDisplayToggleMessage();
static hstring PowerDisplayApplyProfileMessage();
static hstring PowerDisplayTerminateAppMessage();

View File

@@ -69,7 +69,6 @@ namespace PowerToys
static String SettingsUpdatedPowerDisplayEvent();
static String PowerDisplaySendSettingsTelemetryEvent();
static String HotkeyUpdatedPowerDisplayEvent();
static String RescanPowerDisplayMonitorsEvent();
static String PowerDisplayToggleMessage();
static String PowerDisplayApplyProfileMessage();
static String PowerDisplayTerminateAppMessage();

View File

@@ -165,7 +165,6 @@ namespace CommonSharedConstants
const wchar_t SETTINGS_UPDATED_POWER_DISPLAY_EVENT[] = L"Local\\PowerToysPowerDisplay-SettingsUpdatedEvent-2e4d6f8a-1c3b-5e7f-9a1d-4c6e8f0b2d3e";
const wchar_t POWER_DISPLAY_SEND_SETTINGS_TELEMETRY_EVENT[] = L"Local\\PowerToysPowerDisplay-SettingsTelemetryEvent-8c4f2a1d-5e3b-7f9c-1a6d-3b8e5f2c9a7d";
const wchar_t HOTKEY_UPDATED_POWER_DISPLAY_EVENT[] = L"Local\\PowerToysPowerDisplay-HotkeyUpdatedEvent-9d5f3a2b-7e1c-4b8a-6f3d-2a9e5c7b1d4f";
const wchar_t RESCAN_POWER_DISPLAY_MONITORS_EVENT[] = L"Local\\PowerToysPowerDisplay-RescanMonitorsEvent-7f3e8c5a-1d4b-4a9e-bc6f-5d8a2b9e3c4f";
// IPC Messages used in PowerDisplay (Named Pipe communication)
const wchar_t POWER_DISPLAY_TOGGLE_MESSAGE[] = L"Toggle";

View File

@@ -1,7 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<Project DefaultTargets="Build" ToolsVersion="15.0" xmlns="http://schemas.microsoft.com/developer/msbuild/2003">
<Import Project="$(VCTargetsPath)\Microsoft.Cpp.Default.props" />
<Import Project="$(RepoRoot)packages\Microsoft.Windows.CppWinRT.2.0.250303.1\build\native\Microsoft.Windows.CppWinRT.props" Condition="Exists('$(RepoRoot)packages\Microsoft.Windows.CppWinRT.2.0.250303.1\build\native\Microsoft.Windows.CppWinRT.props')" />
<ItemGroup Label="ProjectConfigurations">
<ProjectConfiguration Include="Debug|x64">
<Configuration>Debug</Configuration>
@@ -194,18 +193,5 @@
<ItemGroup>
<Manifest Include="GrabAndMove.manifest" />
</ItemGroup>
<ItemGroup>
<None Include="packages.config" />
</ItemGroup>
<Import Project="$(VCTargetsPath)\Microsoft.Cpp.targets" />
<ImportGroup Label="ExtensionTargets">
<Import Project="$(RepoRoot)packages\Microsoft.Windows.CppWinRT.2.0.250303.1\build\native\Microsoft.Windows.CppWinRT.targets" Condition="Exists('$(RepoRoot)packages\Microsoft.Windows.CppWinRT.2.0.250303.1\build\native\Microsoft.Windows.CppWinRT.targets')" />
</ImportGroup>
<Target Name="EnsureNuGetPackageBuildImports" BeforeTargets="PrepareForBuild">
<PropertyGroup>
<ErrorText>This project references NuGet package(s) that are missing on this computer. Use NuGet Package Restore to download them. For more information, see http://go.microsoft.com/fwlink/?LinkID=322105. The missing file is {0}.</ErrorText>
</PropertyGroup>
<Error Condition="!Exists('$(RepoRoot)packages\Microsoft.Windows.CppWinRT.2.0.250303.1\build\native\Microsoft.Windows.CppWinRT.props')" Text="$([System.String]::Format('$(ErrorText)', '$(RepoRoot)packages\Microsoft.Windows.CppWinRT.2.0.250303.1\build\native\Microsoft.Windows.CppWinRT.props'))" />
<Error Condition="!Exists('$(RepoRoot)packages\Microsoft.Windows.CppWinRT.2.0.250303.1\build\native\Microsoft.Windows.CppWinRT.targets')" Text="$([System.String]::Format('$(ErrorText)', '$(RepoRoot)packages\Microsoft.Windows.CppWinRT.2.0.250303.1\build\native\Microsoft.Windows.CppWinRT.targets'))" />
</Target>
</Project>

View File

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

View File

@@ -21,6 +21,7 @@ using System.IO.Pipes;
using System.Linq;
using System.Security.Authentication.ExtendedProtection;
using System.Security.Principal;
using System.ServiceModel.Channels;
using System.ServiceProcess;
using System.Threading;
using System.Threading.Tasks;

View File

@@ -66,6 +66,7 @@
<PackageReference Include="Microsoft.Bcl.AsyncInterfaces" />
<PackageReference Include="Microsoft.Windows.Compatibility" />
<PackageReference Include="StreamJsonRpc" />
<PackageReference Include="System.Data.SqlClient" /> <!-- It's a dependency of Microsoft.Windows.Compatibility. We're adding it here to force it to the version specified in Directory.Packages.props -->
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\..\..\common\GPOWrapper\GPOWrapper.vcxproj" />

View File

@@ -214,6 +214,7 @@
<PackageReference Include="Microsoft.Bcl.AsyncInterfaces" />
<PackageReference Include="Microsoft.Windows.Compatibility" />
<PackageReference Include="StreamJsonRpc" />
<PackageReference Include="System.Data.SqlClient" /> <!-- It's a dependency of Microsoft.Windows.Compatibility. We're adding it here to force it to the version specified in Directory.Packages.props -->
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\..\common\GPOWrapper\GPOWrapper.vcxproj" />

View File

@@ -71,6 +71,7 @@
<PackageReference Include="Microsoft.Extensions.Logging" />
<PackageReference Include="Microsoft.Extensions.Hosting.WindowsServices" />
<PackageReference Include="StreamJsonRpc" />
<PackageReference Include="System.Data.SqlClient" /> <!-- It's a dependency of Microsoft.Windows.Compatibility. We're adding it here to force it to the version specified in Directory.Packages.props -->
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\..\..\common\GPOWrapper\GPOWrapper.vcxproj" />

View File

@@ -3353,14 +3353,8 @@ void RegisterAllHotkeys(HWND hWnd)
}
if (g_RecordToggleKey) {
registerHotkey( RECORD_HOTKEY, g_RecordToggleMod | MOD_NOREPEAT, g_RecordToggleKey & 0xFF );
UINT cropMod = g_RecordToggleMod ^ MOD_SHIFT;
UINT windowMod = g_RecordToggleMod ^ MOD_ALT;
if ( cropMod != 0 ) {
registerHotkey( RECORD_CROP_HOTKEY, cropMod | MOD_NOREPEAT, g_RecordToggleKey & 0xFF );
}
if ( windowMod != 0 ) {
registerHotkey( RECORD_WINDOW_HOTKEY, windowMod | MOD_NOREPEAT, g_RecordToggleKey & 0xFF );
}
registerHotkey( RECORD_CROP_HOTKEY, ( g_RecordToggleMod ^ MOD_SHIFT ) | MOD_NOREPEAT, g_RecordToggleKey & 0xFF );
registerHotkey( RECORD_WINDOW_HOTKEY, ( g_RecordToggleMod ^ MOD_ALT ) | MOD_NOREPEAT, g_RecordToggleKey & 0xFF );
}
// Note: COPY_IMAGE_HOTKEY, COPY_CROP_HOTKEY (Ctrl+C, Ctrl+Shift+C) and
@@ -5465,18 +5459,16 @@ INT_PTR CALLBACK OptionsProc( HWND hDlg, UINT message,
break;
}
else if( newRecordToggleKey ) {
UINT cropMod = newRecordToggleMod ^ MOD_SHIFT;
UINT windowMod = newRecordToggleMod ^ MOD_ALT;
if (!RegisterHotKey(GetParent(hDlg), RECORD_HOTKEY, newRecordToggleMod | MOD_NOREPEAT, newRecordToggleKey & 0xFF) ||
(cropMod != 0 && !RegisterHotKey(GetParent(hDlg), RECORD_CROP_HOTKEY, cropMod | MOD_NOREPEAT, newRecordToggleKey & 0xFF)) ||
(windowMod != 0 && !RegisterHotKey(GetParent(hDlg), RECORD_WINDOW_HOTKEY, windowMod | MOD_NOREPEAT, newRecordToggleKey & 0xFF))) {
else if( newRecordToggleKey &&
(!RegisterHotKey(GetParent(hDlg), RECORD_HOTKEY, newRecordToggleMod | MOD_NOREPEAT, newRecordToggleKey & 0xFF) ||
!RegisterHotKey(GetParent(hDlg), RECORD_CROP_HOTKEY, (newRecordToggleMod ^ MOD_SHIFT) | MOD_NOREPEAT, newRecordToggleKey & 0xFF) ||
!RegisterHotKey(GetParent(hDlg), RECORD_WINDOW_HOTKEY, (newRecordToggleMod ^ MOD_ALT) | MOD_NOREPEAT, newRecordToggleKey & 0xFF))) {
MessageBox(hDlg, L"The specified record hotkey is already in use.\nSelect a different record hotkey.",
APPNAME, MB_ICONERROR);
UnregisterAllHotkeys(GetParent(hDlg));
break;
MessageBox(hDlg, L"The specified record hotkey is already in use.\nSelect a different record hotkey.",
APPNAME, MB_ICONERROR);
UnregisterAllHotkeys(GetParent(hDlg));
break;
}
} else {
g_BreakTimeout = newTimeout;
@@ -7510,17 +7502,14 @@ LRESULT APIENTRY MainWndProc(
showOptions = TRUE;
}
else if (g_RecordToggleKey) {
UINT cropMod = g_RecordToggleMod ^ MOD_SHIFT;
UINT windowMod = g_RecordToggleMod ^ MOD_ALT;
if (!RegisterHotKey(hWnd, RECORD_HOTKEY, g_RecordToggleMod | MOD_NOREPEAT, g_RecordToggleKey & 0xFF) ||
(cropMod != 0 && !RegisterHotKey(hWnd, RECORD_CROP_HOTKEY, cropMod | MOD_NOREPEAT, g_RecordToggleKey & 0xFF)) ||
(windowMod != 0 && !RegisterHotKey(hWnd, RECORD_WINDOW_HOTKEY, windowMod | MOD_NOREPEAT, g_RecordToggleKey & 0xFF))) {
else if (g_RecordToggleKey &&
(!RegisterHotKey(hWnd, RECORD_HOTKEY, g_RecordToggleMod | MOD_NOREPEAT, g_RecordToggleKey & 0xFF) ||
!RegisterHotKey(hWnd, RECORD_CROP_HOTKEY, (g_RecordToggleMod ^ MOD_SHIFT) | MOD_NOREPEAT, g_RecordToggleKey & 0xFF) ||
!RegisterHotKey(hWnd, RECORD_WINDOW_HOTKEY, (g_RecordToggleMod ^ MOD_ALT) | MOD_NOREPEAT, g_RecordToggleKey & 0xFF))) {
MessageBox(hWnd, L"The specified record hotkey is already in use.\nSelect a different record hotkey.",
APPNAME, MB_ICONERROR);
showOptions = TRUE;
}
MessageBox(hWnd, L"The specified record hotkey is already in use.\nSelect a different record hotkey.",
APPNAME, MB_ICONERROR);
showOptions = TRUE;
}
if( showOptions ) {
@@ -10186,11 +10175,9 @@ LRESULT APIENTRY MainWndProc(
}
if (g_RecordToggleKey)
{
UINT cropMod = g_RecordToggleMod ^ MOD_SHIFT;
UINT windowMod = g_RecordToggleMod ^ MOD_ALT;
if (!RegisterHotKey(hWnd, RECORD_HOTKEY, g_RecordToggleMod | MOD_NOREPEAT, g_RecordToggleKey & 0xFF) ||
(cropMod != 0 && !RegisterHotKey(hWnd, RECORD_CROP_HOTKEY, cropMod | MOD_NOREPEAT, g_RecordToggleKey & 0xFF)) ||
(windowMod != 0 && !RegisterHotKey(hWnd, RECORD_WINDOW_HOTKEY, windowMod | MOD_NOREPEAT, g_RecordToggleKey & 0xFF)))
!RegisterHotKey(hWnd, RECORD_CROP_HOTKEY, (g_RecordToggleMod ^ MOD_SHIFT) | MOD_NOREPEAT, g_RecordToggleKey & 0xFF) ||
!RegisterHotKey(hWnd, RECORD_WINDOW_HOTKEY, (g_RecordToggleMod ^ MOD_ALT) | MOD_NOREPEAT, g_RecordToggleKey & 0xFF))
{
if(!g_StartedByPowerToys)
{

View File

@@ -60,19 +60,7 @@ public abstract partial class AppExtensionHost : IExtensionHost
return Task.CompletedTask.AsAsyncAction();
}
switch (message.State)
{
case MessageState.Error:
CoreLogger.LogError(message.Message);
break;
case MessageState.Warning:
CoreLogger.LogWarning(message.Message);
break;
case MessageState.Info:
default:
CoreLogger.LogInfo(message.Message);
break;
}
CoreLogger.LogDebug(message.Message);
_ = Task.Run(() =>
{

View File

@@ -211,7 +211,7 @@ public sealed class CommandProviderWrapper : ICommandProviderContext
SupportsPinning = true;
// Load pinned commands from saved settings
pinnedCommands = LoadPinnedCommands(four, settingsService.Settings);
pinnedCommands = LoadPinnedCommands(four, providerSettings);
}
Id = model.Id;
@@ -261,6 +261,12 @@ public sealed class CommandProviderWrapper : ICommandProviderContext
}
}
private record TopLevelObjects(
ICommandItem[]? Commands,
IFallbackCommandItem[]? Fallbacks,
ICommandItem[]? PinnedCommands,
ICommandItem[]? DockBands);
private void InitializeCommands(
TopLevelObjects objects,
IServiceProvider serviceProvider,
@@ -289,24 +295,7 @@ public sealed class CommandProviderWrapper : ICommandProviderContext
if (objects.PinnedCommands is not null)
{
foreach (var pinnedCommand in objects.PinnedCommands)
{
var pinnedItem = make(pinnedCommand, TopLevelType.Normal);
var alreadyExists = false;
foreach (var existingItem in topLevelList)
{
if (existingItem.Id == pinnedItem.Id)
{
alreadyExists = true;
break;
}
}
if (!alreadyExists)
{
topLevelList.Add(pinnedItem);
}
}
topLevelList.AddRange(objects.PinnedCommands.Select(c => make(c, TopLevelType.Normal)));
}
TopLevelItems = topLevelList.ToArray();
@@ -409,11 +398,11 @@ public sealed class CommandProviderWrapper : ICommandProviderContext
return null;
}
private ICommandItem[] LoadPinnedCommands(ICommandProvider4 model, SettingsModel settings)
private ICommandItem[] LoadPinnedCommands(ICommandProvider4 model, ProviderSettings providerSettings)
{
var pinnedItems = new List<ICommandItem>();
foreach (var pinnedId in settings.GetPinnedCommandIds(ProviderId))
foreach (var pinnedId in providerSettings.PinnedCommandIds)
{
try
{
@@ -452,29 +441,55 @@ public sealed class CommandProviderWrapper : ICommandProviderContext
public void PinCommand(string commandId, IServiceProvider serviceProvider)
{
var settingsService = serviceProvider.GetRequiredService<ISettingsService>();
if (settingsService.Settings.IsCommandPinned(ProviderId, commandId))
var providerSettings = GetProviderSettings(settingsService.Settings);
if (!providerSettings.PinnedCommandIds.Contains(commandId))
{
return;
settingsService.UpdateSettings(
s =>
{
if (!s.ProviderSettings.TryGetValue(ProviderId, out var ps))
{
ps = new ProviderSettings();
}
var providerSettings = ps.WithConnection(this);
var newPinned = providerSettings.PinnedCommandIds.Add(commandId);
var newPs = providerSettings with { PinnedCommandIds = newPinned };
return s with
{
ProviderSettings = s.ProviderSettings.SetItem(ProviderId, newPs),
};
},
hotReload: false);
// Raise CommandsChanged so the TopLevelCommandManager reloads our commands
this.CommandsChanged?.Invoke(this, new ItemsChangedEventArgs(-1));
}
settingsService.UpdateSettings(
s => s.TryPinCommand(ProviderId, commandId),
hotReload: false);
// Raise CommandsChanged so the TopLevelCommandManager reloads our commands
this.CommandsChanged?.Invoke(this, new ItemsChangedEventArgs(-1));
}
public void UnpinCommand(string commandId, IServiceProvider serviceProvider)
{
var settingsService = serviceProvider.GetRequiredService<ISettingsService>();
if (!settingsService.Settings.IsCommandPinned(ProviderId, commandId))
{
return;
}
settingsService.UpdateSettings(
s => s.TryUnpinCommand(ProviderId, commandId),
s =>
{
if (!s.ProviderSettings.TryGetValue(ProviderId, out var ps))
{
ps = new ProviderSettings();
}
var providerSettings = ps.WithConnection(this);
var newPinned = providerSettings.PinnedCommandIds.Remove(commandId);
var newPs = providerSettings with { PinnedCommandIds = newPinned };
return s with
{
ProviderSettings = s.ProviderSettings.SetItem(ProviderId, newPs),
};
},
hotReload: false);
// Raise CommandsChanged so the TopLevelCommandManager reloads our commands
@@ -658,10 +673,4 @@ public sealed class CommandProviderWrapper : ICommandProviderContext
this.DockBandItems = bands.ToArray();
this.CommandsChanged?.Invoke(this, new ItemsChangedEventArgs());
}
private record TopLevelObjects(
ICommandItem[]? Commands,
IFallbackCommandItem[]? Fallbacks,
ICommandItem[]? PinnedCommands,
ICommandItem[]? DockBands);
}

View File

@@ -129,7 +129,7 @@ public partial class CommandViewModel : ExtensionObjectViewModel
Icon = new(iconInfo);
Icon.InitializeProperties();
break;
case nameof(Properties):
case nameof(_properties):
UpdatePropertiesFromExtension(model as IExtendedAttributesProvider);
break;

View File

@@ -51,15 +51,10 @@ public sealed partial class MainListPage : DynamicListPage,
// Stable separator instances so that the VM cache and InPlaceUpdateList
// recognise them across successive GetItems() calls
private readonly Separator _pinnedSeparator = new(Resources.home_sections_pinned_title);
private readonly Separator _resultsSeparator = new(Resources.results);
private readonly Separator _fallbacksSeparator = new(Resources.fallbacks);
private readonly Separator _commandsSeparator = new(Resources.home_sections_commands_title);
private TopLevelViewModel[]? _cachedPinnedViewModels;
private TopLevelViewModel[]? _cachedRegularViewModels;
private bool _defaultViewDirty = true;
private RoScored<IListItem>[]? _filteredItems;
private RoScored<IListItem>[]? _filteredApps;
@@ -105,7 +100,6 @@ public sealed partial class MainListPage : DynamicListPage,
_tlcManager.PropertyChanged += TlcManager_PropertyChanged;
_tlcManager.TopLevelCommands.CollectionChanged += Commands_CollectionChanged;
_tlcManager.PinnedCommands.CollectionChanged += PinnedCommands_CollectionChanged;
_refreshThrottledDebouncedAction = new ThrottledDebouncedAction(
() =>
@@ -172,15 +166,8 @@ public sealed partial class MainListPage : DynamicListPage,
}
}
private void PinnedCommands_CollectionChanged(object? sender, NotifyCollectionChangedEventArgs e)
{
_defaultViewDirty = true;
RaiseItemsChanged();
}
private void Commands_CollectionChanged(object? sender, NotifyCollectionChangedEventArgs e)
{
_defaultViewDirty = true;
_includeApps = _tlcManager.IsProviderActive(AllAppsCommandProvider.WellKnownId);
if (_includeApps != _filteredItemsIncludesApps)
{
@@ -251,108 +238,65 @@ public sealed partial class MainListPage : DynamicListPage,
{
lock (_tlcManager.TopLevelCommands)
{
return string.IsNullOrWhiteSpace(SearchText) ? GetDefaultViewItems() : GetSearchViewItems();
}
}
private IListItem[] GetSearchViewItems()
{
var validScoredFallbacks = _scoredFallbackItems?
.Where(s => !string.IsNullOrWhiteSpace(s.Item.Title))
.ToList();
var validFallbacks = _fallbackItems?
.Where(s => !string.IsNullOrWhiteSpace(s.Item.Title))
.ToList();
return MainListPageResultFactory.Create(
_filteredItems,
validScoredFallbacks,
_filteredApps,
validFallbacks,
_resultsSeparator,
_fallbacksSeparator,
AppResultLimit);
}
private IListItem[] GetDefaultViewItems()
{
if (_defaultViewDirty)
{
RebuildDefaultViewCache();
}
var pinned = _cachedPinnedViewModels!;
var regular = _cachedRegularViewModels!;
var pinnedCount = pinned.Length;
var regularCount = regular.Length;
var sectionCount = (pinnedCount > 0 ? 1 : 0) + (regularCount > 0 ? 1 : 0);
if (sectionCount == 0)
{
return [];
}
var result = new IListItem[pinnedCount + regularCount + sectionCount];
var writeIndex = 0;
if (pinnedCount > 0)
{
result[writeIndex++] = _pinnedSeparator;
Array.Copy(pinned, 0, result, writeIndex, pinnedCount);
writeIndex += pinnedCount;
}
if (regularCount > 0)
{
result[writeIndex++] = _commandsSeparator;
Array.Copy(regular, 0, result, writeIndex, regularCount);
}
return result;
}
private void RebuildDefaultViewCache()
{
var allCommands = _tlcManager.TopLevelCommands;
var pinnedSettings = _tlcManager.PinnedCommands;
// Resolve pinned VMs in settings order
var pinned = new List<TopLevelViewModel>(pinnedSettings.Count);
for (var i = 0; i < pinnedSettings.Count; i++)
{
var s = pinnedSettings[i];
for (var j = 0; j < allCommands.Count; j++)
// Either return the top-level commands (no search text), or the merged and
// filtered results.
if (string.IsNullOrWhiteSpace(SearchText))
{
var cmd = allCommands[j];
if (IsEligibleTopLevelCommand(cmd) &&
cmd.CommandProviderId == s.ProviderId &&
cmd.Id == s.CommandId)
var allCommands = _tlcManager.TopLevelCommands;
// First pass: count eligible commands
var eligibleCount = 0;
for (var i = 0; i < allCommands.Count; i++)
{
pinned.Add(cmd);
break;
var cmd = allCommands[i];
if (!cmd.IsFallback && !string.IsNullOrEmpty(cmd.Title))
{
eligibleCount++;
}
}
}
}
// Single pass for regular items
var regular = new List<TopLevelViewModel>(allCommands.Count);
for (var i = 0; i < allCommands.Count; i++)
{
var candidate = allCommands[i];
if (IsEligibleTopLevelCommand(candidate) && !_tlcManager.IsPinned(candidate.CommandProviderId, candidate.Id))
if (eligibleCount == 0)
{
return [];
}
// +1 for the separator
var result = new IListItem[eligibleCount + 1];
result[0] = _commandsSeparator;
// Second pass: populate
var writeIndex = 1;
for (var i = 0; i < allCommands.Count; i++)
{
var cmd = allCommands[i];
if (!cmd.IsFallback && !string.IsNullOrEmpty(cmd.Title))
{
result[writeIndex++] = cmd;
}
}
return result;
}
else
{
regular.Add(candidate);
var validScoredFallbacks = _scoredFallbackItems?
.Where(s => !string.IsNullOrWhiteSpace(s.Item.Title))
.ToList();
var validFallbacks = _fallbackItems?
.Where(s => !string.IsNullOrWhiteSpace(s.Item.Title))
.ToList();
return MainListPageResultFactory.Create(
_filteredItems,
validScoredFallbacks,
_filteredApps,
validFallbacks,
_resultsSeparator,
_fallbacksSeparator,
AppResultLimit);
}
}
_cachedPinnedViewModels = [.. pinned];
_cachedRegularViewModels = [.. regular];
_defaultViewDirty = false;
}
private static bool IsEligibleTopLevelCommand(TopLevelViewModel command)
{
return !command.IsFallback && !string.IsNullOrEmpty(command.Title);
}
private void ClearResults()
@@ -535,9 +479,11 @@ public sealed partial class MainListPage : DynamicListPage,
var allNewApps = AllAppsCommandProvider.Page.GetItems().Cast<AppListItem>().ToList();
// We need to remove pinned apps from allNewApps so they don't show twice.
var pinnedCommandIds = _settingsService.Settings.GetPinnedCommandIds(AllAppsCommandProvider.WellKnownId);
// Pinned app command IDs are stored in ProviderSettings.PinnedCommandIds.
_settingsService.Settings.ProviderSettings.TryGetValue(AllAppsCommandProvider.WellKnownId, out var providerSettings);
var pinnedCommandIds = providerSettings?.PinnedCommandIds;
if (pinnedCommandIds.Count > 0)
if (pinnedCommandIds is not null && pinnedCommandIds.Count > 0)
{
newApps = allNewApps.Where(li => li.Command != null && !pinnedCommandIds.Contains(li.Command.Id));
}
@@ -756,8 +702,6 @@ public sealed partial class MainListPage : DynamicListPage,
public void Receive(UpdateFallbackItemsMessage message)
{
_tlcManager.RebuildPinnedCache();
_defaultViewDirty = true;
RequestRefresh(fullRefresh: false);
}
@@ -773,7 +717,6 @@ public sealed partial class MainListPage : DynamicListPage,
_tlcManager.PropertyChanged -= TlcManager_PropertyChanged;
_tlcManager.TopLevelCommands.CollectionChanged -= Commands_CollectionChanged;
_tlcManager.PinnedCommands.CollectionChanged -= PinnedCommands_CollectionChanged;
if (_settingsService is not null)
{

View File

@@ -845,112 +845,6 @@ public sealed partial class DockViewModel : IDisposable
Logger.LogDebug($"Unpinned band {bandId} (not saved yet)");
}
/// <summary>
/// Removes a band from this dock by its ID. Used when a band is dragged to
/// another monitor's dock. Does not save — save happens when exiting edit mode.
/// </summary>
public void RemoveBandById(string bandId)
{
if (FindBandById(bandId) == null)
{
return;
}
EnsureMonitorForked();
var (activeStart, activeCenter, activeEnd) = GetActiveBands();
_settings = WithActiveBands(
activeStart.RemoveAll(b => b.CommandId == bandId),
activeCenter.RemoveAll(b => b.CommandId == bandId),
activeEnd.RemoveAll(b => b.CommandId == bandId));
RemoveBandFromCollection(StartItems, bandId);
RemoveBandFromCollection(CenterItems, bandId);
RemoveBandFromCollection(EndItems, bandId);
Logger.LogDebug($"Removed band {bandId} from monitor {_monitorDeviceId} (cross-monitor drag)");
}
/// <summary>
/// Accepts a dock band from another monitor during a cross-monitor drag.
/// Creates the band ViewModel and inserts it at the specified position.
/// Does not save — save happens when exiting edit mode.
/// </summary>
public void AcceptBandFromMonitor(string bandId, DockPinSide targetSide, int targetIndex)
{
if (FindBandById(bandId) != null)
{
Logger.LogWarning($"AcceptBandFromMonitor: band {bandId} already in this dock");
return;
}
EnsureMonitorForked();
var topLevel = _topLevelCommandManager.LookupDockBand(bandId);
if (topLevel is null)
{
Logger.LogWarning($"AcceptBandFromMonitor: band {bandId} not found in DockBandsSnapshot");
return;
}
var bandSettings = new DockBandSettings { ProviderId = topLevel.CommandProviderId, CommandId = bandId };
var bandVm = CreateBandItem(bandSettings, topLevel.ItemViewModel);
var (activeStart, activeCenter, activeEnd) = GetActiveBands();
switch (targetSide)
{
case DockPinSide.Start:
{
var idx = Math.Min(targetIndex, activeStart.Count);
activeStart = activeStart.Insert(idx, bandSettings);
var uiIdx = Math.Min(targetIndex, StartItems.Count);
StartItems.Insert(uiIdx, bandVm);
break;
}
case DockPinSide.Center:
{
var idx = Math.Min(targetIndex, activeCenter.Count);
activeCenter = activeCenter.Insert(idx, bandSettings);
var uiIdx = Math.Min(targetIndex, CenterItems.Count);
CenterItems.Insert(uiIdx, bandVm);
break;
}
case DockPinSide.End:
{
var idx = Math.Min(targetIndex, activeEnd.Count);
activeEnd = activeEnd.Insert(idx, bandSettings);
var uiIdx = Math.Min(targetIndex, EndItems.Count);
EndItems.Insert(uiIdx, bandVm);
break;
}
}
_settings = WithActiveBands(activeStart, activeCenter, activeEnd);
bandVm.SnapshotShowLabels();
Task.Run(() =>
{
bandVm.SafeInitializePropertiesSynchronous();
});
Logger.LogDebug($"Accepted band {bandId} at {targetSide}[{targetIndex}] on monitor {_monitorDeviceId}");
}
private static void RemoveBandFromCollection(ObservableCollection<DockBandViewModel> collection, string bandId)
{
for (var i = collection.Count - 1; i >= 0; i--)
{
if (collection[i].Id == bandId)
{
collection.RemoveAt(i);
}
}
}
private void DoOnUiThread(Action action)
{
Task.Factory.StartNew(

View File

@@ -48,11 +48,6 @@ public sealed partial class ExtensionGalleryItemViewModel : ObservableObject
private readonly IReadOnlyDictionary<string, GalleryInstallSource> _installSourcesByType;
private readonly IReadOnlyDictionary<string, GallerySourceViewModel> _sourcesByKind;
// HTTP uris are set only for sources that can be opened in a browser
private readonly Uri? _homepageHttpUri;
private readonly Uri? _authorPageHttpUri;
private readonly Uri? _installLinkHttpUri;
public ExtensionGalleryItemViewModel(
GalleryExtensionEntry entry,
ILogger<ExtensionGalleryItemViewModel> logger,
@@ -67,9 +62,6 @@ public sealed partial class ExtensionGalleryItemViewModel : ObservableObject
_winGetOperationTrackerService = winGetOperationTrackerService;
_installSourcesByType = BuildInstallSourceLookup(entry.InstallSources);
(Sources, _sourcesByKind) = BuildSourceInfos(_installSourcesByType, entry.Homepage);
_homepageHttpUri = TryCreateWebUri(entry.Homepage);
_authorPageHttpUri = TryCreateWebUri(entry.Author?.Url);
_installLinkHttpUri = TryCreateWebUri(GetSource(SourceTypeGitHub)?.Uri ?? GetSource(SourceTypeWebsite)?.Uri);
Screenshots = BuildScreenshots(entry.ScreenshotUrls);
var resolvedIconUri = ResolveIconUri();
@@ -122,11 +114,11 @@ public sealed partial class ExtensionGalleryItemViewModel : ObservableObject
public bool HasStoreSource => HasSource(SourceTypeStore);
public bool HasUrlSource => _installSourcesByType.ContainsKey(SourceTypeUrl) && InstallUrl is not null;
public bool HasUrlSource => _installSourcesByType.ContainsKey(SourceTypeUrl) && !string.IsNullOrWhiteSpace(InstallUrl);
public bool HasHomepage => _homepageHttpUri is not null;
public bool HasHomepage => !string.IsNullOrWhiteSpace(Homepage);
public bool HasAuthorUrl => _authorPageHttpUri is not null;
public bool HasAuthorUrl => !string.IsNullOrWhiteSpace(AuthorUrl);
public bool HasGitHubSource => HasSource(SourceTypeGitHub);
@@ -175,7 +167,7 @@ public sealed partial class ExtensionGalleryItemViewModel : ObservableObject
public string? StoreId => GetSource(SourceTypeStore)?.Id;
public string? InstallUrl => _installLinkHttpUri?.AbsoluteUri;
public string? InstallUrl => GetSource(SourceTypeGitHub)?.Uri ?? GetSource(SourceTypeWebsite)?.Uri;
public string WinGetInstallCommand => !string.IsNullOrWhiteSpace(WinGetId) ? $"winget install --id {WinGetId}" : string.Empty;
@@ -191,7 +183,7 @@ public sealed partial class ExtensionGalleryItemViewModel : ObservableObject
public string GitHubTooltip => GetSource(SourceTypeGitHub)?.Uri ?? Resources.gallery_item_github_source;
public string WebsiteTooltip => GetSource(SourceTypeWebsite)?.Uri ?? _homepageHttpUri?.AbsoluteUri ?? Resources.gallery_item_website_source;
public string WebsiteTooltip => GetSource(SourceTypeWebsite)?.Uri ?? Homepage ?? Resources.gallery_item_website_source;
public string WinGetMenuText => !string.IsNullOrWhiteSpace(WinGetId)
? FormatResource(Resources.gallery_item_winget_menu_text_with_id, WinGetId)
@@ -301,21 +293,21 @@ public sealed partial class ExtensionGalleryItemViewModel : ObservableObject
ApplySourceDetails(SourceTypeWinGet, CreateSourceDetails(packageInfo.Details));
}
[RelayCommand(CanExecute = nameof(HasHomepage))]
[RelayCommand]
private void OpenHomepage()
{
if (_homepageHttpUri is not null)
if (!string.IsNullOrEmpty(Homepage))
{
ShellHelpers.OpenInShell(_homepageHttpUri.AbsoluteUri);
ShellHelpers.OpenInShell(Homepage);
}
}
[RelayCommand(CanExecute = nameof(HasAuthorUrl))]
[RelayCommand]
private void OpenAuthorPage()
{
if (_authorPageHttpUri is not null)
if (!string.IsNullOrEmpty(AuthorUrl))
{
ShellHelpers.OpenInShell(_authorPageHttpUri.AbsoluteUri);
ShellHelpers.OpenInShell(AuthorUrl);
}
}
@@ -328,12 +320,12 @@ public sealed partial class ExtensionGalleryItemViewModel : ObservableObject
}
}
[RelayCommand(CanExecute = nameof(HasUrlSource))]
[RelayCommand]
private void OpenInstallUrl()
{
if (_installLinkHttpUri is not null)
if (!string.IsNullOrEmpty(InstallUrl))
{
ShellHelpers.OpenInShell(_installLinkHttpUri.AbsoluteUri);
ShellHelpers.OpenInShell(InstallUrl);
}
}
@@ -694,21 +686,15 @@ public sealed partial class ExtensionGalleryItemViewModel : ObservableObject
};
}
private static GallerySourceViewModel? CreateSourceFromUrl(string? url)
private static GallerySourceViewModel CreateSourceFromUrl(string? url)
{
var webUri = TryCreateWebUri(url);
if (webUri is null)
{
return null;
}
if (IsGitHubUri(webUri))
if (IsGitHubUri(url))
{
return CreateSourceViewModel(
SourceTypeGitHub,
Resources.gallery_item_source_name_github,
id: null,
uri: webUri.AbsoluteUri,
uri: url,
isKnown: true);
}
@@ -716,19 +702,19 @@ public sealed partial class ExtensionGalleryItemViewModel : ObservableObject
SourceTypeWebsite,
Resources.gallery_item_source_name_website,
id: null,
uri: webUri.AbsoluteUri,
uri: url,
isKnown: true);
}
private static bool TryCreateSourceFromUri(string? uriValue, out GallerySourceViewModel source)
{
source = default!;
if (CreateSourceFromUrl(uriValue) is not { } webSource)
if (string.IsNullOrWhiteSpace(uriValue) || !Uri.TryCreate(uriValue, UriKind.Absolute, out _))
{
return false;
}
source = webSource;
source = CreateSourceFromUrl(uriValue);
return true;
}
@@ -783,7 +769,7 @@ public sealed partial class ExtensionGalleryItemViewModel : ObservableObject
private static void AddDetail(ICollection<GallerySourceDetailItemViewModel> target, string label, string? value, string? uri)
{
var normalizedValue = ToNullIfWhiteSpace(value);
var normalizedUri = TryCreateWebUri(uri);
var normalizedUri = TryCreateUri(uri);
if (normalizedValue is null && normalizedUri is null)
{
return;
@@ -792,14 +778,14 @@ public sealed partial class ExtensionGalleryItemViewModel : ObservableObject
target.Add(new GallerySourceDetailItemViewModel(label, normalizedValue ?? normalizedUri!.AbsoluteUri, normalizedUri));
}
private static Uri? TryCreateWebUri(string? value)
private static Uri? TryCreateUri(string? value)
{
if (string.IsNullOrWhiteSpace(value) || !Uri.TryCreate(value, UriKind.Absolute, out var uri))
{
return null;
}
return IsWebUri(uri) ? uri : null;
return uri;
}
private static string? ToNullIfWhiteSpace(string? value)
@@ -838,18 +824,17 @@ public sealed partial class ExtensionGalleryItemViewModel : ObservableObject
return builder.ToString();
}
private static bool IsGitHubUri(Uri uri)
private static bool IsGitHubUri(string? value)
{
if (string.IsNullOrWhiteSpace(value) || !Uri.TryCreate(value, UriKind.Absolute, out var uri))
{
return false;
}
return uri.Host.Equals("github.com", StringComparison.OrdinalIgnoreCase)
|| uri.Host.EndsWith(".github.com", StringComparison.OrdinalIgnoreCase);
}
private static bool IsWebUri(Uri uri)
{
return uri.Scheme.Equals(Uri.UriSchemeHttp, StringComparison.OrdinalIgnoreCase)
|| uri.Scheme.Equals(Uri.UriSchemeHttps, StringComparison.OrdinalIgnoreCase);
}
private static bool AreStatusTextsEquivalent(string first, string second)
{
return string.Equals(NormalizeStatusText(first), NormalizeStatusText(second), StringComparison.OrdinalIgnoreCase);

View File

@@ -12,12 +12,6 @@ public static class Icons
public static IconInfo UnpinIcon => new("\uE77A"); // Unpin icon
public static IconInfo MoveUpIcon => new("\uE74A"); // Up icon
public static IconInfo MoveDownIcon => new("\uE74B"); // Down icon
public static IconInfo MoveToTopIcon => new("\uE898"); // Move to top icon
public static IconInfo SettingsIcon => new("\uE713"); // Settings icon
public static IconInfo EditIcon => new("\uE70F"); // Edit icon

View File

@@ -12,8 +12,6 @@ namespace Microsoft.CmdPal.UI.ViewModels;
public partial class ListItemViewModel : CommandItemViewModel
{
private const int MaxVisibleTags = 3;
public new ExtensionObject<IListItem> Model { get; }
public List<TagViewModel>? Tags { get; set; }
@@ -22,10 +20,6 @@ public partial class ListItemViewModel : CommandItemViewModel
// cannot be marked [ObservableProperty]
public bool HasTags => (Tags?.Count ?? 0) > 0;
public List<TagViewModel>? VisibleTags { get; private set; }
private TagViewModel? _overflowTag;
public string TextToSuggest { get; private set; } = string.Empty;
public string Section { get; private set; } = string.Empty;
@@ -279,45 +273,13 @@ public partial class ListItemViewModel : CommandItemViewModel
// Tags being an ObservableCollection instead of a List lead to
// many COM exception issues.
Tags = [.. newTags];
UpdateVisibleTags();
// We're already in UI thread, so just raise the events
OnPropertyChanged(nameof(Tags));
OnPropertyChanged(nameof(HasTags));
OnPropertyChanged(nameof(VisibleTags));
});
}
private void UpdateVisibleTags()
{
var allTags = Tags;
if (allTags is null || allTags.Count == 0)
{
VisibleTags = null;
}
else if (allTags.Count <= MaxVisibleTags)
{
VisibleTags = [.. allTags];
}
else
{
_overflowTag?.SafeCleanup();
var visible = allTags.Take(MaxVisibleTags).ToList();
var overflowCount = allTags.Count - MaxVisibleTags;
var hiddenTagNames = allTags.Skip(MaxVisibleTags).Select(t => t.Text);
var overflowTag = new TagViewModel(
new Tag($"+{overflowCount}")
{
ToolTip = string.Join("\n", hiddenTagNames),
},
PageContext);
overflowTag.InitializeProperties();
_overflowTag = overflowTag;
visible.Add(overflowTag);
VisibleTags = visible;
}
}
private void UpdateShowsTitle()
{
var oldShowTitle = ShowTitle;
@@ -344,7 +306,6 @@ public partial class ListItemViewModel : CommandItemViewModel
// Tags don't have event handlers or anything to cleanup
Tags?.ForEach(t => t.SafeCleanup());
_overflowTag?.SafeCleanup();
Details?.SafeCleanup();
var model = Model.Unsafe;

View File

@@ -1,27 +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;
namespace Microsoft.CmdPal.UI.ViewModels.Messages;
/// <summary>
/// Sent when a dock band is dropped onto a different monitor's dock.
/// The source DockControl should remove the band from its ViewModel.
/// </summary>
public sealed class CrossMonitorBandDropMessage
{
public string BandId { get; }
public string SourceMonitorDeviceId { get; }
public CrossMonitorBandDropMessage(string bandId, string sourceMonitorDeviceId)
{
ArgumentNullException.ThrowIfNull(bandId);
ArgumentNullException.ThrowIfNull(sourceMonitorDeviceId);
BandId = bandId;
SourceMonitorDeviceId = sourceMonitorDeviceId;
}
}

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.ComponentModel;
using Microsoft.CommandPalette.Extensions;
namespace Microsoft.CmdPal.UI.ViewModels.Messages;

View File

@@ -17,13 +17,7 @@ public interface IMonitorService
IReadOnlyList<MonitorInfo> GetMonitors();
/// <summary>
/// Gets a specific monitor by its stable hardware identifier.
/// </summary>
MonitorInfo? GetMonitorByStableId(string stableId);
/// <summary>
/// Gets a specific monitor by its GDI device name (e.g. <c>\\.\DISPLAY1</c>).
/// Prefer <see cref="GetMonitorByStableId"/> for persistent lookups.
/// Gets a specific monitor by its device identifier.
/// </summary>
MonitorInfo? GetMonitorByDeviceId(string deviceId);

View File

@@ -12,21 +12,10 @@ namespace Microsoft.CmdPal.UI.ViewModels.Models;
public sealed record MonitorInfo
{
/// <summary>
/// Gets the GDI device name (e.g. <c>\\.\DISPLAY1</c>).
/// This is volatile and may change across reboots or plug/unplug events.
/// Use <see cref="StableId"/> for persistent identification.
/// Gets the device identifier (e.g. <c>\\.\DISPLAY1</c>).
/// </summary>
public required string DeviceId { get; init; }
/// <summary>
/// Gets a stable hardware identifier derived from the Display Configuration API
/// device path (e.g. <c>\\?\DISPLAY#GSM1388#4&amp;125707d6&amp;0&amp;UID8388688#{guid}</c>).
/// Unlike <see cref="DeviceId"/>, this value survives reboots, driver updates,
/// and plug/unplug events on the same GPU port. Falls back to <see cref="DeviceId"/>
/// when the Display Configuration API is unavailable.
/// </summary>
public required string StableId { get; init; }
/// <summary>
/// Gets the human-readable display name (e.g. <c>DELL U2723QE</c>).
/// </summary>

View File

@@ -74,20 +74,6 @@ public abstract partial class ParameterRunViewModel : ExtensionObjectViewModel
{
// Override in derived classes
}
protected override void UnsafeCleanup()
{
base.UnsafeCleanup();
// Unsubscribe from the extension model's PropChanged event so we
// don't keep this view model alive for as long as the extension
// object lives.
var model = _model.Unsafe;
if (model is not null)
{
model.PropChanged -= Model_PropChanged;
}
}
}
/// <summary>
@@ -196,20 +182,12 @@ public partial class ParameterValueRunViewModel : ParameterRunViewModel
}
}
public partial class StringParameterRunViewModel : ParameterValueRunViewModel, IDisposable
public partial class StringParameterRunViewModel : ParameterValueRunViewModel
{
// Exclusive scheduler ensures writes to the extension's Text property are
// serialized in the order they were submitted from the UI, so rapid
// typing can't deliver updates out of order.
private readonly TaskFactory _writeTaskFactory = new(new ConcurrentExclusiveSchedulerPair().ExclusiveScheduler);
private ExtensionObject<IStringParameterRun> _model;
private string _modelText = string.Empty;
// For cancelling in-flight writes when a newer value arrives.
private CancellationTokenSource? _writeCancellationTokenSource;
public string TextForUI { get => _modelText; set => SetTextFromUi(value); }
public StringParameterRunViewModel(IStringParameterRun stringRun, WeakReference<IPageContext> context)
@@ -234,74 +212,19 @@ public partial class StringParameterRunViewModel : ParameterValueRunViewModel, I
public void SetTextFromUi(string value)
{
if (value == _modelText)
if (value != _modelText)
{
return;
}
_modelText = value;
_modelText = value;
// Cancel any pending write that hasn't started yet, so we don't push
// stale values to the extension.
CancelAndDisposeTokenSource(ref _writeCancellationTokenSource);
var writeCts = _writeCancellationTokenSource = new CancellationTokenSource();
var writeToken = writeCts.Token;
// Hop off to an exclusive scheduler background thread to update the
// extension. The exclusive scheduler ensures writes are serialized
// and in-order (mirroring ListViewModel.OnSearchTextBoxUpdated).
_ = _writeTaskFactory.StartNew(
() =>
_ = Task.Run(() =>
{
if (writeToken.IsCancellationRequested)
var stringRun = _model.Unsafe;
if (stringRun != null)
{
return;
stringRun.Text = value;
}
try
{
var stringRun = _model.Unsafe;
if (stringRun != null)
{
stringRun.Text = value;
}
}
catch (OperationCanceledException)
{
}
catch (Exception ex)
{
ShowException(ex);
}
},
writeToken,
TaskCreationOptions.None,
_writeTaskFactory.Scheduler!);
}
private static void CancelAndDisposeTokenSource(ref CancellationTokenSource? tokenSource)
{
var tokenSourceToDispose = Interlocked.Exchange(ref tokenSource, null);
if (tokenSourceToDispose is null)
{
return;
});
}
tokenSourceToDispose.Cancel();
tokenSourceToDispose.Dispose();
}
protected override void UnsafeCleanup()
{
base.UnsafeCleanup();
CancelAndDisposeTokenSource(ref _writeCancellationTokenSource);
}
public void Dispose()
{
GC.SuppressFinalize(this);
SafeCleanup();
}
protected override void FetchProperty(string propertyName)
@@ -433,7 +356,10 @@ public partial class CommandParameterRunViewModel : ParameterValueRunViewModel,
GetHwndMessage msg = new();
WeakReferenceMessenger.Default.Send(msg);
var command = commandRun.GetSelectValueCommand((ulong)msg.Hwnd);
if (command is IListPage list)
if (command == null)
{
}
else if (command is IListPage list)
{
if (PageContext.TryGetTarget(out var pageContext))
{
@@ -517,18 +443,10 @@ public partial class CommandParameterRunViewModel : ParameterValueRunViewModel,
WeakReferenceMessenger.Default.Send(m);
}
protected override void UnsafeCleanup()
{
base.UnsafeCleanup();
_listViewModel?.Dispose();
_listViewModel = null;
}
public void Dispose()
{
GC.SuppressFinalize(this);
SafeCleanup();
_listViewModel?.Dispose();
}
}
@@ -670,22 +588,14 @@ public partial class ParametersPageViewModel : PageViewModel, IDisposable
if (removedItem is CommandParameterRunViewModel removedCmdParam)
{
removedCmdParam.ValueChanged -= ListParamValueChanged;
// If the active list param is being removed, clear the
// active references so we don't hold on to a disposed
// ListViewModel.
if (removedCmdParam == _activeListParam)
{
SetActiveListParameter(null);
}
}
removedItem.SafeCleanup();
}
}
catch (Exception ex)
catch (Exception)
{
CoreLogger.LogError($"Error fetching parameter items: {ex.Message}");
// Handle exceptions (e.g., log them)
}
DoOnUiThread(
@@ -728,17 +638,20 @@ public partial class ParametersPageViewModel : PageViewModel, IDisposable
if (e.PropertyName == nameof(ParameterValueRunViewModel.NeedsValue))
{
// Marshal to UI thread — PropChanged events from the extension
// arrive on a background thread, but UpdateCommand touches UI
// controls.
//
// Note: For CommandParameterRunViewModel, advancing focus and
// clearing the active list is handled exclusively by
// ListParamValueChanged (subscribed to ValueChanged). That event
// fires for NeedsValue, DisplayText, and Icon changes, so it
// covers both first-pick and re-pick. Handling it here as well
// would risk a double-advance race.
// arrive on a background thread, but FocusNextParameter sends a
// message that ultimately touches UI controls.
DoOnUiThread(() =>
{
// First-time pick for a list param (NeedsValue true -> false).
if (sender is CommandParameterRunViewModel cmdParam &&
cmdParam == _activeListParam &&
!cmdParam.NeedsValue)
{
CoreLogger.LogDebug($"[ParametersPageVM] First-time list param pick, clearing active list");
SetActiveListParameter(null);
FocusNextParameter(cmdParam);
}
UpdateCommand();
});
}
@@ -835,27 +748,16 @@ public partial class ParametersPageViewModel : PageViewModel, IDisposable
public void Dispose()
{
GC.SuppressFinalize(this);
SafeCleanup();
}
protected override void UnsafeCleanup()
{
base.UnsafeCleanup();
// Drop the active list param reference before disposing items so we
// don't end up pointing at a disposed ListViewModel.
SetActiveListParameter(null);
lock (_listLock)
{
foreach (var item in Items)
{
item.PropertyChanged -= ItemPropertyChanged;
if (item is CommandParameterRunViewModel cmdParam)
{
cmdParam.ValueChanged -= ListParamValueChanged;
}
item.SafeCleanup();
}

View File

@@ -1,7 +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.
namespace Microsoft.CmdPal.UI.ViewModels;
public record PinnedCommandSettings(string ProviderId, string CommandId);

View File

@@ -321,15 +321,6 @@ namespace Microsoft.CmdPal.UI.ViewModels.Properties {
}
}
/// <summary>
/// Looks up a localized string similar to {0}, {1} command.
/// </summary>
public static string builtin_extension_subtext_singular {
get {
return ResourceManager.GetString("builtin_extension_subtext_singular", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to {0}, {1}.
/// </summary>
@@ -348,33 +339,6 @@ namespace Microsoft.CmdPal.UI.ViewModels.Properties {
}
}
/// <summary>
/// Looks up a localized string similar to {0}, {1} command, {2} fallback command.
/// </summary>
public static string builtin_extension_subtext_with_fallback_singular_both {
get {
return ResourceManager.GetString("builtin_extension_subtext_with_fallback_singular_both", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to {0}, {1} command, {2} fallback commands.
/// </summary>
public static string builtin_extension_subtext_with_fallback_singular_command {
get {
return ResourceManager.GetString("builtin_extension_subtext_with_fallback_singular_command", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to {0}, {1} commands, {2} fallback command.
/// </summary>
public static string builtin_extension_subtext_with_fallback_singular_fallback {
get {
return ResourceManager.GetString("builtin_extension_subtext_with_fallback_singular_fallback", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to Home.
/// </summary>
@@ -1176,15 +1140,6 @@ namespace Microsoft.CmdPal.UI.ViewModels.Properties {
}
}
/// <summary>
/// Looks up a localized string similar to Pinned.
/// </summary>
public static string home_sections_pinned_title {
get {
return ResourceManager.GetString("home_sections_pinned_title", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to Pinned.
/// </summary>

View File

@@ -133,26 +133,10 @@
<value>{0}, {1} commands</value>
<comment>{0}=extension name, {1}=number of commands</comment>
</data>
<data name="builtin_extension_subtext_singular" xml:space="preserve">
<value>{0}, {1} command</value>
<comment>{0}=extension name, {1}=number of commands (1)</comment>
</data>
<data name="builtin_extension_subtext_with_fallback" xml:space="preserve">
<value>{0}, {1} commands, {2} fallback commands</value>
<comment>{0}=extension name, {1}=number of commands, {2} number of fallback commands</comment>
</data>
<data name="builtin_extension_subtext_with_fallback_singular_command" xml:space="preserve">
<value>{0}, {1} command, {2} fallback commands</value>
<comment>{0}=extension name, {1}=number of commands (1), {2} number of fallback commands</comment>
</data>
<data name="builtin_extension_subtext_with_fallback_singular_fallback" xml:space="preserve">
<value>{0}, {1} commands, {2} fallback command</value>
<comment>{0}=extension name, {1}=number of commands, {2} number of fallback commands (1)</comment>
</data>
<data name="builtin_extension_subtext_with_fallback_singular_both" xml:space="preserve">
<value>{0}, {1} command, {2} fallback command</value>
<comment>{0}=extension name, {1}=number of commands (1), {2} number of fallback commands (1)</comment>
</data>
<data name="builtin_extension_subtext_disabled" xml:space="preserve">
<value>{0}, {1}</value>
<comment>{0}=extension name, {1}=message</comment>
@@ -315,9 +299,6 @@
<value>Results</value>
<comment>Section title for list of all search results that doesn't fall into any other category</comment>
</data>
<data name="home_sections_pinned_title" xml:space="preserve">
<value>Pinned</value>
</data>
<data name="home_sections_commands_title" xml:space="preserve">
<value>Commands</value>
</data>

View File

@@ -18,11 +18,7 @@ public partial class ProviderSettingsViewModel : ObservableObject
{
private static readonly IconInfoViewModel EmptyIcon = new(null);
private static readonly CompositeFormat ExtensionSubtextFormat = CompositeFormat.Parse(Resources.builtin_extension_subtext);
private static readonly CompositeFormat ExtensionSubtextSingularFormat = CompositeFormat.Parse(Resources.builtin_extension_subtext_singular);
private static readonly CompositeFormat ExtensionSubtextWithFallbackFormat = CompositeFormat.Parse(Resources.builtin_extension_subtext_with_fallback);
private static readonly CompositeFormat ExtensionSubtextWithFallbackSingularCommandFormat = CompositeFormat.Parse(Resources.builtin_extension_subtext_with_fallback_singular_command);
private static readonly CompositeFormat ExtensionSubtextWithFallbackSingularFallbackFormat = CompositeFormat.Parse(Resources.builtin_extension_subtext_with_fallback_singular_fallback);
private static readonly CompositeFormat ExtensionSubtextWithFallbackSingularBothFormat = CompositeFormat.Parse(Resources.builtin_extension_subtext_with_fallback_singular_both);
private static readonly CompositeFormat ExtensionSubtextDisabledFormat = CompositeFormat.Parse(Resources.builtin_extension_subtext_disabled);
private readonly CommandProviderWrapper _provider;
@@ -51,40 +47,11 @@ public partial class ProviderSettingsViewModel : ObservableObject
public string ExtensionName => _provider.Extension?.ExtensionDisplayName ?? Resources.builtin_extension_name;
public string ExtensionSubtext
{
get
{
if (!IsEnabled)
{
return string.Format(CultureInfo.CurrentCulture, ExtensionSubtextDisabledFormat, ExtensionName, Resources.builtin_disabled_extension);
}
int commandCount = TopLevelCommands.Count;
if (HasFallbackCommands)
{
int fallbackCount = _provider.FallbackItems?.Length ?? 0;
bool commandSingular = commandCount == 1;
bool fallbackSingular = fallbackCount == 1;
CompositeFormat format = (commandSingular, fallbackSingular) switch
{
(true, true) => ExtensionSubtextWithFallbackSingularBothFormat,
(true, false) => ExtensionSubtextWithFallbackSingularCommandFormat,
(false, true) => ExtensionSubtextWithFallbackSingularFallbackFormat,
(false, false) => ExtensionSubtextWithFallbackFormat,
};
return string.Format(CultureInfo.CurrentCulture, format, ExtensionName, commandCount, fallbackCount);
}
else
{
CompositeFormat format = commandCount == 1 ? ExtensionSubtextSingularFormat : ExtensionSubtextFormat;
return string.Format(CultureInfo.CurrentCulture, format, ExtensionName, commandCount);
}
}
}
public string ExtensionSubtext => IsEnabled ?
HasFallbackCommands ?
string.Format(CultureInfo.CurrentCulture, ExtensionSubtextWithFallbackFormat, ExtensionName, TopLevelCommands.Count, _provider.FallbackItems?.Length ?? 0) :
string.Format(CultureInfo.CurrentCulture, ExtensionSubtextFormat, ExtensionName, TopLevelCommands.Count) :
string.Format(CultureInfo.CurrentCulture, ExtensionSubtextDisabledFormat, ExtensionName, Resources.builtin_disabled_extension);
[MemberNotNullWhen(true, nameof(Extension))]
public bool IsFromExtension => _provider.Extension is not null;

View File

@@ -96,13 +96,6 @@ public sealed class SettingsService : ISettingsService
Debug.WriteLine($"Migration check failed: {ex}");
}
var normalizedSettings = _settings.NormalizePinnedCommands();
if (!ReferenceEquals(normalizedSettings, _settings))
{
_settings = normalizedSettings;
migratedAny = true;
}
if (migratedAny)
{
Save(hotReload: false);

View File

@@ -109,12 +109,11 @@ public record DockSettings
/// Gets the dock side override for a specific monitor, or <c>null</c> if the
/// monitor has no override (inherits global <see cref="Side"/>).
/// </summary>
/// <param name="stableId">The monitor's stable hardware identifier (stored in <see cref="DockMonitorConfig.MonitorDeviceId"/>).</param>
public DockSide? GetSideForMonitor(string stableId)
public DockSide? GetSideForMonitor(string deviceId)
{
foreach (var cfg in MonitorConfigs)
{
if (string.Equals(cfg.MonitorDeviceId, stableId, StringComparison.OrdinalIgnoreCase))
if (string.Equals(cfg.MonitorDeviceId, deviceId, StringComparison.OrdinalIgnoreCase))
{
return cfg.Side;
}

View File

@@ -11,9 +11,9 @@ namespace Microsoft.CmdPal.UI.ViewModels.Settings;
/// <summary>
/// Reconciles persisted <see cref="DockMonitorConfig"/> entries against the
/// set of currently connected monitors. Uses <see cref="MonitorInfo.StableId"/>
/// (hardware device path) for persistent identification, with automatic
/// migration from legacy GDI device names (e.g. <c>\\.\DISPLAY1</c>).
/// set of currently connected monitors. Handles stale device IDs that may change
/// across reboots by using the <see cref="DockMonitorConfig.IsPrimary"/> flag as
/// a secondary matching key.
/// </summary>
/// <remarks>
/// All operations are pure — they return new immutable lists rather than
@@ -30,8 +30,7 @@ public static class MonitorConfigReconciler
/// <summary>
/// Reconciles persisted monitor configs against the current set of connected monitors.
/// <para>
/// <b>Phase 1</b>: Exact StableId matching — keep IsPrimary up-to-date.<br/>
/// <b>Phase 1.5</b>: Legacy migration — match configs with GDI-style IDs by GDI name, then rewrite to StableId.<br/>
/// <b>Phase 1</b>: Exact DeviceId matching — keep IsPrimary up-to-date.<br/>
/// <b>Phase 2</b>: Fuzzy matching — reassociate unmatched configs by IsPrimary flag.<br/>
/// <b>Phase 3</b>: Create default configs for monitors that have no matching config.<br/>
/// <b>Phase 4</b>: Retain disconnected monitor configs for future reconnection; prune entries not seen for 6+ months.
@@ -62,63 +61,39 @@ public static class MonitorConfigReconciler
return existingConfigs;
}
// Build a MonitorDeviceId → index lookup for O(1) matching
var configIndexById = new Dictionary<string, int>(existingConfigs.Count, StringComparer.OrdinalIgnoreCase);
// Build a DeviceId → index lookup for O(1) matching in Phase 1
var configIndexByDeviceId = new Dictionary<string, int>(existingConfigs.Count, StringComparer.OrdinalIgnoreCase);
for (var i = 0; i < existingConfigs.Count; i++)
{
configIndexById.TryAdd(existingConfigs[i].MonitorDeviceId, i);
configIndexByDeviceId.TryAdd(existingConfigs[i].MonitorDeviceId, i);
}
var matchedMonitorStableIds = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
var matchedMonitorDeviceIds = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
var matchedConfigIndices = new HashSet<int>();
var result = new List<DockMonitorConfig>(currentMonitors.Count);
// Phase 1: Exact match on StableId (configs already migrated to stable paths)
// Phase 1: Exact DeviceId match (O(N) with dictionary lookup)
for (var mi = 0; mi < currentMonitors.Count; mi++)
{
var monitor = currentMonitors[mi];
if (configIndexById.TryGetValue(monitor.StableId, out var ci) && !matchedConfigIndices.Contains(ci))
if (configIndexByDeviceId.TryGetValue(monitor.DeviceId, out var ci) && !matchedConfigIndices.Contains(ci))
{
// Update IsPrimary and LastSeen to current state
result.Add(existingConfigs[ci] with { IsPrimary = monitor.IsPrimary, LastSeen = utcNow });
matchedMonitorStableIds.Add(monitor.StableId);
matchedMonitorDeviceIds.Add(monitor.DeviceId);
matchedConfigIndices.Add(ci);
}
}
// Phase 1.5: Legacy migration — match configs that still have GDI-style IDs
// (e.g. "\\.\DISPLAY1") by matching against the monitor's GDI DeviceId,
// then rewrite the MonitorDeviceId to the monitor's stable hardware path.
for (var mi = 0; mi < currentMonitors.Count; mi++)
{
var monitor = currentMonitors[mi];
if (matchedMonitorStableIds.Contains(monitor.StableId))
{
continue;
}
if (configIndexById.TryGetValue(monitor.DeviceId, out var ci) && !matchedConfigIndices.Contains(ci))
{
// Migrate: rewrite from GDI name to stable path
result.Add(existingConfigs[ci] with
{
MonitorDeviceId = monitor.StableId,
IsPrimary = monitor.IsPrimary,
LastSeen = utcNow,
});
matchedMonitorStableIds.Add(monitor.StableId);
matchedConfigIndices.Add(ci);
}
}
// Phase 2: Fuzzy match — recover primary monitor config when its ID changed.
// Windows can reassign device paths across driver updates or cable swaps.
// When the primary monitor's StableId no longer matches any saved config,
// Phase 2: Fuzzy match — recover primary monitor config when its DeviceId changed.
// Windows can reassign DeviceId strings across reboots, driver updates, or cable
// swaps. When the primary monitor's DeviceId no longer matches any saved config,
// we look for an unmatched config that was previously marked as primary and
// reassociate it. Secondary monitors are not interchangeable, so we skip them.
for (var mi = 0; mi < currentMonitors.Count; mi++)
{
var monitor = currentMonitors[mi];
if (!monitor.IsPrimary || matchedMonitorStableIds.Contains(monitor.StableId))
if (!monitor.IsPrimary || matchedMonitorDeviceIds.Contains(monitor.DeviceId))
{
continue;
}
@@ -132,13 +107,14 @@ public static class MonitorConfigReconciler
if (existingConfigs[ci].IsPrimary)
{
// Reassociate: update DeviceId, IsPrimary, and LastSeen
result.Add(existingConfigs[ci] with
{
MonitorDeviceId = monitor.StableId,
MonitorDeviceId = monitor.DeviceId,
IsPrimary = monitor.IsPrimary,
LastSeen = utcNow,
});
matchedMonitorStableIds.Add(monitor.StableId);
matchedMonitorDeviceIds.Add(monitor.DeviceId);
matchedConfigIndices.Add(ci);
break;
}
@@ -147,21 +123,22 @@ public static class MonitorConfigReconciler
// Phase 3: Create defaults for new monitors with no matching config.
// Primary monitors inherit global bands (IsCustomized = false) for a seamless
// upgrade path. Secondary monitors start disabled with empty band lists
// users opt-in via Settings when they want the dock on additional displays.
// upgrade path. Secondary monitors start with empty band lists so users don't
// have to manually unpin bands from every new display.
for (var mi = 0; mi < currentMonitors.Count; mi++)
{
var monitor = currentMonitors[mi];
if (matchedMonitorStableIds.Contains(monitor.StableId))
if (matchedMonitorDeviceIds.Contains(monitor.DeviceId))
{
continue;
}
if (monitor.IsPrimary)
{
// Primary: inherit global bands (IsCustomized = false)
result.Add(new DockMonitorConfig
{
MonitorDeviceId = monitor.StableId,
MonitorDeviceId = monitor.DeviceId,
Enabled = true,
IsPrimary = true,
LastSeen = utcNow,
@@ -169,10 +146,11 @@ public static class MonitorConfigReconciler
}
else
{
// Secondary: start with empty bands so users choose what to pin per-monitor
result.Add(new DockMonitorConfig
{
MonitorDeviceId = monitor.StableId,
Enabled = false,
MonitorDeviceId = monitor.DeviceId,
Enabled = true,
IsPrimary = false,
IsCustomized = true,
StartBands = ImmutableList<DockBandSettings>.Empty,

View File

@@ -37,9 +37,6 @@ public record SettingsModel
public bool AllowBreakthroughShortcut { get; init; }
public ImmutableList<PinnedCommandSettings> PinnedCommands { get; init; }
= ImmutableList<PinnedCommandSettings>.Empty;
public bool AllowExternalReload { get; init; }
private ImmutableDictionary<string, ProviderSettings>? _providerSettings
@@ -140,25 +137,6 @@ public record SettingsModel
// END SETTINGS
///////////////////////////////////////////////////////////////////////////
[JsonConstructor]
public SettingsModel(
ImmutableList<PinnedCommandSettings>? pinnedCommands = null,
ImmutableDictionary<string, ProviderSettings>? providerSettings = null,
string[]? fallbackRanks = null,
ImmutableDictionary<string, CommandAlias>? aliases = null,
ImmutableList<TopLevelHotkey>? commandHotkeys = null)
{
PinnedCommands = pinnedCommands ?? ImmutableList<PinnedCommandSettings>.Empty;
ProviderSettings = providerSettings ?? ImmutableDictionary<string, ProviderSettings>.Empty;
FallbackRanks = fallbackRanks ?? [];
Aliases = aliases ?? ImmutableDictionary<string, CommandAlias>.Empty;
CommandHotkeys = commandHotkeys ?? ImmutableList<TopLevelHotkey>.Empty;
}
public SettingsModel()
{
}
public (SettingsModel Model, ProviderSettings Settings) GetProviderSettings(CommandProviderWrapper provider)
{
if (!ProviderSettings.TryGetValue(provider.ProviderId, out var settings))
@@ -181,186 +159,6 @@ public record SettingsModel
return (newModel, connected);
}
public SettingsModel NormalizePinnedCommands()
{
var pinnedCommands = PinnedCommands;
if (pinnedCommands.Count == 0)
{
var migratedPins = ImmutableList.CreateBuilder<PinnedCommandSettings>();
foreach (var (providerId, providerSettings) in ProviderSettings.OrderBy(static kvp => kvp.Key, StringComparer.Ordinal))
{
foreach (var commandId in providerSettings.PinnedCommandIds)
{
migratedPins.Add(new PinnedCommandSettings(providerId, commandId));
}
}
pinnedCommands = migratedPins.ToImmutable();
}
return WithPinnedCommands(pinnedCommands);
}
public SettingsModel WithPinnedCommands(ImmutableList<PinnedCommandSettings> pinnedCommands)
{
var groupedPinnedCommands = pinnedCommands
.GroupBy(static pin => pin.ProviderId, StringComparer.Ordinal)
.ToDictionary(
static group => group.Key,
static group => group.Select(static pin => pin.CommandId).ToImmutableList(),
StringComparer.Ordinal);
var allProviderIds = ProviderSettings.Keys.Union(groupedPinnedCommands.Keys, StringComparer.Ordinal).ToArray();
var providerSettingsAlreadyMatch = allProviderIds.All(providerId =>
{
ProviderSettings.TryGetValue(providerId, out var currentProviderSettings);
groupedPinnedCommands.TryGetValue(providerId, out var desiredPinnedIds);
var currentPinnedIds = currentProviderSettings?.PinnedCommandIds ?? ImmutableList<string>.Empty;
desiredPinnedIds ??= ImmutableList<string>.Empty;
return currentPinnedIds.SequenceEqual(desiredPinnedIds);
});
if (PinnedCommands.SequenceEqual(pinnedCommands) && providerSettingsAlreadyMatch)
{
return this;
}
var providerSettingsBuilder = ProviderSettings.ToBuilder();
foreach (var providerId in allProviderIds)
{
providerSettingsBuilder.TryGetValue(providerId, out var providerSettings);
providerSettings ??= new ProviderSettings();
groupedPinnedCommands.TryGetValue(providerId, out var desiredPinnedIds);
desiredPinnedIds ??= ImmutableList<string>.Empty;
providerSettingsBuilder[providerId] = providerSettings with { PinnedCommandIds = desiredPinnedIds };
}
return this with
{
PinnedCommands = pinnedCommands,
ProviderSettings = providerSettingsBuilder.ToImmutable(),
};
}
public bool IsCommandPinned(string providerId, string commandId)
{
foreach (var pinnedCommand in PinnedCommands)
{
if (pinnedCommand.ProviderId == providerId &&
pinnedCommand.CommandId == commandId)
{
return true;
}
}
return false;
}
public List<string> GetPinnedCommandIds(string providerId)
{
List<string> pinnedCommandIds = [];
foreach (var pinnedCommand in PinnedCommands)
{
if (pinnedCommand.ProviderId == providerId)
{
pinnedCommandIds.Add(pinnedCommand.CommandId);
}
}
return pinnedCommandIds;
}
public SettingsModel TryPinCommand(string providerId, string commandId)
{
if (IsCommandPinned(providerId, commandId))
{
return this;
}
return WithPinnedCommands(PinnedCommands.Add(new PinnedCommandSettings(providerId, commandId)));
}
public SettingsModel TryUnpinCommand(string providerId, string commandId)
{
for (var i = 0; i < PinnedCommands.Count; i++)
{
var pinnedCommand = PinnedCommands[i];
if (pinnedCommand.ProviderId == providerId &&
pinnedCommand.CommandId == commandId)
{
return WithPinnedCommands(PinnedCommands.RemoveAt(i));
}
}
return this;
}
public SettingsModel TryMovePinnedCommand(string providerId, string commandId, bool moveUp, Func<PinnedCommandSettings, bool>? isVisible = null)
{
var index = FindPinnedCommandIndex(providerId, commandId);
if (index < 0)
{
return this;
}
// Find the next visible neighbor in the move direction, skipping
// stale entries (removed/disabled/failed extensions).
var direction = moveUp ? -1 : 1;
var targetIndex = index + direction;
while (targetIndex >= 0 && targetIndex < PinnedCommands.Count &&
isVisible != null && !isVisible(PinnedCommands[targetIndex]))
{
targetIndex += direction;
}
if (targetIndex < 0 || targetIndex >= PinnedCommands.Count)
{
return this;
}
// Remove and re-insert rather than swap so that stale entries
// between index and targetIndex keep their relative positions.
var pinnedCommand = PinnedCommands[index];
var pinnedCommands = PinnedCommands.RemoveAt(index);
pinnedCommands = pinnedCommands.Insert(targetIndex, pinnedCommand);
return WithPinnedCommands(pinnedCommands);
}
public SettingsModel TryMovePinnedCommandToTop(string providerId, string commandId)
{
var index = FindPinnedCommandIndex(providerId, commandId);
if (index <= 0)
{
return this;
}
var pinnedCommand = PinnedCommands[index];
var pinnedCommands = PinnedCommands.RemoveAt(index);
pinnedCommands = pinnedCommands.Insert(0, pinnedCommand);
return WithPinnedCommands(pinnedCommands);
}
private int FindPinnedCommandIndex(string providerId, string commandId)
{
for (var i = 0; i < PinnedCommands.Count; i++)
{
var pinnedCommand = PinnedCommands[i];
if (pinnedCommand.ProviderId == providerId &&
pinnedCommand.CommandId == commandId)
{
return i;
}
}
return -1;
}
public string[] GetGlobalFallbacks()
{
var globalFallbacks = new HashSet<string>();
@@ -410,7 +208,6 @@ public record SettingsModel
[JsonSerializable(typeof(ImmutableList<HistoryItem>), TypeInfoPropertyName = "ImmutableHistoryList")]
[JsonSerializable(typeof(ImmutableDictionary<string, FallbackSettings>), TypeInfoPropertyName = "ImmutableFallbackDictionary")]
[JsonSerializable(typeof(ImmutableList<string>), TypeInfoPropertyName = "ImmutableStringList")]
[JsonSerializable(typeof(ImmutableList<PinnedCommandSettings>), TypeInfoPropertyName = "ImmutablePinnedCommandSettingsList")]
[JsonSerializable(typeof(ImmutableList<DockBandSettings>), TypeInfoPropertyName = "ImmutableDockBandSettingsList")]
[JsonSerializable(typeof(DockMonitorConfig))]
[JsonSerializable(typeof(ImmutableList<DockMonitorConfig>), TypeInfoPropertyName = "ImmutableDockMonitorConfigList")]
@@ -418,7 +215,6 @@ public record SettingsModel
[JsonSerializable(typeof(ImmutableDictionary<string, CommandAlias>), TypeInfoPropertyName = "ImmutableAliasDictionary")]
[JsonSerializable(typeof(ImmutableList<TopLevelHotkey>), TypeInfoPropertyName = "ImmutableTopLevelHotkeyList")]
[JsonSerializable(typeof(Dictionary<string, object>), TypeInfoPropertyName = "Dictionary")]
[JsonSerializable(typeof(PinnedCommandSettings))]
[JsonSourceGenerationOptions(UseStringEnumConverter = true, WriteIndented = true, IncludeFields = true, PropertyNameCaseInsensitive = true, AllowTrailingCommas = true)]
[System.Diagnostics.CodeAnalysis.SuppressMessage("StyleCop.CSharp.MaintainabilityRules", "SA1402:File may only contain a single type", Justification = "Just used here")]
internal sealed partial class JsonSerializationContext : JsonSerializerContext

View File

@@ -371,7 +371,7 @@ public partial class SettingsViewModel : INotifyPropertyChanged
foreach (var monitor in monitors)
{
var config = reconciled.FirstOrDefault(c =>
string.Equals(c.MonitorDeviceId, monitor.StableId, StringComparison.OrdinalIgnoreCase));
string.Equals(c.MonitorDeviceId, monitor.DeviceId, StringComparison.OrdinalIgnoreCase));
if (config is not null)
{

View File

@@ -48,8 +48,6 @@ public sealed partial class TopLevelCommandManager : ObservableObject,
private CancellationTokenSource _extensionLoadCts = new();
private CancellationToken _currentExtensionLoadCancellationToken;
private HashSet<(string ProviderId, string CommandId)> _pinnedCommandSet = [];
public TopLevelCommandManager(IServiceProvider serviceProvider, ICommandProviderCache commandProviderCache)
{
_serviceProvider = serviceProvider;
@@ -61,11 +59,8 @@ public sealed partial class TopLevelCommandManager : ObservableObject,
WeakReferenceMessenger.Default.Register<UnpinCommandItemMessage>(this);
WeakReferenceMessenger.Default.Register<PinToDockMessage>(this);
_reloadCommandsGate = new(ReloadAllCommandsAsyncCore);
RebuildPinnedCache();
}
public ObservableCollection<PinnedCommandSettings> PinnedCommands { get; } = [];
public ObservableCollection<TopLevelViewModel> TopLevelCommands { get; set; } = [];
public ObservableCollection<TopLevelViewModel> DockBands { get; set; } = [];
@@ -84,18 +79,6 @@ public sealed partial class TopLevelCommandManager : ObservableObject,
}
}
internal bool IsPinned(string providerId, string commandId)
{
return _pinnedCommandSet.Contains((providerId, commandId));
}
internal void RebuildPinnedCache()
{
var settings = _serviceProvider.GetRequiredService<ISettingsService>().Settings;
_pinnedCommandSet = new(settings.PinnedCommands.Select(p => (p.ProviderId, p.CommandId)));
ListHelpers.InPlaceUpdateList(PinnedCommands, settings.PinnedCommands);
}
public async Task<bool> LoadBuiltinsAsync()
{
var s = new Stopwatch();
@@ -710,14 +693,12 @@ public sealed partial class TopLevelCommandManager : ObservableObject,
{
var wrapper = LookupProvider(message.ProviderId);
wrapper?.PinCommand(message.CommandId, _serviceProvider);
RebuildPinnedCache();
}
public void Receive(UnpinCommandItemMessage message)
{
var wrapper = LookupProvider(message.ProviderId);
wrapper?.UnpinCommand(message.CommandId, _serviceProvider);
RebuildPinnedCache();
}
public void Receive(PinToDockMessage message)

View File

@@ -95,9 +95,11 @@ internal sealed partial class CommandPaletteContextMenuFactory : IContextMenuFac
// Add pin/unpin commands for pinning items to the top-level or to
// the dock.
var providerId = providerContext.ProviderId;
if (_topLevelCommandManager.LookupProvider(providerId) is CommandProviderWrapper)
if (_topLevelCommandManager.LookupProvider(providerId) is CommandProviderWrapper provider)
{
var alreadyPinnedToTopLevel = _settingsService.Settings.IsCommandPinned(providerId, itemId);
var (_, providerSettings) = _settingsService.Settings.GetProviderSettings(provider);
var alreadyPinnedToTopLevel = providerSettings.PinnedCommandIds.Contains(itemId);
// Don't add pin/unpin commands for items displayed as
// TopLevelViewModels that aren't already pinned.
@@ -119,7 +121,7 @@ internal sealed partial class CommandPaletteContextMenuFactory : IContextMenuFac
moreCommands.Add(contextItem);
}
TryAddPinToDockCommand(itemId, providerId, moreCommands, commandItem);
TryAddPinToDockCommand(providerSettings, itemId, providerId, moreCommands, commandItem);
}
}
@@ -153,19 +155,33 @@ internal sealed partial class CommandPaletteContextMenuFactory : IContextMenuFac
List<IContextItem?> contextItems)
{
var itemId = topLevelItem.Id;
var supportsPinning = providerContext.SupportsPinning;
List<IContextItem> moreCommands = [];
var commandItem = topLevelItem.ItemViewModel;
// Add pin/unpin commands for pinning items to the top-level or to
// the dock.
var providerId = providerContext.ProviderId;
if (_topLevelCommandManager.LookupProvider(providerId) is CommandProviderWrapper)
if (_topLevelCommandManager.LookupProvider(providerId) is CommandProviderWrapper provider)
{
TryAddMovePinnedCommands(itemId, providerId, commandItem, moreCommands);
TryAddUnpinFromHomeCommand(itemId, providerId, commandItem, moreCommands);
TryAddPinToHomeCommand(itemId, providerId, commandItem, moreCommands);
var (_, providerSettings) = _settingsService.Settings.GetProviderSettings(provider);
TryAddPinToDockCommand(itemId, providerId, moreCommands, commandItem);
var isPinnedSubCommand = providerSettings.PinnedCommandIds.Contains(itemId);
if (isPinnedSubCommand)
{
var pinToTopLevelCommand = new PinToCommand(
commandId: itemId,
providerId: providerId,
pin: !isPinnedSubCommand,
PinLocation.TopLevel,
_settingsService,
_topLevelCommandManager);
var contextItem = new PinToContextItem(pinToTopLevelCommand, commandItem);
moreCommands.Add(contextItem);
}
TryAddPinToDockCommand(providerSettings, itemId, providerId, moreCommands, commandItem);
}
if (moreCommands.Count > 0)
@@ -177,73 +193,8 @@ internal sealed partial class CommandPaletteContextMenuFactory : IContextMenuFac
}
}
private void TryAddPinToHomeCommand(
string itemId,
string providerId,
CommandItemViewModel commandItem,
List<IContextItem> moreCommands)
{
if (_settingsService.Settings.IsCommandPinned(providerId, itemId))
{
return;
}
var pinToTopLevelCommand = new PinToCommand(
commandId: itemId,
providerId: providerId,
pin: true,
PinLocation.TopLevel,
_settingsService,
_topLevelCommandManager);
var contextItem = new PinToContextItem(pinToTopLevelCommand, commandItem);
moreCommands.Add(contextItem);
}
private void TryAddUnpinFromHomeCommand(
string itemId,
string providerId,
CommandItemViewModel commandItem,
List<IContextItem> moreCommands)
{
var isPinnedSubCommand = _settingsService.Settings.IsCommandPinned(providerId, itemId);
if (isPinnedSubCommand)
{
var pinToTopLevelCommand = new PinToCommand(
commandId: itemId,
providerId: providerId,
pin: !isPinnedSubCommand,
PinLocation.TopLevel,
_settingsService,
_topLevelCommandManager);
var contextItem = new PinToContextItem(pinToTopLevelCommand, commandItem);
moreCommands.Add(contextItem);
}
}
private void TryAddMovePinnedCommands(
string itemId,
string providerId,
CommandItemViewModel commandItem,
List<IContextItem> moreCommands)
{
if (!_settingsService.Settings.IsCommandPinned(providerId, itemId))
{
return;
}
var moveToTopCommand = new MovePinnedCommand(providerId, itemId, MovePinnedDirection.ToTop, _settingsService, _topLevelCommandManager);
moreCommands.Add(new MovePinnedContextItem(moveToTopCommand, commandItem));
var moveUpCommand = new MovePinnedCommand(providerId, itemId, MovePinnedDirection.Up, _settingsService, _topLevelCommandManager);
moreCommands.Add(new MovePinnedContextItem(moveUpCommand, commandItem));
var moveDownCommand = new MovePinnedCommand(providerId, itemId, MovePinnedDirection.Down, _settingsService, _topLevelCommandManager);
moreCommands.Add(new MovePinnedContextItem(moveDownCommand, commandItem));
}
private void TryAddPinToDockCommand(
ProviderSettings providerSettings,
string itemId,
string providerId,
List<IContextItem> moreCommands,
@@ -310,30 +261,6 @@ internal sealed partial class CommandPaletteContextMenuFactory : IContextMenuFac
}
}
private sealed partial class MovePinnedContextItem : CommandContextItem
{
private readonly MovePinnedCommand _command;
private readonly CommandItemViewModel _commandItem;
public MovePinnedContextItem(MovePinnedCommand command, CommandItemViewModel commandItem)
: base(command)
{
_command = command;
_commandItem = commandItem;
command.MoveStateChanged += this.OnMoveStateChanged;
}
private void OnMoveStateChanged(object? sender, EventArgs e)
{
_commandItem.RefreshMoreCommands();
}
~MovePinnedContextItem()
{
_command.MoveStateChanged -= this.OnMoveStateChanged;
}
}
private sealed partial class PinToCommand : InvokableCommand
{
private readonly string _commandId;
@@ -439,85 +366,4 @@ internal sealed partial class CommandPaletteContextMenuFactory : IContextMenuFac
WeakReferenceMessenger.Default.Send(message);
}
}
private sealed partial class MovePinnedCommand : InvokableCommand
{
private readonly string _providerId;
private readonly string _commandId;
private readonly MovePinnedDirection _moveDirection;
private readonly ISettingsService _settingsService;
private readonly TopLevelCommandManager _topLevelCommandManager;
public override IconInfo Icon => _moveDirection switch
{
MovePinnedDirection.ToTop => Icons.MoveToTopIcon,
MovePinnedDirection.Up => Icons.MoveUpIcon,
_ => Icons.MoveDownIcon,
};
public override string Name => _moveDirection switch
{
MovePinnedDirection.ToTop => RS_.GetString("top_level_move_to_top_command_name"),
MovePinnedDirection.Up => RS_.GetString("top_level_move_up_command_name"),
_ => RS_.GetString("top_level_move_down_command_name"),
};
internal event EventHandler? MoveStateChanged;
public MovePinnedCommand(
string providerId,
string commandId,
MovePinnedDirection moveDirection,
ISettingsService settingsService,
TopLevelCommandManager topLevelCommandManager)
{
_providerId = providerId;
_commandId = commandId;
_moveDirection = moveDirection;
_settingsService = settingsService;
_topLevelCommandManager = topLevelCommandManager;
}
public override CommandResult Invoke()
{
var moved = false;
_settingsService.UpdateSettings(
s =>
{
var updated = _moveDirection switch
{
MovePinnedDirection.ToTop => s.TryMovePinnedCommandToTop(_providerId, _commandId),
MovePinnedDirection.Up => s.TryMovePinnedCommand(_providerId, _commandId, true, IsLoaded),
_ => s.TryMovePinnedCommand(_providerId, _commandId, false, IsLoaded),
};
moved = !ReferenceEquals(updated, s);
return updated;
},
hotReload: false);
if (moved)
{
WeakReferenceMessenger.Default.Send<UpdateFallbackItemsMessage>();
MoveStateChanged?.Invoke(this, EventArgs.Empty);
}
return CommandResult.KeepOpen();
// Pass a visibility check so moves skip stale pinned entries
// (removed/disabled/failed extensions) that aren't shown on home.
bool IsLoaded(PinnedCommandSettings pin)
{
return _topLevelCommandManager.LookupCommand(pin.CommandId) is TopLevelViewModel cmd &&
cmd.CommandProviderId == pin.ProviderId;
}
}
}
private enum MovePinnedDirection
{
ToTop,
Up,
Down,
}
}

View File

@@ -703,22 +703,6 @@ public sealed partial class SearchBar : UserControl,
e.Handled = true;
}
}
else if (e.Key == VirtualKey.Tab)
{
// Tab away from a list parameter: dismiss the list panel so it
// doesn't linger when the user keyboard-navigates to a different
// control. Don't mark e.Handled — let the default Tab behavior
// move focus to the next/previous control.
if (textBox.DataContext is CommandParameterRunViewModel listParam)
{
if (!listParam.NeedsValue)
{
listParam.CancelEditing();
}
parametersPage.SetActiveListParameter(null);
}
}
else if (e.Key == VirtualKey.Up)
{
WeakReferenceMessenger.Default.Send<NavigatePreviousCommand>();

View File

@@ -15,15 +15,15 @@
HorizontalAlignment="Right"
VerticalAlignment="Bottom"
Style="{StaticResource AccentButtonStyle}"
Visibility="{x:Bind help:BindTransformers.BoolToVisibility(ViewModel.HasVisibleOperations), Mode=OneWay, FallbackValue=Collapsed}">
Visibility="{x:Bind help:BindTransformers.BoolToVisibility(HasVisibleOperations), Mode=OneWay, FallbackValue=Collapsed}">
<StackPanel Orientation="Horizontal" Spacing="10">
<FontIcon FontSize="14" Glyph="&#xE8B7;" />
<ProgressRing
Width="16"
Height="16"
IsActive="{x:Bind ViewModel.HasActiveOperations, Mode=OneWay}"
Visibility="{x:Bind help:BindTransformers.BoolToVisibility(ViewModel.HasActiveOperations), Mode=OneWay, FallbackValue=Collapsed}" />
<TextBlock Text="{x:Bind ViewModel.SummaryText, Mode=OneWay}" />
IsActive="{x:Bind HasActiveOperations, Mode=OneWay}"
Visibility="{x:Bind help:BindTransformers.BoolToVisibility(HasActiveOperations), Mode=OneWay, FallbackValue=Collapsed}" />
<TextBlock Text="{x:Bind SummaryText, Mode=OneWay}" />
</StackPanel>
<Button.Flyout>
<Flyout Placement="TopEdgeAlignedRight">
@@ -32,7 +32,7 @@
MaxHeight="420"
Padding="16">
<StackPanel Spacing="12">
<TextBlock Style="{StaticResource BodyStrongTextBlockStyle}" Text="{x:Bind ViewModel.FlyoutHeaderText, Mode=OneWay}" />
<TextBlock Style="{StaticResource BodyStrongTextBlockStyle}" Text="{x:Bind FlyoutHeaderText, Mode=OneWay}" />
<ScrollViewer MaxHeight="320">
<ItemsControl ItemsSource="{x:Bind ViewModel.Operations, Mode=OneWay}">
<ItemsControl.ItemTemplate>

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.ComponentModel;
using Microsoft.CmdPal.Common.WinGet.Services;
using Microsoft.CmdPal.UI.ViewModels.WinGet;
using Microsoft.Extensions.DependencyInjection;
@@ -15,6 +16,14 @@ public sealed partial class WinGetOperationsButton : UserControl, IDisposable
public WinGetOperationsViewModel ViewModel { get; }
public bool HasVisibleOperations => ViewModel.HasVisibleOperations;
public bool HasActiveOperations => ViewModel.HasActiveOperations;
public string SummaryText => ViewModel.SummaryText;
public string FlyoutHeaderText => ViewModel.FlyoutHeaderText;
public WinGetOperationsButton()
{
var trackerService = App.Current.Services.GetRequiredService<IWinGetOperationTrackerService>();
@@ -22,6 +31,7 @@ public sealed partial class WinGetOperationsButton : UserControl, IDisposable
ViewModel = new WinGetOperationsViewModel(trackerService, uiScheduler);
this.InitializeComponent();
ViewModel.PropertyChanged += OnViewModelPropertyChanged;
}
public void Dispose()
@@ -32,6 +42,12 @@ public sealed partial class WinGetOperationsButton : UserControl, IDisposable
}
_disposed = true;
ViewModel.PropertyChanged -= OnViewModelPropertyChanged;
ViewModel.Dispose();
}
private void OnViewModelPropertyChanged(object? sender, PropertyChangedEventArgs e)
{
Bindings.Update();
}
}

View File

@@ -23,7 +23,7 @@ using Windows.Foundation;
namespace Microsoft.CmdPal.UI.Dock;
public sealed partial class DockControl : UserControl, IRecipient<CloseContextMenuMessage>, IRecipient<EnterDockEditModeMessage>, IRecipient<ExitDockEditModeMessage>, IRecipient<CrossMonitorBandDropMessage>
public sealed partial class DockControl : UserControl, IRecipient<CloseContextMenuMessage>, IRecipient<EnterDockEditModeMessage>, IRecipient<ExitDockEditModeMessage>
{
private DockViewModel _viewModel;
@@ -96,7 +96,6 @@ public sealed partial class DockControl : UserControl, IRecipient<CloseContextMe
WeakReferenceMessenger.Default.Register<CloseContextMenuMessage>(this);
WeakReferenceMessenger.Default.Register<EnterDockEditModeMessage>(this);
WeakReferenceMessenger.Default.Register<ExitDockEditModeMessage>(this);
WeakReferenceMessenger.Default.Register<CrossMonitorBandDropMessage>(this);
ViewModel.CenterItems.CollectionChanged -= CenterItems_CollectionChanged;
ViewModel.CenterItems.CollectionChanged += CenterItems_CollectionChanged;
@@ -462,21 +461,12 @@ public sealed partial class DockControl : UserControl, IRecipient<CloseContextMe
{
_draggedBand = band;
e.Data.RequestedOperation = DataPackageOperation.Move;
// Only advertise cross-monitor data when we have a real monitor ID.
// Without one (single-monitor / global dock) the cross-monitor path
// cannot safely distinguish source from target.
if (ViewModel.MonitorDeviceId is not null)
{
e.Data.Properties["DockBandId"] = band.Id;
e.Data.Properties["SourceMonitorDeviceId"] = ViewModel.MonitorDeviceId;
}
}
}
private void BandListView_DragOver(object sender, DragEventArgs e)
{
if (_draggedBand != null || e.DataView.Properties.ContainsKey("DockBandId"))
if (_draggedBand != null)
{
e.AcceptedOperation = DataPackageOperation.Move;
}
@@ -538,27 +528,15 @@ public sealed partial class DockControl : UserControl, IRecipient<CloseContextMe
private void HandleCrossListDrop(DockPinSide targetSide, DragEventArgs e)
{
if (_draggedBand != null)
if (_draggedBand == null)
{
HandleLocalCrossListDrop(targetSide, e);
return;
}
// Cross-monitor drag from another DockControl
if (e.DataView.Properties.TryGetValue("DockBandId", out var bandIdObj) &&
e.DataView.Properties.TryGetValue("SourceMonitorDeviceId", out var sourceMonitorObj) &&
bandIdObj is string bandId &&
sourceMonitorObj is string sourceMonitorDeviceId)
{
HandleCrossMonitorDrop(bandId, sourceMonitorDeviceId, targetSide, e);
}
}
private void HandleLocalCrossListDrop(DockPinSide targetSide, DragEventArgs e)
{
// Check which list the band is currently in
var isInStart = ViewModel.StartItems.Contains(_draggedBand!);
var isInCenter = ViewModel.CenterItems.Contains(_draggedBand!);
var isInStart = ViewModel.StartItems.Contains(_draggedBand);
var isInCenter = ViewModel.CenterItems.Contains(_draggedBand);
var isInEnd = ViewModel.EndItems.Contains(_draggedBand);
DockPinSide sourceSide;
if (isInStart)
@@ -577,6 +555,7 @@ public sealed partial class DockControl : UserControl, IRecipient<CloseContextMe
// Only handle cross-list drops here; same-list reorders are handled in DragItemsCompleted
if (sourceSide != targetSide)
{
// Calculate drop index based on drop position
var targetListView = targetSide switch
{
DockPinSide.Start => StartListView,
@@ -593,38 +572,11 @@ public sealed partial class DockControl : UserControl, IRecipient<CloseContextMe
var dropIndex = GetDropIndex(targetListView, e, targetCollection.Count);
// Move the band to the new side (without saving - save happens on Done)
ViewModel.MoveBandWithoutSaving(_draggedBand!, targetSide, dropIndex);
ViewModel.MoveBandWithoutSaving(_draggedBand, targetSide, dropIndex);
e.Handled = true;
}
}
private void HandleCrossMonitorDrop(string bandId, string sourceMonitorDeviceId, DockPinSide targetSide, DragEventArgs e)
{
var targetListView = targetSide switch
{
DockPinSide.Start => StartListView,
DockPinSide.Center => CenterListView,
_ => EndListView,
};
var targetCollection = targetSide switch
{
DockPinSide.Start => ViewModel.StartItems,
DockPinSide.Center => ViewModel.CenterItems,
_ => ViewModel.EndItems,
};
var dropIndex = GetDropIndex(targetListView, e, targetCollection.Count);
ViewModel.AcceptBandFromMonitor(bandId, targetSide, dropIndex);
if (!string.IsNullOrEmpty(sourceMonitorDeviceId))
{
WeakReferenceMessenger.Default.Send(new CrossMonitorBandDropMessage(bandId, sourceMonitorDeviceId));
}
e.Handled = true;
}
private int GetDropIndex(ListView listView, DragEventArgs e, int itemCount)
{
var position = e.GetPosition(listView);
@@ -704,7 +656,7 @@ public sealed partial class DockControl : UserControl, IRecipient<CloseContextMe
private void BandListView_DragEnter(object sender, DragEventArgs e)
{
if (sender is ListView view && (_draggedBand != null || e.DataView.Properties.ContainsKey("DockBandId")))
if (sender is ListView view)
{
view.Background = Application.Current.Resources["ControlAltFillColorQuarternaryBrush"] as SolidColorBrush;
e.DragUIOverride.IsGlyphVisible = false;
@@ -724,23 +676,4 @@ public sealed partial class DockControl : UserControl, IRecipient<CloseContextMe
listView.Background = new SolidColorBrush(Colors.Transparent);
}
}
public void Receive(CrossMonitorBandDropMessage message)
{
// Only match if this dock has a real monitor ID that matches the source.
if (ViewModel.MonitorDeviceId is null)
{
return;
}
if (!string.Equals(ViewModel.MonitorDeviceId, message.SourceMonitorDeviceId, StringComparison.OrdinalIgnoreCase))
{
return;
}
DispatcherQueue.TryEnqueue(() =>
{
ViewModel.RemoveBandById(message.BandId);
});
}
}

View File

@@ -500,7 +500,7 @@ public sealed partial class DockWindow : WindowEx,
return;
}
var refreshed = _monitorService.GetMonitorByStableId(_targetMonitor.StableId);
var refreshed = _monitorService.GetMonitorByDeviceId(_targetMonitor.DeviceId);
if (refreshed is not null)
{
_targetMonitor = refreshed;
@@ -515,7 +515,7 @@ public sealed partial class DockWindow : WindowEx,
return;
}
_sideOverride = _settings.GetSideForMonitor(_targetMonitor.StableId);
_sideOverride = _settings.GetSideForMonitor(_targetMonitor.DeviceId);
}
/// <summary>

View File

@@ -131,7 +131,7 @@ public sealed partial class DockWindowManager : IDisposable
continue;
}
var monitor = _monitorService.GetMonitorByStableId(config.MonitorDeviceId);
var monitor = _monitorService.GetMonitorByDeviceId(config.MonitorDeviceId);
if (monitor is null)
{
continue;
@@ -184,7 +184,7 @@ public sealed partial class DockWindowManager : IDisposable
{
var viewModel = CreateDockViewModel(monitorDeviceId);
var monitor = _monitorService.GetMonitorByStableId(monitorDeviceId);
var monitor = _monitorService.GetMonitorByDeviceId(monitorDeviceId);
var sideOverride = dockSettings.GetSideForMonitor(monitorDeviceId);
var window = new DockWindow(viewModel, monitor, sideOverride);
@@ -250,7 +250,7 @@ public sealed partial class DockWindowManager : IDisposable
{
new DockMonitorConfig
{
MonitorDeviceId = primary.StableId,
MonitorDeviceId = primary.DeviceId,
Enabled = true,
Side = null,
IsPrimary = true,

View File

@@ -1,696 +0,0 @@
<?xml version="1.0" encoding="utf-8" ?>
<UserControl
x:Class="Microsoft.CmdPal.UI.ListItemsView"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:cmdpalUI="using:Microsoft.CmdPal.UI"
xmlns:controls="using:CommunityToolkit.WinUI.Controls"
xmlns:cpcontrols="using:Microsoft.CmdPal.UI.Controls"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:help="using:Microsoft.CmdPal.UI.Helpers"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
xmlns:viewModels="using:Microsoft.CmdPal.UI.ViewModels"
x:Name="ViewRoot"
Background="Transparent"
DataContext="{x:Bind ViewModel, Mode=OneWay}"
mc:Ignorable="d">
<UserControl.Resources>
<!--
GridViewItemCornerRadius is the corner radius defined in GridView template; make
it bigger to match the radii of the gallery
-->
<CornerRadius x:Key="GalleryGridViewItemContainerCornerRadius">6</CornerRadius>
<CornerRadius x:Key="IconGridViewItemContainerCornerRadius">4</CornerRadius>
<CornerRadius x:Key="GalleryGridViewItemRadius">4</CornerRadius>
<CornerRadius x:Key="SmallGridViewItemCornerRadius">8</CornerRadius>
<CornerRadius x:Key="MediumGridViewItemCornerRadius">8</CornerRadius>
<x:Double x:Key="SmallGridSize">32</x:Double>
<x:Double x:Key="MediumGridSize">48</x:Double>
<x:Double x:Key="MediumGridContainerSize">100</x:Double>
<x:Double x:Key="GalleryGridSize">160</x:Double>
<!--
BEAR LOADING: The list view is virtualized and the item container style is set to a fixed height
to ensure the virtualization works correctly.
-->
<x:Double x:Key="SingleRowListViewItemHeight">44</x:Double>
<x:Double x:Key="ListViewSectionHeight">28</x:Double>
<x:Double x:Key="ListViewSeparatorHeight">28</x:Double>
<Style x:Key="IconGridViewItemStyle" TargetType="GridViewItem">
<Setter Property="HorizontalContentAlignment" Value="Stretch" />
<Setter Property="VerticalContentAlignment" Value="Center" />
<Setter Property="Template">
<Setter.Value>
<ControlTemplate TargetType="GridViewItem">
<ListViewItemPresenter
x:Name="Root"
HorizontalContentAlignment="{TemplateBinding HorizontalContentAlignment}"
VerticalContentAlignment="{TemplateBinding VerticalContentAlignment}"
CheckBoxBorderBrush="{ThemeResource GridViewItemCheckBoxBorderBrush}"
CheckBoxBrush="{ThemeResource GridViewItemCheckBoxBrush}"
CheckBoxCornerRadius="{ThemeResource GridViewItemCheckBoxCornerRadius}"
CheckBoxDisabledBorderBrush="{ThemeResource GridViewItemCheckBoxDisabledBorderBrush}"
CheckBoxDisabledBrush="{ThemeResource GridViewItemCheckBoxDisabledBrush}"
CheckBoxPointerOverBorderBrush="{ThemeResource GridViewItemCheckBoxPointerOverBorderBrush}"
CheckBoxPointerOverBrush="{ThemeResource GridViewItemCheckBoxPointerOverBrush}"
CheckBoxPressedBorderBrush="{ThemeResource GridViewItemCheckBoxPressedBorderBrush}"
CheckBoxPressedBrush="{ThemeResource GridViewItemCheckBoxPressedBrush}"
CheckBoxSelectedBrush="{ThemeResource GridViewItemCheckBoxSelectedBrush}"
CheckBoxSelectedDisabledBrush="{ThemeResource GridViewItemCheckBoxSelectedDisabledBrush}"
CheckBoxSelectedPointerOverBrush="{ThemeResource GridViewItemCheckBoxSelectedPointerOverBrush}"
CheckBoxSelectedPressedBrush="{ThemeResource GridViewItemCheckBoxSelectedPressedBrush}"
CheckBrush="{ThemeResource GridViewItemCheckBrush}"
CheckDisabledBrush="{ThemeResource GridViewItemCheckDisabledBrush}"
CheckMode="{ThemeResource GridViewItemCheckMode}"
CheckPressedBrush="{ThemeResource GridViewItemCheckPressedBrush}"
ContentMargin="{TemplateBinding Padding}"
ContentTransitions="{TemplateBinding ContentTransitions}"
Control.IsTemplateFocusTarget="True"
CornerRadius="{StaticResource IconGridViewItemContainerCornerRadius}"
DisabledOpacity="{ThemeResource ListViewItemDisabledThemeOpacity}"
DragBackground="{ThemeResource GridViewItemDragBackground}"
DragForeground="{ThemeResource GridViewItemDragForeground}"
DragOpacity="{ThemeResource ListViewItemDragThemeOpacity}"
FocusBorderBrush="{ThemeResource GridViewItemFocusBorderBrush}"
FocusVisualMargin="{TemplateBinding FocusVisualMargin}"
FocusVisualPrimaryBrush="{TemplateBinding FocusVisualPrimaryBrush}"
FocusVisualPrimaryThickness="{TemplateBinding FocusVisualPrimaryThickness}"
FocusVisualSecondaryBrush="{TemplateBinding FocusVisualSecondaryBrush}"
FocusVisualSecondaryThickness="{TemplateBinding FocusVisualSecondaryThickness}"
PlaceholderBackground="{ThemeResource GridViewItemPlaceholderBackground}"
PointerOverBackground="{ThemeResource GridViewItemBackgroundPointerOver}"
PointerOverBorderBrush="{ThemeResource GridViewItemPointerOverBorderBrush}"
PointerOverForeground="{ThemeResource GridViewItemForegroundPointerOver}"
PressedBackground="{ThemeResource GridViewItemBackgroundPressed}"
ReorderHintOffset="{ThemeResource GridViewItemReorderHintThemeOffset}"
SelectedBackground="{ThemeResource GridViewItemBackgroundSelected}"
SelectedBorderBrush="{ThemeResource GridViewItemSelectedBorderBrush}"
SelectedBorderThickness="{ThemeResource GridViewItemSelectedBorderThickness}"
SelectedDisabledBackground="{ThemeResource GridViewItemBackgroundSelectedDisabled}"
SelectedDisabledBorderBrush="{ThemeResource GridViewItemSelectedDisabledBorderBrush}"
SelectedForeground="{ThemeResource GridViewItemForegroundSelected}"
SelectedInnerBorderBrush="{ThemeResource GridViewItemSelectedInnerBorderBrush}"
SelectedPointerOverBackground="{ThemeResource GridViewItemBackgroundSelectedPointerOver}"
SelectedPointerOverBorderBrush="{ThemeResource GridViewItemSelectedPointerOverBorderBrush}"
SelectedPressedBackground="{ThemeResource GridViewItemBackgroundSelectedPressed}"
SelectedPressedBorderBrush="{ThemeResource GridViewItemSelectedPressedBorderBrush}"
SelectionCheckMarkVisualEnabled="{ThemeResource GridViewItemSelectionCheckMarkVisualEnabled}" />
</ControlTemplate>
</Setter.Value>
</Setter>
</Style>
<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">
<ListViewItemPresenter
x:Name="Root"
HorizontalContentAlignment="{TemplateBinding HorizontalContentAlignment}"
VerticalContentAlignment="{TemplateBinding VerticalContentAlignment}"
CheckBoxBorderBrush="{ThemeResource GridViewItemCheckBoxBorderBrush}"
CheckBoxBrush="{ThemeResource GridViewItemCheckBoxBrush}"
CheckBoxCornerRadius="{ThemeResource GridViewItemCheckBoxCornerRadius}"
CheckBoxDisabledBorderBrush="{ThemeResource GridViewItemCheckBoxDisabledBorderBrush}"
CheckBoxDisabledBrush="{ThemeResource GridViewItemCheckBoxDisabledBrush}"
CheckBoxPointerOverBorderBrush="{ThemeResource GridViewItemCheckBoxPointerOverBorderBrush}"
CheckBoxPointerOverBrush="{ThemeResource GridViewItemCheckBoxPointerOverBrush}"
CheckBoxPressedBorderBrush="{ThemeResource GridViewItemCheckBoxPressedBorderBrush}"
CheckBoxPressedBrush="{ThemeResource GridViewItemCheckBoxPressedBrush}"
CheckBoxSelectedBrush="{ThemeResource GridViewItemCheckBoxSelectedBrush}"
CheckBoxSelectedDisabledBrush="{ThemeResource GridViewItemCheckBoxSelectedDisabledBrush}"
CheckBoxSelectedPointerOverBrush="{ThemeResource GridViewItemCheckBoxSelectedPointerOverBrush}"
CheckBoxSelectedPressedBrush="{ThemeResource GridViewItemCheckBoxSelectedPressedBrush}"
CheckBrush="{ThemeResource GridViewItemCheckBrush}"
CheckDisabledBrush="{ThemeResource GridViewItemCheckDisabledBrush}"
CheckMode="{ThemeResource GridViewItemCheckMode}"
CheckPressedBrush="{ThemeResource GridViewItemCheckPressedBrush}"
ContentMargin="{TemplateBinding Padding}"
ContentTransitions="{TemplateBinding ContentTransitions}"
Control.IsTemplateFocusTarget="True"
CornerRadius="{StaticResource GalleryGridViewItemContainerCornerRadius}"
DisabledOpacity="{ThemeResource ListViewItemDisabledThemeOpacity}"
DragBackground="{ThemeResource GridViewItemDragBackground}"
DragForeground="{ThemeResource GridViewItemDragForeground}"
DragOpacity="{ThemeResource ListViewItemDragThemeOpacity}"
FocusBorderBrush="{ThemeResource GridViewItemFocusBorderBrush}"
FocusVisualMargin="{TemplateBinding FocusVisualMargin}"
FocusVisualPrimaryBrush="{TemplateBinding FocusVisualPrimaryBrush}"
FocusVisualPrimaryThickness="{TemplateBinding FocusVisualPrimaryThickness}"
FocusVisualSecondaryBrush="{TemplateBinding FocusVisualSecondaryBrush}"
FocusVisualSecondaryThickness="{TemplateBinding FocusVisualSecondaryThickness}"
PlaceholderBackground="{ThemeResource GridViewItemPlaceholderBackground}"
PointerOverBackground="{ThemeResource GridViewItemBackgroundPointerOver}"
PointerOverBorderBrush="{ThemeResource GridViewItemPointerOverBorderBrush}"
PointerOverForeground="{ThemeResource GridViewItemForegroundPointerOver}"
PressedBackground="{ThemeResource GridViewItemBackgroundPressed}"
ReorderHintOffset="{ThemeResource GridViewItemReorderHintThemeOffset}"
SelectedBackground="{ThemeResource GridViewItemBackgroundSelected}"
SelectedBorderBrush="{ThemeResource GridViewItemSelectedBorderBrush}"
SelectedBorderThickness="{ThemeResource GridViewItemSelectedBorderThickness}"
SelectedDisabledBackground="{ThemeResource GridViewItemBackgroundSelectedDisabled}"
SelectedDisabledBorderBrush="{ThemeResource GridViewItemSelectedDisabledBorderBrush}"
SelectedForeground="{ThemeResource GridViewItemForegroundSelected}"
SelectedInnerBorderBrush="{ThemeResource GridViewItemSelectedInnerBorderBrush}"
SelectedPointerOverBackground="{ThemeResource GridViewItemBackgroundSelectedPointerOver}"
SelectedPointerOverBorderBrush="{ThemeResource GridViewItemSelectedPointerOverBorderBrush}"
SelectedPressedBackground="{ThemeResource GridViewItemBackgroundSelectedPressed}"
SelectedPressedBorderBrush="{ThemeResource GridViewItemSelectedPressedBorderBrush}"
SelectionCheckMarkVisualEnabled="{ThemeResource GridViewItemSelectionCheckMarkVisualEnabled}" />
</ControlTemplate>
</Setter.Value>
</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,0,12,0" />
<Setter Property="Margin" Value="0" />
<Setter Property="HorizontalContentAlignment" Value="Stretch" />
<Setter Property="VerticalContentAlignment" Value="Bottom" />
<Setter Property="MinHeight" Value="{StaticResource ListViewSectionHeight}" />
<Setter Property="Height" Value="{StaticResource ListViewSectionHeight}" />
<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,0,12,0" />
<Setter Property="Margin" Value="0" />
<Setter Property="HorizontalContentAlignment" Value="Stretch" />
<Setter Property="VerticalContentAlignment" Value="Center" />
<Setter Property="MinHeight" Value="{StaticResource ListViewSeparatorHeight}" />
<Setter Property="Height" Value="{StaticResource ListViewSeparatorHeight}" />
<Setter Property="cpcontrols:WrapPanel.IsFullLine" Value="True" />
</Style>
<ControlTemplate x:Key="ListViewItemWithoutVisualIndicatorTemplate" TargetType="ListViewItem">
<ListViewItemPresenter
x:Name="Root"
HorizontalContentAlignment="{TemplateBinding HorizontalContentAlignment}"
VerticalContentAlignment="{TemplateBinding VerticalContentAlignment}"
CheckBoxBorderBrush="{ThemeResource ListViewItemCheckBoxBorderBrush}"
CheckBoxBrush="{ThemeResource ListViewItemCheckBoxBrush}"
CheckBoxCornerRadius="{ThemeResource ListViewItemCheckBoxCornerRadius}"
CheckBoxDisabledBorderBrush="{ThemeResource ListViewItemCheckBoxDisabledBorderBrush}"
CheckBoxDisabledBrush="{ThemeResource ListViewItemCheckBoxDisabledBrush}"
CheckBoxPointerOverBorderBrush="{ThemeResource ListViewItemCheckBoxPointerOverBorderBrush}"
CheckBoxPointerOverBrush="{ThemeResource ListViewItemCheckBoxPointerOverBrush}"
CheckBoxPressedBorderBrush="{ThemeResource ListViewItemCheckBoxPressedBorderBrush}"
CheckBoxPressedBrush="{ThemeResource ListViewItemCheckBoxPressedBrush}"
CheckBoxSelectedBrush="{ThemeResource ListViewItemCheckBoxSelectedBrush}"
CheckBoxSelectedDisabledBrush="{ThemeResource ListViewItemCheckBoxSelectedDisabledBrush}"
CheckBoxSelectedPointerOverBrush="{ThemeResource ListViewItemCheckBoxSelectedPointerOverBrush}"
CheckBoxSelectedPressedBrush="{ThemeResource ListViewItemCheckBoxSelectedPressedBrush}"
CheckBrush="{ThemeResource ListViewItemCheckBrush}"
CheckDisabledBrush="{ThemeResource ListViewItemCheckDisabledBrush}"
CheckMode="{ThemeResource ListViewItemCheckMode}"
CheckPressedBrush="{ThemeResource ListViewItemCheckPressedBrush}"
ContentMargin="{TemplateBinding Padding}"
ContentTransitions="{TemplateBinding ContentTransitions}"
Control.IsTemplateFocusTarget="True"
CornerRadius="{ThemeResource ListViewItemCornerRadius}"
DisabledOpacity="{ThemeResource ListViewItemDisabledThemeOpacity}"
DragBackground="{ThemeResource ListViewItemDragBackground}"
DragForeground="{ThemeResource ListViewItemDragForeground}"
DragOpacity="{ThemeResource ListViewItemDragThemeOpacity}"
FocusBorderBrush="{ThemeResource ListViewItemFocusBorderBrush}"
FocusSecondaryBorderBrush="{ThemeResource ListViewItemFocusSecondaryBorderBrush}"
FocusVisualMargin="{TemplateBinding FocusVisualMargin}"
FocusVisualPrimaryBrush="{TemplateBinding FocusVisualPrimaryBrush}"
FocusVisualPrimaryThickness="{TemplateBinding FocusVisualPrimaryThickness}"
FocusVisualSecondaryBrush="{TemplateBinding FocusVisualSecondaryBrush}"
FocusVisualSecondaryThickness="{TemplateBinding FocusVisualSecondaryThickness}"
PlaceholderBackground="{ThemeResource ListViewItemPlaceholderBackground}"
PointerOverBackground="{ThemeResource ListViewItemBackgroundPointerOver}"
PointerOverForeground="{ThemeResource ListViewItemForegroundPointerOver}"
PressedBackground="{ThemeResource ListViewItemBackgroundPressed}"
ReorderHintOffset="{ThemeResource ListViewItemReorderHintThemeOffset}"
SelectedBackground="{ThemeResource ListViewItemBackgroundSelected}"
SelectedDisabledBackground="{ThemeResource ListViewItemBackgroundSelectedDisabled}"
SelectedForeground="{ThemeResource ListViewItemForegroundSelected}"
SelectedPointerOverBackground="{ThemeResource ListViewItemBackgroundSelectedPointerOver}"
SelectedPressedBackground="{ThemeResource ListViewItemBackgroundSelectedPressed}"
SelectionCheckMarkVisualEnabled="{ThemeResource ListViewItemSelectionCheckMarkVisualEnabled}"
SelectionIndicatorBrush="{ThemeResource ListViewItemSelectionIndicatorBrush}"
SelectionIndicatorCornerRadius="{ThemeResource ListViewItemSelectionIndicatorCornerRadius}"
SelectionIndicatorDisabledBrush="{ThemeResource ListViewItemSelectionIndicatorDisabledBrush}"
SelectionIndicatorPointerOverBrush="{ThemeResource ListViewItemSelectionIndicatorPointerOverBrush}"
SelectionIndicatorPressedBrush="{ThemeResource ListViewItemSelectionIndicatorPressedBrush}"
SelectionIndicatorVisualEnabled="False" />
</ControlTemplate>
<Style
x:Key="ListSingleRowItemContainerStyle"
BasedOn="{StaticResource DefaultListViewItemStyle}"
TargetType="ListViewItem">
<Setter Property="MinHeight" Value="{StaticResource SingleRowListViewItemHeight}" />
<Setter Property="Height" Value="{StaticResource SingleRowListViewItemHeight}" />
<Setter Property="HorizontalContentAlignment" Value="Stretch" />
<Setter Property="Template" Value="{StaticResource ListViewItemWithoutVisualIndicatorTemplate}" />
</Style>
<Style
x:Key="ListSectionContainerStyle"
BasedOn="{StaticResource DefaultListViewItemStyle}"
TargetType="ListViewItem">
<Setter Property="IsEnabled" Value="False" />
<Setter Property="AllowFocusWhenDisabled" Value="False" />
<Setter Property="AllowFocusOnInteraction" Value="False" />
<Setter Property="IsHitTestVisible" Value="False" />
<Setter Property="IsTabStop" Value="False" />
<Setter Property="IsHoldingEnabled" Value="False" />
<Setter Property="AllowDrop" Value="False" />
<Setter Property="Padding" Value="16,0,12,0" />
<Setter Property="HorizontalContentAlignment" Value="Stretch" />
<Setter Property="VerticalContentAlignment" Value="Bottom" />
<Setter Property="MinHeight" Value="{StaticResource ListViewSectionHeight}" />
<Setter Property="Height" Value="{StaticResource ListViewSectionHeight}" />
</Style>
<Style
x:Key="ListSeparatorContainerStyle"
BasedOn="{StaticResource DefaultListViewItemStyle}"
TargetType="ListViewItem">
<Setter Property="IsEnabled" Value="False" />
<Setter Property="AllowFocusWhenDisabled" Value="False" />
<Setter Property="AllowFocusOnInteraction" Value="False" />
<Setter Property="IsHitTestVisible" Value="False" />
<Setter Property="IsTabStop" Value="False" />
<Setter Property="IsHoldingEnabled" Value="False" />
<Setter Property="AllowDrop" Value="False" />
<Setter Property="Padding" Value="16,0,12,0" />
<Setter Property="HorizontalContentAlignment" Value="Stretch" />
<Setter Property="VerticalContentAlignment" Value="Center" />
<Setter Property="MinHeight" Value="{StaticResource ListViewSeparatorHeight}" />
<Setter Property="Height" Value="{StaticResource ListViewSeparatorHeight}" />
</Style>
<DataTemplate x:Key="TagTemplate" x:DataType="viewModels:TagViewModel">
<!--
Tags are immutable, so we don't have to worry about binding mode.
-->
<cpcontrols:Tag
AutomationProperties.Name="{x:Bind Text}"
BackgroundColor="{x:Bind Background}"
FontSize="12"
ForegroundColor="{x:Bind Foreground}"
Icon="{x:Bind Icon}"
Text="{x:Bind Text}"
ToolTipService.ToolTip="{x:Bind ToolTip}" />
</DataTemplate>
<cmdpalUI:ListItemTemplateSelector
x:Key="ListItemTemplateSelector"
x:DataType="viewModels:ListItemViewModel"
ListItem="{StaticResource ListItemSingleRowViewModelTemplate}"
Section="{StaticResource ListSectionViewModelTemplate}"
Separator="{StaticResource ListSeparatorViewModelTemplate}" />
<cmdpalUI:ListItemContainerStyleSelector
x:Key="ListItemContainerStyleSelector"
Default="{StaticResource ListSingleRowItemContainerStyle}"
Section="{StaticResource ListSectionContainerStyle}"
Separator="{StaticResource ListSeparatorContainerStyle}" />
<cmdpalUI:GridItemTemplateSelector
x:Key="GridItemTemplateSelector"
x:DataType="viewModels:ListItemViewModel"
Gallery="{StaticResource GalleryGridItemViewModelTemplate}"
GridProperties="{x:Bind ViewModel.GridProperties, Mode=OneWay}"
Medium="{StaticResource MediumGridItemViewModelTemplate}"
Section="{StaticResource ListSectionViewModelTemplate}"
Separator="{StaticResource GridSeparatorViewModelTemplate}"
Small="{StaticResource SmallGridItemViewModelTemplate}" />
<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}" />
<DataTemplate x:Key="ListItemSingleRowViewModelTemplate" x:DataType="viewModels:ListItemViewModel">
<Grid AutomationProperties.Name="{x:Bind Title, Mode=OneWay}" ColumnSpacing="12">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="28" />
<ColumnDefinition Width="*" />
<ColumnDefinition Width="Auto" />
</Grid.ColumnDefinitions>
<cpcontrols:IconBox
x:Name="IconBorder"
Grid.Column="0"
Width="20"
Height="20"
AutomationProperties.AccessibilityView="Raw"
Foreground="{ThemeResource TextFillColorSecondaryBrush}"
SourceKey="{x:Bind Icon, Mode=OneWay}"
SourceRequested="{x:Bind help:IconProvider.SourceRequested20}" />
<!--
Title and subtitle are intentionally in a nested Grid instead in the outer container,
to avoid pushing the following element (tags) out of bounds.
-->
<Grid Grid.Column="1" ColumnSpacing="12">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="Auto" />
<ColumnDefinition Width="*" />
</Grid.ColumnDefinitions>
<TextBlock
Grid.Column="0"
VerticalAlignment="Center"
CharacterSpacing="12"
FontSize="14"
MaxLines="1"
Style="{StaticResource CaptionTextBlockStyle}"
Text="{x:Bind Title, Mode=OneWay}"
TextTrimming="CharacterEllipsis"
TextWrapping="NoWrap" />
<TextBlock
Grid.Column="1"
VerticalAlignment="Center"
CharacterSpacing="12"
FontSize="14"
Foreground="{ThemeResource TextFillColorSecondaryBrush}"
MaxLines="1"
Style="{StaticResource CaptionTextBlockStyle}"
Text="{x:Bind Subtitle, Mode=OneWay}"
TextTrimming="CharacterEllipsis"
TextWrapping="NoWrap"
Visibility="{x:Bind ShowSubtitle, Mode=OneWay}" />
</Grid>
<!--
An 8px right margin is added to visually match the spacing between the icon
and the left margin of the list.
Tags are capped at 3 with a [+N] overflow badge to prevent
unbounded growth that pushes the title out of view.
-->
<ItemsRepeater
Grid.Column="2"
Margin="0,0,8,0"
VerticalAlignment="Center"
IsHitTestVisible="False"
IsTabStop="False"
ItemTemplate="{StaticResource TagTemplate}"
ItemsSource="{x:Bind VisibleTags, Mode=OneWay}"
Visibility="{x:Bind HasTags, Mode=OneWay}">
<ItemsRepeater.Layout>
<StackLayout Orientation="Horizontal" Spacing="4" />
</ItemsRepeater.Layout>
</ItemsRepeater>
</Grid>
</DataTemplate>
<DataTemplate x:Key="ListSeparatorViewModelTemplate" x:DataType="viewModels:ListItemViewModel">
<Grid ColumnSpacing="12">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="28" />
<ColumnDefinition Width="*" />
</Grid.ColumnDefinitions>
<Rectangle
Grid.Column="1"
Height="1"
Fill="{ThemeResource DividerStrokeColorDefaultBrush}" />
</Grid>
</DataTemplate>
<DataTemplate x:Key="ListSectionViewModelTemplate" x:DataType="viewModels:ListItemViewModel">
<Grid
Margin="0,8,0,0"
VerticalAlignment="Center"
cpcontrols:WrapPanel.IsFullLine="True"
ColumnSpacing="8"
IsTabStop="False"
IsTapEnabled="True">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="Auto" />
<ColumnDefinition Width="*" />
</Grid.ColumnDefinitions>
<TextBlock
Grid.Column="0"
Foreground="{ThemeResource TextFillColorSecondaryBrush}"
Style="{ThemeResource CaptionTextBlockStyle}"
Text="{x:Bind Section}" />
</Grid>
</DataTemplate>
<!-- Grid item templates for visual grid representation -->
<DataTemplate x:Key="SmallGridItemViewModelTemplate" x:DataType="viewModels:ListItemViewModel">
<StackPanel
HorizontalAlignment="Center"
VerticalAlignment="Center"
AutomationProperties.Name="{x:Bind Title, Mode=OneWay}"
BorderThickness="0"
CornerRadius="{StaticResource SmallGridViewItemCornerRadius}"
Orientation="Vertical"
ToolTipService.ToolTip="{x:Bind Title, Mode=OneWay}">
<cpcontrols:IconBox
x:Name="GridIconBorder"
Width="{StaticResource SmallGridSize}"
Height="{StaticResource SmallGridSize}"
Margin="0"
HorizontalAlignment="Center"
VerticalAlignment="Center"
Foreground="{ThemeResource TextFillColorPrimary}"
SourceKey="{x:Bind Icon, Mode=OneWay}"
SourceRequested="{x:Bind help:IconProvider.SourceRequested32}" />
</StackPanel>
</DataTemplate>
<DataTemplate x:Key="MediumGridItemViewModelTemplate" x:DataType="viewModels:ListItemViewModel">
<Grid
Width="{StaticResource MediumGridContainerSize}"
Height="{StaticResource MediumGridContainerSize}"
Padding="8"
AutomationProperties.Name="{x:Bind Title, Mode=OneWay}"
CornerRadius="{StaticResource MediumGridViewItemCornerRadius}"
ToolTipService.ToolTip="{x:Bind Title, Mode=OneWay}">
<Grid.RowDefinitions>
<RowDefinition Height="*" />
<RowDefinition Height="Auto" />
</Grid.RowDefinitions>
<cpcontrols:IconBox
x:Name="GridIconBorder"
Grid.Row="0"
Width="{StaticResource MediumGridSize}"
Height="{StaticResource MediumGridSize}"
HorizontalAlignment="Center"
VerticalAlignment="Center"
CharacterSpacing="12"
FontSize="14"
Foreground="{ThemeResource TextFillColorPrimary}"
SourceKey="{x:Bind Icon, Mode=OneWay}"
SourceRequested="{x:Bind help:IconProvider.SourceRequested64}" />
<TextBlock
x:Name="TitleTextBlock"
Grid.Row="1"
Height="32"
Margin="0,8,0,0"
HorizontalAlignment="Center"
CharacterSpacing="12"
FontSize="12"
Text="{x:Bind Title, Mode=OneWay}"
TextAlignment="Center"
TextTrimming="WordEllipsis"
TextWrapping="Wrap"
Visibility="{x:Bind LayoutShowsTitle, Mode=OneWay}" />
</Grid>
</DataTemplate>
<DataTemplate x:Key="GalleryGridItemViewModelTemplate" x:DataType="viewModels:ListItemViewModel">
<StackPanel
Width="{StaticResource GalleryGridSize}"
Margin="4"
Padding="0"
HorizontalAlignment="Center"
VerticalAlignment="Center"
AutomationProperties.Name="{x:Bind Title, Mode=OneWay}"
BorderThickness="0"
CornerRadius="{StaticResource GalleryGridViewItemRadius}"
Orientation="Vertical"
ToolTipService.ToolTip="{x:Bind Title, Mode=OneWay}">
<Grid
Width="{StaticResource GalleryGridSize}"
Height="{StaticResource GalleryGridSize}"
Margin="0"
HorizontalAlignment="Center"
VerticalAlignment="Center"
CornerRadius="{StaticResource GalleryGridViewItemRadius}">
<Viewbox
HorizontalAlignment="Center"
Stretch="UniformToFill"
StretchDirection="Both">
<cpcontrols:IconBox
CornerRadius="4"
Foreground="{ThemeResource TextFillColorPrimary}"
SourceKey="{x:Bind Icon, Mode=OneWay}"
SourceRequested="{x:Bind help:IconProvider.SourceRequested256}" />
</Viewbox>
</Grid>
<StackPanel
Padding="4"
Orientation="Vertical"
Spacing="4"
Visibility="{x:Bind help:BindTransformers.VisibleWhenAny(ShowTitle, ShowSubtitle)}">
<TextBlock
x:Name="TitleTextBlock"
MaxWidth="152"
MaxHeight="40"
HorizontalAlignment="Left"
VerticalAlignment="Center"
CharacterSpacing="12"
FontSize="14"
Foreground="{ThemeResource TextFillColorPrimary}"
Text="{x:Bind Title, Mode=OneWay}"
TextAlignment="Center"
TextTrimming="WordEllipsis"
TextWrapping="NoWrap"
Visibility="{x:Bind ShowTitle, Mode=OneWay}" />
<TextBlock
x:Name="SubTitleTextBlock"
MaxWidth="152"
MaxHeight="40"
HorizontalAlignment="Left"
VerticalAlignment="Center"
CharacterSpacing="11"
FontSize="11"
Foreground="{ThemeResource TextFillColorSecondaryBrush}"
Text="{x:Bind Subtitle, Mode=OneWay}"
TextAlignment="Center"
TextTrimming="WordEllipsis"
TextWrapping="NoWrap"
Visibility="{x:Bind ShowSubtitle, Mode=OneWay}" />
</StackPanel>
</StackPanel>
</DataTemplate>
<DataTemplate x:Key="GridSeparatorViewModelTemplate" x:DataType="viewModels:ListItemViewModel">
<Rectangle Height="1" Fill="{ThemeResource DividerStrokeColorDefaultBrush}" />
</DataTemplate>
</UserControl.Resources>
<Grid>
<controls:SwitchPresenter
HorizontalAlignment="Stretch"
TargetType="x:Boolean"
Value="{x:Bind ViewModel.ShowEmptyContent, Mode=OneWay}">
<controls:Case Value="False">
<controls:SwitchPresenter
HorizontalAlignment="Stretch"
TargetType="x:Boolean"
Value="{x:Bind ViewModel.IsGridView, Mode=OneWay}">
<controls:Case Value="False">
<ListView
x:Name="ItemsList"
Padding="0,2,0,0"
CanDragItems="True"
ContextCanceled="Items_OnContextCanceled"
ContextRequested="Items_OnContextRequested"
DoubleTapped="Items_DoubleTapped"
DragItemsCompleted="Items_DragItemsCompleted"
DragItemsStarting="Items_DragItemsStarting"
IsDoubleTapEnabled="True"
IsItemClickEnabled="True"
ItemClick="Items_ItemClick"
ItemContainerStyleSelector="{StaticResource ListItemContainerStyleSelector}"
ItemTemplateSelector="{StaticResource ListItemTemplateSelector}"
ItemsSource="{x:Bind ViewModel.FilteredItems, Mode=OneWay}"
RightTapped="Items_RightTapped"
SelectionChanged="Items_SelectionChanged">
<ListView.ItemContainerTransitions>
<TransitionCollection />
</ListView.ItemContainerTransitions>
</ListView>
</controls:Case>
<controls:Case Value="True">
<GridView
x:Name="ItemsGrid"
Padding="16,16"
CanDragItems="True"
ContextCanceled="Items_OnContextCanceled"
ContextRequested="Items_OnContextRequested"
DoubleTapped="Items_DoubleTapped"
DragItemsCompleted="Items_DragItemsCompleted"
DragItemsStarting="Items_DragItemsStarting"
IsDoubleTapEnabled="True"
IsItemClickEnabled="True"
ItemClick="Items_ItemClick"
ItemContainerStyleSelector="{StaticResource GridItemContainerStyleSelector}"
ItemTemplateSelector="{StaticResource GridItemTemplateSelector}"
ItemsSource="{x:Bind ViewModel.FilteredItems, Mode=OneWay}"
RightTapped="Items_RightTapped"
SelectionChanged="Items_SelectionChanged">
<GridView.ItemsPanel>
<ItemsPanelTemplate>
<cpcontrols:WrapPanel
HorizontalSpacing="8"
Orientation="Horizontal"
VerticalSpacing="8" />
</ItemsPanelTemplate>
</GridView.ItemsPanel>
<GridView.ItemContainerTransitions>
<TransitionCollection />
</GridView.ItemContainerTransitions>
</GridView>
</controls:Case>
</controls:SwitchPresenter>
</controls:Case>
<controls:Case Value="True">
<StackPanel
Margin="24"
HorizontalAlignment="Center"
VerticalAlignment="Center"
Orientation="Vertical"
Spacing="4">
<cpcontrols:IconBox
x:Name="IconBorder"
Width="48"
Height="48"
Margin="8"
Foreground="{ThemeResource TextFillColorSecondaryBrush}"
SourceKey="{x:Bind ViewModel.EmptyContent.Icon, Mode=OneWay}"
SourceRequested="{x:Bind help:IconProvider.SourceRequested64}" />
<TextBlock
Margin="0,4,0,0"
HorizontalAlignment="Center"
FontWeight="SemiBold"
Text="{x:Bind ViewModel.EmptyContent.Title, Mode=OneWay}"
TextAlignment="Center"
TextWrapping="Wrap" />
<TextBlock
HorizontalAlignment="Center"
Foreground="{ThemeResource TextFillColorSecondaryBrush}"
Text="{x:Bind ViewModel.EmptyContent.Subtitle, Mode=OneWay}"
TextAlignment="Center"
TextWrapping="Wrap" />
</StackPanel>
</controls:Case>
</controls:SwitchPresenter>
</Grid>
</UserControl>

View File

@@ -4,10 +4,692 @@
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:cmdpalUI="using:Microsoft.CmdPal.UI"
xmlns:controls="using:CommunityToolkit.WinUI.Controls"
xmlns:cpcontrols="using:Microsoft.CmdPal.UI.Controls"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:help="using:Microsoft.CmdPal.UI.Helpers"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
xmlns:viewModels="using:Microsoft.CmdPal.UI.ViewModels"
x:Name="PageRoot"
Background="Transparent"
DataContext="{x:Bind ViewModel, Mode=OneWay}"
mc:Ignorable="d">
<cmdpalUI:ListItemsView x:Name="ListView" ViewModel="{x:Bind ViewModel, Mode=OneWay}" />
<Page.Resources>
<!--
GridViewItemCornerRadius is the corner radius defined in GridView template; make
it bigger to match the radii of the gallery
-->
<CornerRadius x:Key="GalleryGridViewItemContainerCornerRadius">6</CornerRadius>
<CornerRadius x:Key="IconGridViewItemContainerCornerRadius">4</CornerRadius>
<CornerRadius x:Key="GalleryGridViewItemRadius">4</CornerRadius>
<CornerRadius x:Key="SmallGridViewItemCornerRadius">8</CornerRadius>
<CornerRadius x:Key="MediumGridViewItemCornerRadius">8</CornerRadius>
<x:Double x:Key="SmallGridSize">32</x:Double>
<x:Double x:Key="MediumGridSize">48</x:Double>
<x:Double x:Key="MediumGridContainerSize">100</x:Double>
<x:Double x:Key="GalleryGridSize">160</x:Double>
<!--
BEAR LOADING: The list view is virtualized and the item container style is set to a fixed height
to ensure the virtualization works correctly.
-->
<x:Double x:Key="SingleRowListViewItemHeight">44</x:Double>
<x:Double x:Key="ListViewSectionHeight">28</x:Double>
<x:Double x:Key="ListViewSeparatorHeight">28</x:Double>
<Style x:Key="IconGridViewItemStyle" TargetType="GridViewItem">
<Setter Property="HorizontalContentAlignment" Value="Stretch" />
<Setter Property="VerticalContentAlignment" Value="Center" />
<Setter Property="Template">
<Setter.Value>
<ControlTemplate TargetType="GridViewItem">
<ListViewItemPresenter
x:Name="Root"
HorizontalContentAlignment="{TemplateBinding HorizontalContentAlignment}"
VerticalContentAlignment="{TemplateBinding VerticalContentAlignment}"
CheckBoxBorderBrush="{ThemeResource GridViewItemCheckBoxBorderBrush}"
CheckBoxBrush="{ThemeResource GridViewItemCheckBoxBrush}"
CheckBoxCornerRadius="{ThemeResource GridViewItemCheckBoxCornerRadius}"
CheckBoxDisabledBorderBrush="{ThemeResource GridViewItemCheckBoxDisabledBorderBrush}"
CheckBoxDisabledBrush="{ThemeResource GridViewItemCheckBoxDisabledBrush}"
CheckBoxPointerOverBorderBrush="{ThemeResource GridViewItemCheckBoxPointerOverBorderBrush}"
CheckBoxPointerOverBrush="{ThemeResource GridViewItemCheckBoxPointerOverBrush}"
CheckBoxPressedBorderBrush="{ThemeResource GridViewItemCheckBoxPressedBorderBrush}"
CheckBoxPressedBrush="{ThemeResource GridViewItemCheckBoxPressedBrush}"
CheckBoxSelectedBrush="{ThemeResource GridViewItemCheckBoxSelectedBrush}"
CheckBoxSelectedDisabledBrush="{ThemeResource GridViewItemCheckBoxSelectedDisabledBrush}"
CheckBoxSelectedPointerOverBrush="{ThemeResource GridViewItemCheckBoxSelectedPointerOverBrush}"
CheckBoxSelectedPressedBrush="{ThemeResource GridViewItemCheckBoxSelectedPressedBrush}"
CheckBrush="{ThemeResource GridViewItemCheckBrush}"
CheckDisabledBrush="{ThemeResource GridViewItemCheckDisabledBrush}"
CheckMode="{ThemeResource GridViewItemCheckMode}"
CheckPressedBrush="{ThemeResource GridViewItemCheckPressedBrush}"
ContentMargin="{TemplateBinding Padding}"
ContentTransitions="{TemplateBinding ContentTransitions}"
Control.IsTemplateFocusTarget="True"
CornerRadius="{StaticResource IconGridViewItemContainerCornerRadius}"
DisabledOpacity="{ThemeResource ListViewItemDisabledThemeOpacity}"
DragBackground="{ThemeResource GridViewItemDragBackground}"
DragForeground="{ThemeResource GridViewItemDragForeground}"
DragOpacity="{ThemeResource ListViewItemDragThemeOpacity}"
FocusBorderBrush="{ThemeResource GridViewItemFocusBorderBrush}"
FocusVisualMargin="{TemplateBinding FocusVisualMargin}"
FocusVisualPrimaryBrush="{TemplateBinding FocusVisualPrimaryBrush}"
FocusVisualPrimaryThickness="{TemplateBinding FocusVisualPrimaryThickness}"
FocusVisualSecondaryBrush="{TemplateBinding FocusVisualSecondaryBrush}"
FocusVisualSecondaryThickness="{TemplateBinding FocusVisualSecondaryThickness}"
PlaceholderBackground="{ThemeResource GridViewItemPlaceholderBackground}"
PointerOverBackground="{ThemeResource GridViewItemBackgroundPointerOver}"
PointerOverBorderBrush="{ThemeResource GridViewItemPointerOverBorderBrush}"
PointerOverForeground="{ThemeResource GridViewItemForegroundPointerOver}"
PressedBackground="{ThemeResource GridViewItemBackgroundPressed}"
ReorderHintOffset="{ThemeResource GridViewItemReorderHintThemeOffset}"
SelectedBackground="{ThemeResource GridViewItemBackgroundSelected}"
SelectedBorderBrush="{ThemeResource GridViewItemSelectedBorderBrush}"
SelectedBorderThickness="{ThemeResource GridViewItemSelectedBorderThickness}"
SelectedDisabledBackground="{ThemeResource GridViewItemBackgroundSelectedDisabled}"
SelectedDisabledBorderBrush="{ThemeResource GridViewItemSelectedDisabledBorderBrush}"
SelectedForeground="{ThemeResource GridViewItemForegroundSelected}"
SelectedInnerBorderBrush="{ThemeResource GridViewItemSelectedInnerBorderBrush}"
SelectedPointerOverBackground="{ThemeResource GridViewItemBackgroundSelectedPointerOver}"
SelectedPointerOverBorderBrush="{ThemeResource GridViewItemSelectedPointerOverBorderBrush}"
SelectedPressedBackground="{ThemeResource GridViewItemBackgroundSelectedPressed}"
SelectedPressedBorderBrush="{ThemeResource GridViewItemSelectedPressedBorderBrush}"
SelectionCheckMarkVisualEnabled="{ThemeResource GridViewItemSelectionCheckMarkVisualEnabled}" />
</ControlTemplate>
</Setter.Value>
</Setter>
</Style>
<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">
<ListViewItemPresenter
x:Name="Root"
HorizontalContentAlignment="{TemplateBinding HorizontalContentAlignment}"
VerticalContentAlignment="{TemplateBinding VerticalContentAlignment}"
CheckBoxBorderBrush="{ThemeResource GridViewItemCheckBoxBorderBrush}"
CheckBoxBrush="{ThemeResource GridViewItemCheckBoxBrush}"
CheckBoxCornerRadius="{ThemeResource GridViewItemCheckBoxCornerRadius}"
CheckBoxDisabledBorderBrush="{ThemeResource GridViewItemCheckBoxDisabledBorderBrush}"
CheckBoxDisabledBrush="{ThemeResource GridViewItemCheckBoxDisabledBrush}"
CheckBoxPointerOverBorderBrush="{ThemeResource GridViewItemCheckBoxPointerOverBorderBrush}"
CheckBoxPointerOverBrush="{ThemeResource GridViewItemCheckBoxPointerOverBrush}"
CheckBoxPressedBorderBrush="{ThemeResource GridViewItemCheckBoxPressedBorderBrush}"
CheckBoxPressedBrush="{ThemeResource GridViewItemCheckBoxPressedBrush}"
CheckBoxSelectedBrush="{ThemeResource GridViewItemCheckBoxSelectedBrush}"
CheckBoxSelectedDisabledBrush="{ThemeResource GridViewItemCheckBoxSelectedDisabledBrush}"
CheckBoxSelectedPointerOverBrush="{ThemeResource GridViewItemCheckBoxSelectedPointerOverBrush}"
CheckBoxSelectedPressedBrush="{ThemeResource GridViewItemCheckBoxSelectedPressedBrush}"
CheckBrush="{ThemeResource GridViewItemCheckBrush}"
CheckDisabledBrush="{ThemeResource GridViewItemCheckDisabledBrush}"
CheckMode="{ThemeResource GridViewItemCheckMode}"
CheckPressedBrush="{ThemeResource GridViewItemCheckPressedBrush}"
ContentMargin="{TemplateBinding Padding}"
ContentTransitions="{TemplateBinding ContentTransitions}"
Control.IsTemplateFocusTarget="True"
CornerRadius="{StaticResource GalleryGridViewItemContainerCornerRadius}"
DisabledOpacity="{ThemeResource ListViewItemDisabledThemeOpacity}"
DragBackground="{ThemeResource GridViewItemDragBackground}"
DragForeground="{ThemeResource GridViewItemDragForeground}"
DragOpacity="{ThemeResource ListViewItemDragThemeOpacity}"
FocusBorderBrush="{ThemeResource GridViewItemFocusBorderBrush}"
FocusVisualMargin="{TemplateBinding FocusVisualMargin}"
FocusVisualPrimaryBrush="{TemplateBinding FocusVisualPrimaryBrush}"
FocusVisualPrimaryThickness="{TemplateBinding FocusVisualPrimaryThickness}"
FocusVisualSecondaryBrush="{TemplateBinding FocusVisualSecondaryBrush}"
FocusVisualSecondaryThickness="{TemplateBinding FocusVisualSecondaryThickness}"
PlaceholderBackground="{ThemeResource GridViewItemPlaceholderBackground}"
PointerOverBackground="{ThemeResource GridViewItemBackgroundPointerOver}"
PointerOverBorderBrush="{ThemeResource GridViewItemPointerOverBorderBrush}"
PointerOverForeground="{ThemeResource GridViewItemForegroundPointerOver}"
PressedBackground="{ThemeResource GridViewItemBackgroundPressed}"
ReorderHintOffset="{ThemeResource GridViewItemReorderHintThemeOffset}"
SelectedBackground="{ThemeResource GridViewItemBackgroundSelected}"
SelectedBorderBrush="{ThemeResource GridViewItemSelectedBorderBrush}"
SelectedBorderThickness="{ThemeResource GridViewItemSelectedBorderThickness}"
SelectedDisabledBackground="{ThemeResource GridViewItemBackgroundSelectedDisabled}"
SelectedDisabledBorderBrush="{ThemeResource GridViewItemSelectedDisabledBorderBrush}"
SelectedForeground="{ThemeResource GridViewItemForegroundSelected}"
SelectedInnerBorderBrush="{ThemeResource GridViewItemSelectedInnerBorderBrush}"
SelectedPointerOverBackground="{ThemeResource GridViewItemBackgroundSelectedPointerOver}"
SelectedPointerOverBorderBrush="{ThemeResource GridViewItemSelectedPointerOverBorderBrush}"
SelectedPressedBackground="{ThemeResource GridViewItemBackgroundSelectedPressed}"
SelectedPressedBorderBrush="{ThemeResource GridViewItemSelectedPressedBorderBrush}"
SelectionCheckMarkVisualEnabled="{ThemeResource GridViewItemSelectionCheckMarkVisualEnabled}" />
</ControlTemplate>
</Setter.Value>
</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,0,12,0" />
<Setter Property="Margin" Value="0" />
<Setter Property="HorizontalContentAlignment" Value="Stretch" />
<Setter Property="VerticalContentAlignment" Value="Bottom" />
<Setter Property="MinHeight" Value="{StaticResource ListViewSectionHeight}" />
<Setter Property="Height" Value="{StaticResource ListViewSectionHeight}" />
<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,0,12,0" />
<Setter Property="Margin" Value="0" />
<Setter Property="HorizontalContentAlignment" Value="Stretch" />
<Setter Property="VerticalContentAlignment" Value="Center" />
<Setter Property="MinHeight" Value="{StaticResource ListViewSeparatorHeight}" />
<Setter Property="Height" Value="{StaticResource ListViewSeparatorHeight}" />
<Setter Property="cpcontrols:WrapPanel.IsFullLine" Value="True" />
</Style>
<ControlTemplate x:Key="ListViewItemWithoutVisualIndicatorTemplate" TargetType="ListViewItem">
<ListViewItemPresenter
x:Name="Root"
HorizontalContentAlignment="{TemplateBinding HorizontalContentAlignment}"
VerticalContentAlignment="{TemplateBinding VerticalContentAlignment}"
CheckBoxBorderBrush="{ThemeResource ListViewItemCheckBoxBorderBrush}"
CheckBoxBrush="{ThemeResource ListViewItemCheckBoxBrush}"
CheckBoxCornerRadius="{ThemeResource ListViewItemCheckBoxCornerRadius}"
CheckBoxDisabledBorderBrush="{ThemeResource ListViewItemCheckBoxDisabledBorderBrush}"
CheckBoxDisabledBrush="{ThemeResource ListViewItemCheckBoxDisabledBrush}"
CheckBoxPointerOverBorderBrush="{ThemeResource ListViewItemCheckBoxPointerOverBorderBrush}"
CheckBoxPointerOverBrush="{ThemeResource ListViewItemCheckBoxPointerOverBrush}"
CheckBoxPressedBorderBrush="{ThemeResource ListViewItemCheckBoxPressedBorderBrush}"
CheckBoxPressedBrush="{ThemeResource ListViewItemCheckBoxPressedBrush}"
CheckBoxSelectedBrush="{ThemeResource ListViewItemCheckBoxSelectedBrush}"
CheckBoxSelectedDisabledBrush="{ThemeResource ListViewItemCheckBoxSelectedDisabledBrush}"
CheckBoxSelectedPointerOverBrush="{ThemeResource ListViewItemCheckBoxSelectedPointerOverBrush}"
CheckBoxSelectedPressedBrush="{ThemeResource ListViewItemCheckBoxSelectedPressedBrush}"
CheckBrush="{ThemeResource ListViewItemCheckBrush}"
CheckDisabledBrush="{ThemeResource ListViewItemCheckDisabledBrush}"
CheckMode="{ThemeResource ListViewItemCheckMode}"
CheckPressedBrush="{ThemeResource ListViewItemCheckPressedBrush}"
ContentMargin="{TemplateBinding Padding}"
ContentTransitions="{TemplateBinding ContentTransitions}"
Control.IsTemplateFocusTarget="True"
CornerRadius="{ThemeResource ListViewItemCornerRadius}"
DisabledOpacity="{ThemeResource ListViewItemDisabledThemeOpacity}"
DragBackground="{ThemeResource ListViewItemDragBackground}"
DragForeground="{ThemeResource ListViewItemDragForeground}"
DragOpacity="{ThemeResource ListViewItemDragThemeOpacity}"
FocusBorderBrush="{ThemeResource ListViewItemFocusBorderBrush}"
FocusSecondaryBorderBrush="{ThemeResource ListViewItemFocusSecondaryBorderBrush}"
FocusVisualMargin="{TemplateBinding FocusVisualMargin}"
FocusVisualPrimaryBrush="{TemplateBinding FocusVisualPrimaryBrush}"
FocusVisualPrimaryThickness="{TemplateBinding FocusVisualPrimaryThickness}"
FocusVisualSecondaryBrush="{TemplateBinding FocusVisualSecondaryBrush}"
FocusVisualSecondaryThickness="{TemplateBinding FocusVisualSecondaryThickness}"
PlaceholderBackground="{ThemeResource ListViewItemPlaceholderBackground}"
PointerOverBackground="{ThemeResource ListViewItemBackgroundPointerOver}"
PointerOverForeground="{ThemeResource ListViewItemForegroundPointerOver}"
PressedBackground="{ThemeResource ListViewItemBackgroundPressed}"
ReorderHintOffset="{ThemeResource ListViewItemReorderHintThemeOffset}"
SelectedBackground="{ThemeResource ListViewItemBackgroundSelected}"
SelectedDisabledBackground="{ThemeResource ListViewItemBackgroundSelectedDisabled}"
SelectedForeground="{ThemeResource ListViewItemForegroundSelected}"
SelectedPointerOverBackground="{ThemeResource ListViewItemBackgroundSelectedPointerOver}"
SelectedPressedBackground="{ThemeResource ListViewItemBackgroundSelectedPressed}"
SelectionCheckMarkVisualEnabled="{ThemeResource ListViewItemSelectionCheckMarkVisualEnabled}"
SelectionIndicatorBrush="{ThemeResource ListViewItemSelectionIndicatorBrush}"
SelectionIndicatorCornerRadius="{ThemeResource ListViewItemSelectionIndicatorCornerRadius}"
SelectionIndicatorDisabledBrush="{ThemeResource ListViewItemSelectionIndicatorDisabledBrush}"
SelectionIndicatorPointerOverBrush="{ThemeResource ListViewItemSelectionIndicatorPointerOverBrush}"
SelectionIndicatorPressedBrush="{ThemeResource ListViewItemSelectionIndicatorPressedBrush}"
SelectionIndicatorVisualEnabled="False" />
</ControlTemplate>
<Style
x:Key="ListSingleRowItemContainerStyle"
BasedOn="{StaticResource DefaultListViewItemStyle}"
TargetType="ListViewItem">
<Setter Property="MinHeight" Value="{StaticResource SingleRowListViewItemHeight}" />
<Setter Property="Height" Value="{StaticResource SingleRowListViewItemHeight}" />
<Setter Property="HorizontalContentAlignment" Value="Stretch" />
<Setter Property="Template" Value="{StaticResource ListViewItemWithoutVisualIndicatorTemplate}" />
</Style>
<Style
x:Key="ListSectionContainerStyle"
BasedOn="{StaticResource DefaultListViewItemStyle}"
TargetType="ListViewItem">
<Setter Property="IsEnabled" Value="False" />
<Setter Property="AllowFocusWhenDisabled" Value="False" />
<Setter Property="AllowFocusOnInteraction" Value="False" />
<Setter Property="IsHitTestVisible" Value="False" />
<Setter Property="IsTabStop" Value="False" />
<Setter Property="IsHoldingEnabled" Value="False" />
<Setter Property="AllowDrop" Value="False" />
<Setter Property="Padding" Value="16,0,12,0" />
<Setter Property="HorizontalContentAlignment" Value="Stretch" />
<Setter Property="VerticalContentAlignment" Value="Bottom" />
<Setter Property="MinHeight" Value="{StaticResource ListViewSectionHeight}" />
<Setter Property="Height" Value="{StaticResource ListViewSectionHeight}" />
</Style>
<Style
x:Key="ListSeparatorContainerStyle"
BasedOn="{StaticResource DefaultListViewItemStyle}"
TargetType="ListViewItem">
<Setter Property="IsEnabled" Value="False" />
<Setter Property="AllowFocusWhenDisabled" Value="False" />
<Setter Property="AllowFocusOnInteraction" Value="False" />
<Setter Property="IsHitTestVisible" Value="False" />
<Setter Property="IsTabStop" Value="False" />
<Setter Property="IsHoldingEnabled" Value="False" />
<Setter Property="AllowDrop" Value="False" />
<Setter Property="Padding" Value="16,0,12,0" />
<Setter Property="HorizontalContentAlignment" Value="Stretch" />
<Setter Property="VerticalContentAlignment" Value="Center" />
<Setter Property="MinHeight" Value="{StaticResource ListViewSeparatorHeight}" />
<Setter Property="Height" Value="{StaticResource ListViewSeparatorHeight}" />
</Style>
<DataTemplate x:Key="TagTemplate" x:DataType="viewModels:TagViewModel">
<!--
Tags are immutable, so we don't have to worry about binding mode.
-->
<cpcontrols:Tag
AutomationProperties.Name="{x:Bind Text}"
BackgroundColor="{x:Bind Background}"
FontSize="12"
ForegroundColor="{x:Bind Foreground}"
Icon="{x:Bind Icon}"
Text="{x:Bind Text}"
ToolTipService.ToolTip="{x:Bind ToolTip}" />
</DataTemplate>
<cmdpalUI:ListItemTemplateSelector
x:Key="ListItemTemplateSelector"
x:DataType="viewModels:ListItemViewModel"
ListItem="{StaticResource ListItemSingleRowViewModelTemplate}"
Section="{StaticResource ListSectionViewModelTemplate}"
Separator="{StaticResource ListSeparatorViewModelTemplate}" />
<cmdpalUI:ListItemContainerStyleSelector
x:Key="ListItemContainerStyleSelector"
Default="{StaticResource ListSingleRowItemContainerStyle}"
Section="{StaticResource ListSectionContainerStyle}"
Separator="{StaticResource ListSeparatorContainerStyle}" />
<cmdpalUI:GridItemTemplateSelector
x:Key="GridItemTemplateSelector"
x:DataType="viewModels:ListItemViewModel"
Gallery="{StaticResource GalleryGridItemViewModelTemplate}"
GridProperties="{x:Bind ViewModel.GridProperties, Mode=OneWay}"
Medium="{StaticResource MediumGridItemViewModelTemplate}"
Section="{StaticResource ListSectionViewModelTemplate}"
Separator="{StaticResource GridSeparatorViewModelTemplate}"
Small="{StaticResource SmallGridItemViewModelTemplate}" />
<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}" />
<DataTemplate x:Key="ListItemSingleRowViewModelTemplate" x:DataType="viewModels:ListItemViewModel">
<Grid AutomationProperties.Name="{x:Bind Title, Mode=OneWay}" ColumnSpacing="12">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="28" />
<ColumnDefinition Width="*" />
<ColumnDefinition Width="Auto" />
</Grid.ColumnDefinitions>
<cpcontrols:IconBox
x:Name="IconBorder"
Grid.Column="0"
Width="20"
Height="20"
AutomationProperties.AccessibilityView="Raw"
Foreground="{ThemeResource TextFillColorSecondaryBrush}"
SourceKey="{x:Bind Icon, Mode=OneWay}"
SourceRequested="{x:Bind help:IconProvider.SourceRequested20}" />
<!--
Title and subtitle are intentionally in a nested Grid instead in the outer container,
to avoid pushing the following element (tags) out of bounds.
-->
<Grid Grid.Column="1" ColumnSpacing="12">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="Auto" />
<ColumnDefinition Width="*" />
</Grid.ColumnDefinitions>
<TextBlock
Grid.Column="0"
VerticalAlignment="Center"
CharacterSpacing="12"
FontSize="14"
MaxLines="1"
Style="{StaticResource CaptionTextBlockStyle}"
Text="{x:Bind Title, Mode=OneWay}"
TextTrimming="CharacterEllipsis"
TextWrapping="NoWrap" />
<TextBlock
Grid.Column="1"
VerticalAlignment="Center"
CharacterSpacing="12"
FontSize="14"
Foreground="{ThemeResource TextFillColorSecondaryBrush}"
MaxLines="1"
Style="{StaticResource CaptionTextBlockStyle}"
Text="{x:Bind Subtitle, Mode=OneWay}"
TextTrimming="CharacterEllipsis"
TextWrapping="NoWrap"
Visibility="{x:Bind ShowSubtitle, Mode=OneWay}" />
</Grid>
<!--
An 8px right margin is added to visually match the spacing between the icon
and the left margin of the list.
ItemRepeater is a lightweight control (compared to ItemsControl).
-->
<ItemsRepeater
Grid.Column="2"
Margin="0,0,8,0"
VerticalAlignment="Center"
IsHitTestVisible="False"
IsTabStop="False"
ItemTemplate="{StaticResource TagTemplate}"
ItemsSource="{x:Bind Tags, Mode=OneWay}"
Visibility="{x:Bind HasTags, Mode=OneWay}">
<ItemsRepeater.Layout>
<StackLayout Orientation="Horizontal" Spacing="4" />
</ItemsRepeater.Layout>
</ItemsRepeater>
</Grid>
</DataTemplate>
<DataTemplate x:Key="ListSeparatorViewModelTemplate" x:DataType="viewModels:ListItemViewModel">
<Grid ColumnSpacing="12">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="28" />
<ColumnDefinition Width="*" />
</Grid.ColumnDefinitions>
<Rectangle
Grid.Column="1"
Height="1"
Fill="{ThemeResource DividerStrokeColorDefaultBrush}" />
</Grid>
</DataTemplate>
<DataTemplate x:Key="ListSectionViewModelTemplate" x:DataType="viewModels:ListItemViewModel">
<Grid
Margin="0,8,0,0"
VerticalAlignment="Center"
cpcontrols:WrapPanel.IsFullLine="True"
ColumnSpacing="8"
IsTabStop="False"
IsTapEnabled="True">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="Auto" />
<ColumnDefinition Width="*" />
</Grid.ColumnDefinitions>
<TextBlock
Grid.Column="0"
Foreground="{ThemeResource TextFillColorSecondaryBrush}"
Style="{ThemeResource CaptionTextBlockStyle}"
Text="{x:Bind Section}" />
</Grid>
</DataTemplate>
<!-- Grid item templates for visual grid representation -->
<DataTemplate x:Key="SmallGridItemViewModelTemplate" x:DataType="viewModels:ListItemViewModel">
<StackPanel
HorizontalAlignment="Center"
VerticalAlignment="Center"
AutomationProperties.Name="{x:Bind Title, Mode=OneWay}"
BorderThickness="0"
CornerRadius="{StaticResource SmallGridViewItemCornerRadius}"
Orientation="Vertical"
ToolTipService.ToolTip="{x:Bind Title, Mode=OneWay}">
<cpcontrols:IconBox
x:Name="GridIconBorder"
Width="{StaticResource SmallGridSize}"
Height="{StaticResource SmallGridSize}"
Margin="0"
HorizontalAlignment="Center"
VerticalAlignment="Center"
Foreground="{ThemeResource TextFillColorPrimary}"
SourceKey="{x:Bind Icon, Mode=OneWay}"
SourceRequested="{x:Bind help:IconProvider.SourceRequested32}" />
</StackPanel>
</DataTemplate>
<DataTemplate x:Key="MediumGridItemViewModelTemplate" x:DataType="viewModels:ListItemViewModel">
<Grid
Width="{StaticResource MediumGridContainerSize}"
Height="{StaticResource MediumGridContainerSize}"
Padding="8"
AutomationProperties.Name="{x:Bind Title, Mode=OneWay}"
CornerRadius="{StaticResource MediumGridViewItemCornerRadius}"
ToolTipService.ToolTip="{x:Bind Title, Mode=OneWay}">
<Grid.RowDefinitions>
<RowDefinition Height="*" />
<RowDefinition Height="Auto" />
</Grid.RowDefinitions>
<cpcontrols:IconBox
x:Name="GridIconBorder"
Grid.Row="0"
Width="{StaticResource MediumGridSize}"
Height="{StaticResource MediumGridSize}"
HorizontalAlignment="Center"
VerticalAlignment="Center"
CharacterSpacing="12"
FontSize="14"
Foreground="{ThemeResource TextFillColorPrimary}"
SourceKey="{x:Bind Icon, Mode=OneWay}"
SourceRequested="{x:Bind help:IconProvider.SourceRequested64}" />
<TextBlock
x:Name="TitleTextBlock"
Grid.Row="1"
Height="32"
Margin="0,8,0,0"
HorizontalAlignment="Center"
CharacterSpacing="12"
FontSize="12"
Text="{x:Bind Title, Mode=OneWay}"
TextAlignment="Center"
TextTrimming="WordEllipsis"
TextWrapping="Wrap"
Visibility="{x:Bind LayoutShowsTitle, Mode=OneWay}" />
</Grid>
</DataTemplate>
<DataTemplate x:Key="GalleryGridItemViewModelTemplate" x:DataType="viewModels:ListItemViewModel">
<StackPanel
Width="{StaticResource GalleryGridSize}"
Margin="4"
Padding="0"
HorizontalAlignment="Center"
VerticalAlignment="Center"
AutomationProperties.Name="{x:Bind Title, Mode=OneWay}"
BorderThickness="0"
CornerRadius="{StaticResource GalleryGridViewItemRadius}"
Orientation="Vertical"
ToolTipService.ToolTip="{x:Bind Title, Mode=OneWay}">
<Grid
Width="{StaticResource GalleryGridSize}"
Height="{StaticResource GalleryGridSize}"
Margin="0"
HorizontalAlignment="Center"
VerticalAlignment="Center"
CornerRadius="{StaticResource GalleryGridViewItemRadius}">
<Viewbox
HorizontalAlignment="Center"
Stretch="UniformToFill"
StretchDirection="Both">
<cpcontrols:IconBox
CornerRadius="4"
Foreground="{ThemeResource TextFillColorPrimary}"
SourceKey="{x:Bind Icon, Mode=OneWay}"
SourceRequested="{x:Bind help:IconProvider.SourceRequested256}" />
</Viewbox>
</Grid>
<StackPanel
Padding="4"
Orientation="Vertical"
Spacing="4"
Visibility="{x:Bind help:BindTransformers.VisibleWhenAny(ShowTitle, ShowSubtitle)}">
<TextBlock
x:Name="TitleTextBlock"
MaxWidth="152"
MaxHeight="40"
HorizontalAlignment="Left"
VerticalAlignment="Center"
CharacterSpacing="12"
FontSize="14"
Foreground="{ThemeResource TextFillColorPrimary}"
Text="{x:Bind Title, Mode=OneWay}"
TextAlignment="Center"
TextTrimming="WordEllipsis"
TextWrapping="NoWrap"
Visibility="{x:Bind ShowTitle, Mode=OneWay}" />
<TextBlock
x:Name="SubTitleTextBlock"
MaxWidth="152"
MaxHeight="40"
HorizontalAlignment="Left"
VerticalAlignment="Center"
CharacterSpacing="11"
FontSize="11"
Foreground="{ThemeResource TextFillColorSecondaryBrush}"
Text="{x:Bind Subtitle, Mode=OneWay}"
TextAlignment="Center"
TextTrimming="WordEllipsis"
TextWrapping="NoWrap"
Visibility="{x:Bind ShowSubtitle, Mode=OneWay}" />
</StackPanel>
</StackPanel>
</DataTemplate>
<DataTemplate x:Key="GridSeparatorViewModelTemplate" x:DataType="viewModels:ListItemViewModel">
<Rectangle Height="1" Fill="{ThemeResource DividerStrokeColorDefaultBrush}" />
</DataTemplate>
</Page.Resources>
<Grid>
<controls:SwitchPresenter
HorizontalAlignment="Stretch"
TargetType="x:Boolean"
Value="{x:Bind ViewModel.ShowEmptyContent, Mode=OneWay}">
<controls:Case Value="False">
<controls:SwitchPresenter
HorizontalAlignment="Stretch"
TargetType="x:Boolean"
Value="{x:Bind ViewModel.IsGridView, Mode=OneWay}">
<controls:Case Value="False">
<ListView
x:Name="ItemsList"
Padding="0,2,0,0"
CanDragItems="True"
ContextCanceled="Items_OnContextCanceled"
ContextRequested="Items_OnContextRequested"
DoubleTapped="Items_DoubleTapped"
DragItemsCompleted="Items_DragItemsCompleted"
DragItemsStarting="Items_DragItemsStarting"
IsDoubleTapEnabled="True"
IsItemClickEnabled="True"
ItemClick="Items_ItemClick"
ItemContainerStyleSelector="{StaticResource ListItemContainerStyleSelector}"
ItemTemplateSelector="{StaticResource ListItemTemplateSelector}"
ItemsSource="{x:Bind ViewModel.FilteredItems, Mode=OneWay}"
RightTapped="Items_RightTapped"
SelectionChanged="Items_SelectionChanged">
<ListView.ItemContainerTransitions>
<TransitionCollection />
</ListView.ItemContainerTransitions>
</ListView>
</controls:Case>
<controls:Case Value="True">
<GridView
x:Name="ItemsGrid"
Padding="16,16"
CanDragItems="True"
ContextCanceled="Items_OnContextCanceled"
ContextRequested="Items_OnContextRequested"
DoubleTapped="Items_DoubleTapped"
DragItemsCompleted="Items_DragItemsCompleted"
DragItemsStarting="Items_DragItemsStarting"
IsDoubleTapEnabled="True"
IsItemClickEnabled="True"
ItemClick="Items_ItemClick"
ItemContainerStyleSelector="{StaticResource GridItemContainerStyleSelector}"
ItemTemplateSelector="{StaticResource GridItemTemplateSelector}"
ItemsSource="{x:Bind ViewModel.FilteredItems, Mode=OneWay}"
RightTapped="Items_RightTapped"
SelectionChanged="Items_SelectionChanged">
<GridView.ItemsPanel>
<ItemsPanelTemplate>
<cpcontrols:WrapPanel
HorizontalSpacing="8"
Orientation="Horizontal"
VerticalSpacing="8" />
</ItemsPanelTemplate>
</GridView.ItemsPanel>
<GridView.ItemContainerTransitions>
<TransitionCollection />
</GridView.ItemContainerTransitions>
</GridView>
</controls:Case>
</controls:SwitchPresenter>
</controls:Case>
<controls:Case Value="True">
<StackPanel
Margin="24"
HorizontalAlignment="Center"
VerticalAlignment="Center"
Orientation="Vertical"
Spacing="4">
<cpcontrols:IconBox
x:Name="IconBorder"
Width="48"
Height="48"
Margin="8"
Foreground="{ThemeResource TextFillColorSecondaryBrush}"
SourceKey="{x:Bind ViewModel.EmptyContent.Icon, Mode=OneWay}"
SourceRequested="{x:Bind help:IconProvider.SourceRequested64}" />
<TextBlock
Margin="0,4,0,0"
HorizontalAlignment="Center"
FontWeight="SemiBold"
Text="{x:Bind ViewModel.EmptyContent.Title, Mode=OneWay}"
TextAlignment="Center"
TextWrapping="Wrap" />
<TextBlock
HorizontalAlignment="Center"
Foreground="{ThemeResource TextFillColorSecondaryBrush}"
Text="{x:Bind ViewModel.EmptyContent.Subtitle, Mode=OneWay}"
TextAlignment="Center"
TextWrapping="Wrap" />
</StackPanel>
</controls:Case>
</controls:SwitchPresenter>
</Grid>
</Page>

View File

@@ -3,26 +3,91 @@
x:Class="Microsoft.CmdPal.UI.ParametersPage"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:Interactions="using:Microsoft.Xaml.Interactions.Core"
xmlns:Interactivity="using:Microsoft.Xaml.Interactivity"
xmlns:cmdPalControls="using:Microsoft.CmdPal.UI.Controls"
xmlns:cmdpalUI="using:Microsoft.CmdPal.UI"
xmlns:controls="using:CommunityToolkit.WinUI.Controls"
xmlns:converters="using:CommunityToolkit.WinUI.Converters"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:help="using:Microsoft.CmdPal.UI.Helpers"
xmlns:local="using:Microsoft.CmdPal.UI"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
xmlns:viewModels="using:Microsoft.CmdPal.UI.ViewModels"
Background="Transparent"
mc:Ignorable="d">
<Page.Resources>
<ResourceDictionary>
<DataTemplate x:Key="ParameterListItemTemplate" x:DataType="viewModels:ListItemViewModel">
<Grid AutomationProperties.Name="{x:Bind Title, Mode=OneWay}" ColumnSpacing="12">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="28" />
<ColumnDefinition Width="*" />
</Grid.ColumnDefinitions>
<cmdPalControls:IconBox
Grid.Column="0"
Width="20"
Height="20"
AutomationProperties.AccessibilityView="Raw"
Foreground="{ThemeResource TextFillColorSecondaryBrush}"
SourceKey="{x:Bind Icon, Mode=OneWay}"
SourceRequested="{x:Bind help:IconProvider.SourceRequested20}" />
<Grid Grid.Column="1" ColumnSpacing="12">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="Auto" />
<ColumnDefinition Width="*" />
</Grid.ColumnDefinitions>
<TextBlock
Grid.Column="0"
VerticalAlignment="Center"
CharacterSpacing="12"
FontSize="14"
MaxLines="1"
Style="{StaticResource CaptionTextBlockStyle}"
Text="{x:Bind Title, Mode=OneWay}"
TextTrimming="CharacterEllipsis"
TextWrapping="NoWrap" />
<TextBlock
Grid.Column="1"
VerticalAlignment="Center"
CharacterSpacing="12"
FontSize="14"
Foreground="{ThemeResource TextFillColorSecondaryBrush}"
MaxLines="1"
Style="{StaticResource CaptionTextBlockStyle}"
Text="{x:Bind Subtitle, Mode=OneWay}"
TextTrimming="CharacterEllipsis"
TextWrapping="NoWrap"
Visibility="{x:Bind ShowSubtitle, Mode=OneWay}" />
</Grid>
</Grid>
</DataTemplate>
</ResourceDictionary>
</Page.Resources>
<Grid>
<controls:SwitchPresenter
HorizontalAlignment="Stretch"
TargetType="x:Boolean"
Value="{x:Bind ViewModel.HasActiveList, Mode=OneWay}">
<controls:Case Value="True">
<!--
Show list items from the active list parameter using the same
list view used by ListPage so behavior stays in one place.
-->
<cmdpalUI:ListItemsView ViewModel="{x:Bind ViewModel.ActiveListViewModel, Mode=OneWay}" />
<!-- Show list items from the active list parameter -->
<ListView
x:Name="ParamItemsList"
Padding="0,2,0,0"
IsItemClickEnabled="True"
ItemClick="ParamItems_ItemClick"
ItemTemplate="{StaticResource ParameterListItemTemplate}"
ItemsSource="{x:Bind ViewModel.ActiveListViewModel.FilteredItems, Mode=OneWay}">
<ListView.ItemContainerTransitions>
<TransitionCollection />
</ListView.ItemContainerTransitions>
</ListView>
</controls:Case>
<controls:Case Value="False">
<controls:SwitchPresenter

View File

@@ -2,9 +2,14 @@
// 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 CommunityToolkit.Mvvm.Messaging;
using ManagedCommon;
using Microsoft.CmdPal.Common;
using Microsoft.CmdPal.UI.ViewModels;
using Microsoft.CmdPal.UI.ViewModels.Commands;
using Microsoft.CmdPal.UI.ViewModels.Messages;
using Microsoft.UI.Dispatching;
using Microsoft.UI.Xaml;
using Microsoft.UI.Xaml.Controls;
using Microsoft.UI.Xaml.Navigation;
@@ -12,12 +17,17 @@ using Microsoft.UI.Xaml.Navigation;
namespace Microsoft.CmdPal.UI;
/// <summary>
/// Hosts a parameter run, optionally embedding a <see cref="ListItemsView"/> when
/// a list parameter is active. List rendering, selection, and keyboard navigation
/// are handled by the embedded <see cref="ListItemsView"/>.
/// An empty page that can be used on its own or navigated to within a Frame.
/// </summary>
public sealed partial class ParametersPage : Page
public sealed partial class ParametersPage : Page,
IRecipient<NavigateNextCommand>,
IRecipient<NavigatePreviousCommand>,
IRecipient<NavigatePageDownCommand>,
IRecipient<NavigatePageUpCommand>,
IRecipient<ActivateSelectedListItemMessage>
{
private readonly DispatcherQueue _queue = DispatcherQueue.GetForCurrentThread();
public ParametersPageViewModel? ViewModel
{
get => (ParametersPageViewModel?)GetValue(ViewModelProperty);
@@ -31,6 +41,20 @@ public sealed partial class ParametersPage : Page
public ParametersPage()
{
this.InitializeComponent();
this.Unloaded += OnUnloaded;
WeakReferenceMessenger.Default.Register<NavigateNextCommand>(this);
WeakReferenceMessenger.Default.Register<NavigatePreviousCommand>(this);
WeakReferenceMessenger.Default.Register<NavigatePageDownCommand>(this);
WeakReferenceMessenger.Default.Register<NavigatePageUpCommand>(this);
WeakReferenceMessenger.Default.Register<ActivateSelectedListItemMessage>(this);
}
private void OnUnloaded(object sender, RoutedEventArgs e)
{
// Unhook from everything to ensure nothing can reach us
// between this point and our complete and utter destruction.
WeakReferenceMessenger.Default.UnregisterAll(this);
}
protected override void OnNavigatedTo(NavigationEventArgs e)
@@ -60,9 +84,104 @@ public sealed partial class ParametersPage : Page
private static void OnViewModelChanged(DependencyObject d, DependencyPropertyChangedEventArgs e)
{
if (d is ParametersPage && e.NewValue is null)
if (d is ParametersPage @this)
{
CoreLogger.LogDebug("cleared view model");
if (e.OldValue is ParametersPageViewModel old)
{
old.PropertyChanged -= @this.ViewModel_PropertyChanged;
}
if (e.NewValue is ParametersPageViewModel page)
{
page.PropertyChanged += @this.ViewModel_PropertyChanged;
}
else if (e.NewValue is null)
{
CoreLogger.LogDebug("cleared view model");
}
}
}
private void ViewModel_PropertyChanged(object? sender, System.ComponentModel.PropertyChangedEventArgs e)
{
var prop = e.PropertyName;
if (prop == nameof(ViewModel.ShowCommand))
{
Debug.WriteLine($"ViewModel.ShowCommand {ViewModel?.ShowCommand}");
}
else if (prop == nameof(ViewModel.ActiveListViewModel))
{
if (ViewModel?.HasActiveList == true)
{
SelectFirstItem();
}
}
}
private void ParamItems_ItemClick(object sender, ItemClickEventArgs e)
{
if (e.ClickedItem is ListItemViewModel item)
{
ViewModel?.ActiveListViewModel?.InvokeItemCommand.Execute(item);
}
}
public void Receive(NavigateNextCommand message) => NavigateList(1);
public void Receive(NavigatePreviousCommand message) => NavigateList(-1);
public void Receive(NavigatePageDownCommand message) => NavigateList(10);
public void Receive(NavigatePageUpCommand message) => NavigateList(-10);
public void Receive(ActivateSelectedListItemMessage message)
{
if (ViewModel?.HasActiveList != true)
{
return;
}
if (ParamItemsList.SelectedItem is ListItemViewModel item)
{
ViewModel.ActiveListViewModel?.InvokeItemCommand.Execute(item);
}
else if (ViewModel.ActiveListViewModel?.FilteredItems.Count > 0 &&
ViewModel.ActiveListViewModel.FilteredItems[0] is ListItemViewModel firstItem)
{
ViewModel.ActiveListViewModel.InvokeItemCommand.Execute(firstItem);
}
}
private void NavigateList(int delta)
{
if (ViewModel?.HasActiveList != true)
{
return;
}
var list = ParamItemsList;
var count = list.Items.Count;
if (count == 0)
{
return;
}
var current = list.SelectedIndex;
var target = Math.Clamp(current + delta, 0, count - 1);
list.SelectedIndex = target;
list.ScrollIntoView(list.SelectedItem);
}
public void SelectFirstItem()
{
// Use TryEnqueue so the ListView has had time to populate from the binding
_queue.TryEnqueue(() =>
{
if (ParamItemsList.Items.Count > 0)
{
ParamItemsList.SelectedIndex = 0;
ParamItemsList.ScrollIntoView(ParamItemsList.SelectedItem);
}
});
}
}

View File

@@ -6,6 +6,7 @@ using System.Diagnostics.CodeAnalysis;
using System.Runtime.InteropServices;
using CommunityToolkit.Mvvm.Messaging;
using Microsoft.CmdPal.UI.Messages;
using Microsoft.CmdPal.UI.ViewModels;
using Microsoft.CmdPal.UI.ViewModels.Messages;
using Microsoft.CmdPal.UI.ViewModels.Services;
using Microsoft.UI.Xaml;
@@ -136,14 +137,11 @@ internal sealed partial class TrayIconService
{
var exePath = Path.Combine(AppContext.BaseDirectory, "Microsoft.CmdPal.UI.exe");
// DestroyIconSafeHandle largeIcon;
Span<HICON> large = new([default]); // 1 size array to accept icon
var extractedIconCount = PInvoke.ExtractIconEx(exePath, 0, large);
if ((extractedIconCount < 1) || (large[0] == HICON.Null))
{
return new DestroyIconSafeHandle(HICON.Null);
}
return new DestroyIconSafeHandle(large[0]);
PInvoke.ExtractIconEx(exePath, 0, large);
DestroyIconSafeHandle h = new(large[0]);
return h;
}
private LRESULT WindowProc(

View File

@@ -43,21 +43,6 @@ public sealed class MonitorService : IMonitorService
}
}
/// <inheritdoc/>
public MonitorInfo? GetMonitorByStableId(string stableId)
{
var monitors = GetMonitors();
foreach (var monitor in monitors)
{
if (string.Equals(monitor.StableId, stableId, StringComparison.OrdinalIgnoreCase))
{
return monitor;
}
}
return null;
}
/// <inheritdoc/>
public MonitorInfo? GetMonitorByDeviceId(string deviceId)
{
@@ -107,7 +92,7 @@ public sealed class MonitorService : IMonitorService
private static unsafe List<MonitorInfo> EnumerateMonitors()
{
var monitors = new List<MonitorInfo>();
var displayInfo = BuildDisplayInfoMap();
var friendlyNames = BuildFriendlyNameMap();
PInvoke.EnumDisplayMonitors(
HDC.Null,
@@ -130,26 +115,13 @@ public sealed class MonitorService : IMonitorService
var isPrimary = (infoEx.monitorInfo.dwFlags & PrimaryFlag) != 0;
var deviceName = new string(infoEx.szDevice.AsSpan()).TrimEnd('\0');
var friendlyName = string.Empty;
var stableId = deviceName; // Fall back to GDI name
if (displayInfo.TryGetValue(deviceName, out var info))
{
friendlyName = info.FriendlyName;
if (!string.IsNullOrEmpty(info.DevicePath))
{
stableId = info.DevicePath;
}
}
var displayName = FormatDisplayName(deviceName, isPrimary, friendlyName);
var displayName = FormatDisplayName(deviceName, isPrimary, friendlyNames);
var rcMonitor = infoEx.monitorInfo.rcMonitor;
var rcWork = infoEx.monitorInfo.rcWork;
monitors.Add(new MonitorInfo
{
DeviceId = deviceName,
StableId = stableId,
DisplayName = displayName,
Bounds = new ScreenRect(
rcMonitor.left,
@@ -174,13 +146,13 @@ public sealed class MonitorService : IMonitorService
}
/// <summary>
/// Builds a map from GDI device name (e.g. <c>\\.\DISPLAY1</c>) to display metadata
/// (friendly name and stable device path) using the Display Configuration APIs.
/// Builds a map from GDI device name (e.g. <c>\\.\DISPLAY1</c>) to the hardware
/// friendly name (e.g. <c>DELL U2723QE</c>) using the Display Configuration APIs.
/// Returns an empty dictionary on failure so callers can fall back gracefully.
/// </summary>
private static unsafe Dictionary<string, (string FriendlyName, string DevicePath)> BuildDisplayInfoMap()
private static unsafe Dictionary<string, string> BuildFriendlyNameMap()
{
var map = new Dictionary<string, (string FriendlyName, string DevicePath)>(StringComparer.OrdinalIgnoreCase);
var map = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
try
{
@@ -195,14 +167,14 @@ public sealed class MonitorService : IMonitorService
var paths = new DISPLAYCONFIG_PATH_INFO[pathCount];
var modes = new DISPLAYCONFIG_MODE_INFO[modeCount];
var topologyId = default(DISPLAYCONFIG_TOPOLOGY_ID);
DISPLAYCONFIG_TOPOLOGY_ID topologyId = default;
result = PInvoke.QueryDisplayConfig(
QUERY_DISPLAY_CONFIG_FLAGS.QDC_ONLY_ACTIVE_PATHS,
ref pathCount,
paths,
paths.AsSpan(),
ref modeCount,
modes,
modes.AsSpan(),
ref topologyId);
if (result != WIN32_ERROR.NO_ERROR)
{
@@ -244,28 +216,27 @@ public sealed class MonitorService : IMonitorService
}
var friendly = new string(targetName.monitorFriendlyDeviceName.AsSpan()).TrimEnd('\0');
var devicePath = new string(targetName.monitorDevicePath.AsSpan()).TrimEnd('\0');
if (!string.IsNullOrEmpty(friendly) || !string.IsNullOrEmpty(devicePath))
if (!string.IsNullOrEmpty(friendly))
{
map.TryAdd(gdiName, (friendly ?? string.Empty, devicePath ?? string.Empty));
map.TryAdd(gdiName, friendly);
}
}
}
catch (Exception ex) when (ex is not OutOfMemoryException)
{
Logger.LogError($"BuildDisplayInfoMap failed: {ex.Message}");
Logger.LogError($"BuildFriendlyNameMap failed: {ex.Message}");
}
return map;
}
private static string FormatDisplayName(string deviceName, bool isPrimary, string friendlyName)
private static string FormatDisplayName(string deviceName, bool isPrimary, Dictionary<string, string> friendlyNames)
{
string name;
if (!string.IsNullOrEmpty(friendlyName))
if (friendlyNames.TryGetValue(deviceName, out var friendly))
{
name = friendlyName;
name = friendly;
}
else if (deviceName.StartsWith(@"\\.\DISPLAY", StringComparison.OrdinalIgnoreCase))
{

View File

@@ -30,7 +30,7 @@ internal sealed partial class WindowThemeSynchronizer : IDisposable
}
/// <summary>
/// Unsubscribe from theme change events.
/// Unsubscribes from theme change events.
/// </summary>
public void Dispose()
{

View File

@@ -193,12 +193,12 @@
<TextBlock
HorizontalAlignment="Center"
FontWeight="SemiBold"
Text="{x:Bind CurrentScreenshotDisplayName, Mode=OneTime}" />
Text="{x:Bind CurrentScreenshotDisplayName, Mode=OneWay}" />
<TextBlock
HorizontalAlignment="Center"
Foreground="{ThemeResource TextFillColorSecondaryBrush}"
Style="{StaticResource CaptionTextBlockStyle}"
Text="{x:Bind CurrentScreenshotPositionText, Mode=OneTime}" />
Text="{x:Bind CurrentScreenshotPositionText, Mode=OneWay}" />
</StackPanel>
</Border>
</Grid>

View File

@@ -973,18 +973,6 @@ Right-click to remove the key combination, thereby deactivating the shortcut.</v
<value>Unpin from Dock</value>
<comment>Command name for unpinning an item from the dock</comment>
</data>
<data name="top_level_move_up_command_name" xml:space="preserve">
<value>Move up</value>
<comment>Command name for moving a pinned home item up in the pinned section order</comment>
</data>
<data name="top_level_move_to_top_command_name" xml:space="preserve">
<value>Move to the top</value>
<comment>Command name for moving a pinned home item to the top of the pinned section order</comment>
</data>
<data name="top_level_move_down_command_name" xml:space="preserve">
<value>Move down</value>
<comment>Command name for moving a pinned home item down in the pinned section order</comment>
</data>
<data name="FiltersDropDown.[using:Microsoft.UI.Xaml.Automation]AutomationProperties.Name" xml:space="preserve">
<value>Filters</value>
</data>

View File

@@ -25,10 +25,7 @@
Color="{ThemeResource SystemAccentColor}" />
<SolidColorBrush x:Key="ParameterBackgroundFocused" Color="{ThemeResource SolidBackgroundFillColorSecondary}" />
</ResourceDictionary>
<ResourceDictionary x:Key="HighContrast">
<SolidColorBrush x:Key="ParameterBackground" Color="{ThemeResource SystemColorButtonFaceColor}" />
<SolidColorBrush x:Key="ParameterBackgroundFocused" Color="{ThemeResource SystemColorHighlightColor}" />
</ResourceDictionary>
<ResourceDictionary x:Key="HighContrast" />
</ResourceDictionary.ThemeDictionaries>
<converters:StringVisibilityConverter

View File

@@ -5,7 +5,6 @@
using System;
using System.Linq;
using System.Threading.Tasks;
using Microsoft.CmdPal.Ext.Apps.Programs;
using Microsoft.VisualStudio.TestTools.UnitTesting;
namespace Microsoft.CmdPal.Ext.Apps.UnitTests;
@@ -74,62 +73,4 @@ public class AllAppsPageTests : AppsTestBase
Assert.IsTrue(items.Any(i => i.Title == "Notepad"));
Assert.IsTrue(items.Any(i => i.Title == "Calculator"));
}
[TestMethod]
public async Task AllAppsPage_GetItems_HidesSubtitlesWhenSettingEnabled()
{
// Arrange
var mockCache = new MockAppCache();
var win32App = TestDataHelper.CreateTestWin32Program("Notepad", "C:\\Windows\\System32\\notepad.exe");
mockCache.AddWin32Program(win32App);
try
{
AllAppsSettings.Instance.Settings.Update("{\"apps.HideAppDescriptions\": \"true\"}");
var page = new AllAppsPage(mockCache);
await Task.Delay(100);
// Act
var items = page.GetItems();
// Assert
Assert.AreEqual(1, items.Length);
var appItem = items.OfType<AppListItem>().Single();
Assert.AreEqual(string.Empty, appItem.Subtitle);
}
finally
{
AllAppsSettings.Instance.Settings.Update("{\"apps.HideAppDescriptions\": \"false\"}");
}
}
[TestMethod]
public async Task AllAppsPage_GetItems_ShowsSubtitlesWhenSettingDisabled()
{
// Arrange
var mockCache = new MockAppCache();
var win32App = TestDataHelper.CreateTestWin32Program("Notepad", "C:\\Windows\\System32\\notepad.exe");
mockCache.AddWin32Program(win32App);
try
{
AllAppsSettings.Instance.Settings.Update("{\"apps.HideAppDescriptions\": \"false\"}");
var page = new AllAppsPage(mockCache);
await Task.Delay(100);
// Act
var items = page.GetItems();
// Assert
Assert.AreEqual(1, items.Length);
var appItem = items.OfType<AppListItem>().Single();
Assert.IsFalse(string.IsNullOrEmpty(appItem.Subtitle));
}
finally
{
AllAppsSettings.Instance.Settings.Update("{\"apps.HideAppDescriptions\": \"false\"}");
}
}
}

View File

@@ -16,8 +16,6 @@ namespace Microsoft.CmdPal.Ext.Calc.UnitTests;
[TestClass]
public class ExtendedCalculatorParserTests : CommandPaletteUnitTestBase
{
private const int IntMax = int.MaxValue;
[DataTestMethod]
[DataRow(null)]
[DataRow("")]
@@ -66,14 +64,6 @@ public class ExtendedCalculatorParserTests : CommandPaletteUnitTestBase
["log2(3)", 1.58496250072116M],
["log10(3)", 0.47712125471966M],
["ln(e)", 1M],
// Space between function name and '(' must produce the same result
// (regression test for the log-mapping bug).
["ln (3)", 1.09861228866811M],
["log (3)", 0.47712125471966M],
["log2 (3)", 1.58496250072116M],
["log10 (3)", 0.47712125471966M],
["cosh(0)", 1M],
["1*10^(-5)", 0.00001M],
["1*10^(-15)", 0.0000000000000001M],
@@ -411,136 +401,13 @@ public class ExtendedCalculatorParserTests : CommandPaletteUnitTestBase
[DataTestMethod]
[DataRow("171!")]
[DataRow("1000!")]
public void Interpret_ReturnsOutOfBoundsError_WhenValueOverflowsDecimal(string input)
public void Interpret_ReturnsError_WhenValueOverflowsDecimal(string input)
{
var settings = new Settings();
CalculateEngine.Interpret(settings, input, CultureInfo.InvariantCulture, out var error);
Assert.AreEqual(Properties.Resources.calculator_not_covert_to_decimal, error);
}
[DataTestMethod]
[DataRow("exp(99999)")]
[DataRow("-exp(99999)")]
public void Interpret_ReturnsOutOfBoundsError_WhenResultIsInfinity(string input)
{
var settings = new Settings();
var result = CalculateEngine.Interpret(settings, input, CultureInfo.InvariantCulture, out var error);
Assert.AreEqual(default, result);
Assert.AreEqual(Properties.Resources.calculator_not_covert_to_decimal, error);
}
[DataTestMethod]
[DataRow("1 2")]
public void Interpret_ReturnsExpressionNotCompleteError_WhenExpressionIsInvalid(string input)
{
var settings = new Settings();
var result = CalculateEngine.Interpret(settings, input, CultureInfo.InvariantCulture, out var error);
Assert.AreEqual(default, result);
Assert.AreEqual(Properties.Resources.calculator_expression_not_complete, error);
}
[TestMethod]
public void Interpret_ReturnsNotANumberError_WhenResultIsNaN()
{
var settings = new Settings();
var result = CalculateEngine.Interpret(settings, "sqrt(-1)", CultureInfo.InvariantCulture, out var error);
Assert.AreEqual(default, result);
Assert.AreEqual(Properties.Resources.calculator_not_a_number, error);
}
[DataTestMethod]
[DataRow("factorial(-1)")]
[DataRow("factorial(0.5)")]
[DataRow("factorial(sqrt(-1))")]
[DataRow("sign(sqrt(-1))")]
public void Interpret_ReturnsNotANumberError_WhenCustomFunctionArgumentInvalid(string input)
{
var settings = new Settings();
var result = CalculateEngine.Interpret(settings, input, CultureInfo.InvariantCulture, out var error);
Assert.AreEqual(default, result);
Assert.AreEqual(Properties.Resources.calculator_not_a_number, error);
}
[TestMethod]
public void Interpret_Rand_ReturnsValueInRange()
{
var settings = new Settings();
var result = CalculateEngine.Interpret(settings, "rand()", CultureInfo.InvariantCulture, out var error);
Assert.IsNull(error);
Assert.IsTrue(result.Result.HasValue, "rand() returned no result");
Assert.IsTrue(result.Result >= 0M && result.Result < 1M, $"rand() result {result.Result} was not in [0, 1)");
}
[TestMethod]
public void Interpret_Randi_ReturnsZero_WhenArgIsOne()
{
// randi(1) has only one valid outcome: 0. This test is fully deterministic.
var settings = new Settings();
var result = CalculateEngine.Interpret(settings, "randi(1)", CultureInfo.InvariantCulture, out var error);
Assert.IsNull(error);
Assert.IsTrue(result.Result.HasValue, "randi(1) returned no result");
Assert.AreEqual(0M, result.Result!.Value, "randi(1) must always return 0");
}
[TestMethod]
public void Interpret_Randi_StaysInRange_WhenArgIsTwo()
{
// randi(2) has the smallest non-trivial range [0, 1]. Running many iterations
// gives high confidence both boundary values are reachable.
var settings = new Settings();
for (var i = 0; i < 100; i++)
{
var result = CalculateEngine.Interpret(settings, "randi(2)", CultureInfo.InvariantCulture, out var error);
Assert.IsNull(error);
Assert.IsTrue(result.Result.HasValue, "randi(2) returned no result");
var value = result.Result!.Value;
Assert.AreEqual(value, Math.Floor(value), $"randi(2) result {value} was not an integer");
Assert.IsTrue(value >= 0M && value <= 1M, $"randi(2) result {value} was not in [0, 1]");
}
}
[TestMethod]
public void Interpret_Randi_HandlesIntMaxArgument()
{
// Ensures no integer overflow in the C++ cast when the argument is int.MaxValue.
var settings = new Settings();
var result = CalculateEngine.Interpret(settings, $"randi({IntMax})", CultureInfo.InvariantCulture, out var error);
Assert.IsNull(error);
Assert.IsTrue(result.Result.HasValue, $"randi({IntMax}) returned no result");
var value = result.Result!.Value;
Assert.AreEqual(value, Math.Floor(value), $"randi({IntMax}) result {value} was not an integer");
Assert.IsTrue(value >= 0M && value < IntMax, $"randi({IntMax}) result {value} was out of range");
}
[DataTestMethod]
[DataRow("randi(0)")]
[DataRow("randi(0.5)")]
[DataRow("randi(-1)")]
[DataRow("randi(exp(10000))")]
public void Interpret_Randi_ReturnsNotANumberError_WhenArgumentInvalid(string input)
{
var settings = new Settings();
var result = CalculateEngine.Interpret(settings, input, CultureInfo.InvariantCulture, out var error);
Assert.AreEqual(default, result);
Assert.AreEqual(Properties.Resources.calculator_not_a_number, error);
Assert.IsFalse(string.IsNullOrEmpty(error));
Assert.AreNotEqual(null, error);
}
}

View File

@@ -88,47 +88,6 @@ public class NumberTranslatorTests
Assert.AreEqual(expectedResult, result);
}
[DataTestMethod]
[DataRow("ceil(123,456.23)", "ceil(123456.23)")]
[DataRow("floor(123,456.23)", "floor(123456.23)")]
[DataRow("round(123,456.23)", "round(123456.23)")]
[DataRow("log(123,456.23)", "log(123456.23)")]
[DataRow("sin(123,456.23)", "sin(123456.23)")]
[DataRow("max(ceil(123,456.23),2)", "max(ceil(123456.23),2)")]
[DataRow("pow(round(1,234.5),2)", "pow(round(1234.5),2)")]
public void Translate_PreservesGroupedNumbers_ForSingleArgumentFunctions_WhenCultureUsesCommaListSeparator(string input, string expectedResult)
{
var translator = NumberTranslator.Create(new CultureInfo("en-US", false), new CultureInfo("en-US", false));
var result = translator.Translate(input);
Assert.IsNotNull(result);
Assert.AreEqual(expectedResult, result);
}
[DataTestMethod]
[DataRow("max(1,2)", "max(1,2)")]
[DataRow("min(1,2)", "min(1,2)")]
[DataRow("pow(2,3)", "pow(2,3)")]
[DataRow("max(1.5,2.5)", "max(1.5,2.5)")]
[DataRow("pow(9,0.5)", "pow(9,0.5)")]
[DataRow("max(123,45)", "max(123,45)")]
[DataRow("max(123,456)", "max(123,456)")]
[DataRow("max(123,min(12,pow(2,6)))", "max(123,min(12,pow(2,6)))")]
[DataRow("max ( 12, 34 )", "max ( 12, 34 )")]
[DataRow("pow(max(2,3),2)", "pow(max(2,3),2)")]
[DataRow("max(1e3,2e3)", "max(1e3,2e3)")]
[DataRow("pow(1.5e2,2)", "pow(1.5e2,2)")]
public void Translate_PreservesFunctionArgumentSeparators_WhenCultureUsesCommaListSeparator(string input, string expectedResult)
{
var translator = NumberTranslator.Create(new CultureInfo("en-US", false), new CultureInfo("en-US", false));
var result = translator.Translate(input);
Assert.IsNotNull(result);
Assert.AreEqual(expectedResult, result);
}
[DataTestMethod]
[DataRow("2.0 * 2", "2,0 * 2")]
[DataRow("4 * 3.6 + 9", "4 * 3,6 + 9")]
@@ -211,9 +170,7 @@ public class NumberTranslatorTests
[DataRow("en-US", "0xF000", "61440")]
[DataRow("en-US", "0xf4572220", "4099351072")]
[DataRow("en-US", "0x12345678", "305419896")]
[DataRow("en-US", "0b101010", "42")]
[DataRow("en-US", "0o377", "255")]
public void Translate_BaseLiteralsToDecimal(string sourceCultureName, string input, string expectedResult)
public void Translate_LargeHexadecimalNumbersToDecimal(string sourceCultureName, string input, string expectedResult)
{
// Arrange
var translator = NumberTranslator.Create(new CultureInfo(sourceCultureName, false), new CultureInfo("en-US", false));

View File

@@ -40,21 +40,6 @@ public class QueryTests : CommandPaletteUnitTestBase
[DataRow("10/2", "5")]
[DataRow("sqrt(16)", "4")]
[DataRow("2^3", "8")]
[DataRow("max(1,2)", "2")]
[DataRow("min(1,2)", "1")]
[DataRow("pow(2,3)", "8")]
[DataRow("max(1.5,2.5)", "2.5")]
[DataRow("pow(9,0.5)", "3")]
[DataRow("max(123,45)", "123")]
[DataRow("max(123,456)", "456")]
[DataRow("max(123,min(12,pow(2,6)))", "123")]
[DataRow("max ( 12, 34 )", "34")]
[DataRow("ceil(123,456.23)", "123457")]
[DataRow("max(ceil(123,456.23),2)", "123457")]
[DataRow("pow(round(1,234.5),2)", "1525225")]
[DataRow("max(1e3,2e3)", "2000")]
[DataRow("pow(1.5e2,2)", "22500")]
[DataRow("max(0b1010,0o12)", "10")]
public void TopLevelPageQueryTest(string input, string expectedResult)
{
var settings = new Settings();

View File

@@ -13,6 +13,8 @@ using System.Threading.Tasks;
using Microsoft.CmdPal.Common.Services;
using Microsoft.CmdPal.Ext.Run;
using Microsoft.CmdPal.Ext.UnitTestBase;
using Microsoft.CommandPalette.Extensions;
using Microsoft.CommandPalette.Extensions.Toolkit;
using Microsoft.VisualStudio.TestTools.UnitTesting;
using Moq;
@@ -90,13 +92,6 @@ public class RunPageTests : CommandPaletteUnitTestBase
[TestMethod]
public async Task TestSimple()
{
// Note to future self: are the tests hanging mysteriously? Running
// forever, but not actually doing anything, seemingly just spinning on
// this case?
//
// If they are, then you forgot to add a string resource to our .resx
// somewhere. Fix that, and the tests will run again.
// Setup
var nativeService = CreateMockHistoryService().Object;
using var page = new RunListPage(nativeService, telemetryService: null);
@@ -637,7 +632,7 @@ public class RunPageTests : CommandPaletteUnitTestBase
// Filtered items should be less than or equal to all items
Assert.IsTrue(filteredItems.Length <= allItems.Length);
// All filtered items should contain 'D' (case-insensitive)
// All filtered items should contain 'D' (case insensitive)
foreach (var item in filteredItems)
{
StringAssert.Contains(item.Title, "D", StringComparison.OrdinalIgnoreCase);

View File

@@ -21,7 +21,6 @@ public class DockMultiMonitorTests
private static readonly MonitorInfo PrimaryMonitor = new()
{
DeviceId = @"\\.\DISPLAY1",
StableId = @"\\?\DISPLAY#PRI1234#4&aaa&0&UID111#{guid1}",
DisplayName = "Display 1 (Primary)",
Bounds = new ScreenRect(0, 0, 1920, 1080),
WorkArea = new ScreenRect(0, 0, 1920, 1040),
@@ -32,7 +31,6 @@ public class DockMultiMonitorTests
private static readonly MonitorInfo SecondaryMonitor = new()
{
DeviceId = @"\\.\DISPLAY2",
StableId = @"\\?\DISPLAY#SEC5678#4&bbb&0&UID222#{guid2}",
DisplayName = "Display 2",
Bounds = new ScreenRect(1920, 0, 3840, 1080),
WorkArea = new ScreenRect(1920, 0, 3840, 1040),
@@ -169,8 +167,8 @@ public class DockMultiMonitorTests
public void Reconciler_ExactMatch_PreservesExistingConfigs()
{
var configs = ImmutableList.Create(
new DockMonitorConfig { MonitorDeviceId = PrimaryMonitor.StableId, Enabled = true, IsPrimary = true },
new DockMonitorConfig { MonitorDeviceId = SecondaryMonitor.StableId, Enabled = true });
new DockMonitorConfig { MonitorDeviceId = @"\\.\DISPLAY1", Enabled = true, IsPrimary = true },
new DockMonitorConfig { MonitorDeviceId = @"\\.\DISPLAY2", Enabled = true });
var monitors = new List<MonitorInfo> { PrimaryMonitor, SecondaryMonitor };
@@ -183,7 +181,7 @@ public class DockMultiMonitorTests
public void Reconciler_NewMonitor_CreatesDefaultConfig()
{
var configs = ImmutableList.Create(
new DockMonitorConfig { MonitorDeviceId = PrimaryMonitor.StableId, Enabled = true, IsPrimary = true });
new DockMonitorConfig { MonitorDeviceId = @"\\.\DISPLAY1", Enabled = true, IsPrimary = true });
var monitors = new List<MonitorInfo> { PrimaryMonitor, SecondaryMonitor };
@@ -191,24 +189,25 @@ public class DockMultiMonitorTests
Assert.AreEqual(2, result.Count);
var newConfig = result[1];
Assert.AreEqual(SecondaryMonitor.StableId, newConfig.MonitorDeviceId);
Assert.IsFalse(newConfig.Enabled, "New secondary monitor should be disabled by default");
Assert.AreEqual(@"\\.\DISPLAY2", newConfig.MonitorDeviceId);
Assert.IsTrue(newConfig.Enabled);
}
[TestMethod]
public void Reconciler_DisconnectedMonitor_PreservesConfig()
{
var configs = ImmutableList.Create(
new DockMonitorConfig { MonitorDeviceId = PrimaryMonitor.StableId, Enabled = true, LastSeen = DateTime.UtcNow },
new DockMonitorConfig { MonitorDeviceId = @"\\?\DISPLAY#GONE#4&ccc&0&UID999#{guid99}", Enabled = true, IsCustomized = true, LastSeen = DateTime.UtcNow });
new DockMonitorConfig { MonitorDeviceId = @"\\.\DISPLAY1", Enabled = true, LastSeen = DateTime.UtcNow },
new DockMonitorConfig { MonitorDeviceId = @"\\.\DISPLAY99", Enabled = true, IsCustomized = true, LastSeen = DateTime.UtcNow });
var monitors = new List<MonitorInfo> { PrimaryMonitor };
var result = MonitorConfigReconciler.Reconcile(configs, monitors);
// Connected monitor is first, disconnected monitor is retained at end
Assert.AreEqual(2, result.Count);
Assert.AreEqual(PrimaryMonitor.StableId, result[0].MonitorDeviceId);
Assert.AreEqual(@"\\?\DISPLAY#GONE#4&ccc&0&UID999#{guid99}", result[1].MonitorDeviceId);
Assert.AreEqual(@"\\.\DISPLAY1", result[0].MonitorDeviceId);
Assert.AreEqual(@"\\.\DISPLAY99", result[1].MonitorDeviceId);
Assert.IsTrue(result[1].IsCustomized, "Disconnected monitor should preserve its customizations.");
}
@@ -257,26 +256,26 @@ public class DockMultiMonitorTests
[TestMethod]
public void Reconciler_FuzzyMatch_UpdatesPrimaryFlag()
{
// Config has old stable ID but marked as primary
// Config has old device ID but marked as primary
var configs = ImmutableList.Create(
new DockMonitorConfig { MonitorDeviceId = @"\\?\DISPLAY#OLD#4&ddd&0&UID000#{guidOld}", Enabled = true, IsPrimary = true });
new DockMonitorConfig { MonitorDeviceId = @"\\.\DISPLAY_OLD", Enabled = true, IsPrimary = true });
var monitors = new List<MonitorInfo> { PrimaryMonitor };
var result = MonitorConfigReconciler.Reconcile(configs, monitors);
Assert.AreEqual(1, result.Count);
Assert.AreEqual(PrimaryMonitor.StableId, result[0].MonitorDeviceId);
Assert.AreEqual(@"\\.\DISPLAY1", result[0].MonitorDeviceId);
Assert.IsTrue(result[0].IsPrimary);
}
[TestMethod]
public void Reconciler_FuzzyMatch_DoesNotMatchNonPrimaryMonitors()
{
// Config has stale stable ID for a non-primary monitor
// Config has stale device ID for a non-primary monitor
var configs = ImmutableList.Create(
new DockMonitorConfig { MonitorDeviceId = PrimaryMonitor.StableId, Enabled = true, IsPrimary = true },
new DockMonitorConfig { MonitorDeviceId = @"\\?\DISPLAY#STALE#4&eee&0&UID333#{guidStale}", Enabled = true, IsPrimary = false, IsCustomized = true, LastSeen = DateTime.UtcNow });
new DockMonitorConfig { MonitorDeviceId = @"\\.\DISPLAY1", Enabled = true, IsPrimary = true },
new DockMonitorConfig { MonitorDeviceId = @"\\.\DISPLAY_STALE", Enabled = true, IsPrimary = false, IsCustomized = true, LastSeen = DateTime.UtcNow });
// Current monitors have primary + a different secondary
var monitors = new List<MonitorInfo> { PrimaryMonitor, SecondaryMonitor };
@@ -286,11 +285,11 @@ public class DockMultiMonitorTests
// Primary keeps its config, new secondary gets a fresh customized config,
// stale secondary is retained at end for future reconnection
Assert.AreEqual(3, result.Count);
Assert.AreEqual(PrimaryMonitor.StableId, result[0].MonitorDeviceId);
Assert.AreEqual(SecondaryMonitor.StableId, result[1].MonitorDeviceId);
Assert.AreEqual(@"\\.\DISPLAY1", result[0].MonitorDeviceId);
Assert.AreEqual(@"\\.\DISPLAY2", result[1].MonitorDeviceId);
Assert.IsTrue(result[1].IsCustomized, "New secondary should get an empty-bands customized config.");
Assert.AreEqual(0, result[1].StartBands?.Count ?? 0, "New secondary should start with empty bands.");
Assert.AreEqual(@"\\?\DISPLAY#STALE#4&eee&0&UID333#{guidStale}", result[2].MonitorDeviceId, "Stale config should be preserved.");
Assert.AreEqual(@"\\.\DISPLAY_STALE", result[2].MonitorDeviceId, "Stale config should be preserved.");
Assert.IsTrue(result[2].IsCustomized, "Stale config should retain its customizations.");
}
@@ -359,8 +358,8 @@ public class DockMultiMonitorTests
var now = DateTime.UtcNow;
var monitors = new List<MonitorInfo> { PrimaryMonitor, SecondaryMonitor };
var configs = ImmutableList.Create(
new DockMonitorConfig { MonitorDeviceId = PrimaryMonitor.StableId, Enabled = true, IsPrimary = true, LastSeen = now },
new DockMonitorConfig { MonitorDeviceId = SecondaryMonitor.StableId, Enabled = true, IsPrimary = false, LastSeen = now });
new DockMonitorConfig { MonitorDeviceId = PrimaryMonitor.DeviceId, Enabled = true, IsPrimary = true, LastSeen = now },
new DockMonitorConfig { MonitorDeviceId = SecondaryMonitor.DeviceId, Enabled = true, IsPrimary = false, LastSeen = now });
var reconciled = MonitorConfigReconciler.Reconcile(configs, monitors, now);
@@ -376,7 +375,7 @@ public class DockMultiMonitorTests
var monitorOneConfig = new DockMonitorConfig
{
MonitorDeviceId = PrimaryMonitor.StableId,
MonitorDeviceId = PrimaryMonitor.DeviceId,
Enabled = true,
IsPrimary = true,
IsCustomized = true,
@@ -386,7 +385,7 @@ public class DockMultiMonitorTests
};
var monitorTwoConfig = new DockMonitorConfig
{
MonitorDeviceId = SecondaryMonitor.StableId,
MonitorDeviceId = SecondaryMonitor.DeviceId,
Enabled = true,
IsPrimary = false,
IsCustomized = true,
@@ -479,7 +478,7 @@ public class DockMultiMonitorTests
public void DockMonitorConfigViewModel_IsEnabled_ReadsFromConfig()
{
var settings = CreateSettingsModelWithConfigs(
new DockMonitorConfig { MonitorDeviceId = PrimaryMonitor.StableId, Enabled = false, IsPrimary = true });
new DockMonitorConfig { MonitorDeviceId = PrimaryMonitor.DeviceId, Enabled = false, IsPrimary = true });
var mockSettings = CreateMockSettingsService(settings);
var vm = new DockMonitorConfigViewModel(
@@ -492,7 +491,7 @@ public class DockMultiMonitorTests
public void DockMonitorConfigViewModel_IsEnabled_PersistsChange()
{
var settings = CreateSettingsModelWithConfigs(
new DockMonitorConfig { MonitorDeviceId = PrimaryMonitor.StableId, Enabled = true, IsPrimary = true });
new DockMonitorConfig { MonitorDeviceId = PrimaryMonitor.DeviceId, Enabled = true, IsPrimary = true });
var mockSettings = CreateMockSettingsService(settings);
var vm = new DockMonitorConfigViewModel(
@@ -507,7 +506,7 @@ public class DockMultiMonitorTests
public void DockMonitorConfigViewModel_SideOverrideIndex_ReturnsZeroWhenNull()
{
var settings = CreateSettingsModelWithConfigs(
new DockMonitorConfig { MonitorDeviceId = PrimaryMonitor.StableId, Side = null });
new DockMonitorConfig { MonitorDeviceId = PrimaryMonitor.DeviceId, Side = null });
var mockSettings = CreateMockSettingsService(settings);
var vm = new DockMonitorConfigViewModel(
@@ -521,7 +520,7 @@ public class DockMultiMonitorTests
public void DockMonitorConfigViewModel_SideOverrideIndex_MapsCorrectly()
{
var settings = CreateSettingsModelWithConfigs(
new DockMonitorConfig { MonitorDeviceId = PrimaryMonitor.StableId, Side = DockSide.Right });
new DockMonitorConfig { MonitorDeviceId = PrimaryMonitor.DeviceId, Side = DockSide.Right });
var mockSettings = CreateMockSettingsService(settings);
var vm = new DockMonitorConfigViewModel(
@@ -535,7 +534,7 @@ public class DockMultiMonitorTests
public void DockMonitorConfigViewModel_DisplayInfo_ExposesMonitorProperties()
{
var settings = CreateSettingsModelWithConfigs(
new DockMonitorConfig { MonitorDeviceId = PrimaryMonitor.StableId, IsPrimary = true });
new DockMonitorConfig { MonitorDeviceId = PrimaryMonitor.DeviceId, IsPrimary = true });
var mockSettings = CreateMockSettingsService(settings);
var vm = new DockMonitorConfigViewModel(
@@ -554,7 +553,6 @@ public class DockMultiMonitorTests
var tertiary = new MonitorInfo
{
DeviceId = @"\\.\DISPLAY3",
StableId = @"\\?\DISPLAY#TER9012#4&fff&0&UID333#{guid3}",
DisplayName = "Display 3",
Bounds = new ScreenRect(3840, 0, 5760, 1080),
WorkArea = new ScreenRect(3840, 0, 5760, 1040),
@@ -568,18 +566,10 @@ public class DockMultiMonitorTests
Assert.AreEqual(3, reconciled.Count, "Should create configs for all 3 monitors");
// Only the primary monitor should be enabled by default
// All monitors should be enabled by default
foreach (var config in reconciled)
{
if (config.IsPrimary)
{
Assert.IsTrue(config.Enabled, $"Primary monitor {config.MonitorDeviceId} should be enabled");
}
else
{
Assert.IsFalse(config.Enabled, $"Secondary monitor {config.MonitorDeviceId} should be disabled by default");
}
Assert.IsTrue(config.Enabled, $"Monitor {config.MonitorDeviceId} should be enabled");
Assert.IsNull(config.Side, $"Monitor {config.MonitorDeviceId} should inherit global side");
}
@@ -596,11 +586,11 @@ public class DockMultiMonitorTests
}
// Primary should be flagged correctly
var primaryConfig = reconciled.Find(c => c.MonitorDeviceId == PrimaryMonitor.StableId);
var primaryConfig = reconciled.Find(c => c.MonitorDeviceId == PrimaryMonitor.DeviceId);
Assert.IsNotNull(primaryConfig, "Primary monitor config should exist");
Assert.IsTrue(primaryConfig.IsPrimary, "Primary config should be marked as primary");
var secondaryConfig = reconciled.Find(c => c.MonitorDeviceId == SecondaryMonitor.StableId);
var secondaryConfig = reconciled.Find(c => c.MonitorDeviceId == SecondaryMonitor.DeviceId);
Assert.IsNotNull(secondaryConfig, "Secondary monitor config should exist");
Assert.IsFalse(secondaryConfig.IsPrimary, "Secondary config should not be marked as primary");
}
@@ -623,7 +613,7 @@ public class DockMultiMonitorTests
var secondaryConfig = reconciled.Find(c => !c.IsPrimary);
Assert.IsNotNull(secondaryConfig, "Secondary config should be created");
Assert.IsFalse(secondaryConfig.Enabled, "Secondary should be disabled by default");
Assert.IsTrue(secondaryConfig.Enabled, "Secondary should be enabled by default");
Assert.IsTrue(secondaryConfig.IsCustomized, "Secondary should start with custom (empty) bands");
}
@@ -634,10 +624,10 @@ public class DockMultiMonitorTests
var customBands = ImmutableList.Create(new DockBandSettings { ProviderId = "custom", CommandId = "cmd1" });
var now = DateTime.UtcNow;
var configs = ImmutableList.Create(
new DockMonitorConfig { MonitorDeviceId = PrimaryMonitor.StableId, Enabled = true, IsPrimary = true, LastSeen = now },
new DockMonitorConfig { MonitorDeviceId = PrimaryMonitor.DeviceId, Enabled = true, IsPrimary = true, LastSeen = now },
new DockMonitorConfig
{
MonitorDeviceId = SecondaryMonitor.StableId,
MonitorDeviceId = SecondaryMonitor.DeviceId,
Enabled = true,
IsPrimary = false,
IsCustomized = true,
@@ -660,7 +650,7 @@ public class DockMultiMonitorTests
// Verify customizations survived the round-trip
var secondaryConfig = afterReconnect.Find(c =>
string.Equals(c.MonitorDeviceId, SecondaryMonitor.StableId, StringComparison.OrdinalIgnoreCase));
string.Equals(c.MonitorDeviceId, SecondaryMonitor.DeviceId, StringComparison.OrdinalIgnoreCase));
Assert.IsNotNull(secondaryConfig, "Secondary config should be found after reconnection");
Assert.IsTrue(secondaryConfig.IsCustomized, "Customization flag should survive");
Assert.AreEqual(DockSide.Left, secondaryConfig.Side, "Side override should survive");
@@ -676,9 +666,9 @@ public class DockMultiMonitorTests
var fiveMonthsAgo = now - TimeSpan.FromDays(150);
var configs = ImmutableList.Create(
new DockMonitorConfig { MonitorDeviceId = PrimaryMonitor.StableId, Enabled = true, IsPrimary = true, LastSeen = now },
new DockMonitorConfig { MonitorDeviceId = @"\\?\DISPLAY#STALE#4&sss&0&UID444#{guidStale}", Enabled = true, IsPrimary = false, LastSeen = sevenMonthsAgo },
new DockMonitorConfig { MonitorDeviceId = @"\\?\DISPLAY#RECENT#4&rrr&0&UID555#{guidRecent}", Enabled = true, IsPrimary = false, LastSeen = fiveMonthsAgo });
new DockMonitorConfig { MonitorDeviceId = PrimaryMonitor.DeviceId, Enabled = true, IsPrimary = true, LastSeen = now },
new DockMonitorConfig { MonitorDeviceId = @"\\.\DISPLAY_STALE", Enabled = true, IsPrimary = false, LastSeen = sevenMonthsAgo },
new DockMonitorConfig { MonitorDeviceId = @"\\.\DISPLAY_RECENT", Enabled = true, IsPrimary = false, LastSeen = fiveMonthsAgo });
var monitors = new List<MonitorInfo> { PrimaryMonitor };
@@ -686,8 +676,8 @@ public class DockMultiMonitorTests
// Primary is matched, RECENT is retained (< 6 months), STALE is pruned (> 6 months)
Assert.AreEqual(2, result.Count, "Should have matched primary + recently-seen disconnected config");
Assert.AreEqual(PrimaryMonitor.StableId, result[0].MonitorDeviceId);
Assert.AreEqual(@"\\?\DISPLAY#RECENT#4&rrr&0&UID555#{guidRecent}", result[1].MonitorDeviceId, "Recently-seen config should be retained");
Assert.AreEqual(PrimaryMonitor.DeviceId, result[0].MonitorDeviceId);
Assert.AreEqual(@"\\.\DISPLAY_RECENT", result[1].MonitorDeviceId, "Recently-seen config should be retained");
}
[TestMethod]
@@ -695,8 +685,8 @@ public class DockMultiMonitorTests
{
// Configs from before LastSeen was added (LastSeen is null)
var configs = ImmutableList.Create(
new DockMonitorConfig { MonitorDeviceId = PrimaryMonitor.StableId, Enabled = true, IsPrimary = true },
new DockMonitorConfig { MonitorDeviceId = @"\\?\DISPLAY#LEGACY#4&lll&0&UID666#{guidLegacy}", Enabled = true, IsPrimary = false, IsCustomized = true });
new DockMonitorConfig { MonitorDeviceId = PrimaryMonitor.DeviceId, Enabled = true, IsPrimary = true },
new DockMonitorConfig { MonitorDeviceId = @"\\.\DISPLAY_LEGACY", Enabled = true, IsPrimary = false, IsCustomized = true });
var monitors = new List<MonitorInfo> { PrimaryMonitor };
@@ -704,27 +694,7 @@ public class DockMultiMonitorTests
// Legacy config (null LastSeen) should be treated as fresh and retained
Assert.AreEqual(2, result.Count, "Legacy config without LastSeen should be retained");
Assert.AreEqual(@"\\?\DISPLAY#LEGACY#4&lll&0&UID666#{guidLegacy}", result[1].MonitorDeviceId);
}
[TestMethod]
public void Reconciler_LegacyGdiName_MigratedToStableId()
{
// Simulate upgrade from pre-stable-ID settings: configs use GDI device names
var configs = ImmutableList.Create(
new DockMonitorConfig { MonitorDeviceId = @"\\.\DISPLAY1", Enabled = true, IsPrimary = true, Side = DockSide.Left },
new DockMonitorConfig { MonitorDeviceId = @"\\.\DISPLAY2", Enabled = true, IsPrimary = false, IsCustomized = true });
var monitors = new List<MonitorInfo> { PrimaryMonitor, SecondaryMonitor };
var result = MonitorConfigReconciler.Reconcile(configs, monitors);
// Phase 1.5 should detect GDI-style names and rewrite to stable IDs
Assert.AreEqual(2, result.Count);
Assert.AreEqual(PrimaryMonitor.StableId, result[0].MonitorDeviceId, "Primary should be migrated to stable ID");
Assert.AreEqual(SecondaryMonitor.StableId, result[1].MonitorDeviceId, "Secondary should be migrated to stable ID");
Assert.AreEqual(DockSide.Left, result[0].Side, "Side override should survive migration");
Assert.IsTrue(result[1].IsCustomized, "Customization flag should survive migration");
Assert.AreEqual(@"\\.\DISPLAY_LEGACY", result[1].MonitorDeviceId);
}
private static SettingsModel CreateSettingsModelWithConfigs(params DockMonitorConfig[] configs)

View File

@@ -96,41 +96,6 @@ public class ExtensionGalleryItemViewModelTests
Assert.IsTrue(viewModel.Sources.Count >= 4);
}
[TestMethod]
public void Constructor_IgnoresNonWebGalleryLinks()
{
var entry = new GalleryExtensionEntry
{
Id = "unsafe-links-extension",
Title = "Unsafe links extension",
Description = "Unsafe links extension description",
Homepage = "ms-settings:appsfeatures",
Author = new GalleryAuthor
{
Name = "Author",
Url = "file:///C:/Windows/System32/calc.exe",
},
InstallSources =
[
new GalleryInstallSource { Type = "url", Uri = "file:///C:/Windows/System32/notepad.exe" },
],
};
var viewModel = CreateViewModel(entry);
Assert.IsFalse(viewModel.HasHomepage);
Assert.IsFalse(viewModel.HasAuthorUrl);
Assert.IsFalse(viewModel.HasUrlSource);
Assert.IsFalse(viewModel.HasActionableSourceDetails);
Assert.IsNull(viewModel.InstallUrl);
Assert.IsFalse(viewModel.Sources.Any(source =>
string.Equals(source.Kind, "github", StringComparison.OrdinalIgnoreCase)
|| string.Equals(source.Kind, "website", StringComparison.OrdinalIgnoreCase)));
Assert.IsFalse(viewModel.OpenHomepageCommand.CanExecute(null));
Assert.IsFalse(viewModel.OpenAuthorPageCommand.CanExecute(null));
Assert.IsFalse(viewModel.OpenInstallUrlCommand.CanExecute(null));
}
[TestMethod]
public void Constructor_EnablesCopyCommand_WhenWinGetIdIsAvailable()
{
@@ -382,68 +347,6 @@ public class ExtensionGalleryItemViewModelTests
CollectionAssert.Contains(sourceDetails.Select(item => item.Value).ToList(), "utility, productivity");
}
[TestMethod]
public void ApplyWinGetPackageInfo_IgnoresNonWebMetadataLinks()
{
var entry = new GalleryExtensionEntry
{
Id = "winget-details-unsafe-links-extension",
Title = "WinGet details unsafe links extension",
Description = "WinGet details unsafe links extension description",
Author = new GalleryAuthor { Name = "Author" },
InstallSources =
[
new GalleryInstallSource { Type = "winget", Id = "Contoso.Extension" },
],
};
var details = new WinGetPackageDetails(
Name: "Contoso Extension",
Version: "1.2.3",
Summary: "Summary",
Description: "Description",
Publisher: "Contoso",
PublisherUrl: "file:///C:/Windows/System32/calc.exe",
PublisherSupportUrl: "ms-settings:appsfeatures",
Author: "Contoso Team",
License: "MIT",
LicenseUrl: "https://contoso.example/license",
PackageUrl: "https://contoso.example/package",
ReleaseNotes: "Release notes",
ReleaseNotesUrl: "mailto:support@contoso.example",
IconUrl: "https://contoso.example/iconUrl.png",
DocumentationLinks:
[
new WinGetNamedLink("Unsafe docs", "search-ms:query=contoso"),
new WinGetNamedLink("Safe docs", "https://contoso.example/docs"),
],
Tags:
[
"utility",
"productivity",
]);
var viewModel = CreateViewModel(entry);
viewModel.ApplyWinGetPackageInfo(
new WinGetPackageInfo(
new WinGetPackageStatus(
IsInstalled: true,
IsInstalledStateKnown: true,
IsUpdateAvailable: false,
IsUpdateStateKnown: true),
details));
var wingetSource = viewModel.Sources.First(source => string.Equals(source.Kind, "winget", StringComparison.OrdinalIgnoreCase));
var sourceDetails = wingetSource.Details;
Assert.IsTrue(sourceDetails.Any(item => string.Equals(item.LinkUri?.AbsoluteUri, "https://contoso.example/license", StringComparison.OrdinalIgnoreCase)));
Assert.IsTrue(sourceDetails.Any(item => string.Equals(item.LinkUri?.AbsoluteUri, "https://contoso.example/package", StringComparison.OrdinalIgnoreCase)));
Assert.IsTrue(sourceDetails.Any(item => string.Equals(item.LinkUri?.AbsoluteUri, "https://contoso.example/docs", StringComparison.OrdinalIgnoreCase)));
Assert.IsFalse(sourceDetails.Any(item => item.LinkUri is not null && !string.Equals(item.LinkUri.Scheme, Uri.UriSchemeHttp, StringComparison.OrdinalIgnoreCase) && !string.Equals(item.LinkUri.Scheme, Uri.UriSchemeHttps, StringComparison.OrdinalIgnoreCase)));
Assert.IsFalse(sourceDetails.Any(item => string.Equals(item.Value, "ms-settings:appsfeatures", StringComparison.OrdinalIgnoreCase)));
Assert.IsFalse(sourceDetails.Any(item => string.Equals(item.Value, "search-ms:query=contoso", StringComparison.OrdinalIgnoreCase)));
}
[TestMethod]
public void ApplyWinGetPackageInfo_RaisesHasDetailsChanged_WhenMetadataIsAdded()
{

View File

@@ -484,7 +484,7 @@ follow - these are not part of the current SDK spec.
> [!NOTE]
>
> A thought: what if an action returns a `CommandResult.Entity`, then that takes
> A thought: what if a action returns a `CommandResult.Entity`, then that takes
> devpal back home, but leaves the entity in the query box. This would allow for
> a Quicksilver-like "thing, do" flow. That command would prepopulate the
> parameters. So we would then filter top-level commands based on things that can

View File

@@ -38,7 +38,6 @@ internal sealed partial class ActionsTestPage : ListPage
var items = new List<ListItem>();
#if DEBUG
var actionsDebug = string.Empty;
foreach (var action in actions)
@@ -49,7 +48,6 @@ internal sealed partial class ActionsTestPage : ListPage
}
Logger.LogDebug(actionsDebug);
#endif
foreach (var action in actions)
{

View File

@@ -98,14 +98,6 @@ public sealed partial class AllAppsPage : ListPage
items.Sort((a, b) => string.Compare(a.Title, b.Title, StringComparison.Ordinal));
if (AllAppsSettings.Instance.HideAppDescriptions)
{
foreach (var item in items)
{
item.Subtitle = string.Empty;
}
}
return [.. items];
}
}

View File

@@ -66,8 +66,6 @@ public class AllAppsSettings : JsonSettingsManager, ISettingsInterface
public bool IncludeNonAppsInStartMenu => _includeNonAppsInStartMenu.Value;
public bool HideAppDescriptions => _hideAppDescriptions.Value;
private readonly ChoiceSetSetting _searchResultLimitSource = new(
Namespaced(nameof(SearchResultLimit)),
Resources.limit_fallback_results_source,
@@ -139,12 +137,6 @@ public class AllAppsSettings : JsonSettingsManager, ISettingsInterface
string.Empty,
true);
private readonly ToggleSetting _hideAppDescriptions = new(
Namespaced(nameof(HideAppDescriptions)),
Resources.hide_app_descriptions,
Resources.hide_app_descriptions_description,
false);
public double MinScoreThreshold { get; set; } = 0.75;
internal const char SuffixSeparator = ';';
@@ -168,7 +160,6 @@ public class AllAppsSettings : JsonSettingsManager, ISettingsInterface
Settings.Add(_enableRegistrySource);
Settings.Add(_enablePathEnvironmentVariableSource);
Settings.Add(_searchResultLimitSource);
Settings.Add(_hideAppDescriptions);
LoadSettings();

View File

@@ -141,24 +141,6 @@ namespace Microsoft.CmdPal.Ext.Apps.Properties {
}
}
/// <summary>
/// Looks up a localized string similar to Hide app description in search results.
/// </summary>
internal static string hide_app_descriptions {
get {
return ResourceManager.GetString("hide_app_descriptions", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to Hide description text next to app results for a cleaner look.
/// </summary>
internal static string hide_app_descriptions_description {
get {
return ResourceManager.GetString("hide_app_descriptions_description", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to Also include non-app shortcuts from the Start menu.
/// </summary>

View File

@@ -244,10 +244,4 @@
<data name="include_non_apps_in_start_menu" xml:space="preserve">
<value>Also include non-app shortcuts from the Start menu</value>
</data>
<data name="hide_app_descriptions" xml:space="preserve">
<value>Hide app description in search results</value>
</data>
<data name="hide_app_descriptions_description" xml:space="preserve">
<value>Hide description text next to app results for a cleaner look</value>
</data>
</root>

View File

@@ -0,0 +1,119 @@
// Copyright (c) Microsoft Corporation
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
using System.Globalization;
using System.Linq;
using System.Text;
using System.Text.Json.Nodes;
using Microsoft.CmdPal.Ext.Bookmarks.Helpers;
using Microsoft.CmdPal.Ext.Bookmarks.Persistence;
using Microsoft.CmdPal.Ext.Bookmarks.Services;
namespace Microsoft.CmdPal.Ext.Bookmarks.Pages;
internal sealed partial class BookmarkPlaceholderForm : FormContent
{
private static readonly CompositeFormat ErrorMessage = CompositeFormat.Parse(Resources.bookmarks_required_placeholder);
private readonly BookmarkData _bookmarkData;
private readonly IBookmarkResolver _commandResolver;
public BookmarkPlaceholderForm(BookmarkData data, IBookmarkResolver commandResolver, IPlaceholderParser placeholderParser)
{
ArgumentNullException.ThrowIfNull(data);
ArgumentNullException.ThrowIfNull(commandResolver);
_bookmarkData = data;
_commandResolver = commandResolver;
placeholderParser.ParsePlaceholders(data.Bookmark, out _, out var placeholders);
var inputs = placeholders.Distinct(PlaceholderInfoNameEqualityComparer.Instance).Select(placeholder =>
{
var errorMessage = string.Format(CultureInfo.CurrentCulture, ErrorMessage, placeholder.Name);
return $$"""
{
"type": "Input.Text",
"style": "text",
"id": "{{placeholder.Name}}",
"label": "{{placeholder.Name}}",
"isRequired": true,
"errorMessage": "{{errorMessage}}"
}
""";
}).ToList();
var allInputs = string.Join(",", inputs);
TemplateJson = $$"""
{
"$schema": "http://adaptivecards.io/schemas/adaptive-card.json",
"type": "AdaptiveCard",
"version": "1.5",
"body": [
{
"type": "TextBlock",
"size": "Medium",
"weight": "Bolder",
"text": "{{_bookmarkData.Name}}"
},
{{allInputs}}
],
"actions": [
{
"type": "Action.Submit",
"title": "{{Resources.bookmarks_form_open}}",
"data": {
"placeholder": "placeholder"
}
}
]
}
""";
}
public override CommandResult SubmitForm(string payload)
{
// parse the submitted JSON and then open the link
var formInput = JsonNode.Parse(payload);
var formObject = formInput?.AsObject();
if (formObject is null)
{
return CommandResult.GoHome();
}
// we need to classify this twice:
// first we need to know if the original bookmark is a URL or protocol, because that determines how we encode the placeholders
// then we need to classify the final target to be sure the classification didn't change by adding the placeholders
var placeholderClassification = _commandResolver.ClassifyOrUnknown(_bookmarkData.Bookmark);
var placeholders = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
foreach (var (key, value) in formObject)
{
var placeholderData = value?.ToString();
placeholders[key] = placeholderData ?? string.Empty;
}
var target = ReplacePlaceholders(_bookmarkData.Bookmark, placeholders, placeholderClassification);
var classification = _commandResolver.ClassifyOrUnknown(target);
var success = CommandLauncher.Launch(classification);
return success ? CommandResult.Dismiss() : CommandResult.KeepOpen();
}
private static string ReplacePlaceholders(string input, Dictionary<string, string> placeholders, Classification classification)
{
var result = input;
foreach (var (key, value) in placeholders)
{
var placeholderString = $"{{{key}}}";
var encodedValue = value;
if (classification.Kind is CommandKind.Protocol or CommandKind.WebUrl)
{
encodedValue = Uri.EscapeDataString(value);
}
result = result.Replace(placeholderString, encodedValue, StringComparison.OrdinalIgnoreCase);
}
return result;
}
}

View File

@@ -10,48 +10,24 @@ using Microsoft.CommandPalette.Extensions;
namespace Microsoft.CmdPal.Ext.Bookmarks.Pages;
internal sealed partial class BookmarkPlaceholderPage : ParametersPage, IDisposable
internal sealed partial class BookmarkPlaceholderPage : ContentPage, IDisposable
{
private readonly BookmarkData _bookmarkData;
private readonly IBookmarkResolver _resolver;
private readonly Classification _bookmarkClassification;
private readonly IParameterRun[] _parameters;
private readonly Dictionary<string, StringParameterRun> _placeholderRuns;
private readonly ListItem _commandItem;
private readonly FormContent _bookmarkPlaceholder;
private readonly SupersedingAsyncValueGate<IIconInfo?> _iconReloadGate;
public BookmarkPlaceholderPage(BookmarkData bookmarkData, IBookmarkIconLocator iconLocator, IBookmarkResolver resolver, IPlaceholderParser placeholderParser)
{
ArgumentNullException.ThrowIfNull(bookmarkData);
ArgumentNullException.ThrowIfNull(resolver);
ArgumentNullException.ThrowIfNull(placeholderParser);
_bookmarkData = bookmarkData;
_resolver = resolver;
// Cache the original bookmark's classification — it doesn't depend on
// placeholder values, and we need it on every keystroke to know how to
// encode the preview/launched URL.
_bookmarkClassification = resolver.ClassifyOrUnknown(bookmarkData.Bookmark);
Name = Resources.bookmarks_command_name_open;
Id = CommandIds.GetLaunchBookmarkItemId(bookmarkData.Id);
placeholderParser.ParsePlaceholders(bookmarkData.Bookmark, out _, out var placeholders);
(_parameters, _placeholderRuns) = BuildParameterRuns(bookmarkData.Bookmark, placeholders);
var submitCommand = new LaunchPlaceholderCommand(this);
_commandItem = new ListItem(submitCommand);
foreach (var run in _placeholderRuns.Values)
{
run.PropChanged += OnPlaceholderChanged;
}
UpdateSubtitle();
_bookmarkPlaceholder = new BookmarkPlaceholderForm(bookmarkData, resolver, placeholderParser);
_iconReloadGate = new(
async ct => await iconLocator.GetIconForPath(_bookmarkClassification, ct),
async ct =>
{
var c = resolver.ClassifyOrUnknown(bookmarkData.Bookmark);
return await iconLocator.GetIconForPath(c, ct);
},
icon =>
{
Icon = icon as IconInfo ?? Icons.PinIcon;
@@ -59,115 +35,7 @@ internal sealed partial class BookmarkPlaceholderPage : ParametersPage, IDisposa
RequestIconReloadAsync();
}
public override IParameterRun[] Parameters => _parameters;
public override IListItem Command => _commandItem;
private static (IParameterRun[] Runs, Dictionary<string, StringParameterRun> RunsByName) BuildParameterRuns(string bookmark, List<PlaceholderInfo> placeholders)
{
var runs = new List<IParameterRun>();
var byName = new Dictionary<string, StringParameterRun>(StringComparer.OrdinalIgnoreCase);
var cursor = 0;
// PlaceholderParser emits placeholders in source order, but be defensive
// in case that ever changes — slicing relies on monotonic indices.
placeholders.Sort((a, b) => a.Index.CompareTo(b.Index));
foreach (var placeholder in placeholders)
{
if (placeholder.Index > cursor)
{
runs.Add(new LabelRun(bookmark.Substring(cursor, placeholder.Index - cursor)));
}
if (!byName.TryGetValue(placeholder.Name, out var run))
{
run = new StringParameterRun
{
PlaceholderText = placeholder.Name,
};
byName[placeholder.Name] = run;
}
runs.Add(run);
// Advance past "{Name}" — name length plus the two braces.
cursor = placeholder.Index + placeholder.Name.Length + 2;
}
if (cursor < bookmark.Length)
{
runs.Add(new LabelRun(bookmark.Substring(cursor)));
}
return (runs.ToArray(), byName);
}
private CommandResult LaunchWithCurrentValues()
{
var target = BuildEvaluatedBookmark();
// Re-classify the final target — adding placeholder values may change
// what kind of command this is (e.g. a path that needs different launch).
var classification = _resolver.ClassifyOrUnknown(target);
var success = CommandLauncher.Launch(classification);
return success ? CommandResult.Dismiss() : CommandResult.KeepOpen();
}
private string BuildEvaluatedBookmark()
{
var values = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
foreach (var (name, run) in _placeholderRuns)
{
values[name] = run.Text ?? string.Empty;
}
return ReplacePlaceholders(_bookmarkData.Bookmark, values, _bookmarkClassification);
}
private bool AllPlaceholdersHaveValues()
{
foreach (var run in _placeholderRuns.Values)
{
if (string.IsNullOrEmpty(run.Text))
{
return false;
}
}
return true;
}
private void OnPlaceholderChanged(object sender, IPropChangedEventArgs args)
{
if (args.PropertyName == nameof(StringParameterRun.Text))
{
UpdateSubtitle();
}
}
private void UpdateSubtitle()
{
_commandItem.Subtitle = AllPlaceholdersHaveValues() ? BuildEvaluatedBookmark() : string.Empty;
}
private static string ReplacePlaceholders(string input, Dictionary<string, string> placeholders, Classification classification)
{
var result = input;
foreach (var (key, value) in placeholders)
{
var placeholderString = $"{{{key}}}";
var encodedValue = value;
if (classification.Kind is CommandKind.Protocol or CommandKind.WebUrl)
{
encodedValue = Uri.EscapeDataString(value);
}
result = result.Replace(placeholderString, encodedValue, StringComparison.OrdinalIgnoreCase);
}
return result;
}
public override IContent[] GetContent() => [_bookmarkPlaceholder];
private void RequestIconReloadAsync()
{
@@ -176,27 +44,5 @@ internal sealed partial class BookmarkPlaceholderPage : ParametersPage, IDisposa
_ = _iconReloadGate.ExecuteAsync();
}
public void Dispose()
{
foreach (var run in _placeholderRuns.Values)
{
run.PropChanged -= OnPlaceholderChanged;
}
_iconReloadGate.Dispose();
}
private sealed partial class LaunchPlaceholderCommand : InvokableCommand
{
private readonly BookmarkPlaceholderPage _page;
public LaunchPlaceholderCommand(BookmarkPlaceholderPage page)
{
_page = page;
Name = Resources.bookmarks_form_open;
Icon = Icons.PinIcon;
}
public override ICommandResult Invoke() => _page.LaunchWithCurrentValues();
}
public void Dispose() => _iconReloadGate.Dispose();
}

View File

@@ -51,13 +51,11 @@ public static partial class CalculateEngine
return default;
}
// ExprTK uses log == ln and log10 for base-10, so we remap here
// to match the user-facing names (log → log10, ln log).
// Use regex replacements so optional whitespace between the function name and
// '(' is handled correctly - "log (100)" must map to log10 just like "log(100)"
// does. The negative lookahead prevents "log10" / "log2" from being touched.
input = LogRegex().Replace(input, "log10(");
input = LnRegex().Replace(input, "log(");
// mages has quirky log representation
// mage has log == ln vs log10
input = input.
Replace("log(", "log10(", true, CultureInfo.CurrentCulture).
Replace("ln(", "log(", true, CultureInfo.CurrentCulture);
input = CalculateHelper.FixHumanMultiplicationExpressions(input);
@@ -75,15 +73,9 @@ public static partial class CalculateEngine
var result = _calculator.EvaluateExpression(input);
// This could happen for some incorrect queries, like pi(2)
if (result == "ParseError")
{
error = Properties.Resources.calculator_expression_not_complete;
return default;
}
if (result == "NaN")
{
error = Properties.Resources.calculator_not_a_number;
error = Properties.Resources.calculator_expression_not_complete;
return default;
}
@@ -96,7 +88,6 @@ public static partial class CalculateEngine
if (string.IsNullOrEmpty(result))
{
error = Properties.Resources.calculator_not_a_number;
return default;
}
@@ -144,15 +135,6 @@ public static partial class CalculateEngine
return rounded / 1.000000000000000000000000000000000m;
}
[GeneratedRegex("\\/\\s*0(?!(?:[,\\.0-9]|[box]0*[1-9a-f]))", RegexOptions.IgnoreCase)]
[GeneratedRegex("\\/\\s*0(?!(?:[,\\.0-9]|[box]0*[1-9a-f]))", RegexOptions.IgnoreCase, "en-US")]
private static partial Regex DivisionByZeroRegex();
// Case-insensitive match for "log" not followed by a digit, then optional whitespace,
// then '('. The negative lookahead protects "log2" and "log10". A new log variant
// like "logb" must be handled explicitly.
[GeneratedRegex("log(?![0-9])\\s*\\(", RegexOptions.IgnoreCase)]
private static partial Regex LogRegex();
[GeneratedRegex("ln\\s*\\(", RegexOptions.IgnoreCase)]
private static partial Regex LnRegex();
}

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.
@@ -14,7 +14,7 @@ public static partial class CalculateHelper
@"^(" +
@"%|" +
@"ceil\s*\(|floor\s*\(|exp\s*\(|max\s*\(|min\s*\(|abs\s*\(|log(?:2|10)?\s*\(|ln\s*\(|sqrt\s*\(|pow\s*\(|" +
@"factorial\s*\(|sign\s*\(|round\s*\(|rand\s*\(\)|randi\s*\((?=[^\)])|" +
@"factorial\s*\(|sign\s*\(|round\s*\(|rand\s*\(\)|randi\s*\([^\)]|" +
@"sin\s*\(|cos\s*\(|tan\s*\(|arcsin\s*\(|arccos\s*\(|arctan\s*\(|" +
@"sinh\s*\(|cosh\s*\(|tanh\s*\(|arsinh\s*\(|arcosh\s*\(|artanh\s*\(|" +
@"rad\s*\(|deg\s*\(|grad\s*\(|" + /* trigonometry unit conversion macros */
@@ -385,7 +385,7 @@ public static partial class CalculateHelper
public static string UpdateFactorialFunctions(string input)
{
// Handle n! -> factorial(n)
var startSearch = 0;
int startSearch = 0;
while (true)
{
var index = input.IndexOf('!', startSearch);

View File

@@ -15,50 +15,6 @@ namespace Microsoft.CmdPal.Ext.Calc.Helper;
/// </summary>
public class NumberTranslator
{
private const string ProtectedListSeparatorToken = "\uE000";
/// <summary>
/// ExprTK does not expose a public API that lets us enumerate the built-in functions accepted by
/// this calculator together with their arity. Keep this table in sync with the functions allowed
/// by <see cref="CalculateHelper.InputValid"/> and extend the translator/query tests when adding
/// new functions.
/// </summary>
private static readonly Dictionary<string, int> SupportedFunctionArgumentCounts = new(StringComparer.OrdinalIgnoreCase)
{
{ "ceil", 1 },
{ "floor", 1 },
{ "exp", 1 },
{ "max", 2 },
{ "min", 2 },
{ "abs", 1 },
{ "log", 1 },
{ "log2", 1 },
{ "log10", 1 },
{ "ln", 1 },
{ "sqrt", 1 },
{ "pow", 2 },
{ "factorial", 1 },
{ "sign", 1 },
{ "round", 1 },
{ "rand", 0 },
{ "randi", 1 },
{ "sin", 1 },
{ "cos", 1 },
{ "tan", 1 },
{ "arcsin", 1 },
{ "arccos", 1 },
{ "arctan", 1 },
{ "sinh", 1 },
{ "cosh", 1 },
{ "tanh", 1 },
{ "arsinh", 1 },
{ "arcosh", 1 },
{ "artanh", 1 },
{ "rad", 1 },
{ "deg", 1 },
{ "grad", 1 },
};
private readonly CultureInfo sourceCulture;
private readonly CultureInfo targetCulture;
private readonly Regex splitRegexForSource;
@@ -138,23 +94,13 @@ public class NumberTranslator
private static string Translate(string input, CultureInfo cultureFrom, CultureInfo cultureTo, Regex splitRegex)
{
var protectFunctionArgumentSeparators = cultureFrom.NumberFormat.NumberGroupSeparator == cultureFrom.TextInfo.ListSeparator;
// In cultures such as en-US, ',' can mean either digit grouping or a function argument
// separator. Preserve separators only inside the supported multi-argument functions before
// the regex-based number pass so expressions like max(123,456) are not collapsed to 123456,
// while single-argument calls such as ceil(123,456.23) still keep their grouped number.
var workingInput = protectFunctionArgumentSeparators
? ProtectFunctionArgumentSeparators(input, cultureFrom.TextInfo.ListSeparator, ProtectedListSeparatorToken)
: input;
var outputBuilder = new StringBuilder();
// Match numbers in hexadecimal (0x..), binary (0b..), or octal (0o..) format,
// and convert them to decimal form for compatibility with ExprTk (which only supports decimal input).
var baseNumberRegex = new Regex(@"(0[xX][\da-fA-F]+|0[bB][0-9]+|0[oO][0-9]+)");
var tokens = baseNumberRegex.Split(workingInput);
var tokens = baseNumberRegex.Split(input);
foreach (var token in tokens)
{
@@ -192,125 +138,26 @@ public class NumberTranslator
outputBuilder.Append(
decimal.TryParse(inner, NumberStyles.Number, cultureFrom, out number)
? ((inner.Contains(cultureFrom.NumberFormat.NumberDecimalSeparator, StringComparison.Ordinal) ? string.Empty : new string('0', leadingZeroCount)) + number.ToString(cultureTo))
? (new string('0', leadingZeroCount) + number.ToString(cultureTo))
: inner.Replace(cultureFrom.TextInfo.ListSeparator, cultureTo.TextInfo.ListSeparator));
}
}
var translated = outputBuilder.ToString();
// Restore protected argument separators after numeric translation has finished.
return protectFunctionArgumentSeparators
? translated.Replace(ProtectedListSeparatorToken, cultureTo.TextInfo.ListSeparator, StringComparison.Ordinal)
: translated;
}
private static string ProtectFunctionArgumentSeparators(string input, string listSeparator, string placeholder)
{
if (string.IsNullOrEmpty(listSeparator))
{
return input;
}
var outputBuilder = new StringBuilder();
var parenthesisProtection = new Stack<bool>();
for (var i = 0; i < input.Length; i++)
{
if (parenthesisProtection.Count > 0 && parenthesisProtection.Peek() && MatchesAt(input, listSeparator, i))
{
// Protect separators only for the current multi-argument function call. Nested
// single-argument functions such as max(ceil(123,456.23), 2) must still be able to
// treat ',' as a digit-group separator inside their own argument.
outputBuilder.Append(placeholder);
i += listSeparator.Length - 1;
continue;
}
var current = input[i];
outputBuilder.Append(current);
if (current == '(')
{
parenthesisProtection.Push(ShouldProtectFunctionArgumentSeparators(input, i));
}
else if (current == ')' && parenthesisProtection.Count > 0)
{
parenthesisProtection.Pop();
}
}
return outputBuilder.ToString();
}
private static bool ShouldProtectFunctionArgumentSeparators(string input, int openParenIndex)
{
var end = openParenIndex - 1;
// Allow whitespace between a function name and its opening parenthesis.
while (end >= 0 && char.IsWhiteSpace(input[end]))
{
end--;
}
if (end < 0 || !char.IsLetterOrDigit(input[end]))
{
return false;
}
var start = end;
while (start >= 0 && char.IsLetterOrDigit(input[start]))
{
start--;
}
start++;
// Treat identifier-like text such as max ( as a function call, but avoid marking plain
// grouping parentheses like (1 + 2) as function syntax. Only supported functions with more
// than one argument need protection; single-argument functions must still allow grouping
// separators inside their numeric inputs.
if (start > end || !char.IsLetter(input[start]))
{
return false;
}
var functionName = input.Substring(start, end - start + 1);
return SupportedFunctionArgumentCounts.TryGetValue(functionName, out var argumentCount) && argumentCount > 1;
}
private static bool MatchesAt(string input, string value, int index)
{
return index + value.Length <= input.Length &&
string.Compare(input, index, value, 0, value.Length, StringComparison.Ordinal) == 0;
}
private static Regex GetSplitRegex(CultureInfo culture)
{
var listSeparator = culture.TextInfo.ListSeparator;
var groupSeparator = culture.NumberFormat.NumberGroupSeparator;
var hasAmbiguousNumericSeparators = groupSeparator == listSeparator;
// Some cultures use a non-breaking space for digit grouping, but users may type a
// normal space instead. Expand the group separator to allow for either character.
// if the group separator is a no-break space, we also add a normal space to the regex
if (groupSeparator == "\u00a0")
{
groupSeparator = "\u0020\u00a0";
}
var decimalSeparator = Regex.Escape(culture.NumberFormat.NumberDecimalSeparator);
// Strictly match only culture-valid numbers when the group separator is also the
// function argument separator. In cultures like en-US, a looser pattern would
// swallow max(1,2) as if "1,2" were a single number instead of two arguments.
var strictNumberTokenPattern =
$@"((?:\d{{1,3}}(?:[{Regex.Escape(groupSeparator)}]\d{{3}})+|\d+)(?:{decimalSeparator}\d+)?|{decimalSeparator}\d+)";
// Preserve the legacy looser matching in cultures where numeric grouping cannot be
// confused with function argument separators. This keeps existing behavior for cases
// like de-DE, where '.' is a group separator but ';' separates function arguments.
var looseNumberTokenPattern = $"([0-9{decimalSeparator}{Regex.Escape(groupSeparator)}]+)";
return new Regex(hasAmbiguousNumericSeparators ? strictNumberTokenPattern : looseNumberTokenPattern);
var splitPattern = $"([0-9{Regex.Escape(culture.NumberFormat.NumberDecimalSeparator)}" +
$"{Regex.Escape(groupSeparator)}]+)";
return new Regex(splitPattern);
}
}

View File

@@ -1,61 +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 Windows.Win32;
using Windows.Win32.System.Power;
namespace CoreWidgetProvider.Helpers;
internal sealed partial class BatteryStats
{
// GetSystemPowerStatus returns 255 (0xFF) for percent / time when unknown.
private const byte BatteryPercentUnknown = 255;
private const uint BatteryLifeTimeUnknown = 0xFFFFFFFF;
// BatteryFlag bits.
private const byte BatteryFlagCharging = 0x08;
private const byte BatteryFlagNoBattery = 0x80;
private const byte BatteryFlagUnknown = 0xFF;
public bool HasBattery { get; set; }
public bool IsCharging { get; set; }
public bool IsOnAcPower { get; set; }
/// <summary>
/// Charge level in [0, 1], or -1 when unknown.
/// </summary>
public float ChargePercent { get; set; } = -1f;
/// <summary>
/// Estimated seconds of battery life remaining, or -1 when unknown / charging / on AC.
/// </summary>
public int SecondsRemaining { get; set; } = -1;
public void GetData()
{
if (!PInvoke.GetSystemPowerStatus(out SYSTEM_POWER_STATUS status))
{
HasBattery = false;
IsCharging = false;
IsOnAcPower = false;
ChargePercent = -1f;
SecondsRemaining = -1;
return;
}
HasBattery = status.BatteryFlag != BatteryFlagUnknown && (status.BatteryFlag & BatteryFlagNoBattery) == 0;
IsCharging = HasBattery && (status.BatteryFlag & BatteryFlagCharging) != 0;
IsOnAcPower = status.ACLineStatus == 1;
ChargePercent = status.BatteryLifePercent != BatteryPercentUnknown
? status.BatteryLifePercent / 100f
: -1f;
SecondsRemaining = status.BatteryLifeTime != BatteryLifeTimeUnknown
? (int)status.BatteryLifeTime
: -1;
}
}

View File

@@ -46,11 +46,7 @@ internal sealed partial class CPUStats : PerformanceCounterSourceBase, IDisposab
new ProcessStats()
];
// Use "% Processor Time" instead of "% Processor Utility": the latter is unbounded above 100%
// when cores boost above their nominal base frequency (it is scaled by % Processor Performance),
// which produced values like 144% in the dock under heavy load. % Processor Time is the same
// counter Task Manager renders and is naturally bounded to 0-100%. See issue #46381.
_procPerf = CreatePerformanceCounter("Processor Information", "% Processor Time", "_Total");
_procPerf = CreatePerformanceCounter("Processor Information", "% Processor Utility", "_Total");
_procPerformance = CreatePerformanceCounter("Processor Information", "% Processor Performance", "_Total");
_procFrequency = CreatePerformanceCounter("Processor Information", "Processor Frequency", "_Total");
}

View File

@@ -61,14 +61,6 @@ internal sealed partial class DataManager : IDisposable
}
}
private void GetBatteryData()
{
lock (_systemData.BatteryStats)
{
_systemData.BatteryStats.GetData();
}
}
private void UpdateTimer_Elapsed(object? sender, System.Timers.ElapsedEventArgs e)
{
var firstUpdateBlockSuffix = GetFirstUpdateBlockSuffix();
@@ -106,12 +98,6 @@ internal sealed partial class DataManager : IDisposable
GetNetworkData();
break;
}
case DataType.Battery:
{
GetBatteryData();
break;
}
}
if (isTracked)
@@ -146,7 +132,6 @@ internal sealed partial class DataManager : IDisposable
DataType.GPU => "GPU.FirstUpdate",
DataType.Memory => "Memory.FirstUpdate",
DataType.Network => "Network.FirstUpdate",
DataType.Battery => "Battery.FirstUpdate",
_ => null,
};
}
@@ -183,14 +168,6 @@ internal sealed partial class DataManager : IDisposable
}
}
internal BatteryStats GetBatteryStats()
{
lock (_systemData.BatteryStats)
{
return _systemData.BatteryStats;
}
}
public void Start()
{
_updateTimer.Start();

View File

@@ -32,9 +32,4 @@ public enum DataType
/// Network related data.
/// </summary>
Network,
/// <summary>
/// Battery related data.
/// </summary>
Battery,
}

View File

@@ -15,7 +15,6 @@ internal sealed partial class SystemData
private readonly Lazy<NetworkStats> _networkStats = new(() => CreateGuarded("Network.Initialize", static () => new NetworkStats()));
private readonly Lazy<GPUStats> _gpuStats = new(() => CreateGuarded("GPU.Initialize", static () => new GPUStats()));
private readonly Lazy<CPUStats> _cpuStats = new(() => CreateGuarded("CPU.Initialize", static () => new CPUStats()));
private readonly Lazy<BatteryStats> _batteryStats = new(() => CreateGuarded("Battery.Initialize", static () => new BatteryStats()));
public MemoryStats MemoryStats => _memoryStats.Value;
@@ -25,8 +24,6 @@ internal sealed partial class SystemData
public CPUStats CpuStats => _cpuStats.Value;
public BatteryStats BatteryStats => _batteryStats.Value;
private SystemData()
{
}

View File

@@ -1,90 +0,0 @@
{
"type": "AdaptiveCard",
"body": [
{
"type": "Container",
"$when": "${errorMessage != null}",
"items": [
{
"type": "TextBlock",
"text": "${errorMessage}",
"wrap": true,
"size": "small"
}
],
"style": "warning"
},
{
"type": "Container",
"$when": "${errorMessage == null}",
"items": [
{
"type": "ColumnSet",
"columns": [
{
"type": "Column",
"items": [
{
"text": "%Battery_Widget_Template/Charge%",
"type": "TextBlock",
"size": "small",
"isSubtle": true
},
{
"text": "${batteryCharge}",
"type": "TextBlock",
"size": "${if($host.widgetSize == \"small\", \"medium\", \"extraLarge\")}",
"weight": "bolder"
}
]
},
{
"type": "Column",
"items": [
{
"text": "%Battery_Widget_Template/Status%",
"type": "TextBlock",
"size": "small",
"isSubtle": true,
"horizontalAlignment": "right"
},
{
"text": "${batteryStatus}",
"type": "TextBlock",
"size": "${if($host.widgetSize == \"small\", \"medium\", \"large\")}",
"weight": "bolder",
"horizontalAlignment": "right"
}
]
}
]
},
{
"type": "ColumnSet",
"$when": "${$host.widgetSize != \"small\"}",
"columns": [
{
"type": "Column",
"items": [
{
"text": "%Battery_Widget_Template/TimeRemaining%",
"type": "TextBlock",
"size": "small",
"isSubtle": true
},
{
"text": "${batteryTimeRemaining}",
"type": "TextBlock",
"size": "medium",
"wrap": true
}
]
}
]
}
]
}
],
"$schema": "http://adaptivecards.io/schemas/adaptive-card.json",
"version": "1.5"
}

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 Microsoft.CommandPalette.Extensions.Toolkit;
namespace Microsoft.CmdPal.Ext.PerformanceMonitor;
@@ -25,40 +24,6 @@ internal static class Icons
internal static IconInfo GpuIcon => new("\uE950"); // Component icon
internal static IconInfo BatteryIcon => BatteryIcons[10]; // MobBattery10 (page identity)
// Pre-built cache so the 1 Hz dock-band update reuses IconInfo instances
// instead of allocating one per tick. 11 discharging (MobBattery0..10), 11
// charging (MobBatteryCharging0..10), 1 unknown (MobBatteryUnknown).
private static readonly IconInfo[] BatteryIcons = BuildBatteryGlyphs(0xEBA0);
private static readonly IconInfo[] BatteryChargingIcons = BuildBatteryGlyphs(0xEBAB);
private static readonly IconInfo BatteryUnknownIcon = new("\uEC02");
private static IconInfo[] BuildBatteryGlyphs(int baseCodepoint)
{
var icons = new IconInfo[11];
for (var i = 0; i <= 10; i++)
{
icons[i] = new IconInfo(char.ConvertFromUtf32(baseCodepoint + i));
}
return icons;
}
// Returns a MobBattery glyph that reflects the actual charge level so the dock-band icon
// is never misread as "100%". Range maps 0-100% to MobBattery0..MobBattery10 (\uEBA0..\uEBAA);
// charging swaps to MobBatteryCharging0..10 (\uEBAB..\uEBB5); unknown/no battery uses MobBatteryUnknown.
internal static IconInfo BatteryGlyph(double percent01, bool isCharging, bool hasBattery)
{
if (!hasBattery || percent01 < 0)
{
return BatteryUnknownIcon;
}
var level = (int)Math.Round(Math.Clamp(percent01, 0, 1) * 10);
return isCharging ? BatteryChargingIcons[level] : BatteryIcons[level];
}
internal static IconInfo NavigateBackwardIcon => new("\uE72B"); // Previous icon
internal static IconInfo NavigateForwardIcon => new("\uE72A"); // Next icon

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