mirror of
https://github.com/microsoft/PowerToys.git
synced 2026-01-10 22:36:38 +01:00
Compare commits
22 Commits
user/yeela
...
stable
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0314a709f5 | ||
|
|
a246789719 | ||
|
|
af401dd6e9 | ||
|
|
6c2a99dfd6 | ||
|
|
7cf32bf204 | ||
|
|
ae9ba62a40 | ||
|
|
d48338bad3 | ||
|
|
fd19168883 | ||
|
|
db9f8d555e | ||
|
|
be90b587da | ||
|
|
a2cd47f36c | ||
|
|
3167145d42 | ||
|
|
0899961e56 | ||
|
|
8a7503e7dc | ||
|
|
4704e3edb8 | ||
|
|
4aec8f9d0e | ||
|
|
961a65f470 | ||
|
|
876130c3cd | ||
|
|
082ba75ec7 | ||
|
|
8ed090d38e | ||
|
|
b9de59ce64 | ||
|
|
72fc8288eb |
9
.github/actions/spell-check/expect.txt
vendored
9
.github/actions/spell-check/expect.txt
vendored
@@ -215,6 +215,7 @@ cim
|
||||
CImage
|
||||
cla
|
||||
CLASSDC
|
||||
classmethod
|
||||
CLASSNOTAVAILABLE
|
||||
CLEARTYPE
|
||||
clickable
|
||||
@@ -364,6 +365,7 @@ DEFAULTFLAGS
|
||||
DEFAULTICON
|
||||
defaultlib
|
||||
DEFAULTONLY
|
||||
DEFAULTSIZE
|
||||
DEFAULTTONEAREST
|
||||
Defaulttonearest
|
||||
DEFAULTTONULL
|
||||
@@ -830,9 +832,11 @@ ITHUMBNAIL
|
||||
IUI
|
||||
IUWP
|
||||
IWIC
|
||||
jeli
|
||||
jfif
|
||||
jgeosdfsdsgmkedfgdfgdfgbkmhcgcflmi
|
||||
jjw
|
||||
JOBOBJECT
|
||||
jobject
|
||||
jpe
|
||||
jpnime
|
||||
@@ -1599,7 +1603,7 @@ sharpfuzz
|
||||
SHCNE
|
||||
SHCNF
|
||||
SHCONTF
|
||||
Shcore
|
||||
shcore
|
||||
shellapi
|
||||
SHELLDETAILS
|
||||
SHELLDLL
|
||||
@@ -1698,6 +1702,7 @@ srw
|
||||
srwlock
|
||||
sse
|
||||
ssf
|
||||
sszzz
|
||||
STACKFRAME
|
||||
stackoverflow
|
||||
STARTF
|
||||
@@ -1708,6 +1713,7 @@ STARTUPINFOW
|
||||
startupscreen
|
||||
STATFLAG
|
||||
STATICEDGE
|
||||
staticmethod
|
||||
STATSTG
|
||||
stdafx
|
||||
STDAPI
|
||||
@@ -1874,6 +1880,7 @@ uild
|
||||
uitests
|
||||
UITo
|
||||
ULONGLONG
|
||||
Ultrawide
|
||||
ums
|
||||
UMax
|
||||
UMin
|
||||
|
||||
3
.github/actions/spell-check/patterns.txt
vendored
3
.github/actions/spell-check/patterns.txt
vendored
@@ -273,3 +273,6 @@ St&yle
|
||||
# Usernames with numbers
|
||||
# 0x6f677548 is user name but user folder causes a flag
|
||||
\bx6f677548\b
|
||||
|
||||
# Microsoft Store URLs and product IDs
|
||||
ms-windows-store://\S+
|
||||
|
||||
80
.github/copilot-instructions.md
vendored
80
.github/copilot-instructions.md
vendored
@@ -1,59 +1,45 @@
|
||||
---
|
||||
description: PowerToys AI contributor guidance.
|
||||
applyTo: pullRequests
|
||||
description: 'PowerToys AI contributor guidance'
|
||||
---
|
||||
|
||||
# PowerToys - Copilot guide (concise)
|
||||
# PowerToys – Copilot Instructions
|
||||
|
||||
This is the top-level guide for AI changes. Keep edits small, follow existing patterns, and cite exact paths in PRs.
|
||||
Concise guidance for AI contributions. For complete details, see [AGENTS.md](../AGENTS.md).
|
||||
|
||||
# Repo map (1-line per area)
|
||||
- Core apps: `src/runner/**` (tray/loader), `src/settings-ui/**` (Settings app)
|
||||
- Shared libs: `src/common/**`
|
||||
- Modules: `src/modules/*` (one per utility; Command Palette in `src/modules/cmdpal/**`)
|
||||
- Build tools/docs: `tools/**`, `doc/devdocs/**`
|
||||
## Quick Reference
|
||||
|
||||
# Build and test (defaults)
|
||||
- Prerequisites: Visual Studio 2022 17.4+, minimal Windows 10 1803+.
|
||||
- Build discipline:
|
||||
- One terminal per operation (build -> test). Do not switch or open new ones mid-flow.
|
||||
- After making changes, `cd` to the project folder that changed (`.csproj`/`.vcxproj`).
|
||||
- Use scripts to build, synchronously block and wait in foreground for completion: `tools/build/build.ps1|.cmd` (current folder), `build-essentials.*` (once per brand new build for missing nuget packages).
|
||||
- Treat build exit code 0 as success; any non-zero exit code is a failure. Read the errors log in the build folder (such as `build.*.*.errors.log`) and surface problems.
|
||||
- Do not start tests or launch Runner until the previous step succeeded.
|
||||
- Tests (fast and targeted):
|
||||
- Find the test project by product code prefix (for example FancyZones, AdvancedPaste). Look for a sibling folder or one to two levels up named like `<Product>*UnitTests` or `<Product>*UITests`.
|
||||
- Build the test project, wait for exit, then run only those tests via VS Test Explorer or `vstest.console.exe` with filters. Avoid `dotnet test` in this repo.
|
||||
- Add or adjust tests when changing behavior; if skipped, state why (for example comment-only or string rename).
|
||||
- **Build**: `tools\build\build-essentials.cmd` (first time), then `tools\build\build.cmd`
|
||||
- **Tests**: Find `<Product>*UnitTests` project, build it, run via VS Test Explorer
|
||||
- **Exit code 0 = success** – do not proceed if build fails
|
||||
|
||||
# Pull requests (expectations)
|
||||
- Atomic: one logical change; no drive-by refactors.
|
||||
- Describe: problem, approach, risk, test evidence.
|
||||
- List: touched paths if not obvious.
|
||||
## Key Rules
|
||||
|
||||
# When to ask for clarification
|
||||
- Ambiguous spec after scanning relevant docs (see below).
|
||||
- Cross-module impact (shared enum or struct) not clear.
|
||||
- Security, elevation, or installer changes.
|
||||
- One terminal per operation (build → test)
|
||||
- Atomic PRs: one logical change, no drive-by refactors
|
||||
- Add tests when changing behavior
|
||||
- Keep hot paths quiet (no logging in hooks/tight loops)
|
||||
|
||||
# Logging (use existing stacks)
|
||||
- C++ logging lives in `src/common/logger/**` (`Logger::info`, `Logger::warn`, `Logger::error`, `Logger::debug`). Keep hot paths quiet (hooks, tight loops).
|
||||
- C# logging goes through `ManagedCommon.Logger` (`LogInfo`, `LogWarning`, `LogError`, `LogDebug`, `LogTrace`). Some UIs use injected `ILogger` via `LoggerInstance.Logger`.
|
||||
## Style Enforcement
|
||||
|
||||
# Docs to consult
|
||||
- `tools/build/BUILD-GUIDELINES.md`
|
||||
- `doc/devdocs/core/architecture.md`
|
||||
- `doc/devdocs/core/runner.md`
|
||||
- `doc/devdocs/core/settings/readme.md`
|
||||
- `doc/devdocs/modules/readme.md`
|
||||
- C#: `src/.editorconfig`, StyleCop.Analyzers
|
||||
- C++: `src/.clang-format`
|
||||
- XAML: XamlStyler
|
||||
|
||||
# Language style rules
|
||||
- Always enforce repo analyzers: `src/.editorconfig` plus any `stylecop.json`.
|
||||
- C# code follows StyleCop.Analyzers and Microsoft.CodeAnalysis.NetAnalyzers.
|
||||
- C++ code honors `src/.clang-format` for formatting.
|
||||
- Markdown files wrap at 80 characters and use ATX headers with fenced code blocks that include language tags.
|
||||
- YAML files indent two spaces and add comments for complex settings while keeping keys clear.
|
||||
- PowerShell scripts use Verb-Noun names and prefer single-quoted literals while documenting parameters and satisfying PSScriptAnalyzer.
|
||||
## When to Ask for Clarification
|
||||
|
||||
# Done checklist (self review before finishing)
|
||||
- Build clean? Tests updated or passed? No unintended formatting? Any new dependency? Documented skips?
|
||||
- Ambiguous spec after scanning docs
|
||||
- Cross-module impact unclear
|
||||
- Security, elevation, or installer changes
|
||||
|
||||
## Component-Specific Instructions
|
||||
|
||||
These are auto-applied based on file location:
|
||||
- [Runner & Settings UI](.github/instructions/runner-settings-ui.instructions.md)
|
||||
- [Common Libraries](.github/instructions/common-libraries.instructions.md)
|
||||
|
||||
## Detailed Documentation
|
||||
|
||||
- [AGENTS.md](../AGENTS.md) – Full contributor guide
|
||||
- [Build Guidelines](../tools/build/BUILD-GUIDELINES.md)
|
||||
- [Architecture](../doc/devdocs/core/architecture.md)
|
||||
- [Coding Style](../doc/devdocs/development/style.md)
|
||||
|
||||
791
.github/instructions/agents.instructions.md
vendored
Normal file
791
.github/instructions/agents.instructions.md
vendored
Normal file
@@ -0,0 +1,791 @@
|
||||
---
|
||||
description: 'Guidelines for creating custom agent files for GitHub Copilot'
|
||||
applyTo: '**/*.agent.md'
|
||||
---
|
||||
|
||||
# Custom Agent File Guidelines
|
||||
|
||||
Instructions for creating effective and maintainable custom agent files that provide specialized expertise for specific development tasks in GitHub Copilot.
|
||||
|
||||
## Project Context
|
||||
|
||||
- Target audience: Developers creating custom agents for GitHub Copilot
|
||||
- File format: Markdown with YAML frontmatter
|
||||
- File naming convention: lowercase with hyphens (e.g., `test-specialist.agent.md`)
|
||||
- Location: `.github/agents/` directory (repository-level) or `agents/` directory (organization/enterprise-level)
|
||||
- Purpose: Define specialized agents with tailored expertise, tools, and instructions for specific tasks
|
||||
- Official documentation: https://docs.github.com/en/copilot/how-tos/use-copilot-agents/coding-agent/create-custom-agents
|
||||
|
||||
## Required Frontmatter
|
||||
|
||||
Every agent file must include YAML frontmatter with the following fields:
|
||||
|
||||
```yaml
|
||||
---
|
||||
description: 'Brief description of the agent purpose and capabilities'
|
||||
name: 'Agent Display Name'
|
||||
tools: ['read', 'edit', 'search']
|
||||
model: 'Claude Sonnet 4.5'
|
||||
target: 'vscode'
|
||||
infer: true
|
||||
---
|
||||
```
|
||||
|
||||
### Core Frontmatter Properties
|
||||
|
||||
#### **description** (REQUIRED)
|
||||
- Single-quoted string, clearly stating the agent's purpose and domain expertise
|
||||
- Should be concise (50-150 characters) and actionable
|
||||
- Example: `'Focuses on test coverage, quality, and testing best practices'`
|
||||
|
||||
#### **name** (OPTIONAL)
|
||||
- Display name for the agent in the UI
|
||||
- If omitted, defaults to filename (without `.md` or `.agent.md`)
|
||||
- Use title case and be descriptive
|
||||
- Example: `'Testing Specialist'`
|
||||
|
||||
#### **tools** (OPTIONAL)
|
||||
- List of tool names or aliases the agent can use
|
||||
- Supports comma-separated string or YAML array format
|
||||
- If omitted, agent has access to all available tools
|
||||
- See "Tool Configuration" section below for details
|
||||
|
||||
#### **model** (STRONGLY RECOMMENDED)
|
||||
- Specifies which AI model the agent should use
|
||||
- Supported in VS Code, JetBrains IDEs, Eclipse, and Xcode
|
||||
- Example: `'Claude Sonnet 4.5'`, `'gpt-4'`, `'gpt-4o'`
|
||||
- Choose based on agent complexity and required capabilities
|
||||
|
||||
#### **target** (OPTIONAL)
|
||||
- Specifies target environment: `'vscode'` or `'github-copilot'`
|
||||
- If omitted, agent is available in both environments
|
||||
- Use when agent has environment-specific features
|
||||
|
||||
#### **infer** (OPTIONAL)
|
||||
- Boolean controlling whether Copilot can automatically use this agent based on context
|
||||
- Default: `true` if omitted
|
||||
- Set to `false` to require manual agent selection
|
||||
|
||||
#### **metadata** (OPTIONAL, GitHub.com only)
|
||||
- Object with name-value pairs for agent annotation
|
||||
- Example: `metadata: { category: 'testing', version: '1.0' }`
|
||||
- Not supported in VS Code
|
||||
|
||||
#### **mcp-servers** (OPTIONAL, Organization/Enterprise only)
|
||||
- Configure MCP servers available only to this agent
|
||||
- Only supported for organization/enterprise level agents
|
||||
- See "MCP Server Configuration" section below
|
||||
|
||||
## Tool Configuration
|
||||
|
||||
### Tool Specification Strategies
|
||||
|
||||
**Enable all tools** (default):
|
||||
```yaml
|
||||
# Omit tools property entirely, or use:
|
||||
tools: ['*']
|
||||
```
|
||||
|
||||
**Enable specific tools**:
|
||||
```yaml
|
||||
tools: ['read', 'edit', 'search', 'execute']
|
||||
```
|
||||
|
||||
**Enable MCP server tools**:
|
||||
```yaml
|
||||
tools: ['read', 'edit', 'github/*', 'playwright/navigate']
|
||||
```
|
||||
|
||||
**Disable all tools**:
|
||||
```yaml
|
||||
tools: []
|
||||
```
|
||||
|
||||
### Standard Tool Aliases
|
||||
|
||||
All aliases are case-insensitive:
|
||||
|
||||
| Alias | Alternative Names | Category | Description |
|
||||
|-------|------------------|----------|-------------|
|
||||
| `execute` | shell, Bash, powershell | Shell execution | Execute commands in appropriate shell |
|
||||
| `read` | Read, NotebookRead, view | File reading | Read file contents |
|
||||
| `edit` | Edit, MultiEdit, Write, NotebookEdit | File editing | Edit and modify files |
|
||||
| `search` | Grep, Glob, search | Code search | Search for files or text in files |
|
||||
| `agent` | custom-agent, Task | Agent invocation | Invoke other custom agents |
|
||||
| `web` | WebSearch, WebFetch | Web access | Fetch web content and search |
|
||||
| `todo` | TodoWrite | Task management | Create and manage task lists (VS Code only) |
|
||||
|
||||
### Built-in MCP Server Tools
|
||||
|
||||
**GitHub MCP Server**:
|
||||
```yaml
|
||||
tools: ['github/*'] # All GitHub tools
|
||||
tools: ['github/get_file_contents', 'github/search_repositories'] # Specific tools
|
||||
```
|
||||
- All read-only tools available by default
|
||||
- Token scoped to source repository
|
||||
|
||||
**Playwright MCP Server**:
|
||||
```yaml
|
||||
tools: ['playwright/*'] # All Playwright tools
|
||||
tools: ['playwright/navigate', 'playwright/screenshot'] # Specific tools
|
||||
```
|
||||
- Configured to access localhost only
|
||||
- Useful for browser automation and testing
|
||||
|
||||
### Tool Selection Best Practices
|
||||
|
||||
- **Principle of Least Privilege**: Only enable tools necessary for the agent's purpose
|
||||
- **Security**: Limit `execute` access unless explicitly required
|
||||
- **Focus**: Fewer tools = clearer agent purpose and better performance
|
||||
- **Documentation**: Comment why specific tools are required for complex configurations
|
||||
|
||||
## Sub-Agent Invocation (Agent Orchestration)
|
||||
|
||||
Agents can invoke other agents using `runSubagent` to orchestrate multi-step workflows.
|
||||
|
||||
### How It Works
|
||||
|
||||
Include `agent` in tools list to enable sub-agent invocation:
|
||||
|
||||
```yaml
|
||||
tools: ['read', 'edit', 'search', 'agent']
|
||||
```
|
||||
|
||||
Then invoke other agents with `runSubagent`:
|
||||
|
||||
```javascript
|
||||
const result = await runSubagent({
|
||||
description: 'What this step does',
|
||||
prompt: `You are the [Specialist] specialist.
|
||||
|
||||
Context:
|
||||
- Parameter: ${parameterValue}
|
||||
- Input: ${inputPath}
|
||||
- Output: ${outputPath}
|
||||
|
||||
Task:
|
||||
1. Do the specific work
|
||||
2. Write results to output location
|
||||
3. Return summary of completion`
|
||||
});
|
||||
```
|
||||
|
||||
### Basic Pattern
|
||||
|
||||
Structure each sub-agent call with:
|
||||
|
||||
1. **description**: Clear one-line purpose of the sub-agent invocation
|
||||
2. **prompt**: Detailed instructions with substituted variables
|
||||
|
||||
The prompt should include:
|
||||
- Who the sub-agent is (specialist role)
|
||||
- What context it needs (parameters, paths)
|
||||
- What to do (concrete tasks)
|
||||
- Where to write output
|
||||
- What to return (summary)
|
||||
|
||||
### Example: Multi-Step Processing
|
||||
|
||||
```javascript
|
||||
// Step 1: Process data
|
||||
const processing = await runSubagent({
|
||||
description: 'Transform raw input data',
|
||||
prompt: `You are the Data Processor specialist.
|
||||
|
||||
Project: ${projectName}
|
||||
Input: ${basePath}/raw/
|
||||
Output: ${basePath}/processed/
|
||||
|
||||
Task:
|
||||
1. Read all files from input directory
|
||||
2. Apply transformations
|
||||
3. Write processed files to output
|
||||
4. Create summary: ${basePath}/processed/summary.md
|
||||
|
||||
Return: Number of files processed and any issues found`
|
||||
});
|
||||
|
||||
// Step 2: Analyze (depends on Step 1)
|
||||
const analysis = await runSubagent({
|
||||
description: 'Analyze processed data',
|
||||
prompt: `You are the Data Analyst specialist.
|
||||
|
||||
Project: ${projectName}
|
||||
Input: ${basePath}/processed/
|
||||
Output: ${basePath}/analysis/
|
||||
|
||||
Task:
|
||||
1. Read processed files from input
|
||||
2. Generate analysis report
|
||||
3. Write to: ${basePath}/analysis/report.md
|
||||
|
||||
Return: Key findings and identified patterns`
|
||||
});
|
||||
```
|
||||
|
||||
### Key Points
|
||||
|
||||
- **Pass variables in prompts**: Use `${variableName}` for all dynamic values
|
||||
- **Keep prompts focused**: Clear, specific tasks for each sub-agent
|
||||
- **Return summaries**: Each sub-agent should report what it accomplished
|
||||
- **Sequential execution**: Use `await` to maintain order when steps depend on each other
|
||||
- **Error handling**: Check results before proceeding to dependent steps
|
||||
|
||||
### ⚠️ Tool Availability Requirement
|
||||
|
||||
**Critical**: If a sub-agent requires specific tools (e.g., `edit`, `execute`, `search`), the orchestrator must include those tools in its own `tools` list. Sub-agents cannot access tools that aren't available to their parent orchestrator.
|
||||
|
||||
**Example**:
|
||||
```yaml
|
||||
# If your sub-agents need to edit files, execute commands, or search code
|
||||
tools: ['read', 'edit', 'search', 'execute', 'agent']
|
||||
```
|
||||
|
||||
The orchestrator's tool permissions act as a ceiling for all invoked sub-agents. Plan your tool list carefully to ensure all sub-agents have the tools they need.
|
||||
|
||||
### ⚠️ Important Limitation
|
||||
|
||||
**Sub-agent orchestration is NOT suitable for large-scale data processing.** Avoid using `runSubagent` when:
|
||||
- Processing hundreds or thousands of files
|
||||
- Handling large datasets
|
||||
- Performing bulk transformations on big codebases
|
||||
- Orchestrating more than 5-10 sequential steps
|
||||
|
||||
Each sub-agent call adds latency and context overhead. For high-volume processing, implement logic directly in a single agent instead. Use orchestration only for coordinating specialized tasks on focused, manageable datasets.
|
||||
|
||||
## Agent Prompt Structure
|
||||
|
||||
The markdown content below the frontmatter defines the agent's behavior, expertise, and instructions. Well-structured prompts typically include:
|
||||
|
||||
1. **Agent Identity and Role**: Who the agent is and its primary role
|
||||
2. **Core Responsibilities**: What specific tasks the agent performs
|
||||
3. **Approach and Methodology**: How the agent works to accomplish tasks
|
||||
4. **Guidelines and Constraints**: What to do/avoid and quality standards
|
||||
5. **Output Expectations**: Expected output format and quality
|
||||
|
||||
### Prompt Writing Best Practices
|
||||
|
||||
- **Be Specific and Direct**: Use imperative mood ("Analyze", "Generate"); avoid vague terms
|
||||
- **Define Boundaries**: Clearly state scope limits and constraints
|
||||
- **Include Context**: Explain domain expertise and reference relevant frameworks
|
||||
- **Focus on Behavior**: Describe how the agent should think and work
|
||||
- **Use Structured Format**: Headers, bullets, and lists make prompts scannable
|
||||
|
||||
## Variable Definition and Extraction
|
||||
|
||||
Agents can define dynamic parameters to extract values from user input and use them throughout the agent's behavior and sub-agent communications. This enables flexible, context-aware agents that adapt to user-provided data.
|
||||
|
||||
### When to Use Variables
|
||||
|
||||
**Use variables when**:
|
||||
- Agent behavior depends on user input
|
||||
- Need to pass dynamic values to sub-agents
|
||||
- Want to make agents reusable across different contexts
|
||||
- Require parameterized workflows
|
||||
- Need to track or reference user-provided context
|
||||
|
||||
**Examples**:
|
||||
- Extract project name from user prompt
|
||||
- Capture certification name for pipeline processing
|
||||
- Identify file paths or directories
|
||||
- Extract configuration options
|
||||
- Parse feature names or module identifiers
|
||||
|
||||
### Variable Declaration Pattern
|
||||
|
||||
Define variables section early in the agent prompt to document expected parameters:
|
||||
|
||||
```markdown
|
||||
# Agent Name
|
||||
|
||||
## Dynamic Parameters
|
||||
|
||||
- **Parameter Name**: Description and usage
|
||||
- **Another Parameter**: How it's extracted and used
|
||||
|
||||
## Your Mission
|
||||
|
||||
Process [PARAMETER_NAME] to accomplish [task].
|
||||
```
|
||||
|
||||
### Variable Extraction Methods
|
||||
|
||||
#### 1. **Explicit User Input**
|
||||
Ask the user to provide the variable if not detected in the prompt:
|
||||
|
||||
```markdown
|
||||
## Your Mission
|
||||
|
||||
Process the project by analyzing your codebase.
|
||||
|
||||
### Step 1: Identify Project
|
||||
If no project name is provided, **ASK THE USER** for:
|
||||
- Project name or identifier
|
||||
- Base path or directory location
|
||||
- Configuration type (if applicable)
|
||||
|
||||
Use this information to contextualize all subsequent tasks.
|
||||
```
|
||||
|
||||
#### 2. **Implicit Extraction from Prompt**
|
||||
Automatically extract variables from the user's natural language input:
|
||||
|
||||
```javascript
|
||||
// Example: Extract certification name from user input
|
||||
const userInput = "Process My Certification";
|
||||
|
||||
// Extract key information
|
||||
const certificationName = extractCertificationName(userInput);
|
||||
// Result: "My Certification"
|
||||
|
||||
const basePath = `certifications/${certificationName}`;
|
||||
// Result: "certifications/My Certification"
|
||||
```
|
||||
|
||||
#### 3. **Contextual Variable Resolution**
|
||||
Use file context or workspace information to derive variables:
|
||||
|
||||
```markdown
|
||||
## Variable Resolution Strategy
|
||||
|
||||
1. **From User Prompt**: First, look for explicit mentions in user input
|
||||
2. **From File Context**: Check current file name or path
|
||||
3. **From Workspace**: Use workspace folder or active project
|
||||
4. **From Settings**: Reference configuration files
|
||||
5. **Ask User**: If all else fails, request missing information
|
||||
```
|
||||
|
||||
### Using Variables in Agent Prompts
|
||||
|
||||
#### Variable Substitution in Instructions
|
||||
|
||||
Use template variables in agent prompts to make them dynamic:
|
||||
|
||||
```markdown
|
||||
# Agent Name
|
||||
|
||||
## Dynamic Parameters
|
||||
- **Project Name**: ${projectName}
|
||||
- **Base Path**: ${basePath}
|
||||
- **Output Directory**: ${outputDir}
|
||||
|
||||
## Your Mission
|
||||
|
||||
Process the **${projectName}** project located at `${basePath}`.
|
||||
|
||||
## Process Steps
|
||||
|
||||
1. Read input from: `${basePath}/input/`
|
||||
2. Process files according to project configuration
|
||||
3. Write results to: `${outputDir}/`
|
||||
4. Generate summary report
|
||||
|
||||
## Quality Standards
|
||||
|
||||
- Maintain project-specific coding standards for **${projectName}**
|
||||
- Follow directory structure: `${basePath}/[structure]`
|
||||
```
|
||||
|
||||
#### Passing Variables to Sub-Agents
|
||||
|
||||
When invoking a sub-agent, pass all context through template variables in the prompt:
|
||||
|
||||
```javascript
|
||||
// Extract and prepare variables
|
||||
const basePath = `projects/${projectName}`;
|
||||
const inputPath = `${basePath}/src/`;
|
||||
const outputPath = `${basePath}/docs/`;
|
||||
|
||||
// Pass to sub-agent with all variables substituted
|
||||
const result = await runSubagent({
|
||||
description: 'Generate project documentation',
|
||||
prompt: `You are the Documentation specialist.
|
||||
|
||||
Project: ${projectName}
|
||||
Input: ${inputPath}
|
||||
Output: ${outputPath}
|
||||
|
||||
Task:
|
||||
1. Read source files from ${inputPath}
|
||||
2. Generate comprehensive documentation
|
||||
3. Write to ${outputPath}/index.md
|
||||
4. Include code examples and usage guides
|
||||
|
||||
Return: Summary of documentation generated (file count, word count)`
|
||||
});
|
||||
```
|
||||
|
||||
The sub-agent receives all necessary context embedded in the prompt. Variables are resolved before sending the prompt, so the sub-agent works with concrete paths and values, not variable placeholders.
|
||||
|
||||
### Real-World Example: Code Review Orchestrator
|
||||
|
||||
Example of a simple orchestrator that validates code through multiple specialized agents:
|
||||
|
||||
```javascript
|
||||
async function reviewCodePipeline(repositoryName, prNumber) {
|
||||
const basePath = `projects/${repositoryName}/pr-${prNumber}`;
|
||||
|
||||
// Step 1: Security Review
|
||||
const security = await runSubagent({
|
||||
description: 'Scan for security vulnerabilities',
|
||||
prompt: `You are the Security Reviewer specialist.
|
||||
|
||||
Repository: ${repositoryName}
|
||||
PR: ${prNumber}
|
||||
Code: ${basePath}/changes/
|
||||
|
||||
Task:
|
||||
1. Scan code for OWASP Top 10 vulnerabilities
|
||||
2. Check for injection attacks, auth flaws
|
||||
3. Write findings to ${basePath}/security-review.md
|
||||
|
||||
Return: List of critical, high, and medium issues found`
|
||||
});
|
||||
|
||||
// Step 2: Test Coverage Check
|
||||
const coverage = await runSubagent({
|
||||
description: 'Verify test coverage for changes',
|
||||
prompt: `You are the Test Coverage specialist.
|
||||
|
||||
Repository: ${repositoryName}
|
||||
PR: ${prNumber}
|
||||
Changes: ${basePath}/changes/
|
||||
|
||||
Task:
|
||||
1. Analyze code coverage for modified files
|
||||
2. Identify untested critical paths
|
||||
3. Write report to ${basePath}/coverage-report.md
|
||||
|
||||
Return: Current coverage percentage and gaps`
|
||||
});
|
||||
|
||||
// Step 3: Aggregate Results
|
||||
const finalReport = await runSubagent({
|
||||
description: 'Compile all review findings',
|
||||
prompt: `You are the Review Aggregator specialist.
|
||||
|
||||
Repository: ${repositoryName}
|
||||
Reports: ${basePath}/*.md
|
||||
|
||||
Task:
|
||||
1. Read all review reports from ${basePath}/
|
||||
2. Synthesize findings into single report
|
||||
3. Determine overall verdict (APPROVE/NEEDS_FIXES/BLOCK)
|
||||
4. Write to ${basePath}/final-review.md
|
||||
|
||||
Return: Final verdict and executive summary`
|
||||
});
|
||||
|
||||
return finalReport;
|
||||
}
|
||||
```
|
||||
|
||||
This pattern applies to any orchestration scenario: extract variables, call sub-agents with clear context, await results.
|
||||
|
||||
|
||||
### Variable Best Practices
|
||||
|
||||
#### 1. **Clear Documentation**
|
||||
Always document what variables are expected:
|
||||
|
||||
```markdown
|
||||
## Required Variables
|
||||
- **projectName**: The name of the project (string, required)
|
||||
- **basePath**: Root directory for project files (path, required)
|
||||
|
||||
## Optional Variables
|
||||
- **mode**: Processing mode - quick/standard/detailed (enum, default: standard)
|
||||
- **outputFormat**: Output format - markdown/json/html (enum, default: markdown)
|
||||
|
||||
## Derived Variables
|
||||
- **outputDir**: Automatically set to ${basePath}/output
|
||||
- **logFile**: Automatically set to ${basePath}/.log.md
|
||||
```
|
||||
|
||||
#### 2. **Consistent Naming**
|
||||
Use consistent variable naming conventions:
|
||||
|
||||
```javascript
|
||||
// Good: Clear, descriptive naming
|
||||
const variables = {
|
||||
projectName, // What project to work on
|
||||
basePath, // Where project files are located
|
||||
outputDirectory, // Where to save results
|
||||
processingMode, // How to process (detail level)
|
||||
configurationPath // Where config files are
|
||||
};
|
||||
|
||||
// Avoid: Ambiguous or inconsistent
|
||||
const bad_variables = {
|
||||
name, // Too generic
|
||||
path, // Unclear which path
|
||||
mode, // Too short
|
||||
config // Too vague
|
||||
};
|
||||
```
|
||||
|
||||
#### 3. **Validation and Constraints**
|
||||
Document valid values and constraints:
|
||||
|
||||
```markdown
|
||||
## Variable Constraints
|
||||
|
||||
**projectName**:
|
||||
- Type: string (alphanumeric, hyphens, underscores allowed)
|
||||
- Length: 1-100 characters
|
||||
- Required: yes
|
||||
- Pattern: `/^[a-zA-Z0-9_-]+$/`
|
||||
|
||||
**processingMode**:
|
||||
- Type: enum
|
||||
- Valid values: "quick" (< 5min), "standard" (5-15min), "detailed" (15+ min)
|
||||
- Default: "standard"
|
||||
- Required: no
|
||||
```
|
||||
|
||||
## MCP Server Configuration (Organization/Enterprise Only)
|
||||
|
||||
MCP servers extend agent capabilities with additional tools. Only supported for organization and enterprise-level agents.
|
||||
|
||||
### Configuration Format
|
||||
|
||||
```yaml
|
||||
---
|
||||
name: my-custom-agent
|
||||
description: 'Agent with MCP integration'
|
||||
tools: ['read', 'edit', 'custom-mcp/tool-1']
|
||||
mcp-servers:
|
||||
custom-mcp:
|
||||
type: 'local'
|
||||
command: 'some-command'
|
||||
args: ['--arg1', '--arg2']
|
||||
tools: ["*"]
|
||||
env:
|
||||
ENV_VAR_NAME: ${{ secrets.API_KEY }}
|
||||
---
|
||||
```
|
||||
|
||||
### MCP Server Properties
|
||||
|
||||
- **type**: Server type (`'local'` or `'stdio'`)
|
||||
- **command**: Command to start the MCP server
|
||||
- **args**: Array of command arguments
|
||||
- **tools**: Tools to enable from this server (`["*"]` for all)
|
||||
- **env**: Environment variables (supports secrets)
|
||||
|
||||
### Environment Variables and Secrets
|
||||
|
||||
Secrets must be configured in repository settings under "copilot" environment.
|
||||
|
||||
**Supported syntax**:
|
||||
```yaml
|
||||
env:
|
||||
# Environment variable only
|
||||
VAR_NAME: COPILOT_MCP_ENV_VAR_VALUE
|
||||
|
||||
# Variable with header
|
||||
VAR_NAME: $COPILOT_MCP_ENV_VAR_VALUE
|
||||
VAR_NAME: ${COPILOT_MCP_ENV_VAR_VALUE}
|
||||
|
||||
# GitHub Actions-style (YAML only)
|
||||
VAR_NAME: ${{ secrets.COPILOT_MCP_ENV_VAR_VALUE }}
|
||||
VAR_NAME: ${{ var.COPILOT_MCP_ENV_VAR_VALUE }}
|
||||
```
|
||||
|
||||
## File Organization and Naming
|
||||
|
||||
### Repository-Level Agents
|
||||
- Location: `.github/agents/`
|
||||
- Scope: Available only in the specific repository
|
||||
- Access: Uses repository-configured MCP servers
|
||||
|
||||
### Organization/Enterprise-Level Agents
|
||||
- Location: `.github-private/agents/` (then move to `agents/` root)
|
||||
- Scope: Available across all repositories in org/enterprise
|
||||
- Access: Can configure dedicated MCP servers
|
||||
|
||||
### Naming Conventions
|
||||
- Use lowercase with hyphens: `test-specialist.agent.md`
|
||||
- Name should reflect agent purpose
|
||||
- Filename becomes default agent name (if `name` not specified)
|
||||
- Allowed characters: `.`, `-`, `_`, `a-z`, `A-Z`, `0-9`
|
||||
|
||||
## Agent Processing and Behavior
|
||||
|
||||
### Versioning
|
||||
- Based on Git commit SHAs for the agent file
|
||||
- Create branches/tags for different agent versions
|
||||
- Instantiated using latest version for repository/branch
|
||||
- PR interactions use same agent version for consistency
|
||||
|
||||
### Name Conflicts
|
||||
Priority (highest to lowest):
|
||||
1. Repository-level agent
|
||||
2. Organization-level agent
|
||||
3. Enterprise-level agent
|
||||
|
||||
Lower-level configurations override higher-level ones with the same name.
|
||||
|
||||
### Tool Processing
|
||||
- `tools` list filters available tools (built-in and MCP)
|
||||
- No tools specified = all tools enabled
|
||||
- Empty list (`[]`) = all tools disabled
|
||||
- Specific list = only those tools enabled
|
||||
- Unrecognized tool names are ignored (allows environment-specific tools)
|
||||
|
||||
### MCP Server Processing Order
|
||||
1. Out-of-the-box MCP servers (e.g., GitHub MCP)
|
||||
2. Custom agent MCP configuration (org/enterprise only)
|
||||
3. Repository-level MCP configurations
|
||||
|
||||
Each level can override settings from previous levels.
|
||||
|
||||
## Agent Creation Checklist
|
||||
|
||||
### Frontmatter
|
||||
- [ ] `description` field present and descriptive (50-150 chars)
|
||||
- [ ] `description` wrapped in single quotes
|
||||
- [ ] `name` specified (optional but recommended)
|
||||
- [ ] `tools` configured appropriately (or intentionally omitted)
|
||||
- [ ] `model` specified for optimal performance
|
||||
- [ ] `target` set if environment-specific
|
||||
- [ ] `infer` set to `false` if manual selection required
|
||||
|
||||
### Prompt Content
|
||||
- [ ] Clear agent identity and role defined
|
||||
- [ ] Core responsibilities listed explicitly
|
||||
- [ ] Approach and methodology explained
|
||||
- [ ] Guidelines and constraints specified
|
||||
- [ ] Output expectations documented
|
||||
- [ ] Examples provided where helpful
|
||||
- [ ] Instructions are specific and actionable
|
||||
- [ ] Scope and boundaries clearly defined
|
||||
- [ ] Total content under 30,000 characters
|
||||
|
||||
### File Structure
|
||||
- [ ] Filename follows lowercase-with-hyphens convention
|
||||
- [ ] File placed in correct directory (`.github/agents/` or `agents/`)
|
||||
- [ ] Filename uses only allowed characters
|
||||
- [ ] File extension is `.agent.md`
|
||||
|
||||
### Quality Assurance
|
||||
- [ ] Agent purpose is unique and not duplicative
|
||||
- [ ] Tools are minimal and necessary
|
||||
- [ ] Instructions are clear and unambiguous
|
||||
- [ ] Agent has been tested with representative tasks
|
||||
- [ ] Documentation references are current
|
||||
- [ ] Security considerations addressed (if applicable)
|
||||
|
||||
## Common Agent Patterns
|
||||
|
||||
### Testing Specialist
|
||||
**Purpose**: Focus on test coverage and quality
|
||||
**Tools**: All tools (for comprehensive test creation)
|
||||
**Approach**: Analyze, identify gaps, write tests, avoid production code changes
|
||||
|
||||
### Implementation Planner
|
||||
**Purpose**: Create detailed technical plans and specifications
|
||||
**Tools**: Limited to `['read', 'search', 'edit']`
|
||||
**Approach**: Analyze requirements, create documentation, avoid implementation
|
||||
|
||||
### Code Reviewer
|
||||
**Purpose**: Review code quality and provide feedback
|
||||
**Tools**: `['read', 'search']` only
|
||||
**Approach**: Analyze, suggest improvements, no direct modifications
|
||||
|
||||
### Refactoring Specialist
|
||||
**Purpose**: Improve code structure and maintainability
|
||||
**Tools**: `['read', 'search', 'edit']`
|
||||
**Approach**: Analyze patterns, propose refactorings, implement safely
|
||||
|
||||
### Security Auditor
|
||||
**Purpose**: Identify security issues and vulnerabilities
|
||||
**Tools**: `['read', 'search', 'web']`
|
||||
**Approach**: Scan code, check against OWASP, report findings
|
||||
|
||||
## Common Mistakes to Avoid
|
||||
|
||||
### Frontmatter Errors
|
||||
- ❌ Missing `description` field
|
||||
- ❌ Description not wrapped in quotes
|
||||
- ❌ Invalid tool names without checking documentation
|
||||
- ❌ Incorrect YAML syntax (indentation, quotes)
|
||||
|
||||
### Tool Configuration Issues
|
||||
- ❌ Granting excessive tool access unnecessarily
|
||||
- ❌ Missing required tools for agent's purpose
|
||||
- ❌ Not using tool aliases consistently
|
||||
- ❌ Forgetting MCP server namespace (`server-name/tool`)
|
||||
|
||||
### Prompt Content Problems
|
||||
- ❌ Vague, ambiguous instructions
|
||||
- ❌ Conflicting or contradictory guidelines
|
||||
- ❌ Lack of clear scope definition
|
||||
- ❌ Missing output expectations
|
||||
- ❌ Overly verbose instructions (exceeding character limits)
|
||||
- ❌ No examples or context for complex tasks
|
||||
|
||||
### Organizational Issues
|
||||
- ❌ Filename doesn't reflect agent purpose
|
||||
- ❌ Wrong directory (confusing repo vs org level)
|
||||
- ❌ Using spaces or special characters in filename
|
||||
- ❌ Duplicate agent names causing conflicts
|
||||
|
||||
## Testing and Validation
|
||||
|
||||
### Manual Testing
|
||||
1. Create the agent file with proper frontmatter
|
||||
2. Reload VS Code or refresh GitHub.com
|
||||
3. Select the agent from the dropdown in Copilot Chat
|
||||
4. Test with representative user queries
|
||||
5. Verify tool access works as expected
|
||||
6. Confirm output meets expectations
|
||||
|
||||
### Integration Testing
|
||||
- Test agent with different file types in scope
|
||||
- Verify MCP server connectivity (if configured)
|
||||
- Check agent behavior with missing context
|
||||
- Test error handling and edge cases
|
||||
- Validate agent switching and handoffs
|
||||
|
||||
### Quality Checks
|
||||
- Run through agent creation checklist
|
||||
- Review against common mistakes list
|
||||
- Compare with example agents in repository
|
||||
- Get peer review for complex agents
|
||||
- Document any special configuration needs
|
||||
|
||||
## Additional Resources
|
||||
|
||||
### Official Documentation
|
||||
- [Creating Custom Agents](https://docs.github.com/en/copilot/how-tos/use-copilot-agents/coding-agent/create-custom-agents)
|
||||
- [Custom Agents Configuration](https://docs.github.com/en/copilot/reference/custom-agents-configuration)
|
||||
- [Custom Agents in VS Code](https://code.visualstudio.com/docs/copilot/customization/custom-agents)
|
||||
- [MCP Integration](https://docs.github.com/en/copilot/how-tos/use-copilot-agents/coding-agent/extend-coding-agent-with-mcp)
|
||||
|
||||
### Community Resources
|
||||
- [Awesome Copilot Agents Collection](https://github.com/github/awesome-copilot/tree/main/agents)
|
||||
- [Customization Library Examples](https://docs.github.com/en/copilot/tutorials/customization-library/custom-agents)
|
||||
- [Your First Custom Agent Tutorial](https://docs.github.com/en/copilot/tutorials/customization-library/custom-agents/your-first-custom-agent)
|
||||
|
||||
### Related Files
|
||||
- [Prompt Files Guidelines](./prompt.instructions.md) - For creating prompt files
|
||||
- [Instructions Guidelines](./instructions.instructions.md) - For creating instruction files
|
||||
|
||||
## Version Compatibility Notes
|
||||
|
||||
### GitHub.com (Coding Agent)
|
||||
- ✅ Fully supports all standard frontmatter properties
|
||||
- ✅ Repository and org/enterprise level agents
|
||||
- ✅ MCP server configuration (org/enterprise)
|
||||
- ❌ Does not support `model`, `argument-hint`, `handoffs` properties
|
||||
|
||||
### VS Code / JetBrains / Eclipse / Xcode
|
||||
- ✅ Supports `model` property for AI model selection
|
||||
- ✅ Supports `argument-hint` and `handoffs` properties
|
||||
- ✅ User profile and workspace-level agents
|
||||
- ❌ Cannot configure MCP servers at repository level
|
||||
- ⚠️ Some properties may behave differently
|
||||
|
||||
When creating agents for multiple environments, focus on common properties and test in all target environments. Use `target` property to create environment-specific agents when necessary.
|
||||
187
.github/instructions/azure-devops-pipelines.instructions.md
vendored
Normal file
187
.github/instructions/azure-devops-pipelines.instructions.md
vendored
Normal file
@@ -0,0 +1,187 @@
|
||||
---
|
||||
description: 'Best practices for Azure DevOps Pipeline YAML files'
|
||||
applyTo: '**/azure-pipelines.yml, **/azure-pipelines*.yml, **/*.pipeline.yml'
|
||||
---
|
||||
|
||||
# Azure DevOps Pipeline YAML Best Practices
|
||||
|
||||
Guidelines for creating maintainable, secure, and efficient Azure DevOps pipelines in PowerToys.
|
||||
|
||||
## General Guidelines
|
||||
|
||||
- Use YAML syntax consistently with proper indentation (2 spaces)
|
||||
- Always include meaningful names and display names for pipelines, stages, jobs, and steps
|
||||
- Implement proper error handling and conditional execution
|
||||
- Use variables and parameters to make pipelines reusable and maintainable
|
||||
- Follow the principle of least privilege for service connections and permissions
|
||||
- Include comprehensive logging and diagnostics for troubleshooting
|
||||
|
||||
## Pipeline Structure
|
||||
|
||||
- Organize complex pipelines using stages for better visualization and control
|
||||
- Use jobs to group related steps and enable parallel execution when possible
|
||||
- Implement proper dependencies between stages and jobs
|
||||
- Use templates for reusable pipeline components
|
||||
- Keep pipeline files focused and modular - split large pipelines into multiple files
|
||||
|
||||
## Build Best Practices
|
||||
|
||||
- Use specific agent pool versions and VM images for consistency
|
||||
- Cache dependencies (npm, NuGet, Maven, etc.) to improve build performance
|
||||
- Implement proper artifact management with meaningful names and retention policies
|
||||
- Use build variables for version numbers and build metadata
|
||||
- Include code quality gates (lint checks, testing, security scans)
|
||||
- Ensure builds are reproducible and environment-independent
|
||||
|
||||
## Testing Integration
|
||||
|
||||
- Run unit tests as part of the build process
|
||||
- Publish test results in standard formats (JUnit, VSTest, etc.)
|
||||
- Include code coverage reporting and quality gates
|
||||
- Implement integration and end-to-end tests in appropriate stages
|
||||
- Use test impact analysis when available to optimize test execution
|
||||
- Fail fast on test failures to provide quick feedback
|
||||
|
||||
## Security Considerations
|
||||
|
||||
- Use Azure Key Vault for sensitive configuration and secrets
|
||||
- Implement proper secret management with variable groups
|
||||
- Use service connections with minimal required permissions
|
||||
- Enable security scans (dependency vulnerabilities, static analysis)
|
||||
- Implement approval gates for production deployments
|
||||
- Use managed identities when possible instead of service principals
|
||||
|
||||
## Deployment Strategies
|
||||
|
||||
- Implement proper environment promotion (dev → staging → production)
|
||||
- Use deployment jobs with proper environment targeting
|
||||
- Implement blue-green or canary deployment strategies when appropriate
|
||||
- Include rollback mechanisms and health checks
|
||||
- Use infrastructure as code (ARM, Bicep, Terraform) for consistent deployments
|
||||
- Implement proper configuration management per environment
|
||||
|
||||
## Variable and Parameter Management
|
||||
|
||||
- Use variable groups for shared configuration across pipelines
|
||||
- Implement runtime parameters for flexible pipeline execution
|
||||
- Use conditional variables based on branches or environments
|
||||
- Secure sensitive variables and mark them as secrets
|
||||
- Document variable purposes and expected values
|
||||
- Use variable templates for complex variable logic
|
||||
|
||||
## Performance Optimization
|
||||
|
||||
- Use parallel jobs and matrix strategies when appropriate
|
||||
- Implement proper caching strategies for dependencies and build outputs
|
||||
- Use shallow clone for Git operations when full history isn't needed
|
||||
- Optimize Docker image builds with multi-stage builds and layer caching
|
||||
- Monitor pipeline performance and optimize bottlenecks
|
||||
- Use pipeline resource triggers efficiently
|
||||
|
||||
## Monitoring and Observability
|
||||
|
||||
- Include comprehensive logging throughout the pipeline
|
||||
- Use Azure Monitor and Application Insights for deployment tracking
|
||||
- Implement proper notification strategies for failures and successes
|
||||
- Include deployment health checks and automated rollback triggers
|
||||
- Use pipeline analytics to identify improvement opportunities
|
||||
- Document pipeline behavior and troubleshooting steps
|
||||
|
||||
## Template and Reusability
|
||||
|
||||
- Create pipeline templates for common patterns
|
||||
- Use extends templates for complete pipeline inheritance
|
||||
- Implement step templates for reusable task sequences
|
||||
- Use variable templates for complex variable logic
|
||||
- Version templates appropriately for stability
|
||||
- Document template parameters and usage examples
|
||||
|
||||
## Branch and Trigger Strategy
|
||||
|
||||
- Implement appropriate triggers for different branch types
|
||||
- Use path filters to trigger builds only when relevant files change
|
||||
- Configure proper CI/CD triggers for main/master branches
|
||||
- Use pull request triggers for code validation
|
||||
- Implement scheduled triggers for maintenance tasks
|
||||
- Consider resource triggers for multi-repository scenarios
|
||||
|
||||
## Example Structure
|
||||
|
||||
```yaml
|
||||
# azure-pipelines.yml
|
||||
trigger:
|
||||
branches:
|
||||
include:
|
||||
- main
|
||||
- develop
|
||||
paths:
|
||||
exclude:
|
||||
- docs/*
|
||||
- README.md
|
||||
|
||||
variables:
|
||||
- group: shared-variables
|
||||
- name: buildConfiguration
|
||||
value: 'Release'
|
||||
|
||||
stages:
|
||||
- stage: Build
|
||||
displayName: 'Build and Test'
|
||||
jobs:
|
||||
- job: Build
|
||||
displayName: 'Build Application'
|
||||
pool:
|
||||
vmImage: 'ubuntu-latest'
|
||||
steps:
|
||||
- task: UseDotNet@2
|
||||
displayName: 'Use .NET SDK'
|
||||
inputs:
|
||||
version: '8.x'
|
||||
|
||||
- task: DotNetCoreCLI@2
|
||||
displayName: 'Restore dependencies'
|
||||
inputs:
|
||||
command: 'restore'
|
||||
projects: '**/*.csproj'
|
||||
|
||||
- task: DotNetCoreCLI@2
|
||||
displayName: 'Build application'
|
||||
inputs:
|
||||
command: 'build'
|
||||
projects: '**/*.csproj'
|
||||
arguments: '--configuration $(buildConfiguration) --no-restore'
|
||||
|
||||
- stage: Deploy
|
||||
displayName: 'Deploy to Staging'
|
||||
dependsOn: Build
|
||||
condition: and(succeeded(), eq(variables['Build.SourceBranch'], 'refs/heads/main'))
|
||||
jobs:
|
||||
- deployment: DeployToStaging
|
||||
displayName: 'Deploy to Staging Environment'
|
||||
environment: 'staging'
|
||||
strategy:
|
||||
runOnce:
|
||||
deploy:
|
||||
steps:
|
||||
- download: current
|
||||
displayName: 'Download drop artifact'
|
||||
artifact: drop
|
||||
- task: AzureWebApp@1
|
||||
displayName: 'Deploy to Azure Web App'
|
||||
inputs:
|
||||
azureSubscription: 'staging-service-connection'
|
||||
appType: 'webApp'
|
||||
appName: 'myapp-staging'
|
||||
package: '$(Pipeline.Workspace)/drop/**/*.zip'
|
||||
```
|
||||
|
||||
## Common Anti-Patterns to Avoid
|
||||
|
||||
- Hardcoding sensitive values directly in YAML files
|
||||
- Using overly broad triggers that cause unnecessary builds
|
||||
- Mixing build and deployment logic in a single stage
|
||||
- Not implementing proper error handling and cleanup
|
||||
- Using deprecated task versions without upgrade plans
|
||||
- Creating monolithic pipelines that are difficult to maintain
|
||||
- Not using proper naming conventions for clarity
|
||||
- Ignoring pipeline security best practices
|
||||
61
.github/instructions/common-libraries.instructions.md
vendored
Normal file
61
.github/instructions/common-libraries.instructions.md
vendored
Normal file
@@ -0,0 +1,61 @@
|
||||
---
|
||||
description: 'Guidelines for shared libraries including logging, IPC, settings, DPI, telemetry, and utilities consumed by multiple modules'
|
||||
applyTo: 'src/common/**'
|
||||
---
|
||||
|
||||
# Common Libraries – Shared Code Guidance
|
||||
|
||||
Guidelines for modifying shared code in `src/common/`. Changes here can have wide-reaching impact across the entire PowerToys codebase.
|
||||
|
||||
## Scope
|
||||
|
||||
- Logging infrastructure (`src/common/logger/`)
|
||||
- IPC primitives and named pipe utilities
|
||||
- Settings serialization and management
|
||||
- DPI awareness and scaling utilities
|
||||
- Telemetry helpers
|
||||
- General utilities (JSON parsing, string helpers, etc.)
|
||||
|
||||
## Guidelines
|
||||
|
||||
### API Stability
|
||||
|
||||
- Avoid breaking public headers/APIs; if changed, search & update all callers
|
||||
- Coordinate ABI-impacting struct/class layout changes; keep binary compatibility
|
||||
- When modifying public interfaces, grep the entire codebase for usages
|
||||
|
||||
### Performance
|
||||
|
||||
- Watch perf in hot paths (hooks, timers, serialization)
|
||||
- Avoid avoidable allocations in frequently called code
|
||||
- Profile changes that touch performance-sensitive areas
|
||||
|
||||
### Dependencies
|
||||
|
||||
- Ask before adding third-party deps or changing serialization formats
|
||||
- New dependencies must be MIT-licensed or approved by PM team
|
||||
- Add any new external packages to `NOTICE.md`
|
||||
|
||||
### Logging
|
||||
|
||||
- C++ logging uses spdlog (`Logger::info`, `Logger::warn`, `Logger::error`, `Logger::debug`)
|
||||
- Initialize with `init_logger()` early in startup
|
||||
- Keep hot paths quiet – no logging in tight loops or hooks
|
||||
|
||||
## Acceptance Criteria
|
||||
|
||||
- No unintended ABI breaks
|
||||
- No noisy logs in hot paths
|
||||
- New non-obvious symbols briefly commented
|
||||
- All callers updated when interfaces change
|
||||
|
||||
## Code Style
|
||||
|
||||
- **C++**: Follow `.clang-format` in `src/`; use Modern C++ patterns per C++ Core Guidelines
|
||||
- **C#**: Follow `src/.editorconfig`; enforce StyleCop.Analyzers
|
||||
|
||||
## Validation
|
||||
|
||||
- Build: `tools\build\build.cmd` from `src/common/` folder
|
||||
- Verify no ABI breaks: grep for changed function/struct names across codebase
|
||||
- Check logs: ensure no new logging in performance-critical paths
|
||||
256
.github/instructions/instructions.instructions.md
vendored
Normal file
256
.github/instructions/instructions.instructions.md
vendored
Normal file
@@ -0,0 +1,256 @@
|
||||
---
|
||||
description: 'Guidelines for creating high-quality custom instruction files for GitHub Copilot'
|
||||
applyTo: '**/*.instructions.md'
|
||||
---
|
||||
|
||||
# Custom Instructions File Guidelines
|
||||
|
||||
Instructions for creating effective and maintainable custom instruction files that guide GitHub Copilot in generating domain-specific code and following project conventions.
|
||||
|
||||
## Project Context
|
||||
|
||||
- Target audience: Developers and GitHub Copilot working with domain-specific code
|
||||
- File format: Markdown with YAML frontmatter
|
||||
- File naming convention: lowercase with hyphens (e.g., `react-best-practices.instructions.md`)
|
||||
- Location: `.github/instructions/` directory
|
||||
- Purpose: Provide context-aware guidance for code generation, review, and documentation
|
||||
|
||||
## Required Frontmatter
|
||||
|
||||
Every instruction file must include YAML frontmatter with the following fields:
|
||||
|
||||
```yaml
|
||||
---
|
||||
description: 'Brief description of the instruction purpose and scope'
|
||||
applyTo: 'glob pattern for target files (e.g., **/*.ts, **/*.py)'
|
||||
---
|
||||
```
|
||||
|
||||
### Frontmatter Guidelines
|
||||
|
||||
- **description**: Single-quoted string, 1-500 characters, clearly stating the purpose
|
||||
- **applyTo**: Glob pattern(s) specifying which files these instructions apply to
|
||||
- Single pattern: `'**/*.ts'`
|
||||
- Multiple patterns: `'**/*.ts, **/*.tsx, **/*.js'`
|
||||
- Specific files: `'src/**/*.py'`
|
||||
- All files: `'**'`
|
||||
|
||||
## File Structure
|
||||
|
||||
A well-structured instruction file should include the following sections:
|
||||
|
||||
### 1. Title and Overview
|
||||
|
||||
- Clear, descriptive title using `#` heading
|
||||
- Brief introduction explaining the purpose and scope
|
||||
- Optional: Project context section with key technologies and versions
|
||||
|
||||
### 2. Core Sections
|
||||
|
||||
Organize content into logical sections based on the domain:
|
||||
|
||||
- **General Instructions**: High-level guidelines and principles
|
||||
- **Best Practices**: Recommended patterns and approaches
|
||||
- **Code Standards**: Naming conventions, formatting, style rules
|
||||
- **Architecture/Structure**: Project organization and design patterns
|
||||
- **Common Patterns**: Frequently used implementations
|
||||
- **Security**: Security considerations (if applicable)
|
||||
- **Performance**: Optimization guidelines (if applicable)
|
||||
- **Testing**: Testing standards and approaches (if applicable)
|
||||
|
||||
### 3. Examples and Code Snippets
|
||||
|
||||
Provide concrete examples with clear labels:
|
||||
|
||||
```markdown
|
||||
### Good Example
|
||||
\`\`\`language
|
||||
// Recommended approach
|
||||
code example here
|
||||
\`\`\`
|
||||
|
||||
### Bad Example
|
||||
\`\`\`language
|
||||
// Avoid this pattern
|
||||
code example here
|
||||
\`\`\`
|
||||
```
|
||||
|
||||
### 4. Validation and Verification (Optional but Recommended)
|
||||
|
||||
- Build commands to verify code
|
||||
- Lint checks and formatting tools
|
||||
- Testing requirements
|
||||
- Verification steps
|
||||
|
||||
## Content Guidelines
|
||||
|
||||
### Writing Style
|
||||
|
||||
- Use clear, concise language
|
||||
- Write in imperative mood ("Use", "Implement", "Avoid")
|
||||
- Be specific and actionable
|
||||
- Avoid ambiguous terms like "should", "might", "possibly"
|
||||
- Use bullet points and lists for readability
|
||||
- Keep sections focused and scannable
|
||||
|
||||
### Best Practices
|
||||
|
||||
- **Be Specific**: Provide concrete examples rather than abstract concepts
|
||||
- **Show Why**: Explain the reasoning behind recommendations when it adds value
|
||||
- **Use Tables**: For comparing options, listing rules, or showing patterns
|
||||
- **Include Examples**: Real code snippets are more effective than descriptions
|
||||
- **Stay Current**: Reference current versions and best practices
|
||||
- **Link Resources**: Include official documentation and authoritative sources
|
||||
|
||||
### Common Patterns to Include
|
||||
|
||||
1. **Naming Conventions**: How to name variables, functions, classes, files
|
||||
2. **Code Organization**: File structure, module organization, import order
|
||||
3. **Error Handling**: Preferred error handling patterns
|
||||
4. **Dependencies**: How to manage and document dependencies
|
||||
5. **Comments and Documentation**: When and how to document code
|
||||
6. **Version Information**: Target language/framework versions
|
||||
|
||||
## Patterns to Follow
|
||||
|
||||
### Bullet Points and Lists
|
||||
|
||||
```markdown
|
||||
## Security Best Practices
|
||||
|
||||
- Always validate user input before processing
|
||||
- Use parameterized queries to prevent SQL injection
|
||||
- Store secrets in environment variables, never in code
|
||||
- Implement proper authentication and authorization
|
||||
- Enable HTTPS for all production endpoints
|
||||
```
|
||||
|
||||
### Tables for Structured Information
|
||||
|
||||
```markdown
|
||||
## Common Issues
|
||||
|
||||
| Issue | Solution | Example |
|
||||
| ---------------- | ------------------- | ----------------------------- |
|
||||
| Magic numbers | Use named constants | `const MAX_RETRIES = 3` |
|
||||
| Deep nesting | Extract functions | Refactor nested if statements |
|
||||
| Hardcoded values | Use configuration | Store API URLs in config |
|
||||
```
|
||||
|
||||
### Code Comparison
|
||||
|
||||
```markdown
|
||||
### Good Example - Using TypeScript interfaces
|
||||
\`\`\`typescript
|
||||
interface User {
|
||||
id: string;
|
||||
name: string;
|
||||
email: string;
|
||||
}
|
||||
|
||||
function getUser(id: string): User {
|
||||
// Implementation
|
||||
}
|
||||
\`\`\`
|
||||
|
||||
### Bad Example - Using any type
|
||||
\`\`\`typescript
|
||||
function getUser(id: any): any {
|
||||
// Loses type safety
|
||||
}
|
||||
\`\`\`
|
||||
```
|
||||
|
||||
### Conditional Guidance
|
||||
|
||||
```markdown
|
||||
## Framework Selection
|
||||
|
||||
- **For small projects**: Use Minimal API approach
|
||||
- **For large projects**: Use controller-based architecture with clear separation
|
||||
- **For microservices**: Consider domain-driven design patterns
|
||||
```
|
||||
|
||||
## Patterns to Avoid
|
||||
|
||||
- **Overly verbose explanations**: Keep it concise and scannable
|
||||
- **Outdated information**: Always reference current versions and practices
|
||||
- **Ambiguous guidelines**: Be specific about what to do or avoid
|
||||
- **Missing examples**: Abstract rules without concrete code examples
|
||||
- **Contradictory advice**: Ensure consistency throughout the file
|
||||
- **Copy-paste from documentation**: Add value by distilling and providing context
|
||||
|
||||
## Testing Your Instructions
|
||||
|
||||
Before finalizing instruction files:
|
||||
|
||||
1. **Test with Copilot**: Try the instructions with actual prompts in VS Code
|
||||
2. **Verify Examples**: Ensure code examples are correct and run without errors
|
||||
3. **Check Glob Patterns**: Confirm `applyTo` patterns match intended files
|
||||
|
||||
## Example Structure
|
||||
|
||||
Here's a minimal example structure for a new instruction file:
|
||||
|
||||
```markdown
|
||||
---
|
||||
description: 'Brief description of purpose'
|
||||
applyTo: '**/*.ext'
|
||||
---
|
||||
|
||||
# Technology Name Development
|
||||
|
||||
Brief introduction and context.
|
||||
|
||||
## General Instructions
|
||||
|
||||
- High-level guideline 1
|
||||
- High-level guideline 2
|
||||
|
||||
## Best Practices
|
||||
|
||||
- Specific practice 1
|
||||
- Specific practice 2
|
||||
|
||||
## Code Standards
|
||||
|
||||
### Naming Conventions
|
||||
- Rule 1
|
||||
- Rule 2
|
||||
|
||||
### File Organization
|
||||
- Structure 1
|
||||
- Structure 2
|
||||
|
||||
## Common Patterns
|
||||
|
||||
### Pattern 1
|
||||
Description and example
|
||||
|
||||
\`\`\`language
|
||||
code example
|
||||
\`\`\`
|
||||
|
||||
### Pattern 2
|
||||
Description and example
|
||||
|
||||
## Validation
|
||||
|
||||
- Build command: `command to verify`
|
||||
- Lint checks: `command to lint`
|
||||
- Testing: `command to test`
|
||||
```
|
||||
|
||||
## Maintenance
|
||||
|
||||
- Review instructions when dependencies or frameworks are updated
|
||||
- Update examples to reflect current best practices
|
||||
- Remove outdated patterns or deprecated features
|
||||
- Add new patterns as they emerge in the community
|
||||
- Keep glob patterns accurate as project structure evolves
|
||||
|
||||
## Additional Resources
|
||||
|
||||
- [Custom Instructions Documentation](https://code.visualstudio.com/docs/copilot/customization/custom-instructions)
|
||||
- [Awesome Copilot Instructions](https://github.com/github/awesome-copilot/tree/main/instructions)
|
||||
88
.github/instructions/prompt.instructions.md
vendored
Normal file
88
.github/instructions/prompt.instructions.md
vendored
Normal file
@@ -0,0 +1,88 @@
|
||||
---
|
||||
description: 'Guidelines for creating high-quality prompt files for GitHub Copilot'
|
||||
applyTo: '**/*.prompt.md'
|
||||
---
|
||||
|
||||
# Copilot Prompt Files Guidelines
|
||||
|
||||
Instructions for creating effective and maintainable prompt files that guide GitHub Copilot in delivering consistent, high-quality outcomes across any repository.
|
||||
|
||||
## Scope and Principles
|
||||
- Target audience: maintainers and contributors authoring reusable prompts for Copilot Chat.
|
||||
- Goals: predictable behaviour, clear expectations, minimal permissions, and portability across repositories.
|
||||
- Primary references: VS Code documentation on prompt files and organization-specific conventions.
|
||||
|
||||
## Frontmatter Requirements
|
||||
|
||||
Every prompt file should include YAML frontmatter with the following fields:
|
||||
|
||||
### Required/Recommended Fields
|
||||
|
||||
| Field | Required | Description |
|
||||
|-------|----------|-------------|
|
||||
| `description` | Recommended | A short description of the prompt (single sentence, actionable outcome) |
|
||||
| `name` | Optional | The name shown after typing `/` in chat. Defaults to filename if not specified |
|
||||
| `agent` | Recommended | The agent to use: `ask`, `edit`, `agent`, or a custom agent name. Defaults to current agent |
|
||||
| `model` | Optional | The language model to use. Defaults to currently selected model |
|
||||
| `tools` | Optional | List of tool/tool set names available for this prompt |
|
||||
| `argument-hint` | Optional | Hint text shown in chat input to guide user interaction |
|
||||
|
||||
### Guidelines
|
||||
|
||||
- Use consistent quoting (single quotes recommended) and keep one field per line for readability and version control clarity
|
||||
- If `tools` are specified and current agent is `ask` or `edit`, the default agent becomes `agent`
|
||||
- Preserve any additional metadata (`language`, `tags`, `visibility`, etc.) required by your organization
|
||||
|
||||
## File Naming and Placement
|
||||
- Use kebab-case filenames ending with `.prompt.md` and store them under `.github/prompts/` unless your workspace standard specifies another directory.
|
||||
- Provide a short filename that communicates the action (for example, `generate-readme.prompt.md` rather than `prompt1.prompt.md`).
|
||||
|
||||
## Body Structure
|
||||
- Start with an `#` level heading that matches the prompt intent so it surfaces well in Quick Pick search.
|
||||
- Organize content with predictable sections. Recommended baseline: `Mission` or `Primary Directive`, `Scope & Preconditions`, `Inputs`, `Workflow` (step-by-step), `Output Expectations`, and `Quality Assurance`.
|
||||
- Adjust section names to fit the domain, but retain the logical flow: why → context → inputs → actions → outputs → validation.
|
||||
- Reference related prompts or instruction files using relative links to aid discoverability.
|
||||
|
||||
## Input and Context Handling
|
||||
- Use `${input:variableName[:placeholder]}` for required values and explain when the user must supply them. Provide defaults or alternatives where possible.
|
||||
- Call out contextual variables such as `${selection}`, `${file}`, `${workspaceFolder}` only when they are essential, and describe how Copilot should interpret them.
|
||||
- Document how to proceed when mandatory context is missing (for example, “Request the file path and stop if it remains undefined”).
|
||||
|
||||
## Tool and Permission Guidance
|
||||
- Limit `tools` to the smallest set that enables the task. List them in the preferred execution order when the sequence matters.
|
||||
- If the prompt inherits tools from a chat mode, mention that relationship and state any critical tool behaviours or side effects.
|
||||
- Warn about destructive operations (file creation, edits, terminal commands) and include guard rails or confirmation steps in the workflow.
|
||||
|
||||
## Instruction Tone and Style
|
||||
- Write in direct, imperative sentences targeted at Copilot (for example, “Analyze”, “Generate”, “Summarize”).
|
||||
- Keep sentences short and unambiguous, following Google Developer Documentation translation best practices to support localization.
|
||||
- Avoid idioms, humor, or culturally specific references; favor neutral, inclusive language.
|
||||
|
||||
## Output Definition
|
||||
- Specify the format, structure, and location of expected results (for example, “Create an architecture decision record file using the template below, such as `docs/architecture-decisions/record-XXXX.md`).
|
||||
- Include success criteria and failure triggers so Copilot knows when to halt or retry.
|
||||
- Provide validation steps—manual checks, automated commands, or acceptance criteria lists—that reviewers can execute after running the prompt.
|
||||
|
||||
## Examples and Reusable Assets
|
||||
- Embed Good/Bad examples or scaffolds (Markdown templates, JSON stubs) that the prompt should produce or follow.
|
||||
- Maintain reference tables (capabilities, status codes, role descriptions) inline to keep the prompt self-contained. Update these tables when upstream resources change.
|
||||
- Link to authoritative documentation instead of duplicating lengthy guidance.
|
||||
|
||||
## Quality Assurance Checklist
|
||||
- [ ] Frontmatter fields are complete, accurate, and least-privilege.
|
||||
- [ ] Inputs include placeholders, default behaviours, and fallbacks.
|
||||
- [ ] Workflow covers preparation, execution, and post-processing without gaps.
|
||||
- [ ] Output expectations include formatting and storage details.
|
||||
- [ ] Validation steps are actionable (commands, diff checks, review prompts).
|
||||
- [ ] Security, compliance, and privacy policies referenced by the prompt are current.
|
||||
- [ ] Prompt executes successfully in VS Code (`Chat: Run Prompt`) using representative scenarios.
|
||||
|
||||
## Maintenance Guidance
|
||||
- Version-control prompts alongside the code they affect; update them when dependencies, tooling, or review processes change.
|
||||
- Review prompts periodically to ensure tool lists, model requirements, and linked documents remain valid.
|
||||
- Coordinate with other repositories: when a prompt proves broadly useful, extract common guidance into instruction files or shared prompt packs.
|
||||
|
||||
## Additional Resources
|
||||
- [Prompt Files Documentation](https://code.visualstudio.com/docs/copilot/customization/prompt-files#_prompt-file-format)
|
||||
- [Awesome Copilot Prompt Files](https://github.com/github/awesome-copilot/tree/main/prompts)
|
||||
- [Tool Configuration](https://code.visualstudio.com/docs/copilot/chat/chat-agent-mode#_agent-mode-tools)
|
||||
68
.github/instructions/runner-settings-ui.instructions.md
vendored
Normal file
68
.github/instructions/runner-settings-ui.instructions.md
vendored
Normal file
@@ -0,0 +1,68 @@
|
||||
---
|
||||
description: 'Guidelines for Runner and Settings UI components that communicate via named pipes and manage module lifecycle'
|
||||
applyTo: 'src/runner/**,src/settings-ui/**'
|
||||
---
|
||||
|
||||
# Runner & Settings UI – Core Components Guidance
|
||||
|
||||
Guidelines for modifying the Runner (tray/module loader) and Settings UI (configuration app). These components communicate via Windows Named Pipes using JSON messages.
|
||||
|
||||
## Runner (`src/runner/`)
|
||||
|
||||
### Scope
|
||||
|
||||
- Module bootstrap, hotkey management, settings bridge, update/elevation handling
|
||||
|
||||
### Guidelines
|
||||
|
||||
- If IPC/JSON contracts change, mirror updates in `src/settings-ui/**`
|
||||
- Keep module discovery in `src/runner/main.cpp` in sync when adding/removing modules
|
||||
- Keep startup lean: avoid blocking/network calls in early init path
|
||||
- Preserve GPO & elevation behaviors; confirm no regression in policy handling
|
||||
- Ask before modifying update workflow or elevation logic
|
||||
|
||||
### Acceptance Criteria
|
||||
|
||||
- Stable startup, consistent contracts, no unnecessary logging noise
|
||||
|
||||
## Settings UI (`src/settings-ui/`)
|
||||
|
||||
### Scope
|
||||
|
||||
- WinUI/WPF UI, communicates with Runner over named pipes; manages persisted settings schema
|
||||
|
||||
### Guidelines
|
||||
|
||||
- Don't break settings schema silently; add migration when shape changes
|
||||
- If IPC/JSON contracts change, align with `src/runner/**` implementation
|
||||
- Keep UI responsive: marshal to UI thread for UI-bound operations
|
||||
- Reuse existing styles/resources; avoid duplicate theme keys
|
||||
- Add/adjust migration or serialization tests when changing persisted settings
|
||||
|
||||
### Acceptance Criteria
|
||||
|
||||
- Schema integrity preserved, responsive UI, consistent contracts, no style duplication
|
||||
|
||||
## Shared Concerns
|
||||
|
||||
### IPC Contract Changes
|
||||
|
||||
When modifying the JSON message format between Runner and Settings UI:
|
||||
|
||||
1. Update both `src/runner/` and `src/settings-ui/` in the same PR
|
||||
2. Preserve backward compatibility where possible
|
||||
3. Add migration logic for settings schema changes
|
||||
4. Test both directions of communication
|
||||
|
||||
### Code Style
|
||||
|
||||
- **C++ (Runner)**: Follow `.clang-format` in `src/`
|
||||
- **C# (Settings UI)**: Follow `src/.editorconfig`, use StyleCop.Analyzers
|
||||
- **XAML**: Use XamlStyler or run `.\.pipelines\applyXamlStyling.ps1 -Main`
|
||||
|
||||
## Validation
|
||||
|
||||
- Build Runner: `tools\build\build.cmd` from `src/runner/`
|
||||
- Build Settings UI: `tools\build\build.cmd` from `src/settings-ui/`
|
||||
- Test IPC: Launch both Runner and Settings UI, verify communication works
|
||||
- Schema changes: Run serialization tests if settings shape changed
|
||||
57
.github/prompts/create-commit-title.prompt.md
vendored
57
.github/prompts/create-commit-title.prompt.md
vendored
@@ -1,15 +1,50 @@
|
||||
---
|
||||
agent: 'agent'
|
||||
model: GPT-5.1-Codex-Max
|
||||
description: 'Generate an 80-character git commit title for the local diff.'
|
||||
model: 'GPT-5.1-Codex-Max'
|
||||
description: 'Generate an 80-character git commit title for the local diff'
|
||||
---
|
||||
|
||||
**Goal:** Provide a ready-to-paste git commit title (<= 80 characters) that captures the most important local changes since `HEAD`.
|
||||
# Generate Commit Title
|
||||
|
||||
**Workflow:**
|
||||
1. Run a single command to view the local diff since the last commit:
|
||||
```@terminal
|
||||
git diff HEAD
|
||||
```
|
||||
2. From that diff, identify the dominant area (reference key paths like `src/modules/*`, `doc/devdocs/**`, etc.), the type of change (bug fix, docs update, config tweak), and any notable impact.
|
||||
3. Draft a concise, imperative commit title summarizing the dominant change. Keep it plain ASCII, <= 80 characters, and avoid trailing punctuation. Mention the primary component when obvious (for example `FancyZones:` or `Docs:`).
|
||||
4. Respond with only the final commit title on a single line so it can be pasted directly into `git commit`.
|
||||
## Purpose
|
||||
Provide a single-line, ready-to-paste git commit title (<= 80 characters) that reflects the most important local changes since `HEAD`.
|
||||
|
||||
## Input to collect
|
||||
- Run exactly one command to view the local diff:
|
||||
```@terminal
|
||||
git diff HEAD
|
||||
```
|
||||
|
||||
## How to decide the title
|
||||
1. From the diff, find the dominant area (e.g., `src/modules/*`, `doc/devdocs/**`) and the change type (bug fix, docs update, config tweak).
|
||||
2. Draft an imperative, plain-ASCII title that:
|
||||
- Mentions the primary component when obvious (e.g., `FancyZones:` or `Docs:`)
|
||||
- Stays within 80 characters and has no trailing punctuation
|
||||
|
||||
## Final output
|
||||
- Reply with only the commit title on a single line—no extra text.
|
||||
|
||||
## PR title convention (when asked)
|
||||
Use Conventional Commits style:
|
||||
|
||||
`<type>(<scope>): <summary>`
|
||||
|
||||
**Allowed types**
|
||||
- feat, fix, docs, refactor, perf, test, build, ci, chore
|
||||
|
||||
**Scope rules**
|
||||
- Use a short, PowerToys-focused scope (one word preferred). Common scopes:
|
||||
- Core: `runner`, `settings-ui`, `common`, `docs`, `build`, `ci`, `installer`, `gpo`, `dsc`
|
||||
- Modules: `fancyzones`, `powerrename`, `awake`, `colorpicker`, `imageresizer`, `keyboardmanager`, `mouseutils`, `peek`, `hosts`, `file-locksmith`, `screen-ruler`, `text-extractor`, `cropandlock`, `paste`, `powerlauncher`
|
||||
- If unclear, pick the closest module or subsystem; omit only if unavoidable
|
||||
|
||||
**Summary rules**
|
||||
- Imperative, present tense (“add”, “update”, “remove”, “fix”)
|
||||
- Keep it <= 72 characters when possible; be specific, avoid “misc changes”
|
||||
|
||||
**Examples**
|
||||
- `feat(fancyzones): add canvas template duplication`
|
||||
- `fix(mouseutils): guard crosshair toggle when dpi info missing`
|
||||
- `docs(runner): document tray icon states`
|
||||
- `build(installer): align wix v5 suffix flag`
|
||||
- `ci(ci): cache pipeline artifacts for x64`
|
||||
|
||||
10
.github/prompts/create-pr-summary.prompt.md
vendored
10
.github/prompts/create-pr-summary.prompt.md
vendored
@@ -1,7 +1,10 @@
|
||||
agent: 'agent'
|
||||
model: GPT-5.1-Codex-Max
|
||||
description: 'Generate a PowerToys-ready pull request description from the local diff.'
|
||||
---
|
||||
agent: 'agent'
|
||||
model: 'GPT-5.1-Codex-Max'
|
||||
description: 'Generate a PowerToys-ready pull request description from the local diff'
|
||||
---
|
||||
|
||||
# Generate PR Summary
|
||||
|
||||
**Goal:** Produce a ready-to-paste PR title and description that follows PowerToys conventions by comparing the current branch against a user-selected target branch.
|
||||
|
||||
@@ -19,3 +22,4 @@ description: 'Generate a PowerToys-ready pull request description from the local
|
||||
5. Confirm validation: list tests executed with results or state why tests were skipped in line with repo guidance.
|
||||
6. Load `.github/pull_request_template.md`, mirror its section order, and populate it with the gathered facts. Include only relevant checklist entries, marking them `[x]/[ ]` and noting any intentional omissions as "N/A".
|
||||
7. Present the filled template inside a fenced ```markdown code block with no extra commentary so it is ready to paste into a PR, clearly flagging any placeholders that still need user input.
|
||||
8. Prepend the PR title above the filled template, applying the Conventional Commit type/scope rules from `.github/prompts/create-commit-title.prompt.md`; pick the dominant component from the diff and keep the title concise and imperative.
|
||||
|
||||
9
.github/prompts/fix-issue.prompt.md
vendored
9
.github/prompts/fix-issue.prompt.md
vendored
@@ -1,9 +1,12 @@
|
||||
---
|
||||
agent: 'agent'
|
||||
model: GPT-5.1-Codex-Max
|
||||
description: "Execute the fix for a GitHub issue using the previously generated implementation plan. Apply code & tests directly in the repo. Output only a PR description (and optional manual steps)."
|
||||
model: 'GPT-5.1-Codex-Max'
|
||||
description: 'Execute the fix for a GitHub issue using the previously generated implementation plan'
|
||||
---
|
||||
|
||||
# DEPENDENCY
|
||||
# Fix GitHub Issue
|
||||
|
||||
## Dependencies
|
||||
Source review prompt (for generating the implementation plan if missing):
|
||||
- .github/prompts/review-issue.prompt.md
|
||||
|
||||
|
||||
18
.github/prompts/fix-spelling.prompt.md
vendored
18
.github/prompts/fix-spelling.prompt.md
vendored
@@ -1,14 +1,17 @@
|
||||
agent: 'agent'
|
||||
model: GPT-5.1-Codex-Max
|
||||
description: 'Resolve Code scanning / check-spelling comments on the active PR.'
|
||||
---
|
||||
agent: 'agent'
|
||||
model: 'GPT-5.1-Codex-Max'
|
||||
description: 'Resolve Code scanning / check-spelling comments on the active PR'
|
||||
---
|
||||
|
||||
# Fix Spelling Comments
|
||||
|
||||
**Goal:** Clear every outstanding GitHub pull request comment created by the `Code scanning / check-spelling` workflow by explicitly allowing intentional terms.
|
||||
|
||||
**Guardrails:**
|
||||
- Update only discussion threads authored by `github-actions` or `github-actions[bot]` that mention `Code scanning results / check-spelling`.
|
||||
- Resolve findings solely by editing `.github/actions/spell-check/expect.txt`; reuse existing entries.
|
||||
- Leave all other files and topics untouched.
|
||||
- Prefer improving the wording in the originally flagged file when it clarifies intent without changing meaning; if the wording is already clear/standard for the context, handle it via `.github/actions/spell-check/expect.txt` and reuse existing entries.
|
||||
- Limit edits to the flagged text and `.github/actions/spell-check/expect.txt`; leave all other files and topics untouched.
|
||||
|
||||
**Prerequisites:**
|
||||
- Install GitHub CLI if it is not present: `winget install GitHub.cli`.
|
||||
@@ -17,5 +20,6 @@ description: 'Resolve Code scanning / check-spelling comments on the active PR.'
|
||||
**Workflow:**
|
||||
1. Determine the active pull request with a single `gh pr view --json number` call (default to the current branch).
|
||||
2. Fetch all PR discussion data once via `gh pr view --json comments,reviews` and filter to check-spelling comments authored by `github-actions` or `github-actions[bot]` that are not minimized; when several remain, process only the most recent comment body.
|
||||
3. For each flagged token, review `.github/actions/spell-check/expect.txt` for an equivalent term (for example an existing lowercase variant); when found, reuse that normalized term rather than adding a new entry, even if the flagged token differs only by casing. Only add a new entry after confirming no equivalent already exists.
|
||||
4. Add any remaining missing token to `.github/actions/spell-check/expect.txt`, keeping surrounding formatting intact.
|
||||
3. For each flagged token, first consider tightening or rephrasing the original text to avoid the false positive while keeping the meaning intact; if the existing wording is already normal and professional for the context, proceed to allowlisting instead of changing it.
|
||||
4. When allowlisting, review `.github/actions/spell-check/expect.txt` for an equivalent term (for example an existing lowercase variant); when found, reuse that normalized term rather than adding a new entry, even if the flagged token differs only by casing. Only add a new entry after confirming no equivalent already exists.
|
||||
5. Add any remaining missing token to `.github/actions/spell-check/expect.txt`, keeping surrounding formatting intact.
|
||||
9
.github/prompts/review-issue.prompt.md
vendored
9
.github/prompts/review-issue.prompt.md
vendored
@@ -1,9 +1,12 @@
|
||||
---
|
||||
agent: 'agent'
|
||||
model: GPT-5.1-Codex-Max
|
||||
description: "You are a GitHub issue review and planning expert; score (0-100) and write one implementation plan. Outputs: overview.md, implementation-plan.md."
|
||||
model: 'GPT-5.1-Codex-Max'
|
||||
description: 'Review a GitHub issue, score it (0-100), and generate an implementation plan'
|
||||
---
|
||||
|
||||
# GOAL
|
||||
# Review GitHub Issue
|
||||
|
||||
## Goal
|
||||
For **#{{issue_number}}** produce:
|
||||
1) `Generated Files/issueReview/{{issue_number}}/overview.md`
|
||||
2) `Generated Files/issueReview/{{issue_number}}/implementation-plan.md`
|
||||
|
||||
7
.github/prompts/review-pr.prompt.md
vendored
7
.github/prompts/review-pr.prompt.md
vendored
@@ -1,9 +1,10 @@
|
||||
---
|
||||
agent: 'agent'
|
||||
model: GPT-5.1-Codex-Max
|
||||
description: "gh-driven PR review; per-step Markdown + machine-readable outputs"
|
||||
model: 'GPT-5.1-Codex-Max'
|
||||
description: 'Perform a comprehensive PR review with per-step Markdown and machine-readable outputs'
|
||||
---
|
||||
|
||||
# PR Review — gh + stepwise
|
||||
# Review Pull Request
|
||||
|
||||
**Goal**: Given `{{pr_number}}`, run a *one-topic-per-step* review. Write files to `Generated Files/prReview/{{pr_number}}/` (replace `{{pr_number}}` with the integer). Emit machine‑readable blocks for a GitHub MCP to post review comments.
|
||||
|
||||
|
||||
@@ -125,6 +125,10 @@
|
||||
"WinUI3Apps\\Powertoys.Peek.UI.exe",
|
||||
"WinUI3Apps\\Powertoys.Peek.dll",
|
||||
|
||||
"WinUI3Apps\\PowerToys.QuickAccess.dll",
|
||||
"WinUI3Apps\\PowerToys.QuickAccess.exe",
|
||||
"WinUI3Apps\\PowerToys.Settings.UI.Controls.dll",
|
||||
|
||||
"WinUI3Apps\\PowerToys.EnvironmentVariablesModuleInterface.dll",
|
||||
"WinUI3Apps\\PowerToys.EnvironmentVariablesUILib.dll",
|
||||
"WinUI3Apps\\PowerToys.EnvironmentVariables.dll",
|
||||
|
||||
17
.vscode/settings.json
vendored
Normal file
17
.vscode/settings.json
vendored
Normal file
@@ -0,0 +1,17 @@
|
||||
{
|
||||
"github.copilot.chat.reviewSelection.instructions": [
|
||||
{
|
||||
"file": ".github/prompts/review-pr.prompt.md"
|
||||
}
|
||||
],
|
||||
"github.copilot.chat.commitMessageGeneration.instructions": [
|
||||
{
|
||||
"file": ".github/prompts/create-commit-title.prompt.md"
|
||||
}
|
||||
],
|
||||
"github.copilot.chat.pullRequestDescriptionGeneration.instructions": [
|
||||
{
|
||||
"file": ".github/prompts/create-pr-summary.prompt.md"
|
||||
}
|
||||
]
|
||||
}
|
||||
165
AGENTS.md
Normal file
165
AGENTS.md
Normal file
@@ -0,0 +1,165 @@
|
||||
---
|
||||
description: 'Top-level AI contributor guidance for developing PowerToys - a collection of Windows productivity utilities'
|
||||
applyTo: '**'
|
||||
---
|
||||
|
||||
# PowerToys – AI Contributor Guide
|
||||
|
||||
This is the top-level guidance for AI contributions to PowerToys. Keep changes atomic, follow existing patterns, and cite exact paths in PRs.
|
||||
|
||||
## Overview
|
||||
|
||||
PowerToys is a set of utilities for power users to tune and streamline their Windows experience.
|
||||
|
||||
| Area | Location | Description |
|
||||
|------|----------|-------------|
|
||||
| Runner | `src/runner/` | Main executable, tray icon, module loader, hotkey management |
|
||||
| Settings UI | `src/settings-ui/` | WinUI/WPF configuration app communicating via named pipes |
|
||||
| Modules | `src/modules/` | Individual PowerToys utilities (each in its own subfolder) |
|
||||
| Common Libraries | `src/common/` | Shared code: logging, IPC, settings, DPI, telemetry, utilities |
|
||||
| Build Tools | `tools/build/` | Build scripts and automation |
|
||||
| Documentation | `doc/devdocs/` | Developer documentation |
|
||||
| Installer | `installer/` | WiX-based installer projects |
|
||||
|
||||
For architecture details and module types, see [Architecture Overview](doc/devdocs/core/architecture.md).
|
||||
|
||||
## Conventions
|
||||
|
||||
For detailed coding conventions, see:
|
||||
- [Coding Guidelines](doc/devdocs/development/guidelines.md) – Dependencies, testing, PR management
|
||||
- [Coding Style](doc/devdocs/development/style.md) – Formatting, C++/C#/XAML style rules
|
||||
- [Logging](doc/devdocs/development/logging.md) – C++ spdlog and C# Logger usage
|
||||
|
||||
### Component-Specific Instructions
|
||||
|
||||
These instruction files are automatically applied when working in their respective areas:
|
||||
- [Runner & Settings UI](.github/instructions/runner-settings-ui.instructions.md) – IPC contracts, schema migrations
|
||||
- [Common Libraries](.github/instructions/common-libraries.instructions.md) – ABI stability, shared code guidelines
|
||||
|
||||
## Build
|
||||
|
||||
### Prerequisites
|
||||
|
||||
- Visual Studio 2022 17.4+
|
||||
- Windows 10 1803+ (April 2018 Update or newer)
|
||||
- Initialize submodules once: `git submodule update --init --recursive`
|
||||
|
||||
### Build Commands
|
||||
|
||||
| Task | Command |
|
||||
|------|---------|
|
||||
| First build / NuGet restore | `tools\build\build-essentials.cmd` |
|
||||
| Build current folder | `tools\build\build.cmd` |
|
||||
| Build with options | `build.ps1 -Platform x64 -Configuration Release` |
|
||||
|
||||
### Build Discipline
|
||||
|
||||
1. One terminal per operation (build → test). Do not switch or open new ones mid-flow
|
||||
2. After making changes, `cd` to the project folder that changed (`.csproj`/`.vcxproj`)
|
||||
3. Use scripts to build: `tools/build/build.ps1` or `tools/build/build.cmd`
|
||||
4. For first build or missing NuGet packages, run `build-essentials.cmd` first
|
||||
5. **Exit code 0 = success; non-zero = failure** – treat this as absolute
|
||||
6. On failure, read the errors log: `build.<config>.<platform>.errors.log`
|
||||
7. Do not start tests or launch Runner until the build succeeds
|
||||
|
||||
### Build Logs
|
||||
|
||||
Located next to the solution/project being built:
|
||||
- `build.<configuration>.<platform>.errors.log` – errors only (check this first)
|
||||
- `build.<configuration>.<platform>.all.log` – full log
|
||||
- `build.<configuration>.<platform>.trace.binlog` – for MSBuild Structured Log Viewer
|
||||
|
||||
For complete details, see [Build Guidelines](tools/build/BUILD-GUIDELINES.md).
|
||||
|
||||
## Tests
|
||||
|
||||
### Test Discovery
|
||||
|
||||
- Find test projects by product code prefix (e.g., `FancyZones`, `AdvancedPaste`)
|
||||
- Look for sibling folders or 1-2 levels up named `<Product>*UnitTests` or `<Product>*UITests`
|
||||
|
||||
### Running Tests
|
||||
|
||||
1. **Build the test project first**, wait for exit code 0
|
||||
2. Run via VS Test Explorer (`Ctrl+E, T`) or `vstest.console.exe` with filters
|
||||
3. **Avoid `dotnet test`** in this repo – use VS Test Explorer or vstest.console.exe
|
||||
|
||||
### Test Types
|
||||
|
||||
| Type | Requirements | Setup |
|
||||
|------|--------------|-------|
|
||||
| Unit Tests | Standard dev environment | None |
|
||||
| UI Tests | WinAppDriver v1.2.1, Developer Mode | Install from [WinAppDriver releases](https://github.com/microsoft/WinAppDriver/releases/tag/v1.2.1) |
|
||||
| Fuzz Tests | OneFuzz, .NET 8 | See [Fuzzing Tests](doc/devdocs/tools/fuzzingtesting.md) |
|
||||
|
||||
### Test Discipline
|
||||
|
||||
1. Add or adjust tests when changing behavior
|
||||
2. If tests skipped, state why (e.g., comment-only change, string rename)
|
||||
3. New modules handling file I/O or user input **must** implement fuzzing tests
|
||||
|
||||
### Special Requirements
|
||||
|
||||
- **Mouse Without Borders**: Requires 2+ physical computers (not VMs)
|
||||
- **Multi-monitor utilities**: Test with 2+ monitors, different DPI settings
|
||||
|
||||
For UI test setup details, see [UI Tests](doc/devdocs/development/ui-tests.md).
|
||||
|
||||
## Boundaries
|
||||
|
||||
### Ask for Clarification When
|
||||
|
||||
- Ambiguous spec after scanning relevant docs
|
||||
- Cross-module impact (shared enum/struct) is unclear
|
||||
- Security, elevation, or installer changes involved
|
||||
- GPO or policy handling modifications needed
|
||||
|
||||
### Areas Requiring Extra Care
|
||||
|
||||
| Area | Concern | Reference |
|
||||
|------|---------|-----------|
|
||||
| `src/common/` | ABI breaks | [Common Libraries Instructions](.github/instructions/common-libraries.instructions.md) |
|
||||
| `src/runner/`, `src/settings-ui/` | IPC contracts, schema | [Runner & Settings UI Instructions](.github/instructions/runner-settings-ui.instructions.md) |
|
||||
| Installer files | Release impact | Careful review required |
|
||||
| Elevation/GPO logic | Security | Confirm no regression in policy handling |
|
||||
|
||||
### What NOT to Do
|
||||
|
||||
- Don't merge incomplete features into main (use feature branches)
|
||||
- Don't break IPC/JSON contracts without updating both runner and settings-ui
|
||||
- Don't add noisy logs in hot paths
|
||||
- Don't introduce third-party deps without PM approval and `NOTICE.md` update
|
||||
|
||||
## Validation Checklist
|
||||
|
||||
Before finishing, verify:
|
||||
|
||||
- [ ] Build clean with exit code 0
|
||||
- [ ] Tests updated and passing locally
|
||||
- [ ] No unintended ABI breaks or schema changes
|
||||
- [ ] IPC contracts consistent between runner and settings-ui
|
||||
- [ ] New dependencies added to `NOTICE.md`
|
||||
- [ ] PR is atomic (one logical change), with issue linked
|
||||
|
||||
## Documentation Index
|
||||
|
||||
### Core Architecture
|
||||
- [Architecture Overview](doc/devdocs/core/architecture.md)
|
||||
- [Runner](doc/devdocs/core/runner.md)
|
||||
- [Settings System](doc/devdocs/core/settings/readme.md)
|
||||
- [Module Interface](doc/devdocs/modules/interface.md)
|
||||
|
||||
### Development
|
||||
- [Coding Guidelines](doc/devdocs/development/guidelines.md)
|
||||
- [Coding Style](doc/devdocs/development/style.md)
|
||||
- [Logging](doc/devdocs/development/logging.md)
|
||||
- [UI Tests](doc/devdocs/development/ui-tests.md)
|
||||
- [Fuzzing Tests](doc/devdocs/tools/fuzzingtesting.md)
|
||||
|
||||
### Build & Tools
|
||||
- [Build Guidelines](tools/build/BUILD-GUIDELINES.md)
|
||||
- [Tools Overview](doc/devdocs/tools/readme.md)
|
||||
|
||||
### Instructions (Auto-Applied)
|
||||
- [Runner & Settings UI](.github/instructions/runner-settings-ui.instructions.md)
|
||||
- [Common Libraries](.github/instructions/common-libraries.instructions.md)
|
||||
@@ -39,7 +39,7 @@ namespace Microsoft.PowerToys.FilePreviewCommon
|
||||
var softlineBreak = new Markdig.Extensions.Hardlines.SoftlineBreakAsHardlineExtension();
|
||||
|
||||
MarkdownPipelineBuilder pipelineBuilder;
|
||||
pipelineBuilder = new MarkdownPipelineBuilder().UseAdvancedExtensions().UseEmojiAndSmiley().UseYamlFrontMatter().UseMathematics().DisableHtml();
|
||||
pipelineBuilder = new MarkdownPipelineBuilder().UseAdvancedExtensions().UseEmojiAndSmiley().UseYamlFrontMatter().UseMathematics();
|
||||
pipelineBuilder.Extensions.Add(extension);
|
||||
pipelineBuilder.Extensions.Add(softlineBreak);
|
||||
|
||||
|
||||
@@ -1,16 +0,0 @@
|
||||
---
|
||||
applyTo: "**/*.cs,**/*.cpp,**/*.c,**/*.h,**/*.hpp"
|
||||
---
|
||||
# Common – shared libraries guidance (concise)
|
||||
|
||||
Scope
|
||||
- Logging, IPC, settings, DPI, telemetry, utilities consumed by multiple modules.
|
||||
|
||||
Guidelines
|
||||
- Avoid breaking public headers/APIs; if changed, search & update all callers.
|
||||
- Coordinate ABI-impacting struct/class layout changes; keep binary compatibility.
|
||||
- Watch perf in hot paths (hooks, timers, serialization); avoid avoidable allocations.
|
||||
- Ask before adding third‑party deps or changing serialization formats.
|
||||
|
||||
Acceptance
|
||||
- No unintended ABI breaks, no noisy logs, new non-obvious symbols briefly commented.
|
||||
@@ -1,5 +1,48 @@
|
||||
#include "resource.h"
|
||||
#include <windows.h>
|
||||
#include "../../../common/version/version.h"
|
||||
|
||||
#define APSTUDIO_READONLY_SYMBOLS
|
||||
#include "winres.h"
|
||||
#undef APSTUDIO_READONLY_SYMBOLS
|
||||
|
||||
/////////////////////////////////////////////////////////////////////////////
|
||||
//
|
||||
// Version
|
||||
//
|
||||
|
||||
VS_VERSION_INFO VERSIONINFO
|
||||
FILEVERSION FILE_VERSION
|
||||
PRODUCTVERSION PRODUCT_VERSION
|
||||
FILEFLAGSMASK 0x3fL
|
||||
#ifdef _DEBUG
|
||||
FILEFLAGS 0x1L
|
||||
#else
|
||||
FILEFLAGS 0x0L
|
||||
#endif
|
||||
FILEOS 0x40004L
|
||||
FILETYPE 0x1L
|
||||
FILESUBTYPE 0x0L
|
||||
BEGIN
|
||||
BLOCK "StringFileInfo"
|
||||
BEGIN
|
||||
BLOCK "040904b0"
|
||||
BEGIN
|
||||
VALUE "CompanyName", COMPANY_NAME
|
||||
VALUE "FileDescription", "File Locksmith CLI"
|
||||
VALUE "FileVersion", FILE_VERSION_STRING
|
||||
VALUE "InternalName", "FileLocksmithCLI.exe"
|
||||
VALUE "LegalCopyright", COPYRIGHT_NOTE
|
||||
VALUE "OriginalFilename", "FileLocksmithCLI.exe"
|
||||
VALUE "ProductName", PRODUCT_NAME
|
||||
VALUE "ProductVersion", PRODUCT_VERSION_STRING
|
||||
END
|
||||
END
|
||||
BLOCK "VarFileInfo"
|
||||
BEGIN
|
||||
VALUE "Translation", 0x409, 1200
|
||||
END
|
||||
END
|
||||
|
||||
STRINGTABLE
|
||||
BEGIN
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
# Validating/Testing Cursor Wrap.
|
||||
|
||||
If a user determines that CursorWrap isn't working on their PC there are some steps you can take to determine why CursorWrap functionality might not be working as expected.
|
||||
|
||||
Note that for a single monitor cursor wrap should always work since all monitor edges are not touching/overlapping with other monitors - the cursor should always wrap to the opposite edge of the same monitor.
|
||||
|
||||
Multi-monitor is supported through building a polygon shape for the outer edges of all monitors, inner monitor edges are ignored, movement of the cursor from one monitor to an adjacent monitor is handled by Windows - CursorWrap doesn't get involved in monitor-to-monitor movement, only outer-edges.
|
||||
|
||||
We have seen a couple of computer setups that have multi-monitors where CursorWrap doesn't work as expected, this appears to be due to a monitor not being 'snapped' to the edge of an adjacent monitor - If you use Display Settings in Windows you can move monitors around, these appear to 'snap' to an edge of an existing monitor.
|
||||
|
||||
What to do if Cursor Wrapping isn't working as expected ?
|
||||
|
||||
1. in the CursorWrapTests folder there's a PowerShell script called `Capture-MonitorLayout.ps1` - this will generate a .json file in the form `"$($env:USERNAME)_monitor_layout.json` - the .json file contains an array of monitors, their position, size, dpi, and scaling.
|
||||
2. Use `CursorWrapTests/monitor_layout_tests.py` to validate the monitor layout/wrapping behavior (uses the json file from point 1 above).
|
||||
3. Use `analyze_test_results.py` to analyze the monitor layout test output and provide information about why wrapping might not be working
|
||||
|
||||
To run `monitor_layout_tests.py` you will need Python installed on your PC.
|
||||
|
||||
Run `python monitor_layout_tests.py --layout-file <path to json file>` you can also add an optional `--verbose` to view verbose output.
|
||||
|
||||
monitor_layout_tests.py will produce an output file called `test_report.json` - the contents of the file will look like this (this is from a single monitor test).
|
||||
|
||||
```json
|
||||
{
|
||||
"summary": {
|
||||
"total_configs": 1,
|
||||
"passed": 1,
|
||||
"failed": 0,
|
||||
"total_issues": 0,
|
||||
"pass_rate": "100.00%"
|
||||
},
|
||||
"failures": [],
|
||||
"recommendations": [
|
||||
"All tests passed - edge detection logic is working correctly!"
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
If there are failures (the failures array is not empty) you can run the second python application called `analyze_test_results.py`
|
||||
|
||||
Supported options include:
|
||||
```text
|
||||
-h, --help show this help message and exit
|
||||
--report REPORT Path to test report JSON file
|
||||
--detailed Show detailed failure listing
|
||||
--copilot Generate GitHub Copilot-friendly fix prompt
|
||||
```
|
||||
|
||||
Running the analyze_test_results.py script against our single monitor test results produces the following:
|
||||
|
||||
```text
|
||||
python .\analyze_test_results.py --detailed
|
||||
================================================================================
|
||||
CURSORWRAP TEST RESULTS ANALYSIS
|
||||
================================================================================
|
||||
|
||||
Total Configurations Tested: 1
|
||||
Passed: 1 (100.00%)
|
||||
Failed: 0
|
||||
Total Issues: 0
|
||||
|
||||
✓ ALL TESTS PASSED! Edge detection logic is working correctly.
|
||||
|
||||
✓ No failures to analyze!
|
||||
```
|
||||
|
||||
@@ -0,0 +1,266 @@
|
||||
#!/usr/bin/env pwsh
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Captures the current monitor layout configuration for CursorWrap testing.
|
||||
|
||||
.DESCRIPTION
|
||||
Queries Windows for all connected monitors and saves their configuration
|
||||
(position, size, DPI, primary status) to a JSON file that can be used
|
||||
for testing the CursorWrap module.
|
||||
|
||||
.PARAMETER OutputPath
|
||||
Path where the JSON file will be saved. Default: monitor_layout.json
|
||||
|
||||
.EXAMPLE
|
||||
.\Capture-MonitorLayout.ps1
|
||||
|
||||
.EXAMPLE
|
||||
.\Capture-MonitorLayout.ps1 -OutputPath "my_setup.json"
|
||||
#>
|
||||
|
||||
param(
|
||||
[Parameter(Mandatory=$false)]
|
||||
[string]$OutputPath = "$($env:USERNAME)_monitor_layout.json"
|
||||
)
|
||||
|
||||
# Add Windows Forms for screen enumeration
|
||||
Add-Type -AssemblyName System.Windows.Forms
|
||||
|
||||
function Get-MonitorDPI {
|
||||
param([System.Windows.Forms.Screen]$Screen)
|
||||
|
||||
# Try to get DPI using P/Invoke with multiple methods
|
||||
Add-Type @"
|
||||
using System;
|
||||
using System.Runtime.InteropServices;
|
||||
public class DisplayConfig {
|
||||
[DllImport("user32.dll")]
|
||||
public static extern IntPtr MonitorFromPoint(POINT pt, uint dwFlags);
|
||||
|
||||
[DllImport("shcore.dll")]
|
||||
public static extern int GetDpiForMonitor(IntPtr hmonitor, int dpiType, out uint dpiX, out uint dpiY);
|
||||
|
||||
[DllImport("user32.dll")]
|
||||
public static extern bool GetMonitorInfo(IntPtr hMonitor, ref MONITORINFOEX lpmi);
|
||||
|
||||
[DllImport("user32.dll")]
|
||||
public static extern IntPtr MonitorFromWindow(IntPtr hwnd, uint dwFlags);
|
||||
|
||||
[DllImport("gdi32.dll")]
|
||||
public static extern int GetDeviceCaps(IntPtr hdc, int nIndex);
|
||||
|
||||
[DllImport("user32.dll")]
|
||||
public static extern IntPtr GetDC(IntPtr hWnd);
|
||||
|
||||
[DllImport("user32.dll")]
|
||||
public static extern int ReleaseDC(IntPtr hWnd, IntPtr hDC);
|
||||
|
||||
[StructLayout(LayoutKind.Sequential)]
|
||||
public struct POINT {
|
||||
public int X;
|
||||
public int Y;
|
||||
}
|
||||
|
||||
[StructLayout(LayoutKind.Sequential, CharSet = CharSet.Auto)]
|
||||
public struct MONITORINFOEX {
|
||||
public int cbSize;
|
||||
public RECT rcMonitor;
|
||||
public RECT rcWork;
|
||||
public uint dwFlags;
|
||||
[MarshalAs(UnmanagedType.ByValTStr, SizeConst = 32)]
|
||||
public string szDevice;
|
||||
}
|
||||
|
||||
[StructLayout(LayoutKind.Sequential)]
|
||||
public struct RECT {
|
||||
public int Left;
|
||||
public int Top;
|
||||
public int Right;
|
||||
public int Bottom;
|
||||
}
|
||||
|
||||
public const uint MONITOR_DEFAULTTOPRIMARY = 1;
|
||||
public const int MDT_EFFECTIVE_DPI = 0;
|
||||
public const int MDT_ANGULAR_DPI = 1;
|
||||
public const int MDT_RAW_DPI = 2;
|
||||
public const int LOGPIXELSX = 88;
|
||||
public const int LOGPIXELSY = 90;
|
||||
}
|
||||
"@ -ErrorAction SilentlyContinue
|
||||
|
||||
try {
|
||||
$point = New-Object DisplayConfig+POINT
|
||||
$point.X = $Screen.Bounds.Left + ($Screen.Bounds.Width / 2)
|
||||
$point.Y = $Screen.Bounds.Top + ($Screen.Bounds.Height / 2)
|
||||
|
||||
$hMonitor = [DisplayConfig]::MonitorFromPoint($point, 1)
|
||||
|
||||
# Method 1: Try GetDpiForMonitor (Windows 8.1+)
|
||||
[uint]$dpiX = 0
|
||||
[uint]$dpiY = 0
|
||||
$result = [DisplayConfig]::GetDpiForMonitor($hMonitor, 0, [ref]$dpiX, [ref]$dpiY)
|
||||
|
||||
if ($result -eq 0 -and $dpiX -gt 0) {
|
||||
Write-Verbose "DPI detected via GetDpiForMonitor: $dpiX"
|
||||
return $dpiX
|
||||
}
|
||||
|
||||
# Method 2: Try RAW DPI
|
||||
$result = [DisplayConfig]::GetDpiForMonitor($hMonitor, 2, [ref]$dpiX, [ref]$dpiY)
|
||||
if ($result -eq 0 -and $dpiX -gt 0) {
|
||||
Write-Verbose "DPI detected via RAW DPI: $dpiX"
|
||||
return $dpiX
|
||||
}
|
||||
|
||||
# Method 3: Try getting device context DPI (legacy method)
|
||||
$hdc = [DisplayConfig]::GetDC([IntPtr]::Zero)
|
||||
if ($hdc -ne [IntPtr]::Zero) {
|
||||
$dpiValue = [DisplayConfig]::GetDeviceCaps($hdc, 88) # LOGPIXELSX
|
||||
[DisplayConfig]::ReleaseDC([IntPtr]::Zero, $hdc)
|
||||
if ($dpiValue -gt 0) {
|
||||
Write-Verbose "DPI detected via GetDeviceCaps: $dpiValue"
|
||||
return $dpiValue
|
||||
}
|
||||
}
|
||||
}
|
||||
catch {
|
||||
Write-Verbose "DPI detection error: $($_.Exception.Message)"
|
||||
}
|
||||
|
||||
Write-Warning "Could not detect DPI for $($Screen.DeviceName), using default 96 DPI"
|
||||
return 96 # Standard 96 DPI (100% scaling)
|
||||
}
|
||||
|
||||
function Capture-MonitorLayout {
|
||||
Write-Host "Capturing monitor layout..." -ForegroundColor Cyan
|
||||
Write-Host "=" * 80
|
||||
|
||||
$screens = [System.Windows.Forms.Screen]::AllScreens
|
||||
$monitors = @()
|
||||
|
||||
foreach ($screen in $screens) {
|
||||
$isPrimary = $screen.Primary
|
||||
$bounds = $screen.Bounds
|
||||
$dpi = Get-MonitorDPI -Screen $screen
|
||||
|
||||
$monitor = [ordered]@{
|
||||
left = $bounds.Left
|
||||
top = $bounds.Top
|
||||
right = $bounds.Right
|
||||
bottom = $bounds.Bottom
|
||||
width = $bounds.Width
|
||||
height = $bounds.Height
|
||||
dpi = $dpi
|
||||
scaling_percent = [math]::Round(($dpi / 96.0) * 100, 0)
|
||||
primary = $isPrimary
|
||||
device_name = $screen.DeviceName
|
||||
}
|
||||
|
||||
$monitors += $monitor
|
||||
|
||||
# Display info
|
||||
$primaryTag = if ($isPrimary) { " [PRIMARY]" } else { "" }
|
||||
$scaling = [math]::Round(($dpi / 96.0) * 100, 0)
|
||||
|
||||
Write-Host "`nMonitor $($monitors.Count)$primaryTag" -ForegroundColor Green
|
||||
Write-Host " Device: $($screen.DeviceName)"
|
||||
Write-Host " Position: ($($bounds.Left), $($bounds.Top))"
|
||||
Write-Host " Size: $($bounds.Width)x$($bounds.Height)"
|
||||
Write-Host " DPI: $dpi ($scaling% scaling)"
|
||||
Write-Host " Bounds: [$($bounds.Left), $($bounds.Top), $($bounds.Right), $($bounds.Bottom)]"
|
||||
}
|
||||
|
||||
# Create output object
|
||||
$output = [ordered]@{
|
||||
captured_at = (Get-Date -Format "yyyy-MM-ddTHH:mm:sszzz")
|
||||
computer_name = $env:COMPUTERNAME
|
||||
user_name = $env:USERNAME
|
||||
monitor_count = $monitors.Count
|
||||
monitors = $monitors
|
||||
}
|
||||
|
||||
# Save to JSON
|
||||
$json = $output | ConvertTo-Json -Depth 10
|
||||
Set-Content -Path $OutputPath -Value $json -Encoding UTF8
|
||||
|
||||
Write-Host "`n" + ("=" * 80)
|
||||
Write-Host "Monitor layout saved to: $OutputPath" -ForegroundColor Green
|
||||
Write-Host "Total monitors captured: $($monitors.Count)" -ForegroundColor Cyan
|
||||
Write-Host "`nYou can now use this file with the test script:" -ForegroundColor Yellow
|
||||
Write-Host " python monitor_layout_tests.py --layout-file $OutputPath" -ForegroundColor White
|
||||
|
||||
return $output
|
||||
}
|
||||
|
||||
# Main execution
|
||||
try {
|
||||
$layout = Capture-MonitorLayout
|
||||
|
||||
# Display summary
|
||||
Write-Host "`n" + ("=" * 80)
|
||||
Write-Host "SUMMARY" -ForegroundColor Cyan
|
||||
Write-Host ("=" * 80)
|
||||
Write-Host "Configuration Name: $($layout.computer_name)"
|
||||
Write-Host "Captured: $($layout.captured_at)"
|
||||
Write-Host "Monitors: $($layout.monitor_count)"
|
||||
|
||||
# Calculate desktop dimensions
|
||||
$widths = @($layout.monitors | ForEach-Object { $_.width })
|
||||
$heights = @($layout.monitors | ForEach-Object { $_.height })
|
||||
|
||||
$totalWidth = ($widths | Measure-Object -Sum).Sum
|
||||
$maxHeight = ($heights | Measure-Object -Maximum).Maximum
|
||||
|
||||
Write-Host "Total Desktop Width: $totalWidth pixels"
|
||||
Write-Host "Max Desktop Height: $maxHeight pixels"
|
||||
|
||||
# Analyze potential coordinate issues
|
||||
Write-Host "`n" + ("=" * 80)
|
||||
Write-Host "COORDINATE ANALYSIS" -ForegroundColor Cyan
|
||||
Write-Host ("=" * 80)
|
||||
|
||||
# Check for gaps between monitors
|
||||
if ($layout.monitor_count -gt 1) {
|
||||
$hasGaps = $false
|
||||
for ($i = 0; $i -lt $layout.monitor_count - 1; $i++) {
|
||||
$m1 = $layout.monitors[$i]
|
||||
for ($j = $i + 1; $j -lt $layout.monitor_count; $j++) {
|
||||
$m2 = $layout.monitors[$j]
|
||||
|
||||
# Check horizontal gap
|
||||
$hGap = [Math]::Min([Math]::Abs($m1.right - $m2.left), [Math]::Abs($m2.right - $m1.left))
|
||||
# Check vertical overlap
|
||||
$vOverlapStart = [Math]::Max($m1.top, $m2.top)
|
||||
$vOverlapEnd = [Math]::Min($m1.bottom, $m2.bottom)
|
||||
$vOverlap = $vOverlapEnd - $vOverlapStart
|
||||
|
||||
if ($hGap -gt 50 -and $vOverlap -gt 0) {
|
||||
Write-Host "⚠ Gap detected between Monitor $($i+1) and Monitor $($j+1): ${hGap}px horizontal gap" -ForegroundColor Yellow
|
||||
Write-Host " Vertical overlap: ${vOverlap}px" -ForegroundColor Yellow
|
||||
Write-Host " This may indicate a Windows coordinate bug if monitors appear snapped in Display Settings" -ForegroundColor Yellow
|
||||
$hasGaps = $true
|
||||
}
|
||||
}
|
||||
}
|
||||
if (-not $hasGaps) {
|
||||
Write-Host "✓ No unexpected gaps detected" -ForegroundColor Green
|
||||
}
|
||||
}
|
||||
|
||||
# DPI/Scaling notes
|
||||
Write-Host "`nDPI/Scaling Impact on Coordinates:" -ForegroundColor Cyan
|
||||
Write-Host "• Coordinate values (left, top, right, bottom) are in LOGICAL PIXELS"
|
||||
Write-Host "• These are DPI-independent virtual coordinates"
|
||||
Write-Host "• Physical pixels = Logical pixels × (DPI / 96)"
|
||||
Write-Host "• Example: 1920 logical pixels at 150% scaling = 1920 × 1.5 = 2880 physical pixels"
|
||||
Write-Host "• Windows snaps monitors using logical pixel coordinates"
|
||||
Write-Host "• If monitors appear snapped but coordinates show gaps, this is a Windows bug"
|
||||
|
||||
exit 0
|
||||
}
|
||||
catch {
|
||||
Write-Host "`nError capturing monitor layout:" -ForegroundColor Red
|
||||
Write-Host $_.Exception.Message -ForegroundColor Red
|
||||
Write-Host $_.ScriptStackTrace -ForegroundColor DarkGray
|
||||
exit 1
|
||||
}
|
||||
@@ -0,0 +1,430 @@
|
||||
"""
|
||||
Test Results Analyzer for CursorWrap Monitor Layout Tests
|
||||
|
||||
Analyzes test_report.json and provides detailed explanations of failures,
|
||||
patterns, and recommendations.
|
||||
"""
|
||||
|
||||
import json
|
||||
import sys
|
||||
from collections import defaultdict
|
||||
from typing import Dict, List, Any
|
||||
|
||||
|
||||
class TestResultAnalyzer:
|
||||
"""Analyzes test results and provides insights"""
|
||||
|
||||
def __init__(self, report_path: str = "test_report.json"):
|
||||
with open(report_path, 'r') as f:
|
||||
self.report = json.load(f)
|
||||
|
||||
self.failures = self.report.get('failures', [])
|
||||
self.summary = self.report.get('summary', {})
|
||||
self.recommendations = self.report.get('recommendations', [])
|
||||
|
||||
def print_overview(self):
|
||||
"""Print test overview"""
|
||||
print("=" * 80)
|
||||
print("CURSORWRAP TEST RESULTS ANALYSIS")
|
||||
print("=" * 80)
|
||||
print(f"\nTotal Configurations Tested: {self.summary.get('total_configs', 0)}")
|
||||
print(f"Passed: {self.summary.get('passed', 0)} ({self.summary.get('pass_rate', 'N/A')})")
|
||||
print(f"Failed: {self.summary.get('failed', 0)}")
|
||||
print(f"Total Issues: {self.summary.get('total_issues', 0)}")
|
||||
|
||||
if self.summary.get('passed', 0) == self.summary.get('total_configs', 0):
|
||||
print("\n✓ ALL TESTS PASSED! Edge detection logic is working correctly.")
|
||||
return
|
||||
|
||||
print(f"\n⚠ {self.summary.get('total_issues', 0)} issues detected\n")
|
||||
|
||||
def analyze_failure_patterns(self):
|
||||
"""Analyze and categorize failure patterns"""
|
||||
print("=" * 80)
|
||||
print("FAILURE PATTERN ANALYSIS")
|
||||
print("=" * 80)
|
||||
|
||||
# Group by test type
|
||||
by_test_type = defaultdict(list)
|
||||
for failure in self.failures:
|
||||
by_test_type[failure['test_name']].append(failure)
|
||||
|
||||
# Group by configuration
|
||||
by_config = defaultdict(list)
|
||||
for failure in self.failures:
|
||||
by_config[failure['monitor_config']].append(failure)
|
||||
|
||||
print(f"\n1. Failures by Test Type:")
|
||||
for test_type, failures in sorted(by_test_type.items(), key=lambda x: len(x[1]), reverse=True):
|
||||
print(f" • {test_type}: {len(failures)} failures")
|
||||
|
||||
print(f"\n2. Configurations with Failures:")
|
||||
for config, failures in sorted(by_config.items(), key=lambda x: len(x[1]), reverse=True):
|
||||
print(f" • {config}")
|
||||
print(f" {len(failures)} issues")
|
||||
|
||||
return by_test_type, by_config
|
||||
|
||||
def analyze_wrap_calculation_failures(self, failures: List[Dict[str, Any]]):
|
||||
"""Detailed analysis of wrap calculation failures"""
|
||||
print("\n" + "=" * 80)
|
||||
print("WRAP CALCULATION FAILURE ANALYSIS")
|
||||
print("=" * 80)
|
||||
|
||||
# Analyze cursor positions
|
||||
positions = []
|
||||
configs = set()
|
||||
|
||||
for failure in failures:
|
||||
configs.add(failure['monitor_config'])
|
||||
# Extract position from expected message
|
||||
if 'test_point' in failure.get('details', {}):
|
||||
pos = failure['details']['test_point']
|
||||
positions.append(pos)
|
||||
|
||||
print(f"\nAffected Configurations: {len(configs)}")
|
||||
for config in sorted(configs):
|
||||
print(f" • {config}")
|
||||
|
||||
if positions:
|
||||
print(f"\nFailed Test Points: {len(positions)}")
|
||||
# Analyze if failures are at edges
|
||||
edge_positions = defaultdict(int)
|
||||
for x, y in positions:
|
||||
# Simplified edge detection
|
||||
if x <= 10:
|
||||
edge_positions['left edge'] += 1
|
||||
elif y <= 10:
|
||||
edge_positions['top edge'] += 1
|
||||
else:
|
||||
edge_positions['other'] += 1
|
||||
|
||||
if edge_positions:
|
||||
print("\nPosition Distribution:")
|
||||
for pos_type, count in edge_positions.items():
|
||||
print(f" • {pos_type}: {count}")
|
||||
|
||||
def explain_common_issues(self):
|
||||
"""Explain common issues found in results"""
|
||||
print("\n" + "=" * 80)
|
||||
print("COMMON ISSUE EXPLANATIONS")
|
||||
print("=" * 80)
|
||||
|
||||
has_wrap_failures = any(f['test_name'] == 'wrap_calculation' for f in self.failures)
|
||||
has_edge_failures = any(f['test_name'] == 'single_monitor_edges' for f in self.failures)
|
||||
has_touching_failures = any(f['test_name'] == 'touching_monitors' for f in self.failures)
|
||||
|
||||
if has_wrap_failures:
|
||||
print("\n⚠ WRAP CALCULATION FAILURES")
|
||||
print("-" * 80)
|
||||
print("Issue: Cursor is on an outer edge but wrapping is not occurring.")
|
||||
print("\nLikely Causes:")
|
||||
print(" 1. Partial Overlap Problem:")
|
||||
print(" • When monitors have different sizes (e.g., 4K + 1080p)")
|
||||
print(" • Only part of an edge is actually adjacent to another monitor")
|
||||
print(" • Current code marks the ENTIRE edge as non-outer if ANY part is adjacent")
|
||||
print(" • This prevents wrapping even in regions where it should occur")
|
||||
print("\n 2. Edge Detection Logic:")
|
||||
print(" • Check IdentifyOuterEdges() in MonitorTopology.cpp")
|
||||
print(" • Consider segmenting edges based on actual overlap regions")
|
||||
print("\n 3. Test Point Selection:")
|
||||
print(" • Failures may be at corners or quarter points")
|
||||
print(" • Indicates edge behavior varies along its length")
|
||||
|
||||
if has_edge_failures:
|
||||
print("\n⚠ SINGLE MONITOR EDGE FAILURES")
|
||||
print("-" * 80)
|
||||
print("Issue: Single monitor should have exactly 4 outer edges.")
|
||||
print("\nThis indicates a fundamental problem in edge detection baseline.")
|
||||
|
||||
if has_touching_failures:
|
||||
print("\n⚠ TOUCHING MONITORS FAILURES")
|
||||
print("-" * 80)
|
||||
print("Issue: Adjacent monitors not detected correctly.")
|
||||
print("\nCheck EdgesAreAdjacent() logic and 50px tolerance settings.")
|
||||
|
||||
def print_recommendations(self):
|
||||
"""Print recommendations from the report"""
|
||||
if not self.recommendations:
|
||||
return
|
||||
|
||||
print("\n" + "=" * 80)
|
||||
print("RECOMMENDATIONS")
|
||||
print("=" * 80)
|
||||
|
||||
for i, rec in enumerate(self.recommendations, 1):
|
||||
print(f"\n{i}. {rec}")
|
||||
|
||||
def detailed_failure_dump(self):
|
||||
"""Print all failure details"""
|
||||
print("\n" + "=" * 80)
|
||||
print("DETAILED FAILURE LISTING")
|
||||
print("=" * 80)
|
||||
|
||||
for i, failure in enumerate(self.failures, 1):
|
||||
print(f"\n[{i}] {failure['test_name']}")
|
||||
print(f"Configuration: {failure['monitor_config']}")
|
||||
print(f"Expected: {failure['expected']}")
|
||||
print(f"Actual: {failure['actual']}")
|
||||
|
||||
if 'details' in failure:
|
||||
details = failure['details']
|
||||
if 'edge' in details:
|
||||
edge = details['edge']
|
||||
print(f"Edge: {edge.get('edge_type', 'N/A')} at position {edge.get('position', 'N/A')}, "
|
||||
f"range [{edge.get('range_start', 'N/A')}, {edge.get('range_end', 'N/A')}]")
|
||||
if 'test_point' in details:
|
||||
print(f"Test Point: {details['test_point']}")
|
||||
print("-" * 80)
|
||||
|
||||
def generate_github_copilot_prompt(self):
|
||||
"""Generate a prompt suitable for GitHub Copilot to fix the issues"""
|
||||
print("\n" + "=" * 80)
|
||||
print("GITHUB COPILOT FIX PROMPT")
|
||||
print("=" * 80)
|
||||
print("\n```markdown")
|
||||
print("# CursorWrap Edge Detection Bug Report")
|
||||
print()
|
||||
print("## Test Results Summary")
|
||||
print(f"- Total Configurations Tested: {self.summary.get('total_configs', 0)}")
|
||||
print(f"- Pass Rate: {self.summary.get('pass_rate', 'N/A')}")
|
||||
print(f"- Failed Tests: {self.summary.get('failed', 0)}")
|
||||
print(f"- Total Issues: {self.summary.get('total_issues', 0)}")
|
||||
print()
|
||||
|
||||
# Group failures
|
||||
by_test_type = defaultdict(list)
|
||||
for failure in self.failures:
|
||||
by_test_type[failure['test_name']].append(failure)
|
||||
|
||||
print("## Critical Issues Found")
|
||||
print()
|
||||
|
||||
# Analyze wrap calculation failures
|
||||
if 'wrap_calculation' in by_test_type:
|
||||
failures = by_test_type['wrap_calculation']
|
||||
configs = set(f['monitor_config'] for f in failures)
|
||||
|
||||
print("### 1. Wrap Calculation Failures (PARTIAL OVERLAP BUG)")
|
||||
print()
|
||||
print(f"**Count**: {len(failures)} failures across {len(configs)} configuration(s)")
|
||||
print()
|
||||
print("**Affected Configurations**:")
|
||||
for config in sorted(configs):
|
||||
print(f"- {config}")
|
||||
print()
|
||||
|
||||
print("**Root Cause Analysis**:")
|
||||
print()
|
||||
print("The current implementation in `MonitorTopology::IdentifyOuterEdges()` marks an")
|
||||
print("ENTIRE edge as non-outer if ANY portion of that edge is adjacent to another monitor.")
|
||||
print()
|
||||
print("**Problem Scenario**: 1080p monitor + 4K monitor at bottom-right")
|
||||
print("```")
|
||||
print("4K Monitor (3840x2160 at 0,0)")
|
||||
print("┌────────────────────────────────────────┐")
|
||||
print("│ │ <- Y: 0-1080 NO adjacent monitor")
|
||||
print("│ │ RIGHT EDGE SHOULD BE OUTER")
|
||||
print("│ │")
|
||||
print("│ │┌──────────┐")
|
||||
print("│ ││ 1080p │ <- Y: 1080-2160 HAS adjacent")
|
||||
print("└────────────────────────────────────────┘│ at │ RIGHT EDGE NOT OUTER")
|
||||
print(" │ (3840, │")
|
||||
print(" │ 1080) │")
|
||||
print(" └──────────┘")
|
||||
print("```")
|
||||
print()
|
||||
print("**Current Behavior**: Right edge of 4K monitor is marked as NON-OUTER for entire")
|
||||
print("range (Y: 0-2160) because it detects adjacency in the bottom portion (Y: 1080-2160).")
|
||||
print()
|
||||
print("**Expected Behavior**: Right edge should be:")
|
||||
print("- OUTER from Y: 0 to Y: 1080 (no adjacent monitor)")
|
||||
print("- NON-OUTER from Y: 1080 to Y: 2160 (adjacent to 1080p monitor)")
|
||||
print()
|
||||
|
||||
print("**Failed Test Examples**:")
|
||||
print()
|
||||
for i, failure in enumerate(failures[:3], 1): # Show first 3
|
||||
details = failure.get('details', {})
|
||||
test_point = details.get('test_point', 'N/A')
|
||||
edge = details.get('edge', {})
|
||||
edge_type = edge.get('edge_type', 'N/A')
|
||||
position = edge.get('position', 'N/A')
|
||||
range_start = edge.get('range_start', 'N/A')
|
||||
range_end = edge.get('range_end', 'N/A')
|
||||
|
||||
print(f"{i}. **Configuration**: {failure['monitor_config']}")
|
||||
print(f" - Test Point: {test_point}")
|
||||
print(f" - Edge: {edge_type} at X={position}, Y range=[{range_start}, {range_end}]")
|
||||
print(f" - Expected: Cursor wraps to opposite edge")
|
||||
print(f" - Actual: No wrap occurred (edge incorrectly marked as non-outer)")
|
||||
print()
|
||||
|
||||
if len(failures) > 3:
|
||||
print(f" ... and {len(failures) - 3} more similar failures")
|
||||
print()
|
||||
|
||||
# Other failure types
|
||||
if 'single_monitor_edges' in by_test_type:
|
||||
print("### 2. Single Monitor Edge Detection Failures")
|
||||
print()
|
||||
print(f"**Count**: {len(by_test_type['single_monitor_edges'])} failures")
|
||||
print()
|
||||
print("Single monitor configurations should have exactly 4 outer edges.")
|
||||
print("This indicates a fundamental problem in baseline edge detection.")
|
||||
print()
|
||||
|
||||
if 'touching_monitors' in by_test_type:
|
||||
print("### 3. Adjacent Monitor Detection Failures")
|
||||
print()
|
||||
print(f"**Count**: {len(by_test_type['touching_monitors'])} failures")
|
||||
print()
|
||||
print("Adjacent monitors not being detected correctly by EdgesAreAdjacent().")
|
||||
print()
|
||||
|
||||
print("## Required Code Changes")
|
||||
print()
|
||||
print("### File: `MonitorTopology.cpp`")
|
||||
print()
|
||||
print("**Change 1**: Modify `IdentifyOuterEdges()` to support partial edge adjacency")
|
||||
print()
|
||||
print("Instead of marking entire edges as outer/non-outer, the code needs to:")
|
||||
print()
|
||||
print("1. **Segment edges** based on actual overlap regions with adjacent monitors")
|
||||
print("2. Create **sub-edges** for portions of an edge that have different outer status")
|
||||
print("3. Update `IsOnOuterEdge()` to check if the **cursor's specific position** is on an outer portion")
|
||||
print()
|
||||
print("**Proposed Approach**:")
|
||||
print()
|
||||
print("```cpp")
|
||||
print("// Instead of: edge.isOuter = true/false for entire edge")
|
||||
print("// Use: Store list of outer ranges for each edge")
|
||||
print()
|
||||
print("struct MonitorEdge {")
|
||||
print(" // ... existing fields ...")
|
||||
print(" std::vector<std::pair<int, int>> outerRanges; // Ranges where edge is outer")
|
||||
print("};")
|
||||
print()
|
||||
print("// In IdentifyOuterEdges():")
|
||||
print("// For each edge, find ALL adjacent opposite edges")
|
||||
print("// Calculate which portions of the edge have NO adjacent opposite")
|
||||
print("// Store these as outer ranges")
|
||||
print()
|
||||
print("// In IsOnOuterEdge():")
|
||||
print("// Check if cursor position falls within any outer range")
|
||||
print("if (edge.type == EdgeType::Left || edge.type == EdgeType::Right) {")
|
||||
print(" // Check if cursorPos.y is in any outer range")
|
||||
print("} else {")
|
||||
print(" // Check if cursorPos.x is in any outer range")
|
||||
print("}")
|
||||
print("```")
|
||||
print()
|
||||
print("**Change 2**: Update `EdgesAreAdjacent()` validation")
|
||||
print()
|
||||
print("The 50px tolerance logic is correct but needs to return overlap range info:")
|
||||
print()
|
||||
print("```cpp")
|
||||
print("struct AdjacencyResult {")
|
||||
print(" bool isAdjacent;")
|
||||
print(" int overlapStart; // Where the adjacency begins")
|
||||
print(" int overlapEnd; // Where the adjacency ends")
|
||||
print("};")
|
||||
print()
|
||||
print("AdjacencyResult CheckEdgeAdjacency(const MonitorEdge& edge1, ")
|
||||
print(" const MonitorEdge& edge2, ")
|
||||
print(" int tolerance);")
|
||||
print("```")
|
||||
print()
|
||||
print("## Test Validation")
|
||||
print()
|
||||
print("After implementing changes, run:")
|
||||
print("```bash")
|
||||
print("python monitor_layout_tests.py --max-monitors 10")
|
||||
print("```")
|
||||
print()
|
||||
print("Expected results:")
|
||||
print("- All 21+ configurations should pass")
|
||||
print("- Specifically, the 4K+1080p configuration should pass all 5 test points per edge")
|
||||
print("- Wrap calculation should work correctly at partial overlap boundaries")
|
||||
print()
|
||||
print("## Files to Modify")
|
||||
print()
|
||||
print("1. `MonitorTopology.h` - Update MonitorEdge structure")
|
||||
print("2. `MonitorTopology.cpp` - Implement segmented edge detection")
|
||||
print(" - `IdentifyOuterEdges()` - Main logic change")
|
||||
print(" - `IsOnOuterEdge()` - Check position against ranges")
|
||||
print(" - `EdgesAreAdjacent()` - Optionally return range info")
|
||||
print()
|
||||
print("```")
|
||||
|
||||
def run_analysis(self, detailed: bool = False, copilot_mode: bool = False):
|
||||
"""Run complete analysis"""
|
||||
if copilot_mode:
|
||||
self.generate_github_copilot_prompt()
|
||||
return
|
||||
|
||||
self.print_overview()
|
||||
|
||||
if not self.failures:
|
||||
print("\n✓ No failures to analyze!")
|
||||
return
|
||||
|
||||
by_test_type, by_config = self.analyze_failure_patterns()
|
||||
|
||||
# Specific analysis for wrap calculation failures
|
||||
if 'wrap_calculation' in by_test_type:
|
||||
self.analyze_wrap_calculation_failures(by_test_type['wrap_calculation'])
|
||||
|
||||
self.explain_common_issues()
|
||||
self.print_recommendations()
|
||||
|
||||
if detailed:
|
||||
self.detailed_failure_dump()
|
||||
|
||||
|
||||
def main():
|
||||
"""Main entry point"""
|
||||
import argparse
|
||||
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Analyze CursorWrap test results"
|
||||
)
|
||||
parser.add_argument(
|
||||
"--report",
|
||||
default="test_report.json",
|
||||
help="Path to test report JSON file"
|
||||
)
|
||||
parser.add_argument(
|
||||
"--detailed",
|
||||
action="store_true",
|
||||
help="Show detailed failure listing"
|
||||
)
|
||||
parser.add_argument(
|
||||
"--copilot",
|
||||
action="store_true",
|
||||
help="Generate GitHub Copilot-friendly fix prompt"
|
||||
)
|
||||
|
||||
args = parser.parse_args()
|
||||
|
||||
try:
|
||||
analyzer = TestResultAnalyzer(args.report)
|
||||
analyzer.run_analysis(detailed=args.detailed, copilot_mode=args.copilot)
|
||||
|
||||
# Exit with error code if there were failures
|
||||
sys.exit(0 if not analyzer.failures else 1)
|
||||
|
||||
except FileNotFoundError:
|
||||
print(f"Error: Could not find report file: {args.report}")
|
||||
print("\nRun monitor_layout_tests.py first to generate the report.")
|
||||
sys.exit(1)
|
||||
except json.JSONDecodeError:
|
||||
print(f"Error: Invalid JSON in report file: {args.report}")
|
||||
sys.exit(1)
|
||||
except Exception as e:
|
||||
print(f"Error analyzing report: {e}")
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,892 @@
|
||||
"""
|
||||
Monitor Layout Edge Detection Test Suite for CursorWrap
|
||||
|
||||
This script validates the edge detection and wrapping logic across thousands of
|
||||
monitor configurations without requiring the full PowerToys build environment.
|
||||
|
||||
Tests:
|
||||
- 1-4 monitor configurations
|
||||
- Common resolutions and DPI scales
|
||||
- Various arrangements (horizontal, vertical, L-shape, grid)
|
||||
- Edge detection (touching vs. gap)
|
||||
- Wrap calculations
|
||||
|
||||
Output: JSON report with failures for GitHub Copilot analysis
|
||||
"""
|
||||
|
||||
import json
|
||||
from dataclasses import dataclass, asdict
|
||||
from typing import List, Tuple, Dict, Optional
|
||||
from enum import Enum
|
||||
import sys
|
||||
|
||||
# ============================================================================
|
||||
# Data Structures (mirrors C++ implementation)
|
||||
# ============================================================================
|
||||
|
||||
|
||||
@dataclass
|
||||
class MonitorInfo:
|
||||
"""Represents a physical monitor"""
|
||||
left: int
|
||||
top: int
|
||||
right: int
|
||||
bottom: int
|
||||
dpi: int = 96
|
||||
primary: bool = False
|
||||
|
||||
@property
|
||||
def width(self) -> int:
|
||||
return self.right - self.left
|
||||
|
||||
@property
|
||||
def height(self) -> int:
|
||||
return self.bottom - self.top
|
||||
|
||||
@property
|
||||
def center_x(self) -> int:
|
||||
return (self.left + self.right) // 2
|
||||
|
||||
@property
|
||||
def center_y(self) -> int:
|
||||
return (self.top + self.bottom) // 2
|
||||
|
||||
|
||||
class EdgeType(Enum):
|
||||
LEFT = "Left"
|
||||
RIGHT = "Right"
|
||||
TOP = "Top"
|
||||
BOTTOM = "Bottom"
|
||||
|
||||
|
||||
@dataclass
|
||||
class Edge:
|
||||
"""Represents a monitor edge"""
|
||||
edge_type: EdgeType
|
||||
position: int # x for vertical, y for horizontal
|
||||
range_start: int
|
||||
range_end: int
|
||||
monitor_index: int
|
||||
|
||||
def overlaps(self, other: 'Edge', tolerance: int = 1) -> bool:
|
||||
"""Check if two edges overlap in their perpendicular range"""
|
||||
if self.edge_type != other.edge_type:
|
||||
return False
|
||||
if abs(self.position - other.position) > tolerance:
|
||||
return False
|
||||
return not (
|
||||
self.range_end <= other.range_start or other.range_end <= self.range_start)
|
||||
|
||||
|
||||
@dataclass
|
||||
class TestFailure:
|
||||
"""Records a test failure for analysis"""
|
||||
test_name: str
|
||||
monitor_config: str
|
||||
expected: str
|
||||
actual: str
|
||||
details: Dict
|
||||
|
||||
# ============================================================================
|
||||
# Edge Detection Logic (Python implementation of C++ logic)
|
||||
# ============================================================================
|
||||
|
||||
|
||||
class MonitorTopology:
|
||||
"""Implements the edge detection logic to be validated"""
|
||||
|
||||
ADJACENCY_TOLERANCE = 50 # Pixels - tolerance for detecting adjacent edges (matches C++ implementation)
|
||||
EDGE_THRESHOLD = 1 # Pixels - cursor must be within this distance to trigger wrap
|
||||
|
||||
def __init__(self, monitors: List[MonitorInfo]):
|
||||
self.monitors = monitors
|
||||
self.outer_edges: List[Edge] = []
|
||||
self._detect_outer_edges()
|
||||
|
||||
def _detect_outer_edges(self):
|
||||
"""Detect which edges are outer (can wrap)"""
|
||||
all_edges = self._collect_all_edges()
|
||||
|
||||
for edge in all_edges:
|
||||
if self._is_outer_edge(edge, all_edges):
|
||||
self.outer_edges.append(edge)
|
||||
|
||||
def _collect_all_edges(self) -> List[Edge]:
|
||||
"""Collect all edges from all monitors"""
|
||||
edges = []
|
||||
|
||||
for idx, mon in enumerate(self.monitors):
|
||||
edges.append(
|
||||
Edge(
|
||||
EdgeType.LEFT,
|
||||
mon.left,
|
||||
mon.top,
|
||||
mon.bottom,
|
||||
idx))
|
||||
edges.append(
|
||||
Edge(
|
||||
EdgeType.RIGHT,
|
||||
mon.right,
|
||||
mon.top,
|
||||
mon.bottom,
|
||||
idx))
|
||||
edges.append(Edge(EdgeType.TOP, mon.top, mon.left, mon.right, idx))
|
||||
edges.append(
|
||||
Edge(
|
||||
EdgeType.BOTTOM,
|
||||
mon.bottom,
|
||||
mon.left,
|
||||
mon.right,
|
||||
idx))
|
||||
|
||||
return edges
|
||||
|
||||
def _is_outer_edge(self, edge: Edge, all_edges: List[Edge]) -> bool:
|
||||
"""
|
||||
Determine if an edge is "outer" (can wrap)
|
||||
|
||||
Rules:
|
||||
1. If edge has an adjacent opposite edge (within 50px tolerance AND overlapping range), it's NOT outer
|
||||
2. Otherwise, edge IS outer
|
||||
Note: This matches C++ EdgesAreAdjacent() logic
|
||||
"""
|
||||
opposite_type = self._get_opposite_edge_type(edge.edge_type)
|
||||
|
||||
# Find opposite edges that overlap in perpendicular range
|
||||
opposite_edges = [e for e in all_edges
|
||||
if e.edge_type == opposite_type
|
||||
and e.monitor_index != edge.monitor_index
|
||||
and self._ranges_overlap(edge.range_start, edge.range_end,
|
||||
e.range_start, e.range_end)]
|
||||
|
||||
if not opposite_edges:
|
||||
return True # No opposite edges = outer edge
|
||||
|
||||
# Check if any opposite edge is adjacent (within tolerance)
|
||||
for opp in opposite_edges:
|
||||
distance = abs(edge.position - opp.position)
|
||||
if distance <= self.ADJACENCY_TOLERANCE:
|
||||
return False # Adjacent edge found = not outer
|
||||
|
||||
return True # No adjacent edges = outer
|
||||
|
||||
@staticmethod
|
||||
def _get_opposite_edge_type(edge_type: EdgeType) -> EdgeType:
|
||||
"""Get the opposite edge type"""
|
||||
opposites = {
|
||||
EdgeType.LEFT: EdgeType.RIGHT,
|
||||
EdgeType.RIGHT: EdgeType.LEFT,
|
||||
EdgeType.TOP: EdgeType.BOTTOM,
|
||||
EdgeType.BOTTOM: EdgeType.TOP
|
||||
}
|
||||
return opposites[edge_type]
|
||||
|
||||
@staticmethod
|
||||
def _ranges_overlap(
|
||||
a_start: int,
|
||||
a_end: int,
|
||||
b_start: int,
|
||||
b_end: int) -> bool:
|
||||
"""Check if two 1D ranges overlap"""
|
||||
return not (a_end <= b_start or b_end <= a_start)
|
||||
|
||||
def calculate_wrap_position(self, x: int, y: int) -> Tuple[int, int]:
|
||||
"""Calculate where cursor should wrap to"""
|
||||
# Find which outer edge was crossed and calculate wrap
|
||||
# At corners, multiple edges may match - try all and return first successful wrap
|
||||
for edge in self.outer_edges:
|
||||
if self._is_on_edge(x, y, edge):
|
||||
new_x, new_y = self._wrap_from_edge(x, y, edge)
|
||||
if (new_x, new_y) != (x, y):
|
||||
# Wrap succeeded
|
||||
return (new_x, new_y)
|
||||
|
||||
return (x, y) # No wrap
|
||||
|
||||
def _is_on_edge(self, x: int, y: int, edge: Edge) -> bool:
|
||||
"""Check if point is on the given edge"""
|
||||
tolerance = 2 # Pixels
|
||||
|
||||
if edge.edge_type in (EdgeType.LEFT, EdgeType.RIGHT):
|
||||
return (abs(x - edge.position) <= tolerance and
|
||||
edge.range_start <= y <= edge.range_end)
|
||||
else:
|
||||
return (abs(y - edge.position) <= tolerance and
|
||||
edge.range_start <= x <= edge.range_end)
|
||||
|
||||
def _wrap_from_edge(self, x: int, y: int, edge: Edge) -> Tuple[int, int]:
|
||||
"""Calculate wrap destination from an outer edge"""
|
||||
opposite_type = self._get_opposite_edge_type(edge.edge_type)
|
||||
|
||||
# Find opposite outer edges that overlap
|
||||
opposite_edges = [e for e in self.outer_edges
|
||||
if e.edge_type == opposite_type
|
||||
and self._point_in_range(x, y, e)]
|
||||
|
||||
if not opposite_edges:
|
||||
return (x, y) # No wrap destination
|
||||
|
||||
# Find closest opposite edge
|
||||
target_edge = min(opposite_edges,
|
||||
key=lambda e: abs(e.position - edge.position))
|
||||
|
||||
# Calculate new position
|
||||
if edge.edge_type in (EdgeType.LEFT, EdgeType.RIGHT):
|
||||
return (target_edge.position, y)
|
||||
else:
|
||||
return (x, target_edge.position)
|
||||
|
||||
@staticmethod
|
||||
def _point_in_range(x: int, y: int, edge: Edge) -> bool:
|
||||
"""Check if point's perpendicular coordinate is in edge's range"""
|
||||
if edge.edge_type in (EdgeType.LEFT, EdgeType.RIGHT):
|
||||
return edge.range_start <= y <= edge.range_end
|
||||
else:
|
||||
return edge.range_start <= x <= edge.range_end
|
||||
|
||||
# ============================================================================
|
||||
# Test Configuration Generators
|
||||
# ============================================================================
|
||||
|
||||
|
||||
class TestConfigGenerator:
|
||||
"""Generates comprehensive test configurations"""
|
||||
|
||||
# Common resolutions
|
||||
RESOLUTIONS = [
|
||||
(1920, 1080), # 1080p
|
||||
(2560, 1440), # 1440p
|
||||
(3840, 2160), # 4K
|
||||
(3440, 1440), # Ultrawide
|
||||
(1920, 1200), # 16:10
|
||||
]
|
||||
|
||||
# DPI scales
|
||||
DPI_SCALES = [96, 120, 144, 192] # 100%, 125%, 150%, 200%
|
||||
|
||||
@classmethod
|
||||
def load_from_file(cls, filepath: str) -> List[List[MonitorInfo]]:
|
||||
"""Load monitor configuration from captured JSON file"""
|
||||
# Handle UTF-8 with BOM (PowerShell default)
|
||||
with open(filepath, 'r', encoding='utf-8-sig') as f:
|
||||
data = json.load(f)
|
||||
|
||||
monitors = []
|
||||
for mon in data.get('monitors', []):
|
||||
monitor = MonitorInfo(
|
||||
left=mon['left'],
|
||||
top=mon['top'],
|
||||
right=mon['right'],
|
||||
bottom=mon['bottom'],
|
||||
dpi=mon.get('dpi', 96),
|
||||
primary=mon.get('primary', False)
|
||||
)
|
||||
monitors.append(monitor)
|
||||
|
||||
return [monitors] if monitors else []
|
||||
|
||||
@classmethod
|
||||
def generate_all_configs(cls,
|
||||
max_monitors: int = 4) -> List[List[MonitorInfo]]:
|
||||
"""Generate all test configurations"""
|
||||
configs = []
|
||||
|
||||
# Single monitor (baseline)
|
||||
configs.extend(cls._single_monitor_configs())
|
||||
|
||||
# Two monitors (most common)
|
||||
if max_monitors >= 2:
|
||||
configs.extend(cls._two_monitor_configs())
|
||||
|
||||
# Three monitors
|
||||
if max_monitors >= 3:
|
||||
configs.extend(cls._three_monitor_configs())
|
||||
|
||||
# Four monitors
|
||||
if max_monitors >= 4:
|
||||
configs.extend(cls._four_monitor_configs())
|
||||
|
||||
# Five+ monitors
|
||||
if max_monitors >= 5:
|
||||
configs.extend(cls._five_plus_monitor_configs(max_monitors))
|
||||
|
||||
return configs
|
||||
|
||||
@classmethod
|
||||
def _single_monitor_configs(cls) -> List[List[MonitorInfo]]:
|
||||
"""Single monitor configurations"""
|
||||
configs = []
|
||||
|
||||
for width, height in cls.RESOLUTIONS[:3]: # Limit for single monitor
|
||||
for dpi in cls.DPI_SCALES[:2]: # Limit DPI variations
|
||||
mon = MonitorInfo(0, 0, width, height, dpi, True)
|
||||
configs.append([mon])
|
||||
|
||||
return configs
|
||||
|
||||
@classmethod
|
||||
def _two_monitor_configs(cls) -> List[List[MonitorInfo]]:
|
||||
"""Two monitor configurations"""
|
||||
configs = []
|
||||
# Both 1080p for simplicity
|
||||
res1, res2 = cls.RESOLUTIONS[0], cls.RESOLUTIONS[0]
|
||||
|
||||
# Horizontal (touching)
|
||||
configs.append([
|
||||
MonitorInfo(0, 0, res1[0], res1[1], primary=True),
|
||||
MonitorInfo(res1[0], 0, res1[0] + res2[0], res2[1])
|
||||
])
|
||||
|
||||
# Vertical (touching)
|
||||
configs.append([
|
||||
MonitorInfo(0, 0, res1[0], res1[1], primary=True),
|
||||
MonitorInfo(0, res1[1], res2[0], res1[1] + res2[1])
|
||||
])
|
||||
|
||||
# Different resolutions
|
||||
res_big = cls.RESOLUTIONS[2] # 4K
|
||||
configs.append([
|
||||
MonitorInfo(0, 0, res1[0], res1[1], primary=True),
|
||||
MonitorInfo(res1[0], 0, res1[0] + res_big[0], res_big[1])
|
||||
])
|
||||
|
||||
# Offset alignment (common real-world scenario)
|
||||
offset = 200
|
||||
configs.append([
|
||||
MonitorInfo(0, offset, res1[0], offset + res1[1], primary=True),
|
||||
MonitorInfo(res1[0], 0, res1[0] + res2[0], res2[1])
|
||||
])
|
||||
|
||||
return configs
|
||||
|
||||
@classmethod
|
||||
def _three_monitor_configs(cls) -> List[List[MonitorInfo]]:
|
||||
"""Three monitor configurations"""
|
||||
configs = []
|
||||
res = cls.RESOLUTIONS[0] # 1080p
|
||||
|
||||
# Linear horizontal
|
||||
configs.append([
|
||||
MonitorInfo(0, 0, res[0], res[1], primary=True),
|
||||
MonitorInfo(res[0], 0, res[0] * 2, res[1]),
|
||||
MonitorInfo(res[0] * 2, 0, res[0] * 3, res[1])
|
||||
])
|
||||
|
||||
# L-shape (common gaming setup)
|
||||
configs.append([
|
||||
MonitorInfo(0, 0, res[0], res[1], primary=True),
|
||||
MonitorInfo(res[0], 0, res[0] * 2, res[1]),
|
||||
MonitorInfo(0, res[1], res[0], res[1] * 2)
|
||||
])
|
||||
|
||||
# Vertical stack
|
||||
configs.append([
|
||||
MonitorInfo(0, 0, res[0], res[1], primary=True),
|
||||
MonitorInfo(0, res[1], res[0], res[1] * 2),
|
||||
MonitorInfo(0, res[1] * 2, res[0], res[1] * 3)
|
||||
])
|
||||
|
||||
return configs
|
||||
|
||||
@classmethod
|
||||
def _four_monitor_configs(cls) -> List[List[MonitorInfo]]:
|
||||
"""Four monitor configurations"""
|
||||
configs = []
|
||||
res = cls.RESOLUTIONS[0] # 1080p
|
||||
|
||||
# 2x2 grid (classic)
|
||||
configs.append([
|
||||
MonitorInfo(0, 0, res[0], res[1], primary=True),
|
||||
MonitorInfo(res[0], 0, res[0] * 2, res[1]),
|
||||
MonitorInfo(0, res[1], res[0], res[1] * 2),
|
||||
MonitorInfo(res[0], res[1], res[0] * 2, res[1] * 2)
|
||||
])
|
||||
|
||||
# Linear horizontal
|
||||
configs.append([
|
||||
MonitorInfo(0, 0, res[0], res[1], primary=True),
|
||||
MonitorInfo(res[0], 0, res[0] * 2, res[1]),
|
||||
MonitorInfo(res[0] * 2, 0, res[0] * 3, res[1]),
|
||||
MonitorInfo(res[0] * 3, 0, res[0] * 4, res[1])
|
||||
])
|
||||
|
||||
return configs
|
||||
|
||||
@classmethod
|
||||
def _five_plus_monitor_configs(cls, max_count: int) -> List[List[MonitorInfo]]:
|
||||
"""Five to ten monitor configurations"""
|
||||
configs = []
|
||||
res = cls.RESOLUTIONS[0] # 1080p
|
||||
|
||||
# Linear horizontal (5-10 monitors)
|
||||
for count in range(5, min(max_count + 1, 11)):
|
||||
monitor_list = []
|
||||
for i in range(count):
|
||||
is_primary = (i == 0)
|
||||
monitor_list.append(
|
||||
MonitorInfo(res[0] * i, 0, res[0] * (i + 1), res[1], primary=is_primary)
|
||||
)
|
||||
configs.append(monitor_list)
|
||||
|
||||
return configs
|
||||
|
||||
# ============================================================================
|
||||
# Test Validators
|
||||
# ============================================================================
|
||||
|
||||
|
||||
class EdgeDetectionValidator:
|
||||
"""Validates edge detection logic"""
|
||||
|
||||
@staticmethod
|
||||
def validate_single_monitor(
|
||||
monitors: List[MonitorInfo]) -> Optional[TestFailure]:
|
||||
"""Single monitor should have 4 outer edges"""
|
||||
topology = MonitorTopology(monitors)
|
||||
expected_count = 4
|
||||
actual_count = len(topology.outer_edges)
|
||||
|
||||
if actual_count != expected_count:
|
||||
return TestFailure(
|
||||
test_name="single_monitor_edges",
|
||||
monitor_config=EdgeDetectionValidator._describe_config(
|
||||
monitors),
|
||||
expected=f"{expected_count} outer edges",
|
||||
actual=f"{actual_count} outer edges",
|
||||
details={"edges": [asdict(e) for e in topology.outer_edges]}
|
||||
)
|
||||
return None
|
||||
|
||||
@staticmethod
|
||||
def validate_touching_monitors(
|
||||
monitors: List[MonitorInfo]) -> Optional[TestFailure]:
|
||||
"""Touching monitors should have no gap between them"""
|
||||
topology = MonitorTopology(monitors)
|
||||
|
||||
# For 2 touching monitors horizontally, expect 6 outer edges (not 8)
|
||||
if len(monitors) == 2:
|
||||
# Check if they're aligned horizontally and touching
|
||||
m1, m2 = monitors
|
||||
if m1.right == m2.left and m1.top == m2.top and m1.bottom == m2.bottom:
|
||||
expected = 6 # 2 internal edges removed
|
||||
actual = len(topology.outer_edges)
|
||||
if actual != expected:
|
||||
return TestFailure(
|
||||
test_name="touching_monitors",
|
||||
monitor_config=EdgeDetectionValidator._describe_config(
|
||||
monitors),
|
||||
expected=f"{expected} outer edges (2 touching edges removed)",
|
||||
actual=f"{actual} outer edges",
|
||||
details={"edges": [asdict(e)
|
||||
for e in topology.outer_edges]}
|
||||
)
|
||||
return None
|
||||
|
||||
@staticmethod
|
||||
def validate_wrap_calculation(
|
||||
monitors: List[MonitorInfo]) -> List[TestFailure]:
|
||||
"""Validate cursor wrap calculations"""
|
||||
failures = []
|
||||
topology = MonitorTopology(monitors)
|
||||
|
||||
# Test wrapping at each outer edge with multiple points
|
||||
for edge in topology.outer_edges:
|
||||
test_points = EdgeDetectionValidator._get_test_points_on_edge(
|
||||
edge, monitors)
|
||||
|
||||
for test_point in test_points:
|
||||
x, y = test_point
|
||||
|
||||
# Check if there's actually a valid wrap destination
|
||||
# (some outer edges may not have opposite edges due to partial overlap)
|
||||
opposite_type = topology._get_opposite_edge_type(edge.edge_type)
|
||||
has_opposite = any(
|
||||
e.edge_type == opposite_type and
|
||||
topology._point_in_range(x, y, e)
|
||||
for e in topology.outer_edges
|
||||
)
|
||||
|
||||
if not has_opposite:
|
||||
# No wrap destination available - this is OK for partial overlaps
|
||||
continue
|
||||
|
||||
new_x, new_y = topology.calculate_wrap_position(x, y)
|
||||
|
||||
# Verify wrap happened (position changed)
|
||||
if (new_x, new_y) == (x, y):
|
||||
# Should have wrapped but didn't
|
||||
failure = TestFailure(
|
||||
test_name="wrap_calculation",
|
||||
monitor_config=EdgeDetectionValidator._describe_config(
|
||||
monitors),
|
||||
expected=f"Cursor should wrap from ({x},{y})",
|
||||
actual=f"No wrap occurred",
|
||||
details={
|
||||
"edge": asdict(edge),
|
||||
"test_point": (x, y)
|
||||
}
|
||||
)
|
||||
failures.append(failure)
|
||||
|
||||
return failures
|
||||
|
||||
@staticmethod
|
||||
def _get_test_points_on_edge(
|
||||
edge: Edge, monitors: List[MonitorInfo]) -> List[Tuple[int, int]]:
|
||||
"""Get multiple test points on the given edge (5 points: top/left corner, quarter, center, three-quarter, bottom/right corner)"""
|
||||
monitor = monitors[edge.monitor_index]
|
||||
points = []
|
||||
|
||||
if edge.edge_type == EdgeType.LEFT:
|
||||
x = monitor.left
|
||||
for ratio in [0.0, 0.25, 0.5, 0.75, 1.0]:
|
||||
y = int(monitor.top + (monitor.height - 1) * ratio)
|
||||
points.append((x, y))
|
||||
elif edge.edge_type == EdgeType.RIGHT:
|
||||
x = monitor.right - 1
|
||||
for ratio in [0.0, 0.25, 0.5, 0.75, 1.0]:
|
||||
y = int(monitor.top + (monitor.height - 1) * ratio)
|
||||
points.append((x, y))
|
||||
elif edge.edge_type == EdgeType.TOP:
|
||||
y = monitor.top
|
||||
for ratio in [0.0, 0.25, 0.5, 0.75, 1.0]:
|
||||
x = int(monitor.left + (monitor.width - 1) * ratio)
|
||||
points.append((x, y))
|
||||
elif edge.edge_type == EdgeType.BOTTOM:
|
||||
y = monitor.bottom - 1
|
||||
for ratio in [0.0, 0.25, 0.5, 0.75, 1.0]:
|
||||
x = int(monitor.left + (monitor.width - 1) * ratio)
|
||||
points.append((x, y))
|
||||
|
||||
return points
|
||||
|
||||
@staticmethod
|
||||
def _describe_config(monitors: List[MonitorInfo]) -> str:
|
||||
"""Generate human-readable config description"""
|
||||
if len(monitors) == 1:
|
||||
m = monitors[0]
|
||||
return f"Single {m.width}x{m.height} @{m.dpi}DPI"
|
||||
|
||||
desc = f"{len(monitors)} monitors: "
|
||||
for i, m in enumerate(monitors):
|
||||
desc += f"M{i}({m.width}x{m.height} at {m.left},{m.top}) "
|
||||
return desc.strip()
|
||||
|
||||
# ============================================================================
|
||||
# Test Runner
|
||||
# ============================================================================
|
||||
|
||||
|
||||
class TestRunner:
|
||||
"""Orchestrates the test execution"""
|
||||
|
||||
def __init__(self, max_monitors: int = 10, verbose: bool = False, layout_file: str = None):
|
||||
self.max_monitors = max_monitors
|
||||
self.verbose = verbose
|
||||
self.layout_file = layout_file
|
||||
self.failures: List[TestFailure] = []
|
||||
self.test_count = 0
|
||||
self.passed_count = 0
|
||||
|
||||
def _print_layout_diagram(self, monitors: List[MonitorInfo]):
|
||||
"""Print a text-based diagram of the monitor layout"""
|
||||
print("\n" + "=" * 80)
|
||||
print("MONITOR LAYOUT DIAGRAM")
|
||||
print("=" * 80)
|
||||
|
||||
# Find bounds of entire desktop
|
||||
min_x = min(m.left for m in monitors)
|
||||
min_y = min(m.top for m in monitors)
|
||||
max_x = max(m.right for m in monitors)
|
||||
max_y = max(m.bottom for m in monitors)
|
||||
|
||||
# Calculate scale to fit in ~70 chars wide
|
||||
desktop_width = max_x - min_x
|
||||
desktop_height = max_y - min_y
|
||||
|
||||
# Scale factor: target 70 chars width
|
||||
scale = desktop_width / 70.0
|
||||
if scale < 1:
|
||||
scale = 1
|
||||
|
||||
# Create grid (70 chars wide, proportional height)
|
||||
grid_width = 70
|
||||
grid_height = max(10, int(desktop_height / scale))
|
||||
grid_height = min(grid_height, 30) # Cap at 30 lines
|
||||
|
||||
# Initialize grid with spaces
|
||||
grid = [[' ' for _ in range(grid_width)] for _ in range(grid_height)]
|
||||
|
||||
# Draw each monitor
|
||||
for idx, mon in enumerate(monitors):
|
||||
# Convert monitor coords to grid coords
|
||||
x1 = int((mon.left - min_x) / scale)
|
||||
y1 = int((mon.top - min_y) / scale)
|
||||
x2 = int((mon.right - min_x) / scale)
|
||||
y2 = int((mon.bottom - min_y) / scale)
|
||||
|
||||
# Clamp to grid
|
||||
x1 = max(0, min(x1, grid_width - 1))
|
||||
x2 = max(0, min(x2, grid_width))
|
||||
y1 = max(0, min(y1, grid_height - 1))
|
||||
y2 = max(0, min(y2, grid_height))
|
||||
|
||||
# Draw monitor border and fill
|
||||
char = str(idx) if idx < 10 else chr(65 + idx - 10) # 0-9, then A-Z
|
||||
|
||||
for y in range(y1, y2):
|
||||
for x in range(x1, x2):
|
||||
if y < grid_height and x < grid_width:
|
||||
# Draw borders
|
||||
if y == y1 or y == y2 - 1:
|
||||
grid[y][x] = '─'
|
||||
elif x == x1 or x == x2 - 1:
|
||||
grid[y][x] = '│'
|
||||
else:
|
||||
grid[y][x] = char
|
||||
|
||||
# Draw corners
|
||||
if y1 < grid_height and x1 < grid_width:
|
||||
grid[y1][x1] = '┌'
|
||||
if y1 < grid_height and x2 - 1 < grid_width:
|
||||
grid[y1][x2 - 1] = '┐'
|
||||
if y2 - 1 < grid_height and x1 < grid_width:
|
||||
grid[y2 - 1][x1] = '└'
|
||||
if y2 - 1 < grid_height and x2 - 1 < grid_width:
|
||||
grid[y2 - 1][x2 - 1] = '┘'
|
||||
|
||||
# Print grid
|
||||
print()
|
||||
for row in grid:
|
||||
print(''.join(row))
|
||||
|
||||
# Print legend
|
||||
print("\n" + "-" * 80)
|
||||
print("MONITOR DETAILS:")
|
||||
print("-" * 80)
|
||||
for idx, mon in enumerate(monitors):
|
||||
char = str(idx) if idx < 10 else chr(65 + idx - 10)
|
||||
primary = " [PRIMARY]" if mon.primary else ""
|
||||
scaling = int((mon.dpi / 96.0) * 100)
|
||||
print(f" [{char}] Monitor {idx}{primary}")
|
||||
print(f" Position: ({mon.left}, {mon.top})")
|
||||
print(f" Size: {mon.width}x{mon.height}")
|
||||
print(f" DPI: {mon.dpi} ({scaling}% scaling)")
|
||||
print(f" Bounds: [{mon.left}, {mon.top}, {mon.right}, {mon.bottom}]")
|
||||
|
||||
print("=" * 80 + "\n")
|
||||
|
||||
def run_all_tests(self):
|
||||
"""Execute all test configurations"""
|
||||
print("=" * 80)
|
||||
print("CursorWrap Monitor Layout Edge Detection Test Suite")
|
||||
print("=" * 80)
|
||||
|
||||
# Load or generate configs
|
||||
if self.layout_file:
|
||||
print(f"\nLoading monitor layout from {self.layout_file}...")
|
||||
configs = TestConfigGenerator.load_from_file(self.layout_file)
|
||||
# Show visual diagram for captured layouts
|
||||
if configs:
|
||||
self._print_layout_diagram(configs[0])
|
||||
else:
|
||||
print("\nGenerating test configurations...")
|
||||
configs = TestConfigGenerator.generate_all_configs(self.max_monitors)
|
||||
|
||||
total_tests = len(configs)
|
||||
print(f"Testing {total_tests} configuration(s)")
|
||||
print("=" * 80)
|
||||
|
||||
# Run tests
|
||||
for i, config in enumerate(configs, 1):
|
||||
self._run_test_config(config, i, total_tests)
|
||||
|
||||
# Report results
|
||||
self._print_summary()
|
||||
self._save_report()
|
||||
|
||||
def _run_test_config(
|
||||
self,
|
||||
monitors: List[MonitorInfo],
|
||||
iteration: int,
|
||||
total: int):
|
||||
"""Run all validators on a single configuration"""
|
||||
desc = EdgeDetectionValidator._describe_config(monitors)
|
||||
|
||||
if not self.verbose:
|
||||
# Minimal output: just progress
|
||||
progress = (iteration / total) * 100
|
||||
print(
|
||||
f"\r[{iteration}/{total}] {progress:5.1f}% - Testing: {desc[:60]:<60}", end="", flush=True)
|
||||
else:
|
||||
print(f"\n[{iteration}/{total}] Testing: {desc}")
|
||||
|
||||
# Run validators
|
||||
self.test_count += 1
|
||||
config_passed = True
|
||||
|
||||
# Single monitor validation
|
||||
if len(monitors) == 1:
|
||||
failure = EdgeDetectionValidator.validate_single_monitor(monitors)
|
||||
if failure:
|
||||
self.failures.append(failure)
|
||||
config_passed = False
|
||||
|
||||
# Touching monitors validation (2+ monitors)
|
||||
if len(monitors) >= 2:
|
||||
failure = EdgeDetectionValidator.validate_touching_monitors(monitors)
|
||||
if failure:
|
||||
self.failures.append(failure)
|
||||
config_passed = False
|
||||
|
||||
# Wrap calculation validation
|
||||
wrap_failures = EdgeDetectionValidator.validate_wrap_calculation(monitors)
|
||||
if wrap_failures:
|
||||
self.failures.extend(wrap_failures)
|
||||
config_passed = False
|
||||
|
||||
if config_passed:
|
||||
self.passed_count += 1
|
||||
|
||||
if self.verbose and not config_passed:
|
||||
print(f" ? FAILED ({len([f for f in self.failures if desc in f.monitor_config])} issues)")
|
||||
elif self.verbose:
|
||||
print(" ? PASSED")
|
||||
|
||||
def _print_summary(self):
|
||||
"""Print test summary"""
|
||||
print("\n\n" + "=" * 80)
|
||||
print("TEST SUMMARY")
|
||||
print("=" * 80)
|
||||
print(f"Total Configurations: {self.test_count}")
|
||||
print(f"Passed: {self.passed_count} ({self.passed_count/self.test_count*100:.1f}%)")
|
||||
print(f"Failed: {self.test_count - self.passed_count} ({(self.test_count - self.passed_count)/self.test_count*100:.1f}%)")
|
||||
print(f"Total Issues Found: {len(self.failures)}")
|
||||
print("=" * 80)
|
||||
|
||||
if self.failures:
|
||||
print("\n?? FAILURES DETECTED - See test_report.json for details")
|
||||
print("\nTop 5 Failure Types:")
|
||||
failure_types = {}
|
||||
for f in self.failures:
|
||||
failure_types[f.test_name] = failure_types.get(f.test_name, 0) + 1
|
||||
|
||||
for test_name, count in sorted(failure_types.items(), key=lambda x: x[1], reverse=True)[:5]:
|
||||
print(f" - {test_name}: {count} failures")
|
||||
else:
|
||||
print("\n? ALL TESTS PASSED!")
|
||||
|
||||
def _save_report(self):
|
||||
"""Save detailed JSON report"""
|
||||
|
||||
# Helper to convert enums to strings
|
||||
def convert_for_json(obj):
|
||||
if isinstance(obj, dict):
|
||||
return {k: convert_for_json(v) for k, v in obj.items()}
|
||||
elif isinstance(obj, list):
|
||||
return [convert_for_json(item) for item in obj]
|
||||
elif isinstance(obj, Enum):
|
||||
return obj.value
|
||||
else:
|
||||
return obj
|
||||
|
||||
report = {
|
||||
"summary": {
|
||||
"total_configs": self.test_count,
|
||||
"passed": self.passed_count,
|
||||
"failed": self.test_count - self.passed_count,
|
||||
"total_issues": len(self.failures),
|
||||
"pass_rate": f"{self.passed_count/self.test_count*100:.2f}%"
|
||||
},
|
||||
"failures": convert_for_json([asdict(f) for f in self.failures]),
|
||||
"recommendations": self._generate_recommendations()
|
||||
}
|
||||
|
||||
output_file = "test_report.json"
|
||||
with open(output_file, "w") as f:
|
||||
json.dump(report, f, indent=2)
|
||||
|
||||
print(f"\n?? Detailed report saved to: {output_file}")
|
||||
|
||||
def _generate_recommendations(self) -> List[str]:
|
||||
"""Generate recommendations based on failures"""
|
||||
recommendations = []
|
||||
|
||||
failure_types = {}
|
||||
for f in self.failures:
|
||||
failure_types[f.test_name] = failure_types.get(f.test_name, 0) + 1
|
||||
|
||||
if "single_monitor_edges" in failure_types:
|
||||
recommendations.append(
|
||||
"Single monitor edge detection failing - verify baseline case in MonitorTopology::_detect_outer_edges()"
|
||||
)
|
||||
|
||||
if "touching_monitors" in failure_types:
|
||||
recommendations.append(
|
||||
f"Adjacent monitor detection failing ({failure_types['touching_monitors']} cases) - "
|
||||
"review ADJACENCY_TOLERANCE (50px) and edge overlap logic in EdgesAreAdjacent()"
|
||||
)
|
||||
|
||||
if "wrap_calculation" in failure_types:
|
||||
recommendations.append(
|
||||
f"Wrap calculation failing ({failure_types['wrap_calculation']} cases) - "
|
||||
"review CursorWrapCore::HandleMouseMove() wrap destination logic"
|
||||
)
|
||||
|
||||
if not recommendations:
|
||||
recommendations.append("All tests passed - edge detection logic is working correctly!")
|
||||
|
||||
return recommendations
|
||||
|
||||
# ============================================================================
|
||||
# Main Entry Point
|
||||
# ============================================================================
|
||||
|
||||
# ============================================================================
|
||||
# Main Entry Point
|
||||
# ============================================================================
|
||||
|
||||
def main():
|
||||
"""Main entry point"""
|
||||
import argparse
|
||||
|
||||
parser = argparse.ArgumentParser(
|
||||
description="CursorWrap Monitor Layout Edge Detection Test Suite"
|
||||
)
|
||||
parser.add_argument(
|
||||
"--max-monitors",
|
||||
type=int,
|
||||
default=10,
|
||||
help="Maximum number of monitors to test (1-10)"
|
||||
)
|
||||
parser.add_argument(
|
||||
"--verbose",
|
||||
action="store_true",
|
||||
help="Enable verbose output"
|
||||
)
|
||||
parser.add_argument(
|
||||
"--layout-file",
|
||||
type=str,
|
||||
help="Use captured monitor layout JSON file instead of generated configs"
|
||||
)
|
||||
|
||||
args = parser.parse_args()
|
||||
|
||||
if not args.layout_file:
|
||||
# Validate max_monitors only for generated configs
|
||||
if args.max_monitors < 1 or args.max_monitors > 10:
|
||||
print("Error: max-monitors must be between 1 and 10")
|
||||
sys.exit(1)
|
||||
|
||||
runner = TestRunner(
|
||||
max_monitors=args.max_monitors,
|
||||
verbose=args.verbose,
|
||||
layout_file=args.layout_file
|
||||
)
|
||||
runner.run_all_tests()
|
||||
|
||||
# Exit with error code if tests failed
|
||||
sys.exit(0 if not runner.failures else 1)
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -506,8 +506,58 @@ public:
|
||||
return CallNextHookEx(nullptr, nCode, wParam, lParam);
|
||||
}
|
||||
|
||||
// Helper method to check if there's a monitor adjacent in coordinate space (not grid)
|
||||
bool HasAdjacentMonitorInCoordinateSpace(const RECT& currentMonitorRect, int direction)
|
||||
{
|
||||
// direction: 0=left, 1=right, 2=top, 3=bottom
|
||||
const int tolerance = 50; // Allow small gaps
|
||||
|
||||
for (const auto& monitor : m_monitors)
|
||||
{
|
||||
bool isAdjacent = false;
|
||||
|
||||
switch (direction)
|
||||
{
|
||||
case 0: // Left - check if another monitor's right edge touches/overlaps our left edge
|
||||
isAdjacent = (abs(monitor.rect.right - currentMonitorRect.left) <= tolerance) &&
|
||||
(monitor.rect.bottom > currentMonitorRect.top + tolerance) &&
|
||||
(monitor.rect.top < currentMonitorRect.bottom - tolerance);
|
||||
break;
|
||||
|
||||
case 1: // Right - check if another monitor's left edge touches/overlaps our right edge
|
||||
isAdjacent = (abs(monitor.rect.left - currentMonitorRect.right) <= tolerance) &&
|
||||
(monitor.rect.bottom > currentMonitorRect.top + tolerance) &&
|
||||
(monitor.rect.top < currentMonitorRect.bottom - tolerance);
|
||||
break;
|
||||
|
||||
case 2: // Top - check if another monitor's bottom edge touches/overlaps our top edge
|
||||
isAdjacent = (abs(monitor.rect.bottom - currentMonitorRect.top) <= tolerance) &&
|
||||
(monitor.rect.right > currentMonitorRect.left + tolerance) &&
|
||||
(monitor.rect.left < currentMonitorRect.right - tolerance);
|
||||
break;
|
||||
|
||||
case 3: // Bottom - check if another monitor's top edge touches/overlaps our bottom edge
|
||||
isAdjacent = (abs(monitor.rect.top - currentMonitorRect.bottom) <= tolerance) &&
|
||||
(monitor.rect.right > currentMonitorRect.left + tolerance) &&
|
||||
(monitor.rect.left < currentMonitorRect.right - tolerance);
|
||||
break;
|
||||
}
|
||||
|
||||
if (isAdjacent)
|
||||
{
|
||||
#ifdef _DEBUG
|
||||
Logger::info(L"CursorWrap DEBUG: Found adjacent monitor in coordinate space (direction {})", direction);
|
||||
#endif
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
// *** COMPLETELY REWRITTEN CURSOR WRAPPING LOGIC ***
|
||||
// Implements vertical scrolling to bottom/top of vertical stack as requested
|
||||
// Only wraps when there's NO adjacent monitor in the coordinate space
|
||||
POINT HandleMouseMove(const POINT& currentPos)
|
||||
{
|
||||
POINT newPos = currentPos;
|
||||
@@ -546,12 +596,22 @@ public:
|
||||
|
||||
// *** VERTICAL WRAPPING LOGIC - CONFIRMED WORKING ***
|
||||
// Move to bottom of vertical stack when hitting top edge
|
||||
// Only wrap if there's NO adjacent monitor in the coordinate space
|
||||
if (currentPos.y <= currentMonitorInfo.rcMonitor.top)
|
||||
{
|
||||
#ifdef _DEBUG
|
||||
Logger::info(L"CursorWrap DEBUG: ======= VERTICAL WRAP: TOP EDGE DETECTED =======");
|
||||
#endif
|
||||
|
||||
// Check if there's an adjacent monitor above in coordinate space
|
||||
if (HasAdjacentMonitorInCoordinateSpace(currentMonitorInfo.rcMonitor, 2))
|
||||
{
|
||||
#ifdef _DEBUG
|
||||
Logger::info(L"CursorWrap DEBUG: SKIPPING WRAP - Adjacent monitor exists above (Windows will handle)");
|
||||
#endif
|
||||
return currentPos; // Let Windows handle natural cursor movement
|
||||
}
|
||||
|
||||
// Find the bottom-most monitor in the vertical stack (same column)
|
||||
HMONITOR bottomMonitor = nullptr;
|
||||
|
||||
@@ -604,6 +664,15 @@ public:
|
||||
Logger::info(L"CursorWrap DEBUG: ======= VERTICAL WRAP: BOTTOM EDGE DETECTED =======");
|
||||
#endif
|
||||
|
||||
// Check if there's an adjacent monitor below in coordinate space
|
||||
if (HasAdjacentMonitorInCoordinateSpace(currentMonitorInfo.rcMonitor, 3))
|
||||
{
|
||||
#ifdef _DEBUG
|
||||
Logger::info(L"CursorWrap DEBUG: SKIPPING WRAP - Adjacent monitor exists below (Windows will handle)");
|
||||
#endif
|
||||
return currentPos; // Let Windows handle natural cursor movement
|
||||
}
|
||||
|
||||
// Find the top-most monitor in the vertical stack (same column)
|
||||
HMONITOR topMonitor = nullptr;
|
||||
|
||||
@@ -653,13 +722,22 @@ public:
|
||||
|
||||
// *** FIXED HORIZONTAL WRAPPING LOGIC ***
|
||||
// Move to opposite end of horizontal stack when hitting left/right edge
|
||||
// Only handle horizontal wrapping if we haven't already wrapped vertically
|
||||
// Only wrap if there's NO adjacent monitor in the coordinate space (let Windows handle natural transitions)
|
||||
if (!wrapped && currentPos.x <= currentMonitorInfo.rcMonitor.left)
|
||||
{
|
||||
#ifdef _DEBUG
|
||||
Logger::info(L"CursorWrap DEBUG: ======= HORIZONTAL WRAP: LEFT EDGE DETECTED =======");
|
||||
#endif
|
||||
|
||||
// Check if there's an adjacent monitor to the left in coordinate space
|
||||
if (HasAdjacentMonitorInCoordinateSpace(currentMonitorInfo.rcMonitor, 0))
|
||||
{
|
||||
#ifdef _DEBUG
|
||||
Logger::info(L"CursorWrap DEBUG: SKIPPING WRAP - Adjacent monitor exists to the left (Windows will handle)");
|
||||
#endif
|
||||
return currentPos; // Let Windows handle natural cursor movement
|
||||
}
|
||||
|
||||
// Find the right-most monitor in the horizontal stack (same row)
|
||||
HMONITOR rightMonitor = nullptr;
|
||||
|
||||
@@ -712,6 +790,15 @@ public:
|
||||
Logger::info(L"CursorWrap DEBUG: ======= HORIZONTAL WRAP: RIGHT EDGE DETECTED =======");
|
||||
#endif
|
||||
|
||||
// Check if there's an adjacent monitor to the right in coordinate space
|
||||
if (HasAdjacentMonitorInCoordinateSpace(currentMonitorInfo.rcMonitor, 1))
|
||||
{
|
||||
#ifdef _DEBUG
|
||||
Logger::info(L"CursorWrap DEBUG: SKIPPING WRAP - Adjacent monitor exists to the right (Windows will handle)");
|
||||
#endif
|
||||
return currentPos; // Let Windows handle natural cursor movement
|
||||
}
|
||||
|
||||
// Find the left-most monitor in the horizontal stack (same row)
|
||||
HMONITOR leftMonitor = nullptr;
|
||||
|
||||
@@ -981,45 +1068,104 @@ void MonitorTopology::Initialize(const std::vector<MonitorInfo>& monitors)
|
||||
}
|
||||
else
|
||||
{
|
||||
// For more than 2 monitors, use the general algorithm
|
||||
RECT totalBounds = monitors[0].rect;
|
||||
for (const auto& monitor : monitors)
|
||||
{
|
||||
totalBounds.left = min(totalBounds.left, monitor.rect.left);
|
||||
totalBounds.top = min(totalBounds.top, monitor.rect.top);
|
||||
totalBounds.right = max(totalBounds.right, monitor.rect.right);
|
||||
totalBounds.bottom = max(totalBounds.bottom, monitor.rect.bottom);
|
||||
// For more than 2 monitors, use edge-based alignment algorithm
|
||||
// This ensures monitors with aligned edges (e.g., top edges at same Y) are grouped in same row
|
||||
|
||||
// Helper lambda to check if two ranges overlap or are adjacent (with tolerance)
|
||||
auto rangesOverlapOrTouch = [](int start1, int end1, int start2, int end2, int tolerance = 50) -> bool {
|
||||
// Check if ranges overlap or are within tolerance distance
|
||||
return (start1 <= end2 + tolerance) && (start2 <= end1 + tolerance);
|
||||
};
|
||||
|
||||
// Sort monitors by horizontal position (left edge) for column assignment
|
||||
std::vector<const MonitorInfo*> monitorsByX;
|
||||
for (const auto& monitor : monitors) {
|
||||
monitorsByX.push_back(&monitor);
|
||||
}
|
||||
std::sort(monitorsByX.begin(), monitorsByX.end(), [](const MonitorInfo* a, const MonitorInfo* b) {
|
||||
return a->rect.left < b->rect.left;
|
||||
});
|
||||
|
||||
// Sort monitors by vertical position (top edge) for row assignment
|
||||
std::vector<const MonitorInfo*> monitorsByY;
|
||||
for (const auto& monitor : monitors) {
|
||||
monitorsByY.push_back(&monitor);
|
||||
}
|
||||
std::sort(monitorsByY.begin(), monitorsByY.end(), [](const MonitorInfo* a, const MonitorInfo* b) {
|
||||
return a->rect.top < b->rect.top;
|
||||
});
|
||||
|
||||
// Assign rows based on vertical overlap - monitors that overlap vertically should be in same row
|
||||
std::map<const MonitorInfo*, int> monitorToRow;
|
||||
int currentRow = 0;
|
||||
|
||||
for (size_t i = 0; i < monitorsByY.size(); i++) {
|
||||
const auto* monitor = monitorsByY[i];
|
||||
|
||||
// Check if this monitor overlaps vertically with any monitor already assigned to current row
|
||||
bool foundOverlap = false;
|
||||
for (size_t j = 0; j < i; j++) {
|
||||
const auto* other = monitorsByY[j];
|
||||
if (monitorToRow[other] == currentRow) {
|
||||
// Check vertical overlap
|
||||
if (rangesOverlapOrTouch(monitor->rect.top, monitor->rect.bottom,
|
||||
other->rect.top, other->rect.bottom)) {
|
||||
monitorToRow[monitor] = currentRow;
|
||||
foundOverlap = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!foundOverlap) {
|
||||
// Start new row if no overlap found and we have room
|
||||
if (currentRow < 2 && i < monitorsByY.size() - 1) {
|
||||
currentRow++;
|
||||
}
|
||||
monitorToRow[monitor] = currentRow;
|
||||
}
|
||||
}
|
||||
|
||||
int totalWidth = totalBounds.right - totalBounds.left;
|
||||
int totalHeight = totalBounds.bottom - totalBounds.top;
|
||||
int gridWidth = max(1, totalWidth / 3);
|
||||
int gridHeight = max(1, totalHeight / 3);
|
||||
// Assign columns based on horizontal position (left-to-right order)
|
||||
// Monitors are already sorted by X coordinate (left edge)
|
||||
std::map<const MonitorInfo*, int> monitorToCol;
|
||||
|
||||
// Place monitors in the 3x3 grid based on their center points
|
||||
// For horizontal arrangement, distribute monitors evenly across columns
|
||||
if (monitorsByX.size() == 1) {
|
||||
// Single monitor - place in middle column
|
||||
monitorToCol[monitorsByX[0]] = 1;
|
||||
}
|
||||
else if (monitorsByX.size() == 2) {
|
||||
// Two monitors - place at opposite ends for wrapping
|
||||
monitorToCol[monitorsByX[0]] = 0; // Leftmost monitor
|
||||
monitorToCol[monitorsByX[1]] = 2; // Rightmost monitor
|
||||
}
|
||||
else {
|
||||
// Three or more monitors - distribute across grid
|
||||
for (size_t i = 0; i < monitorsByX.size() && i < 3; i++) {
|
||||
monitorToCol[monitorsByX[i]] = static_cast<int>(i);
|
||||
}
|
||||
// If more than 3 monitors, place extras in rightmost column
|
||||
for (size_t i = 3; i < monitorsByX.size(); i++) {
|
||||
monitorToCol[monitorsByX[i]] = 2;
|
||||
}
|
||||
}
|
||||
|
||||
// Place monitors in grid using the computed row/column assignments
|
||||
for (const auto& monitor : monitors)
|
||||
{
|
||||
HMONITOR hMonitor = MonitorFromRect(&monitor.rect, MONITOR_DEFAULTTONEAREST);
|
||||
|
||||
// Calculate center point of monitor
|
||||
int centerX = (monitor.rect.left + monitor.rect.right) / 2;
|
||||
int centerY = (monitor.rect.top + monitor.rect.bottom) / 2;
|
||||
|
||||
// Map to grid position
|
||||
int col = (centerX - totalBounds.left) / gridWidth;
|
||||
int row = (centerY - totalBounds.top) / gridHeight;
|
||||
|
||||
// Ensure we stay within bounds
|
||||
col = max(0, min(2, col));
|
||||
row = max(0, min(2, row));
|
||||
int row = monitorToRow[&monitor];
|
||||
int col = monitorToCol[&monitor];
|
||||
|
||||
grid[row][col] = hMonitor;
|
||||
monitorToPosition[hMonitor] = {row, col, true};
|
||||
positionToMonitor[{row, col}] = hMonitor;
|
||||
|
||||
#ifdef _DEBUG
|
||||
Logger::info(L"CursorWrap DEBUG: Monitor {} placed at grid[{}][{}], center=({}, {})",
|
||||
monitor.monitorId, row, col, centerX, centerY);
|
||||
Logger::info(L"CursorWrap DEBUG: Monitor {} placed at grid[{}][{}] (left={}, top={}, right={}, bottom={})",
|
||||
monitor.monitorId, row, col,
|
||||
monitor.rect.left, monitor.rect.top, monitor.rect.right, monitor.rect.bottom);
|
||||
#endif
|
||||
}
|
||||
}
|
||||
|
||||
@@ -21,7 +21,7 @@ public sealed partial class BuiltInsCommandProvider : CommandProvider
|
||||
public override ICommandItem[] TopLevelCommands() =>
|
||||
[
|
||||
new CommandItem(openSettings) { },
|
||||
new CommandItem(_newExtension) { Title = _newExtension.Title, Subtitle = Properties.Resources.builtin_new_extension_subtitle },
|
||||
new CommandItem(_newExtension) { Title = _newExtension.Title },
|
||||
];
|
||||
|
||||
public override IFallbackCommandItem[] FallbackCommands() =>
|
||||
|
||||
@@ -134,6 +134,9 @@
|
||||
ItemsSource="{x:Bind ViewModel.FilteredItems, Mode=OneWay}"
|
||||
PreviewKeyDown="CommandsDropdown_PreviewKeyDown"
|
||||
SelectionMode="Single">
|
||||
<ListView.Resources>
|
||||
<x:Boolean x:Key="ListViewItemSelectionIndicatorVisualEnabled">False</x:Boolean>
|
||||
</ListView.Resources>
|
||||
<ListView.ItemContainerStyle>
|
||||
<Style BasedOn="{StaticResource DefaultListViewItemStyle}" TargetType="ListViewItem">
|
||||
<Setter Property="MinHeight" Value="0" />
|
||||
|
||||
@@ -99,8 +99,6 @@ public sealed partial class WrapPanel : Panel
|
||||
set { SetValue(HorizontalSpacingProperty, value); }
|
||||
}
|
||||
|
||||
private bool IsSectionItem(UIElement element) => element is FrameworkElement fe && fe.DataContext is ListItemViewModel item && item.IsSectionOrSeparator;
|
||||
|
||||
/// <summary>
|
||||
/// Identifies the <see cref="HorizontalSpacing"/> dependency property.
|
||||
/// </summary>
|
||||
@@ -350,7 +348,7 @@ public sealed partial class WrapPanel : Panel
|
||||
return;
|
||||
}
|
||||
|
||||
var isFullLine = IsSectionItem(child);
|
||||
var isFullLine = GetIsFullLine(child);
|
||||
var desiredMeasure = new UvMeasure(Orientation, child.DesiredSize);
|
||||
|
||||
if (isFullLine)
|
||||
|
||||
@@ -18,8 +18,19 @@ internal sealed partial class GridItemContainerStyleSelector : StyleSelector
|
||||
|
||||
public Style? Gallery { get; set; }
|
||||
|
||||
public Style? Section { get; set; }
|
||||
|
||||
public Style? Separator { get; set; }
|
||||
|
||||
protected override Style? SelectStyleCore(object item, DependencyObject container)
|
||||
{
|
||||
if (item is ListItemViewModel { IsSectionOrSeparator: true } listItem)
|
||||
{
|
||||
return string.IsNullOrWhiteSpace(listItem.Title)
|
||||
? Separator!
|
||||
: Section;
|
||||
}
|
||||
|
||||
return GridProperties switch
|
||||
{
|
||||
SmallGridPropertiesViewModel => Small,
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
// Copyright (c) Microsoft Corporation
|
||||
// The Microsoft Corporation licenses this file to you under the MIT license.
|
||||
// See the LICENSE file in the project root for more information.
|
||||
|
||||
using Microsoft.CmdPal.Core.ViewModels;
|
||||
using Microsoft.UI.Xaml;
|
||||
using Microsoft.UI.Xaml.Controls;
|
||||
|
||||
namespace Microsoft.CmdPal.UI;
|
||||
|
||||
internal sealed partial class ListItemContainerStyleSelector : StyleSelector
|
||||
{
|
||||
public Style? Default { get; set; }
|
||||
|
||||
public Style? Section { get; set; }
|
||||
|
||||
public Style? Separator { get; set; }
|
||||
|
||||
protected override Style? SelectStyleCore(object item, DependencyObject container)
|
||||
{
|
||||
return item switch
|
||||
{
|
||||
ListItemViewModel { IsSectionOrSeparator: true } listItemViewModel when string.IsNullOrWhiteSpace(listItemViewModel.Title) => Separator!,
|
||||
ListItemViewModel { IsSectionOrSeparator: true } => Section,
|
||||
_ => Default,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -27,6 +27,10 @@
|
||||
<CornerRadius x:Key="SmallGridViewItemCornerRadius">8</CornerRadius>
|
||||
<CornerRadius x:Key="MediumGridViewItemCornerRadius">8</CornerRadius>
|
||||
|
||||
<x:Double x:Key="ListViewItemMinHeight">40</x:Double>
|
||||
<x:Double x:Key="ListViewSectionMinHeight">0</x:Double>
|
||||
<x:Double x:Key="ListViewSeparatorMinHeight">0</x:Double>
|
||||
|
||||
<Style x:Key="IconGridViewItemStyle" TargetType="GridViewItem">
|
||||
<Setter Property="HorizontalContentAlignment" Value="Stretch" />
|
||||
<Setter Property="VerticalContentAlignment" Value="Center" />
|
||||
@@ -94,6 +98,7 @@
|
||||
<Style x:Key="GalleryGridViewItemStyle" TargetType="GridViewItem">
|
||||
<Setter Property="HorizontalContentAlignment" Value="Stretch" />
|
||||
<Setter Property="VerticalContentAlignment" Value="Center" />
|
||||
<Setter Property="Margin" Value="0" />
|
||||
<Setter Property="Template">
|
||||
<Setter.Value>
|
||||
<ControlTemplate TargetType="GridViewItem">
|
||||
@@ -155,6 +160,70 @@
|
||||
</Setter>
|
||||
</Style>
|
||||
|
||||
<Style
|
||||
x:Key="GridViewSectionItemStyle"
|
||||
BasedOn="{StaticResource DefaultGridViewItemStyle}"
|
||||
TargetType="GridViewItem">
|
||||
<Setter Property="IsHitTestVisible" Value="False" />
|
||||
<Setter Property="IsTabStop" Value="False" />
|
||||
<Setter Property="IsHoldingEnabled" Value="False" />
|
||||
<Setter Property="Padding" Value="4,8,12,0" />
|
||||
<Setter Property="Margin" Value="0" />
|
||||
<Setter Property="HorizontalContentAlignment" Value="Stretch" />
|
||||
<Setter Property="VerticalContentAlignment" Value="Bottom" />
|
||||
<Setter Property="MinHeight" Value="{StaticResource ListViewSectionMinHeight}" />
|
||||
<Setter Property="cpcontrols:WrapPanel.IsFullLine" Value="True" />
|
||||
</Style>
|
||||
|
||||
<Style
|
||||
x:Key="GridViewSeparatorItemStyle"
|
||||
BasedOn="{StaticResource DefaultGridViewItemStyle}"
|
||||
TargetType="GridViewItem">
|
||||
<Setter Property="IsHitTestVisible" Value="False" />
|
||||
<Setter Property="IsTabStop" Value="False" />
|
||||
<Setter Property="IsHoldingEnabled" Value="False" />
|
||||
<Setter Property="Padding" Value="4,4,12,4" />
|
||||
<Setter Property="Margin" Value="0" />
|
||||
<Setter Property="HorizontalContentAlignment" Value="Stretch" />
|
||||
<Setter Property="VerticalContentAlignment" Value="Center" />
|
||||
<Setter Property="MinHeight" Value="{StaticResource ListViewSeparatorMinHeight}" />
|
||||
<Setter Property="cpcontrols:WrapPanel.IsFullLine" Value="True" />
|
||||
</Style>
|
||||
|
||||
<Style
|
||||
x:Key="ListDefaultContainerStyle"
|
||||
BasedOn="{StaticResource DefaultListViewItemStyle}"
|
||||
TargetType="ListViewItem">
|
||||
<Setter Property="MinHeight" Value="{StaticResource ListViewItemMinHeight}" />
|
||||
</Style>
|
||||
|
||||
<Style
|
||||
x:Key="ListSectionContainerStyle"
|
||||
BasedOn="{StaticResource DefaultListViewItemStyle}"
|
||||
TargetType="ListViewItem">
|
||||
<Setter Property="IsHitTestVisible" Value="False" />
|
||||
<Setter Property="IsTabStop" Value="False" />
|
||||
<Setter Property="IsHoldingEnabled" Value="False" />
|
||||
<Setter Property="Padding" Value="16,8,12,0" />
|
||||
<Setter Property="HorizontalContentAlignment" Value="Stretch" />
|
||||
<Setter Property="VerticalContentAlignment" Value="Bottom" />
|
||||
<Setter Property="MinHeight" Value="{StaticResource ListViewSectionMinHeight}" />
|
||||
<Setter Property="AllowDrop" Value="False" />
|
||||
</Style>
|
||||
|
||||
<Style
|
||||
x:Key="ListSeparatorContainerStyle"
|
||||
BasedOn="{StaticResource DefaultListViewItemStyle}"
|
||||
TargetType="ListViewItem">
|
||||
<Setter Property="IsHitTestVisible" Value="False" />
|
||||
<Setter Property="IsTabStop" Value="False" />
|
||||
<Setter Property="IsHoldingEnabled" Value="False" />
|
||||
<Setter Property="Padding" Value="16,4,12,4" />
|
||||
<Setter Property="HorizontalContentAlignment" Value="Stretch" />
|
||||
<Setter Property="VerticalContentAlignment" Value="Center" />
|
||||
<Setter Property="MinHeight" Value="{StaticResource ListViewSeparatorMinHeight}" />
|
||||
</Style>
|
||||
|
||||
<DataTemplate x:Key="TagTemplate" x:DataType="coreViewModels:TagViewModel">
|
||||
<cpcontrols:Tag
|
||||
AutomationProperties.Name="{x:Bind Text, Mode=OneWay}"
|
||||
@@ -166,16 +235,6 @@
|
||||
ToolTipService.ToolTip="{x:Bind ToolTip, Mode=OneWay}" />
|
||||
</DataTemplate>
|
||||
|
||||
<cmdpalUI:GridItemTemplateSelector
|
||||
x:Key="GridItemTemplateSelector"
|
||||
x:DataType="coreViewModels:ListItemViewModel"
|
||||
Gallery="{StaticResource GalleryGridItemViewModelTemplate}"
|
||||
GridProperties="{x:Bind ViewModel.GridProperties, Mode=OneWay}"
|
||||
Medium="{StaticResource MediumGridItemViewModelTemplate}"
|
||||
Section="{StaticResource ListSectionViewModelTemplate}"
|
||||
Separator="{StaticResource ListSeparatorViewModelTemplate}"
|
||||
Small="{StaticResource SmallGridItemViewModelTemplate}" />
|
||||
|
||||
<cmdpalUI:ListItemTemplateSelector
|
||||
x:Key="ListItemTemplateSelector"
|
||||
x:DataType="coreViewModels:ListItemViewModel"
|
||||
@@ -183,11 +242,29 @@
|
||||
Section="{StaticResource ListSectionViewModelTemplate}"
|
||||
Separator="{StaticResource ListSeparatorViewModelTemplate}" />
|
||||
|
||||
<cmdpalUI:ListItemContainerStyleSelector
|
||||
x:Key="ListItemContainerStyleSelector"
|
||||
Default="{StaticResource ListDefaultContainerStyle}"
|
||||
Section="{StaticResource ListSectionContainerStyle}"
|
||||
Separator="{StaticResource ListSeparatorContainerStyle}" />
|
||||
|
||||
<cmdpalUI:GridItemTemplateSelector
|
||||
x:Key="GridItemTemplateSelector"
|
||||
x:DataType="coreViewModels:ListItemViewModel"
|
||||
Gallery="{StaticResource GalleryGridItemViewModelTemplate}"
|
||||
GridProperties="{x:Bind ViewModel.GridProperties, Mode=OneWay}"
|
||||
Medium="{StaticResource MediumGridItemViewModelTemplate}"
|
||||
Section="{StaticResource ListSectionViewModelTemplate}"
|
||||
Separator="{StaticResource GridSeparatorViewModelTemplate}"
|
||||
Small="{StaticResource SmallGridItemViewModelTemplate}" />
|
||||
|
||||
<cmdpalUI:GridItemContainerStyleSelector
|
||||
x:Key="GridItemContainerStyleSelector"
|
||||
Gallery="{StaticResource GalleryGridViewItemStyle}"
|
||||
GridProperties="{x:Bind ViewModel.GridProperties, Mode=OneWay}"
|
||||
Medium="{StaticResource IconGridViewItemStyle}"
|
||||
Section="{StaticResource GridViewSectionItemStyle}"
|
||||
Separator="{StaticResource GridViewSeparatorItemStyle}"
|
||||
Small="{StaticResource IconGridViewItemStyle}" />
|
||||
|
||||
<!-- https://learn.microsoft.com/windows/apps/design/controls/itemsview#specify-the-look-of-the-items -->
|
||||
@@ -255,21 +332,21 @@
|
||||
</DataTemplate>
|
||||
|
||||
<DataTemplate x:Key="ListSeparatorViewModelTemplate" x:DataType="coreViewModels:ListItemViewModel">
|
||||
<Grid>
|
||||
<Grid ColumnSpacing="12">
|
||||
<Grid.ColumnDefinitions>
|
||||
<ColumnDefinition Width="28" />
|
||||
<ColumnDefinition Width="*" />
|
||||
</Grid.ColumnDefinitions>
|
||||
<Rectangle
|
||||
Grid.Column="1"
|
||||
Height="1"
|
||||
Margin="0,2,0,2"
|
||||
Fill="{ThemeResource DividerStrokeColorDefaultBrush}" />
|
||||
</Grid>
|
||||
</DataTemplate>
|
||||
|
||||
<DataTemplate x:Key="ListSectionViewModelTemplate" x:DataType="coreViewModels:ListItemViewModel">
|
||||
<Grid
|
||||
Margin="0"
|
||||
Margin="0,8,0,0"
|
||||
VerticalAlignment="Center"
|
||||
cpcontrols:WrapPanel.IsFullLine="True"
|
||||
ColumnSpacing="8"
|
||||
@@ -281,13 +358,9 @@
|
||||
</Grid.ColumnDefinitions>
|
||||
<TextBlock
|
||||
Grid.Column="0"
|
||||
Foreground="{ThemeResource TextFillColorDisabled}"
|
||||
Foreground="{ThemeResource TextFillColorSecondaryBrush}"
|
||||
Style="{ThemeResource CaptionTextBlockStyle}"
|
||||
Text="{x:Bind Section}" />
|
||||
<Rectangle
|
||||
Grid.Column="1"
|
||||
Height="1"
|
||||
Fill="{ThemeResource DividerStrokeColorDefaultBrush}" />
|
||||
</Grid>
|
||||
</DataTemplate>
|
||||
|
||||
@@ -414,7 +487,7 @@
|
||||
VerticalAlignment="Center"
|
||||
CharacterSpacing="11"
|
||||
FontSize="11"
|
||||
Foreground="{ThemeResource TextFillColorTertiary}"
|
||||
Foreground="{ThemeResource TextFillColorSecondaryBrush}"
|
||||
Text="{x:Bind Subtitle, Mode=OneWay}"
|
||||
TextAlignment="Center"
|
||||
TextTrimming="WordEllipsis"
|
||||
@@ -423,6 +496,10 @@
|
||||
</StackPanel>
|
||||
</StackPanel>
|
||||
</DataTemplate>
|
||||
|
||||
<DataTemplate x:Key="GridSeparatorViewModelTemplate" x:DataType="coreViewModels:ListItemViewModel">
|
||||
<Rectangle Height="1" Fill="{ThemeResource DividerStrokeColorDefaultBrush}" />
|
||||
</DataTemplate>
|
||||
</Page.Resources>
|
||||
|
||||
<Grid>
|
||||
@@ -448,6 +525,7 @@
|
||||
IsDoubleTapEnabled="True"
|
||||
IsItemClickEnabled="True"
|
||||
ItemClick="Items_ItemClick"
|
||||
ItemContainerStyleSelector="{StaticResource ListItemContainerStyleSelector}"
|
||||
ItemTemplateSelector="{StaticResource ListItemTemplateSelector}"
|
||||
ItemsSource="{x:Bind ViewModel.FilteredItems, Mode=OneWay}"
|
||||
RightTapped="Items_RightTapped"
|
||||
@@ -460,7 +538,7 @@
|
||||
<controls:Case Value="True">
|
||||
<GridView
|
||||
x:Name="ItemsGrid"
|
||||
Padding="16,0"
|
||||
Padding="16,16"
|
||||
CanDragItems="True"
|
||||
ContextCanceled="Items_OnContextCanceled"
|
||||
ContextRequested="Items_OnContextRequested"
|
||||
@@ -477,7 +555,10 @@
|
||||
SelectionChanged="Items_SelectionChanged">
|
||||
<GridView.ItemsPanel>
|
||||
<ItemsPanelTemplate>
|
||||
<cpcontrols:WrapPanel HorizontalSpacing="8" Orientation="Horizontal" />
|
||||
<cpcontrols:WrapPanel
|
||||
HorizontalSpacing="8"
|
||||
Orientation="Horizontal"
|
||||
VerticalSpacing="8" />
|
||||
</ItemsPanelTemplate>
|
||||
</GridView.ItemsPanel>
|
||||
<GridView.ItemContainerTransitions>
|
||||
|
||||
@@ -18,6 +18,9 @@ internal static class BindTransformers
|
||||
public static Visibility EmptyOrWhitespaceToCollapsed(string? input)
|
||||
=> string.IsNullOrWhiteSpace(input) ? Visibility.Collapsed : Visibility.Visible;
|
||||
|
||||
public static Visibility EmptyOrWhitespaceToVisible(string? input)
|
||||
=> string.IsNullOrWhiteSpace(input) ? Visibility.Visible : Visibility.Collapsed;
|
||||
|
||||
public static Visibility VisibleWhenAny(bool value1, bool value2)
|
||||
=> (value1 || value2) ? Visibility.Visible : Visibility.Collapsed;
|
||||
}
|
||||
|
||||
@@ -126,16 +126,16 @@
|
||||
</DataTemplate>
|
||||
<DataTemplate x:Key="DetailsSeparatorTemplate" x:DataType="coreViewModels:DetailsSeparatorViewModel">
|
||||
<StackPanel Margin="0,8,8,0" Orientation="Vertical">
|
||||
<TextBlock
|
||||
Margin="0,8,0,0"
|
||||
Style="{StaticResource SeparatorKeyTextBlockStyle}"
|
||||
Text="{x:Bind Key, Mode=OneWay}"
|
||||
Visibility="{x:Bind help:BindTransformers.EmptyOrWhitespaceToCollapsed(Key), Mode=OneWay, FallbackValue=Collapsed}" />
|
||||
<Border
|
||||
Margin="0,0,0,0"
|
||||
BorderBrush="{ThemeResource DividerStrokeColorDefaultBrush}"
|
||||
BorderThickness="0,0,0,2">
|
||||
<TextBlock
|
||||
Margin="0,0,0,0"
|
||||
Style="{StaticResource SeparatorKeyTextBlockStyle}"
|
||||
Text="{x:Bind Key, Mode=OneWay}"
|
||||
Visibility="{x:Bind help:BindTransformers.EmptyOrWhitespaceToCollapsed(Key), FallbackValue=Collapsed}" />
|
||||
</Border>
|
||||
BorderThickness="0,0,0,1"
|
||||
Visibility="{x:Bind help:BindTransformers.EmptyOrWhitespaceToVisible(Key), Mode=OneWay, FallbackValue=Collapsed}" />
|
||||
</StackPanel>
|
||||
</DataTemplate>
|
||||
<DataTemplate x:Key="DetailsTagsTemplate" x:DataType="coreViewModels:DetailsTagsViewModel">
|
||||
|
||||
@@ -2,6 +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.Diagnostics;
|
||||
using System.Globalization;
|
||||
using ManagedCommon;
|
||||
using Microsoft.CmdPal.UI.Helpers;
|
||||
using Microsoft.CmdPal.UI.ViewModels;
|
||||
using Microsoft.CmdPal.UI.ViewModels.Services;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
@@ -30,8 +34,56 @@ public sealed partial class GeneralPage : Page
|
||||
{
|
||||
get
|
||||
{
|
||||
var version = Package.Current.Id.Version;
|
||||
return $"Version {version.Major}.{version.Minor}.{version.Build}.{version.Revision}";
|
||||
var versionNo = ResourceLoaderInstance.GetString("Settings_GeneralPage_VersionNo");
|
||||
if (!TryGetPackagedVersion(out var version) && !TryGetAssemblyVersion(out version))
|
||||
{
|
||||
version = "?";
|
||||
}
|
||||
|
||||
return string.Format(CultureInfo.CurrentCulture, versionNo, version);
|
||||
}
|
||||
}
|
||||
|
||||
private static bool TryGetPackagedVersion(out string version)
|
||||
{
|
||||
version = string.Empty;
|
||||
try
|
||||
{
|
||||
// Package.Current throws InvalidOperationException if the app is not packaged
|
||||
var v = Package.Current.Id.Version;
|
||||
version = $"{v.Major}.{v.Minor}.{v.Build}.{v.Revision}";
|
||||
return true;
|
||||
}
|
||||
catch (InvalidOperationException)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Logger.LogError("Failed to get version from the package", ex);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private static bool TryGetAssemblyVersion(out string version)
|
||||
{
|
||||
version = string.Empty;
|
||||
try
|
||||
{
|
||||
var processPath = Environment.ProcessPath;
|
||||
if (string.IsNullOrEmpty(processPath))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var info = FileVersionInfo.GetVersionInfo(processPath);
|
||||
version = $"{info.FileMajorPart}.{info.FileMinorPart}.{info.FileBuildPart}.{info.FilePrivatePart}";
|
||||
return true;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Logger.LogError("Failed to get version from the executable", ex);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -724,4 +724,7 @@ Right-click to remove the key combination, thereby deactivating the shortcut.</v
|
||||
<data name="Settings_ExtensionsPage_More_ReorderFallbacks_MenuFlyoutItem.Text" xml:space="preserve">
|
||||
<value>Manage fallback order</value>
|
||||
</data>
|
||||
<data name="Settings_GeneralPage_VersionNo" xml:space="preserve">
|
||||
<value>Version {0}</value>
|
||||
</data>
|
||||
</root>
|
||||
@@ -90,20 +90,5 @@ namespace Microsoft.CmdPal.Ext.TimeDate.UnitTests
|
||||
// Assert
|
||||
Assert.IsFalse(string.IsNullOrEmpty(displayName));
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void GetTranslatedPluginDescriptionTest()
|
||||
{
|
||||
// Setup
|
||||
var provider = new TimeDateCommandsProvider();
|
||||
|
||||
// Act
|
||||
var commands = provider.TopLevelCommands();
|
||||
var subtitle = commands[0].Subtitle;
|
||||
|
||||
// Assert
|
||||
Assert.IsFalse(string.IsNullOrEmpty(subtitle));
|
||||
Assert.IsTrue(subtitle.Contains("Show time and date values in different formats"));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
<XesUseOneStoreVersioning>true</XesUseOneStoreVersioning>
|
||||
<XesBaseYearForStoreVersion>2025</XesBaseYearForStoreVersion>
|
||||
<VersionMajor>0</VersionMajor>
|
||||
<VersionMinor>7</VersionMinor>
|
||||
<VersionMinor>8</VersionMinor>
|
||||
<VersionInfoProductName>Microsoft Command Palette</VersionInfoProductName>
|
||||
</PropertyGroup>
|
||||
</Project>
|
||||
|
||||
@@ -15,7 +15,6 @@ public partial class CalculatorCommandProvider : CommandProvider
|
||||
private static ISettingsInterface settings = new SettingsManager();
|
||||
private readonly ListItem _listItem = new(new CalculatorListPage(settings))
|
||||
{
|
||||
Subtitle = Resources.calculator_top_level_subtitle,
|
||||
MoreCommands = [new CommandContextItem(((SettingsManager)settings).Settings.SettingsPage)],
|
||||
};
|
||||
|
||||
|
||||
@@ -19,7 +19,6 @@ public partial class ClipboardHistoryCommandsProvider : CommandProvider
|
||||
_clipboardHistoryListItem = new ListItem(new ClipboardHistoryListPage(_settingsManager))
|
||||
{
|
||||
Title = Properties.Resources.list_item_title,
|
||||
Subtitle = Properties.Resources.list_item_subtitle,
|
||||
Icon = Icons.ClipboardListIcon,
|
||||
MoreCommands = [
|
||||
new CommandContextItem(_settingsManager.Settings.SettingsPage),
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 3.0 KiB |
@@ -0,0 +1,73 @@
|
||||
// Copyright (c) Microsoft Corporation
|
||||
// The Microsoft Corporation licenses this file to you under the MIT license.
|
||||
// See the LICENSE file in the project root for more information.
|
||||
|
||||
using System;
|
||||
using System.Diagnostics;
|
||||
using System.IO;
|
||||
using ManagedCommon;
|
||||
using Microsoft.CmdPal.Ext.Indexer.Properties;
|
||||
using Microsoft.CommandPalette.Extensions.Toolkit;
|
||||
|
||||
namespace Microsoft.CmdPal.Ext.Indexer.Commands;
|
||||
|
||||
/// <summary>
|
||||
/// Command to preview a file using PowerToys Peek.
|
||||
/// </summary>
|
||||
public sealed partial class PeekFileCommand : InvokableCommand
|
||||
{
|
||||
private const string PeekExecutable = @"WinUI3Apps\PowerToys.Peek.UI.exe";
|
||||
|
||||
private static readonly Lazy<string> _peekPath = new(GetPeekExecutablePath);
|
||||
|
||||
private readonly string _fullPath;
|
||||
|
||||
public PeekFileCommand(string fullPath)
|
||||
{
|
||||
_fullPath = fullPath;
|
||||
Name = Resources.Indexer_Command_Peek;
|
||||
Icon = Icons.PeekIcon;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets a value indicating whether Peek is available on this system.
|
||||
/// </summary>
|
||||
public static bool IsPeekAvailable => !string.IsNullOrEmpty(_peekPath.Value);
|
||||
|
||||
public override CommandResult Invoke()
|
||||
{
|
||||
var peekExe = _peekPath.Value;
|
||||
if (string.IsNullOrEmpty(peekExe))
|
||||
{
|
||||
return CommandResult.ShowToast(Resources.Indexer_Command_Peek_NotAvailable);
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
using var process = new Process();
|
||||
process.StartInfo.FileName = peekExe;
|
||||
process.StartInfo.Arguments = $"\"{_fullPath}\"";
|
||||
process.StartInfo.UseShellExecute = false;
|
||||
process.Start();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
ExtensionHost.LogMessage($"Unable to launch Peek for {_fullPath}\n{ex}");
|
||||
return CommandResult.ShowToast(Resources.Indexer_Command_Peek_Failed);
|
||||
}
|
||||
|
||||
return CommandResult.Dismiss();
|
||||
}
|
||||
|
||||
private static string GetPeekExecutablePath()
|
||||
{
|
||||
var installPath = PowerToysPathResolver.GetPowerToysInstallPath();
|
||||
if (string.IsNullOrEmpty(installPath))
|
||||
{
|
||||
return string.Empty;
|
||||
}
|
||||
|
||||
var peekPath = Path.Combine(installPath, PeekExecutable);
|
||||
return File.Exists(peekPath) ? peekPath : string.Empty;
|
||||
}
|
||||
}
|
||||
@@ -7,6 +7,7 @@ using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using Microsoft.CmdPal.Core.Common.Commands;
|
||||
using Microsoft.CmdPal.Ext.Indexer.Commands;
|
||||
using Microsoft.CmdPal.Ext.Indexer.Helpers;
|
||||
using Microsoft.CmdPal.Ext.Indexer.Pages;
|
||||
using Microsoft.CmdPal.Ext.Indexer.Properties;
|
||||
@@ -96,6 +97,13 @@ internal sealed partial class IndexerListItem : ListItem
|
||||
}
|
||||
|
||||
commands.Add(new CommandContextItem(new OpenWithCommand(fullPath)));
|
||||
|
||||
// Add Peek command if available (only for files, not directories)
|
||||
if (!isDir && PeekFileCommand.IsPeekAvailable)
|
||||
{
|
||||
commands.Add(new CommandContextItem(new PeekFileCommand(fullPath)) { RequestedShortcut = KeyChords.Peek });
|
||||
}
|
||||
|
||||
commands.Add(new CommandContextItem(new ShowFileInFolderCommand(fullPath) { Name = Resources.Indexer_Command_ShowInFolder }) { RequestedShortcut = KeyChords.OpenFileLocation });
|
||||
commands.Add(new CommandContextItem(new CopyPathCommand(fullPath) { Name = Resources.Indexer_Command_CopyPath }) { RequestedShortcut = KeyChords.CopyFilePath });
|
||||
commands.Add(new CommandContextItem(new OpenInConsoleCommand(fullPath)) { RequestedShortcut = KeyChords.OpenInConsole });
|
||||
|
||||
@@ -23,4 +23,6 @@ internal static class Icons
|
||||
internal static IconInfo FilesIcon { get; } = new("\uF571"); // PrintAllPages
|
||||
|
||||
internal static IconInfo FilterIcon { get; } = new("\uE71C"); // Filter
|
||||
|
||||
internal static IconInfo PeekIcon { get; } = IconHelpers.FromRelativePath("Assets\\Peek.png");
|
||||
}
|
||||
|
||||
@@ -4,6 +4,8 @@
|
||||
|
||||
using Microsoft.CmdPal.Core.Common.Helpers;
|
||||
using Microsoft.CommandPalette.Extensions;
|
||||
using Microsoft.CommandPalette.Extensions.Toolkit;
|
||||
using Windows.System;
|
||||
|
||||
namespace Microsoft.CmdPal.Ext.Indexer;
|
||||
|
||||
@@ -14,4 +16,6 @@ internal static class KeyChords
|
||||
internal static KeyChord CopyFilePath { get; } = WellKnownKeyChords.CopyFilePath;
|
||||
|
||||
internal static KeyChord OpenInConsole { get; } = WellKnownKeyChords.OpenInConsole;
|
||||
|
||||
internal static KeyChord Peek { get; } = KeyChordHelpers.FromModifiers(ctrl: true, vkey: (int)VirtualKey.Space);
|
||||
}
|
||||
|
||||
@@ -42,6 +42,9 @@
|
||||
<Content Update="Assets\FileExplorer.svg">
|
||||
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
|
||||
</Content>
|
||||
<Content Update="Assets\Peek.png">
|
||||
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
|
||||
</Content>
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
|
||||
@@ -132,6 +132,33 @@ namespace Microsoft.CmdPal.Ext.Indexer.Properties {
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Looks up a localized string similar to Peek preview.
|
||||
/// </summary>
|
||||
internal static string Indexer_Command_Peek {
|
||||
get {
|
||||
return ResourceManager.GetString("Indexer_Command_Peek", resourceCulture);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Looks up a localized string similar to Failed to launch Peek.
|
||||
/// </summary>
|
||||
internal static string Indexer_Command_Peek_Failed {
|
||||
get {
|
||||
return ResourceManager.GetString("Indexer_Command_Peek_Failed", resourceCulture);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Looks up a localized string similar to PowerToys Peek is not available.
|
||||
/// </summary>
|
||||
internal static string Indexer_Command_Peek_NotAvailable {
|
||||
get {
|
||||
return ResourceManager.GetString("Indexer_Command_Peek_NotAvailable", resourceCulture);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Looks up a localized string similar to Search all files.
|
||||
/// </summary>
|
||||
|
||||
@@ -202,4 +202,13 @@ You can try searching all files on this PC or adjust your indexing settings.</va
|
||||
<data name="Indexer_Filter_Files_Only" xml:space="preserve">
|
||||
<value>Files</value>
|
||||
</data>
|
||||
<data name="Indexer_Command_Peek" xml:space="preserve">
|
||||
<value>Peek preview</value>
|
||||
</data>
|
||||
<data name="Indexer_Command_Peek_NotAvailable" xml:space="preserve">
|
||||
<value>PowerToys Peek is not available</value>
|
||||
</data>
|
||||
<data name="Indexer_Command_Peek_Failed" xml:space="preserve">
|
||||
<value>Failed to launch Peek</value>
|
||||
</data>
|
||||
</root>
|
||||
@@ -31,7 +31,6 @@ public partial class RemoteDesktopCommandProvider : CommandProvider
|
||||
|
||||
listPageCommand = new CommandItem(listPage)
|
||||
{
|
||||
Subtitle = Resources.remotedesktop_subtitle,
|
||||
Icon = Icons.RDPIcon,
|
||||
MoreCommands = [
|
||||
new CommandContextItem(settingsManager.Settings.SettingsPage),
|
||||
|
||||
@@ -39,7 +39,6 @@ public partial class ShellCommandsProvider : CommandProvider
|
||||
{
|
||||
Icon = Icons.RunV2Icon,
|
||||
Title = Resources.shell_command_name,
|
||||
Subtitle = Resources.cmd_plugin_description,
|
||||
MoreCommands = [
|
||||
new CommandContextItem(Settings.SettingsPage),
|
||||
],
|
||||
|
||||
@@ -28,7 +28,6 @@ public sealed partial class TimeDateCommandsProvider : CommandProvider
|
||||
{
|
||||
Icon = _timeDateExtensionPage.Icon,
|
||||
Title = Resources.Microsoft_plugin_timedate_plugin_name,
|
||||
Subtitle = GetTranslatedPluginDescription(),
|
||||
MoreCommands = [new CommandContextItem(_settingsManager.Settings.SettingsPage)],
|
||||
};
|
||||
|
||||
|
||||
@@ -26,7 +26,6 @@ public partial class WindowWalkerCommandsProvider : CommandProvider
|
||||
_windowWalkerPageItem = new CommandItem(new WindowWalkerListPage())
|
||||
{
|
||||
Title = Resources.window_walker_top_level_command_title,
|
||||
Subtitle = Resources.windowwalker_name,
|
||||
MoreCommands = [
|
||||
new CommandContextItem(Settings.SettingsPage),
|
||||
],
|
||||
|
||||
@@ -30,7 +30,6 @@ public sealed partial class WindowsSettingsCommandsProvider : CommandProvider
|
||||
_searchSettingsListItem = new CommandItem(new WindowsSettingsListPage(_windowsSettings))
|
||||
{
|
||||
Title = Resources.settings_title,
|
||||
Subtitle = Resources.settings_subtitle,
|
||||
};
|
||||
_fallback = new(_windowsSettings);
|
||||
|
||||
|
||||
@@ -35,6 +35,10 @@ internal sealed partial class SectionsIndexPage : ListPage
|
||||
{
|
||||
Title = "A Gallery grid page with sections",
|
||||
},
|
||||
new ListItem(new SampleListPageWithSections(new GalleryGridLayout() { ShowTitle = false, ShowSubtitle = false }))
|
||||
{
|
||||
Title = "A Gallery grid page without labels with sections",
|
||||
},
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
// The Microsoft Corporation licenses this file to you under the MIT license.
|
||||
// See the LICENSE file in the project root for more information.
|
||||
|
||||
using System.Runtime.CompilerServices;
|
||||
using Windows.Foundation;
|
||||
|
||||
namespace Microsoft.CommandPalette.Extensions.Toolkit;
|
||||
@@ -14,7 +15,7 @@ public partial class BaseObservable : INotifyPropChanged
|
||||
{
|
||||
public event TypedEventHandler<object, IPropChangedEventArgs>? PropChanged;
|
||||
|
||||
protected void OnPropertyChanged(string propertyName)
|
||||
protected void OnPropertyChanged([CallerMemberName] string? propertyName = null)
|
||||
{
|
||||
try
|
||||
{
|
||||
@@ -22,10 +23,37 @@ public partial class BaseObservable : INotifyPropChanged
|
||||
// this can crash as we try to invoke the handlers from that process.
|
||||
// However, just catching it seems to still raise the event on the
|
||||
// new host?
|
||||
PropChanged?.Invoke(this, new PropChangedEventArgs(propertyName));
|
||||
PropChanged?.Invoke(this, new PropChangedEventArgs(propertyName!));
|
||||
}
|
||||
catch
|
||||
{
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Sets the backing field to the specified value and raises a property changed
|
||||
/// notification if the value is different from the current one.
|
||||
/// </summary>
|
||||
/// <typeparam name="T">The type of the property.</typeparam>
|
||||
/// <param name="field">A reference to the backing field for the property.</param>
|
||||
/// <param name="value">The new value to assign to the property.</param>
|
||||
/// <param name="propertyName">
|
||||
/// The name of the property. This is optional and is usually supplied
|
||||
/// automatically by the <see cref="CallerMemberNameAttribute"/>.
|
||||
/// </param>
|
||||
/// <returns>
|
||||
/// <see langword="true"/> if the field was updated and a property changed
|
||||
/// notification was raised; otherwise, <see langword="false"/>.
|
||||
/// </returns>
|
||||
protected bool SetProperty<T>(ref T field, T value, [CallerMemberName] string? propertyName = null)
|
||||
{
|
||||
if (EqualityComparer<T>.Default.Equals(field, value))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
field = value;
|
||||
OnPropertyChanged(propertyName!);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,31 +6,11 @@ namespace Microsoft.CommandPalette.Extensions.Toolkit;
|
||||
|
||||
public partial class Command : BaseObservable, ICommand
|
||||
{
|
||||
public virtual string Name
|
||||
{
|
||||
get;
|
||||
set
|
||||
{
|
||||
field = value;
|
||||
OnPropertyChanged(nameof(Name));
|
||||
}
|
||||
}
|
||||
|
||||
= string.Empty;
|
||||
public virtual string Name { get; set => SetProperty(ref field, value); } = string.Empty;
|
||||
|
||||
public virtual string Id { get; set; } = string.Empty;
|
||||
|
||||
public virtual IconInfo Icon
|
||||
{
|
||||
get;
|
||||
set
|
||||
{
|
||||
field = value;
|
||||
OnPropertyChanged(nameof(Icon));
|
||||
}
|
||||
}
|
||||
|
||||
= new();
|
||||
public virtual IconInfo Icon { get; set => SetProperty(ref field, value); } = new();
|
||||
|
||||
IIconInfo ICommand.Icon => Icon;
|
||||
}
|
||||
|
||||
@@ -6,9 +6,9 @@ namespace Microsoft.CommandPalette.Extensions.Toolkit;
|
||||
|
||||
public partial class CommandContextItem : CommandItem, ICommandContextItem
|
||||
{
|
||||
public virtual bool IsCritical { get; set; }
|
||||
public virtual bool IsCritical { get; set => SetProperty(ref field, value); }
|
||||
|
||||
public virtual KeyChord RequestedShortcut { get; set; }
|
||||
public virtual KeyChord RequestedShortcut { get; set => SetProperty(ref field, value); }
|
||||
|
||||
public CommandContextItem(ICommand command)
|
||||
: base(command)
|
||||
|
||||
@@ -19,44 +19,36 @@ public partial class CommandItem : BaseObservable, ICommandItem, IExtendedAttrib
|
||||
private DataPackage? _dataPackage;
|
||||
private DataPackageView? _dataPackageView;
|
||||
|
||||
public virtual IIconInfo? Icon
|
||||
{
|
||||
get => field;
|
||||
set
|
||||
{
|
||||
field = value;
|
||||
OnPropertyChanged(nameof(Icon));
|
||||
}
|
||||
}
|
||||
public virtual IIconInfo? Icon { get; set => SetProperty(ref field, value); }
|
||||
|
||||
public virtual string Title
|
||||
{
|
||||
get => !string.IsNullOrEmpty(_title) ? _title : _command?.Name ?? string.Empty;
|
||||
|
||||
set
|
||||
{
|
||||
var oldTitle = Title;
|
||||
_title = value;
|
||||
OnPropertyChanged(nameof(Title));
|
||||
if (Title != oldTitle)
|
||||
{
|
||||
OnPropertyChanged();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public virtual string Subtitle
|
||||
{
|
||||
get;
|
||||
set
|
||||
{
|
||||
field = value;
|
||||
OnPropertyChanged(nameof(Subtitle));
|
||||
}
|
||||
}
|
||||
|
||||
= string.Empty;
|
||||
public virtual string Subtitle { get; set => SetProperty(ref field, value); } = string.Empty;
|
||||
|
||||
public virtual ICommand? Command
|
||||
{
|
||||
get => _command;
|
||||
set
|
||||
{
|
||||
if (EqualityComparer<ICommand?>.Default.Equals(value, _command))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var oldTitle = Title;
|
||||
|
||||
if (_commandListener is not null)
|
||||
{
|
||||
_commandListener.Detach();
|
||||
@@ -71,8 +63,8 @@ public partial class CommandItem : BaseObservable, ICommandItem, IExtendedAttrib
|
||||
value.PropChanged += _commandListener.OnEvent;
|
||||
}
|
||||
|
||||
OnPropertyChanged(nameof(Command));
|
||||
if (string.IsNullOrEmpty(_title))
|
||||
OnPropertyChanged();
|
||||
if (string.IsNullOrEmpty(_title) && oldTitle != Title)
|
||||
{
|
||||
OnPropertyChanged(nameof(Title));
|
||||
}
|
||||
@@ -88,17 +80,7 @@ public partial class CommandItem : BaseObservable, ICommandItem, IExtendedAttrib
|
||||
}
|
||||
}
|
||||
|
||||
public virtual IContextItem[] MoreCommands
|
||||
{
|
||||
get;
|
||||
set
|
||||
{
|
||||
field = value;
|
||||
OnPropertyChanged(nameof(MoreCommands));
|
||||
}
|
||||
}
|
||||
|
||||
= [];
|
||||
public virtual IContextItem[] MoreCommands { get; set => SetProperty(ref field, value); } = [];
|
||||
|
||||
public DataPackage? DataPackage
|
||||
{
|
||||
|
||||
@@ -6,9 +6,9 @@ namespace Microsoft.CommandPalette.Extensions.Toolkit;
|
||||
|
||||
public partial class CommandResult : ICommandResult
|
||||
{
|
||||
public ICommandResultArgs? Args { get; private set; }
|
||||
public ICommandResultArgs? Args { get; private init; }
|
||||
|
||||
public CommandResultKind Kind { get; private set; } = CommandResultKind.Dismiss;
|
||||
public CommandResultKind Kind { get; private init; } = CommandResultKind.Dismiss;
|
||||
|
||||
public static CommandResult Dismiss()
|
||||
{
|
||||
|
||||
@@ -10,17 +10,9 @@ public abstract partial class ContentPage : Page, IContentPage
|
||||
{
|
||||
public event TypedEventHandler<object, IItemsChangedEventArgs>? ItemsChanged;
|
||||
|
||||
public virtual IDetails? Details
|
||||
{
|
||||
get => field;
|
||||
set
|
||||
{
|
||||
field = value;
|
||||
OnPropertyChanged(nameof(Details));
|
||||
}
|
||||
}
|
||||
public virtual IDetails? Details { get; set => SetProperty(ref field, value); }
|
||||
|
||||
public virtual IContextItem[] Commands { get; set; } = [];
|
||||
public virtual IContextItem[] Commands { get; set => SetProperty(ref field, value); } = [];
|
||||
|
||||
public abstract IContent[] GetContent();
|
||||
|
||||
|
||||
@@ -7,65 +7,15 @@ namespace Microsoft.CommandPalette.Extensions.Toolkit;
|
||||
|
||||
public partial class Details : BaseObservable, IDetails, IExtendedAttributesProvider
|
||||
{
|
||||
public virtual IIconInfo HeroImage
|
||||
{
|
||||
get => field;
|
||||
set
|
||||
{
|
||||
field = value;
|
||||
OnPropertyChanged(nameof(HeroImage));
|
||||
}
|
||||
}
|
||||
public virtual IIconInfo HeroImage { get; set => SetProperty(ref field, value); } = new IconInfo();
|
||||
|
||||
= new IconInfo();
|
||||
public virtual string Title { get; set => SetProperty(ref field, value); } = string.Empty;
|
||||
|
||||
public virtual string Title
|
||||
{
|
||||
get;
|
||||
set
|
||||
{
|
||||
field = value;
|
||||
OnPropertyChanged(nameof(Title));
|
||||
}
|
||||
}
|
||||
public virtual string Body { get; set => SetProperty(ref field, value); } = string.Empty;
|
||||
|
||||
= string.Empty;
|
||||
public virtual IDetailsElement[] Metadata { get; set => SetProperty(ref field, value); } = [];
|
||||
|
||||
public virtual string Body
|
||||
{
|
||||
get;
|
||||
set
|
||||
{
|
||||
field = value;
|
||||
OnPropertyChanged(nameof(Body));
|
||||
}
|
||||
}
|
||||
|
||||
= string.Empty;
|
||||
|
||||
public virtual IDetailsElement[] Metadata
|
||||
{
|
||||
get;
|
||||
set
|
||||
{
|
||||
field = value;
|
||||
OnPropertyChanged(nameof(Metadata));
|
||||
}
|
||||
}
|
||||
|
||||
= [];
|
||||
|
||||
public virtual ContentSize Size
|
||||
{
|
||||
get;
|
||||
set
|
||||
{
|
||||
field = value;
|
||||
OnPropertyChanged(nameof(Size));
|
||||
}
|
||||
}
|
||||
|
||||
= ContentSize.Small;
|
||||
public virtual ContentSize Size { get; set => SetProperty(ref field, value); } = ContentSize.Small;
|
||||
|
||||
public IDictionary<string, object>? GetProperties() => new ValueSet()
|
||||
{
|
||||
|
||||
@@ -6,39 +6,9 @@ namespace Microsoft.CommandPalette.Extensions.Toolkit;
|
||||
|
||||
public partial class Filter : BaseObservable, IFilter
|
||||
{
|
||||
public virtual IIconInfo Icon
|
||||
{
|
||||
get => field;
|
||||
set
|
||||
{
|
||||
field = value;
|
||||
OnPropertyChanged(nameof(Icon));
|
||||
}
|
||||
}
|
||||
public virtual IIconInfo Icon { get; set => SetProperty(ref field, value); } = new IconInfo();
|
||||
|
||||
= new IconInfo();
|
||||
public virtual string Id { get; set => SetProperty(ref field, value); } = string.Empty;
|
||||
|
||||
public virtual string Id
|
||||
{
|
||||
get;
|
||||
set
|
||||
{
|
||||
field = value;
|
||||
OnPropertyChanged(nameof(Id));
|
||||
}
|
||||
}
|
||||
|
||||
= string.Empty;
|
||||
|
||||
public virtual string Name
|
||||
{
|
||||
get;
|
||||
set
|
||||
{
|
||||
field = value;
|
||||
OnPropertyChanged(nameof(Name));
|
||||
}
|
||||
}
|
||||
|
||||
= string.Empty;
|
||||
public virtual string Name { get; set => SetProperty(ref field, value); } = string.Empty;
|
||||
}
|
||||
|
||||
@@ -6,17 +6,7 @@ namespace Microsoft.CommandPalette.Extensions.Toolkit;
|
||||
|
||||
public abstract partial class Filters : BaseObservable, IFilters
|
||||
{
|
||||
public string CurrentFilterId
|
||||
{
|
||||
get => field;
|
||||
set
|
||||
{
|
||||
field = value;
|
||||
OnPropertyChanged(nameof(CurrentFilterId));
|
||||
}
|
||||
}
|
||||
|
||||
= string.Empty;
|
||||
public string CurrentFilterId { get; set => SetProperty(ref field, value); } = string.Empty;
|
||||
|
||||
// This method should be overridden in derived classes to provide the actual filters.
|
||||
public abstract IFilterItem[] GetFilters();
|
||||
|
||||
@@ -6,41 +6,11 @@ namespace Microsoft.CommandPalette.Extensions.Toolkit;
|
||||
|
||||
public partial class FormContent : BaseObservable, IFormContent
|
||||
{
|
||||
public virtual string DataJson
|
||||
{
|
||||
get;
|
||||
set
|
||||
{
|
||||
field = value;
|
||||
OnPropertyChanged(nameof(DataJson));
|
||||
}
|
||||
}
|
||||
public virtual string DataJson { get; set => SetProperty(ref field, value); } = string.Empty;
|
||||
|
||||
= string.Empty;
|
||||
public virtual string StateJson { get; set => SetProperty(ref field, value); } = string.Empty;
|
||||
|
||||
public virtual string StateJson
|
||||
{
|
||||
get;
|
||||
set
|
||||
{
|
||||
field = value;
|
||||
OnPropertyChanged(nameof(StateJson));
|
||||
}
|
||||
}
|
||||
|
||||
= string.Empty;
|
||||
|
||||
public virtual string TemplateJson
|
||||
{
|
||||
get;
|
||||
set
|
||||
{
|
||||
field = value;
|
||||
OnPropertyChanged(nameof(TemplateJson));
|
||||
}
|
||||
}
|
||||
|
||||
= string.Empty;
|
||||
public virtual string TemplateJson { get; set => SetProperty(ref field, value); } = string.Empty;
|
||||
|
||||
public virtual ICommandResult SubmitForm(string inputs, string data) => SubmitForm(inputs);
|
||||
|
||||
|
||||
@@ -6,27 +6,7 @@ namespace Microsoft.CommandPalette.Extensions.Toolkit;
|
||||
|
||||
public partial class GalleryGridLayout : BaseObservable, IGalleryGridLayout
|
||||
{
|
||||
public virtual bool ShowTitle
|
||||
{
|
||||
get => field;
|
||||
set
|
||||
{
|
||||
field = value;
|
||||
OnPropertyChanged(nameof(ShowTitle));
|
||||
}
|
||||
}
|
||||
public virtual bool ShowTitle { get; set => SetProperty(ref field, value); } = true;
|
||||
|
||||
= true;
|
||||
|
||||
public virtual bool ShowSubtitle
|
||||
{
|
||||
get => field;
|
||||
set
|
||||
{
|
||||
field = value;
|
||||
OnPropertyChanged(nameof(ShowSubtitle));
|
||||
}
|
||||
}
|
||||
|
||||
= true;
|
||||
public virtual bool ShowSubtitle { get; set => SetProperty(ref field, value); } = true;
|
||||
}
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
// The Microsoft Corporation licenses this file to you under the MIT license.
|
||||
// See the LICENSE file in the project root for more information.
|
||||
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
using Windows.Storage.Streams;
|
||||
|
||||
namespace Microsoft.CommandPalette.Extensions.Toolkit;
|
||||
|
||||
@@ -6,51 +6,13 @@ namespace Microsoft.CommandPalette.Extensions.Toolkit;
|
||||
|
||||
public partial class ListItem : CommandItem, IListItem
|
||||
{
|
||||
private ITag[] _tags = [];
|
||||
private IDetails? _details;
|
||||
public virtual ITag[] Tags { get; set => SetProperty(ref field, value); } = [];
|
||||
|
||||
private string _section = string.Empty;
|
||||
private string _textToSuggest = string.Empty;
|
||||
public virtual IDetails? Details { get; set => SetProperty(ref field, value); }
|
||||
|
||||
public virtual ITag[] Tags
|
||||
{
|
||||
get => _tags;
|
||||
set
|
||||
{
|
||||
_tags = value;
|
||||
OnPropertyChanged(nameof(Tags));
|
||||
}
|
||||
}
|
||||
public virtual string Section { get; set => SetProperty(ref field, value); } = string.Empty;
|
||||
|
||||
public virtual IDetails? Details
|
||||
{
|
||||
get => _details;
|
||||
set
|
||||
{
|
||||
_details = value;
|
||||
OnPropertyChanged(nameof(Details));
|
||||
}
|
||||
}
|
||||
|
||||
public virtual string Section
|
||||
{
|
||||
get => _section;
|
||||
set
|
||||
{
|
||||
_section = value;
|
||||
OnPropertyChanged(nameof(Section));
|
||||
}
|
||||
}
|
||||
|
||||
public virtual string TextToSuggest
|
||||
{
|
||||
get => _textToSuggest;
|
||||
set
|
||||
{
|
||||
_textToSuggest = value;
|
||||
OnPropertyChanged(nameof(TextToSuggest));
|
||||
}
|
||||
}
|
||||
public virtual string TextToSuggest { get; set => SetProperty(ref field, value); } = string.Empty;
|
||||
|
||||
public ListItem(ICommand command)
|
||||
: base(command)
|
||||
|
||||
@@ -8,85 +8,23 @@ namespace Microsoft.CommandPalette.Extensions.Toolkit;
|
||||
|
||||
public partial class ListPage : Page, IListPage
|
||||
{
|
||||
private string _placeholderText = string.Empty;
|
||||
private string _searchText = string.Empty;
|
||||
private bool _showDetails;
|
||||
private bool _hasMore;
|
||||
private IFilters? _filters;
|
||||
private IGridProperties? _gridProperties;
|
||||
private ICommandItem? _emptyContent;
|
||||
|
||||
public event TypedEventHandler<object, IItemsChangedEventArgs>? ItemsChanged;
|
||||
|
||||
public virtual string PlaceholderText
|
||||
{
|
||||
get => _placeholderText;
|
||||
set
|
||||
{
|
||||
_placeholderText = value;
|
||||
OnPropertyChanged(nameof(PlaceholderText));
|
||||
}
|
||||
}
|
||||
private string _searchText = string.Empty;
|
||||
|
||||
public virtual string SearchText
|
||||
{
|
||||
get => _searchText;
|
||||
set
|
||||
{
|
||||
_searchText = value;
|
||||
OnPropertyChanged(nameof(SearchText));
|
||||
}
|
||||
}
|
||||
public virtual string PlaceholderText { get; set => SetProperty(ref field, value); } = string.Empty;
|
||||
|
||||
public virtual bool ShowDetails
|
||||
{
|
||||
get => _showDetails;
|
||||
set
|
||||
{
|
||||
_showDetails = value;
|
||||
OnPropertyChanged(nameof(ShowDetails));
|
||||
}
|
||||
}
|
||||
public virtual string SearchText { get => _searchText; set => SetProperty(ref _searchText, value); }
|
||||
|
||||
public virtual bool HasMoreItems
|
||||
{
|
||||
get => _hasMore;
|
||||
set
|
||||
{
|
||||
_hasMore = value;
|
||||
OnPropertyChanged(nameof(HasMoreItems));
|
||||
}
|
||||
}
|
||||
public virtual bool ShowDetails { get; set => SetProperty(ref field, value); }
|
||||
|
||||
public virtual IFilters? Filters
|
||||
{
|
||||
get => _filters;
|
||||
set
|
||||
{
|
||||
_filters = value;
|
||||
OnPropertyChanged(nameof(Filters));
|
||||
}
|
||||
}
|
||||
public virtual bool HasMoreItems { get; set => SetProperty(ref field, value); }
|
||||
|
||||
public virtual IGridProperties? GridProperties
|
||||
{
|
||||
get => _gridProperties;
|
||||
set
|
||||
{
|
||||
_gridProperties = value;
|
||||
OnPropertyChanged(nameof(GridProperties));
|
||||
}
|
||||
}
|
||||
public virtual IFilters? Filters { get; set => SetProperty(ref field, value); }
|
||||
|
||||
public virtual ICommandItem? EmptyContent
|
||||
{
|
||||
get => _emptyContent;
|
||||
set
|
||||
{
|
||||
_emptyContent = value;
|
||||
OnPropertyChanged(nameof(EmptyContent));
|
||||
}
|
||||
}
|
||||
public virtual IGridProperties? GridProperties { get; set => SetProperty(ref field, value); }
|
||||
|
||||
public virtual ICommandItem? EmptyContent { get; set => SetProperty(ref field, value); }
|
||||
|
||||
public virtual IListItem[] GetItems() => [];
|
||||
|
||||
|
||||
@@ -6,17 +6,7 @@ namespace Microsoft.CommandPalette.Extensions.Toolkit;
|
||||
|
||||
public partial class MarkdownContent : BaseObservable, IMarkdownContent
|
||||
{
|
||||
public virtual string Body
|
||||
{
|
||||
get;
|
||||
set
|
||||
{
|
||||
field = value;
|
||||
OnPropertyChanged(nameof(Body));
|
||||
}
|
||||
}
|
||||
|
||||
= string.Empty;
|
||||
public virtual string Body { get; set => SetProperty(ref field, value); } = string.Empty;
|
||||
|
||||
public MarkdownContent()
|
||||
{
|
||||
|
||||
@@ -6,15 +6,5 @@ namespace Microsoft.CommandPalette.Extensions.Toolkit;
|
||||
|
||||
public partial class MediumGridLayout : BaseObservable, IMediumGridLayout
|
||||
{
|
||||
public virtual bool ShowTitle
|
||||
{
|
||||
get => field;
|
||||
set
|
||||
{
|
||||
field = value;
|
||||
OnPropertyChanged(nameof(ShowTitle));
|
||||
}
|
||||
}
|
||||
|
||||
= true;
|
||||
public virtual bool ShowTitle { get; set => SetProperty(ref field, value); } = true;
|
||||
}
|
||||
|
||||
@@ -6,37 +6,9 @@ namespace Microsoft.CommandPalette.Extensions.Toolkit;
|
||||
|
||||
public partial class Page : Command, IPage
|
||||
{
|
||||
private bool _loading;
|
||||
private string _title = string.Empty;
|
||||
private OptionalColor _accentColor;
|
||||
public virtual bool IsLoading { get; set => SetProperty(ref field, value); }
|
||||
|
||||
public virtual bool IsLoading
|
||||
{
|
||||
get => _loading;
|
||||
set
|
||||
{
|
||||
_loading = value;
|
||||
OnPropertyChanged(nameof(IsLoading));
|
||||
}
|
||||
}
|
||||
public virtual string Title { get; set => SetProperty(ref field, value); } = string.Empty;
|
||||
|
||||
public virtual string Title
|
||||
{
|
||||
get => _title;
|
||||
set
|
||||
{
|
||||
_title = value;
|
||||
OnPropertyChanged(nameof(Title));
|
||||
}
|
||||
}
|
||||
|
||||
public virtual OptionalColor AccentColor
|
||||
{
|
||||
get => _accentColor;
|
||||
set
|
||||
{
|
||||
_accentColor = value;
|
||||
OnPropertyChanged(nameof(AccentColor));
|
||||
}
|
||||
}
|
||||
public virtual OptionalColor AccentColor { get; set => SetProperty(ref field, value); }
|
||||
}
|
||||
|
||||
@@ -6,27 +6,7 @@ namespace Microsoft.CommandPalette.Extensions.Toolkit;
|
||||
|
||||
public partial class ProgressState : BaseObservable, IProgressState
|
||||
{
|
||||
private bool _isIndeterminate;
|
||||
public virtual bool IsIndeterminate { get; set => SetProperty(ref field, value); }
|
||||
|
||||
private uint _progressPercent;
|
||||
|
||||
public virtual bool IsIndeterminate
|
||||
{
|
||||
get => _isIndeterminate;
|
||||
set
|
||||
{
|
||||
_isIndeterminate = value;
|
||||
OnPropertyChanged(nameof(IsIndeterminate));
|
||||
}
|
||||
}
|
||||
|
||||
public virtual uint ProgressPercent
|
||||
{
|
||||
get => _progressPercent;
|
||||
set
|
||||
{
|
||||
_progressPercent = value;
|
||||
OnPropertyChanged(nameof(ProgressPercent));
|
||||
}
|
||||
}
|
||||
public virtual uint ProgressPercent { get; set => SetProperty(ref field, value); }
|
||||
}
|
||||
|
||||
@@ -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 Windows.Foundation;
|
||||
|
||||
namespace Microsoft.CommandPalette.Extensions.Toolkit;
|
||||
|
||||
public partial class PropChangedEventArgs : IPropChangedEventArgs
|
||||
|
||||
@@ -12,11 +12,6 @@ public sealed partial class Section : IEnumerable<IListItem>
|
||||
|
||||
public string SectionTitle { get; set; } = string.Empty;
|
||||
|
||||
private Separator CreateSectionListItem()
|
||||
{
|
||||
return new Separator(SectionTitle);
|
||||
}
|
||||
|
||||
public Section(string sectionName, IListItem[] items)
|
||||
{
|
||||
SectionTitle = sectionName;
|
||||
@@ -33,6 +28,11 @@ public sealed partial class Section : IEnumerable<IListItem>
|
||||
{
|
||||
}
|
||||
|
||||
private Separator CreateSectionListItem()
|
||||
{
|
||||
return new Separator(SectionTitle);
|
||||
}
|
||||
|
||||
public IEnumerator<IListItem> GetEnumerator() => Items.ToList().GetEnumerator();
|
||||
|
||||
IEnumerator IEnumerable.GetEnumerator() => GetEnumerator();
|
||||
|
||||
@@ -4,15 +4,8 @@
|
||||
|
||||
namespace Microsoft.CommandPalette.Extensions.Toolkit;
|
||||
|
||||
public partial class Separator : IListItem, ISeparatorContextItem, ISeparatorFilterItem
|
||||
public partial class Separator : BaseObservable, IListItem, ISeparatorContextItem, ISeparatorFilterItem
|
||||
{
|
||||
public Separator(string? title = "")
|
||||
: base()
|
||||
{
|
||||
Section = title ?? string.Empty;
|
||||
Command = null;
|
||||
}
|
||||
|
||||
public IDetails? Details => null;
|
||||
|
||||
public string? Section { get; private set; }
|
||||
@@ -21,7 +14,7 @@ public partial class Separator : IListItem, ISeparatorContextItem, ISeparatorFil
|
||||
|
||||
public string? TextToSuggest => null;
|
||||
|
||||
public ICommand? Command { get; private set; }
|
||||
public ICommand? Command => null;
|
||||
|
||||
public IIconInfo? Icon => null;
|
||||
|
||||
@@ -32,12 +25,19 @@ public partial class Separator : IListItem, ISeparatorContextItem, ISeparatorFil
|
||||
public string? Title
|
||||
{
|
||||
get => Section;
|
||||
set => Section = value;
|
||||
set
|
||||
{
|
||||
if (Section != value)
|
||||
{
|
||||
Section = value;
|
||||
OnPropertyChanged();
|
||||
OnPropertyChanged(Section);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public event Windows.Foundation.TypedEventHandler<object, IPropChangedEventArgs>? PropChanged
|
||||
public Separator(string? title = "")
|
||||
{
|
||||
add { }
|
||||
remove { }
|
||||
Section = title ?? string.Empty;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,37 +6,9 @@ namespace Microsoft.CommandPalette.Extensions.Toolkit;
|
||||
|
||||
public partial class StatusMessage : BaseObservable, IStatusMessage
|
||||
{
|
||||
public virtual string Message
|
||||
{
|
||||
get;
|
||||
set
|
||||
{
|
||||
field = value;
|
||||
OnPropertyChanged(nameof(Message));
|
||||
}
|
||||
}
|
||||
public virtual string Message { get; set => SetProperty(ref field, value); } = string.Empty;
|
||||
|
||||
= string.Empty;
|
||||
public virtual MessageState State { get; set => SetProperty(ref field, value); } = MessageState.Info;
|
||||
|
||||
public virtual MessageState State
|
||||
{
|
||||
get;
|
||||
set
|
||||
{
|
||||
field = value;
|
||||
OnPropertyChanged(nameof(State));
|
||||
}
|
||||
}
|
||||
|
||||
= MessageState.Info;
|
||||
|
||||
public virtual IProgressState? Progress
|
||||
{
|
||||
get;
|
||||
set
|
||||
{
|
||||
field = value;
|
||||
OnPropertyChanged(nameof(Progress));
|
||||
}
|
||||
}
|
||||
public virtual IProgressState? Progress { get; set => SetProperty(ref field, value); }
|
||||
}
|
||||
|
||||
@@ -6,63 +6,15 @@ namespace Microsoft.CommandPalette.Extensions.Toolkit;
|
||||
|
||||
public partial class Tag : BaseObservable, ITag
|
||||
{
|
||||
private OptionalColor _foreground;
|
||||
private OptionalColor _background;
|
||||
private string _text = string.Empty;
|
||||
public virtual OptionalColor Foreground { get; set => SetProperty(ref field, value); }
|
||||
|
||||
public virtual OptionalColor Foreground
|
||||
{
|
||||
get => _foreground;
|
||||
set
|
||||
{
|
||||
_foreground = value;
|
||||
OnPropertyChanged(nameof(Foreground));
|
||||
}
|
||||
}
|
||||
public virtual OptionalColor Background { get; set => SetProperty(ref field, value); }
|
||||
|
||||
public virtual OptionalColor Background
|
||||
{
|
||||
get => _background;
|
||||
set
|
||||
{
|
||||
_background = value;
|
||||
OnPropertyChanged(nameof(Background));
|
||||
}
|
||||
}
|
||||
public virtual IIconInfo Icon { get; set => SetProperty(ref field, value); } = new IconInfo();
|
||||
|
||||
public virtual IIconInfo Icon
|
||||
{
|
||||
get;
|
||||
set
|
||||
{
|
||||
field = value;
|
||||
OnPropertyChanged(nameof(Icon));
|
||||
}
|
||||
}
|
||||
public virtual string Text { get; set => SetProperty(ref field, value); } = string.Empty;
|
||||
|
||||
= new IconInfo();
|
||||
|
||||
public virtual string Text
|
||||
{
|
||||
get => _text;
|
||||
set
|
||||
{
|
||||
_text = value;
|
||||
OnPropertyChanged(nameof(Text));
|
||||
}
|
||||
}
|
||||
|
||||
public virtual string ToolTip
|
||||
{
|
||||
get;
|
||||
set
|
||||
{
|
||||
field = value;
|
||||
OnPropertyChanged(nameof(ToolTip));
|
||||
}
|
||||
}
|
||||
|
||||
= string.Empty;
|
||||
public virtual string ToolTip { get; set => SetProperty(ref field, value); } = string.Empty;
|
||||
|
||||
public Tag()
|
||||
{
|
||||
@@ -70,6 +22,6 @@ public partial class Tag : BaseObservable, ITag
|
||||
|
||||
public Tag(string text)
|
||||
{
|
||||
_text = text;
|
||||
Text = text;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,19 +8,11 @@ namespace Microsoft.CommandPalette.Extensions.Toolkit;
|
||||
|
||||
public partial class TreeContent : BaseObservable, ITreeContent
|
||||
{
|
||||
public event TypedEventHandler<object, IItemsChangedEventArgs>? ItemsChanged;
|
||||
|
||||
public IContent[] Children { get; set; } = [];
|
||||
|
||||
public virtual IContent? RootContent
|
||||
{
|
||||
get;
|
||||
set
|
||||
{
|
||||
field = value;
|
||||
OnPropertyChanged(nameof(RootContent));
|
||||
}
|
||||
}
|
||||
|
||||
public event TypedEventHandler<object, IItemsChangedEventArgs>? ItemsChanged;
|
||||
public virtual IContent? RootContent { get; set => SetProperty(ref field, value); }
|
||||
|
||||
public virtual IContent[] GetChildren() => Children;
|
||||
|
||||
|
||||
@@ -191,27 +191,22 @@ bool EditorParameters::Save(const WorkAreaConfiguration& configuration, OnThread
|
||||
|
||||
monitorJson.dpi = dpi;
|
||||
|
||||
MONITORINFOEX monitorInfo{};
|
||||
// Get DPI-unaware values for dimensions (virtual coordinates for WPF sizing)
|
||||
MONITORINFOEX monitorInfoUnaware{};
|
||||
dpiUnawareThread.submit(OnThreadExecutor::task_t{ [&] {
|
||||
monitorInfo.cbSize = sizeof(monitorInfo);
|
||||
if (!GetMonitorInfo(monitor, &monitorInfo))
|
||||
{
|
||||
return;
|
||||
}
|
||||
monitorInfoUnaware.cbSize = sizeof(monitorInfoUnaware);
|
||||
GetMonitorInfo(monitor, &monitorInfoUnaware);
|
||||
} }).wait();
|
||||
|
||||
float width = static_cast<float>(monitorInfo.rcMonitor.right - monitorInfo.rcMonitor.left);
|
||||
float height = static_cast<float>(monitorInfo.rcMonitor.bottom - monitorInfo.rcMonitor.top);
|
||||
DPIAware::Convert(monitor, width, height);
|
||||
// Dimensions in virtual coordinates (from DPI-unaware thread)
|
||||
monitorJson.monitorWidth = monitorInfoUnaware.rcMonitor.right - monitorInfoUnaware.rcMonitor.left;
|
||||
monitorJson.monitorHeight = monitorInfoUnaware.rcMonitor.bottom - monitorInfoUnaware.rcMonitor.top;
|
||||
monitorJson.workAreaWidth = monitorInfoUnaware.rcWork.right - monitorInfoUnaware.rcWork.left;
|
||||
monitorJson.workAreaHeight = monitorInfoUnaware.rcWork.bottom - monitorInfoUnaware.rcWork.top;
|
||||
|
||||
monitorJson.monitorWidth = static_cast<int>(std::roundf(width));
|
||||
monitorJson.monitorHeight = static_cast<int>(std::roundf(height));
|
||||
|
||||
// use dpi-unaware values
|
||||
monitorJson.top = monitorInfo.rcWork.top;
|
||||
monitorJson.left = monitorInfo.rcWork.left;
|
||||
monitorJson.workAreaWidth = monitorInfo.rcWork.right - monitorInfo.rcWork.left;
|
||||
monitorJson.workAreaHeight = monitorInfo.rcWork.bottom - monitorInfo.rcWork.top;
|
||||
// Position in virtual coordinates (matched by DPI-unaware context in WPF editor)
|
||||
monitorJson.left = monitorInfoUnaware.rcWork.left;
|
||||
monitorJson.top = monitorInfoUnaware.rcWork.top;
|
||||
|
||||
argsJson.monitors.emplace_back(std::move(monitorJson));
|
||||
}
|
||||
|
||||
@@ -67,10 +67,18 @@ namespace FancyZonesEditor.Models
|
||||
Window.KeyUp += ((App)Application.Current).App_KeyUp;
|
||||
Window.KeyDown += ((App)Application.Current).App_KeyDown;
|
||||
|
||||
// Store for DPI-unaware positioning
|
||||
_virtualWorkArea = workArea;
|
||||
|
||||
// Set initial WPF properties
|
||||
Window.Left = workArea.X;
|
||||
Window.Top = workArea.Y;
|
||||
Window.Width = workArea.Width;
|
||||
Window.Height = workArea.Height;
|
||||
|
||||
// After HWND is created, reposition using DPI-unaware context
|
||||
// This matches the C++ backend which uses a DPI-unaware thread
|
||||
Window.SourceInitialized += OnWindowSourceInitialized;
|
||||
}
|
||||
|
||||
public Monitor(string monitorName, string monitorInstanceId, string monitorSerialNumber, string virtualDesktop, int dpi, Rect workArea, Size monitorSize)
|
||||
@@ -80,16 +88,33 @@ namespace FancyZonesEditor.Models
|
||||
}
|
||||
|
||||
private LayoutSettings _settings;
|
||||
private Rect _virtualWorkArea;
|
||||
|
||||
private void OnWindowSourceInitialized(object sender, EventArgs e)
|
||||
{
|
||||
// Reposition window using DPI-unaware context to match the virtual coordinates
|
||||
// from the FancyZones C++ backend (which uses a DPI-unaware thread)
|
||||
Utils.NativeMethods.SetWindowPositionDpiUnaware(
|
||||
Window,
|
||||
(int)_virtualWorkArea.X,
|
||||
(int)_virtualWorkArea.Y,
|
||||
(int)_virtualWorkArea.Width,
|
||||
(int)_virtualWorkArea.Height);
|
||||
}
|
||||
|
||||
public void Scale(double scaleFactor)
|
||||
{
|
||||
Device.Scale(scaleFactor);
|
||||
|
||||
var workArea = Device.WorkAreaRect;
|
||||
Window.Left = workArea.X;
|
||||
Window.Top = workArea.Y;
|
||||
Window.Width = workArea.Width;
|
||||
Window.Height = workArea.Height;
|
||||
_virtualWorkArea = Device.WorkAreaRect;
|
||||
|
||||
// Use DPI-unaware positioning
|
||||
Utils.NativeMethods.SetWindowPositionDpiUnaware(
|
||||
Window,
|
||||
(int)_virtualWorkArea.X,
|
||||
(int)_virtualWorkArea.Y,
|
||||
(int)_virtualWorkArea.Width,
|
||||
(int)_virtualWorkArea.Height);
|
||||
}
|
||||
|
||||
public void SetLayoutSettings(LayoutModel model)
|
||||
|
||||
@@ -69,7 +69,11 @@ namespace FancyZonesEditor.Utils
|
||||
}
|
||||
else
|
||||
{
|
||||
return ScreenBoundsWidth + " × " + ScreenBoundsHeight;
|
||||
// Convert virtual coordinates to physical resolution by applying DPI scale
|
||||
double scale = DPI / 96.0;
|
||||
int physicalWidth = (int)Math.Round(ScreenBoundsWidth * scale);
|
||||
int physicalHeight = (int)Math.Round(ScreenBoundsHeight * scale);
|
||||
return physicalWidth + " × " + physicalHeight;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
// Copyright (c) Microsoft Corporation
|
||||
// Copyright (c) Microsoft Corporation
|
||||
// The Microsoft Corporation licenses this file to you under the MIT license.
|
||||
// See the LICENSE file in the project root for more information.
|
||||
|
||||
@@ -17,14 +17,48 @@ namespace FancyZonesEditor.Utils
|
||||
[DllImport("user32.dll")]
|
||||
private static extern int SetWindowLong(IntPtr hWnd, int nIndex, int dwNewLong);
|
||||
|
||||
[DllImport("user32.dll", SetLastError = true)]
|
||||
private static extern bool SetWindowPos(IntPtr hWnd, IntPtr hWndInsertAfter, int X, int Y, int cx, int cy, uint uFlags);
|
||||
|
||||
[DllImport("user32.dll")]
|
||||
private static extern IntPtr SetThreadDpiAwarenessContext(IntPtr dpiContext);
|
||||
|
||||
private const int GWL_EX_STYLE = -20;
|
||||
private const int WS_EX_APPWINDOW = 0x00040000;
|
||||
private const int WS_EX_TOOLWINDOW = 0x00000080;
|
||||
private const uint SWP_NOZORDER = 0x0004;
|
||||
private const uint SWP_NOACTIVATE = 0x0010;
|
||||
|
||||
private static readonly IntPtr DPI_AWARENESS_CONTEXT_UNAWARE = new IntPtr(-1);
|
||||
|
||||
public static void SetWindowStyleToolWindow(Window hwnd)
|
||||
{
|
||||
var helper = new WindowInteropHelper(hwnd).Handle;
|
||||
_ = SetWindowLong(helper, GWL_EX_STYLE, (GetWindowLong(helper, GWL_EX_STYLE) | WS_EX_TOOLWINDOW) & ~WS_EX_APPWINDOW);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Positions a WPF window using DPI-unaware context to match the virtual coordinates
|
||||
/// from the FancyZones C++ backend (which uses a DPI-unaware thread).
|
||||
/// This fixes overlay positioning on mixed-DPI multi-monitor setups.
|
||||
/// </summary>
|
||||
public static void SetWindowPositionDpiUnaware(Window window, int x, int y, int width, int height)
|
||||
{
|
||||
var helper = new WindowInteropHelper(window).Handle;
|
||||
if (helper != IntPtr.Zero)
|
||||
{
|
||||
// Temporarily switch to DPI-unaware context to position window.
|
||||
// This matches how the C++ backend gets coordinates via dpiUnawareThread.
|
||||
IntPtr oldContext = SetThreadDpiAwarenessContext(DPI_AWARENESS_CONTEXT_UNAWARE);
|
||||
try
|
||||
{
|
||||
SetWindowPos(helper, IntPtr.Zero, x, y, width, height, SWP_NOZORDER | SWP_NOACTIVATE);
|
||||
}
|
||||
finally
|
||||
{
|
||||
SetThreadDpiAwarenessContext(oldContext);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -21,6 +21,19 @@
|
||||
x:Name="OpenUriDialog"
|
||||
x:Uid="OpenUriDialog"
|
||||
PrimaryButtonStyle="{ThemeResource AccentButtonStyle}"
|
||||
SecondaryButtonClick="OpenUriDialog_SecondaryButtonClick" />
|
||||
SecondaryButtonClick="OpenUriDialog_SecondaryButtonClick">
|
||||
<StackPanel Spacing="16">
|
||||
<controls:InfoBar
|
||||
x:Name="OpenUriWarningBanner"
|
||||
x:Uid="OpenUriWarningBanner"
|
||||
IsClosable="False"
|
||||
IsOpen="True"
|
||||
Severity="Warning" />
|
||||
<TextBlock
|
||||
x:Name="OpenUriDialogContent"
|
||||
IsTextSelectionEnabled="True"
|
||||
TextWrapping="Wrap" />
|
||||
</StackPanel>
|
||||
</ContentDialog>
|
||||
</Grid>
|
||||
</UserControl>
|
||||
|
||||
@@ -34,6 +34,11 @@ namespace Peek.FilePreviewer.Controls
|
||||
|
||||
private Color? _originalBackgroundColor;
|
||||
|
||||
/// <summary>
|
||||
/// URI of the current source being previewed (for resource filtering)
|
||||
/// </summary>
|
||||
private Uri? _currentSourceUri;
|
||||
|
||||
public delegate void NavigationCompletedHandler(WebView2? sender, CoreWebView2NavigationCompletedEventArgs? args);
|
||||
|
||||
public delegate void DOMContentLoadedHandler(CoreWebView2? sender, CoreWebView2DOMContentLoadedEventArgs? args);
|
||||
@@ -97,6 +102,7 @@ namespace Peek.FilePreviewer.Controls
|
||||
{
|
||||
this.InitializeComponent();
|
||||
Environment.SetEnvironmentVariable("WEBVIEW2_USER_DATA_FOLDER", TempFolderPath.Path, EnvironmentVariableTarget.Process);
|
||||
Environment.SetEnvironmentVariable("WEBVIEW2_ADDITIONAL_BROWSER_ARGUMENTS", "--block-new-web-contents", EnvironmentVariableTarget.Process);
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
@@ -105,6 +111,7 @@ namespace Peek.FilePreviewer.Controls
|
||||
{
|
||||
PreviewBrowser.CoreWebView2.DOMContentLoaded -= CoreWebView2_DOMContentLoaded;
|
||||
PreviewBrowser.CoreWebView2.ContextMenuRequested -= CoreWebView2_ContextMenuRequested;
|
||||
RemoveResourceFilter();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -123,6 +130,14 @@ namespace Peek.FilePreviewer.Controls
|
||||
{
|
||||
/* CoreWebView2.Navigate() will always trigger a navigation even if the content/URI is the same.
|
||||
* Use WebView2.Source to avoid re-navigating to the same content. */
|
||||
_currentSourceUri = Source;
|
||||
|
||||
// Only apply resource filter for non-dev files
|
||||
if (!IsDevFilePreview)
|
||||
{
|
||||
ApplyResourceFilter();
|
||||
}
|
||||
|
||||
PreviewBrowser.CoreWebView2.Navigate(Source.ToString());
|
||||
}
|
||||
}
|
||||
@@ -146,10 +161,14 @@ namespace Peek.FilePreviewer.Controls
|
||||
if (IsDevFilePreview)
|
||||
{
|
||||
PreviewBrowser.CoreWebView2.SetVirtualHostNameToFolderMapping(Microsoft.PowerToys.FilePreviewCommon.MonacoHelper.VirtualHostName, Microsoft.PowerToys.FilePreviewCommon.MonacoHelper.MonacoDirectory, CoreWebView2HostResourceAccessKind.Allow);
|
||||
|
||||
// Remove resource filter for dev files (Monaco needs to load resources)
|
||||
RemoveResourceFilter();
|
||||
}
|
||||
else
|
||||
{
|
||||
PreviewBrowser.CoreWebView2.ClearVirtualHostNameToFolderMapping(Microsoft.PowerToys.FilePreviewCommon.MonacoHelper.VirtualHostName);
|
||||
ApplyResourceFilter();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -283,6 +302,66 @@ namespace Peek.FilePreviewer.Controls
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Applies strict resource filtering for non-dev files to block external resources.
|
||||
/// This prevents XSS attacks and unwanted external content loading.
|
||||
/// </summary>
|
||||
private void ApplyResourceFilter()
|
||||
{
|
||||
// Remove existing handler to prevent duplicate subscriptions
|
||||
RemoveResourceFilter();
|
||||
|
||||
// Add filter and subscribe to resource requests
|
||||
PreviewBrowser.CoreWebView2.AddWebResourceRequestedFilter("*", CoreWebView2WebResourceContext.All);
|
||||
PreviewBrowser.CoreWebView2.WebResourceRequested += CoreWebView2_WebResourceRequested;
|
||||
}
|
||||
|
||||
private void RemoveResourceFilter()
|
||||
{
|
||||
PreviewBrowser.CoreWebView2.WebResourceRequested -= CoreWebView2_WebResourceRequested;
|
||||
}
|
||||
|
||||
private void CoreWebView2_WebResourceRequested(CoreWebView2 sender, CoreWebView2WebResourceRequestedEventArgs args)
|
||||
{
|
||||
if (_currentSourceUri == null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var requestUri = new Uri(args.Request.Uri);
|
||||
|
||||
// Allow loading the source file itself
|
||||
if (requestUri == _currentSourceUri)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
// For local file:// resources, allow same directory and subdirectories
|
||||
if (requestUri.Scheme == "file" && _currentSourceUri.Scheme == "file")
|
||||
{
|
||||
try
|
||||
{
|
||||
var sourceDirectory = System.IO.Path.GetDirectoryName(_currentSourceUri.LocalPath);
|
||||
var requestPath = requestUri.LocalPath;
|
||||
|
||||
// Allow resources in the same directory or subdirectories
|
||||
if (!string.IsNullOrEmpty(sourceDirectory) &&
|
||||
requestPath.StartsWith(sourceDirectory, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return;
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
// If path processing fails, block for security
|
||||
}
|
||||
}
|
||||
|
||||
// Block all other resources including http(s) requests to prevent external tracking,
|
||||
// data exfiltration, and XSS attacks
|
||||
args.Response = PreviewBrowser.CoreWebView2.Environment.CreateWebResourceResponse(null, 403, "Forbidden", null);
|
||||
}
|
||||
|
||||
private void CoreWebView2_DOMContentLoaded(CoreWebView2 sender, CoreWebView2DOMContentLoadedEventArgs args)
|
||||
{
|
||||
// If the file being previewed is HTML or HTM, reset the background color to its original state.
|
||||
@@ -344,7 +423,7 @@ namespace Peek.FilePreviewer.Controls
|
||||
|
||||
private async Task ShowOpenUriDialogAsync(Uri uri)
|
||||
{
|
||||
OpenUriDialog.Content = uri.ToString();
|
||||
OpenUriDialogContent.Text = uri.ToString();
|
||||
var result = await OpenUriDialog.ShowAsync();
|
||||
|
||||
if (result == ContentDialogResult.Primary)
|
||||
@@ -356,7 +435,7 @@ namespace Peek.FilePreviewer.Controls
|
||||
private void OpenUriDialog_SecondaryButtonClick(ContentDialog sender, ContentDialogButtonClickEventArgs args)
|
||||
{
|
||||
var dataPackage = new DataPackage();
|
||||
dataPackage.SetText(sender.Content.ToString());
|
||||
dataPackage.SetText(OpenUriDialogContent.Text);
|
||||
Clipboard.SetContent(dataPackage);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -113,38 +113,41 @@ namespace Peek.FilePreviewer.Previewers
|
||||
|
||||
await Dispatcher.RunOnUiThread(async () =>
|
||||
{
|
||||
bool isHtml = File.Extension == ".html" || File.Extension == ".htm";
|
||||
bool isMarkdown = File.Extension == ".md";
|
||||
bool isSvg = File.Extension == ".svg";
|
||||
string extension = File.Extension;
|
||||
|
||||
bool supportedByMonaco = MonacoHelper.SupportedMonacoFileTypes.Contains(File.Extension);
|
||||
bool useMonaco = supportedByMonaco && !isHtml && !isMarkdown && !isSvg;
|
||||
// Default: non-dev file preview with standard context menu
|
||||
IsDevFilePreview = false;
|
||||
CustomContextMenu = false;
|
||||
|
||||
IsDevFilePreview = supportedByMonaco;
|
||||
CustomContextMenu = useMonaco;
|
||||
|
||||
if (useMonaco)
|
||||
// Determine preview strategy based on file type priority
|
||||
if (extension == ".md")
|
||||
{
|
||||
var raw = await ReadHelper.Read(File.Path.ToString());
|
||||
Preview = new Uri(MonacoHelper.PreviewTempFile(raw, File.Extension, TempFolderPath.Path, _previewSettings.SourceCodeTryFormat, _previewSettings.SourceCodeWrapText, _previewSettings.SourceCodeStickyScroll, _previewSettings.SourceCodeFontSize, _previewSettings.SourceCodeMinimap));
|
||||
}
|
||||
else if (isMarkdown)
|
||||
{
|
||||
IsDevFilePreview = false;
|
||||
// Markdown files use custom renderer
|
||||
var raw = await ReadHelper.Read(File.Path.ToString());
|
||||
Preview = new Uri(MarkdownHelper.PreviewTempFile(raw, File.Path, TempFolderPath.Path));
|
||||
}
|
||||
else if (isSvg)
|
||||
else if (extension == ".svg")
|
||||
{
|
||||
// SVG files are rendered directly by WebView2 for better compatibility
|
||||
// with complex SVGs from Adobe Illustrator, Inkscape, etc.
|
||||
IsDevFilePreview = false;
|
||||
Preview = new Uri(File.Path);
|
||||
}
|
||||
else if (extension == ".html" || extension == ".htm")
|
||||
{
|
||||
// Simple html file to preview. Shouldn't do things like enabling scripts or using a virtual mapped directory.
|
||||
Preview = new Uri(File.Path);
|
||||
}
|
||||
else if (MonacoHelper.SupportedMonacoFileTypes.Contains(extension))
|
||||
{
|
||||
// Source code files use Monaco editor
|
||||
IsDevFilePreview = true;
|
||||
CustomContextMenu = true;
|
||||
var raw = await ReadHelper.Read(File.Path.ToString());
|
||||
Preview = new Uri(MonacoHelper.PreviewTempFile(raw, extension, TempFolderPath.Path, _previewSettings.SourceCodeTryFormat, _previewSettings.SourceCodeWrapText, _previewSettings.SourceCodeStickyScroll, _previewSettings.SourceCodeFontSize, _previewSettings.SourceCodeMinimap));
|
||||
}
|
||||
else
|
||||
{
|
||||
// Simple html file to preview. Shouldn't do things like enabling scripts or using a virtual mapped directory.
|
||||
IsDevFilePreview = false;
|
||||
// Fallback for other supported file types (e.g., PDF)
|
||||
Preview = new Uri(File.Path);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -250,9 +250,17 @@
|
||||
<comment>Dialog showed when an URI is clicked. Button to copy the URI.</comment>
|
||||
</data>
|
||||
<data name="OpenUriDialog.Title" xml:space="preserve">
|
||||
<value>Do you want Peek to open the external application?</value>
|
||||
<value>Do you want Peek to open this link?</value>
|
||||
<comment>Title of the dialog showed when an URI is clicked,"Peek" is the name of the utility. </comment>
|
||||
</data>
|
||||
<data name="OpenUriWarningBanner.Title" xml:space="preserve">
|
||||
<value>Security Warning</value>
|
||||
<comment>Title of the warning banner in the open URI dialog.</comment>
|
||||
</data>
|
||||
<data name="OpenUriWarningBanner.Message" xml:space="preserve">
|
||||
<value>This link will open externally. Make sure you trust the source before proceeding.</value>
|
||||
<comment>Warning message displayed in the banner when opening an external URI.</comment>
|
||||
</data>
|
||||
<data name="ReadableString_BytesString" xml:space="preserve">
|
||||
<value> ({1:N0} bytes)</value>
|
||||
<comment>Displays total number of bytes. Don't localize the "{1:N0}" part.</comment>
|
||||
|
||||
@@ -347,14 +347,19 @@ bool isMetadataUsed(_In_ PCWSTR source, PowerRenameLib::MetadataType metadataTyp
|
||||
|
||||
// According to the metadata support table, only these formats support metadata extraction:
|
||||
// - JPEG (IFD, Exif, XMP, GPS, IPTC) - supports fast metadata encoding
|
||||
// - TIFF (IFD, Exif, XMP, GPS, IPTC) - supports fast metadata encoding
|
||||
// - TIFF (IFD, Exif, XMP, GPS, IPTC) - supports fast metadata encoding
|
||||
// - PNG (text chunks)
|
||||
// - HEIF/HEIC (IFD, Exif, XMP, GPS) - requires HEIF Image Extensions from Microsoft Store
|
||||
// - AVIF (IFD, Exif, XMP, GPS) - requires AV1 Video Extension from Microsoft Store
|
||||
static const std::unordered_set<std::wstring> supportedExtensions = {
|
||||
L".jpg",
|
||||
L".jpeg",
|
||||
L".png",
|
||||
L".tif",
|
||||
L".tiff"
|
||||
L".tiff",
|
||||
L".heic",
|
||||
L".heif",
|
||||
L".avif"
|
||||
};
|
||||
|
||||
// If file type doesn't support metadata, no need to check patterns
|
||||
|
||||
@@ -754,8 +754,26 @@ DWORD WINAPI CPowerRenameManager::s_fileOpWorkerThread(_In_ void* pv)
|
||||
// We add the items to the operation in depth-first order. This allows child items to be
|
||||
// renamed before parent items.
|
||||
|
||||
// First pass: find the maximum depth to properly size the matrix
|
||||
UINT maxDepth = 0;
|
||||
for (UINT u = 0; u < itemCount; u++)
|
||||
{
|
||||
CComPtr<IPowerRenameItem> spItem;
|
||||
if (SUCCEEDED(pwtd->spsrm->GetItemByIndex(u, &spItem)))
|
||||
{
|
||||
UINT depth = 0;
|
||||
spItem->GetDepth(&depth);
|
||||
if (depth > maxDepth)
|
||||
{
|
||||
maxDepth = depth;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Creating a vector of vectors of items of the same depth
|
||||
std::vector<std::vector<UINT>> matrix(itemCount);
|
||||
// Size by maxDepth+1 (not itemCount) to avoid excessive memory allocation
|
||||
// Cast to size_t before arithmetic to avoid overflow on 32-bit UINT
|
||||
std::vector<std::vector<UINT>> matrix(static_cast<size_t>(maxDepth) + 1);
|
||||
|
||||
for (UINT u = 0; u < itemCount; u++)
|
||||
{
|
||||
@@ -769,7 +787,7 @@ DWORD WINAPI CPowerRenameManager::s_fileOpWorkerThread(_In_ void* pv)
|
||||
}
|
||||
|
||||
// From the greatest depth first, add all items of that depth to the operation
|
||||
for (LONG v = itemCount - 1; v >= 0; v--)
|
||||
for (LONG v = static_cast<LONG>(maxDepth); v >= 0; v--)
|
||||
{
|
||||
for (auto it : matrix[v])
|
||||
{
|
||||
|
||||
@@ -20,7 +20,7 @@ namespace
|
||||
|
||||
// WIC metadata property paths
|
||||
const std::wstring EXIF_DATE_TAKEN = L"/app1/ifd/exif/{ushort=36867}"; // DateTimeOriginal
|
||||
const std::wstring EXIF_DATE_DIGITIZED = L"/app1/ifd/exif/{ushort=36868}"; // DateTimeDigitized
|
||||
const std::wstring EXIF_DATE_DIGITIZED = L"/app1/ifd/exif/{ushort=36868}"; // DateTimeDigitized
|
||||
const std::wstring EXIF_DATE_MODIFIED = L"/app1/ifd/{ushort=306}"; // DateTime
|
||||
const std::wstring EXIF_CAMERA_MAKE = L"/app1/ifd/{ushort=271}"; // Make
|
||||
const std::wstring EXIF_CAMERA_MODEL = L"/app1/ifd/{ushort=272}"; // Model
|
||||
@@ -37,14 +37,43 @@ namespace
|
||||
const std::wstring EXIF_HEIGHT = L"/app1/ifd/exif/{ushort=40963}"; // PixelYDimension - actual image height
|
||||
const std::wstring EXIF_ARTIST = L"/app1/ifd/{ushort=315}"; // Artist
|
||||
const std::wstring EXIF_COPYRIGHT = L"/app1/ifd/{ushort=33432}"; // Copyright
|
||||
|
||||
// GPS paths
|
||||
|
||||
// GPS paths for JPEG format
|
||||
const std::wstring GPS_LATITUDE = L"/app1/ifd/gps/{ushort=2}"; // GPSLatitude
|
||||
const std::wstring GPS_LATITUDE_REF = L"/app1/ifd/gps/{ushort=1}"; // GPSLatitudeRef
|
||||
const std::wstring GPS_LONGITUDE = L"/app1/ifd/gps/{ushort=4}"; // GPSLongitude
|
||||
const std::wstring GPS_LONGITUDE_REF = L"/app1/ifd/gps/{ushort=3}"; // GPSLongitudeRef
|
||||
const std::wstring GPS_ALTITUDE = L"/app1/ifd/gps/{ushort=6}"; // GPSAltitude
|
||||
const std::wstring GPS_ALTITUDE_REF = L"/app1/ifd/gps/{ushort=5}"; // GPSAltitudeRef
|
||||
|
||||
// WIC metadata property paths for TIFF/HEIF format (uses /ifd prefix directly)
|
||||
// HEIF/HEIC images use TIFF-style metadata paths
|
||||
const std::wstring HEIF_DATE_TAKEN = L"/ifd/exif/{ushort=36867}"; // DateTimeOriginal
|
||||
const std::wstring HEIF_DATE_DIGITIZED = L"/ifd/exif/{ushort=36868}"; // DateTimeDigitized
|
||||
const std::wstring HEIF_DATE_MODIFIED = L"/ifd/{ushort=306}"; // DateTime
|
||||
const std::wstring HEIF_CAMERA_MAKE = L"/ifd/{ushort=271}"; // Make
|
||||
const std::wstring HEIF_CAMERA_MODEL = L"/ifd/{ushort=272}"; // Model
|
||||
const std::wstring HEIF_LENS_MODEL = L"/ifd/exif/{ushort=42036}"; // LensModel
|
||||
const std::wstring HEIF_ISO = L"/ifd/exif/{ushort=34855}"; // ISOSpeedRatings
|
||||
const std::wstring HEIF_APERTURE = L"/ifd/exif/{ushort=33437}"; // FNumber
|
||||
const std::wstring HEIF_SHUTTER_SPEED = L"/ifd/exif/{ushort=33434}"; // ExposureTime
|
||||
const std::wstring HEIF_FOCAL_LENGTH = L"/ifd/exif/{ushort=37386}"; // FocalLength
|
||||
const std::wstring HEIF_EXPOSURE_BIAS = L"/ifd/exif/{ushort=37380}"; // ExposureBiasValue
|
||||
const std::wstring HEIF_FLASH = L"/ifd/exif/{ushort=37385}"; // Flash
|
||||
const std::wstring HEIF_ORIENTATION = L"/ifd/{ushort=274}"; // Orientation
|
||||
const std::wstring HEIF_COLOR_SPACE = L"/ifd/exif/{ushort=40961}"; // ColorSpace
|
||||
const std::wstring HEIF_WIDTH = L"/ifd/exif/{ushort=40962}"; // PixelXDimension
|
||||
const std::wstring HEIF_HEIGHT = L"/ifd/exif/{ushort=40963}"; // PixelYDimension
|
||||
const std::wstring HEIF_ARTIST = L"/ifd/{ushort=315}"; // Artist
|
||||
const std::wstring HEIF_COPYRIGHT = L"/ifd/{ushort=33432}"; // Copyright
|
||||
|
||||
// GPS paths for TIFF/HEIF format
|
||||
const std::wstring HEIF_GPS_LATITUDE = L"/ifd/gps/{ushort=2}"; // GPSLatitude
|
||||
const std::wstring HEIF_GPS_LATITUDE_REF = L"/ifd/gps/{ushort=1}"; // GPSLatitudeRef
|
||||
const std::wstring HEIF_GPS_LONGITUDE = L"/ifd/gps/{ushort=4}"; // GPSLongitude
|
||||
const std::wstring HEIF_GPS_LONGITUDE_REF = L"/ifd/gps/{ushort=3}"; // GPSLongitudeRef
|
||||
const std::wstring HEIF_GPS_ALTITUDE = L"/ifd/gps/{ushort=6}"; // GPSAltitude
|
||||
const std::wstring HEIF_GPS_ALTITUDE_REF = L"/ifd/gps/{ushort=5}"; // GPSAltitudeRef
|
||||
|
||||
|
||||
// Documentation: https://developer.adobe.com/xmp/docs/XMPNamespaces/xmp/
|
||||
@@ -465,8 +494,11 @@ bool WICMetadataExtractor::LoadEXIFMetadata(
|
||||
return false;
|
||||
}
|
||||
|
||||
ExtractAllEXIFFields(reader, outMetadata);
|
||||
ExtractGPSData(reader, outMetadata);
|
||||
// Detect container format to determine correct metadata paths
|
||||
MetadataPathFormat pathFormat = GetMetadataPathFormatFromDecoder(decoder);
|
||||
|
||||
ExtractAllEXIFFields(reader, outMetadata, pathFormat);
|
||||
ExtractGPSData(reader, outMetadata, pathFormat);
|
||||
|
||||
return true;
|
||||
}
|
||||
@@ -507,64 +539,126 @@ CComPtr<IWICMetadataQueryReader> WICMetadataExtractor::GetMetadataReader(IWICBit
|
||||
{
|
||||
return nullptr;
|
||||
}
|
||||
|
||||
|
||||
CComPtr<IWICBitmapFrameDecode> frame;
|
||||
if (FAILED(decoder->GetFrame(0, &frame)))
|
||||
{
|
||||
return nullptr;
|
||||
}
|
||||
|
||||
|
||||
CComPtr<IWICMetadataQueryReader> reader;
|
||||
frame->GetMetadataQueryReader(&reader);
|
||||
|
||||
|
||||
return reader;
|
||||
}
|
||||
|
||||
void WICMetadataExtractor::ExtractAllEXIFFields(IWICMetadataQueryReader* reader, EXIFMetadata& metadata)
|
||||
MetadataPathFormat WICMetadataExtractor::GetMetadataPathFormatFromDecoder(IWICBitmapDecoder* decoder)
|
||||
{
|
||||
if (!decoder)
|
||||
{
|
||||
return MetadataPathFormat::JPEG;
|
||||
}
|
||||
|
||||
GUID containerFormat;
|
||||
if (SUCCEEDED(decoder->GetContainerFormat(&containerFormat)))
|
||||
{
|
||||
// HEIF and TIFF use /ifd/... paths directly
|
||||
if (containerFormat == GUID_ContainerFormatHeif ||
|
||||
containerFormat == GUID_ContainerFormatTiff)
|
||||
{
|
||||
return MetadataPathFormat::IFD;
|
||||
}
|
||||
}
|
||||
|
||||
// JPEG and other formats use /app1/ifd/... paths
|
||||
return MetadataPathFormat::JPEG;
|
||||
}
|
||||
|
||||
void WICMetadataExtractor::ExtractAllEXIFFields(IWICMetadataQueryReader* reader, EXIFMetadata& metadata, MetadataPathFormat pathFormat)
|
||||
{
|
||||
if (!reader)
|
||||
return;
|
||||
|
||||
|
||||
// Select the correct paths based on container format
|
||||
const bool useIfdPaths = (pathFormat == MetadataPathFormat::IFD);
|
||||
|
||||
// Date/time paths
|
||||
const auto& dateTakenPath = useIfdPaths ? HEIF_DATE_TAKEN : EXIF_DATE_TAKEN;
|
||||
const auto& dateDigitizedPath = useIfdPaths ? HEIF_DATE_DIGITIZED : EXIF_DATE_DIGITIZED;
|
||||
const auto& dateModifiedPath = useIfdPaths ? HEIF_DATE_MODIFIED : EXIF_DATE_MODIFIED;
|
||||
|
||||
// Camera info paths
|
||||
const auto& cameraMakePath = useIfdPaths ? HEIF_CAMERA_MAKE : EXIF_CAMERA_MAKE;
|
||||
const auto& cameraModelPath = useIfdPaths ? HEIF_CAMERA_MODEL : EXIF_CAMERA_MODEL;
|
||||
const auto& lensModelPath = useIfdPaths ? HEIF_LENS_MODEL : EXIF_LENS_MODEL;
|
||||
|
||||
// Shooting parameter paths
|
||||
const auto& isoPath = useIfdPaths ? HEIF_ISO : EXIF_ISO;
|
||||
const auto& aperturePath = useIfdPaths ? HEIF_APERTURE : EXIF_APERTURE;
|
||||
const auto& shutterSpeedPath = useIfdPaths ? HEIF_SHUTTER_SPEED : EXIF_SHUTTER_SPEED;
|
||||
const auto& focalLengthPath = useIfdPaths ? HEIF_FOCAL_LENGTH : EXIF_FOCAL_LENGTH;
|
||||
const auto& exposureBiasPath = useIfdPaths ? HEIF_EXPOSURE_BIAS : EXIF_EXPOSURE_BIAS;
|
||||
const auto& flashPath = useIfdPaths ? HEIF_FLASH : EXIF_FLASH;
|
||||
|
||||
// Image property paths
|
||||
const auto& widthPath = useIfdPaths ? HEIF_WIDTH : EXIF_WIDTH;
|
||||
const auto& heightPath = useIfdPaths ? HEIF_HEIGHT : EXIF_HEIGHT;
|
||||
const auto& orientationPath = useIfdPaths ? HEIF_ORIENTATION : EXIF_ORIENTATION;
|
||||
const auto& colorSpacePath = useIfdPaths ? HEIF_COLOR_SPACE : EXIF_COLOR_SPACE;
|
||||
|
||||
// Author info paths
|
||||
const auto& artistPath = useIfdPaths ? HEIF_ARTIST : EXIF_ARTIST;
|
||||
const auto& copyrightPath = useIfdPaths ? HEIF_COPYRIGHT : EXIF_COPYRIGHT;
|
||||
|
||||
// Extract date/time fields
|
||||
metadata.dateTaken = ReadDateTime(reader, EXIF_DATE_TAKEN);
|
||||
metadata.dateDigitized = ReadDateTime(reader, EXIF_DATE_DIGITIZED);
|
||||
metadata.dateModified = ReadDateTime(reader, EXIF_DATE_MODIFIED);
|
||||
|
||||
metadata.dateTaken = ReadDateTime(reader, dateTakenPath);
|
||||
metadata.dateDigitized = ReadDateTime(reader, dateDigitizedPath);
|
||||
metadata.dateModified = ReadDateTime(reader, dateModifiedPath);
|
||||
|
||||
// Extract camera information
|
||||
metadata.cameraMake = ReadString(reader, EXIF_CAMERA_MAKE);
|
||||
metadata.cameraModel = ReadString(reader, EXIF_CAMERA_MODEL);
|
||||
metadata.lensModel = ReadString(reader, EXIF_LENS_MODEL);
|
||||
|
||||
metadata.cameraMake = ReadString(reader, cameraMakePath);
|
||||
metadata.cameraModel = ReadString(reader, cameraModelPath);
|
||||
metadata.lensModel = ReadString(reader, lensModelPath);
|
||||
|
||||
// Extract shooting parameters
|
||||
metadata.iso = ReadInteger(reader, EXIF_ISO);
|
||||
metadata.aperture = ReadDouble(reader, EXIF_APERTURE);
|
||||
metadata.shutterSpeed = ReadDouble(reader, EXIF_SHUTTER_SPEED);
|
||||
metadata.focalLength = ReadDouble(reader, EXIF_FOCAL_LENGTH);
|
||||
metadata.exposureBias = ReadDouble(reader, EXIF_EXPOSURE_BIAS);
|
||||
metadata.flash = ReadInteger(reader, EXIF_FLASH);
|
||||
|
||||
metadata.iso = ReadInteger(reader, isoPath);
|
||||
metadata.aperture = ReadDouble(reader, aperturePath);
|
||||
metadata.shutterSpeed = ReadDouble(reader, shutterSpeedPath);
|
||||
metadata.focalLength = ReadDouble(reader, focalLengthPath);
|
||||
metadata.exposureBias = ReadDouble(reader, exposureBiasPath);
|
||||
metadata.flash = ReadInteger(reader, flashPath);
|
||||
|
||||
// Extract image properties
|
||||
metadata.width = ReadInteger(reader, EXIF_WIDTH);
|
||||
metadata.height = ReadInteger(reader, EXIF_HEIGHT);
|
||||
metadata.orientation = ReadInteger(reader, EXIF_ORIENTATION);
|
||||
metadata.colorSpace = ReadInteger(reader, EXIF_COLOR_SPACE);
|
||||
|
||||
metadata.width = ReadInteger(reader, widthPath);
|
||||
metadata.height = ReadInteger(reader, heightPath);
|
||||
metadata.orientation = ReadInteger(reader, orientationPath);
|
||||
metadata.colorSpace = ReadInteger(reader, colorSpacePath);
|
||||
|
||||
// Extract author information
|
||||
metadata.author = ReadString(reader, EXIF_ARTIST);
|
||||
metadata.copyright = ReadString(reader, EXIF_COPYRIGHT);
|
||||
metadata.author = ReadString(reader, artistPath);
|
||||
metadata.copyright = ReadString(reader, copyrightPath);
|
||||
}
|
||||
|
||||
void WICMetadataExtractor::ExtractGPSData(IWICMetadataQueryReader* reader, EXIFMetadata& metadata)
|
||||
void WICMetadataExtractor::ExtractGPSData(IWICMetadataQueryReader* reader, EXIFMetadata& metadata, MetadataPathFormat pathFormat)
|
||||
{
|
||||
if (!reader)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
auto lat = ReadMetadata(reader, GPS_LATITUDE);
|
||||
auto lon = ReadMetadata(reader, GPS_LONGITUDE);
|
||||
auto latRef = ReadMetadata(reader, GPS_LATITUDE_REF);
|
||||
auto lonRef = ReadMetadata(reader, GPS_LONGITUDE_REF);
|
||||
// Select the correct paths based on container format
|
||||
const bool useIfdPaths = (pathFormat == MetadataPathFormat::IFD);
|
||||
|
||||
const auto& latitudePath = useIfdPaths ? HEIF_GPS_LATITUDE : GPS_LATITUDE;
|
||||
const auto& longitudePath = useIfdPaths ? HEIF_GPS_LONGITUDE : GPS_LONGITUDE;
|
||||
const auto& latitudeRefPath = useIfdPaths ? HEIF_GPS_LATITUDE_REF : GPS_LATITUDE_REF;
|
||||
const auto& longitudeRefPath = useIfdPaths ? HEIF_GPS_LONGITUDE_REF : GPS_LONGITUDE_REF;
|
||||
const auto& altitudePath = useIfdPaths ? HEIF_GPS_ALTITUDE : GPS_ALTITUDE;
|
||||
|
||||
auto lat = ReadMetadata(reader, latitudePath);
|
||||
auto lon = ReadMetadata(reader, longitudePath);
|
||||
auto latRef = ReadMetadata(reader, latitudeRefPath);
|
||||
auto lonRef = ReadMetadata(reader, longitudeRefPath);
|
||||
|
||||
if (lat && lon)
|
||||
{
|
||||
@@ -584,7 +678,7 @@ void WICMetadataExtractor::ExtractGPSData(IWICMetadataQueryReader* reader, EXIFM
|
||||
metadata.longitude = coords.second;
|
||||
}
|
||||
|
||||
auto alt = ReadMetadata(reader, GPS_ALTITUDE);
|
||||
auto alt = ReadMetadata(reader, altitudePath);
|
||||
if (alt)
|
||||
{
|
||||
metadata.altitude = MetadataFormatHelper::ParseGPSRational(alt->Get());
|
||||
|
||||
@@ -9,14 +9,32 @@
|
||||
#include <wincodec.h>
|
||||
#include <atlbase.h>
|
||||
|
||||
// Forward declarations for unit test friend classes
|
||||
namespace WICMetadataExtractorTests
|
||||
{
|
||||
class ExtractAVIFMetadataTests;
|
||||
}
|
||||
|
||||
namespace PowerRenameLib
|
||||
{
|
||||
/// <summary>
|
||||
/// Metadata path format based on container type
|
||||
/// </summary>
|
||||
enum class MetadataPathFormat
|
||||
{
|
||||
JPEG, // Uses /app1/ifd/... paths (JPEG)
|
||||
IFD // Uses /ifd/... paths (HEIF, TIFF, etc.)
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Windows Imaging Component (WIC) implementation for metadata extraction
|
||||
/// Provides efficient batch extraction of all metadata types with built-in caching
|
||||
/// </summary>
|
||||
class WICMetadataExtractor
|
||||
{
|
||||
// Friend declarations for unit testing
|
||||
friend class WICMetadataExtractorTests::ExtractAVIFMetadataTests;
|
||||
|
||||
public:
|
||||
WICMetadataExtractor();
|
||||
~WICMetadataExtractor();
|
||||
@@ -45,10 +63,13 @@ namespace PowerRenameLib
|
||||
bool LoadXMPMetadata(const std::wstring& filePath, XMPMetadata& outMetadata);
|
||||
|
||||
// Batch extraction methods
|
||||
void ExtractAllEXIFFields(IWICMetadataQueryReader* reader, EXIFMetadata& metadata);
|
||||
void ExtractGPSData(IWICMetadataQueryReader* reader, EXIFMetadata& metadata);
|
||||
void ExtractAllEXIFFields(IWICMetadataQueryReader* reader, EXIFMetadata& metadata, MetadataPathFormat pathFormat);
|
||||
void ExtractGPSData(IWICMetadataQueryReader* reader, EXIFMetadata& metadata, MetadataPathFormat pathFormat);
|
||||
void ExtractAllXMPFields(IWICMetadataQueryReader* reader, XMPMetadata& metadata);
|
||||
|
||||
// Internal container format detection
|
||||
MetadataPathFormat GetMetadataPathFormatFromDecoder(IWICBitmapDecoder* decoder);
|
||||
|
||||
// Field reading helpers
|
||||
std::optional<SYSTEMTIME> ReadDateTime(IWICMetadataQueryReader* reader, const std::wstring& path);
|
||||
std::optional<std::wstring> ReadString(IWICMetadataQueryReader* reader, const std::wstring& path);
|
||||
|
||||
@@ -89,6 +89,12 @@
|
||||
<None Include="testdata\xmp_test_2.jpg">
|
||||
<DeploymentContent>true</DeploymentContent>
|
||||
</None>
|
||||
<None Include="testdata\heif_test.heic">
|
||||
<DeploymentContent>true</DeploymentContent>
|
||||
</None>
|
||||
<None Include="testdata\avif_test.avif">
|
||||
<DeploymentContent>true</DeploymentContent>
|
||||
</None>
|
||||
<None Include="testdata\ATTRIBUTION.md">
|
||||
<DeploymentContent>true</DeploymentContent>
|
||||
</None>
|
||||
|
||||
@@ -227,18 +227,350 @@ namespace WICMetadataExtractorTests
|
||||
XMPMetadata metadata;
|
||||
|
||||
std::wstring testFile = GetTestDataPath() + L"\\xmp_test.jpg";
|
||||
|
||||
|
||||
bool result1 = extractor.ExtractXMPMetadata(testFile, metadata);
|
||||
Assert::IsTrue(result1);
|
||||
|
||||
|
||||
extractor.ClearCache();
|
||||
|
||||
|
||||
XMPMetadata metadata2;
|
||||
bool result2 = extractor.ExtractXMPMetadata(testFile, metadata2);
|
||||
Assert::IsTrue(result2);
|
||||
|
||||
|
||||
// Both calls should succeed
|
||||
Assert::AreEqual(metadata.title.value().c_str(), metadata2.title.value().c_str());
|
||||
}
|
||||
};
|
||||
|
||||
TEST_CLASS(ExtractHEIFMetadataTests)
|
||||
{
|
||||
public:
|
||||
TEST_METHOD(ExtractHEIF_EXIF_CameraInfo)
|
||||
{
|
||||
// Test HEIF EXIF extraction - camera information
|
||||
// This test requires HEIF Image Extensions to be installed from Microsoft Store
|
||||
WICMetadataExtractor extractor;
|
||||
EXIFMetadata metadata;
|
||||
|
||||
std::wstring testFile = GetTestDataPath() + L"\\heif_test.heic";
|
||||
|
||||
// Check if file exists first
|
||||
if (!std::filesystem::exists(testFile))
|
||||
{
|
||||
Logger::WriteMessage(L"HEIF test file not found, skipping test");
|
||||
return;
|
||||
}
|
||||
|
||||
bool result = extractor.ExtractEXIFMetadata(testFile, metadata);
|
||||
|
||||
// If HEIF extension is not installed, extraction may fail
|
||||
if (!result)
|
||||
{
|
||||
Logger::WriteMessage(L"HEIF extraction failed - HEIF Image Extensions may not be installed");
|
||||
return;
|
||||
}
|
||||
|
||||
Assert::IsTrue(result, L"HEIF EXIF extraction should succeed");
|
||||
|
||||
// Verify camera information from iPhone
|
||||
Assert::IsTrue(metadata.cameraMake.has_value(), L"Camera make should be present");
|
||||
Assert::AreEqual(L"Apple", metadata.cameraMake.value().c_str(), L"Camera make should be Apple");
|
||||
|
||||
Assert::IsTrue(metadata.cameraModel.has_value(), L"Camera model should be present");
|
||||
// Model should contain "iPhone"
|
||||
Assert::IsTrue(metadata.cameraModel.value().find(L"iPhone") != std::wstring::npos,
|
||||
L"Camera model should contain iPhone");
|
||||
}
|
||||
|
||||
TEST_METHOD(ExtractHEIF_EXIF_DateTaken)
|
||||
{
|
||||
// Test HEIF EXIF extraction - date taken
|
||||
WICMetadataExtractor extractor;
|
||||
EXIFMetadata metadata;
|
||||
|
||||
std::wstring testFile = GetTestDataPath() + L"\\heif_test.heic";
|
||||
|
||||
if (!std::filesystem::exists(testFile))
|
||||
{
|
||||
Logger::WriteMessage(L"HEIF test file not found, skipping test");
|
||||
return;
|
||||
}
|
||||
|
||||
bool result = extractor.ExtractEXIFMetadata(testFile, metadata);
|
||||
|
||||
if (!result)
|
||||
{
|
||||
Logger::WriteMessage(L"HEIF extraction failed - HEIF Image Extensions may not be installed");
|
||||
return;
|
||||
}
|
||||
|
||||
Assert::IsTrue(result, L"HEIF EXIF extraction should succeed");
|
||||
|
||||
// Verify date taken is present
|
||||
Assert::IsTrue(metadata.dateTaken.has_value(), L"Date taken should be present");
|
||||
|
||||
// Verify the date is a reasonable year (2020-2030 range)
|
||||
SYSTEMTIME dt = metadata.dateTaken.value();
|
||||
Assert::IsTrue(dt.wYear >= 2020 && dt.wYear <= 2030, L"Date taken year should be reasonable");
|
||||
}
|
||||
|
||||
TEST_METHOD(ExtractHEIF_EXIF_ShootingParameters)
|
||||
{
|
||||
// Test HEIF EXIF extraction - shooting parameters
|
||||
WICMetadataExtractor extractor;
|
||||
EXIFMetadata metadata;
|
||||
|
||||
std::wstring testFile = GetTestDataPath() + L"\\heif_test.heic";
|
||||
|
||||
if (!std::filesystem::exists(testFile))
|
||||
{
|
||||
Logger::WriteMessage(L"HEIF test file not found, skipping test");
|
||||
return;
|
||||
}
|
||||
|
||||
bool result = extractor.ExtractEXIFMetadata(testFile, metadata);
|
||||
|
||||
if (!result)
|
||||
{
|
||||
Logger::WriteMessage(L"HEIF extraction failed - HEIF Image Extensions may not be installed");
|
||||
return;
|
||||
}
|
||||
|
||||
Assert::IsTrue(result, L"HEIF EXIF extraction should succeed");
|
||||
|
||||
// Verify shooting parameters are present
|
||||
Assert::IsTrue(metadata.iso.has_value(), L"ISO should be present");
|
||||
Assert::IsTrue(metadata.iso.value() > 0, L"ISO should be positive");
|
||||
|
||||
Assert::IsTrue(metadata.aperture.has_value(), L"Aperture should be present");
|
||||
Assert::IsTrue(metadata.aperture.value() > 0, L"Aperture should be positive");
|
||||
|
||||
Assert::IsTrue(metadata.shutterSpeed.has_value(), L"Shutter speed should be present");
|
||||
Assert::IsTrue(metadata.shutterSpeed.value() > 0, L"Shutter speed should be positive");
|
||||
|
||||
Assert::IsTrue(metadata.focalLength.has_value(), L"Focal length should be present");
|
||||
Assert::IsTrue(metadata.focalLength.value() > 0, L"Focal length should be positive");
|
||||
}
|
||||
|
||||
TEST_METHOD(ExtractHEIF_EXIF_GPS)
|
||||
{
|
||||
// Test HEIF EXIF extraction - GPS coordinates
|
||||
WICMetadataExtractor extractor;
|
||||
EXIFMetadata metadata;
|
||||
|
||||
std::wstring testFile = GetTestDataPath() + L"\\heif_test.heic";
|
||||
|
||||
if (!std::filesystem::exists(testFile))
|
||||
{
|
||||
Logger::WriteMessage(L"HEIF test file not found, skipping test");
|
||||
return;
|
||||
}
|
||||
|
||||
bool result = extractor.ExtractEXIFMetadata(testFile, metadata);
|
||||
|
||||
if (!result)
|
||||
{
|
||||
Logger::WriteMessage(L"HEIF extraction failed - HEIF Image Extensions may not be installed");
|
||||
return;
|
||||
}
|
||||
|
||||
Assert::IsTrue(result, L"HEIF EXIF extraction should succeed");
|
||||
|
||||
// Verify GPS coordinates are present (if the test file has GPS data)
|
||||
if (metadata.latitude.has_value() && metadata.longitude.has_value())
|
||||
{
|
||||
// Latitude should be between -90 and 90
|
||||
Assert::IsTrue(metadata.latitude.value() >= -90.0 && metadata.latitude.value() <= 90.0,
|
||||
L"Latitude should be valid");
|
||||
|
||||
// Longitude should be between -180 and 180
|
||||
Assert::IsTrue(metadata.longitude.value() >= -180.0 && metadata.longitude.value() <= 180.0,
|
||||
L"Longitude should be valid");
|
||||
}
|
||||
else
|
||||
{
|
||||
Logger::WriteMessage(L"GPS data not present in test file");
|
||||
}
|
||||
}
|
||||
|
||||
TEST_METHOD(ExtractHEIF_EXIF_ImageDimensions)
|
||||
{
|
||||
// Test HEIF EXIF extraction - image dimensions
|
||||
WICMetadataExtractor extractor;
|
||||
EXIFMetadata metadata;
|
||||
|
||||
std::wstring testFile = GetTestDataPath() + L"\\heif_test.heic";
|
||||
|
||||
if (!std::filesystem::exists(testFile))
|
||||
{
|
||||
Logger::WriteMessage(L"HEIF test file not found, skipping test");
|
||||
return;
|
||||
}
|
||||
|
||||
bool result = extractor.ExtractEXIFMetadata(testFile, metadata);
|
||||
|
||||
if (!result)
|
||||
{
|
||||
Logger::WriteMessage(L"HEIF extraction failed - HEIF Image Extensions may not be installed");
|
||||
return;
|
||||
}
|
||||
|
||||
Assert::IsTrue(result, L"HEIF EXIF extraction should succeed");
|
||||
|
||||
// Verify image dimensions are present
|
||||
Assert::IsTrue(metadata.width.has_value(), L"Width should be present");
|
||||
Assert::IsTrue(metadata.width.value() > 0, L"Width should be positive");
|
||||
|
||||
Assert::IsTrue(metadata.height.has_value(), L"Height should be present");
|
||||
Assert::IsTrue(metadata.height.value() > 0, L"Height should be positive");
|
||||
}
|
||||
|
||||
TEST_METHOD(ExtractHEIF_EXIF_LensModel)
|
||||
{
|
||||
// Test HEIF EXIF extraction - lens model
|
||||
WICMetadataExtractor extractor;
|
||||
EXIFMetadata metadata;
|
||||
|
||||
std::wstring testFile = GetTestDataPath() + L"\\heif_test.heic";
|
||||
|
||||
if (!std::filesystem::exists(testFile))
|
||||
{
|
||||
Logger::WriteMessage(L"HEIF test file not found, skipping test");
|
||||
return;
|
||||
}
|
||||
|
||||
bool result = extractor.ExtractEXIFMetadata(testFile, metadata);
|
||||
|
||||
if (!result)
|
||||
{
|
||||
Logger::WriteMessage(L"HEIF extraction failed - HEIF Image Extensions may not be installed");
|
||||
return;
|
||||
}
|
||||
|
||||
Assert::IsTrue(result, L"HEIF EXIF extraction should succeed");
|
||||
|
||||
// Verify lens model is present (iPhone photos typically have this)
|
||||
if (metadata.lensModel.has_value())
|
||||
{
|
||||
Assert::IsFalse(metadata.lensModel.value().empty(), L"Lens model should not be empty");
|
||||
}
|
||||
else
|
||||
{
|
||||
Logger::WriteMessage(L"Lens model not present in test file");
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
TEST_CLASS(ExtractAVIFMetadataTests)
|
||||
{
|
||||
public:
|
||||
TEST_METHOD(ExtractAVIF_EXIF_CameraInfo)
|
||||
{
|
||||
// Test AVIF EXIF extraction - camera information
|
||||
// This test requires AV1 Video Extension to be installed from Microsoft Store
|
||||
WICMetadataExtractor extractor;
|
||||
EXIFMetadata metadata;
|
||||
|
||||
std::wstring testFile = GetTestDataPath() + L"\\avif_test.avif";
|
||||
|
||||
if (!std::filesystem::exists(testFile))
|
||||
{
|
||||
Logger::WriteMessage(L"AVIF test file not found, skipping test");
|
||||
return;
|
||||
}
|
||||
|
||||
bool result = extractor.ExtractEXIFMetadata(testFile, metadata);
|
||||
|
||||
if (!result)
|
||||
{
|
||||
Logger::WriteMessage(L"AVIF extraction failed - AV1 Video Extension may not be installed");
|
||||
return;
|
||||
}
|
||||
|
||||
Assert::IsTrue(result, L"AVIF EXIF extraction should succeed");
|
||||
|
||||
// Verify camera information
|
||||
if (metadata.cameraMake.has_value())
|
||||
{
|
||||
Assert::IsFalse(metadata.cameraMake.value().empty(), L"Camera make should not be empty");
|
||||
}
|
||||
|
||||
if (metadata.cameraModel.has_value())
|
||||
{
|
||||
Assert::IsFalse(metadata.cameraModel.value().empty(), L"Camera model should not be empty");
|
||||
}
|
||||
}
|
||||
|
||||
TEST_METHOD(ExtractAVIF_EXIF_DateTaken)
|
||||
{
|
||||
// Test AVIF EXIF extraction - date taken
|
||||
WICMetadataExtractor extractor;
|
||||
EXIFMetadata metadata;
|
||||
|
||||
std::wstring testFile = GetTestDataPath() + L"\\avif_test.avif";
|
||||
|
||||
if (!std::filesystem::exists(testFile))
|
||||
{
|
||||
Logger::WriteMessage(L"AVIF test file not found, skipping test");
|
||||
return;
|
||||
}
|
||||
|
||||
bool result = extractor.ExtractEXIFMetadata(testFile, metadata);
|
||||
|
||||
if (!result)
|
||||
{
|
||||
Logger::WriteMessage(L"AVIF extraction failed - AV1 Video Extension may not be installed");
|
||||
return;
|
||||
}
|
||||
|
||||
Assert::IsTrue(result, L"AVIF EXIF extraction should succeed");
|
||||
|
||||
// Verify date taken is present
|
||||
if (metadata.dateTaken.has_value())
|
||||
{
|
||||
SYSTEMTIME dt = metadata.dateTaken.value();
|
||||
Assert::IsTrue(dt.wYear >= 2000 && dt.wYear <= 2100, L"Date taken year should be reasonable");
|
||||
}
|
||||
else
|
||||
{
|
||||
Logger::WriteMessage(L"Date taken not present in AVIF test file");
|
||||
}
|
||||
}
|
||||
|
||||
TEST_METHOD(ExtractAVIF_EXIF_ImageDimensions)
|
||||
{
|
||||
// Test AVIF EXIF extraction - image dimensions
|
||||
WICMetadataExtractor extractor;
|
||||
EXIFMetadata metadata;
|
||||
|
||||
std::wstring testFile = GetTestDataPath() + L"\\avif_test.avif";
|
||||
|
||||
if (!std::filesystem::exists(testFile))
|
||||
{
|
||||
Logger::WriteMessage(L"AVIF test file not found, skipping test");
|
||||
return;
|
||||
}
|
||||
|
||||
bool result = extractor.ExtractEXIFMetadata(testFile, metadata);
|
||||
|
||||
if (!result)
|
||||
{
|
||||
Logger::WriteMessage(L"AVIF extraction failed - AV1 Video Extension may not be installed");
|
||||
return;
|
||||
}
|
||||
|
||||
Assert::IsTrue(result, L"AVIF EXIF extraction should succeed");
|
||||
|
||||
// Verify image dimensions are present
|
||||
if (metadata.width.has_value())
|
||||
{
|
||||
Assert::IsTrue(metadata.width.value() > 0, L"Width should be positive");
|
||||
}
|
||||
|
||||
if (metadata.height.has_value())
|
||||
{
|
||||
Assert::IsTrue(metadata.height.value() > 0, L"Height should be positive");
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
BIN
src/modules/powerrename/unittests/testdata/avif_test.avif
vendored
Normal file
BIN
src/modules/powerrename/unittests/testdata/avif_test.avif
vendored
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 2.4 MiB |
BIN
src/modules/powerrename/unittests/testdata/heif_test.heic
vendored
Normal file
BIN
src/modules/powerrename/unittests/testdata/heif_test.heic
vendored
Normal file
Binary file not shown.
@@ -4,6 +4,7 @@
|
||||
|
||||
#include "ActionRunnerUtils.h"
|
||||
#include "general_settings.h"
|
||||
#include "trace.h"
|
||||
#include "UpdateUtils.h"
|
||||
|
||||
#include <common/utils/gpo.h>
|
||||
@@ -176,6 +177,7 @@ void ProcessNewVersionInfo(const github_version_info& version_info,
|
||||
{
|
||||
state.state = UpdateState::readyToInstall;
|
||||
state.downloadedInstallerFilename = new_version_info.installer_filename;
|
||||
Trace::UpdateDownloadCompleted(true, new_version_info.version.toWstring());
|
||||
if (show_notifications)
|
||||
{
|
||||
ShowNewVersionAvailable(new_version_info);
|
||||
@@ -185,6 +187,7 @@ void ProcessNewVersionInfo(const github_version_info& version_info,
|
||||
{
|
||||
state.state = UpdateState::errorDownloading;
|
||||
state.downloadedInstallerFilename = {};
|
||||
Trace::UpdateDownloadCompleted(false, new_version_info.version.toWstring());
|
||||
Logger::error("Couldn't download new installer");
|
||||
}
|
||||
}
|
||||
@@ -233,10 +236,15 @@ void PeriodicUpdateWorker()
|
||||
if (new_version_info.has_value())
|
||||
{
|
||||
version_info_obtained = true;
|
||||
bool updateAvailable = std::holds_alternative<new_version_download_info>(*new_version_info);
|
||||
std::wstring fromVersion = get_product_version();
|
||||
std::wstring toVersion = updateAvailable ? std::get<new_version_download_info>(*new_version_info).version.toWstring() : L"";
|
||||
Trace::UpdateCheckCompleted(true, updateAvailable, fromVersion, toVersion);
|
||||
ProcessNewVersionInfo(*new_version_info, state, download_update, true);
|
||||
}
|
||||
else
|
||||
{
|
||||
Trace::UpdateCheckCompleted(false, false, get_product_version(), L"");
|
||||
Logger::error(L"Couldn't obtain version info from github: {}", new_version_info.error());
|
||||
}
|
||||
}
|
||||
@@ -269,6 +277,7 @@ void CheckForUpdatesCallback()
|
||||
{
|
||||
// We couldn't get a new version from github for some reason, log error
|
||||
state.state = UpdateState::networkError;
|
||||
Trace::UpdateCheckCompleted(false, false, get_product_version(), L"");
|
||||
Logger::error(L"Couldn't obtain version info from github: {}", new_version_info.error());
|
||||
}
|
||||
else
|
||||
@@ -281,6 +290,10 @@ void CheckForUpdatesCallback()
|
||||
download_update = false;
|
||||
}
|
||||
|
||||
bool updateAvailable = std::holds_alternative<new_version_download_info>(*new_version_info);
|
||||
std::wstring fromVersion = get_product_version();
|
||||
std::wstring toVersion = updateAvailable ? std::get<new_version_download_info>(*new_version_info).version.toWstring() : L"";
|
||||
Trace::UpdateCheckCompleted(true, updateAvailable, fromVersion, toVersion);
|
||||
ProcessNewVersionInfo(*new_version_info, state, download_update, false);
|
||||
}
|
||||
|
||||
|
||||
@@ -67,6 +67,7 @@ namespace
|
||||
// TODO: would be nice to get rid of these globals, since they're basically cached json settings
|
||||
static std::wstring settings_theme = L"system";
|
||||
static bool show_tray_icon = true;
|
||||
static bool show_theme_adaptive_tray_icon = false;
|
||||
static bool run_as_elevated = false;
|
||||
static bool show_new_updates_toast_notification = true;
|
||||
static bool download_updates_automatically = true;
|
||||
@@ -99,6 +100,7 @@ json::JsonObject GeneralSettings::to_json()
|
||||
result.SetNamedValue(L"enabled", std::move(enabled));
|
||||
|
||||
result.SetNamedValue(L"show_tray_icon", json::value(showSystemTrayIcon));
|
||||
result.SetNamedValue(L"show_theme_adaptive_tray_icon", json::value(showThemeAdaptiveTrayIcon));
|
||||
result.SetNamedValue(L"is_elevated", json::value(isElevated));
|
||||
result.SetNamedValue(L"run_elevated", json::value(isRunElevated));
|
||||
result.SetNamedValue(L"show_new_updates_toast_notification", json::value(showNewUpdatesToastNotification));
|
||||
@@ -126,6 +128,8 @@ json::JsonObject load_general_settings()
|
||||
{
|
||||
settings_theme = L"system";
|
||||
}
|
||||
show_tray_icon = loaded.GetNamedBoolean(L"show_tray_icon", true);
|
||||
show_theme_adaptive_tray_icon = loaded.GetNamedBoolean(L"show_theme_adaptive_tray_icon", false);
|
||||
run_as_elevated = loaded.GetNamedBoolean(L"run_elevated", false);
|
||||
show_new_updates_toast_notification = loaded.GetNamedBoolean(L"show_new_updates_toast_notification", true);
|
||||
download_updates_automatically = loaded.GetNamedBoolean(L"download_updates_automatically", true) && check_user_is_admin();
|
||||
@@ -159,6 +163,7 @@ GeneralSettings get_general_settings()
|
||||
GeneralSettings settings
|
||||
{
|
||||
.showSystemTrayIcon = show_tray_icon,
|
||||
.showThemeAdaptiveTrayIcon = show_theme_adaptive_tray_icon,
|
||||
.isElevated = is_process_elevated(),
|
||||
.isRunElevated = run_as_elevated,
|
||||
.isAdmin = is_user_admin,
|
||||
@@ -356,10 +361,19 @@ void apply_general_settings(const json::JsonObject& general_configs, bool save)
|
||||
if (json::has(general_configs, L"show_tray_icon", json::JsonValueType::Boolean))
|
||||
{
|
||||
show_tray_icon = general_configs.GetNamedBoolean(L"show_tray_icon");
|
||||
// Update tray icon visibility when setting is toggled
|
||||
set_tray_icon_visible(show_tray_icon);
|
||||
}
|
||||
|
||||
if (json::has(general_configs, L"show_theme_adaptive_tray_icon", json::JsonValueType::Boolean))
|
||||
{
|
||||
bool new_theme_adaptive = general_configs.GetNamedBoolean(L"show_theme_adaptive_tray_icon");
|
||||
if (show_theme_adaptive_tray_icon != new_theme_adaptive)
|
||||
{
|
||||
show_theme_adaptive_tray_icon = new_theme_adaptive;
|
||||
set_tray_icon_theme_adaptive(show_theme_adaptive_tray_icon);
|
||||
}
|
||||
}
|
||||
|
||||
if (json::has(general_configs, L"ignored_conflict_properties", json::JsonValueType::Object))
|
||||
{
|
||||
ignored_conflict_properties = general_configs.GetNamedObject(L"ignored_conflict_properties");
|
||||
|
||||
@@ -13,6 +13,7 @@ struct GeneralSettings
|
||||
{
|
||||
bool isStartupEnabled;
|
||||
bool showSystemTrayIcon;
|
||||
bool showThemeAdaptiveTrayIcon;
|
||||
std::wstring startupDisabledReason;
|
||||
std::map<std::wstring, bool> isModulesEnabledMap;
|
||||
bool isElevated;
|
||||
|
||||
@@ -189,13 +189,18 @@ int runner(bool isProcessElevated, bool openSettings, std::string settingsWindow
|
||||
//init_global_error_handlers();
|
||||
#endif
|
||||
Trace::RegisterProvider();
|
||||
start_tray_icon(isProcessElevated);
|
||||
if (get_general_settings().enableQuickAccess)
|
||||
|
||||
// Load settings from file before reading them
|
||||
load_general_settings();
|
||||
auto const settings = get_general_settings();
|
||||
start_tray_icon(isProcessElevated, settings.showThemeAdaptiveTrayIcon);
|
||||
|
||||
if (settings.enableQuickAccess)
|
||||
{
|
||||
QuickAccessHost::start();
|
||||
}
|
||||
update_quick_access_hotkey(get_general_settings().enableQuickAccess, get_general_settings().quickAccessShortcut);
|
||||
set_tray_icon_visible(get_general_settings().showSystemTrayIcon);
|
||||
update_quick_access_hotkey(settings.enableQuickAccess, settings.quickAccessShortcut);
|
||||
set_tray_icon_visible(settings.showSystemTrayIcon);
|
||||
CentralizedKeyboardHook::Start();
|
||||
|
||||
int result = -1;
|
||||
|
||||
@@ -18,6 +18,7 @@ extern void receive_json_send_to_main_thread(const std::wstring& msg);
|
||||
namespace
|
||||
{
|
||||
wil::unique_handle quick_access_process;
|
||||
wil::unique_handle quick_access_job;
|
||||
wil::unique_handle show_event;
|
||||
wil::unique_handle exit_event;
|
||||
std::wstring show_event_name;
|
||||
@@ -53,6 +54,7 @@ namespace
|
||||
}
|
||||
|
||||
quick_access_process.reset();
|
||||
quick_access_job.reset();
|
||||
show_event.reset();
|
||||
exit_event.reset();
|
||||
show_event_name.clear();
|
||||
@@ -206,7 +208,7 @@ namespace QuickAccessHost
|
||||
startup_info.cb = sizeof(startup_info);
|
||||
PROCESS_INFORMATION process_info{};
|
||||
|
||||
BOOL created = CreateProcessW(exe_path.c_str(), command_line_buffer.data(), nullptr, nullptr, FALSE, 0, nullptr, nullptr, &startup_info, &process_info);
|
||||
BOOL created = CreateProcessW(exe_path.c_str(), command_line_buffer.data(), nullptr, nullptr, FALSE, CREATE_SUSPENDED, nullptr, nullptr, &startup_info, &process_info);
|
||||
if (!created)
|
||||
{
|
||||
Logger::error(L"QuickAccessHost: failed to launch Quick Access host. error={}.", GetLastError());
|
||||
@@ -215,6 +217,31 @@ namespace QuickAccessHost
|
||||
}
|
||||
|
||||
quick_access_process.reset(process_info.hProcess);
|
||||
|
||||
// Assign to job object to ensure the process is killed if the runner exits unexpectedly (e.g. debugging stop)
|
||||
quick_access_job.reset(CreateJobObjectW(nullptr, nullptr));
|
||||
if (quick_access_job)
|
||||
{
|
||||
JOBOBJECT_EXTENDED_LIMIT_INFORMATION jeli = { 0 };
|
||||
jeli.BasicLimitInformation.LimitFlags = JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE;
|
||||
if (!SetInformationJobObject(quick_access_job.get(), JobObjectExtendedLimitInformation, &jeli, sizeof(jeli)))
|
||||
{
|
||||
Logger::warn(L"QuickAccessHost: failed to set job object information. error={}", GetLastError());
|
||||
}
|
||||
else
|
||||
{
|
||||
if (!AssignProcessToJobObject(quick_access_job.get(), quick_access_process.get()))
|
||||
{
|
||||
Logger::warn(L"QuickAccessHost: failed to assign process to job object. error={}", GetLastError());
|
||||
}
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
Logger::warn(L"QuickAccessHost: failed to create job object. error={}", GetLastError());
|
||||
}
|
||||
|
||||
ResumeThread(process_info.hThread);
|
||||
CloseHandle(process_info.hThread);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,17 +0,0 @@
|
||||
---
|
||||
applyTo: "**/*.cpp,**/*.c,**/*.h,**/*.hpp,**/*.rc"
|
||||
---
|
||||
# Runner – tray / host process guidance
|
||||
|
||||
Scope
|
||||
- Module bootstrap, hotkey management, settings bridge, update/elevation handling.
|
||||
|
||||
Guidelines
|
||||
- If IPC/JSON contracts change, mirror updates in `src/settings-ui/**`.
|
||||
- Keep module discovery in `src/runner/main.cpp` in sync when adding/removing modules.
|
||||
- Keep startup lean: avoid blocking/network calls in early init path.
|
||||
- Preserve GPO & elevation behaviors; confirm no regression in policy handling.
|
||||
- Ask before modifying update workflow or elevation logic.
|
||||
|
||||
Acceptance
|
||||
- Stable startup, consistent contracts, no unnecessary logging noise.
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user