Compare commits

..

3 Commits

132 changed files with 974 additions and 4161 deletions

View File

@@ -2278,7 +2278,6 @@ THEMECHANGED
thickframe
Tianma
tmain
tontrager
tskill
tweakable
UBreak

View File

@@ -1,279 +0,0 @@
---
name: LabelIssues
description: 'Labels GitHub issues and pull requests with Product-* labels based on issue template fields, linked issues, changed files, and content analysis. Accepts natural-language filters like "5 days", "my issues", "Needs-Triage issues", or "unlabeled PRs".'
tools: ['execute', 'read', 'github/*']
argument-hint: 'Description of issues/PRs to label (e.g., "5 days", "my issues", "unlabeled PRs this month", "#12345")'
infer: true
---
# LabelIssues Agent
You are an **issue and PR triage agent** that applies `Product-*` labels to GitHub issues and pull requests in the PowerToys repository.
## Goal
Given a user description of which issues or PRs to process, find matching items that are **missing `Product-*` labels**, determine the correct product label(s), and apply them — with appropriate confidence gating.
## Workflow
### Step 1 — Parse the user's request into a search query
Interpret the user's natural-language input and build a `gh` search query. Determine whether the user wants to process **issues**, **PRs**, or **both**.
| User says | Interpreted as |
|-----------|---------------|
| `5 days` | Issues created in the last 5 days |
| `my issues` | Issues assigned to the authenticated user |
| `Needs-Triage` or `needs triage` | Issues with the `Needs-Triage` label |
| `#12345` or `12345` | A single specific issue or PR |
| `open issues this week` | Open issues created in the last 7 days |
| `closed bugs last month` | Closed issues with `Issue-Bug` label from last month |
| `unlabeled PRs` or `PRs this week` | PRs without Product-* labels |
| `unlabeled PRs and issues` | Both PRs and issues without Product-* labels |
**Always add these implicit filters:**
- Exclude items that already have any `Product-*` label
- For issues: exclude pull requests; for PRs: only pull requests
**Echo back** the parsed query to the user before executing:
```
Searching for: [state:open created:>2026-05-06 -label:"Product-*"]
```
### Step 2 — Fetch matching issues and/or PRs
Use `gh` CLI to fetch items. Example commands:
```bash
# Recent issues (last N days)
gh issue list --repo microsoft/PowerToys --state open --json number,title,body,labels --limit 100
# PRs without product labels
gh pr list --repo microsoft/PowerToys --state open --json number,title,body,labels --limit 100
# Single issue or PR
gh issue view 12345 --repo microsoft/PowerToys --json number,title,body,labels
gh pr view 12345 --repo microsoft/PowerToys --json number,title,body,labels,closingIssuesReferences,files
```
Filter out items that already have a `Product-*` label in post-processing.
Report: `Found N issues and M PRs without Product-* labels.`
If more than 50 items match, warn the user and ask whether to proceed or narrow the scope.
### Step 2.5 — Dynamically discover labels and template fields
**Do this once at the start of every run** so the mapping is always current:
1. **Fetch all `Product-*` labels from the repo:**
```bash
gh label list --repo microsoft/PowerToys --search "Product-" --json name --limit 200 --jq '.[].name'
```
Store these as the set of **valid labels**.
2. **Fetch the current bug report template dropdown values:**
```bash
gh api repos/microsoft/PowerToys/contents/.github/ISSUE_TEMPLATE/bug_report.yml --jq '.content' | base64 -d
```
Parse the YAML to extract the `options` list under the "Area(s) with issue?" dropdown field. These are the **template values**.
3. **Build the live mapping** by matching each template value to a `Product-*` label:
- First, check the **override mapping** in `.github/agents/references/product-label-mapping.md` — this file ONLY contains non-obvious name mismatches (e.g., `Keyboard Manager` → `Product-Keyboard Shortcut Manager`)
- Then, try direct match: prepend `Product-` to the template value and check if it exists in the valid labels set
- If neither matches, the template value has no mapping (treat as needing content analysis)
This approach ensures new modules and labels are picked up automatically — the only maintenance needed is when a template dropdown value has a **different name** from its `Product-*` label.
### Step 3 — Determine product labels
#### For Issues
Use the following methods in order:
##### Method A: Deterministic mapping (HIGH confidence)
Parse the issue body for the structured **"Area(s) with issue?"** field from the bug report template. The field appears in the rendered markdown as:
```
### Area(s) with issue?
Command Palette, FancyZones
```
Extract the text between `### Area(s) with issue?` and the next `###` heading (or end of body). Split by commas. Map each value using the **live mapping built in Step 2.5**.
If all selected areas map to known labels → **HIGH confidence**.
##### Method B: Content analysis (variable confidence)
When Method A produces no result (e.g., feature requests without the area field, or free-form issues), analyze the issue title and body yourself to infer the product.
Use the **valid labels list from Step 2.5** as the universe of possible labels — never invent a label that doesn't exist.
Optionally consult the keyword hints in `.github/agents/references/product-label-mapping.md` for guidance on ambiguous terms.
#### For Pull Requests
Use the following methods in priority order. Stop as soon as you get a HIGH confidence result:
##### Method C: Linked issues (HIGH confidence)
Fetch linked issues using:
```bash
gh pr view <number> --repo microsoft/PowerToys --json closingIssuesReferences --jq '.closingIssuesReferences[].number'
```
This returns issues linked via `Fixes #X`, `Closes #X`, or `Resolves #X` keywords in the PR body (including the `- [ ] Closes: #xxx` checklist item from the PR template).
If linked issues are found:
1. Fetch each linked issue's labels
2. Copy any `Product-*` labels from the linked issues → **HIGH confidence**
If linked issues exist but none have `Product-*` labels, apply the issue labeling methods (A/B) to those linked issues first, then copy the result.
##### Method D: Parse body for issue references (MEDIUM → HIGH confidence)
If `closingIssuesReferences` is empty, scan the PR body for `#NNNN` patterns that might reference issues (not other PRs). Fetch those issues and check for `Product-*` labels.
##### Method E: Changed file paths (HIGH confidence)
If no linked issues are found, fetch the PR's changed files:
```bash
gh pr view <number> --repo microsoft/PowerToys --json files --jq '[.files[].path]'
```
Map file paths to products using the `src/modules/` directory structure:
| Path pattern | Product Label |
|-------------|---------------|
| `src/modules/AdvancedPaste/` | `Product-Advanced Paste` |
| `src/modules/alwaysontop/` | `Product-Always On Top` |
| `src/modules/awake/` | `Product-Awake` |
| `src/modules/cmdNotFound/` | `Product-CommandNotFound` |
| `src/modules/cmdpal/` | `Product-Command Palette` |
| `src/modules/colorPicker/` | `Product-Color Picker` |
| `src/modules/CropAndLock/` | `Product-CropAndLock` |
| `src/modules/EnvironmentVariables/` | `Product-Environment Variables` |
| `src/modules/fancyzones/` | `Product-FancyZones` |
| `src/modules/FileLocksmith/` | `Product-File Locksmith` |
| `src/modules/GrabAndMove/` | `Product-Grab And Move` |
| `src/modules/Hosts/` | `Product-Hosts File Editor` |
| `src/modules/imageresizer/` | `Product-Image Resizer` |
| `src/modules/keyboardmanager/` | `Product-Keyboard Shortcut Manager` |
| `src/modules/launcher/` | `Product-PowerToys Run` |
| `src/modules/LightSwitch/` | `Product-LightSwitch` |
| `src/modules/MeasureTool/` | `Product-Screen Ruler` |
| `src/modules/MouseUtils/` | `Product-Mouse Utilities` |
| `src/modules/MouseWithoutBorders/` | `Product-Mouse Without Borders` |
| `src/modules/NewPlus/` | `Product-New+` |
| `src/modules/peek/` | `Product-Peek` |
| `src/modules/poweraccent/` | `Product-Quick Accent` |
| `src/modules/powerdisplay/` | `Product-PowerDisplay` |
| `src/modules/PowerOCR/` | `Product-Text Extractor` |
| `src/modules/powerrename/` | `Product-PowerRename` |
| `src/modules/previewpane/` | `Product-File Explorer` |
| `src/modules/registrypreview/` | `Product-Registry Preview` |
| `src/modules/ShortcutGuide/` | `Product-Shortcut Guide` |
| `src/modules/Workspaces/` | `Product-Workspaces` |
| `src/modules/ZoomIt/` | `Product-ZoomIt` |
Also check `src/settings-ui/` paths — these often contain the product name (e.g., `ZoomItPage.xaml` → `Product-ZoomIt`, `ImageResizerPage.xaml` → `Product-Image Resizer`).
If **all** changed files map to a single product → **HIGH confidence**.
If changed files span exactly 2 products (one being Settings) → HIGH confidence for the non-Settings product.
If changed files span 3+ products → **LOW confidence**, present to user.
##### Method F: PR title/body content analysis (variable confidence)
As a final fallback, analyze the PR title and body. Many PRs use a `[ProductName]` prefix convention in the title (e.g., `[PowerDisplay] Fix brightness...`, `[ZoomIt] Remove stale...`). This is **HIGH confidence** if the bracketed name matches a known product.
Otherwise, apply the same content analysis rules as for issues.
#### Confidence Classification (applies to both issues and PRs)
**HIGH confidence** — assign automatically when:
- The issue has a deterministic template field match (Method A)
- A PR's linked issues have `Product-*` labels (Method C)
- All changed files in a PR map to one product (Method E)
- The PR title uses `[ProductName]` prefix matching a known product (Method F)
- The title/body explicitly and unambiguously names a single product
**LOW confidence** — present to user for approval when:
- Multiple products are mentioned and it's unclear which is primary
- The item is about cross-cutting infrastructure (installer, settings, system tray)
- The item is in a non-English language and you're unsure of the product
- The described feature/bug doesn't clearly map to any existing product
- Changed files span 3+ products
**NO LABEL** — skip entirely when:
- The item is too vague to determine any product
- The item is about the PowerToys project itself (meta discussions, CI/CD, docs, build infra)
- You have no meaningful signal from any method
### Step 4 — Apply labels and report results
**For HIGH confidence items:** Apply labels automatically using:
```bash
# For issues:
gh issue edit <number> --repo microsoft/PowerToys --add-label "<Product-Label>"
# For PRs (same command works):
gh pr edit <number> --repo microsoft/PowerToys --add-label "<Product-Label>"
```
**For LOW confidence items:** Do NOT apply labels. Instead, present them in a table:
```markdown
| # | Type | Title | Suggested Label | Method | Reason |
|---|------|-------|----------------|--------|--------|
| #123 | Issue | ... | Product-FancyZones | Content | Title mentions "zones" but also "settings" |
| #456 | PR | ... | Product-ZoomIt | Files | Changed files span ZoomIt and Settings |
```
Ask the user: *"Would you like me to apply any of these? Reply with the numbers to approve, or 'skip' to leave them."*
If the user approves specific items, apply those labels.
**For NO LABEL items:** List them briefly:
```
Skipped (insufficient signal): #456 (issue), #789 (PR)
```
### Step 5 — Summary
After processing, always output a summary:
```
=== Label Results ===
Issues PRs Total
Auto-labeled: 12 5 17
Needs review: 3 1 4
Skipped: 2 0 2
Total: 17 6 23
```
## Safety Rules
1. **Never remove existing labels** — only add `Product-*` labels
2. **Never add labels to items that already have a `Product-*` label** — skip them
3. **Never add more than 2 `Product-*` labels** to a single item — if you'd infer 3+, mark as LOW confidence
4. **Always echo the search query** before fetching items
5. **Always ask for confirmation** when processing more than 50 items
6. **Prefer false negatives over false positives** — it's better to skip an item than to mislabel it
7. **For PRs, prefer linked-issue labels over content inference** — if a linked issue has a Product-* label, use that even if the PR title/files suggest something different
## Reference
Read the override mapping and keyword hints from: `.github/agents/references/product-label-mapping.md`
This file contains:
- **Override mappings** for template values whose names don't match their `Product-*` label (e.g., `Keyboard Manager` → `Product-Keyboard Shortcut Manager`)
- **Keyword hints** for content analysis when the structured field is absent
- **Non-product template values** that need special handling (Installer, System tray, Welcome window)
The file does NOT need to list every template value — most map directly by prepending `Product-`. Only non-obvious mismatches need entries. Labels and template values are discovered dynamically at runtime (Step 2.5).
## Prerequisites
- GitHub CLI (`gh`) must be installed and authenticated. Verify with `gh auth status`.
- The agent operates on the `microsoft/PowerToys` repository.

View File

@@ -1,106 +0,0 @@
# Product Label Mapping — Overrides & Hints
This file contains **only the non-obvious mappings** between the bug report template
"Area(s) with issue?" dropdown values and `Product-*` labels. Most template values
map directly by prepending `Product-` — only mismatches are listed here.
Labels and template values are discovered dynamically at runtime by the agent.
## Override Mappings (template value ≠ label name)
These template dropdown values have `Product-*` labels with **different names**:
| Template Dropdown Value | Product Label |
|------------------------|---------------|
| ColorPicker | `Product-Color Picker` |
| Command not found | `Product-CommandNotFound` |
| FancyZones Editor | `Product-FancyZones` |
| File Explorer: Preview Pane | `Product-File Explorer` |
| File Explorer: Thumbnail preview | `Product-File Explorer` |
| Hosts File Editor | `Product-Hosts File Editor` |
| Keyboard Manager | `Product-Keyboard Shortcut Manager` |
| Power Display | `Product-PowerDisplay` |
| TextExtractor | `Product-Text Extractor` |
| Screen ruler | `Product-Screen Ruler` |
## Non-Product Template Values
These template values do NOT map to a product label. Use content analysis instead:
| Template Value | Guidance |
|---------------|----------|
| Installer | Consider `Product-General` or infer from context |
| System tray interaction | Consider `Product-Settings` or `Product-General` |
| Welcome / PowerToys Tour window | Consider `Product-General` |
## Keyword Hints for Content Analysis
When the structured field is not available, use these keyword patterns to infer products:
| Keywords / Patterns | Suggested Label |
|--------------------|-----------------|
| CmdPal, cmdpal, command palette, dock | `Product-Command Palette` |
| zones, layout, snap, window arrangement | `Product-FancyZones` |
| grab, move, drag window | `Product-Grab And Move` |
| zoom, screen annotation, draw on screen | `Product-ZoomIt` |
| settings-ui, flyout, quick access, tray | `Product-Settings` |
| paste, clipboard, AI paste | `Product-Advanced Paste` |
| MWB, mouse without borders, cross-machine | `Product-Mouse Without Borders` |
| rename, regex, bulk rename | `Product-PowerRename` |
| peek, file preview, preview pane | `Product-Peek` |
| resize, image resizer, bulk resize | `Product-Image Resizer` |
| theme, dark mode, light switch | `Product-LightSwitch` |
| accent, diacritics, special characters | `Product-Quick Accent` |
| awake, keep awake, caffeine, screen on | `Product-Awake` |
| color picker, eyedropper, hex color | `Product-Color Picker` |
| hosts, hosts file, DNS | `Product-Hosts File Editor` |
| remap, key remap, shortcut remap | `Product-Keyboard Shortcut Manager` |
| mouse highlighter, click highlight | `Product-Mouse Highlighter` |
| mouse jump, teleport mouse | `Product-Mouse Jump` |
| find my mouse, locate cursor | `Product-Find My Mouse` |
| crosshairs, cursor crosshair | `Product-Mouse Pointer Crosshairs` |
| shortcut guide, keyboard overlay | `Product-Shortcut Guide` |
| OCR, text extractor, screen text | `Product-Text Extractor` |
| workspace, save layout, restore windows | `Product-Workspaces` |
| file locksmith, who is using, file lock | `Product-File Locksmith` |
| crop and lock, crop, thumbnail window | `Product-CropAndLock` |
| environment variable, env var, PATH | `Product-Environment Variables` |
| new+, file template, new file | `Product-New+` |
| registry, registry preview, .reg | `Product-Registry Preview` |
| screen ruler, measure, pixel ruler | `Product-Screen Ruler` |
| run, launcher, powertoys run, plugin | `Product-PowerToys Run` |
| command not found, winget, install suggestion | `Product-CommandNotFound` |
| brightness, monitor, display, DDC | `Product-PowerDisplay` |
| cursor wrap, edge wrap, multi-monitor cursor | `Product-Cursor Wrap` |
## PR Title Prefix Conventions
Many PRs use `[ProductName]` prefixes. Common variants:
| Title prefix | Product Label |
|-------------|---------------|
| `[CmdPal]` | `Product-Command Palette` |
| `[PowerDisplay]` | `Product-PowerDisplay` |
| `[ZoomIt]` | `Product-ZoomIt` |
| `[Image Resizer]` | `Product-Image Resizer` |
| `[GPO]` | `Product-General` |
| `[MWB]` | `Product-Mouse Without Borders` |
Most other prefixes match the label directly (e.g., `[FancyZones]``Product-FancyZones`).
## Source Directory → Label Mapping
Non-obvious `src/modules/` directory name mappings:
| Directory | Product Label |
|----------|---------------|
| `launcher/` | `Product-PowerToys Run` |
| `MeasureTool/` | `Product-Screen Ruler` |
| `poweraccent/` | `Product-Quick Accent` |
| `PowerOCR/` | `Product-Text Extractor` |
| `previewpane/` | `Product-File Explorer` |
| `interface/` | `Product-General` (runner/settings host) |
Most other directories match by prepending `Product-` to the directory name.
<!-- Valid Product-* labels are discovered dynamically at runtime via gh label list -->

View File

@@ -0,0 +1,232 @@
name: Scheduled Issue Product Labeling
on:
schedule:
- cron: "20 */6 * * *" # Every 6 hours at :20
workflow_dispatch: # Allow manual trigger
permissions:
models: read
issues: write
concurrency:
group: ${{ github.workflow }}
cancel-in-progress: true
jobs:
label-issues:
runs-on: ubuntu-latest
steps:
- name: Label issues missing Product labels
uses: actions/github-script@v7
with:
script: |
// ── Product label mapping ──────────────────────────────────
// Canonical list of Product-* labels used in this repo,
// derived from .github/skills/release-note-generation/references/step2-labeling.md
const PRODUCT_LABELS = [
"Product-Advanced Paste",
"Product-Always on Top",
"Product-Awake",
"Product-ColorPicker",
"Product-Command not found",
"Product-Command Palette",
"Product-CropAndLock",
"Product-Cursor Wrap",
"Product-Environment Variables",
"Product-FancyZones",
"Product-File Explorer",
"Product-File Locksmith",
"Product-Find My Mouse",
"Product-Hosts",
"Product-Image Resizer",
"Product-Keyboard Manager",
"Product-LightSwitch",
"Product-Mouse Highlighter",
"Product-Mouse Jump",
"Product-Mouse Pointer Crosshairs",
"Product-Mouse Without Borders",
"Product-New+",
"Product-Peek",
"Product-PowerRename",
"Product-PowerToys Run",
"Product-Quick Accent",
"Product-Registry Preview",
"Product-Screen Ruler",
"Product-Settings",
"Product-Shortcut Guide",
"Product-Text Extractor",
"Product-Workspaces",
"Product-ZoomIt",
];
// Map from bug-report "Area(s) with issue?" dropdown values
// to Product-* labels (used as strong hints when the issue body
// contains the area dropdown answer).
const AREA_TO_LABEL = {
"Advanced Paste": "Product-Advanced Paste",
"Always on Top": "Product-Always on Top",
"Awake": "Product-Awake",
"ColorPicker": "Product-ColorPicker",
"Command not found": "Product-Command not found",
"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",
"Image Resizer": "Product-Image Resizer",
"Keyboard Manager": "Product-Keyboard Manager",
"Light Switch": "Product-LightSwitch",
"Mouse Utilities": "Product-Find My Mouse",
"Mouse Without Borders": "Product-Mouse Without Borders",
"New+": "Product-New+",
"Peek": "Product-Peek",
"PowerRename": "Product-PowerRename",
"PowerToys Run": "Product-PowerToys Run",
"Quick Accent": "Product-Quick Accent",
"Registry Preview": "Product-Registry Preview",
"Screen ruler": "Product-Screen Ruler",
"Shortcut Guide": "Product-Shortcut Guide",
"TextExtractor": "Product-Text Extractor",
"Workspaces": "Product-Workspaces",
"ZoomIt": "Product-ZoomIt",
};
// ── Helpers ────────────────────────────────────────────────
function hasProductLabel(labels) {
return labels.some((l) => l.name.startsWith("Product-"));
}
// Try to extract the area from the structured bug-report body
// (the "Area(s) with issue?" dropdown).
function extractAreaFromBody(body) {
if (!body) return null;
// The rendered issue body contains a heading followed by the selected values
const areaMatch = body.match(
/### Area\(s\) with issue\?\s*\n+(.+?)(?:\n###|\n\n|$)/s
);
if (!areaMatch) return null;
const areaText = areaMatch[1].trim();
if (areaText === "_No response_" || areaText === "General") return null;
// Could be comma-separated; take the first specific one
const areas = areaText.split(",").map((a) => a.trim());
for (const area of areas) {
if (AREA_TO_LABEL[area]) return AREA_TO_LABEL[area];
}
return null;
}
// Use GitHub Models to classify an issue when the dropdown area
// is not available or is "General".
const MAX_BODY_LENGTH = 3000; // Truncate body to stay within model token limits while keeping enough context
const MAX_COMPLETION_TOKENS = 60; // Enough for a Product-* label name with some margin
async function classifyWithAI(title, body) {
const truncatedBody = (body || "").slice(0, MAX_BODY_LENGTH);
const labelList = PRODUCT_LABELS.join("\n- ");
const prompt = `You are a GitHub issue triager for the microsoft/PowerToys repository.
Given the issue title and body below, determine which ONE Product label best fits.
Reply with ONLY the label name (e.g. "Product-FancyZones") or "UNKNOWN" if you cannot determine it.
Available labels:
- ${labelList}
Issue title: ${title}
Issue body:
${truncatedBody}`;
try {
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-4o",
messages: [{ role: "user", content: prompt }],
max_tokens: MAX_COMPLETION_TOKENS,
temperature: 0,
}),
}
);
if (!response.ok) {
core.warning(`AI classification failed: ${response.status} ${response.statusText}`);
return null;
}
const data = await response.json();
const answer = data.choices?.[0]?.message?.content?.trim();
if (!answer || answer === "UNKNOWN") return null;
// Validate the answer is a known label
if (PRODUCT_LABELS.includes(answer)) return answer;
// Try fuzzy match (the model may include extra text)
const found = PRODUCT_LABELS.find((l) => answer.includes(l));
return found || null;
} catch (err) {
core.warning(`AI classification error: ${err.message}`);
return null;
}
}
// ── Main ───────────────────────────────────────────────────
const MAX_ISSUES = 50; // Process up to 50 issues per run
let labeled = 0;
let skipped = 0;
core.info("Searching for open issues with Needs-Triage but no Product-* label...");
// Paginate through open issues labeled Needs-Triage
for await (const response of github.paginate.iterator(
github.rest.issues.listForRepo,
{
owner: context.repo.owner,
repo: context.repo.repo,
state: "open",
labels: "Needs-Triage",
sort: "created",
direction: "desc",
per_page: 100,
}
)) {
for (const issue of response.data) {
if (labeled + skipped >= MAX_ISSUES) break;
// Skip pull requests (the API returns them too)
if (issue.pull_request) continue;
if (hasProductLabel(issue.labels)) continue;
core.info(`Processing #${issue.number}: ${issue.title}`);
// 1) Try structured area dropdown first (fast, no AI needed)
let label = extractAreaFromBody(issue.body);
// 2) Fall back to AI classification
if (!label) {
label = await classifyWithAI(issue.title, issue.body);
}
if (label) {
core.info(` → Applying "${label}" to #${issue.number}`);
await github.rest.issues.addLabels({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: issue.number,
labels: [label],
});
labeled++;
} else {
core.info(` → Could not determine product label for #${issue.number}, skipping.`);
skipped++;
}
}
if (labeled + skipped >= MAX_ISSUES) break;
}
core.info(`Done. Labeled: ${labeled}, Skipped: ${skipped}`);

View File

@@ -319,10 +319,6 @@
<Platform Solution="*|ARM64" Project="ARM64" />
<Platform Solution="*|x64" Project="x64" />
</Project>
<Project Path="src/modules/cmdpal/Tests/Microsoft.CmdPal.Ext.Indexer.UnitTests/Microsoft.CmdPal.Ext.Indexer.UnitTests.csproj">
<Platform Solution="*|ARM64" Project="ARM64" />
<Platform Solution="*|x64" Project="x64" />
</Project>
<Project Path="src/modules/cmdpal/Tests/Microsoft.CmdPal.Ext.Registry.UnitTests/Microsoft.CmdPal.Ext.Registry.UnitTests.csproj">
<Platform Solution="*|ARM64" Project="ARM64" />
<Platform Solution="*|x64" Project="x64" />

View File

@@ -29,13 +29,13 @@ PowerToys includes over 30 utilities to help you customize and optimize your Win
| [<img src="doc/images/icons/AdvancedPaste.png" alt="Advanced Paste icon" height="16"> Advanced Paste](https://aka.ms/PowerToysOverview_AdvancedPaste) | [<img src="doc/images/icons/Always%20On%20Top.png" alt="Always on Top icon" height="16"> Always on Top](https://aka.ms/PowerToysOverview_AoT) | [<img src="doc/images/icons/Awake.png" alt="Awake icon" height="16"> Awake](https://aka.ms/PowerToysOverview_Awake) |
| [<img src="doc/images/icons/Color%20Picker.png" alt="Color Picker icon" height="16"> Color Picker](https://aka.ms/PowerToysOverview_ColorPicker) | [<img src="doc/images/icons/Command%20Not%20Found.png" alt="Command Not Found icon" height="16"> Command Not Found](https://aka.ms/PowerToysOverview_CmdNotFound) | [<img src="doc/images/icons/Command Palette.png" alt="Command Palette icon" height="16"> Command Palette](https://aka.ms/PowerToysOverview_CmdPal) |
| [<img src="doc/images/icons/Crop%20And%20Lock.png" alt="Crop and Lock icon" height="16"> Crop And Lock](https://aka.ms/PowerToysOverview_CropAndLock) | [<img src="doc/images/icons/Environment%20Manager.png" alt="Environment Variables icon" height="16"> Environment Variables](https://aka.ms/PowerToysOverview_EnvironmentVariables) | [<img src="doc/images/icons/FancyZones.png" alt="FancyZones icon" height="16"> FancyZones](https://aka.ms/PowerToysOverview_FancyZones) |
| [<img src="doc/images/icons/File%20Explorer%20Preview.png" alt="File Explorer Add-ons icon" height="16"> File Explorer Add-ons](https://aka.ms/PowerToysOverview_FileExplorerAddOns) | [<img src="doc/images/icons/File%20Locksmith.png" alt="File Locksmith icon" height="16"> File Locksmith](https://aka.ms/PowerToysOverview_FileLocksmith) | [<img src="doc/images/icons/GrabAndMove.png" alt="Grab And Move icon" height="16"> Grab And Move](https://aka.ms/PowerToysOverview_GrabAndMove) |
| [<img src="doc/images/icons/Host%20File%20Editor.png" alt="Hosts File Editor icon" height="16"> Hosts File Editor](https://aka.ms/PowerToysOverview_HostsFileEditor) | [<img src="doc/images/icons/Image%20Resizer.png" alt="Image Resizer icon" height="16"> Image Resizer](https://aka.ms/PowerToysOverview_ImageResizer) | [<img src="doc/images/icons/Keyboard%20Manager.png" alt="Keyboard Manager icon" height="16"> Keyboard Manager](https://aka.ms/PowerToysOverview_KeyboardManager) |
| [<img src="doc/images/icons/Light Switch.png" alt="Light Switch icon" height="16"> Light Switch](https://aka.ms/PowerToysOverview_LightSwitch) | [<img src="doc/images/icons/Find My Mouse.png" alt="Mouse Utilities icon" height="16"> Mouse Utilities](https://aka.ms/PowerToysOverview_MouseUtilities) | [<img src="doc/images/icons/MouseWithoutBorders.png" alt="Mouse Without Borders icon" height="16"> Mouse Without Borders](https://aka.ms/PowerToysOverview_MouseWithoutBorders) |
| [<img src="doc/images/icons/NewPlus.png" alt="New+ icon" height="16"> New+](https://aka.ms/PowerToysOverview_NewPlus) | [<img src="doc/images/icons/Peek.png" alt="Peek icon" height="16"> Peek](https://aka.ms/PowerToysOverview_Peek) | [<img src="doc/images/icons/PowerDisplay.png" alt="PowerDisplay icon" height="16"> PowerDisplay](https://aka.ms/PowerToysOverview_PowerDisplay) |
| [<img src="doc/images/icons/PowerRename.png" alt="PowerRename icon" height="16"> PowerRename](https://aka.ms/PowerToysOverview_PowerRename) | [<img src="doc/images/icons/PowerToys%20Run.png" alt="PowerToys Run icon" height="16"> PowerToys Run](https://aka.ms/PowerToysOverview_PowerToysRun) | [<img src="doc/images/icons/PowerAccent.png" alt="Quick Accent icon" height="16"> Quick Accent](https://aka.ms/PowerToysOverview_QuickAccent) |
| [<img src="doc/images/icons/Registry%20Preview.png" alt="Registry Preview icon" height="16"> Registry Preview](https://aka.ms/PowerToysOverview_RegistryPreview) | [<img src="doc/images/icons/MeasureTool.png" alt="Screen Ruler icon" height="16"> Screen Ruler](https://aka.ms/PowerToysOverview_ScreenRuler) | [<img src="doc/images/icons/Shortcut%20Guide.png" alt="Shortcut Guide icon" height="16"> Shortcut Guide](https://aka.ms/PowerToysOverview_ShortcutGuide) |
| [<img src="doc/images/icons/PowerOCR.png" alt="Text Extractor icon" height="16"> Text Extractor](https://aka.ms/PowerToysOverview_TextExtractor) | [<img src="doc/images/icons/Workspaces.png" alt="Workspaces icon" height="16"> Workspaces](https://aka.ms/PowerToysOverview_Workspaces) | [<img src="doc/images/icons/ZoomIt.png" alt="ZoomIt icon" height="16"> ZoomIt](https://aka.ms/PowerToysOverview_ZoomIt) |
| [<img src="doc/images/icons/File%20Explorer%20Preview.png" alt="File Explorer Add-ons icon" height="16"> File Explorer Add-ons](https://aka.ms/PowerToysOverview_FileExplorerAddOns) | [<img src="doc/images/icons/File%20Locksmith.png" alt="File Locksmith icon" height="16"> File Locksmith](https://aka.ms/PowerToysOverview_FileLocksmith) | [<img src="doc/images/icons/Host%20File%20Editor.png" alt="Hosts File Editor icon" height="16"> Hosts File Editor](https://aka.ms/PowerToysOverview_HostsFileEditor) |
| [<img src="doc/images/icons/Image%20Resizer.png" alt="Image Resizer icon" height="16"> Image Resizer](https://aka.ms/PowerToysOverview_ImageResizer) | [<img src="doc/images/icons/Keyboard%20Manager.png" alt="Keyboard Manager icon" height="16"> Keyboard Manager](https://aka.ms/PowerToysOverview_KeyboardManager) | [<img src="doc/images/icons/Light Switch.png" alt="Light Switch icon" height="16"> Light Switch](https://aka.ms/PowerToysOverview_LightSwitch) |
| [<img src="doc/images/icons/Find My Mouse.png" alt="Mouse Utilities icon" height="16"> Mouse Utilities](https://aka.ms/PowerToysOverview_MouseUtilities) | [<img src="doc/images/icons/MouseWithoutBorders.png" alt="Mouse Without Borders icon" height="16"> Mouse Without Borders](https://aka.ms/PowerToysOverview_MouseWithoutBorders) | [<img src="doc/images/icons/NewPlus.png" alt="New+ icon" height="16"> New+](https://aka.ms/PowerToysOverview_NewPlus) |
| [<img src="doc/images/icons/Peek.png" alt="Peek icon" height="16"> Peek](https://aka.ms/PowerToysOverview_Peek) | [<img src="doc/images/icons/PowerRename.png" alt="PowerRename icon" height="16"> PowerRename](https://aka.ms/PowerToysOverview_PowerRename) | [<img src="doc/images/icons/PowerToys%20Run.png" alt="PowerToys Run icon" height="16"> PowerToys Run](https://aka.ms/PowerToysOverview_PowerToysRun) |
| [<img src="doc/images/icons/PowerAccent.png" alt="Quick Accent icon" height="16"> Quick Accent](https://aka.ms/PowerToysOverview_QuickAccent) | [<img src="doc/images/icons/Registry%20Preview.png" alt="Registry Preview icon" height="16"> Registry Preview](https://aka.ms/PowerToysOverview_RegistryPreview) | [<img src="doc/images/icons/MeasureTool.png" alt="Screen Ruler icon" height="16"> Screen Ruler](https://aka.ms/PowerToysOverview_ScreenRuler) |
| [<img src="doc/images/icons/Shortcut%20Guide.png" alt="Shortcut Guide icon" height="16"> Shortcut Guide](https://aka.ms/PowerToysOverview_ShortcutGuide) | [<img src="doc/images/icons/PowerOCR.png" alt="Text Extractor icon" height="16"> Text Extractor](https://aka.ms/PowerToysOverview_TextExtractor) | [<img src="doc/images/icons/Workspaces.png" alt="Workspaces icon" height="16"> Workspaces](https://aka.ms/PowerToysOverview_Workspaces) |
| [<img src="doc/images/icons/ZoomIt.png" alt="ZoomIt icon" height="16"> ZoomIt](https://aka.ms/PowerToysOverview_ZoomIt) | | |
## 📦 Installation

Binary file not shown.

Before

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 84 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 56 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 66 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 318 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 141 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 182 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 111 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 41 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 13 KiB

View File

@@ -17,7 +17,6 @@
<ItemGroup>
<PackageReference Include="Microsoft.WindowsAppSDK" />
<PackageReference Include="WinUIEx" />
<PackageReference Include="CommunityToolkit.WinUI.Controls.Primitives" />
<PackageReference Include="CommunityToolkit.WinUI.Extensions" />
<PackageReference Include="CommunityToolkit.WinUI.Converters" />

View File

@@ -1,223 +0,0 @@
// Copyright (c) Microsoft Corporation
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
using System;
using System.Runtime.InteropServices;
using ManagedCommon;
using Microsoft.UI.Windowing;
using Windows.Graphics;
using WinUIEx;
namespace Microsoft.PowerToys.Common.UI.Controls.Flyout;
/// <summary>
/// Shared helper for positioning and sizing flyout-style WinUI 3 windows
/// (e.g. Quick Access, PowerDisplay) that are pinned to a corner of the work area.
///
/// The public API takes sizes in device-independent pixels (DIP). The helper resolves the
/// target monitor's effective DPI and converts to physical pixels. All window positioning
/// uses absolute screen physical-pixel coordinates via
/// <see cref="AppWindow.MoveAndResize(RectInt32)"/> — the same pattern used by the original
/// Settings.UI flyout, which proved reliable across multi-monitor and mixed-DPI setups.
/// </summary>
public static partial class FlyoutWindowHelper
{
private const uint MdtEffectiveDpi = 0;
private const int DefaultDpi = 96;
[StructLayout(LayoutKind.Sequential)]
private struct POINT
{
public int X;
public int Y;
}
[LibraryImport("user32.dll")]
[return: MarshalAs(UnmanagedType.Bool)]
private static partial bool GetCursorPos(out POINT lpPoint);
[LibraryImport("shcore.dll")]
private static partial int GetDpiForMonitor(nint hMonitor, uint dpiType, out uint dpiX, out uint dpiY);
/// <summary>
/// Get the DPI scale factor (1.0 = 100%, 1.25 = 125%, 1.5 = 150%, 2.0 = 200%) for a window.
/// </summary>
public static double GetDpiScale(WindowEx window)
{
ArgumentNullException.ThrowIfNull(window);
return (double)window.GetDpiForWindow() / DefaultDpi;
}
/// <summary>
/// Get the DPI scale factor for a given <see cref="DisplayArea"/>.
/// Resolves DPI from the underlying monitor handle so the value reflects the
/// target display, regardless of which monitor the window is currently on.
/// </summary>
public static double GetDpiScale(DisplayArea displayArea)
{
ArgumentNullException.ThrowIfNull(displayArea);
return (double)GetEffectiveDpi(global::Microsoft.UI.Win32Interop.GetMonitorFromDisplayId(displayArea.DisplayId)) / DefaultDpi;
}
/// <summary>
/// Convert device-independent pixels (DIP) to physical pixels (rounding up).
/// </summary>
public static int ScaleToPhysicalPixels(int dip, double dpiScale)
{
return (int)Math.Ceiling(dip * dpiScale);
}
/// <summary>
/// Convert physical pixels to device-independent pixels (DIP) (rounding down).
/// </summary>
public static int ScaleToDip(int physicalPixels, double dpiScale)
{
return (int)Math.Floor(physicalPixels / dpiScale);
}
/// <summary>
/// Look up the <see cref="DisplayArea"/> currently containing the mouse cursor.
/// </summary>
public static bool TryGetDisplayAreaAtCursor(out DisplayArea? displayArea)
{
displayArea = null;
if (!GetCursorPos(out var cursorPos))
{
return false;
}
displayArea = DisplayArea.GetFromPoint(new PointInt32(cursorPos.X, cursorPos.Y), DisplayAreaFallback.Nearest);
return displayArea is not null;
}
/// <summary>
/// Position a flyout-style window at the bottom-right corner of the work area on the
/// monitor under the mouse cursor.
/// </summary>
public static void PositionWindowBottomRight(
WindowEx window,
int widthDip,
int heightDip,
int rightMarginDip = 0,
int bottomMarginDip = 0)
{
ArgumentNullException.ThrowIfNull(window);
if (!TryGetDisplayAreaAtCursor(out var displayArea) || displayArea is null)
{
Logger.LogWarning("FlyoutWindowHelper.PositionWindowBottomRight: unable to determine display from cursor; skipping positioning");
return;
}
PositionWindowBottomRight(window, displayArea, widthDip, heightDip, rightMarginDip, bottomMarginDip);
}
/// <summary>
/// Position a flyout-style window at the bottom-right corner of the specified display
/// area's work area. Use this overload when the caller has already resolved the target
/// <see cref="DisplayArea"/> (e.g. the cursor monitor) so size and placement are computed
/// from the same source.
///
/// Internally moves the window in two steps to avoid <c>WM_DPICHANGED</c> double-scaling
/// when the target monitor has a different DPI than the one the window was previously on:
/// first a 1×1 teleport into the target display, then the real position+size while the
/// window is already on that monitor (no DPI boundary crossing).
/// </summary>
public static void PositionWindowBottomRight(
WindowEx window,
DisplayArea displayArea,
int widthDip,
int heightDip,
int rightMarginDip = 0,
int bottomMarginDip = 0)
{
ArgumentNullException.ThrowIfNull(window);
ArgumentNullException.ThrowIfNull(displayArea);
double dpiScale = GetDpiScale(displayArea);
var work = displayArea.WorkArea;
int w = ScaleToPhysicalPixels(widthDip, dpiScale);
int h = ScaleToPhysicalPixels(heightDip, dpiScale);
int marginRight = ScaleToPhysicalPixels(rightMarginDip, dpiScale);
int marginBottom = ScaleToPhysicalPixels(bottomMarginDip, dpiScale);
// Clamp size so the window never extends past the work area minus margins.
// Guards against the bottom/right edge spilling into the taskbar when rounding
// (Math.Ceiling above) would push it just past the boundary.
int maxW = Math.Max(0, work.Width - marginRight);
int maxH = Math.Max(0, work.Height - marginBottom);
w = Math.Min(w, maxW);
h = Math.Min(h, maxH);
// Absolute screen physical-pixel coordinates. WorkArea is in screen coordinates,
// so for non-primary monitors WorkArea.X/Y will be non-zero (and may be negative).
int x = work.X + work.Width - w - marginRight;
int y = work.Y + work.Height - h - marginBottom;
MoveAndResizeOnDisplay(window, displayArea, new RectInt32(x, y, w, h));
}
/// <summary>
/// Center a window within the specified display area's work area.
/// Uses a 1×1 teleport into the target display first to avoid WM_DPICHANGED
/// double-scaling when crossing monitors with different DPI.
/// </summary>
public static void CenterWindowOnDisplay(
WindowEx window,
DisplayArea displayArea,
int widthDip,
int heightDip)
{
ArgumentNullException.ThrowIfNull(window);
ArgumentNullException.ThrowIfNull(displayArea);
double dpiScale = GetDpiScale(displayArea);
var work = displayArea.WorkArea;
int w = Math.Min(ScaleToPhysicalPixels(widthDip, dpiScale), work.Width);
int h = Math.Min(ScaleToPhysicalPixels(heightDip, dpiScale), work.Height);
int x = work.X + ((work.Width - w) / 2);
int y = work.Y + ((work.Height - h) / 2);
MoveAndResizeOnDisplay(window, displayArea, new RectInt32(x, y, w, h));
}
/// <summary>
/// Two-step move that avoids WM_DPICHANGED double-scaling. First teleports a 1×1
/// window into the target display (which may trigger an auto-rescale, but on a 1×1
/// rect the effect is invisible). Then sets the real position+size while the window
/// is already on the target monitor — no DPI boundary crossing, so WinUI's auto
/// handler doesn't fire and overwrite our computed rect.
///
/// Skips the teleport when the window is already on the target display, since there
/// is no boundary to cross.
/// </summary>
private static void MoveAndResizeOnDisplay(WindowEx window, DisplayArea targetDisplay, RectInt32 finalRect)
{
var currentDisplay = DisplayArea.GetFromWindowId(window.AppWindow.Id, DisplayAreaFallback.Nearest);
bool needsTeleport = currentDisplay is null || currentDisplay.DisplayId.Value != targetDisplay.DisplayId.Value;
if (needsTeleport)
{
var work = targetDisplay.WorkArea;
window.AppWindow.MoveAndResize(new RectInt32(work.X, work.Y, 1, 1));
}
window.AppWindow.MoveAndResize(finalRect);
}
private static int GetEffectiveDpi(nint hMonitor)
{
if (hMonitor == 0)
{
return DefaultDpi;
}
var hr = GetDpiForMonitor(hMonitor, MdtEffectiveDpi, out var dpiX, out _);
return hr >= 0 && dpiX > 0 ? (int)dpiX : DefaultDpi;
}
}

View File

@@ -1,92 +0,0 @@
// Copyright (c) Microsoft Corporation
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
using System;
using System.Runtime.InteropServices;
using WinUIEx;
namespace Microsoft.PowerToys.Common.UI.Controls.Window;
/// <summary>
/// Subclasses a window's WndProc and invokes a preprocessor callback for every
/// message before the default window procedure runs. Useful for routing low-level
/// Win32 messages (e.g. <c>WM_HOTKEY</c>) into managed handlers without depending
/// on the WinUI XAML message loop.
/// </summary>
/// <remarks>
/// Usage:
/// <code>
/// _hook = new WindowMessageHook(window, (uMsg, wParam, lParam) =>
/// _hotkeyService.HandleMessage(uMsg, wParam));
/// </code>
/// Dispose to restore the original WndProc.
/// </remarks>
public sealed partial class WindowMessageHook : IDisposable
{
// Called for every message before default processing. Return true to swallow.
private readonly Func<uint, nuint, nint, bool> _preProcessor;
private const int GwlWndProc = -4;
private readonly nint _hwnd;
private nint _originalWndProc;
private WndProcDelegate? _wndProcDelegate;
private bool _disposed;
private delegate nint WndProcDelegate(nint hwnd, uint uMsg, nuint wParam, nint lParam);
[LibraryImport("user32.dll", EntryPoint = "SetWindowLongPtrW")]
private static partial nint SetWindowLongPtr(nint hWnd, int nIndex, nint dwNewLong);
[LibraryImport("user32.dll", EntryPoint = "CallWindowProcW")]
private static partial nint CallWindowProc(nint lpPrevWndFunc, nint hWnd, uint msg, nuint wParam, nint lParam);
/// <summary>
/// Initializes a new instance of the <see cref="WindowMessageHook"/> class
/// and subclasses the supplied window's WndProc.
/// </summary>
/// <param name="window">Window to subclass.</param>
/// <param name="preProcessor">Callback invoked for every message before the
/// default WndProc. Receives <c>(uMsg, wParam, lParam)</c>. Return
/// <see langword="true"/> to swallow the message.</param>
public WindowMessageHook(WindowEx window, Func<uint, nuint, nint, bool> preProcessor)
{
ArgumentNullException.ThrowIfNull(window);
ArgumentNullException.ThrowIfNull(preProcessor);
_hwnd = window.GetWindowHandle();
_preProcessor = preProcessor;
_wndProcDelegate = WndProc;
var ptr = Marshal.GetFunctionPointerForDelegate(_wndProcDelegate);
_originalWndProc = SetWindowLongPtr(_hwnd, GwlWndProc, ptr);
}
private nint WndProc(nint hwnd, uint uMsg, nuint wParam, nint lParam)
{
if (_preProcessor(uMsg, wParam, lParam))
{
return 0;
}
return CallWindowProc(_originalWndProc, hwnd, uMsg, wParam, lParam);
}
public void Dispose()
{
if (_disposed)
{
return;
}
_disposed = true;
if (_originalWndProc != 0)
{
SetWindowLongPtr(_hwnd, GwlWndProc, _originalWndProc);
_originalWndProc = 0;
}
_wndProcDelegate = null;
}
}

View File

@@ -13,15 +13,8 @@ namespace Microsoft.Interop.Tests
[TestClass]
public class InteropTests : IDisposable
{
// Pipe names are machine-global, so two concurrent test runs on the same CI agent
// (or a leaked handle from a prior run) would deadlock if we used a shared constant.
// Suffix with process id + a GUID so every test run gets its own pair.
private const string PipePrefix = @"\\.\pipe\";
private static readonly string PipeSuffix = $"{Environment.ProcessId}_{Guid.NewGuid():N}";
private static readonly string ServerSidePipe = $"{PipePrefix}serverside_{PipeSuffix}";
private static readonly string ClientSidePipe = $"{PipePrefix}clientside_{PipeSuffix}";
private static readonly TimeSpan MessageWaitTimeout = TimeSpan.FromSeconds(30);
private const string ServerSidePipe = "\\\\.\\pipe\\serverside";
private const string ClientSidePipe = "\\\\.\\pipe\\clientside";
internal TwoWayPipeMessageIPCManaged ClientPipe { get; set; }
@@ -61,11 +54,7 @@ namespace Microsoft.Interop.Tests
Thread.Sleep(100);
ClientPipe.Send(testString);
// Bounded wait so a broken pipe handshake fails the test quickly
// instead of hanging the CI agent until the job-level timeout.
var timeoutMessage = $"Pipe callback was not invoked within {MessageWaitTimeout.TotalSeconds}s. Server='{ServerSidePipe}' Client='{ClientSidePipe}'.";
Assert.IsTrue(reset.WaitOne(MessageWaitTimeout), timeoutMessage);
reset.WaitOne();
serverPipe.End();
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 372 KiB

After

Width:  |  Height:  |  Size: 766 B

View File

@@ -26,7 +26,6 @@
"src\\modules\\cmdpal\\Tests\\Microsoft.CmdPal.Ext.Bookmarks.UnitTests\\Microsoft.CmdPal.Ext.Bookmarks.UnitTests.csproj",
"src\\modules\\cmdpal\\Tests\\Microsoft.CmdPal.Ext.Calc.UnitTests\\Microsoft.CmdPal.Ext.Calc.UnitTests.csproj",
"src\\modules\\cmdpal\\Tests\\Microsoft.CmdPal.Ext.ClipboardHistory.UnitTests\\Microsoft.CmdPal.Ext.ClipboardHistory.UnitTests.csproj",
"src\\modules\\cmdpal\\Tests\\Microsoft.CmdPal.Ext.Indexer.UnitTests\\Microsoft.CmdPal.Ext.Indexer.UnitTests.csproj",
"src\\modules\\cmdpal\\Tests\\Microsoft.CmdPal.Ext.Registry.UnitTests\\Microsoft.CmdPal.Ext.Registry.UnitTests.csproj",
"src\\modules\\cmdpal\\Tests\\Microsoft.CmdPal.Ext.RemoteDesktop.UnitTests\\Microsoft.CmdPal.Ext.RemoteDesktop.UnitTests.csproj",
"src\\modules\\cmdpal\\Tests\\Microsoft.CmdPal.Ext.Shell.UnitTests\\Microsoft.CmdPal.Ext.Shell.UnitTests.csproj",
@@ -63,4 +62,4 @@
"src\\settings-ui\\Settings.UI.Library\\Settings.UI.Library.csproj"
]
}
}
}

View File

@@ -1,10 +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.Common.Messages;
/// <summary>
/// Message to request hiding the window.
/// </summary>
public sealed partial record HideWindowMessage;

View File

@@ -86,14 +86,7 @@ public partial class CommandItemViewModel : ExtensionObjectViewModel, ICommandBa
public CommandItemViewModel? SecondaryCommand => _secondaryMoreCommand;
public bool CanOpenContextMenu =>
// BEAR LOADING: A visible synthetic primary command makes the item
// context-openable immediately, even if out-of-proc MoreCommands are still
// hydrating. Without this fast path, the first open request can race slow
// menu initialization and get dropped.
_defaultCommandContextItemViewModel?.ShouldBeVisible == true ||
_moreCommandsSnapshot.Any(item => item is CommandItemViewModel command && command.ShouldBeVisible);
public bool CanOpenContextMenu => AllCommands.Any(item => item is CommandItemViewModel command && command.ShouldBeVisible);
public bool ShouldBeVisible => !string.IsNullOrEmpty(Name);
@@ -139,15 +132,13 @@ public partial class CommandItemViewModel : ExtensionObjectViewModel, ICommandBa
return;
}
var command = model.Command;
Command = new(command, PageContext);
Command = new(model.Command, PageContext);
Command.FastInitializeProperties();
_itemTitle = model.Title;
Subtitle = model.Subtitle;
_titleCache.Invalidate();
_subtitleCache.Invalidate();
TryCreateDefaultCommandContextItem(command);
Initialized |= InitializedState.FastInitialized;
}
@@ -224,7 +215,7 @@ public partial class CommandItemViewModel : ExtensionObjectViewModel, ICommandBa
BuildAndInitMoreCommands();
TryCreateDefaultCommandContextItem(model.Command);
TryCreateDefaultCommandContextItem(model);
lock (_moreCommandsLock)
{
@@ -325,8 +316,7 @@ public partial class CommandItemViewModel : ExtensionObjectViewModel, ICommandBa
{
case nameof(Command):
Command.PropertyChanged -= Command_PropertyChanged;
var command = model.Command;
Command = new(command, PageContext);
Command = new(model.Command, PageContext);
Command.InitializeProperties();
Command.PropertyChanged += Command_PropertyChanged;
@@ -342,7 +332,7 @@ public partial class CommandItemViewModel : ExtensionObjectViewModel, ICommandBa
}
else
{
TryCreateDefaultCommandContextItem(command);
TryCreateDefaultCommandContextItem(model);
}
UpdateProperty(nameof(Name));
@@ -417,7 +407,7 @@ public partial class CommandItemViewModel : ExtensionObjectViewModel, ICommandBa
}
else
{
TryCreateDefaultCommandContextItem(model.Command);
TryCreateDefaultCommandContextItem(model);
}
break;
@@ -437,22 +427,19 @@ public partial class CommandItemViewModel : ExtensionObjectViewModel, ICommandBa
/// When a new instance is created, the snapshot is refreshed and
/// <see cref="AllCommands"/> is notified.
/// </summary>
private void TryCreateDefaultCommandContextItem(ICommand? commandModel)
private void TryCreateDefaultCommandContextItem(ICommandItem model)
{
if (_defaultCommandContextItemViewModel is not null)
{
return;
}
// We only synthesize the primary entry when the command is already
// usable; a null/empty primary must still fall back to late
// MoreCommands-based opening.
if (string.IsNullOrEmpty(Command.Name) || commandModel is null)
if (string.IsNullOrEmpty(model.Command?.Name))
{
return;
}
_defaultCommandContextItemViewModel = new CommandContextItemViewModel(new CommandContextItem(commandModel), PageContext)
_defaultCommandContextItemViewModel = new CommandContextItemViewModel(new CommandContextItem(model.Command!), PageContext)
{
_itemTitle = Name,
Subtitle = Subtitle,

View File

@@ -199,15 +199,8 @@ public partial class ExtensionService : IExtensionService, IDisposable
var extensions = await GetInstalledAppExtensionsAsync();
foreach (var extension in extensions)
{
try
{
var wrappers = await CreateWrappersForExtension(extension);
UpdateExtensionsListsFromWrappers(wrappers);
}
catch (Exception ex)
{
Logger.LogError($"Failed to load extension '{extension.DisplayName}': {ex.Message}");
}
var wrappers = await CreateWrappersForExtension(extension);
UpdateExtensionsListsFromWrappers(wrappers);
}
}
@@ -252,15 +245,8 @@ public partial class ExtensionService : IExtensionService, IDisposable
List<ExtensionWrapper> wrappers = [];
foreach (var classId in classIds)
{
try
{
var extensionWrapper = CreateExtensionWrapper(extension, cmdPalProvider, classId);
wrappers.Add(extensionWrapper);
}
catch (Exception ex)
{
Logger.LogError($"Failed to create wrapper for extension '{extension.DisplayName}' classId '{classId}': {ex.Message}");
}
var extensionWrapper = CreateExtensionWrapper(extension, cmdPalProvider, classId);
wrappers.Add(extensionWrapper);
}
return wrappers;

View File

@@ -18,7 +18,9 @@ public record DockSettings
{
public DockSide Side { get; init; } = DockSide.Top;
public DockSize DockSize { get; init; } = DockSize.Default;
public DockSize DockSize { get; init; } = DockSize.Small;
public DockSize DockIconsSize { get; init; } = DockSize.Small;
public bool AlwaysOnTop { get; set; } = true;
@@ -137,8 +139,9 @@ public enum DockSide
public enum DockSize
{
Default,
Compact,
Small,
Medium,
Large,
}
public enum DockBackdrop

View File

@@ -4,7 +4,7 @@
using CommunityToolkit.Mvvm.Messaging;
using CommunityToolkit.WinUI;
using Microsoft.CmdPal.Common.Messages;
using Microsoft.CmdPal.Ext.ClipboardHistory.Messages;
using Microsoft.CmdPal.UI.Helpers;
using Microsoft.CmdPal.UI.ViewModels;
using Microsoft.CmdPal.UI.ViewModels.Commands;

View File

@@ -17,9 +17,12 @@
<UserControl.Resources>
<ResourceDictionary>
<StackLayout x:Key="ItemsOrientationLayout" Orientation="{x:Bind ItemsOrientation, Mode=OneWay}" />
<StackLayout
x:Key="ItemsOrientationLayout"
Orientation="{x:Bind ItemsOrientation, Mode=OneWay}"
Spacing="4" />
<ItemsPanelTemplate x:Key="HorizontalItemsPanel">
<StackPanel Orientation="Horizontal" />
<StackPanel Orientation="Horizontal" Spacing="4" />
</ItemsPanelTemplate>
<ItemsPanelTemplate x:Key="VerticalItemsPanel">
<StackPanel Orientation="Vertical" Spacing="4" />
@@ -73,7 +76,7 @@
<Style x:Key="DockBandListViewItemStyle" TargetType="ListViewItem">
<Setter Property="Padding" Value="0" />
<Setter Property="Margin" Value="0" />
<Setter Property="Margin" Value="0,0,4,0" />
<Setter Property="MinHeight" Value="0" />
<Setter Property="MinWidth" Value="0" />
<Setter Property="HorizontalContentAlignment" Value="Stretch" />
@@ -206,13 +209,13 @@
<Grid
x:Name="RootGrid"
Background="Transparent"
BorderBrush="{ThemeResource SurfaceStrokeColorDefaultBrush}"
BorderThickness="0,0,0,1"
RightTapped="RootGrid_RightTapped">
<!-- Dock content with Start / Center / End sections -->
<local:DockContentControl
x:Name="ContentGrid"
Margin="4"
Background="Transparent"
IsEditMode="{x:Bind IsEditMode, Mode=OneWay}"
RightTapped="RootGrid_RightTapped">
<local:DockContentControl.StartSource>
@@ -244,6 +247,7 @@
<FontIcon FontSize="12" Glyph="&#xE710;" />
</Button>
</local:DockContentControl.StartActionButton>
<local:DockContentControl.CenterSource>
<ListView
x:Name="CenterListView"
@@ -278,8 +282,6 @@
<ListView
x:Name="EndListView"
MinWidth="48"
MinHeight="0"
HorizontalContentAlignment="Stretch"
DragEnter="BandListView_DragEnter"
DragItemsCompleted="BandListView_DragItemsCompleted"
DragItemsStarting="BandListView_DragItemsStarting"
@@ -309,6 +311,7 @@
</Button>
</local:DockContentControl.EndActionButton>
</local:DockContentControl>
<TeachingTip
x:Name="EditButtonsTeachingTip"
MinWidth="0"
@@ -341,7 +344,7 @@
<ui:IsEqualStateTrigger Value="{x:Bind DockSide, Mode=OneWay}" To="Top" />
</VisualState.StateTriggers>
<VisualState.Setters>
<Setter Target="ContentGrid.Margin" Value="4,0,4,0" />
<Setter Target="ContentGrid.Margin" Value="4,0,4,4" />
</VisualState.Setters>
</VisualState>
<VisualState x:Name="DockOnBottom">
@@ -388,25 +391,6 @@
</VisualState.Setters>
</VisualState>
</VisualStateGroup>
<!--
Compact overrides: zeroes margins/borders set by DockOrientation.
Declared after DockOrientation so its setters win when both groups
target the same property.
-->
<VisualStateGroup x:Name="DockSizeStates">
<VisualState x:Name="DefaultSize" />
<VisualState x:Name="CompactSize">
<VisualState.StateTriggers>
<ui:IsEqualStateTrigger Value="{x:Bind DockSize, Mode=OneWay}" To="Compact" />
</VisualState.StateTriggers>
<VisualState.Setters>
<Setter Target="ContentGrid.Margin" Value="0" />
<Setter Target="ContentGrid.Padding" Value="0" />
<Setter Target="RootGrid.BorderThickness" Value="0" />
</VisualState.Setters>
</VisualState>
</VisualStateGroup>
</VisualStateManager.VisualStateGroups>
</Grid>
</UserControl>

View File

@@ -47,15 +47,6 @@ public sealed partial class DockControl : UserControl, IRecipient<CloseContextMe
set => SetValue(DockSideProperty, value);
}
public static readonly DependencyProperty DockSizeProperty =
DependencyProperty.Register(nameof(DockSize), typeof(DockSize), typeof(DockControl), new PropertyMetadata(DockSize.Default));
public DockSize DockSize
{
get => (DockSize)GetValue(DockSizeProperty);
set => SetValue(DockSizeProperty, value);
}
public static readonly DependencyProperty IsEditModeProperty =
DependencyProperty.Register(nameof(IsEditMode), typeof(bool), typeof(DockControl), new PropertyMetadata(false, OnIsEditModeChanged));
@@ -243,10 +234,7 @@ public sealed partial class DockControl : UserControl, IRecipient<CloseContextMe
{
DockSide = settings.Side;
// Compact mode is only supported for Top/Bottom positions
var isHorizontal = settings.Side == DockSide.Top || settings.Side == DockSide.Bottom;
var effectiveSize = isHorizontal ? settings.DockSize : DockSize.Default;
DockSize = effectiveSize;
ItemsOrientation = isHorizontal ? Orientation.Horizontal : Orientation.Vertical;
@@ -302,11 +290,6 @@ public sealed partial class DockControl : UserControl, IRecipient<CloseContextMe
ShowTitlesMenuItem.IsChecked = _editModeContextBand.ShowTitles;
ShowSubtitlesMenuItem.IsChecked = _editModeContextBand.ShowSubtitles;
// Hide subtitle toggle in compact mode — no subtitle in the template
ShowSubtitlesMenuItem.Visibility = DockSize == DockSize.Compact
? Visibility.Collapsed
: Visibility.Visible;
PreparePopupForShow(EditModeContextMenu, dockItem);
EditModeContextMenu.ShowAt(
dockItem,

View File

@@ -43,7 +43,7 @@
<CornerRadius x:Key="DockItemCornerRadius">4</CornerRadius>
<Thickness x:Key="DockItemPadding">4,0,4,0</Thickness>
<Thickness x:Key="DockItemMargin">2,0,2,0</Thickness>
<Style BasedOn="{StaticResource DefaultDockItemControlStyle}" TargetType="local:DockItemControl" />
<Style x:Key="DefaultDockItemControlStyle" TargetType="local:DockItemControl">
@@ -60,13 +60,12 @@
<Setter Property="Template">
<Setter.Value>
<ControlTemplate TargetType="local:DockItemControl">
<Grid
x:Name="PART_RootGrid"
Padding="{StaticResource DockItemMargin}"
Background="Transparent">
<Grid x:Name="PART_HitTestGrid" Background="Transparent">
<Grid
x:Name="PART_BackPlate"
x:Name="PART_RootGrid"
MinWidth="32"
MinHeight="30"
Margin="{TemplateBinding InnerMargin}"
Padding="{TemplateBinding Padding}"
VerticalAlignment="Stretch"
Background="{TemplateBinding Background}"
@@ -129,20 +128,20 @@
<VisualState x:Name="Normal" />
<VisualState x:Name="PointerOver">
<VisualState.Setters>
<Setter Target="PART_BackPlate.Background" Value="{ThemeResource DockItemBackgroundPointerOver}" />
<Setter Target="PART_BackPlate.BorderBrush" Value="{ThemeResource DockItemBorderBrushPointerOver}" />
<Setter Target="PART_RootGrid.Background" Value="{ThemeResource DockItemBackgroundPointerOver}" />
<Setter Target="PART_RootGrid.BorderBrush" Value="{ThemeResource DockItemBorderBrushPointerOver}" />
</VisualState.Setters>
</VisualState>
<VisualState x:Name="Pressed">
<VisualState.Setters>
<Setter Target="PART_BackPlate.Background" Value="{ThemeResource DockItemBackgroundPointerOver}" />
<Setter Target="PART_BackPlate.BorderBrush" Value="{ThemeResource DockItemBorderBrushPressed}" />
<Setter Target="PART_RootGrid.Background" Value="{ThemeResource DockItemBackgroundPointerOver}" />
<Setter Target="PART_RootGrid.BorderBrush" Value="{ThemeResource DockItemBorderBrushPressed}" />
</VisualState.Setters>
</VisualState>
<VisualState x:Name="Disabled">
<VisualState.Setters>
<Setter Target="PART_BackPlate.Background" Value="{ThemeResource DockItemBackground}" />
<Setter Target="PART_BackPlate.BorderBrush" Value="{ThemeResource DockItemBackground}" />
<Setter Target="PART_RootGrid.Background" Value="{ThemeResource DockItemBackground}" />
<Setter Target="PART_RootGrid.BorderBrush" Value="{ThemeResource DockItemBackground}" />
<Setter Target="IconPresenter.Foreground" Value="{ThemeResource ButtonForegroundDisabled}" />
<Setter Target="TitleText.Foreground" Value="{ThemeResource ButtonForegroundDisabled}" />
<Setter Target="SubtitleText.Foreground" Value="{ThemeResource ButtonForegroundDisabled}" />
@@ -193,16 +192,6 @@
</VisualState.Setters>
</VisualState>
</VisualStateGroup>
<VisualStateGroup x:Name="CompactStates">
<VisualState x:Name="DefaultLayout" />
<VisualState x:Name="Compact">
<VisualState.Setters>
<Setter Target="PART_RootGrid.Padding" Value="0" />
<Setter Target="SubtitleText.Visibility" Value="Collapsed" />
<Setter Target="TitleText.Margin" Value="0,-1,0,0" />
</VisualState.Setters>
</VisualState>
</VisualStateGroup>
</VisualStateManager.VisualStateGroups>
</Grid>
</ControlTemplate>

View File

@@ -84,35 +84,12 @@ public sealed partial class DockItemControl : Control
set => SetValue(TextVisibilityProperty, value);
}
public static readonly DependencyProperty IsCompactProperty =
DependencyProperty.Register(nameof(IsCompact), typeof(bool), typeof(DockItemControl), new PropertyMetadata(false, OnIsCompactPropertyChanged));
public bool IsCompact
{
get => (bool)GetValue(IsCompactProperty);
set => SetValue(IsCompactProperty, value);
}
private static void OnIsCompactPropertyChanged(DependencyObject d, DependencyPropertyChangedEventArgs e)
{
if (d is DockItemControl control)
{
control.UpdateCompactState();
}
}
private void UpdateCompactState()
{
VisualStateManager.GoToState(this, IsCompact ? "Compact" : "DefaultLayout", true);
}
private const string IconPresenterName = "IconPresenter";
private FrameworkElement? _iconPresenter;
private DockControl? _parentDock;
private ToolTip? _toolTip;
private long _dockSideCallbackToken = -1;
private long _dockSizeCallbackToken = -1;
private static void OnTextPropertyChanged(DependencyObject d, DependencyPropertyChangedEventArgs e)
{
@@ -145,14 +122,6 @@ public sealed partial class DockItemControl : Control
private void UpdateTextVisibilityState()
{
// When TextVisibility is Collapsed, always hide text and collapse the
// grid column/spacing so the icon-only layout doesn't waste space.
if (TextVisibility == Visibility.Collapsed)
{
VisualStateManager.GoToState(this, "TextHidden", true);
return;
}
// Determine which visual state to use based on title/subtitle presence
var stateName = (HasTitle, HasSubtitle) switch
{
@@ -215,7 +184,6 @@ public sealed partial class DockItemControl : Control
UpdateIconVisibility();
UpdateToolTip();
UpdateAlignment();
UpdateCompactState();
}
private void UpdateToolTip()
@@ -281,14 +249,10 @@ public sealed partial class DockItemControl : Control
{
_parentDock = dock;
UpdateInnerMarginForDockSide(dock.DockSide);
UpdateCompactFromParent(dock);
UpdateAllVisibility();
_dockSideCallbackToken = dock.RegisterPropertyChangedCallback(
DockControl.DockSideProperty,
OnParentDockSideChanged);
_dockSizeCallbackToken = dock.RegisterPropertyChangedCallback(
DockControl.DockSizeProperty,
OnParentDockSizeChanged);
}
UpdateToolTip();
@@ -302,24 +266,12 @@ public sealed partial class DockItemControl : Control
private void DockItemControl_Unloaded(object sender, RoutedEventArgs e)
{
if (_parentDock is not null)
if (_parentDock is not null && _dockSideCallbackToken >= 0)
{
if (_dockSideCallbackToken >= 0)
{
_parentDock.UnregisterPropertyChangedCallback(
DockControl.DockSideProperty,
_dockSideCallbackToken);
_dockSideCallbackToken = -1;
}
if (_dockSizeCallbackToken >= 0)
{
_parentDock.UnregisterPropertyChangedCallback(
DockControl.DockSizeProperty,
_dockSizeCallbackToken);
_dockSizeCallbackToken = -1;
}
_parentDock.UnregisterPropertyChangedCallback(
DockControl.DockSideProperty,
_dockSideCallbackToken);
_dockSideCallbackToken = -1;
_parentDock = null;
}
@@ -331,23 +283,11 @@ public sealed partial class DockItemControl : Control
{
if (sender is DockControl dock)
{
UpdateInnerMarginForDockSide(dock.DockSide);
UpdateAlignment();
}
}
private void OnParentDockSizeChanged(DependencyObject sender, DependencyProperty dp)
{
if (sender is DockControl dock)
{
UpdateCompactFromParent(dock);
}
}
private void UpdateCompactFromParent(DockControl dock)
{
IsCompact = dock.DockSize == DockSize.Compact;
}
private void UpdateInnerMarginForDockSide(DockSide side)
{
// Push the visual (PART_RootGrid) inward on the screen-edge side so
@@ -356,7 +296,7 @@ public sealed partial class DockItemControl : Control
// DockControl's ContentGrid on the screen-edge side.
InnerMargin = side switch
{
DockSide.Top => new Thickness(0, 0, 0, 0),
DockSide.Top => new Thickness(0, 4, 0, 0),
DockSide.Bottom => new Thickness(0, 0, 0, 4),
DockSide.Left => new Thickness(8, 0, 0, 0),
DockSide.Right => new Thickness(0, 0, 8, 0),

View File

@@ -13,8 +13,9 @@ internal static class DockSettingsToViews
{
return size switch
{
DockSize.Default => 86,
DockSize.Compact => 86,
DockSize.Small => 128,
DockSize.Medium => 192,
DockSize.Large => 256,
_ => throw new NotImplementedException(),
};
}
@@ -23,8 +24,9 @@ internal static class DockSettingsToViews
{
return size switch
{
DockSize.Default => 38,
DockSize.Compact => 24,
DockSize.Small => 38,
DockSize.Medium => 54,
DockSize.Large => 76,
_ => throw new NotImplementedException(),
};
}

View File

@@ -1,4 +1,4 @@
<?xml version="1.0" encoding="utf-8" ?>
<?xml version="1.0" encoding="utf-8" ?>
<winuiex:WindowEx
x:Class="Microsoft.CmdPal.UI.Dock.DockWindow"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"

View File

@@ -77,7 +77,7 @@ public sealed partial class DockWindow : WindowEx,
_settingsService = serviceProvider.GetRequiredService<ISettingsService>();
_settingsService.SettingsChanged += SettingsChangedHandler;
_settings = mainSettings.DockSettings;
_lastSize = EffectiveDockSize(_settings);
_lastSize = _settings.DockSize;
viewModel = serviceProvider.GetService<DockViewModel>()!;
_themeService = serviceProvider.GetRequiredService<IThemeService>();
@@ -174,7 +174,7 @@ public sealed partial class DockWindow : WindowEx,
if (_appBarData.hWnd != IntPtr.Zero)
{
var sameEdge = _appBarData.uEdge == side;
var sameSize = _lastSize == EffectiveDockSize(_settings);
var sameSize = _lastSize == _settings.DockSize;
if (sameEdge && sameSize)
{
UpdateTopmostState();
@@ -332,7 +332,7 @@ public sealed partial class DockWindow : WindowEx,
// Stash the last size we created the bar at, so we know when to hot-
// reload it
_lastSize = EffectiveDockSize(_settings);
_lastSize = _settings.DockSize;
UpdateWindowPosition();
}
@@ -384,9 +384,15 @@ public sealed partial class DockWindow : WindowEx,
var dpi = PInvoke.GetDpiForWindow(_hwnd);
var screenWidth = PInvoke.GetSystemMetrics(SYSTEM_METRICS_INDEX.SM_CXSCREEN);
// Get system border metrics
var borderWidth = PInvoke.GetSystemMetrics(SYSTEM_METRICS_INDEX.SM_CXBORDER);
var edgeWidth = PInvoke.GetSystemMetrics(SYSTEM_METRICS_INDEX.SM_CXEDGE);
var frameWidth = PInvoke.GetSystemMetrics(SYSTEM_METRICS_INDEX.SM_CXFRAME);
var scaleFactor = dpi / 96.0;
var effectiveSize = EffectiveDockSize(_settings);
UpdateAppBarDataForEdge(_settings.Side, effectiveSize, scaleFactor);
UpdateAppBarDataForEdge(_settings.Side, _settings.DockSize, scaleFactor);
// Query and set position
PInvoke.SHAppBarMessage(PInvoke.ABM_QUERYPOS, ref _appBarData);
@@ -400,16 +406,16 @@ public sealed partial class DockWindow : WindowEx,
switch (_settings.Side)
{
case DockSide.Top:
_appBarData.rc.bottom = _appBarData.rc.top + (int)(DockSettingsToViews.HeightForSize(effectiveSize) * scaleFactor);
_appBarData.rc.bottom = _appBarData.rc.top + (int)(DockSettingsToViews.HeightForSize(_settings.DockSize) * scaleFactor);
break;
case DockSide.Bottom:
_appBarData.rc.top = _appBarData.rc.bottom - (int)(DockSettingsToViews.HeightForSize(effectiveSize) * scaleFactor);
_appBarData.rc.top = _appBarData.rc.bottom - (int)(DockSettingsToViews.HeightForSize(_settings.DockSize) * scaleFactor);
break;
case DockSide.Left:
_appBarData.rc.right = _appBarData.rc.left + (int)(DockSettingsToViews.WidthForSize(effectiveSize) * scaleFactor);
_appBarData.rc.right = _appBarData.rc.left + (int)(DockSettingsToViews.WidthForSize(_settings.DockSize) * scaleFactor);
break;
case DockSide.Right:
_appBarData.rc.left = _appBarData.rc.right - (int)(DockSettingsToViews.WidthForSize(effectiveSize) * scaleFactor);
_appBarData.rc.left = _appBarData.rc.right - (int)(DockSettingsToViews.WidthForSize(_settings.DockSize) * scaleFactor);
break;
}
@@ -422,28 +428,23 @@ public sealed partial class DockWindow : WindowEx,
// PInvoke.SHAppBarMessage(ABM_SETSTATE, ref _appBarData);
// PInvoke.SHAppBarMessage(PInvoke.ABM_SETAUTOHIDEBAR, ref _appBarData);
// The dock window is borderless (SetBorderAndTitleBar(false, false),
// IsResizable = false) so no frame compensation is needed — the
// app bar rect matches the window rect exactly.
// Account for system borders when moving the window
// Adjust position to account for window frame/border
var adjustedLeft = _appBarData.rc.left - frameWidth;
var adjustedTop = _appBarData.rc.top - frameWidth;
var adjustedWidth = (_appBarData.rc.right - _appBarData.rc.left) + (2 * frameWidth);
var adjustedHeight = (_appBarData.rc.bottom - _appBarData.rc.top) + (2 * frameWidth);
// Move the actual window
PInvoke.MoveWindow(
_hwnd,
_appBarData.rc.left,
_appBarData.rc.top,
_appBarData.rc.right - _appBarData.rc.left,
_appBarData.rc.bottom - _appBarData.rc.top,
adjustedLeft,
adjustedTop,
adjustedWidth,
adjustedHeight,
true);
}
/// <summary>
/// Compact mode is only supported for Top/Bottom dock positions.
/// For Left/Right, always use Default size.
/// </summary>
private static DockSize EffectiveDockSize(DockSettings settings)
{
var isHorizontal = settings.Side == DockSide.Top || settings.Side == DockSide.Bottom;
return isHorizontal ? settings.DockSize : DockSize.Default;
}
private void UpdateAppBarDataForEdge(DockSide side, DockSize size, double scaleFactor)
{
Logger.LogDebug("UpdateAppBarDataForEdge");
@@ -586,21 +587,11 @@ public sealed partial class DockWindow : WindowEx,
}
}
// Handle WM_GETMINMAXINFO to allow the dock to be smaller than
// the default minimum window size (SM_CYMINTRACK ~36px).
// Handle WM_GETMINMAXINFO to control window size limits
else if (msg == PInvoke.WM_GETMINMAXINFO)
{
// Call the original WndProc first so it fills default values,
// then override the minimum tracking size.
var result = PInvoke.CallWindowProc(_originalWndProc, hwnd, msg, wParam, lParam);
unsafe
{
var minMaxInfo = (MINMAXINFO*)lParam.Value;
minMaxInfo->ptMinTrackSize.X = 1;
minMaxInfo->ptMinTrackSize.Y = 1;
}
return result;
// We can modify the min/max tracking info here if needed
// For now, let it pass through but we could restrict max size
}
// Handle the AppBarMessage message

View File

@@ -43,8 +43,6 @@ public sealed partial class ListPage : Page,
private ListItemViewModel? _stickySelectedItem;
private ListItemViewModel? _lastPushedToVm;
private long _pendingContextMenuOpenRequestId;
private Action? _cancelPendingContextMenuOpen;
// A single search-text change can produce multiple ItemsUpdated calls
// dispatched as separate UI-thread callbacks. A later "soft" update
@@ -126,8 +124,6 @@ public sealed partial class ListPage : Page,
{
base.OnNavigatingFrom(e);
CancelPendingContextMenuOpen();
WeakReferenceMessenger.Default.Unregister<NavigateNextCommand>(this);
WeakReferenceMessenger.Default.Unregister<NavigatePreviousCommand>(this);
WeakReferenceMessenger.Default.Unregister<NavigateLeftCommand>(this);
@@ -287,7 +283,17 @@ public sealed partial class ListPage : Page,
ViewModel?.UpdateSelectedItemCommand.Execute(item);
var pos = e.GetPosition(element);
RequestContextMenuOpen(item, element, pos);
_ = DispatcherQueue.TryEnqueue(
() =>
{
WeakReferenceMessenger.Default.Send<OpenContextMenuMessage>(
new OpenContextMenuMessage(
element,
FlyoutPlacementMode.BottomEdgeAlignedLeft,
pos,
ContextMenuFilterLocation.Top));
});
}
}
@@ -1008,14 +1014,21 @@ public sealed partial class ListPage : Page,
pos = new(0, element.ActualHeight);
}
ViewModel?.UpdateSelectedItemCommand.Execute(item);
RequestContextMenuOpen(item, element, pos);
_ = DispatcherQueue.TryEnqueue(
() =>
{
WeakReferenceMessenger.Default.Send<OpenContextMenuMessage>(
new OpenContextMenuMessage(
element,
FlyoutPlacementMode.BottomEdgeAlignedLeft,
pos,
ContextMenuFilterLocation.Top));
});
e.Handled = true;
}
private void Items_OnContextCanceled(UIElement sender, RoutedEventArgs e)
{
CancelPendingContextMenuOpen();
_ = DispatcherQueue.TryEnqueue(() => WeakReferenceMessenger.Default.Send<CloseContextMenuMessage>());
}
@@ -1197,87 +1210,6 @@ public sealed partial class ListPage : Page,
scroll.ChangeView(horizontalOffset: null, verticalOffset: 0, zoomFactor: null, disableAnimation: true);
}
private void RequestContextMenuOpen(ListItemViewModel item, FrameworkElement element, Point pos)
{
// BEAR LOADING: Right-click can arrive before the selected item's slow
// context-menu hydration completes, especially for out-of-proc
// providers. Keep this exact open request alive until the same
// selected item becomes context-openable instead of dropping the first
// click.
CancelPendingContextMenuOpen();
var requestId = Interlocked.Increment(ref _pendingContextMenuOpenRequestId);
System.ComponentModel.PropertyChangedEventHandler? onItemChanged = null;
Action? detach = null;
detach = () =>
{
if (onItemChanged is not null)
{
item.PropertyChanged -= onItemChanged;
}
if (ReferenceEquals(_cancelPendingContextMenuOpen, detach))
{
_cancelPendingContextMenuOpen = null;
}
};
onItemChanged = (_, args) =>
{
if (args.PropertyName is nameof(ListItemViewModel.CanOpenContextMenu) or nameof(ListItemViewModel.AllCommands) &&
TryOpenContextMenuIfReady(item, element, pos, requestId))
{
detach();
}
};
item.PropertyChanged += onItemChanged;
_cancelPendingContextMenuOpen = detach;
if (TryOpenContextMenuIfReady(item, element, pos, requestId))
{
detach();
}
}
private bool TryOpenContextMenuIfReady(ListItemViewModel item, FrameworkElement element, Point pos, long requestId)
{
// Ignore stale requests so rapid selection changes or cancelled opens
// can't resurrect an old context menu on the wrong item.
if (requestId != Volatile.Read(ref _pendingContextMenuOpenRequestId) ||
!ReferenceEquals(ItemView.SelectedItem, item) ||
!item.CanOpenContextMenu)
{
return false;
}
_ = DispatcherQueue.TryEnqueue(
() =>
{
if (requestId != Volatile.Read(ref _pendingContextMenuOpenRequestId) ||
!ReferenceEquals(ItemView.SelectedItem, item))
{
return;
}
WeakReferenceMessenger.Default.Send<OpenContextMenuMessage>(
new OpenContextMenuMessage(
element,
FlyoutPlacementMode.BottomEdgeAlignedLeft,
pos,
ContextMenuFilterLocation.Top));
});
return true;
}
private void CancelPendingContextMenuOpen()
{
Interlocked.Increment(ref _pendingContextMenuOpenRequestId);
_cancelPendingContextMenuOpen?.Invoke();
_cancelPendingContextMenuOpen = null;
}
private IDisposable SuppressSelectionChangedScope()
{
_suppressSelectionChanged = true;

View File

@@ -8,8 +8,8 @@ using CmdPalKeyboardService;
using CommunityToolkit.Mvvm.Messaging;
using ManagedCommon;
using Microsoft.CmdPal.Common.Helpers;
using Microsoft.CmdPal.Common.Messages;
using Microsoft.CmdPal.Common.Services;
using Microsoft.CmdPal.Ext.ClipboardHistory.Messages;
using Microsoft.CmdPal.UI.Controls;
using Microsoft.CmdPal.UI.Dock;
using Microsoft.CmdPal.UI.Events;

View File

@@ -38,11 +38,6 @@
<DefineConstants>$(DefineConstants)</DefineConstants>
</PropertyGroup>
<!-- Added to ensure telemetry events are triggered from AOT build -->
<PropertyGroup>
<EventSourceSupport>true</EventSourceSupport>
</PropertyGroup>
<!-- For debugging purposes, uncomment this block to enable AOT builds -->
<!-- <PropertyGroup>
<EnableCmdPalAOT>true</EnableCmdPalAOT>

View File

@@ -94,7 +94,6 @@ WM_WINDOWPOSCHANGING
WM_SHOWWINDOW
WM_SIZE
WM_GETMINMAXINFO
MINMAXINFO
SetWinEventHook
WINDOW_STYLE
SC_MINIMIZE

View File

@@ -67,20 +67,6 @@
</ComboBox>
</controls:SettingsCard>
<!-- Dock Size (only for Top/Bottom positions) -->
<controls:SettingsCard
x:Name="DockSizeSettingsCard"
x:Uid="DockAppearance_DockSize_SettingsCard"
HeaderIcon="{ui:FontIcon Glyph=&#xE799;}">
<ComboBox
x:Name="DockSizeComboBox"
MinWidth="{StaticResource SettingActionControlMinWidth}"
SelectedIndex="{x:Bind SelectedDockSizeIndex, Mode=TwoWay}">
<ComboBoxItem x:Uid="DockAppearance_DockSize_Default" />
<ComboBoxItem x:Uid="DockAppearance_DockSize_Compact" />
</ComboBox>
</controls:SettingsCard>
<controls:SettingsCard x:Uid="DockAppearance_AppTheme_SettingsCard" HeaderIcon="{ui:FontIcon Glyph=&#xE793;}">
<ComboBox MinWidth="{StaticResource SettingActionControlMinWidth}" SelectedIndex="{x:Bind ViewModel.DockAppearance.ThemeIndex, Mode=TwoWay}">
<ComboBoxItem x:Uid="Settings_GeneralPage_AppTheme_Mode_System_Automation" Tag="Default">

View File

@@ -42,9 +42,7 @@ public sealed partial class DockSettingsPage : Page
{
// Initialize UI controls to match current settings
DockPositionComboBox.SelectedIndex = SelectedSideIndex;
DockSizeComboBox.SelectedIndex = SelectedDockSizeIndex;
BackdropComboBox.SelectedIndex = SelectedBackdropIndex;
UpdateDockSizeCardVisibility();
}
private async void PickBackgroundImage_Click(object sender, RoutedEventArgs e)
@@ -110,11 +108,7 @@ public sealed partial class DockSettingsPage : Page
public int SelectedSideIndex
{
get => SideToSelectedIndex(ViewModel.Dock_Side);
set
{
ViewModel.Dock_Side = SelectedIndexToSide(value);
UpdateDockSizeCardVisibility();
}
set => ViewModel.Dock_Side = SelectedIndexToSide(value);
}
public int SelectedBackdropIndex
@@ -132,16 +126,18 @@ public sealed partial class DockSettingsPage : Page
// Conversion methods for ComboBox bindings
private static int DockSizeToSelectedIndex(DockSize size) => size switch
{
DockSize.Default => 0,
DockSize.Compact => 1,
DockSize.Small => 0,
DockSize.Medium => 1,
DockSize.Large => 2,
_ => 0,
};
private static DockSize SelectedIndexToDockSize(int index) => index switch
{
0 => DockSize.Default,
1 => DockSize.Compact,
_ => DockSize.Default,
0 => DockSize.Small,
1 => DockSize.Medium,
2 => DockSize.Large,
_ => DockSize.Small,
};
private static int SideToSelectedIndex(DockSide side) => side switch
@@ -176,13 +172,6 @@ public sealed partial class DockSettingsPage : Page
_ => DockBackdrop.Acrylic,
};
private void UpdateDockSizeCardVisibility()
{
var side = ViewModel.Dock_Side;
var isTopOrBottom = side == DockSide.Top || side == DockSide.Bottom;
DockSizeSettingsCard.Visibility = isTopOrBottom ? Visibility.Visible : Visibility.Collapsed;
}
private List<TopLevelViewModel> GetAllBands()
{
var allBands = new List<TopLevelViewModel>();

View File

@@ -939,18 +939,6 @@ Right-click to remove the key combination, thereby deactivating the shortcut.</v
<data name="DockAppearance_DockPosition_Bottom.Content" xml:space="preserve">
<value>Bottom</value>
</data>
<data name="DockAppearance_DockSize_SettingsCard.Header" xml:space="preserve">
<value>Size</value>
</data>
<data name="DockAppearance_DockSize_SettingsCard.Description" xml:space="preserve">
<value>Choose the dock size; subtitles of dock items are hidden in compact mode</value>
</data>
<data name="DockAppearance_DockSize_Default.Content" xml:space="preserve">
<value>Default</value>
</data>
<data name="DockAppearance_DockSize_Compact.Content" xml:space="preserve">
<value>Compact</value>
</data>
<data name="top_level_pin_command_name" xml:space="preserve">
<value>Pin to home</value>
<comment>Command name for pinning an item to the top level list of commands</comment>

View File

@@ -5,7 +5,6 @@
using System.Globalization;
using System.Linq;
using Microsoft.CmdPal.Ext.Calc.Helper;
using Microsoft.CommandPalette.Extensions;
using Microsoft.CommandPalette.Extensions.Toolkit;
using Microsoft.VisualStudio.TestTools.UnitTesting;
using Windows.Foundation;
@@ -19,49 +18,47 @@ public class CloseOnEnterTests
public void PrimaryIsCopy_WhenCloseOnEnterTrue()
{
var settings = new Settings(closeOnEnter: true);
TypedEventHandler<object, object> handleSave = (s, e) => { };
TypedEventHandler<object, object> handleReplace = (s, e) => { };
var item = ResultHelper.CreateResultForPage(
var item = ResultHelper.CreateResult(
4m,
CultureInfo.CurrentCulture,
CultureInfo.CurrentCulture,
"2+2",
settings,
handleSave,
handleReplace);
Assert.IsNotNull(item);
Assert.IsInstanceOfType(item.Command, typeof(CopyTextCommand));
Assert.IsTrue(item.MoreCommands.OfType<CommandContextItem>().All(command => command.Command is not SaveCommand));
var result = ((CopyTextCommand)item.Command).Result;
Assert.AreEqual(CommandResultKind.ShowToast, result.Kind);
var toastArgs = result.Args as ToastArgs;
Assert.IsNotNull(toastArgs);
Assert.AreEqual(CommandResultKind.Hide, ((CommandResult)toastArgs.Result).Kind);
var firstMore = item.MoreCommands.First();
Assert.IsInstanceOfType(firstMore, typeof(CommandContextItem));
Assert.IsInstanceOfType(((CommandItem)firstMore).Command, typeof(SaveCommand));
}
[TestMethod]
public void PrimaryIsCopy_WhenCloseOnEnterFalse()
public void PrimaryIsSave_WhenCloseOnEnterFalse()
{
var settings = new Settings(closeOnEnter: false);
TypedEventHandler<object, object> handleSave = (s, e) => { };
TypedEventHandler<object, object> handleReplace = (s, e) => { };
var item = ResultHelper.CreateResultForPage(
var item = ResultHelper.CreateResult(
4m,
CultureInfo.CurrentCulture,
CultureInfo.CurrentCulture,
"2+2",
settings,
handleSave,
handleReplace);
Assert.IsNotNull(item);
Assert.IsInstanceOfType(item.Command, typeof(CopyTextCommand));
Assert.IsTrue(item.MoreCommands.OfType<CommandContextItem>().All(command => command.Command is not SaveCommand));
Assert.IsInstanceOfType(item.Command, typeof(SaveCommand));
var result = ((CopyTextCommand)item.Command).Result;
Assert.AreEqual(CommandResultKind.ShowToast, result.Kind);
var toastArgs = result.Args as ToastArgs;
Assert.IsNotNull(toastArgs);
Assert.AreEqual(CommandResultKind.KeepOpen, ((CommandResult)toastArgs.Result).Kind);
var firstMore = item.MoreCommands.First();
Assert.IsInstanceOfType(firstMore, typeof(CommandContextItem));
Assert.IsInstanceOfType(((CommandItem)firstMore).Command, typeof(CopyTextCommand));
}
}

View File

@@ -1,98 +0,0 @@
// Copyright (c) Microsoft Corporation
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
using System;
using System.Globalization;
using System.Linq;
using Microsoft.CmdPal.Ext.Calc.Helper;
using Microsoft.CmdPal.Ext.Calc.Pages;
using Microsoft.CommandPalette.Extensions;
using Microsoft.CommandPalette.Extensions.Toolkit;
using Microsoft.VisualStudio.TestTools.UnitTesting;
using Windows.Foundation;
namespace Microsoft.CmdPal.Ext.Calc.UnitTests;
[TestClass]
public class PrimaryActionTests
{
[TestMethod]
public void PrimaryActionPaste_UsesPasteAsPrimaryAndCopyAsSecondary()
{
var settings = new Settings(primaryAction: PrimaryAction.Paste);
TypedEventHandler<object, object> handleReplace = (_, _) => { };
var item = ResultHelper.CreateResultForPage(
4m,
CultureInfo.CurrentCulture,
CultureInfo.CurrentCulture,
"2+2",
settings,
handleReplace);
Assert.IsNotNull(item);
Assert.IsInstanceOfType(item.Command, typeof(CalculatorPasteCommand));
var firstMore = item.MoreCommands.OfType<CommandContextItem>().FirstOrDefault();
Assert.IsNotNull(firstMore);
Assert.IsInstanceOfType(((CommandItem)firstMore).Command, typeof(CalculatorCopyCommand));
}
[TestMethod]
public void HistoryItemsUsePasteWhenPrimaryActionPaste()
{
var settings = new Settings(primaryAction: PrimaryAction.Paste);
settings.AddHistoryItem(new HistoryItem("2+2", "4", DateTime.UtcNow));
var page = new CalculatorListPage(settings);
var historyItem = page.GetItems().FirstOrDefault(item => item.Title == "4");
Assert.IsNotNull(historyItem);
Assert.IsInstanceOfType(historyItem.Command, typeof(CalculatorPasteCommand));
}
[DataTestMethod]
[DataRow(false)]
[DataRow(true)]
public void FallbackItemsUseCalculatorCommandsForCopyAndPaste(bool saveFallbackResultsToHistory)
{
var settings = new Settings(saveFallbackResultsToHistory: saveFallbackResultsToHistory);
var page = new CalculatorListPage(settings);
var item = new FallbackCalculatorItem(settings, page);
item.UpdateQuery("2+2");
Assert.IsInstanceOfType(item.Command, typeof(CalculatorCopyCommand));
Assert.IsInstanceOfType(GetFallbackSecondaryCommand(item), typeof(CalculatorPasteCommand));
}
[DataTestMethod]
[DataRow(false)]
[DataRow(true)]
public void FallbackItemsRespectPrimaryActionWhenHistorySavingToggles(bool saveFallbackResultsToHistory)
{
var settings = new Settings(
primaryAction: PrimaryAction.Paste,
saveFallbackResultsToHistory: saveFallbackResultsToHistory);
var page = new CalculatorListPage(settings);
var item = new FallbackCalculatorItem(settings, page);
item.UpdateQuery("2+2");
Assert.IsInstanceOfType(item.Command, typeof(CalculatorPasteCommand));
Assert.IsInstanceOfType(GetFallbackSecondaryCommand(item), typeof(CalculatorCopyCommand));
}
private static ICommand GetFallbackSecondaryCommand(FallbackCalculatorItem item)
{
var secondaryCommand = item.MoreCommands
.OfType<CommandContextItem>()
.Skip(1)
.Select(contextItem => ((CommandItem)contextItem).Command)
.FirstOrDefault();
Assert.IsNotNull(secondaryCommand);
return secondaryCommand;
}
}

View File

@@ -2,8 +2,6 @@
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
using System;
using System.Collections.Generic;
using Microsoft.CmdPal.Ext.Calc.Helper;
namespace Microsoft.CmdPal.Ext.Calc.UnitTests;
@@ -16,12 +14,7 @@ public class Settings : ISettingsInterface
private readonly bool closeOnEnter;
private readonly bool copyResultToSearchBarIfQueryEndsWithEqualSign;
private readonly bool autoFixQuery;
private readonly bool saveFallbackResultsToHistory;
private readonly bool deleteHistoryRequiresConfirmation;
private readonly PrimaryAction primaryAction;
private readonly bool inputNormalization;
private readonly List<HistoryItem> historyItems = [];
private readonly bool replaceQueryOnEnter;
public Settings(
CalculateEngine.TrigMode trigUnit = CalculateEngine.TrigMode.Radians,
@@ -30,11 +23,7 @@ public class Settings : ISettingsInterface
bool closeOnEnter = true,
bool copyResultToSearchBarIfQueryEndsWithEqualSign = true,
bool autoFixQuery = true,
bool saveFallbackResultsToHistory = false,
bool deleteHistoryRequiresConfirmation = true,
PrimaryAction primaryAction = PrimaryAction.Default,
bool inputNormalization = true,
bool replaceQueryOnEnter = true)
bool inputNormalization = true)
{
this.trigUnit = trigUnit;
this.inputUseEnglishFormat = inputUseEnglishFormat;
@@ -42,11 +31,7 @@ public class Settings : ISettingsInterface
this.closeOnEnter = closeOnEnter;
this.copyResultToSearchBarIfQueryEndsWithEqualSign = copyResultToSearchBarIfQueryEndsWithEqualSign;
this.autoFixQuery = autoFixQuery;
this.saveFallbackResultsToHistory = saveFallbackResultsToHistory;
this.deleteHistoryRequiresConfirmation = deleteHistoryRequiresConfirmation;
this.primaryAction = primaryAction;
this.inputNormalization = inputNormalization;
this.replaceQueryOnEnter = replaceQueryOnEnter;
}
public CalculateEngine.TrigMode TrigUnit => trigUnit;
@@ -61,46 +46,5 @@ public class Settings : ISettingsInterface
public bool AutoFixQuery => autoFixQuery;
public bool SaveFallbackResultsToHistory => saveFallbackResultsToHistory;
public bool DeleteHistoryRequiresConfirmation => deleteHistoryRequiresConfirmation;
public PrimaryAction PrimaryAction => primaryAction;
public bool InputNormalization => inputNormalization;
public event EventHandler HistoryChanged;
#pragma warning disable CS0067 // Event is never used
public event EventHandler SettingsChanged;
#pragma warning restore CS0067 // Event is never used
public IReadOnlyList<HistoryItem> HistoryItems => historyItems;
public bool ReplaceQueryOnEnter => replaceQueryOnEnter;
public void AddHistoryItem(HistoryItem historyItem)
{
historyItems.Add(historyItem);
HistoryChanged?.Invoke(this, EventArgs.Empty);
}
public void RemoveHistoryItem(Guid historyItemId)
{
if (historyItems.RemoveAll(item => item.Id == historyItemId) > 0)
{
HistoryChanged?.Invoke(this, EventArgs.Empty);
}
}
public void ClearHistory()
{
if (historyItems.Count == 0)
{
return;
}
historyItems.Clear();
HistoryChanged?.Invoke(this, EventArgs.Empty);
}
}

View File

@@ -2,8 +2,6 @@
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
using System;
using System.Globalization;
using Microsoft.CmdPal.Ext.Calc.Helper;
using Microsoft.VisualStudio.TestTools.UnitTesting;
@@ -35,9 +33,6 @@ public class SettingsManagerTests
Assert.IsFalse(settings.InputUseEnglishFormat);
Assert.IsFalse(settings.OutputUseEnglishFormat);
Assert.IsTrue(settings.CloseOnEnter);
Assert.IsTrue(settings.SaveFallbackResultsToHistory);
Assert.IsTrue(settings.DeleteHistoryRequiresConfirmation);
Assert.AreEqual(PrimaryAction.Default, settings.PrimaryAction);
}
[TestMethod]
@@ -57,38 +52,4 @@ public class SettingsManagerTests
Assert.IsTrue(settings.OutputUseEnglishFormat);
Assert.IsFalse(settings.CloseOnEnter);
}
[TestMethod]
public void HistorySettingsAddRemoveClearTest()
{
var settingsManager = new SettingsManager();
settingsManager.ClearHistory();
var historyItem = new HistoryItem("1+1", "2", DateTime.UtcNow);
settingsManager.AddHistoryItem(historyItem);
Assert.AreEqual(1, settingsManager.HistoryItems.Count);
settingsManager.RemoveHistoryItem(historyItem.Id);
Assert.AreEqual(0, settingsManager.HistoryItems.Count);
settingsManager.AddHistoryItem(new HistoryItem("2+2", "4", DateTime.UtcNow));
settingsManager.ClearHistory();
Assert.AreEqual(0, settingsManager.HistoryItems.Count);
}
[TestMethod]
public void HistorySettingsTrimsToCapacityTest()
{
var settingsManager = new SettingsManager();
settingsManager.ClearHistory();
for (var i = 0; i < 105; i++)
{
settingsManager.AddHistoryItem(new HistoryItem($"{i}+{i}", (i + i).ToString(CultureInfo.InvariantCulture), DateTime.UtcNow));
}
Assert.AreEqual(100, settingsManager.HistoryItems.Count);
settingsManager.ClearHistory();
}
}

View File

@@ -1,24 +0,0 @@
// Copyright (c) Microsoft Corporation
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
using Microsoft.CmdPal.Ext.Indexer.Indexer;
using Microsoft.CmdPal.Ext.Indexer.Properties;
using Microsoft.VisualStudio.TestTools.UnitTesting;
namespace Microsoft.CmdPal.Ext.Indexer.UnitTests;
[TestClass]
public class FallbackOpenFileItemTests
{
[TestMethod]
public void GetFallbackNoticeText_UsesExtensionNameAsTitle()
{
var notice = new SearchNoticeInfo(Resources.Indexer_SearchFailedMessage!, Resources.Indexer_SearchFailedMessageTip!);
var text = FallbackOpenFileItem.GetFallbackNoticeText(notice);
Assert.AreEqual(Resources.IndexerCommandsProvider_DisplayName, text.Title);
Assert.AreEqual(Resources.Indexer_SearchFailedMessage, text.Subtitle);
}
}

View File

@@ -1,78 +0,0 @@
// Copyright (c) Microsoft Corporation
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
using Microsoft.CmdPal.Ext.Indexer.Indexer.Utils;
using Microsoft.VisualStudio.TestTools.UnitTesting;
namespace Microsoft.CmdPal.Ext.Indexer.UnitTests;
[TestClass]
public class ImplicitWildcardQueryBuilderTests
{
[DataTestMethod]
[DataRow("term", null, "((CONTAINS(System.ItemNameDisplay, '\"term\"') OR CONTAINS(System.ItemNameDisplay, '\"term*\"')) OR System.FileName LIKE '%term%')", "System.FileName LIKE '%term%'")]
[DataRow("term Kind:Folder", "Kind:Folder", "((CONTAINS(System.ItemNameDisplay, '\"term\"') OR CONTAINS(System.ItemNameDisplay, '\"term*\"')) OR System.FileName LIKE '%term%')", "System.FileName LIKE '%term%'")]
[DataRow("System.Kind:folders term", "System.Kind:folders", "((CONTAINS(System.ItemNameDisplay, '\"term\"') OR CONTAINS(System.ItemNameDisplay, '\"term*\"')) OR System.FileName LIKE '%term%')", "System.FileName LIKE '%term%'")]
[DataRow("System.Kind:NOT folders term", "System.Kind:NOT folders", "((CONTAINS(System.ItemNameDisplay, '\"term\"') OR CONTAINS(System.ItemNameDisplay, '\"term*\"')) OR System.FileName LIKE '%term%')", "System.FileName LIKE '%term%'")]
[DataRow("\"two words\"", null, "((CONTAINS(System.ItemNameDisplay, '\"two words\"') OR CONTAINS(System.ItemNameDisplay, '\"two words*\"') OR CONTAINS(System.ItemNameDisplay, '\"two\" AND \"words\"') OR CONTAINS(System.ItemNameDisplay, '\"two*\" AND \"words*\"')) OR System.FileName LIKE '%two words%')", "System.FileName LIKE '%two words%'")]
[DataRow("foo bar", null, "((CONTAINS(System.ItemNameDisplay, '\"foo bar\"') OR CONTAINS(System.ItemNameDisplay, '\"foo bar*\"') OR CONTAINS(System.ItemNameDisplay, '\"foo\" AND \"bar\"') OR CONTAINS(System.ItemNameDisplay, '\"foo*\" AND \"bar*\"')) OR (System.FileName LIKE '%foo%' AND System.FileName LIKE '%bar%'))", "(System.FileName LIKE '%foo%' AND System.FileName LIKE '%bar%')")]
[DataRow("foo-bar", null, "((CONTAINS(System.ItemNameDisplay, '\"foo bar\"') OR CONTAINS(System.ItemNameDisplay, '\"foo bar*\"') OR CONTAINS(System.ItemNameDisplay, '\"foo\" AND \"bar\"') OR CONTAINS(System.ItemNameDisplay, '\"foo*\" AND \"bar*\"')) OR System.FileName LIKE '%foo-bar%')", "System.FileName LIKE '%foo-bar%'")]
[DataRow("foo & bar", null, "((CONTAINS(System.ItemNameDisplay, '\"foo bar\"') OR CONTAINS(System.ItemNameDisplay, '\"foo bar*\"') OR CONTAINS(System.ItemNameDisplay, '\"foo\" AND \"bar\"') OR CONTAINS(System.ItemNameDisplay, '\"foo*\" AND \"bar*\"')) OR (System.FileName LIKE '%foo%' AND System.FileName LIKE '%bar%'))", "(System.FileName LIKE '%foo%' AND System.FileName LIKE '%bar%')")]
[DataRow("tonträger", null, "((CONTAINS(System.ItemNameDisplay, '\"tonträger\"') OR CONTAINS(System.ItemNameDisplay, '\"tonträger*\"')) OR System.FileName LIKE '%tonträger%')", "System.FileName LIKE '%tonträger%'")]
[DataRow("O'Hara", null, "((CONTAINS(System.ItemNameDisplay, '\"Hara\"') OR CONTAINS(System.ItemNameDisplay, '\"Hara*\"')) OR System.FileName LIKE '%O''Hara%')", "System.FileName LIKE '%O''Hara%'")]
[DataRow("AT&T", null, "System.FileName LIKE '%AT&T%'", null)]
[DataRow("file_100%", null, "((CONTAINS(System.ItemNameDisplay, '\"file 100\"') OR CONTAINS(System.ItemNameDisplay, '\"file 100*\"') OR CONTAINS(System.ItemNameDisplay, '\"file\" AND \"100\"') OR CONTAINS(System.ItemNameDisplay, '\"file*\" AND \"100*\"')) OR System.FileName LIKE '%file[_]100[%]%')", "System.FileName LIKE '%file[_]100[%]%'")]
public void BuildExpandedQuery_BuildsExpectedRestrictions(string query, string expectedStructuredSearchText, string expectedPrimaryClause, string expectedFallbackClause)
{
var expandedQuery = ImplicitWildcardQueryBuilder.BuildExpandedQuery(query);
Assert.AreEqual(expectedStructuredSearchText, expandedQuery.StructuredSearchText);
Assert.AreEqual(expectedPrimaryClause, expandedQuery.PrimaryRestriction);
Assert.AreEqual(expectedFallbackClause, expandedQuery.FallbackRestriction);
}
[TestMethod]
public void BuildExpandedQuery_PreservesBracketWrappedTermAsLiteralOnly()
{
var expandedQuery = ImplicitWildcardQueryBuilder.BuildExpandedQuery("[red]");
Assert.AreEqual("System.FileName LIKE '%[[]red[]]%'", expandedQuery.PrimaryRestriction);
Assert.IsFalse(expandedQuery.HasFallbackRestriction);
}
[TestMethod]
public void BuildExpandedQuery_TreatsSinglePercentAsLiteralCharacter()
{
var expandedQuery = ImplicitWildcardQueryBuilder.BuildExpandedQuery("%");
Assert.AreEqual("System.FileName LIKE '%[%]%'", expandedQuery.PrimaryRestriction);
Assert.IsFalse(expandedQuery.HasFallbackRestriction);
}
[TestMethod]
public void BuildExpandedQuery_TreatsSingleUnderscoreAsLiteralCharacter()
{
var expandedQuery = ImplicitWildcardQueryBuilder.BuildExpandedQuery("_");
Assert.AreEqual("System.FileName LIKE '%[_]%'", expandedQuery.PrimaryRestriction);
Assert.IsFalse(expandedQuery.HasFallbackRestriction);
}
[DataTestMethod]
[DataRow("kind:folder")]
[DataRow("name:term")]
[DataRow("name: term")]
[DataRow("name:\"two words\"")]
[DataRow("*term*")]
[DataRow("C:\\Users")]
[DataRow("System.Kind:folders")]
[DataRow("kind:folder AND term")]
public void BuildExpandedQuery_DoesNotBroadenStructuredOrExplicitQueries(string query)
{
var expandedQuery = ImplicitWildcardQueryBuilder.BuildExpandedQuery(query);
Assert.IsFalse(expandedQuery.HasPrimaryRestriction);
Assert.IsFalse(expandedQuery.HasFallbackRestriction);
}
}

View File

@@ -1,21 +0,0 @@
<Project Sdk="Microsoft.NET.Sdk">
<!-- Look at Directory.Build.props in root for common stuff as well -->
<Import Project="$(RepoRoot)src\Common.Dotnet.CsWinRT.props" />
<PropertyGroup>
<IsPackable>false</IsPackable>
<IsTestProject>true</IsTestProject>
<RootNamespace>Microsoft.CmdPal.Ext.Indexer.UnitTests</RootNamespace>
<OutputPath>$(SolutionDir)$(Platform)\$(Configuration)\WinUI3Apps\CmdPal\tests\</OutputPath>
<AppendTargetFrameworkToOutputPath>false</AppendTargetFrameworkToOutputPath>
<AppendRuntimeIdentifierToOutputPath>false</AppendRuntimeIdentifierToOutputPath>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="MSTest" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\ext\Microsoft.CmdPal.Ext.Indexer\Microsoft.CmdPal.Ext.Indexer.csproj" />
</ItemGroup>
</Project>

View File

@@ -1,92 +0,0 @@
// Copyright (c) Microsoft Corporation
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
using Microsoft.CmdPal.Ext.Indexer.Indexer;
using Microsoft.CmdPal.Ext.Indexer.Properties;
using Microsoft.VisualStudio.TestTools.UnitTesting;
namespace Microsoft.CmdPal.Ext.Indexer.UnitTests;
[TestClass]
public class SearchNoticeInfoBuilderTests
{
[DataTestMethod]
[DataRow((int)SearchQuery.QueryState.NullDataSource)]
[DataRow((int)SearchQuery.QueryState.CreateSessionFailed)]
[DataRow((int)SearchQuery.QueryState.CreateCommandFailed)]
public void FromQueryStatus_ReturnsUnavailableNotice_ForInfrastructureFailures(int stateValue)
{
var state = (SearchQuery.QueryState)stateValue;
var notice = SearchNoticeInfoBuilder.FromQueryStatus(new SearchQuery.SearchExecutionStatus(state, null, "failure"));
Assert.IsNotNull(notice);
Assert.AreEqual(Resources.Indexer_SearchUnavailableMessage, notice.Value.Title);
Assert.AreEqual(Resources.Indexer_SearchUnavailableMessageTip, notice.Value.Subtitle);
}
[TestMethod]
public void FromQueryStatus_ReturnsUnavailableNotice_ForRpcFailures()
{
var notice = SearchNoticeInfoBuilder.FromQueryStatus(
new SearchQuery.SearchExecutionStatus(
SearchQuery.QueryState.ExecuteFailed,
unchecked((int)0x800706BA),
"RPC server unavailable"));
Assert.IsNotNull(notice);
Assert.AreEqual(Resources.Indexer_SearchUnavailableMessage, notice.Value.Title);
}
[TestMethod]
public void FromQueryStatus_ReturnsGenericFailureNotice_ForUnexpectedFailures()
{
var notice = SearchNoticeInfoBuilder.FromQueryStatus(
new SearchQuery.SearchExecutionStatus(
SearchQuery.QueryState.ExecuteFailed,
unchecked((int)0x80004005),
"unexpected"));
Assert.IsNotNull(notice);
Assert.AreEqual(Resources.Indexer_SearchFailedMessage, notice.Value.Title);
Assert.AreEqual(Resources.Indexer_SearchFailedMessageTip, notice.Value.Subtitle);
}
[DataTestMethod]
[DataRow((int)SearchQuery.QueryState.Completed)]
[DataRow((int)SearchQuery.QueryState.NoResults)]
[DataRow((int)SearchQuery.QueryState.AllNoise)]
public void FromQueryStatus_ReturnsNull_ForNonFailureStates(int stateValue)
{
var state = (SearchQuery.QueryState)stateValue;
var notice = SearchNoticeInfoBuilder.FromQueryStatus(new SearchQuery.SearchExecutionStatus(state, null, null));
Assert.IsNull(notice);
}
[TestMethod]
public void FromCatalogStatus_ReturnsIndexingNotice_WhenItemsArePending()
{
var notice = SearchNoticeInfoBuilder.FromCatalogStatus(new SearchCatalogStatus(42, null));
Assert.IsNotNull(notice);
Assert.AreEqual(Resources.Indexer_SearchIndexingMessage, notice.Value.Title);
StringAssert.Contains(notice.Value.Subtitle, "42");
}
[TestMethod]
public void FromCatalogStatus_ReturnsNull_WhenStatusReadFails()
{
var notice = SearchNoticeInfoBuilder.FromCatalogStatus(new SearchCatalogStatus(0, unchecked((int)0x800706BA)));
Assert.IsNull(notice);
}
[TestMethod]
public void FromCatalogStatus_ReturnsNull_WhenIndexingIsIdle()
{
var notice = SearchNoticeInfoBuilder.FromCatalogStatus(new SearchCatalogStatus(0, null));
Assert.IsNull(notice);
}
}

View File

@@ -78,25 +78,6 @@ public class CommandItemViewModelTests
Assert.AreEqual("Secondary", viewModel.SecondaryCommand.Name);
}
[TestMethod]
public void FastInitializeProperties_CreatesPrimaryContextItem()
{
// Context menus are opened from fast-initialized list items before slow init completes.
// The synthetic primary command must already exist so the first right-click can open the menu.
var pageContext = new TestPageContext();
var item = new CommandItem(new NoOpCommand { Name = "Primary" })
{
Title = "Primary",
};
var viewModel = new CommandItemViewModel(new(item), new(pageContext), DefaultContextMenuFactory.Instance);
viewModel.FastInitializeProperties();
Assert.AreEqual(1, viewModel.AllCommands.Count);
Assert.IsTrue(viewModel.CanOpenContextMenu);
Assert.AreEqual("Primary", ((CommandContextItemViewModel)viewModel.AllCommands[0]).Name);
}
[TestMethod]
public void LatePrimaryCommandCreation_AddsPrimaryToAllCommands()
{

View File

@@ -12,23 +12,20 @@ namespace Microsoft.CmdPal.Ext.Calc;
public partial class CalculatorCommandProvider : CommandProvider
{
private readonly ISettingsInterface _settings = new SettingsManager();
private readonly ListItem _listItem;
private readonly FallbackCalculatorItem _fallback;
private static ISettingsInterface settings = new SettingsManager();
private readonly ListItem _listItem = new(new CalculatorListPage(settings))
{
MoreCommands = [new CommandContextItem(((SettingsManager)settings).Settings.SettingsPage)],
};
private readonly FallbackCalculatorItem _fallback = new(settings);
public CalculatorCommandProvider()
{
Id = "com.microsoft.cmdpal.builtin.calculator";
DisplayName = Resources.calculator_display_name;
Icon = Icons.CalculatorIcon;
Settings = ((SettingsManager)_settings).Settings;
var calculatorListPage = new CalculatorListPage(_settings);
_listItem = new ListItem(calculatorListPage)
{
MoreCommands = [new CommandContextItem(((SettingsManager)_settings).Settings.SettingsPage)],
};
_fallback = new(_settings, calculatorListPage);
Settings = ((SettingsManager)settings).Settings;
}
public override ICommandItem[] TopLevelCommands() => [_listItem];

View File

@@ -1,56 +0,0 @@
// Copyright (c) Microsoft Corporation
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
using System;
using Microsoft.CommandPalette.Extensions;
using Microsoft.CommandPalette.Extensions.Toolkit;
using Windows.Foundation;
namespace Microsoft.CmdPal.Ext.Calc.Helper;
public sealed partial class CalculatorCopyCommand : CopyTextCommand
{
public event TypedEventHandler<object, object> ReplaceRequested;
private readonly ISettingsInterface _settings;
private readonly Func<bool> _canStoreHistory;
private string _query;
public CalculatorCopyCommand(string result, string query, ISettingsInterface settings, bool canStoreHistory = true)
: this(result, query, settings, () => canStoreHistory)
{
}
public CalculatorCopyCommand(string result, string query, ISettingsInterface settings, Func<bool> canStoreHistory)
: base(result)
{
ArgumentNullException.ThrowIfNull(settings);
ArgumentNullException.ThrowIfNull(canStoreHistory);
_settings = settings;
_canStoreHistory = canStoreHistory;
_query = query ?? string.Empty;
Name = Properties.Resources.calculator_copy_command_name;
Result = ResultHelper.CreateCopyCommandResult(settings.CloseOnEnter);
}
public void Update(string text, string query)
{
Text = text;
_query = query ?? string.Empty;
}
public override ICommandResult Invoke()
{
ClipboardHelper.SetText(Text);
if (_canStoreHistory())
{
_settings.AddHistoryItem(new HistoryItem(_query, Text, DateTime.UtcNow));
}
ReplaceRequested?.Invoke(this, null);
return Result;
}
}

View File

@@ -1,83 +0,0 @@
// Copyright (c) Microsoft Corporation
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
using System;
using System.Threading;
using CommunityToolkit.Mvvm.Messaging;
using Microsoft.CmdPal.Common.Messages;
using Microsoft.CmdPal.Ext.Calc.Properties;
using Microsoft.CommandPalette.Extensions;
using Microsoft.CommandPalette.Extensions.Toolkit;
using Windows.Foundation;
namespace Microsoft.CmdPal.Ext.Calc.Helper;
public sealed partial class CalculatorPasteCommand : InvokableCommand
{
public event TypedEventHandler<object, object> ReplaceRequested;
private readonly ISettingsInterface _settings;
private readonly Func<bool> _canStoreHistory;
private string _query;
private string _text;
public CalculatorPasteCommand(string result, string query, ISettingsInterface settings, bool canStoreHistory = true)
: this(result, query, settings, () => canStoreHistory)
{
}
public CalculatorPasteCommand(string result, string query, ISettingsInterface settings, Func<bool> canStoreHistory)
{
ArgumentNullException.ThrowIfNull(settings);
ArgumentNullException.ThrowIfNull(canStoreHistory);
_settings = settings;
_canStoreHistory = canStoreHistory;
_query = query ?? string.Empty;
_text = result;
Name = Resources.calculator_paste_command_name;
Icon = Icons.PasteIcon;
}
private static void HideWindow()
{
// TODO GH #524: This isn't great - this requires us to have Secret Sauce in
// the clipboard extension to be able to manipulate the HWND.
// We probably need to put some window manipulation into the API, but
// what form that takes is not clear yet.
WeakReferenceMessenger.Default.Send<HideWindowMessage>();
}
public void Update(string text, string query)
{
_text = text;
_query = query ?? string.Empty;
}
public override ICommandResult Invoke()
{
ClipboardHelper.SetText(_text);
if (_canStoreHistory())
{
_settings.AddHistoryItem(new HistoryItem(_query, _text, DateTime.UtcNow));
}
HideWindow();
// Give the window some time to hide, and allow the other app to gain focus.
// Since we don't currently have a way to wait until the other window is ready
// to receive input, we just wing it with a short delay.
Thread.Sleep(200);
PasteHelper.SendPasteKeyCombination();
ReplaceRequested?.Invoke(this, null);
return CommandResult.ShowToast(new ToastArgs()
{
Message = Resources.calculator_paste_toast_text,
Result = CommandResult.KeepOpen(),
});
}
}

View File

@@ -1,29 +0,0 @@
// Copyright (c) Microsoft Corporation
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
using System;
using Microsoft.CmdPal.Ext.Calc.Properties;
using Microsoft.CommandPalette.Extensions.Toolkit;
namespace Microsoft.CmdPal.Ext.Calc.Helper;
internal sealed partial class ClearHistoryCommand : InvokableCommand
{
private readonly ISettingsInterface _settings;
public ClearHistoryCommand(ISettingsInterface settings)
{
ArgumentNullException.ThrowIfNull(settings);
_settings = settings;
Name = Resources.calculator_history_delete_all;
Icon = Icons.DeleteIcon;
}
public override CommandResult Invoke()
{
_settings.ClearHistory();
return CommandResult.KeepOpen();
}
}

View File

@@ -1,31 +0,0 @@
// Copyright (c) Microsoft Corporation
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
using System;
using Microsoft.CmdPal.Ext.Calc.Properties;
using Microsoft.CommandPalette.Extensions.Toolkit;
namespace Microsoft.CmdPal.Ext.Calc.Helper;
internal sealed partial class DeleteHistoryItemCommand : InvokableCommand
{
private readonly ISettingsInterface _settings;
private readonly Guid _historyItemId;
public DeleteHistoryItemCommand(ISettingsInterface settings, Guid historyItemId)
{
ArgumentNullException.ThrowIfNull(settings);
_settings = settings;
_historyItemId = historyItemId;
Name = Resources.calculator_history_delete;
Icon = Icons.DeleteIcon;
}
public override CommandResult Invoke()
{
_settings.RemoveHistoryItem(_historyItemId);
return CommandResult.KeepOpen();
}
}

View File

@@ -1,17 +0,0 @@
// Copyright (c) Microsoft Corporation
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
using System;
using System.Collections.Generic;
using System.Text.Json.Serialization;
namespace Microsoft.CmdPal.Ext.Calc.Helper;
[JsonSerializable(typeof(DateTime))]
[JsonSerializable(typeof(Guid))]
[JsonSerializable(typeof(string))]
[JsonSerializable(typeof(HistoryItem))]
[JsonSerializable(typeof(List<HistoryItem>), TypeInfoPropertyName = "HistoryItemList")]
[JsonSourceGenerationOptions(UseStringEnumConverter = true, WriteIndented = true, IncludeFields = true, PropertyNameCaseInsensitive = true, AllowTrailingCommas = true)]
internal sealed partial class CalculatorJsonSerializationContext : JsonSerializerContext;

View File

@@ -1,30 +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.Ext.Calc.Helper;
public sealed class HistoryItem
{
public Guid Id { get; set; } = Guid.NewGuid();
public string Query { get; set; } = string.Empty;
public string Result { get; set; } = string.Empty;
public DateTime Timestamp { get; set; } = DateTime.UtcNow;
public HistoryItem()
{
}
public HistoryItem(string query, string result, DateTime timestamp)
{
Id = Guid.NewGuid();
Query = query;
Result = result;
Timestamp = timestamp;
}
}

View File

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

View File

@@ -2,17 +2,10 @@
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
using System;
using System.Collections.Generic;
namespace Microsoft.CmdPal.Ext.Calc.Helper;
public interface ISettingsInterface
{
public event EventHandler HistoryChanged;
public event EventHandler SettingsChanged;
public CalculateEngine.TrigMode TrigUnit { get; }
public bool InputUseEnglishFormat { get; }
@@ -21,23 +14,7 @@ public interface ISettingsInterface
public bool CloseOnEnter { get; }
public bool ReplaceQueryOnEnter { get; }
public bool CopyResultToSearchBarIfQueryEndsWithEqualSign { get; }
public bool AutoFixQuery { get; }
public bool SaveFallbackResultsToHistory { get; }
public bool DeleteHistoryRequiresConfirmation { get; }
public PrimaryAction PrimaryAction { get; }
public IReadOnlyList<HistoryItem> HistoryItems { get; }
public void AddHistoryItem(HistoryItem historyItem);
public void RemoveHistoryItem(Guid historyItemId);
public void ClearHistory();
}

View File

@@ -1,150 +0,0 @@
// Copyright (c) Microsoft Corporation
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
using System;
using System.Runtime.InteropServices;
using Microsoft.CommandPalette.Extensions.Toolkit;
using Windows.System;
namespace Microsoft.CmdPal.Ext.Calc.Helper;
internal static partial class PasteHelper
{
private const nuint IgnoreKeyEventFlag = 0x5555;
private static void SendSingleKeyboardInput(VirtualKey keyCode, KeyEventF keyStatus)
{
var input = new INPUT
{
type = INPUTTYPE.INPUT_KEYBOARD,
data = new InputUnion
{
ki = new KEYBDINPUT
{
wVk = (short)keyCode,
dwFlags = (uint)keyStatus,
// Any key event with the extraInfo set to this value will be ignored
// by the keyboard hook and sent to the system instead.
dwExtraInfo = IgnoreKeyEventFlag,
},
},
};
Span<INPUT> inputs = [input];
_ = SendInput(1, inputs, INPUT.Size);
}
private static bool IsKeyDown(VirtualKey key) => (GetAsyncKeyState((int)key) & 0x8000) != 0;
private static void ReleaseModifierIfPressed(VirtualKey key)
{
if (IsKeyDown(key))
{
SendSingleKeyboardInput(key, KeyEventF.KeyUp);
}
}
internal static void SendPasteKeyCombination()
{
ExtensionHost.LogMessage(new LogMessage { Message = "Sending paste keys..." });
// Only release modifier keys that are actually pressed
ReleaseModifierIfPressed(VirtualKey.LeftControl);
ReleaseModifierIfPressed(VirtualKey.RightControl);
ReleaseModifierIfPressed(VirtualKey.LeftWindows);
ReleaseModifierIfPressed(VirtualKey.RightWindows);
ReleaseModifierIfPressed(VirtualKey.LeftShift);
ReleaseModifierIfPressed(VirtualKey.RightShift);
ReleaseModifierIfPressed(VirtualKey.LeftMenu);
ReleaseModifierIfPressed(VirtualKey.RightMenu);
// Send Ctrl + V
SendSingleKeyboardInput(VirtualKey.Control, KeyEventF.KeyDown);
SendSingleKeyboardInput(VirtualKey.V, KeyEventF.KeyDown);
SendSingleKeyboardInput(VirtualKey.V, KeyEventF.KeyUp);
SendSingleKeyboardInput(VirtualKey.Control, KeyEventF.KeyUp);
ExtensionHost.LogMessage(new LogMessage { Message = "Paste sent" });
}
[LibraryImport("user32.dll")]
private static partial uint SendInput(uint nInputs, Span<INPUT> pInputs, int cbSize);
[LibraryImport("user32.dll")]
private static partial short GetAsyncKeyState(int vKey);
[StructLayout(LayoutKind.Sequential)]
[System.Diagnostics.CodeAnalysis.SuppressMessage("StyleCop.CSharp.NamingRules", "SA1307:Accessible fields should begin with upper-case letter", Justification = "Matching Native Structure")]
private struct INPUT
{
public INPUTTYPE type;
public InputUnion data;
public static int Size => Marshal.SizeOf<INPUT>();
}
[StructLayout(LayoutKind.Explicit)]
[System.Diagnostics.CodeAnalysis.SuppressMessage("StyleCop.CSharp.NamingRules", "SA1307:Accessible fields should begin with upper-case letter", Justification = "Matching Native Structure")]
private struct InputUnion
{
[FieldOffset(0)]
public MOUSEINPUT mi;
[FieldOffset(0)]
public KEYBDINPUT ki;
[FieldOffset(0)]
public HARDWAREINPUT hi;
}
[StructLayout(LayoutKind.Sequential)]
[System.Diagnostics.CodeAnalysis.SuppressMessage("StyleCop.CSharp.NamingRules", "SA1307:Accessible fields should begin with upper-case letter", Justification = "Matching Native Structure")]
private struct MOUSEINPUT
{
public int dx;
public int dy;
public int mouseData;
public uint dwFlags;
public uint time;
public nuint dwExtraInfo;
}
[StructLayout(LayoutKind.Sequential)]
[System.Diagnostics.CodeAnalysis.SuppressMessage("StyleCop.CSharp.NamingRules", "SA1307:Accessible fields should begin with upper-case letter", Justification = "Matching Native Structure")]
private struct KEYBDINPUT
{
public short wVk;
public short wScan;
public uint dwFlags;
public int time;
public nuint dwExtraInfo;
}
[StructLayout(LayoutKind.Sequential)]
[System.Diagnostics.CodeAnalysis.SuppressMessage("StyleCop.CSharp.NamingRules", "SA1307:Accessible fields should begin with upper-case letter", Justification = "Matching Native Structure")]
private struct HARDWAREINPUT
{
public int uMsg;
public short wParamL;
public short wParamH;
}
private enum INPUTTYPE : uint
{
INPUT_MOUSE = 0,
INPUT_KEYBOARD = 1,
INPUT_HARDWARE = 2,
}
[Flags]
private enum KeyEventF : uint
{
KeyDown = 0x0000,
ExtendedKey = 0x0001,
KeyUp = 0x0002,
Unicode = 0x0004,
Scancode = 0x0008,
}
}

View File

@@ -1,12 +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.Ext.Calc.Helper;
public enum PrimaryAction
{
Default,
Copy,
Paste,
}

View File

@@ -17,9 +17,14 @@ public static partial class QueryHelper
ISettingsInterface settings,
bool isFallbackSearch,
out string displayQuery,
TypedEventHandler<object, object> handleSave = null,
TypedEventHandler<object, object> handleReplace = null)
{
ArgumentNullException.ThrowIfNull(query);
if (!isFallbackSearch)
{
ArgumentNullException.ThrowIfNull(handleSave);
}
CultureInfo inputCulture =
settings.InputUseEnglishFormat ? new CultureInfo("en-us") : CultureInfo.CurrentCulture;
@@ -82,9 +87,13 @@ public static partial class QueryHelper
return errorMessage == default ? null : ErrorHandler.OnError(isFallbackSearch, query, errorMessage);
}
return isFallbackSearch
? ResultHelper.CreateResultForFallback(result.RoundedResult, inputCulture, outputCulture, displayQuery)
: ResultHelper.CreateResultForPage(result.RoundedResult, inputCulture, outputCulture, displayQuery, settings, handleReplace);
if (isFallbackSearch)
{
// Fallback search
return ResultHelper.CreateResult(result.RoundedResult, inputCulture, outputCulture, displayQuery);
}
return ResultHelper.CreateResult(result.RoundedResult, inputCulture, outputCulture, displayQuery, settings, handleSave, handleReplace);
}
catch (OverflowException)
{

View File

@@ -15,21 +15,13 @@ namespace Microsoft.CmdPal.Ext.Calc.Helper;
public static class ResultHelper
{
internal static CommandResult CreateCopyCommandResult(bool hideOnCopy)
{
return CommandResult.ShowToast(new ToastArgs
{
Message = Properties.Resources.calculator_copy_toast_text,
Result = hideOnCopy ? CommandResult.Hide() : CommandResult.KeepOpen(),
});
}
public static ListItem CreateResultForPage(
public static ListItem CreateResult(
decimal? roundedResult,
CultureInfo inputCulture,
CultureInfo outputCulture,
string query,
ISettingsInterface settings,
TypedEventHandler<object, object> handleSave,
TypedEventHandler<object, object> handleReplace)
{
// Return null when the expression is not a valid calculator query.
@@ -40,44 +32,33 @@ public static class ResultHelper
var result = roundedResult?.ToString(outputCulture);
// Create a SaveCommand and subscribe to the SaveRequested event
// This can append the result to the history list.
var saveCommand = new SaveCommand(result);
saveCommand.SaveRequested += handleSave;
var replaceCommand = new ReplaceQueryCommand();
replaceCommand.ReplaceRequested += handleReplace;
var copyCommand = new CalculatorCopyCommand(result, query, settings);
copyCommand.ReplaceRequested += ReplaceOnAction;
var pasteCommand = new CalculatorPasteCommand(result, query, settings);
pasteCommand.ReplaceRequested += ReplaceOnAction;
var usePaste = settings.PrimaryAction == PrimaryAction.Paste;
var primaryCommand = usePaste ? (ICommand)pasteCommand : copyCommand;
var secondaryCommand = usePaste ? (ICommand)copyCommand : pasteCommand;
var copyCommandItem = CreateResultItem(roundedResult, inputCulture, outputCulture, query, primaryCommand, settings.CloseOnEnter);
var copyCommandItem = CreateResult(roundedResult, inputCulture, outputCulture, query);
// No TextToSuggest on the main save command item. We don't want to keep suggesting what the result is,
// as the user is typing it.
return new ListItem(primaryCommand)
return new ListItem(settings.CloseOnEnter ? copyCommandItem.Command : saveCommand)
{
// Using CurrentCulture since this is user facing
Icon = Icons.ResultIcon,
Title = result,
Subtitle = query,
MoreCommands = [
new CommandContextItem(secondaryCommand),
new CommandContextItem(settings.CloseOnEnter ? saveCommand : copyCommandItem.Command),
new CommandContextItem(replaceCommand) { RequestedShortcut = KeyChords.CopyResultToSearchBox, },
..copyCommandItem.MoreCommands,
],
};
void ReplaceOnAction(object sender, object args)
{
if (settings.ReplaceQueryOnEnter)
{
handleReplace(sender, args);
}
}
}
public static ListItem CreateResultForFallback(decimal? roundedResult, CultureInfo inputCulture, CultureInfo outputCulture, string query)
public static ListItem CreateResult(decimal? roundedResult, CultureInfo inputCulture, CultureInfo outputCulture, string query)
{
// Return null when the expression is not a valid calculator query.
if (roundedResult is null)
@@ -85,13 +66,6 @@ public static class ResultHelper
return null;
}
var decimalResult = roundedResult?.ToString(outputCulture);
var copyCommand = CreateCopyCommand(decimalResult, Properties.Resources.calculator_copy_command_name, hideOnCopy: true);
return CreateResultItem(roundedResult, inputCulture, outputCulture, query, copyCommand, hideOnCopy: true);
}
private static ListItem CreateResultItem(decimal? roundedResult, CultureInfo inputCulture, CultureInfo outputCulture, string query, ICommand copyCommand, bool hideOnCopy)
{
var decimalResult = roundedResult?.ToString(outputCulture);
var decimalValue = (decimal)roundedResult;
@@ -109,7 +83,7 @@ public static class ResultHelper
try
{
var hexResult = BaseConverter.Convert(i, 16);
context.Add(new CommandContextItem(CreateCopyCommand(hexResult, Properties.Resources.calculator_copy_hex, hideOnCopy))
context.Add(new CommandContextItem(new CopyTextCommand(hexResult) { Name = Properties.Resources.calculator_copy_hex })
{
Title = hexResult,
});
@@ -123,7 +97,7 @@ public static class ResultHelper
try
{
var binaryResult = BaseConverter.Convert(i, 2);
context.Add(new CommandContextItem(CreateCopyCommand(binaryResult, Properties.Resources.calculator_copy_binary, hideOnCopy))
context.Add(new CommandContextItem(new CopyTextCommand(binaryResult) { Name = Properties.Resources.calculator_copy_binary })
{
Title = binaryResult,
});
@@ -137,7 +111,7 @@ public static class ResultHelper
try
{
var octalResult = BaseConverter.Convert(i, 8);
context.Add(new CommandContextItem(CreateCopyCommand(octalResult, Properties.Resources.calculator_copy_octal, hideOnCopy))
context.Add(new CommandContextItem(new CopyTextCommand(octalResult) { Name = Properties.Resources.calculator_copy_octal })
{
Title = octalResult,
});
@@ -153,7 +127,7 @@ public static class ResultHelper
Logger.LogError("Error creating integer context items", ex);
}
return new ListItem(copyCommand)
return new ListItem(new CopyTextCommand(decimalResult))
{
// Using CurrentCulture since this is user facing
Title = decimalResult,
@@ -162,15 +136,4 @@ public static class ResultHelper
MoreCommands = context.ToArray(),
};
}
private static CopyTextCommand CreateCopyCommand(string text, string name, bool hideOnCopy)
{
var command = new CopyTextCommand(text)
{
Name = name,
Result = CreateCopyCommandResult(hideOnCopy),
};
return command;
}
}

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.

View File

@@ -2,10 +2,8 @@
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
using System;
using System.Collections.Generic;
using System.IO;
using ManagedCommon;
using Microsoft.CommandPalette.Extensions.Toolkit;
namespace Microsoft.CmdPal.Ext.Calc.Helper;
@@ -13,17 +11,6 @@ namespace Microsoft.CmdPal.Ext.Calc.Helper;
public class SettingsManager : JsonSettingsManager, ISettingsInterface
{
private static readonly string _namespace = "calculator";
private const int HistoryCapacity = 100;
public event EventHandler HistoryChanged
{
add => _history.Changed += value;
remove => _history.Changed -= value;
}
public event EventHandler SettingsChanged;
private readonly HistoryStore _history;
private static string Namespaced(string propertyName) => $"{_namespace}.{propertyName}";
@@ -58,12 +45,6 @@ public class SettingsManager : JsonSettingsManager, ISettingsInterface
Properties.Resources.calculator_settings_close_on_enter_description,
true);
private readonly ToggleSetting _replaceQueryOnEnter = new(
Namespaced(nameof(ReplaceQueryOnEnter)),
Properties.Resources.calculator_settings_replace_query_on_enter,
Properties.Resources.calculator_settings_replace_query_on_enter_description,
true);
private readonly ToggleSetting _copyResultToSearchBarIfQueryEndsWithEqualSign = new(
Namespaced(nameof(CopyResultToSearchBarIfQueryEndsWithEqualSign)),
Properties.Resources.calculator_settings_copy_result_to_search_bar,
@@ -76,28 +57,6 @@ public class SettingsManager : JsonSettingsManager, ISettingsInterface
Properties.Resources.calculator_settings_auto_fix_query_description,
true);
private readonly ToggleSetting _saveFallbackResultsToHistory = new(
Namespaced(nameof(SaveFallbackResultsToHistory)),
Properties.Resources.calculator_settings_fallback_history,
Properties.Resources.calculator_settings_fallback_history_description,
true);
private readonly ToggleSetting _confirmDelete = new(
Namespaced(nameof(DeleteHistoryRequiresConfirmation)),
Properties.Resources.calculator_settings_confirm_delete_title,
Properties.Resources.calculator_settings_confirm_delete_description,
true);
private readonly ChoiceSetSetting _primaryAction = new(
Namespaced(nameof(PrimaryAction)),
Properties.Resources.calculator_settings_primary_action_title,
Properties.Resources.calculator_settings_primary_action_description,
[
new ChoiceSetSetting.Choice(Properties.Resources.calculator_settings_primary_action_default, PrimaryAction.Default.ToString("G")),
new ChoiceSetSetting.Choice(Properties.Resources.calculator_settings_primary_action_copy, PrimaryAction.Copy.ToString("G")),
new ChoiceSetSetting.Choice(Properties.Resources.calculator_settings_primary_action_paste, PrimaryAction.Paste.ToString("G")),
]);
public CalculateEngine.TrigMode TrigUnit
{
get
@@ -134,20 +93,10 @@ public class SettingsManager : JsonSettingsManager, ISettingsInterface
public bool CloseOnEnter => _closeOnEnter.Value;
public bool ReplaceQueryOnEnter => _replaceQueryOnEnter.Value;
public bool CopyResultToSearchBarIfQueryEndsWithEqualSign => _copyResultToSearchBarIfQueryEndsWithEqualSign.Value;
public bool AutoFixQuery => _autoFixQuery.Value;
public bool SaveFallbackResultsToHistory => _saveFallbackResultsToHistory.Value;
public bool DeleteHistoryRequiresConfirmation => _confirmDelete.Value;
public PrimaryAction PrimaryAction => Enum.TryParse<PrimaryAction>(_primaryAction.Value, out var action) ? action : PrimaryAction.Default;
public IReadOnlyList<HistoryItem> HistoryItems => _history.HistoryItems;
internal static string SettingsJsonPath()
{
var directory = Utilities.BaseSettingsPath("Microsoft.CmdPal");
@@ -164,68 +113,11 @@ public class SettingsManager : JsonSettingsManager, ISettingsInterface
Settings.Add(_inputUseEnNumberFormat);
Settings.Add(_outputUseEnNumberFormat);
Settings.Add(_closeOnEnter);
Settings.Add(_replaceQueryOnEnter);
Settings.Add(_copyResultToSearchBarIfQueryEndsWithEqualSign);
Settings.Add(_autoFixQuery);
Settings.Add(_saveFallbackResultsToHistory);
Settings.Add(_confirmDelete);
Settings.Add(_primaryAction);
LoadSettings();
_history = new HistoryStore(HistoryStateJsonPath(), HistoryCapacity);
Settings.SettingsChanged += (s, a) =>
{
this.SaveSettings();
SettingsChanged?.Invoke(this, EventArgs.Empty);
};
}
private static string HistoryStateJsonPath()
{
var directory = Utilities.BaseSettingsPath("Microsoft.CmdPal");
Directory.CreateDirectory(directory);
return Path.Combine(directory, "calculator_history.json");
}
public void AddHistoryItem(HistoryItem historyItem)
{
try
{
_history.Add(historyItem);
}
catch (Exception ex)
{
Logger.LogError("Failed to add item to the calculator history", ex);
ExtensionHost.LogMessage(new LogMessage() { Message = ex.ToString() });
}
}
public void RemoveHistoryItem(Guid historyItemId)
{
try
{
_history.Remove(historyItemId);
}
catch (Exception ex)
{
Logger.LogError("Failed to remove item from the calculator history", ex);
ExtensionHost.LogMessage(new LogMessage() { Message = ex.ToString() });
}
}
public void ClearHistory()
{
try
{
_history.Clear();
}
catch (Exception ex)
{
Logger.LogError("Failed to clear calculator history", ex);
ExtensionHost.LogMessage(new LogMessage() { Message = ex.ToString() });
}
Settings.SettingsChanged += (s, a) => this.SaveSettings();
}
}

View File

@@ -6,7 +6,7 @@ using Microsoft.CommandPalette.Extensions.Toolkit;
namespace Microsoft.CmdPal.Ext.Calc;
internal static class Icons
internal sealed class Icons
{
internal static IconInfo CalculatorIcon => IconHelpers.FromRelativePath("Assets\\Calculator.svg");
@@ -14,11 +14,5 @@ internal static class Icons
internal static IconInfo SaveIcon => new("\uE74E"); // Save icon
internal static IconInfo DeleteIcon => new("\uE74D"); // Delete icon
internal static IconInfo HistoryIcon => new("\uE81C"); // History icon
internal static IconInfo PasteIcon => new("\uE77F"); // Paste icon
internal static IconInfo ErrorIcon => new("\uE783"); // Error icon
}

View File

@@ -10,8 +10,4 @@ namespace Microsoft.CmdPal.Ext.Calc;
internal static class KeyChords
{
internal static KeyChord CopyResultToSearchBox { get; } = new(VirtualKeyModifiers.Control | VirtualKeyModifiers.Shift, (int)VirtualKey.Enter, 0);
internal static KeyChord DeleteItemFromHistory { get; } = new(VirtualKeyModifiers.Control | VirtualKeyModifiers.Shift, (int)VirtualKey.Delete, 0);
internal static KeyChord ClearHistory { get; } = new(VirtualKeyModifiers.Control | VirtualKeyModifiers.Shift | VirtualKeyModifiers.Menu, (int)VirtualKey.Delete, 0);
}

View File

@@ -18,14 +18,8 @@
<ItemGroup>
<ProjectReference Include="..\..\..\..\common\CalculatorEngineCommon\CalculatorEngineCommon.vcxproj" />
<ProjectReference Include="..\..\..\..\common\ManagedCommon\ManagedCommon.csproj" />
<ProjectReference Include="..\..\Microsoft.CmdPal.Common\Microsoft.CmdPal.Common.csproj" />
<!-- CmdPal Toolkit reference now included via Common.ExtDependencies.props -->
</ItemGroup>
<ItemGroup>
<PackageReference Include="CommunityToolkit.Mvvm" />
</ItemGroup>
<ItemGroup>
<CsWinRTInputs Include="..\..\..\..\..\$(Platform)\$(Configuration)\CalculatorEngineCommon.winmd" />
<None Include="..\..\..\..\..\$(Platform)\$(Configuration)\CalculatorEngineCommon.winmd" Link="CalculatorEngineCommon.winmd">

View File

@@ -2,10 +2,8 @@
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
using System;
using System.Collections.Generic;
using System.Threading;
using Microsoft.CmdPal.Common.Commands;
using Microsoft.CmdPal.Ext.Calc.Helper;
using Microsoft.CmdPal.Ext.Calc.Properties;
using Microsoft.CommandPalette.Extensions;
@@ -25,11 +23,10 @@ namespace Microsoft.CmdPal.Ext.Calc.Pages;
public sealed partial class CalculatorListPage : DynamicListPage
{
private readonly Lock _resultsLock = new();
private readonly Lock _historyLock = new();
private readonly ISettingsInterface _settingsManager;
private readonly List<ListItem> _items = [];
private readonly List<ListItem> _history = [];
private readonly ListItem _emptyItem;
private List<ListItem> _historyItems = [];
// This is the text that saved when the user click the result.
// We need to avoid the double calculation. This may cause some wierd behaviors.
@@ -54,26 +51,9 @@ public sealed partial class CalculatorListPage : DynamicListPage
Title = Resources.calculator_placeholder_text,
};
_settingsManager.HistoryChanged += SettingsManagerOnHistoryChanged;
_settingsManager.SettingsChanged += SettingsManagerOnSettingsChanged;
UpdateHistory();
AppendResult(null);
UpdateSearchText(string.Empty, string.Empty);
}
private void SettingsManagerOnHistoryChanged(object sender, EventArgs e)
{
UpdateHistory();
AppendResult(GetCurrentResultItem());
}
private void SettingsManagerOnSettingsChanged(object sender, EventArgs e)
{
UpdateHistory();
AppendResult(RequeryCurrentResult());
}
private void HandleReplaceQuery(object sender, object args)
{
var lastResult = _items[0].Title;
@@ -110,9 +90,9 @@ public sealed partial class CalculatorListPage : DynamicListPage
_emptyItem.Subtitle = newSearch;
var result = QueryHelper.Query(newSearch, _settingsManager, isFallbackSearch: false, out var displayQuery, HandleReplaceQuery);
var result = QueryHelper.Query(newSearch, _settingsManager, isFallbackSearch: false, out var displayQuery, HandleSave, HandleReplaceQuery);
AppendResult(result);
UpdateResult(result);
if (copyResultToSearchText && result is not null)
{
@@ -125,7 +105,7 @@ public sealed partial class CalculatorListPage : DynamicListPage
}
}
private void AppendResult(ListItem result)
private void UpdateResult(ListItem result)
{
lock (_resultsLock)
{
@@ -140,114 +120,42 @@ public sealed partial class CalculatorListPage : DynamicListPage
_items.Add(_emptyItem);
}
lock (_historyLock)
{
if (_historyItems.Count > 0)
{
this._items.Add(CreateSectionHeader(Resources.calculator_history_header));
this._items.AddRange(_historyItems);
}
}
this._items.AddRange(_history);
}
RaiseItemsChanged(this._items.Count);
}
private void UpdateHistory()
private void HandleSave(object sender, object args)
{
List<ListItem> history = [];
var items = _settingsManager.HistoryItems;
for (var index = items.Count - 1; index >= 0; index--)
var lastResult = _items[0].Title;
if (!string.IsNullOrEmpty(lastResult))
{
var historyItem = items[index];
history.Add(CreateHistoryItem(historyItem));
}
var li = new ListItem(new CopyTextCommand(lastResult))
{
Title = _items[0].Title,
Subtitle = _items[0].Subtitle,
TextToSuggest = lastResult,
};
lock (_historyLock)
{
_historyItems = history;
}
}
_history.Insert(0, li);
_items.Insert(1, li);
private ListItem CreateHistoryItem(HistoryItem historyItem)
{
var copyCommand = new CalculatorCopyCommand(historyItem.Result, historyItem.Query, _settingsManager, canStoreHistory: false);
var pasteCommand = new CalculatorPasteCommand(historyItem.Result, historyItem.Query, _settingsManager, canStoreHistory: false);
var primaryCommand = _settingsManager.PrimaryAction == PrimaryAction.Paste ? (ICommand)pasteCommand : copyCommand;
var secondaryCommand = _settingsManager.PrimaryAction == PrimaryAction.Paste ? (ICommand)copyCommand : pasteCommand;
// Why we need to clean the query record? Removed, but if necessary, please move it back.
// _items[0].Subtitle = string.Empty;
var replaceResultCommand = new ReplaceQueryCommand();
replaceResultCommand.ReplaceRequested += (_, _) =>
{
_skipQuerySearchText = SearchText = historyItem.Result;
// this change will call the UpdateSearchText again.
// We need to avoid it.
_skipQuerySearchText = lastResult;
SearchText = lastResult;
// LOAD BEARING: The SearchText setter does not raise a PropertyChanged notification,
// so we must raise it explicitly to ensure the UI updates correctly.
OnPropertyChanged(nameof(SearchText));
};
var deleteConfirmationCommand = new ConfirmableCommand
{
Command = new DeleteHistoryItemCommand(_settingsManager, historyItem.Id),
ConfirmationTitle = Resources.calculator_delete_confirmation_title,
ConfirmationMessage = Resources.calculator_delete_confirmation_message,
IsConfirmationRequired = () => _settingsManager.DeleteHistoryRequiresConfirmation,
};
var deleteAllConfirmationCommand = new ConfirmableCommand
{
Command = new ClearHistoryCommand(_settingsManager),
ConfirmationTitle = Resources.calculator_delete_all_confirmation_title,
ConfirmationMessage = Resources.calculator_delete_all_confirmation_message,
IsConfirmationRequired = () => _settingsManager.DeleteHistoryRequiresConfirmation,
};
return new ListItem(primaryCommand)
{
Icon = Icons.HistoryIcon,
Title = historyItem.Result,
Subtitle = historyItem.Query,
TextToSuggest = historyItem.Result,
MoreCommands =
[
new CommandContextItem(secondaryCommand),
new CommandContextItem(replaceResultCommand),
new Separator(),
new CommandContextItem(deleteConfirmationCommand) { IsCritical = true, RequestedShortcut = KeyChords.DeleteItemFromHistory, },
new CommandContextItem(deleteAllConfirmationCommand) { IsCritical = true, RequestedShortcut = KeyChords.ClearHistory, },
],
};
}
private ListItem GetCurrentResultItem()
{
lock (_resultsLock)
{
return _items.Count > 0 ? _items[0] : _emptyItem;
RaiseItemsChanged(this._items.Count);
}
}
private ListItem RequeryCurrentResult()
{
var searchText = SearchText ?? string.Empty;
if (string.IsNullOrEmpty(searchText))
{
return null;
}
return QueryHelper.Query(searchText, _settingsManager, isFallbackSearch: false, out _, HandleReplaceQuery);
}
public override IListItem[] GetItems() => _items.ToArray();
private static ListItem CreateSectionHeader(string title)
{
return new ListItem(new NoOpCommand())
{
Title = title,
Section = title,
Command = null!,
};
}
}

View File

@@ -2,10 +2,8 @@
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
using System.Collections.Generic;
using Microsoft.CmdPal.Ext.Calc.Helper;
using Microsoft.CmdPal.Ext.Calc.Properties;
using Microsoft.CommandPalette.Extensions;
using Microsoft.CommandPalette.Extensions.Toolkit;
namespace Microsoft.CmdPal.Ext.Calc.Pages;
@@ -13,30 +11,18 @@ namespace Microsoft.CmdPal.Ext.Calc.Pages;
public sealed partial class FallbackCalculatorItem : FallbackCommandItem
{
private const string _id = "com.microsoft.cmdpal.builtin.calculator.fallback";
private readonly NoOpCommand _noOpCommand = new();
private readonly CalculatorCopyCommand _copyCommand;
private readonly CalculatorPasteCommand _pasteCommand;
private readonly CopyTextCommand _copyCommand = new(string.Empty);
private readonly ISettingsInterface _settings;
private readonly CalculatorListPage _calculatorListPage;
private readonly CommandContextItem _openCalculatorPageContextItem;
public FallbackCalculatorItem(ISettingsInterface settings, CalculatorListPage calculatorListPage)
public FallbackCalculatorItem(ISettingsInterface settings)
: base(new NoOpCommand(), Resources.calculator_title, _id)
{
_copyCommand = new CalculatorCopyCommand(string.Empty, string.Empty, settings, () => settings.SaveFallbackResultsToHistory);
_pasteCommand = new CalculatorPasteCommand(string.Empty, string.Empty, settings, () => settings.SaveFallbackResultsToHistory);
Command = _noOpCommand;
Command = _copyCommand;
_copyCommand.Name = string.Empty;
Title = string.Empty;
Subtitle = Resources.calculator_placeholder_text;
Icon = Icons.CalculatorIcon;
_settings = settings;
_calculatorListPage = calculatorListPage;
_openCalculatorPageContextItem = new CommandContextItem(_calculatorListPage)
{
Title = Resources.calculator_open_in_calculator,
};
}
public override void UpdateQuery(string query)
@@ -45,22 +31,16 @@ public sealed partial class FallbackCalculatorItem : FallbackCommandItem
if (result is null)
{
Command = _noOpCommand;
_copyCommand.Text = string.Empty;
_copyCommand.Name = string.Empty;
Title = string.Empty;
Subtitle = string.Empty;
MoreCommands = [];
return;
}
var pasteIsPrimary = _settings.PrimaryAction == PrimaryAction.Paste;
var primaryCommand = pasteIsPrimary ? (IInvokableCommand)_pasteCommand : _copyCommand;
var secondaryCommand = pasteIsPrimary ? (IInvokableCommand)_copyCommand : _pasteCommand;
// Update the selected commands with current query/result
UpdateCommand(primaryCommand, query, result);
UpdateCommand(secondaryCommand, query, result);
Command = primaryCommand;
_copyCommand.Text = result.Title;
_copyCommand.Name = string.IsNullOrWhiteSpace(query) ? string.Empty : Resources.calculator_copy_command_name;
Title = result.Title;
// we have to make the subtitle into an equation,
@@ -68,28 +48,6 @@ public sealed partial class FallbackCalculatorItem : FallbackCommandItem
// Otherwise, something like 1+2 will have a title of "3" and not match
Subtitle = query;
// Set the search text in the calculator list page
_calculatorListPage.SearchText = query;
var fallbackCommands = new List<IContextItem>
{
_openCalculatorPageContextItem,
new CommandContextItem(secondaryCommand),
};
MoreCommands = [.. fallbackCommands, .. result.MoreCommands];
}
private static void UpdateCommand(IInvokableCommand command, string query, ListItem result)
{
switch (command)
{
case CalculatorPasteCommand pasteCommand:
pasteCommand.Update(result.Title, query);
break;
case CalculatorCopyCommand copyCommand:
copyCommand.Update(result.Title, query);
break;
}
MoreCommands = result.MoreCommands;
}
}

View File

@@ -105,51 +105,6 @@ namespace Microsoft.CmdPal.Ext.Calc.Properties {
}
}
/// <summary>
/// Looks up a localized string similar to Copied to clipboard.
/// </summary>
public static string calculator_copy_toast_text {
get {
return ResourceManager.GetString("calculator_copy_toast_text", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to Are you sure you want to delete all history items?.
/// </summary>
public static string calculator_delete_all_confirmation_message {
get {
return ResourceManager.GetString("calculator_delete_all_confirmation_message", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to Delete all history.
/// </summary>
public static string calculator_delete_all_confirmation_title {
get {
return ResourceManager.GetString("calculator_delete_all_confirmation_title", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to Are you sure you want to delete this history item?.
/// </summary>
public static string calculator_delete_confirmation_message {
get {
return ResourceManager.GetString("calculator_delete_confirmation_message", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to Delete history item.
/// </summary>
public static string calculator_delete_confirmation_title {
get {
return ResourceManager.GetString("calculator_delete_confirmation_title", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to Calculator.
/// </summary>
@@ -204,33 +159,6 @@ namespace Microsoft.CmdPal.Ext.Calc.Properties {
}
}
/// <summary>
/// Looks up a localized string similar to Delete.
/// </summary>
public static string calculator_history_delete {
get {
return ResourceManager.GetString("calculator_history_delete", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to Delete all.
/// </summary>
public static string calculator_history_delete_all {
get {
return ResourceManager.GetString("calculator_history_delete_all", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to History.
/// </summary>
public static string calculator_history_header {
get {
return ResourceManager.GetString("calculator_history_header", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to Calculation result is not a valid number (NaN).
/// </summary>
@@ -249,33 +177,6 @@ namespace Microsoft.CmdPal.Ext.Calc.Properties {
}
}
/// <summary>
/// Looks up a localized string similar to Open in calculator.
/// </summary>
public static string calculator_open_in_calculator {
get {
return ResourceManager.GetString("calculator_open_in_calculator", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to Paste.
/// </summary>
public static string calculator_paste_command_name {
get {
return ResourceManager.GetString("calculator_paste_command_name", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to Pasted from calculator.
/// </summary>
public static string calculator_paste_toast_text {
get {
return ResourceManager.GetString("calculator_paste_toast_text", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to Type an equation....
/// </summary>
@@ -330,24 +231,6 @@ namespace Microsoft.CmdPal.Ext.Calc.Properties {
}
}
/// <summary>
/// Looks up a localized string similar to Prompt before deleting history entries.
/// </summary>
public static string calculator_settings_confirm_delete_description {
get {
return ResourceManager.GetString("calculator_settings_confirm_delete_description", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to Ask for confirmation before deleting items.
/// </summary>
public static string calculator_settings_confirm_delete_title {
get {
return ResourceManager.GetString("calculator_settings_confirm_delete_title", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to Replace query with result on equals.
/// </summary>
@@ -366,24 +249,6 @@ namespace Microsoft.CmdPal.Ext.Calc.Properties {
}
}
/// <summary>
/// Looks up a localized string similar to Save fallback calculations.
/// </summary>
public static string calculator_settings_fallback_history {
get {
return ResourceManager.GetString("calculator_settings_fallback_history", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to Save copied results from fallback calculations to history.
/// </summary>
public static string calculator_settings_fallback_history_description {
get {
return ResourceManager.GetString("calculator_settings_fallback_history_description", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to Use English (United States) number format for input.
/// </summary>
@@ -438,51 +303,6 @@ namespace Microsoft.CmdPal.Ext.Calc.Properties {
}
}
/// <summary>
/// Looks up a localized string similar to Copy.
/// </summary>
public static string calculator_settings_primary_action_copy {
get {
return ResourceManager.GetString("calculator_settings_primary_action_copy", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to Default.
/// </summary>
public static string calculator_settings_primary_action_default {
get {
return ResourceManager.GetString("calculator_settings_primary_action_default", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to Choose the default action for results.
/// </summary>
public static string calculator_settings_primary_action_description {
get {
return ResourceManager.GetString("calculator_settings_primary_action_description", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to Paste.
/// </summary>
public static string calculator_settings_primary_action_paste {
get {
return ResourceManager.GetString("calculator_settings_primary_action_paste", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to Primary action (Enter key).
/// </summary>
public static string calculator_settings_primary_action_title {
get {
return ResourceManager.GetString("calculator_settings_primary_action_title", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to Replace input if query ends with &apos;=&apos;.
/// </summary>
@@ -501,24 +321,6 @@ namespace Microsoft.CmdPal.Ext.Calc.Properties {
}
}
/// <summary>
/// Looks up a localized string similar to Replace query on Enter.
/// </summary>
public static string calculator_settings_replace_query_on_enter {
get {
return ResourceManager.GetString("calculator_settings_replace_query_on_enter", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to Replace input when executing Copy or Paste.
/// </summary>
public static string calculator_settings_replace_query_on_enter_description {
get {
return ResourceManager.GetString("calculator_settings_replace_query_on_enter_description", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to Degrees.
/// </summary>

View File

@@ -140,24 +140,6 @@
<data name="calculator_copy_command_name" xml:space="preserve">
<value>Copy</value>
</data>
<data name="calculator_paste_command_name" xml:space="preserve">
<value>Paste</value>
</data>
<data name="calculator_paste_toast_text" xml:space="preserve">
<value>Pasted from calculator</value>
</data>
<data name="calculator_copy_toast_text" xml:space="preserve">
<value>Copied to clipboard</value>
</data>
<data name="calculator_history_delete" xml:space="preserve">
<value>Delete</value>
</data>
<data name="calculator_history_header" xml:space="preserve">
<value>History</value>
</data>
<data name="calculator_history_delete_all" xml:space="preserve">
<value>Delete all</value>
</data>
<data name="calculator_calculation_failed_title" xml:space="preserve">
<value>Failed to calculate the input</value>
</data>
@@ -217,9 +199,6 @@
<data name="calculator_not_covert_to_decimal" xml:space="preserve">
<value>Result value was either too large or too small for a decimal number</value>
</data>
<data name="calculator_open_in_calculator" xml:space="preserve">
<value>Open in calculator</value>
</data>
<data name="calculator_copy_hex" xml:space="preserve">
<value>Copy hexadecimal</value>
</data>
@@ -238,45 +217,6 @@
<data name="calculator_settings_auto_fix_query" xml:space="preserve">
<value>Fix incomplete calculations automatically</value>
</data>
<data name="calculator_settings_fallback_history" xml:space="preserve">
<value>Save fallback calculations</value>
</data>
<data name="calculator_settings_fallback_history_description" xml:space="preserve">
<value>Save copied results from fallback calculations to history</value>
</data>
<data name="calculator_settings_confirm_delete_title" xml:space="preserve">
<value>Ask for confirmation before deleting items</value>
</data>
<data name="calculator_settings_confirm_delete_description" xml:space="preserve">
<value>Prompt before deleting history entries</value>
</data>
<data name="calculator_settings_primary_action_title" xml:space="preserve">
<value>Primary action (Enter key)</value>
</data>
<data name="calculator_settings_primary_action_description" xml:space="preserve">
<value>Choose the default action for results</value>
</data>
<data name="calculator_settings_primary_action_default" xml:space="preserve">
<value>Default</value>
</data>
<data name="calculator_settings_primary_action_copy" xml:space="preserve">
<value>Copy</value>
</data>
<data name="calculator_settings_primary_action_paste" xml:space="preserve">
<value>Paste</value>
</data>
<data name="calculator_delete_confirmation_title" xml:space="preserve">
<value>Delete history item</value>
</data>
<data name="calculator_delete_confirmation_message" xml:space="preserve">
<value>Are you sure you want to delete this history item?</value>
</data>
<data name="calculator_delete_all_confirmation_title" xml:space="preserve">
<value>Delete all history</value>
</data>
<data name="calculator_delete_all_confirmation_message" xml:space="preserve">
<value>Are you sure you want to delete all history items?</value>
</data>
<data name="calculator_settings_auto_fix_query_description" xml:space="preserve">
<value>Attempt to evaluate incomplete calculations by ignoring extra operators or symbols</value>
</data>
@@ -289,10 +229,4 @@
<data name="calculator_copy_octal" xml:space="preserve">
<value>Copy octal</value>
</data>
<data name="calculator_settings_replace_query_on_enter" xml:space="preserve">
<value>Replace query on Enter</value>
</data>
<data name="calculator_settings_replace_query_on_enter_description" xml:space="preserve">
<value>Replace input when executing Copy or Paste</value>
</data>
</root>

View File

@@ -3,8 +3,8 @@
// See the LICENSE file in the project root for more information.
using CommunityToolkit.Mvvm.Messaging;
using Microsoft.CmdPal.Common.Messages;
using Microsoft.CmdPal.Ext.ClipboardHistory.Helpers;
using Microsoft.CmdPal.Ext.ClipboardHistory.Messages;
using Microsoft.CmdPal.Ext.ClipboardHistory.Models;
using Microsoft.CommandPalette.Extensions.Toolkit;
using Windows.ApplicationModel.DataTransfer;

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.
namespace Microsoft.CmdPal.Core.Common.Messages;
namespace Microsoft.CmdPal.Ext.ClipboardHistory.Messages;
/// <summary>
/// Message to request hiding the window.
///
/// Yes, it's a little weird that this lives in the ClipboardHistory extension.
/// Until we need it somewhere else, this is good enough.
/// </summary>
public partial record HideWindowMessage();
public partial record HideWindowMessage()
{
}

View File

@@ -12,7 +12,6 @@ using System.Threading;
using System.Threading.Tasks;
using Microsoft.CmdPal.Ext.Indexer.Data;
using Microsoft.CmdPal.Ext.Indexer.Helpers;
using Microsoft.CmdPal.Ext.Indexer.Indexer;
using Microsoft.CmdPal.Ext.Indexer.Properties;
using Microsoft.CommandPalette.Extensions;
using Microsoft.CommandPalette.Extensions.Toolkit;
@@ -121,19 +120,12 @@ internal sealed partial class FallbackOpenFileItem : FallbackCommandItem, IDispo
ct.ThrowIfCancellationRequested();
// We only need to know whether there are 0, 1, or more than one result
var results = searchEngine.FetchItems(0, 2, queryCookie: HardQueryCookie, out _, out var notice, noIcons: true);
var results = searchEngine.FetchItems(0, 2, queryCookie: HardQueryCookie, out _, noIcons: true);
var count = results.Count;
if (count == 0)
{
if (notice is { } searchNotice)
{
UpdateSearchNoticeForCurrentQuery(query, searchNotice, ct);
}
else
{
ClearResultForCurrentQuery(ct);
}
ClearResultForCurrentQuery(ct);
}
else if (count == 1)
{
@@ -241,35 +233,6 @@ internal sealed partial class FallbackOpenFileItem : FallbackCommandItem, IDispo
}
}
private bool UpdateSearchNoticeForCurrentQuery(string query, SearchNoticeInfo notice, CancellationToken ct)
{
var (title, subtitle) = GetFallbackNoticeText(notice);
var indexerPage = new IndexerPage(query);
var set = UpdateResultForCurrentQuery(
title,
subtitle,
Icons.FileExplorerIcon,
indexerPage,
[
new CommandContextItem(new OpenUrlCommand("ms-settings:search") { Name = Resources.Indexer_Command_OpenIndexerSettings! }),
],
null,
skipIcon: false,
ct);
if (!set)
{
indexerPage.Dispose();
}
return set;
}
internal static (string Title, string Subtitle) GetFallbackNoticeText(SearchNoticeInfo notice)
{
return (Resources.IndexerCommandsProvider_DisplayName!, notice.Title);
}
private void UpdateIconForCurrentQuery(IIconInfo icon, CancellationToken ct)
{
lock (_resultLock)

View File

@@ -26,26 +26,19 @@ internal static class DataSourceManager
private static bool InitializeDataSource()
{
var riid = typeof(IDBInitialize).GUID;
try
{
_dataSource = ComHelper.CreateComInstance<IDBInitialize>(ref Unsafe.AsRef(in CLSID.CollatorDataSource), CLSCTX.InProcServer);
}
catch (Exception ex)
catch (Exception e)
{
Logger.LogError("Failed to create datasource.", ex);
Logger.LogError($"Failed to create datasource. ex: {e.Message}");
return false;
}
try
{
_dataSource.Initialize();
}
catch (Exception ex)
{
Logger.LogError("Failed to initialize datasource.", ex);
_dataSource = null;
return false;
}
_dataSource.Initialize();
return true;
}

View File

@@ -1,10 +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.Ext.Indexer.Indexer;
internal readonly record struct SearchCatalogStatus(uint PendingItemsCount, int? HResult)
{
public bool IsAvailable => HResult is null;
}

View File

@@ -1,76 +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.
#nullable enable
using System;
using System.Runtime.CompilerServices;
using System.Threading;
using ManagedCommon;
using ManagedCsWin32;
using Microsoft.CmdPal.Ext.Indexer.Indexer.SystemSearch;
namespace Microsoft.CmdPal.Ext.Indexer.Indexer;
internal static class SearchCatalogStatusReader
{
private const string SystemIndex = "SystemIndex";
private static readonly Lock FailureLoggingLock = new();
private static int? _lastLoggedFailureHResult;
internal static SearchCatalogStatus GetStatus()
{
try
{
var catalogManager = CreateCatalogManager();
var pendingItemsCount = catalogManager.NumberOfItemsToIndex();
ResetFailureLoggingState();
return new SearchCatalogStatus(pendingItemsCount, null);
}
catch (Exception ex)
{
LogFailure(ex);
return new SearchCatalogStatus(0, ex.HResult);
}
}
private static ISearchCatalogManager CreateCatalogManager()
{
var searchManager = ComHelper.CreateComInstance<ISearchManager>(ref Unsafe.AsRef(in CLSID.SearchManager), CLSCTX.LocalServer);
var catalogManager = searchManager.GetCatalog(SystemIndex);
return catalogManager ?? throw new ArgumentException($"Failed to get catalog manager for {SystemIndex}");
}
private static void LogFailure(Exception ex)
{
var shouldLogWarning = false;
lock (FailureLoggingLock)
{
if (_lastLoggedFailureHResult != ex.HResult)
{
_lastLoggedFailureHResult = ex.HResult;
shouldLogWarning = true;
}
}
var message = $"Failed to read Windows Search catalog status. HResult=0x{ex.HResult:X8}, Message={ex.Message}";
if (shouldLogWarning)
{
Logger.LogWarning(message);
}
else
{
Logger.LogDebug(message);
}
}
private static void ResetFailureLoggingState()
{
lock (FailureLoggingLock)
{
_lastLoggedFailureHResult = null;
}
}
}

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.Ext.Indexer.Indexer;
public readonly record struct SearchNoticeInfo(string Title, string Subtitle);

View File

@@ -1,67 +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.
#nullable enable
using System;
using System.Diagnostics.CodeAnalysis;
using System.Globalization;
using Microsoft.CmdPal.Ext.Indexer.Properties;
namespace Microsoft.CmdPal.Ext.Indexer.Indexer;
internal static class SearchNoticeInfoBuilder
{
private const int RpcServerUnavailable = unchecked((int)0x800706BA);
private const int RpcDisconnected = unchecked((int)0x80010108);
private const int RpcCallRejected = unchecked((int)0x80010001);
private const int RpcServerCallRetryLater = unchecked((int)0x8001010A);
private const int ServiceDisabled = unchecked((int)0x80070422);
private const int ServiceNotActive = unchecked((int)0x80070426);
private const int ClassNotRegistered = unchecked((int)0x80040154);
private const int ServerExecutionFailed = unchecked((int)0x80080005);
internal static SearchNoticeInfo? FromQueryStatus(SearchQuery.SearchExecutionStatus status)
{
return status.State switch
{
SearchQuery.QueryState.NullDataSource or
SearchQuery.QueryState.CreateSessionFailed or
SearchQuery.QueryState.CreateCommandFailed => CreateUnavailableNotice(),
SearchQuery.QueryState.ExecuteFailed when IsSearchUnavailableHResult(status.HResult) => CreateUnavailableNotice(),
SearchQuery.QueryState.ExecuteFailed => CreateSearchFailedNotice(),
_ => null,
};
}
[SuppressMessage("Performance", "CA1863:Cache a 'CompositeFormat' for repeated use in this formatting operation", Justification = "Formatting a low-frequency user-visible notice once per query is sufficient.")]
internal static SearchNoticeInfo? FromCatalogStatus(SearchCatalogStatus status)
{
if (status.PendingItemsCount > 0)
{
return new SearchNoticeInfo(
Resources.Indexer_SearchIndexingMessage,
string.Format(CultureInfo.CurrentCulture, Resources.Indexer_SearchIndexingMessageTip, status.PendingItemsCount));
}
return null;
}
private static SearchNoticeInfo CreateUnavailableNotice() =>
new(Resources.Indexer_SearchUnavailableMessage, Resources.Indexer_SearchUnavailableMessageTip);
private static SearchNoticeInfo CreateSearchFailedNotice() =>
new(Resources.Indexer_SearchFailedMessage, Resources.Indexer_SearchFailedMessageTip);
private static bool IsSearchUnavailableHResult(int? hresult) =>
hresult is RpcServerUnavailable
or RpcDisconnected
or RpcCallRejected
or RpcServerCallRetryLater
or ServiceDisabled
or ServiceNotActive
or ClassNotRegistered
or ServerExecutionFailed;
}

View File

@@ -2,8 +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.
#nullable enable
using System;
using System.Collections.Concurrent;
using System.Diagnostics.CodeAnalysis;
@@ -25,19 +23,17 @@ internal sealed partial class SearchQuery : IDisposable
private readonly Lock _lockObject = new();
private IRowset? _currentRowset;
private SearchSqlQueryPlan _queryPlan;
private bool _fallbackAttempted;
private IRowset _currentRowset;
public QueryState State { get; private set; } = QueryState.NotStarted;
private int? LastHResult { get; set; }
private string? LastErrorMessage { get; set; }
private string LastErrorMessage { get; set; }
public uint Cookie { get; private set; }
public string SearchText { get; private set; } = string.Empty;
public string SearchText { get; private set; }
public ConcurrentQueue<SearchResult> SearchResults { get; private set; } = [];
@@ -56,44 +52,18 @@ internal sealed partial class SearchQuery : IDisposable
{
SearchText = searchText;
Cookie = cookie;
_fallbackAttempted = false;
try
{
_queryPlan = QueryStringBuilder.GenerateQueryPlan(searchText);
}
catch (Exception ex)
{
lock (_lockObject)
{
State = QueryState.ExecuteFailed;
LastHResult = ex.HResult;
LastErrorMessage = ex.Message;
_currentRowset = null;
SearchResults.Clear();
}
Logger.LogError("Error preparing query", ex);
return;
}
ExecuteSyncInternal(_queryPlan.PrimarySqlQuery);
if (_currentRowset is null && State is QueryState.NoResults or QueryState.AllNoise)
{
TryExecuteFallbackQuery("primary query returned no rowset");
}
ExecuteSyncInternal();
}
private void ExecuteSyncInternal(string queryStr)
private void ExecuteSyncInternal()
{
lock (_lockObject)
{
State = QueryState.Running;
LastHResult = null;
LastErrorMessage = null;
_currentRowset = null;
var queryStr = QueryStringBuilder.GenerateQuery(SearchText);
try
{
var result = ExecuteCommand(queryStr);
@@ -147,11 +117,6 @@ internal sealed partial class SearchQuery : IDisposable
{
if (_currentRowset is null)
{
if (offset == 0 && State is QueryState.NoResults or QueryState.AllNoise && TryExecuteFallbackQuery("primary query returned no results"))
{
return FetchRows(offset, limit);
}
var message = $"No rowset to fetch rows from. State={State}, HResult={LastHResult}, Error='{LastErrorMessage}'";
switch (State)
@@ -184,7 +149,7 @@ internal sealed partial class SearchQuery : IDisposable
Logger.LogInfo($"Reset the current rowset. State={State}, HResult={LastHResult}, Error='{LastErrorMessage}'");
Logger.LogError("Failed to cast current rowset to IGetRow", ex);
ExecuteSyncInternal(_queryPlan.PrimarySqlQuery);
ExecuteSyncInternal();
if (_currentRowset is null)
{
@@ -215,11 +180,6 @@ internal sealed partial class SearchQuery : IDisposable
if (rowCountReturned == 0)
{
if (offset == 0 && TryExecuteFallbackQuery("primary query returned zero rows"))
{
return FetchRows(offset, limit);
}
// No more rows to fetch
return false;
}
@@ -258,20 +218,6 @@ internal sealed partial class SearchQuery : IDisposable
}
}
private bool TryExecuteFallbackQuery(string reason)
{
if (_fallbackAttempted || !_queryPlan.HasFallback || State == QueryState.Cancelled)
{
return false;
}
_fallbackAttempted = true;
Logger.LogInfo($"Retrying search with implicit filename wildcard matching. Reason={reason}, Query=\"{SearchText}\"");
ExecuteSyncInternal(_queryPlan.FallbackSqlQuery!);
return _currentRowset is not null;
}
private static ExecuteCommandResult ExecuteCommand(string queryStr)
{
if (string.IsNullOrEmpty(queryStr))
@@ -340,14 +286,6 @@ internal sealed partial class SearchQuery : IDisposable
CancelOutstandingQueries();
}
internal SearchExecutionStatus GetExecutionStatus()
{
lock (_lockObject)
{
return new SearchExecutionStatus(State, LastHResult, LastErrorMessage);
}
}
internal enum QueryState
{
NotStarted = 0,
@@ -363,13 +301,8 @@ internal sealed partial class SearchQuery : IDisposable
}
private readonly record struct ExecuteCommandResult(
IRowset? Rowset,
IRowset Rowset,
QueryState State,
int? HResult,
string? ErrorMessage);
internal readonly record struct SearchExecutionStatus(
QueryState State,
int? HResult,
string? ErrorMessage);
string ErrorMessage);
}

View File

@@ -1,450 +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.
#nullable enable
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
namespace Microsoft.CmdPal.Ext.Indexer.Indexer.Utils;
internal static class ImplicitWildcardQueryBuilder
{
private const int MinimumContainsTermLength = 3;
internal static ImplicitWildcardExpandedQuery BuildExpandedQuery(string searchText)
{
if (string.IsNullOrWhiteSpace(searchText) || ContainsExplicitWildcards(searchText))
{
return default;
}
var parsedTokens = ParseTokens(searchText);
if (parsedTokens.Count == 0 || parsedTokens.Any(static token => token.Kind == ParsedTokenKind.ComplexSyntax))
{
return default;
}
var rawTerms = parsedTokens
.Where(static token => token.Kind == ParsedTokenKind.PlainTextTerm)
.Select(static token => token.Value)
.Distinct(StringComparer.OrdinalIgnoreCase)
.ToList();
if (rawTerms.Count == 0)
{
return default;
}
var structuredTokens = parsedTokens
.Where(static token => token.Kind == ParsedTokenKind.StructuredToken)
.Select(static token => token.Value)
.ToList();
var structuredSearchText = structuredTokens.Count > 0
? string.Join(' ', structuredTokens)
: null;
var containsRestriction = BuildContainsRestriction(ExtractContainsTerms(rawTerms));
var likeRestriction = BuildLikeRestriction(rawTerms);
var primaryRestriction = CombineRestrictions(containsRestriction, likeRestriction);
if (string.IsNullOrWhiteSpace(primaryRestriction))
{
return default;
}
var fallbackRestriction = !string.IsNullOrWhiteSpace(containsRestriction) && !string.IsNullOrWhiteSpace(likeRestriction)
? likeRestriction
: null;
return new ImplicitWildcardExpandedQuery(
structuredSearchText,
primaryRestriction,
fallbackRestriction);
}
private static List<ParsedToken> ParseTokens(string searchText)
{
var parsedTokens = new List<ParsedToken>();
var seenTerms = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
var expectsStructuredValue = false;
foreach (var token in Tokenize(searchText))
{
if (string.IsNullOrWhiteSpace(token))
{
continue;
}
if (IsComplexSyntaxToken(token))
{
parsedTokens.Add(new ParsedToken(token, ParsedTokenKind.ComplexSyntax));
expectsStructuredValue = false;
continue;
}
if (expectsStructuredValue || IsStructuredToken(token))
{
parsedTokens.Add(new ParsedToken(token, ParsedTokenKind.StructuredToken));
expectsStructuredValue = ExpectsAnotherStructuredValue(token);
continue;
}
var candidate = Unquote(token).Trim();
if (candidate.Length == 0 || !ContainsSearchableCharacters(candidate))
{
expectsStructuredValue = false;
continue;
}
if (seenTerms.Add(candidate))
{
parsedTokens.Add(new ParsedToken(candidate, ParsedTokenKind.PlainTextTerm));
}
expectsStructuredValue = false;
}
return parsedTokens;
}
private static bool ContainsExplicitWildcards(string searchText)
{
return searchText.Contains('*') || searchText.Contains('?');
}
private static List<string> Tokenize(string searchText)
{
var tokens = new List<string>();
var currentToken = new StringBuilder();
var inQuotes = false;
foreach (var ch in searchText)
{
if (ch == '"')
{
inQuotes = !inQuotes;
currentToken.Append(ch);
continue;
}
if (char.IsWhiteSpace(ch) && !inQuotes)
{
AppendCurrentToken(tokens, currentToken);
continue;
}
currentToken.Append(ch);
}
AppendCurrentToken(tokens, currentToken);
return tokens;
}
private static void AppendCurrentToken(List<string> tokens, StringBuilder currentToken)
{
if (currentToken.Length == 0)
{
return;
}
tokens.Add(currentToken.ToString());
currentToken.Clear();
}
private static bool IsStructuredToken(string token)
{
if (token.Length > 0 && token[0] is '+' or '-')
{
return true;
}
if (token.Contains('\\') || token.Contains('/'))
{
return true;
}
if (token.Contains('=') || token.Contains('>') || token.Contains('<'))
{
return true;
}
return token.Contains(':') && !LooksLikeDrivePath(token);
}
private static bool ExpectsAnotherStructuredValue(string token)
{
if (!token.Contains(':') || LooksLikeDrivePath(token))
{
return false;
}
var suffix = token[(token.LastIndexOf(':') + 1)..];
return suffix.Length == 0 || suffix.Equals("NOT", StringComparison.OrdinalIgnoreCase);
}
private static bool IsComplexSyntaxToken(string token)
{
return token.Contains('(')
|| token.Contains(')')
|| IsBooleanOperator(token);
}
private static bool IsBooleanOperator(string token)
{
return token.Equals("AND", StringComparison.OrdinalIgnoreCase)
|| token.Equals("OR", StringComparison.OrdinalIgnoreCase)
|| token.Equals("NOT", StringComparison.OrdinalIgnoreCase);
}
private static bool LooksLikeDrivePath(string token)
{
return token.Length >= 2
&& char.IsLetter(token[0])
&& token[1] == ':'
&& (token.Length == 2 || token[2] is '\\' or '/');
}
private static bool ContainsSearchableCharacters(string token)
{
foreach (var ch in token)
{
if (char.IsLetterOrDigit(ch) || IsLiteralLikeSearchCharacter(ch))
{
return true;
}
}
return false;
}
private static bool IsLiteralLikeSearchCharacter(char ch)
{
return ch is '%' or '_';
}
private static string Unquote(string token)
{
return token switch
{
['"', .. var inner, '"'] => inner,
_ => token,
};
}
private static List<string> ExtractContainsTerms(IReadOnlyList<string> rawTerms)
{
var terms = new List<string>();
var seenTerms = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
foreach (var rawTerm in rawTerms)
{
foreach (var candidate in ExtractContainsTermCandidates(rawTerm))
{
if (candidate.Length < MinimumContainsTermLength)
{
continue;
}
if (seenTerms.Add(candidate))
{
terms.Add(candidate);
}
}
}
return terms;
}
private static IEnumerable<string> ExtractContainsTermCandidates(string rawTerm)
{
if (ShouldUseLiteralOnlyMatching(rawTerm))
{
return [];
}
var normalized = new StringBuilder(rawTerm.Length);
foreach (var ch in rawTerm)
{
normalized.Append(char.IsLetterOrDigit(ch) ? ch : ' ');
}
return normalized
.ToString()
.Split(' ', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
}
private static bool ShouldUseLiteralOnlyMatching(string rawTerm)
{
if (rawTerm.Length < 2 || !IsWrapperPair(rawTerm[0], rawTerm[^1]))
{
return false;
}
var inner = rawTerm[1..^1];
if (!ContainsSearchableCharacters(inner))
{
return false;
}
return !HasInternalSeparatorPunctuation(inner);
}
private static bool IsWrapperPair(char start, char end) =>
(start, end) is ('[', ']') or ('{', '}') or ('<', '>');
private static bool HasInternalSeparatorPunctuation(string value)
{
for (var i = 1; i < value.Length - 1; i++)
{
if (!char.IsLetterOrDigit(value[i]) && IsLetterOrDigitNeighbor(value, i - 1, i + 1))
{
return true;
}
}
return false;
}
private static bool IsLetterOrDigitNeighbor(string value, int leftIndex, int rightIndex) =>
char.IsLetterOrDigit(value[leftIndex]) && char.IsLetterOrDigit(value[rightIndex]);
private static string? BuildContainsRestriction(IReadOnlyList<string> terms)
{
if (terms.Count == 0)
{
return null;
}
var predicates = new List<string>();
if (terms.Count == 1)
{
predicates.Add(BuildContainsPredicate(terms[0], usePrefixWildcard: false));
predicates.Add(BuildContainsPredicate(terms[0], usePrefixWildcard: true));
}
else
{
var phrase = string.Join(' ', terms);
predicates.Add(BuildContainsPredicate(phrase, usePrefixWildcard: false));
predicates.Add(BuildContainsPredicate(phrase, usePrefixWildcard: true));
predicates.Add(BuildContainsAllTermsPredicate(terms, usePrefixWildcard: false));
predicates.Add(BuildContainsAllTermsPredicate(terms, usePrefixWildcard: true));
}
return $"({string.Join(" OR ", predicates)})";
}
private static string BuildContainsPredicate(string term, bool usePrefixWildcard)
{
var escapedTerm = EscapeContainsTerm(term);
var query = usePrefixWildcard
? $"\"{escapedTerm}*\""
: $"\"{escapedTerm}\"";
return $"CONTAINS(System.ItemNameDisplay, '{query}')";
}
private static string BuildContainsAllTermsPredicate(IReadOnlyList<string> terms, bool usePrefixWildcard)
{
var joinedTerms = string.Join(
" AND ",
terms.Select(term =>
{
var escapedTerm = EscapeContainsTerm(term);
return usePrefixWildcard
? $"\"{escapedTerm}*\""
: $"\"{escapedTerm}\"";
}));
return $"CONTAINS(System.ItemNameDisplay, '{joinedTerms}')";
}
private static string? BuildLikeRestriction(IReadOnlyList<string> rawTerms)
{
if (rawTerms.Count == 0)
{
return null;
}
var predicates = rawTerms
.Select(BuildLikePredicate)
.ToList();
return predicates.Count == 1
? predicates[0]
: $"({string.Join(" AND ", predicates)})";
}
private static string BuildLikePredicate(string term)
{
var escapedTerm = EscapeLikeTerm(term);
return $"System.FileName LIKE '%{escapedTerm}%'";
}
private static string? CombineRestrictions(string? containsRestriction, string? likeRestriction)
{
if (string.IsNullOrWhiteSpace(containsRestriction))
{
return likeRestriction;
}
if (string.IsNullOrWhiteSpace(likeRestriction))
{
return containsRestriction;
}
return $"({containsRestriction} OR {likeRestriction})";
}
private static string EscapeContainsTerm(string value)
{
return value
.Replace("'", "''", StringComparison.Ordinal)
.Replace("\"", "\"\"", StringComparison.Ordinal);
}
private static string EscapeLikeTerm(string value)
{
var escaped = new StringBuilder(value.Length);
foreach (var ch in value)
{
escaped.Append(ch switch
{
'[' => "[[]",
']' => "[]]",
'%' => "[%]",
'_' => "[_]",
'\'' => "''",
_ => ch,
});
}
return escaped.ToString();
}
internal readonly record struct ImplicitWildcardExpandedQuery(
string? StructuredSearchText,
string? PrimaryRestriction,
string? FallbackRestriction)
{
public bool HasPrimaryRestriction => !string.IsNullOrWhiteSpace(PrimaryRestriction);
public bool HasFallbackRestriction => !string.IsNullOrWhiteSpace(FallbackRestriction);
}
private enum ParsedTokenKind
{
PlainTextTerm = 0,
StructuredToken,
ComplexSyntax,
}
private readonly record struct ParsedToken(string Value, ParsedTokenKind Kind);
}

View File

@@ -2,8 +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.
#nullable enable
using System;
using System.Runtime.CompilerServices;
using ManagedCommon;
@@ -18,89 +16,43 @@ internal static class QueryStringBuilder
private const string SystemIndex = "SystemIndex";
private const string ScopeFileConditions = "SCOPE='file:'";
private const string OrderConditions = "System.DateModified DESC";
private const string ContentProperties = "System.FileName";
public static SearchSqlQueryPlan GenerateQueryPlan(string searchText)
private static ISearchQueryHelper queryHelper;
public static string GenerateQuery(string searchText)
{
var expandedQuery = ImplicitWildcardQueryBuilder.BuildExpandedQuery(searchText);
var primarySqlQuery = expandedQuery.HasPrimaryRestriction
? BuildQuery(expandedQuery.StructuredSearchText, expandedQuery.PrimaryRestriction!)
: GenerateQuery(searchText);
var fallbackSqlQuery = expandedQuery.HasFallbackRestriction
? BuildQuery(expandedQuery.StructuredSearchText, expandedQuery.FallbackRestriction!)
: null;
return new SearchSqlQueryPlan(primarySqlQuery, fallbackSqlQuery);
}
private static string GenerateQuery(string searchText, string? additionalRestrictions = null)
{
var queryHelper = CreateQueryHelper();
queryHelper.SetQuerySelectColumns(Properties);
queryHelper.SetQueryContentProperties(ContentProperties);
queryHelper.SetQuerySorting(OrderConditions);
queryHelper.SetQuerySyntax(SEARCH_QUERY_SYNTAX.SEARCH_ADVANCED_QUERY_SYNTAX);
var restrictions = $"AND {ScopeFileConditions}";
if (!string.IsNullOrWhiteSpace(additionalRestrictions))
{
restrictions += $" AND ({additionalRestrictions})";
}
queryHelper.SetQueryWhereRestrictions(restrictions);
return queryHelper.GenerateSQLFromUserQuery(searchText);
}
private static string BuildQuery(string? structuredSearchText, string restriction)
{
return string.IsNullOrWhiteSpace(structuredSearchText)
? GenerateRestrictionOnlyQuery(restriction)
: GenerateQuery(structuredSearchText, restriction);
}
private static string GenerateRestrictionOnlyQuery(string restriction)
{
return $"""
SELECT {Properties}
FROM {SystemIndex}
WHERE {ScopeFileConditions} AND ({restriction})
ORDER BY {OrderConditions}
""";
}
private static ISearchQueryHelper CreateQueryHelper()
{
ISearchManager searchManager;
try
{
searchManager = ComHelper.CreateComInstance<ISearchManager>(ref Unsafe.AsRef(in CLSID.SearchManager), CLSCTX.LocalServer);
}
catch (Exception ex)
{
Logger.LogError("Failed to create searchManager.", ex);
throw;
}
var catalogManager = searchManager.GetCatalog(SystemIndex);
if (catalogManager is null)
{
throw new ArgumentException($"Failed to get catalog manager for {SystemIndex}");
}
var queryHelper = catalogManager.GetQueryHelper();
if (queryHelper is null)
{
throw new ArgumentException("Failed to get query helper from catalog manager");
ISearchManager searchManager;
try
{
searchManager = ComHelper.CreateComInstance<ISearchManager>(ref Unsafe.AsRef(in CLSID.SearchManager), CLSCTX.LocalServer);
}
catch (Exception ex)
{
Logger.LogError($"Failed to create searchManager. ex: {ex.Message}");
throw;
}
var catalogManager = searchManager.GetCatalog(SystemIndex);
if (catalogManager is null)
{
throw new ArgumentException($"Failed to get catalog manager for {SystemIndex}");
}
queryHelper = catalogManager.GetQueryHelper();
if (queryHelper is null)
{
throw new ArgumentException("Failed to get query helper from catalog manager");
}
queryHelper.SetQuerySelectColumns(Properties);
queryHelper.SetQueryContentProperties("System.FileName");
queryHelper.SetQuerySorting(OrderConditions);
}
return queryHelper;
queryHelper.SetQueryWhereRestrictions($"AND {ScopeFileConditions}");
return queryHelper.GenerateSQLFromUserQuery(searchText);
}
}
internal readonly record struct SearchSqlQueryPlan(string PrimarySqlQuery, string? FallbackSqlQuery)
{
public bool HasFallback => !string.IsNullOrWhiteSpace(FallbackSqlQuery);
}

View File

@@ -59,8 +59,4 @@
<AdditionalFiles Include="NativeMethods.json" />
</ItemGroup>
<ItemGroup>
<InternalsVisibleTo Include="Microsoft.CmdPal.Ext.Indexer.UnitTests" />
</ItemGroup>
</Project>

View File

@@ -34,13 +34,10 @@ internal sealed partial class IndexerPage : DynamicListPage, IDisposable
private CommandItem? _noSearchEmptyContent;
private CommandItem? _nothingFoundEmptyContent;
private CommandItem? _noticeEmptyContent;
private ListItem? _noticeListItem;
private SearchNoticeInfo? _currentNotice;
private bool _deferredLoad;
public override ICommandItem EmptyContent => _isEmptyQuery ? _noSearchEmptyContent! : _currentNotice is null ? _nothingFoundEmptyContent! : _noticeEmptyContent!;
public override ICommandItem EmptyContent => _isEmptyQuery ? _noSearchEmptyContent! : _nothingFoundEmptyContent!;
public IndexerPage()
{
@@ -97,19 +94,6 @@ internal sealed partial class IndexerPage : DynamicListPage, IDisposable
},
],
};
_noticeEmptyContent = new CommandItem(new OpenUrlCommand("ms-settings:search") { Name = Resources.Indexer_Command_OpenIndexerSettings! })
{
Icon = Icon,
};
_noticeListItem = new ListItem(new NoOpCommand())
{
Icon = Icon,
MoreCommands = [
new CommandContextItem(new OpenUrlCommand("ms-settings:search") { Name = Resources.Indexer_Command_OpenIndexerSettings! }),
],
};
}
private void StartManualSearch()
@@ -143,9 +127,7 @@ internal sealed partial class IndexerPage : DynamicListPage, IDisposable
_deferredLoad = false;
}
return _currentNotice is null
? [.. _indexerListItems]
: [_noticeListItem!, .. _indexerListItems];
return [.. _indexerListItems];
}
private string FullSearchString(string query)
@@ -178,8 +160,7 @@ internal sealed partial class IndexerPage : DynamicListPage, IDisposable
offset = _indexerListItems.Count;
}
SearchNoticeInfo? notice = null;
var results = searchEngine?.FetchItems(offset, 20, queryCookie: HardQueryCookie, out hasMore, out notice) ?? [];
var results = searchEngine?.FetchItems(offset, 20, queryCookie: HardQueryCookie, out hasMore) ?? [];
if (ct?.IsCancellationRequested == true)
{
@@ -195,11 +176,10 @@ internal sealed partial class IndexerPage : DynamicListPage, IDisposable
return;
}
ApplyNotice(notice);
_indexerListItems.AddRange(results);
HasMoreItems = hasMore;
IsLoading = false;
RaiseItemsChanged(GetVisibleItemCount());
RaiseItemsChanged(_indexerListItems.Count);
}
}
@@ -208,8 +188,7 @@ internal sealed partial class IndexerPage : DynamicListPage, IDisposable
lock (_searchLock)
{
_indexerListItems.Clear();
var notice = _searchEngine?.Query(query, queryCookie: HardQueryCookie);
ApplyNotice(notice);
_searchEngine?.Query(query, queryCookie: HardQueryCookie);
}
}
@@ -247,7 +226,6 @@ internal sealed partial class IndexerPage : DynamicListPage, IDisposable
// If the user hasn't provided any base query text, results should be empty
// regardless of the currently selected filter.
_isEmptyQuery = string.IsNullOrWhiteSpace(newSearch);
ApplyNotice(null);
if (_isEmptyQuery)
{
@@ -278,7 +256,6 @@ internal sealed partial class IndexerPage : DynamicListPage, IDisposable
lock (_searchLock)
{
RaiseItemsChanged(GetVisibleItemCount());
OnPropertyChanged(nameof(EmptyContent));
}
},
@@ -304,21 +281,4 @@ internal sealed partial class IndexerPage : DynamicListPage, IDisposable
GC.SuppressFinalize(this);
}
private void ApplyNotice(SearchNoticeInfo? notice)
{
_currentNotice = notice;
if (notice is null)
{
return;
}
_noticeEmptyContent!.Title = notice.Value.Title;
_noticeEmptyContent.Subtitle = notice.Value.Subtitle;
_noticeListItem!.Title = notice.Value.Title;
_noticeListItem.Subtitle = notice.Value.Subtitle;
}
private int GetVisibleItemCount() => _indexerListItems.Count + (_currentNotice is null ? 0 : 1);
}

View File

@@ -295,60 +295,6 @@ namespace Microsoft.CmdPal.Ext.Indexer.Properties {
}
}
/// <summary>
/// Looks up a localized string similar to Search couldn&apos;t be completed.
/// </summary>
internal static string Indexer_SearchFailedMessage {
get {
return ResourceManager.GetString("Indexer_SearchFailedMessage", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to Windows Search returned an unexpected error. Try again, or open Windows Search settings if the problem continues..
/// </summary>
internal static string Indexer_SearchFailedMessageTip {
get {
return ResourceManager.GetString("Indexer_SearchFailedMessageTip", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to Windows Search is still indexing files.
/// </summary>
internal static string Indexer_SearchIndexingMessage {
get {
return ResourceManager.GetString("Indexer_SearchIndexingMessage", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to There are still {0:N0} items waiting to be indexed, so some files and folders might not appear yet..
/// </summary>
internal static string Indexer_SearchIndexingMessageTip {
get {
return ResourceManager.GetString("Indexer_SearchIndexingMessageTip", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to Windows Search is unavailable.
/// </summary>
internal static string Indexer_SearchUnavailableMessage {
get {
return ResourceManager.GetString("Indexer_SearchUnavailableMessage", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to The Windows Search service or connection is unavailable right now. Start the service, then try your search again..
/// </summary>
internal static string Indexer_SearchUnavailableMessageTip {
get {
return ResourceManager.GetString("Indexer_SearchUnavailableMessageTip", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to Always on.
/// </summary>

View File

@@ -159,24 +159,6 @@
<data name="Indexer_PlaceholderText" xml:space="preserve">
<value>Search for files and folders...</value>
</data>
<data name="Indexer_SearchFailedMessage" xml:space="preserve">
<value>Search couldn't be completed</value>
</data>
<data name="Indexer_SearchFailedMessageTip" xml:space="preserve">
<value>Windows Search returned an unexpected error. Try again, or open Windows Search settings if the problem continues.</value>
</data>
<data name="Indexer_SearchIndexingMessage" xml:space="preserve">
<value>Windows Search is still indexing files</value>
</data>
<data name="Indexer_SearchIndexingMessageTip" xml:space="preserve">
<value>There are still {0:N0} items waiting to be indexed, so some files and folders might not appear yet.</value>
</data>
<data name="Indexer_SearchUnavailableMessage" xml:space="preserve">
<value>Windows Search is unavailable</value>
</data>
<data name="Indexer_SearchUnavailableMessageTip" xml:space="preserve">
<value>The Windows Search service or connection is unavailable right now. Start the service, then try your search again.</value>
</data>
<data name="Indexer_Settings_FallbackCommand_AlwaysOn" xml:space="preserve">
<value>Always on</value>
</data>
@@ -232,4 +214,4 @@ You can try searching all files on this PC or adjust your indexing settings.</va
<data name="Indexer_Fallback_MultipleResults_Subtitle" xml:space="preserve">
<value>The query matches multiple items</value>
</data>
</root>
</root>

View File

@@ -1,110 +0,0 @@
# File Search Built-in Extension
## Building Search Query
### Query Handling Contract
The module does not always forward the user query to Windows Search unchanged.
For simple free-text queries, it broadens filename matching so search feels more natural.
For queries that already look like AQS or other Windows Search syntax, it does not rewrite them.
That split is intentional.
The module is trying to improve plain filename search without breaking structured Windows Search queries.
### When We Do Not Rewrite
If the input looks structured, we pass it through `ISearchQueryHelper.GenerateSQLFromUserQuery(...)` as-is.
Examples:
- `name:report`
- `kind:folder`
- `kind:folder AND report`
- `*report*`
- `C:\Users`
- `size>10MB`
- `(report)`
Parentheses are treated conservatively because they can be real query syntax.
### What Broadening Means
For simple free-text input we may build two filename restrictions:
- a literal `LIKE` restriction on `System.FileName`
- an indexed `CONTAINS(System.ItemNameDisplay, ...)` restriction
They serve different purposes:
- `LIKE` preserves the original text literally
- `CONTAINS` gives better indexed matching and can normalize separator-like punctuation
The primary query may use both.
The fallback query uses the `LIKE` branch only.
### Intentional Asymmetry
The broadening is intentionally asymmetric.
Desired behavior:
- `red` should find `[red]`
- `[red]` should stay mostly literal
In other words:
- plain terms are broadened
- punctuation-wrapped literals are usually not normalized
- separator punctuation inside a token can still broaden
This is the most important design rule in the module.
### Separator Punctuation vs Wrapper Punctuation
Some punctuation behaves like a separator inside filenames.
Examples:
- `foo-bar`
- `20220409-tontrager.xlsx`
Users usually expect broadening here, because `tontrager` should still find `20220409-tontrager.xlsx`.
Other punctuation usually signals literal intent.
Examples:
- `[red]`
- `{draft}`
- `<todo>`
Those should usually stay on the literal filename path instead of being normalized to bare words.
### Examples
| User input | Behavior |
| --- | --- |
| `red` | broad plain-text search; can match `random [red] search.txt` |
| `[red]` | literal filename match; does not also broaden to plain `red` |
| `foo-bar` | keeps literal `foo-bar` matching and also broadens as a separator-style term |
| `term Kind:Folder` | broadens `term`, preserves `Kind:Folder` |
| `%` | treated as a literal percent sign in the filename match |
| `_` | treated as a literal underscore in the filename match |
| `(report)` | not rewritten locally; passed through to Windows Search |
### Why The Fallback Exists
Some inputs are valid literal filename searches but poor full-text searches.
Typical failure mode:
- the `CONTAINS(...)` side returns `QUERY_E_ALLNOISE`
- or the primary query otherwise fails to produce a useful rowset
When both branches exist:
- primary query = `CONTAINS(...) OR LIKE ...`
- fallback query = `LIKE ...` only
The fallback exists so punctuation-heavy or noisy input can still produce useful filename matches.

View File

@@ -20,12 +20,12 @@ public sealed partial class SearchEngine : IDisposable
{
private SearchQuery? _searchQuery = new();
public SearchNoticeInfo? Query(string query, uint queryCookie)
public void Query(string query, uint queryCookie)
{
var searchQuery = _searchQuery;
if (searchQuery is null)
{
return null;
return;
}
searchQuery.SearchResults.Clear();
@@ -33,7 +33,7 @@ public sealed partial class SearchEngine : IDisposable
if (string.IsNullOrWhiteSpace(query))
{
return null;
return;
}
Stopwatch stopwatch = new();
@@ -43,14 +43,11 @@ public sealed partial class SearchEngine : IDisposable
stopwatch.Stop();
Logger.LogDebug($"Query time: {stopwatch.ElapsedMilliseconds} ms, query: \"{query}\"");
return BuildNotice(searchQuery);
}
public IList<IListItem> FetchItems(int offset, int limit, uint queryCookie, out bool hasMore, out SearchNoticeInfo? notice, bool noIcons = false)
public IList<IListItem> FetchItems(int offset, int limit, uint queryCookie, out bool hasMore, bool noIcons = false)
{
hasMore = false;
notice = null;
var searchQuery = _searchQuery;
if (searchQuery is null)
@@ -67,7 +64,6 @@ public sealed partial class SearchEngine : IDisposable
var results = new List<IListItem>();
var index = 0;
var hasMoreItems = searchQuery.FetchRows(offset, limit);
notice = BuildNotice(searchQuery);
while (!searchQuery.SearchResults.IsEmpty && searchQuery.SearchResults.TryDequeue(out var result) && ++index <= limit)
{
@@ -104,12 +100,6 @@ public sealed partial class SearchEngine : IDisposable
return results;
}
private static SearchNoticeInfo? BuildNotice(SearchQuery searchQuery)
{
return SearchNoticeInfoBuilder.FromQueryStatus(searchQuery.GetExecutionStatus())
?? SearchNoticeInfoBuilder.FromCatalogStatus(SearchCatalogStatusReader.GetStatus());
}
public void Dispose()
{
var searchQuery = _searchQuery;

View File

@@ -102,14 +102,11 @@ namespace ImageResizer.Models
using (var outputStream = _fileSystem.File.Open(path, FileMode.CreateNew, FileAccess.ReadWrite))
{
var winrtOutputStream = outputStream.AsRandomAccessStream();
bool forceFresh = encoderGuid == BitmapEncoder.JpegEncoderId && !noTransformNeeded;
await EncodeToStreamAsync(
decoder,
winrtInputStream,
winrtOutputStream,
encoderGuid,
forceFresh,
async (encoder, isTranscode) =>
{
if (isTranscode)
@@ -124,6 +121,14 @@ namespace ImageResizer.Models
{
encoder.BitmapTransform.Bounds = cropBounds.Value;
}
// Apply codec-specific properties (e.g., JPEG quality).
// Must be set after transforms since re-encoding will occur.
var encoderProps = GetEncoderPropertySet(encoderGuid);
if (encoderProps != null)
{
await encoder.BitmapProperties.SetPropertiesAsync(encoderProps);
}
}
}
else
@@ -183,15 +188,11 @@ namespace ImageResizer.Models
using (var outputStream = _fileSystem.File.Open(path, FileMode.CreateNew, FileAccess.ReadWrite))
{
var winrtOutputStream = outputStream.AsRandomAccessStream();
// SetSoftwareBitmap requires a fresh encoder; the transcode encoder only
// accepts BitmapTransform. Also forces quality to apply for JPEG output.
await EncodeToStreamAsync(
decoder,
winrtInputStream,
winrtOutputStream,
encoderGuid,
forceFresh: true,
(encoder, _) =>
{
encoder.SetSoftwareBitmap(aiResult);
@@ -213,12 +214,10 @@ namespace ImageResizer.Models
IRandomAccessStream inputStream,
IRandomAccessStream outputStream,
Guid encoderGuid,
bool forceFresh,
Func<BitmapEncoder, bool, Task> writeContent)
{
var decoderEncoderId = CodecHelper.GetEncoderIdForDecoder(decoder);
bool canTranscode = !forceFresh
&& !_settings.RemoveMetadata
bool canTranscode = !_settings.RemoveMetadata
&& decoderEncoderId.HasValue
&& decoderEncoderId.Value == encoderGuid;
@@ -258,8 +257,7 @@ namespace ImageResizer.Models
/// <summary>
/// Fresh encoder path: creates a blank encoder and manually writes pixel data.
/// Used when codec options (e.g. JPEG quality) must apply, when metadata must be
/// stripped (RemoveMetadata), or when the format doesn't match (ICO→PNG).
/// Used when metadata must be stripped (RemoveMetadata) or format doesn't match (ICO→PNG).
/// The <paramref name="writeContent"/> callback receives isTranscode=false and should
/// call <see cref="EncodeFramesAsync"/> or <see cref="BitmapEncoder.SetSoftwareBitmap"/>.
/// </summary>
@@ -269,18 +267,22 @@ namespace ImageResizer.Models
Guid encoderGuid,
Func<BitmapEncoder, bool, Task> writeContent)
{
// The blank encoder inherits nothing from the source, so we have to carry
// metadata over ourselves. RemoveMetadata keeps only the rendering-critical
// properties (orientation/colorspace); otherwise mirror the transcode path's
// best-effort EXIF preservation.
var propsToPreserve = _settings.RemoveMetadata
? RenderingMetadataProperties
: KnownMetadataProperties;
var preservedMetadata = await ReadMetadataAsync(decoder, propsToPreserve);
// Read rendering-critical metadata before encoding so we can restore it on
// the blank encoder. Only needed for RemoveMetadata; format-mismatch files
// (e.g. ICO) rarely carry meaningful EXIF data.
BitmapPropertySet renderingMetadata = null;
if (_settings.RemoveMetadata)
{
renderingMetadata = await ReadMetadataAsync(decoder, RenderingMetadataProperties);
}
var encoder = await CreateFreshEncoderAsync(encoderGuid, outputStream);
await writeContent(encoder, false);
await WriteMetadataAsync(encoder, preservedMetadata);
if (renderingMetadata != null)
{
await WriteMetadataAsync(encoder, renderingMetadata);
}
await encoder.FlushAsync();
}

View File

@@ -227,7 +227,7 @@ namespace PowerAccent.Core
LetterKey.VK_Z => new[] { "ʒ", "ǯ", "", "ᶻ" },
LetterKey.VK_COMMA => new[] { "∙", "₋", "⁻", "", "√", "‟", "《", "》", "", "〈", "〉", "″", "‴", "⁗" }, // is in VK_MINUS for other languages, but not VK_COMMA, so we add it here.
LetterKey.VK_PERIOD => new[] { "…", "⁝", "\u0300", "\u0301", "\u0302", "\u0303", "\u0304", "\u0308", "\u030B", "\u030C" },
LetterKey.VK_MINUS => new[] { "~", "", "", "", "", "—", "―", "", "", "⸺", "⸻", "∓", "₋", "⁻" },
LetterKey.VK_MINUS => new[] { "~", "", "", "", "—", "―", "", "", "⸺", "⸻", "∓", "₋", "⁻" },
LetterKey.VK_SLASH_ => new[] { "÷", "√" },
LetterKey.VK_DIVIDE_ => new[] { "÷", "√" },
LetterKey.VK_MULTIPLY_ => new[] { "×", "⋅", "ˣ", "ₓ" },
@@ -744,7 +744,6 @@ namespace PowerAccent.Core
LetterKey.VK_I => new[] { "í" },
LetterKey.VK_O => new[] { "ó", "ő", "ö" },
LetterKey.VK_U => new[] { "ú", "ű", "ü" },
LetterKey.VK_Y => new[] { "ÿ", "ý" },
LetterKey.VK_COMMA => new[] { "„", "”", "»", "«" },
_ => Array.Empty<string>(),
};

View File

@@ -61,36 +61,11 @@ public class SettingsService
ExcludedApps = settings.Properties.ExcludedApps.Value;
_keyboardListener.UpdateExcludedApps(ExcludedApps);
var selectedLangEntries = settings.Properties.SelectedLang.Value
SelectedLang = settings.Properties.SelectedLang.Value
.Split(',', StringSplitOptions.RemoveEmptyEntries)
.Select(lang => lang.Trim())
.Select(lang => Enum.TryParse(lang, out Language selectedLangValue) ? selectedLangValue : Language.SPECIAL)
.ToArray();
// Either select all languages if "ALL" is specified, or parse
// the specified languages while ignoring unrecognized values.
bool isAllSelected = selectedLangEntries.Any(lang =>
lang.Equals("ALL", StringComparison.OrdinalIgnoreCase));
SelectedLang = isAllSelected
? Enum.GetValues<Language>()
: selectedLangEntries
.Select(lang =>
{
if (Enum.TryParse(lang, ignoreCase: true, out Language parsedLang))
{
return (Language?)parsedLang;
}
// Skip unrecognized values.
Logger.LogWarning($"Unknown language value '{lang}' in settings, skipping.");
return null;
})
.Where(lang => lang.HasValue)
.Select(lang => lang!.Value)
.ToArray();
Logger.LogInfo(
$"Languages selected: {(isAllSelected ? "ALL" : string.Join(", ", SelectedLang))}");
switch (settings.Properties.ToolbarPosition.Value)
{
case "Top center":

View File

@@ -0,0 +1,116 @@
// Copyright (c) Microsoft Corporation
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
using System;
using System.Runtime.InteropServices;
using WinUIEx;
namespace PowerDisplay.Helpers
{
/// <summary>
/// Subclasses a window's WndProc to suppress WM_DPICHANGED messages during
/// cross-monitor MoveAndResize calls. Without suppression, the framework
/// auto-scales it a second time, causing double-scaling artifacts.
///
/// Usage:
/// var suppressor = new DpiSuppressor(window);
/// using (suppressor.Suppress())
/// {
/// window.AppWindow.MoveAndResize(rect, displayArea);
/// }
/// </summary>
internal sealed partial class DpiSuppressor : IDisposable
{
// Optional external WndProc handler (e.g., HotkeyService) called before default processing.
// Return true to indicate the message was handled.
private readonly Func<uint, nuint, nint, bool>? _preProcessor;
private const int GwlWndProc = -4;
private const uint WmDpiChanged = 0x02E0;
private readonly nint _hwnd;
private nint _originalWndProc;
private WndProcDelegate? _wndProcDelegate;
private bool _suppressing;
private bool _disposed;
[LibraryImport("user32.dll", EntryPoint = "SetWindowLongPtrW")]
private static partial nint SetWindowLongPtr(nint hWnd, int nIndex, nint dwNewLong);
[LibraryImport("user32.dll", EntryPoint = "CallWindowProcW")]
private static partial nint CallWindowProc(nint lpPrevWndFunc, nint hWnd, uint msg, nuint wParam, nint lParam);
/// <summary>
/// Initializes a new instance of the <see cref="DpiSuppressor"/> class.
/// Subclass the window's WndProc to enable DPI suppression.
/// </summary>
/// <param name="window">Window to subclass.</param>
/// <param name="preProcessor">Optional callback invoked for every message before default processing.
/// Receives (uMsg, wParam, lParam). Return true to swallow the message.</param>
public DpiSuppressor(WindowEx window, Func<uint, nuint, nint, bool>? preProcessor = null)
{
_hwnd = window.GetWindowHandle();
_preProcessor = preProcessor;
_wndProcDelegate = WndProc;
var ptr = Marshal.GetFunctionPointerForDelegate(_wndProcDelegate);
_originalWndProc = SetWindowLongPtr(_hwnd, GwlWndProc, ptr);
}
/// <summary>
/// Returns a disposable scope during which WM_DPICHANGED is suppressed.
/// </summary>
public SuppressScope Suppress() => new(this);
private nint WndProc(nint hwnd, uint uMsg, nuint wParam, nint lParam)
{
// Let external handler process first (e.g., hotkey messages)
if (_preProcessor?.Invoke(uMsg, wParam, lParam) == true)
{
return 0;
}
if (uMsg == WmDpiChanged && _suppressing)
{
return 0;
}
return CallWindowProc(_originalWndProc, hwnd, uMsg, wParam, lParam);
}
public void Dispose()
{
if (_disposed)
{
return;
}
_disposed = true;
// Restore original WndProc
if (_originalWndProc != 0)
{
SetWindowLongPtr(_hwnd, GwlWndProc, _originalWndProc);
_originalWndProc = 0;
}
_wndProcDelegate = null;
}
internal readonly struct SuppressScope : IDisposable
{
private readonly DpiSuppressor _owner;
internal SuppressScope(DpiSuppressor owner)
{
_owner = owner;
_owner._suppressing = true;
}
public void Dispose()
{
_owner._suppressing = false;
}
}
}
}

View File

@@ -2,16 +2,31 @@
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
using System;
using System.Runtime.InteropServices;
using Microsoft.UI.Windowing;
using WinUIEx;
namespace PowerDisplay.Helpers
{
/// <summary>
/// PowerDisplay-local window helpers. Flyout positioning/sizing now lives in
/// <c>Microsoft.PowerToys.Common.UI.Flyout.FlyoutWindowHelper</c> (Common.UI.Controls).
/// </summary>
internal static partial class WindowHelper
{
// Cursor position structure for GetCursorPos
[StructLayout(LayoutKind.Sequential)]
private struct POINT
{
public int X;
public int Y;
}
// Cursor position for detecting the monitor with the mouse
[LibraryImport("user32.dll")]
[return: MarshalAs(UnmanagedType.Bool)]
private static partial bool GetCursorPos(out POINT lpPoint);
[LibraryImport("shcore.dll")]
private static partial int GetDpiForMonitor(nint hMonitor, uint dpiType, out uint dpiX, out uint dpiY);
// Window Styles
private const int GwlStyle = -16;
private const int WsCaption = 0x00C00000;
@@ -29,6 +44,8 @@ namespace PowerDisplay.Helpers
private const uint SwpNosize = 0x0001;
private const uint SwpNomove = 0x0002;
private const uint SwpFramechanged = 0x0020;
private const uint MdtEffectiveDpi = 0;
private const int DefaultDpi = 96;
// P/Invoke declarations (64-bit only - PowerToys only builds for x64/ARM64)
[LibraryImport("user32.dll", EntryPoint = "GetWindowLongPtrW")]
@@ -49,7 +66,7 @@ namespace PowerDisplay.Helpers
uint uFlags);
/// <summary>
/// Disable window moving and resizing functionality.
/// Disable window moving and resizing functionality
/// </summary>
public static void DisableWindowMovingAndResizing(nint hWnd)
{
@@ -61,7 +78,7 @@ namespace PowerDisplay.Helpers
style &= ~WsMaximizebox;
style &= ~WsMinimizebox;
style &= ~WsCaption; // Remove entire title bar
style &= ~WsSysmenu; // Remove system menu
style &= ~WsSysmenu; // Remove system menu
// Set new window style
_ = SetWindowLong(hWnd, GwlStyle, style);
@@ -84,5 +101,159 @@ namespace PowerDisplay.Helpers
0,
SwpNomove | SwpNosize | SwpFramechanged);
}
/// <summary>
/// Get the DPI scale factor for a window (relative to standard 96 DPI)
/// </summary>
/// <param name="window">WinUIEx window</param>
/// <returns>DPI scale factor (1.0 = 100%, 1.25 = 125%, 1.5 = 150%, 2.0 = 200%)</returns>
public static double GetDpiScale(WindowEx window)
{
return (double)window.GetDpiForWindow() / DefaultDpi;
}
/// <summary>
/// Get the DPI scale factor for a display area (relative to standard 96 DPI)
/// </summary>
/// <param name="displayArea">Target display area</param>
/// <returns>DPI scale factor (1.0 = 100%, 1.25 = 125%, 1.5 = 150%, 2.0 = 200%)</returns>
public static double GetDpiScale(DisplayArea displayArea)
{
return (double)GetEffectiveDpi(global::Microsoft.UI.Win32Interop.GetMonitorFromDisplayId(displayArea.DisplayId)) / DefaultDpi;
}
/// <summary>
/// Convert device-independent pixels (DIP) to physical pixels.
/// </summary>
/// <param name="dip">Device-independent pixel value</param>
/// <param name="dpiScale">DPI scale factor</param>
/// <returns>Physical pixel value</returns>
public static int ScaleToPhysicalPixels(int dip, double dpiScale)
{
return (int)Math.Ceiling(dip * dpiScale);
}
/// <summary>
/// Convert physical pixels to device-independent pixels (DIP).
/// </summary>
/// <param name="physicalPixels">Physical pixel value</param>
/// <param name="dpiScale">DPI scale factor</param>
/// <returns>Device-independent pixel value</returns>
public static int ScaleToDip(int physicalPixels, double dpiScale)
{
return (int)Math.Floor(physicalPixels / dpiScale);
}
/// <summary>
/// Position a window at the bottom-right corner of the monitor where the mouse cursor is located.
/// Correctly handles all edge cases:
/// - Multi-monitor setups
/// - Taskbar at any position (top/bottom/left/right)
/// - Different DPI settings
/// </summary>
/// <param name="window">WinUIEx window to position</param>
/// <param name="widthDip">Window width in device-independent pixels (DIP)</param>
/// <param name="heightDip">Window height in device-independent pixels (DIP)</param>
/// <param name="rightMarginDip">Right margin in device-independent pixels (DIP)</param>
/// <param name="bottomMarginDip">Bottom margin in device-independent pixels (DIP)</param>
public static void PositionWindowBottomRight(
WindowEx window,
int widthDip,
int heightDip,
int rightMarginDip = 0,
int bottomMarginDip = 0)
{
if (!TryGetDisplayAreaAtCursor(out var displayArea) || displayArea is null)
{
ManagedCommon.Logger.LogWarning("PositionWindowBottomRight: Unable to determine target display from cursor, skipping positioning");
return;
}
MoveWindowBottomRight(window, displayArea, widthDip, heightDip, rightMarginDip, bottomMarginDip);
}
/// <summary>
/// Center a window within the specified display area's work area.
/// </summary>
public static void CenterWindowOnDisplay(
WindowEx window,
DisplayArea displayArea,
int widthDip,
int heightDip)
{
double dpiScale = GetDpiScale(displayArea);
int w = ScaleToPhysicalPixels(widthDip, dpiScale);
int h = ScaleToPhysicalPixels(heightDip, dpiScale);
// WorkArea relative to DisplayArea (accounts for taskbar position)
var rel = GetWorkAreaRelativeToDisplay(displayArea);
int x = rel.X + ((rel.Width - w) / 2);
int y = rel.Y + ((rel.Height - h) / 2);
window.AppWindow.MoveAndResize(new Windows.Graphics.RectInt32(x, y, w, h), displayArea);
}
private static void MoveWindowBottomRight(
WindowEx window,
DisplayArea displayArea,
int widthDip,
int heightDip,
int rightMarginDip,
int bottomMarginDip)
{
double dpiScale = GetDpiScale(displayArea);
int w = ScaleToPhysicalPixels(widthDip, dpiScale);
int h = ScaleToPhysicalPixels(heightDip, dpiScale);
int marginRight = ScaleToPhysicalPixels(rightMarginDip, dpiScale);
int marginBottom = ScaleToPhysicalPixels(bottomMarginDip, dpiScale);
// WorkArea relative to DisplayArea (accounts for taskbar position)
var rel = GetWorkAreaRelativeToDisplay(displayArea);
int x = rel.X + rel.Width - w - marginRight;
int y = rel.Y + rel.Height - h - marginBottom;
window.AppWindow.MoveAndResize(new Windows.Graphics.RectInt32(x, y, w, h), displayArea);
}
/// <summary>
/// Get the work area rectangle relative to the display area's origin.
/// MoveAndResize(rect, displayArea) expects coordinates relative to the DisplayArea,
/// but WorkArea.X/Y are in absolute screen coordinates, so we subtract the DisplayArea origin.
/// The resulting rect describes where the usable area is within the display (e.g., offset by taskbar).
/// </summary>
private static Windows.Graphics.RectInt32 GetWorkAreaRelativeToDisplay(DisplayArea displayArea)
{
var outer = displayArea.OuterBounds;
var work = displayArea.WorkArea;
return new Windows.Graphics.RectInt32(
work.X - outer.X,
work.Y - outer.Y,
work.Width,
work.Height);
}
internal static bool TryGetDisplayAreaAtCursor(out DisplayArea? displayArea)
{
displayArea = null;
if (!GetCursorPos(out var cursorPos))
{
return false;
}
displayArea = DisplayArea.GetFromPoint(new Windows.Graphics.PointInt32(cursorPos.X, cursorPos.Y), DisplayAreaFallback.None);
return displayArea is not null;
}
private static int GetEffectiveDpi(nint hMonitor)
{
if (hMonitor == 0)
{
return DefaultDpi;
}
var hr = GetDpiForMonitor(hMonitor, MdtEffectiveDpi, out var dpiX, out _);
return hr >= 0 && dpiX > 0 ? (int)dpiX : DefaultDpi;
}
}
}

View File

@@ -90,7 +90,6 @@
<!-- Removed Common.UI dependency - SettingsDeepLink is now implemented locally -->
<ProjectReference Include="..\..\..\common\GPOWrapper\GPOWrapper.vcxproj" />
<ProjectReference Include="..\..\..\common\ManagedCommon\ManagedCommon.csproj" />
<ProjectReference Include="..\..\..\common\Common.UI.Controls\Common.UI.Controls.csproj" />
<!-- Removed ManagedCsWin32 - using CsWin32 directly for TrayIcon APIs -->
<ProjectReference Include="..\..\..\common\interop\PowerToys.Interop.vcxproj" />
<ProjectReference Include="..\..\..\settings-ui\Settings.UI.Library\Settings.UI.Library.csproj" />

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