Compare commits
3 Commits
dev/muyuan
...
copilot/up
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c412e85cfc | ||
|
|
049cbccd21 | ||
|
|
4308eb65a9 |
1
.github/actions/spell-check/expect.txt
vendored
@@ -2278,7 +2278,6 @@ THEMECHANGED
|
||||
thickframe
|
||||
Tianma
|
||||
tmain
|
||||
tontrager
|
||||
tskill
|
||||
tweakable
|
||||
UBreak
|
||||
|
||||
279
.github/agents/LabelIssues.agent.md
vendored
@@ -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.
|
||||
106
.github/agents/references/product-label-mapping.md
vendored
@@ -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 -->
|
||||
232
.github/workflows/scheduled-issue-labeling.yml
vendored
Normal 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}`);
|
||||
@@ -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" />
|
||||
|
||||
14
README.md
@@ -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
|
||||
|
||||
|
||||
|
Before Width: | Height: | Size: 24 KiB |
|
Before Width: | Height: | Size: 20 KiB |
|
Before Width: | Height: | Size: 84 KiB |
|
Before Width: | Height: | Size: 23 KiB |
|
Before Width: | Height: | Size: 56 KiB |
|
Before Width: | Height: | Size: 20 KiB |
|
Before Width: | Height: | Size: 66 KiB |
|
Before Width: | Height: | Size: 19 KiB |
|
Before Width: | Height: | Size: 318 KiB |
|
Before Width: | Height: | Size: 141 KiB |
|
Before Width: | Height: | Size: 182 KiB |
|
Before Width: | Height: | Size: 111 KiB |
|
Before Width: | Height: | Size: 41 KiB |
|
Before Width: | Height: | Size: 13 KiB |
@@ -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" />
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
|
Before Width: | Height: | Size: 372 KiB After Width: | Height: | Size: 766 B |
@@ -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"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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="" />
|
||||
</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>
|
||||
@@ -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,
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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(),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -94,7 +94,6 @@ WM_WINDOWPOSCHANGING
|
||||
WM_SHOWWINDOW
|
||||
WM_SIZE
|
||||
WM_GETMINMAXINFO
|
||||
MINMAXINFO
|
||||
SetWinEventHook
|
||||
WINDOW_STYLE
|
||||
SC_MINIMIZE
|
||||
|
||||
@@ -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=}">
|
||||
<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=}">
|
||||
<ComboBox MinWidth="{StaticResource SettingActionControlMinWidth}" SelectedIndex="{x:Bind ViewModel.DockAppearance.ThemeIndex, Mode=TwoWay}">
|
||||
<ComboBoxItem x:Uid="Settings_GeneralPage_AppTheme_Mode_System_Automation" Tag="Default">
|
||||
|
||||
@@ -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>();
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
{
|
||||
|
||||
@@ -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];
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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(),
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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)
|
||||
{
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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!,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 '='.
|
||||
/// </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>
|
||||
|
||||
@@ -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>
|
||||
@@ -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;
|
||||
|
||||
@@ -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()
|
||||
{
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -59,8 +59,4 @@
|
||||
<AdditionalFiles Include="NativeMethods.json" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<InternalsVisibleTo Include="Microsoft.CmdPal.Ext.Indexer.UnitTests" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -295,60 +295,6 @@ namespace Microsoft.CmdPal.Ext.Indexer.Properties {
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Looks up a localized string similar to Search couldn'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>
|
||||
|
||||
@@ -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>
|
||||
@@ -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.
|
||||
@@ -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;
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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>(),
|
||||
};
|
||||
|
||||
@@ -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":
|
||||
|
||||
116
src/modules/powerdisplay/PowerDisplay/Helpers/DpiSuppressor.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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" />
|
||||
|
||||