mirror of
https://github.com/microsoft/PowerToys.git
synced 2026-02-08 12:20:18 +01:00
Compare commits
23 Commits
issue/4536
...
dev/vanzue
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
032b6163cc | ||
|
|
1ee721c306 | ||
|
|
ee702f9edf | ||
|
|
916182e47d | ||
|
|
753689309e | ||
|
|
2be4c4eb46 | ||
|
|
731532fdd8 | ||
|
|
bde2055f26 | ||
|
|
3336c134dd | ||
|
|
4c0926d7b7 | ||
|
|
d9a1c35132 | ||
|
|
0259e31d20 | ||
|
|
266908c62a | ||
|
|
6f87e947ff | ||
|
|
70d84fcb88 | ||
|
|
0e4d1c1496 | ||
|
|
d6bebf8423 | ||
|
|
ebf36a324a | ||
|
|
eba7760ee1 | ||
|
|
0998bed0d4 | ||
|
|
ad958759fa | ||
|
|
dbf16cf62a | ||
|
|
38d460cc2b |
1
.claude/CLAUDE.md
Symbolic link
1
.claude/CLAUDE.md
Symbolic link
@@ -0,0 +1 @@
|
||||
../.github/copilot-instructions.md
|
||||
1
.claude/agents
Symbolic link
1
.claude/agents
Symbolic link
@@ -0,0 +1 @@
|
||||
../.github/agents
|
||||
1
.claude/commands
Symbolic link
1
.claude/commands
Symbolic link
@@ -0,0 +1 @@
|
||||
../.github/prompts
|
||||
1
.claude/rules
Symbolic link
1
.claude/rules
Symbolic link
@@ -0,0 +1 @@
|
||||
../.github/instructions
|
||||
1
.claude/skills
Symbolic link
1
.claude/skills
Symbolic link
@@ -0,0 +1 @@
|
||||
../.github/skills
|
||||
@@ -1,61 +0,0 @@
|
||||
---
|
||||
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
|
||||
@@ -1,68 +0,0 @@
|
||||
---
|
||||
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
|
||||
@@ -63,7 +63,7 @@
|
||||
<PackageVersion Include="Microsoft.SemanticKernel.Connectors.MistralAI" Version="1.66.0-alpha" />
|
||||
<PackageVersion Include="Microsoft.SemanticKernel.Connectors.Ollama" Version="1.66.0-alpha" />
|
||||
<PackageVersion Include="Microsoft.Toolkit.Uwp.Notifications" Version="7.1.2" />
|
||||
<PackageVersion Include="Microsoft.Web.WebView2" Version="1.0.3179.45" />
|
||||
<PackageVersion Include="Microsoft.Web.WebView2" Version="1.0.3405.78" />
|
||||
<!-- Package Microsoft.Win32.SystemEvents added as a hack for being able to exclude the runtime assets so they don't conflict with 8.0.1. This is a dependency of System.Drawing.Common but the 8.0.1 version wasn't published to nuget. -->
|
||||
<PackageVersion Include="Microsoft.Win32.SystemEvents" Version="9.0.10" />
|
||||
<PackageVersion Include="Microsoft.WindowsPackageManager.ComInterop" Version="1.10.340" />
|
||||
@@ -77,10 +77,8 @@
|
||||
<PackageVersion Include="Microsoft.Windows.CsWinRT" Version="2.2.0" />
|
||||
<PackageVersion Include="Microsoft.Windows.ImplementationLibrary" Version="1.0.231216.1"/>
|
||||
<PackageVersion Include="Microsoft.Windows.SDK.BuildTools" Version="10.0.26100.6901" />
|
||||
<PackageVersion Include="Microsoft.WindowsAppSDK" Version="1.8.251106002" />
|
||||
<PackageVersion Include="Microsoft.WindowsAppSDK.Foundation" Version="1.8.251104000" />
|
||||
<PackageVersion Include="Microsoft.WindowsAppSDK.AI" Version="1.8.39" />
|
||||
<PackageVersion Include="Microsoft.WindowsAppSDK.Runtime" Version="1.8.251106002" />
|
||||
<PackageVersion Include="Microsoft.WindowsAppSDK" Version="2.0.0-experimental4" />
|
||||
<PackageVersion Include="Microsoft.WindowsAppSDK.AI" Version="2.0.130-experimental" />
|
||||
<PackageVersion Include="Microsoft.Xaml.Behaviors.WinUI.Managed" Version="2.0.9" />
|
||||
<PackageVersion Include="Microsoft.Xaml.Behaviors.Wpf" Version="1.1.39" />
|
||||
<PackageVersion Include="ModernWpfUI" Version="0.9.4" />
|
||||
|
||||
@@ -1059,6 +1059,16 @@
|
||||
<Platform Solution="*|x64" Project="x64" />
|
||||
</Project>
|
||||
</Folder>
|
||||
<Folder Name="/tools/SettingsSearchEvaluation/">
|
||||
<Project Path="tools/SettingsSearchEvaluation/SettingsSearchEvaluation.csproj">
|
||||
<Platform Solution="*|ARM64" Project="ARM64" />
|
||||
<Platform Solution="*|x64" Project="x64" />
|
||||
</Project>
|
||||
<Project Path="tools/SettingsSearchEvaluation.Tests/SettingsSearchEvaluation.Tests.csproj">
|
||||
<Platform Solution="*|ARM64" Project="ARM64" />
|
||||
<Platform Solution="*|x64" Project="x64" />
|
||||
</Project>
|
||||
</Folder>
|
||||
<Folder Name="/Solution Items/">
|
||||
<File Path=".vsconfig" />
|
||||
<File Path="Cpp.Build.props" />
|
||||
|
||||
244
SEMANTIC_SEARCH_API_SUMMARY.md
Normal file
244
SEMANTIC_SEARCH_API_SUMMARY.md
Normal file
@@ -0,0 +1,244 @@
|
||||
# Windows App SDK Semantic Search API 总结
|
||||
|
||||
## 1. 环境与依赖
|
||||
|
||||
| 项目 | 版本/值 |
|
||||
|------|---------|
|
||||
| **Windows App SDK** | `2.0.0-experimental3` |
|
||||
| **.NET** | `net9.0-windows10.0.26100.0` |
|
||||
| **AI Search NuGet** | `Microsoft.WindowsAppSDK.AI` (2.0.57-experimental) |
|
||||
| **命名空间** | `Microsoft.Windows.AI.Search.Experimental.AppContentIndex` |
|
||||
| **应用类型** | WinUI 3 MSIX 打包应用 |
|
||||
|
||||
---
|
||||
|
||||
## 2. 核心 API
|
||||
|
||||
### 2.1 索引管理
|
||||
```csharp
|
||||
// 创建/打开索引
|
||||
var result = AppContentIndexer.GetOrCreateIndex("indexName");
|
||||
if (result.Succeeded) {
|
||||
_indexer = result.Indexer;
|
||||
// result.Status: CreatedNew | OpenedExisting
|
||||
}
|
||||
|
||||
// 等待索引能力就绪
|
||||
await _indexer.WaitForIndexCapabilitiesAsync();
|
||||
|
||||
// 等待索引空闲(建索引完成)
|
||||
await _indexer.WaitForIndexingIdleAsync(TimeSpan.FromSeconds(120));
|
||||
|
||||
// 清理
|
||||
_indexer.RemoveAll(); // 删除所有索引
|
||||
_indexer.Remove(id); // 删除单个
|
||||
_indexer.Dispose();
|
||||
```
|
||||
|
||||
### 2.2 添加内容到索引
|
||||
```csharp
|
||||
// 索引文本 → 自动建立 TextLexical + TextSemantic 索引
|
||||
IndexableAppContent textContent = AppManagedIndexableAppContent.CreateFromString(id, text);
|
||||
_indexer.AddOrUpdate(textContent);
|
||||
|
||||
// 索引图片 → 自动建立 ImageSemantic + ImageOcr 索引
|
||||
IndexableAppContent imageContent = AppManagedIndexableAppContent.CreateFromBitmap(id, softwareBitmap);
|
||||
_indexer.AddOrUpdate(imageContent);
|
||||
```
|
||||
|
||||
### 2.3 查询
|
||||
```csharp
|
||||
// 文本查询
|
||||
TextQueryOptions options = new TextQueryOptions {
|
||||
Language = "en-US", // 可选
|
||||
MatchScope = QueryMatchScope.Unconstrained, // 匹配范围
|
||||
TextMatchType = TextLexicalMatchType.Fuzzy // Fuzzy | Exact
|
||||
};
|
||||
AppIndexTextQuery query = _indexer.CreateTextQuery(searchText, options);
|
||||
IReadOnlyList<TextQueryMatch> matches = query.GetNextMatches(5);
|
||||
|
||||
// 图片查询
|
||||
ImageQueryOptions imgOptions = new ImageQueryOptions {
|
||||
MatchScope = QueryMatchScope.Unconstrained,
|
||||
ImageOcrTextMatchType = TextLexicalMatchType.Fuzzy
|
||||
};
|
||||
AppIndexImageQuery imgQuery = _indexer.CreateImageQuery(searchText, imgOptions);
|
||||
IReadOnlyList<ImageQueryMatch> imgMatches = imgQuery.GetNextMatches(5);
|
||||
```
|
||||
|
||||
### 2.4 能力检查(只读)
|
||||
```csharp
|
||||
IndexCapabilities capabilities = _indexer.GetIndexCapabilities();
|
||||
|
||||
bool textLexicalOK = capabilities.GetCapabilityState(IndexCapability.TextLexical)
|
||||
.InitializationStatus == IndexCapabilityInitializationStatus.Initialized;
|
||||
bool textSemanticOK = capabilities.GetCapabilityState(IndexCapability.TextSemantic)
|
||||
.InitializationStatus == IndexCapabilityInitializationStatus.Initialized;
|
||||
bool imageSemanticOK = capabilities.GetCapabilityState(IndexCapability.ImageSemantic)
|
||||
.InitializationStatus == IndexCapabilityInitializationStatus.Initialized;
|
||||
bool imageOcrOK = capabilities.GetCapabilityState(IndexCapability.ImageOcr)
|
||||
.InitializationStatus == IndexCapabilityInitializationStatus.Initialized;
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 3. 四种索引能力
|
||||
|
||||
| 能力 | 说明 | 触发方式 |
|
||||
|------|------|----------|
|
||||
| `TextLexical` | 词法/关键词搜索 | CreateFromString() 自动 |
|
||||
| `TextSemantic` | AI 语义搜索 (Embedding) | CreateFromString() 自动 |
|
||||
| `ImageSemantic` | 图像语义搜索 | CreateFromBitmap() 自动 |
|
||||
| `ImageOcr` | 图片 OCR 文字搜索 | CreateFromBitmap() 自动 |
|
||||
|
||||
---
|
||||
|
||||
## 4. 可控选项(有限)
|
||||
|
||||
### TextQueryOptions
|
||||
| 属性 | 类型 | 说明 |
|
||||
|------|------|------|
|
||||
| `Language` | string | 查询语言(可选,如 "en-US")|
|
||||
| `MatchScope` | QueryMatchScope | Unconstrained / Region / ContentItem |
|
||||
| `TextMatchType` | TextLexicalMatchType | **Fuzzy** / Exact(仅影响 Lexical)|
|
||||
|
||||
### ImageQueryOptions
|
||||
| 属性 | 类型 | 说明 |
|
||||
|------|------|------|
|
||||
| `MatchScope` | QueryMatchScope | Unconstrained / Region / ContentItem |
|
||||
| `ImageOcrTextMatchType` | TextLexicalMatchType | **Fuzzy** / Exact(仅影响 OCR)|
|
||||
|
||||
### 枚举值说明
|
||||
|
||||
**QueryMatchScope:**
|
||||
- `Unconstrained` - 无约束,同时使用 Lexical + Semantic
|
||||
- `Region` - 限制在特定区域
|
||||
- `ContentItem` - 限制在单个内容项
|
||||
|
||||
**TextLexicalMatchType:**
|
||||
- `Fuzzy` - 模糊匹配,允许拼写错误、近似词
|
||||
- `Exact` - 精确匹配,必须完全一致
|
||||
|
||||
---
|
||||
|
||||
## 5. 关键限制 ⚠️
|
||||
|
||||
| 限制 | 说明 |
|
||||
|------|------|
|
||||
| **不能单独指定 Semantic/Lexical** | 系统自动同时使用所有可用能力 |
|
||||
| **Fuzzy/Exact 只影响 Lexical** | 对 Semantic 搜索无效 |
|
||||
| **能力检查是只读的** | `GetIndexCapabilities()` 只能查看,不能控制 |
|
||||
| **无相似度阈值** | 不能设置 Semantic 匹配的阈值 |
|
||||
| **无结果排序控制** | 无法指定按相关度或其他方式排序 |
|
||||
| **语言需手动传** | 不会自动检测,需开发者指定 |
|
||||
| **无相关度分数** | 查询结果不返回匹配分数 |
|
||||
|
||||
---
|
||||
|
||||
## 6. 典型使用流程
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────┐
|
||||
│ App 启动时 │
|
||||
├─────────────────────────────────────────────────────────┤
|
||||
│ 1. GetOrCreateIndex("name") // 创建/打开索引 │
|
||||
│ 2. WaitForIndexCapabilitiesAsync() // 等待能力就绪 │
|
||||
│ 3. GetIndexCapabilities() // 检查可用能力 │
|
||||
│ 4. IndexAll() // 索引所有数据 │
|
||||
│ ├─ CreateFromString() × N │
|
||||
│ └─ CreateFromBitmap() × N │
|
||||
│ 5. WaitForIndexingIdleAsync() // 等待索引完成 │
|
||||
└─────────────────────────────────────────────────────────┘
|
||||
↓
|
||||
┌─────────────────────────────────────────────────────────┐
|
||||
│ 运行时查询 │
|
||||
├─────────────────────────────────────────────────────────┤
|
||||
│ 1. CreateTextQuery(text, options) // 创建查询 │
|
||||
│ 2. query.GetNextMatches(N) // 获取结果 │
|
||||
│ 3. 处理 TextQueryMatch / ImageQueryMatch │
|
||||
└─────────────────────────────────────────────────────────┘
|
||||
↓
|
||||
┌─────────────────────────────────────────────────────────┐
|
||||
│ App 退出时 │
|
||||
├─────────────────────────────────────────────────────────┤
|
||||
│ 1. _indexer.RemoveAll() // 清理索引 │
|
||||
│ 2. _indexer.Dispose() // 释放资源 │
|
||||
└─────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 7. 查询结果处理
|
||||
|
||||
```csharp
|
||||
// 文本查询结果
|
||||
foreach (var match in textMatches)
|
||||
{
|
||||
if (match.ContentKind == QueryMatchContentKind.AppManagedText)
|
||||
{
|
||||
AppManagedTextQueryMatch textResult = (AppManagedTextQueryMatch)match;
|
||||
string contentId = match.ContentId; // 内容 ID
|
||||
int offset = textResult.TextOffset; // 匹配文本偏移
|
||||
int length = textResult.TextLength; // 匹配文本长度
|
||||
}
|
||||
}
|
||||
|
||||
// 图片查询结果
|
||||
foreach (var match in imageMatches)
|
||||
{
|
||||
if (match.ContentKind == QueryMatchContentKind.AppManagedImage)
|
||||
{
|
||||
AppManagedImageQueryMatch imageResult = (AppManagedImageQueryMatch)match;
|
||||
string contentId = imageResult.ContentId; // 图片 ID
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 8. 能力变化监听
|
||||
|
||||
```csharp
|
||||
// 监听索引能力变化
|
||||
_indexer.Listener.IndexCapabilitiesChanged += (indexer, capabilities) =>
|
||||
{
|
||||
// 重新检查能力状态,更新 UI
|
||||
LoadAppIndexCapabilities();
|
||||
};
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 9. 结论
|
||||
|
||||
这是一个**高度封装的黑盒 API**:
|
||||
|
||||
### 优点 ✅
|
||||
- 简单易用,几行代码即可实现搜索
|
||||
- 自动处理 Lexical + Semantic
|
||||
- 支持文本和图片多模态搜索
|
||||
- 系统级集成,无需额外部署模型
|
||||
|
||||
### 缺点 ❌
|
||||
- 无法精细控制搜索类型
|
||||
- 不能只用 Semantic Search
|
||||
- 选项有限,缺乏高级配置
|
||||
- 实验性 API,可能变更
|
||||
|
||||
### 替代方案
|
||||
**如果需要纯 Semantic Search(向量搜索)**,建议:
|
||||
- 直接使用 Embedding 模型生成向量
|
||||
- 配合向量数据库(Azure Cosmos DB、FAISS、Qdrant 等)
|
||||
|
||||
---
|
||||
|
||||
## 10. 相关 NuGet 包
|
||||
|
||||
```xml
|
||||
<PackageReference Include="Microsoft.WindowsAppSDK" Version="2.0.0-experimental3" />
|
||||
<PackageReference Include="Microsoft.WindowsAppSDK.AI" Version="2.0.57-experimental" />
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
*文档生成日期: 2026-01-21*
|
||||
@@ -68,6 +68,7 @@ Once you've discussed your proposed feature/fix/etc. with a team member, and an
|
||||
- Add the `In progress` label to the issue, if not already present. Also add a `Cost-Small/Medium/Large` estimate and make sure all appropriate labels are set.
|
||||
- If you are a community contributor, you will not be able to add labels to the issue; in that case just add a comment saying that you have started work on the issue and try to give an estimate for the delivery date.
|
||||
- If the work item has a medium/large cost, using the markdown task list, list each sub item and update the list with a check mark after completing each sub item.
|
||||
- **Before opening a PR, ensure your changes build successfully locally and functionality tests pass.** This is especially important for AI-assisted (vibe coding) contributions—always verify AI-generated code works as intended. Exploratory PRs or draft PRs for discussion are exceptions.
|
||||
- When opening a PR, follow the PR template.
|
||||
- When you'd like the team to take a look (even if the work is not yet fully complete) mark the PR as 'Ready For Review' so that the team can review your work and provide comments, suggestions, and request changes. It may take several cycles, but the end result will be solid, testable, conformant code that is safe for us to merge.
|
||||
- When the PR is approved, let the owner of the PR merge it. For community contributions, the reviewer who approved the PR can also merge it.
|
||||
|
||||
325
doc/devdocs/sparse-package-investigation.md
Normal file
325
doc/devdocs/sparse-package-investigation.md
Normal file
@@ -0,0 +1,325 @@
|
||||
# Sparse Package + WinUI 3 调查报告
|
||||
|
||||
## 背景
|
||||
|
||||
PowerToys 希望使用 Windows App SDK 的 Semantic Search API (`AppContentIndexer.GetOrCreateIndex`) 来实现语义搜索功能。该 API 要求应用具有 **Package Identity**。
|
||||
|
||||
## 问题现象
|
||||
|
||||
### 1. Semantic Search API 调用失败
|
||||
|
||||
在 [SemanticSearchIndex.cs](../../src/common/Common.Search/SemanticSearch/SemanticSearchIndex.cs) 中调用 `AppContentIndexer.GetOrCreateIndex(_indexName)` 时,抛出 COM 异常:
|
||||
|
||||
```
|
||||
System.Runtime.InteropServices.COMException (0x80004005): Error HRESULT E_FAIL has been returned from a call to a COM component.
|
||||
at WinRT.ExceptionHelpers.<ThrowExceptionForHR>g__Throw|39_0(Int32 hr)
|
||||
at Microsoft.Windows.AI.Search.AppContentIndexer.GetOrCreateIndex(String indexName)
|
||||
```
|
||||
|
||||
### 2. API 要求
|
||||
|
||||
根据 [Windows App SDK 文档](https://learn.microsoft.com/en-us/windows/ai/apis/content-search),Semantic Search API 需要:
|
||||
- Windows 11 24H2 或更高版本
|
||||
- NPU 硬件支持
|
||||
- **Package Identity**(应用需要有 MSIX 包标识)
|
||||
|
||||
## Sparse Package 方案
|
||||
|
||||
### 什么是 Sparse Package
|
||||
|
||||
Sparse Package(稀疏包)是一种为非打包(unpackaged)Win32 应用提供 Package Identity 的技术,无需完整的 MSIX 打包。
|
||||
|
||||
参考:[Grant package identity by packaging with external location](https://learn.microsoft.com/en-us/windows/apps/desktop/modernize/grant-identity-to-nonpackaged-apps)
|
||||
|
||||
### 实现架构
|
||||
|
||||
```
|
||||
PowerToysSparse.msix (仅包含 manifest 和图标)
|
||||
│
|
||||
├── AppxManifest.xml (声明应用和依赖)
|
||||
├── Square44x44Logo.png
|
||||
├── Square150x150Logo.png
|
||||
└── StoreLogo.png
|
||||
|
||||
ExternalLocation (指向实际应用目录)
|
||||
│
|
||||
└── ARM64\Debug\
|
||||
├── PowerToys.Settings.exe
|
||||
├── PowerToys.Settings.pri
|
||||
└── ... (其他应用文件)
|
||||
```
|
||||
|
||||
### 关键组件
|
||||
|
||||
| 文件 | 位置 | 作用 |
|
||||
|------|------|------|
|
||||
| AppxManifest.xml | src/PackageIdentity/ | 定义 sparse package 的应用、依赖和能力 |
|
||||
| app.manifest | src/settings-ui/Settings.UI/ | 嵌入 exe 中,声明与 sparse package 的关联 |
|
||||
| BuildSparsePackage.ps1 | src/PackageIdentity/ | 构建和签名脚本 |
|
||||
|
||||
### Publisher 配置
|
||||
|
||||
**重要**:app.manifest 和 AppxManifest.xml 中的 Publisher 必须匹配。
|
||||
|
||||
| 环境 | Publisher |
|
||||
|------|-----------|
|
||||
| 开发环境 | `CN=PowerToys Dev, O=PowerToys, L=Redmond, S=Washington, C=US` |
|
||||
| 生产环境 | `CN=Microsoft Corporation, O=Microsoft Corporation, L=Redmond, S=Washington, C=US` |
|
||||
|
||||
BuildSparsePackage.ps1 会在本地构建时**自动**将 AppxManifest.xml 的 Publisher 替换为开发环境值,无需手动修改源码。
|
||||
|
||||
## 当前问题:WinUI 3 + Sparse Package 崩溃
|
||||
|
||||
### 现象
|
||||
|
||||
当 Settings.exe(WinUI 3 应用)通过 sparse package 启动时,立即崩溃:
|
||||
|
||||
```
|
||||
Microsoft.UI.Xaml.Markup.XamlParseException (-2144665590):
|
||||
Cannot locate resource from 'ms-appx:///Microsoft.UI.Xaml/Themes/themeresources.xaml'. [Line: 11 Position: 40]
|
||||
```
|
||||
|
||||
### 新观察(2026-01-25)
|
||||
|
||||
对齐 WinAppSDK 版本并恢复 app-local 运行时后,仍可复现**更早期**的崩溃(未写入 Settings 日志):
|
||||
|
||||
- Application Error / WER(AUMID 启动):
|
||||
- Faulting module: `CoreMessagingXP.dll`
|
||||
- Exception code: `0xc0000602`
|
||||
- Faulting module path: `C:\PowerToys\ARM64\Debug\WinUI3Apps\CoreMessagingXP.dll`
|
||||
- 暂时移除 `CoreMessagingXP.dll` 后,出现 .NET Runtime 1026:
|
||||
- `COMException (0x80040111): ClassFactory cannot supply requested class`
|
||||
- 发生在 `Microsoft.UI.Xaml.Application.Start(...)`
|
||||
|
||||
这说明 **“themeresources.xaml 无法解析”并不是唯一/必现的失败模式**,app-local 运行时在 sparse identity 下可能存在更底层的初始化问题。
|
||||
|
||||
### 新观察(2026-01-25 晚间)
|
||||
|
||||
framework-dependent + bootstrap 方向有实质进展:
|
||||
|
||||
- 设置 `WindowsAppSDKSelfContained=false`(仅在 `UseSparseIdentity=true` 时生效)
|
||||
- 添加 `WindowsAppSDKBootstrapAutoInitializeOptions_OnPackageIdentity_NoOp=true`
|
||||
- **从 ExternalLocation 根目录与 `WinUI3Apps` 目录移除 app-local WinAppSDK 运行时文件**
|
||||
- 尤其是 `CoreMessagingXP.dll`,否则会优先加载并导致 `0xc0000602`
|
||||
- **保留/放回 bootstrap DLL**
|
||||
- 必需:`Microsoft.WindowsAppRuntime.Bootstrap.Net.dll`
|
||||
- 建议同时保留:`Microsoft.WindowsAppRuntime.Bootstrap.dll`
|
||||
|
||||
按以上处理后,Settings 通过 AUMID 启动不再崩溃,日志写入恢复。
|
||||
|
||||
### 根本原因分析
|
||||
|
||||
1. **ms-appx:/// URI 机制**
|
||||
- WinUI 3 使用 `ms-appx:///` URI 加载 XAML 资源
|
||||
- 这个 URI scheme 依赖于 MSIX 包的资源索引系统
|
||||
|
||||
2. **框架资源位置**
|
||||
- `themeresources.xaml` 等主题资源在 Windows App Runtime 框架包中
|
||||
- 框架包位置:`C:\Program Files\WindowsApps\Microsoft.WindowsAppRuntime.2.0-experimental4_*\`(应与 WinAppSDK 版本匹配)
|
||||
- 资源编译在框架包的 `resources.pri` 中
|
||||
|
||||
3. **WinAppSDK 版本/依赖不一致(更可能的原因)**
|
||||
- 仓库当前引用 `Microsoft.WindowsAppSDK` **2.0.0-experimental4**(见 `Directory.Packages.props`)
|
||||
- Sparse manifest 仍依赖 **Microsoft.WindowsAppRuntime.2.0-experimental3**(`MinVersion=0.676.658.0`)
|
||||
- 通过包标识启动时会走框架包资源图,如果依赖版本不匹配,WinUI 资源解析可能失败,从而触发上述 `XamlParseException`
|
||||
- 需要先对齐依赖版本,再判断是否是 sparse 本身限制
|
||||
|
||||
4. **app-local 运行时在 sparse identity 下崩溃(已观测)**
|
||||
- 即使对齐 WinAppSDK 版本,也可能在 `CoreMessagingXP.dll` 处崩溃(`0xc0000602`)
|
||||
- 此时 Settings 日志不一定写入,需查看 Application Event Log
|
||||
|
||||
4. **Sparse Package 的限制(待验证)**
|
||||
- 之前推断 `ms-appx:///` 在 sparse package 下无法解析框架依赖资源
|
||||
- 但在修正依赖版本之前无法下结论
|
||||
|
||||
### 对比:WPF 应用可以工作
|
||||
|
||||
WPF 应用(如 ImageResizer)使用 sparse package 时**可以正常工作**,因为:
|
||||
- WPF 不依赖 `ms-appx:///` URI
|
||||
- WPF 资源加载使用不同的机制
|
||||
|
||||
## 已尝试的解决方案
|
||||
|
||||
| 方案 | 结果 | 原因 |
|
||||
|------|------|------|
|
||||
| 复制 PRI 文件到根目录 | ❌ 失败 | `ms-appx:///` 不查找本地 PRI |
|
||||
| 复制 themeresources 到本地 | ❌ 失败 | 资源在 PRI 中,不是独立文件 |
|
||||
| 修改 Settings OutputPath 到根目录 | ❌ 失败 | 问题不在于应用资源位置 |
|
||||
| 复制框架 resources.pri | ❌ 失败 | `ms-appx:///` 机制问题 |
|
||||
| 对齐 WindowsAppRuntime 依赖版本 | ⏳ 待验证 | 先排除依赖不一致导致的资源解析失败 |
|
||||
| app-local 运行时(self-contained)+ sparse identity | ❌ 失败 | Application Error: `CoreMessagingXP.dll` / `0xc0000602` |
|
||||
| 移除 `CoreMessagingXP.dll` | ❌ 失败 | .NET Runtime 1026: `ClassFactory cannot supply requested class` |
|
||||
| framework-dependent + bootstrap + 清理 ExternalLocation 中 app-local 运行时 | ✅ 成功 | 需保留 `Microsoft.WindowsAppRuntime.Bootstrap*.dll` |
|
||||
| 将 resources.pri 打进 sparse MSIX | ✅ 成功 | MRT 可从包内 resources.pri 正常解析字符串 |
|
||||
|
||||
## 当前代码状态
|
||||
|
||||
### 已修正(建议保留)
|
||||
|
||||
1. **Settings.UI 输出路径**
|
||||
- 文件:`src/settings-ui/Settings.UI/PowerToys.Settings.csproj`
|
||||
- 修改:恢复为 `WinUI3Apps`(避免破坏 runner/installer/脚本路径假设)
|
||||
|
||||
2. **AppxManifest.xml 的 Executable 路径**
|
||||
- 文件:`src/PackageIdentity/AppxManifest.xml`
|
||||
- 修改:恢复为 `WinUI3Apps\PowerToys.Settings.exe`
|
||||
|
||||
3. **AppxManifest.xml 的 WindowsAppRuntime 依赖**
|
||||
- 文件:`src/PackageIdentity/AppxManifest.xml`
|
||||
- 修改:更新为 `Microsoft.WindowsAppRuntime.2.0-experimental4`,`MinVersion=0.738.2207.0`(与 `Microsoft.WindowsAppSDK.Runtime` 2.0.0-experimental4 对齐)
|
||||
|
||||
### 未修改(源码中保持生产配置)
|
||||
|
||||
- AppxManifest.xml 的 Publisher 保持 Microsoft Corporation(脚本会自动替换)
|
||||
|
||||
### 验证步骤(建议)
|
||||
|
||||
1. **确认 WindowsAppRuntime 版本已安装**
|
||||
- `Get-AppxPackage -Name Microsoft.WindowsAppRuntime.2.0-experimental4`
|
||||
- 如缺失,可从 NuGet 缓存安装:
|
||||
`Add-AppxPackage -Path "$env:USERPROFILE\.nuget\packages\microsoft.windowsappsdk.runtime\2.0.0-experimental4\tools\MSIX\win10-x64\Microsoft.WindowsAppRuntime.2.0-experimental4.msix"`
|
||||
|
||||
2. **构建并注册 sparse package**
|
||||
- `.\src\PackageIdentity\BuildSparsePackage.ps1 -Platform x64 -Configuration Debug`
|
||||
- `Add-AppxPackage -Path ".\x64\Debug\PowerToysSparse.msix" -ExternalLocation ".\x64\Debug"`
|
||||
|
||||
3. **用包标识启动 Settings**
|
||||
- AUMID:`Microsoft.PowerToys.SparseApp!PowerToys.SettingsUI`
|
||||
- 预期:不再触发 `themeresources.xaml` 解析错误
|
||||
|
||||
## 可能的解决方向
|
||||
|
||||
### 方向 1:等待 Windows App SDK 修复
|
||||
|
||||
- 可能是 Windows App SDK 的已知限制或 bug
|
||||
- 需要在 GitHub issues 中搜索或提交新 issue
|
||||
|
||||
### 方向 2:使用完整 MSIX 打包
|
||||
|
||||
- 不使用 sparse package,而是完整打包
|
||||
- 影响:改变部署模型,增加复杂性
|
||||
|
||||
### 方向 3:创建非 WinUI 3 的 Helper 进程
|
||||
|
||||
- 创建一个 Console App 或 WPF App 作为 helper
|
||||
- 该 helper 具有 package identity,专门调用 Semantic Search API
|
||||
- Settings 通过 IPC 与 helper 通信
|
||||
- 优点:不影响现有 Settings 架构
|
||||
|
||||
### 方向 4:进一步调查 ms-appx:/// 解析
|
||||
|
||||
- 研究是否有配置选项让 sparse package 正确解析框架资源
|
||||
- 可能需要深入 Windows App SDK 源码或联系微软
|
||||
|
||||
### 方向 5:切换为 framework-dependent + Bootstrap(待验证)
|
||||
|
||||
- 设置 `WindowsAppSDKSelfContained=false` 并**重新构建** Settings
|
||||
- 确保外部目录不再携带 app-local WinAppSDK 运行时
|
||||
- 让 `Bootstrap.TryInitialize(...)` 生效,走框架包动态依赖
|
||||
|
||||
## 可复现的工作流(已验证 2026-01-25)
|
||||
|
||||
目标:Settings 使用 sparse identity 启动,WinUI 资源/字符串正常加载。
|
||||
|
||||
### 1) 构建 Settings(framework-dependent + bootstrap no-op)
|
||||
|
||||
在 `PowerToys.Settings.csproj` 中添加(仅在 `UseSparseIdentity=true` 时生效):
|
||||
|
||||
```
|
||||
<PropertyGroup Condition="'$(UseSparseIdentity)'=='true'">
|
||||
<WindowsAppSDKSelfContained>false</WindowsAppSDKSelfContained>
|
||||
<WindowsAppSDKBootstrapAutoInitializeOptions_OnPackageIdentity_NoOp>true</WindowsAppSDKBootstrapAutoInitializeOptions_OnPackageIdentity_NoOp>
|
||||
</PropertyGroup>
|
||||
```
|
||||
|
||||
构建:
|
||||
|
||||
```
|
||||
MSBuild.exe src\settings-ui\Settings.UI\PowerToys.Settings.csproj /p:Platform=ARM64 /p:Configuration=Debug /p:UseSparseIdentity=true /m:1 /p:CL_MPCount=1 /nodeReuse:false
|
||||
```
|
||||
|
||||
### 2) 清理 ExternalLocation 的 app-local WinAppSDK 运行时
|
||||
|
||||
**必须移除** app-local WinAppSDK 运行时文件,否则会优先加载并崩溃(`CoreMessagingXP.dll` / `0xc0000602`)。
|
||||
|
||||
需清理的目录:
|
||||
- `ARM64\Debug`(ExternalLocation 根)
|
||||
- `ARM64\Debug\WinUI3Apps`
|
||||
|
||||
建议只移除 app-local WinAppSDK 相关文件(保留业务 DLL)。
|
||||
|
||||
**保留/放回 bootstrap DLL(必要):**
|
||||
- `Microsoft.WindowsAppRuntime.Bootstrap.dll`
|
||||
- `Microsoft.WindowsAppRuntime.Bootstrap.Net.dll`
|
||||
|
||||
### 3) 生成与包名一致的 resources.pri
|
||||
|
||||
关键点:resources.pri 的 **ResourceMap name 必须与包名一致**。
|
||||
|
||||
使用 `makepri.exe new` 生成,确保 `/mn` 指向 sparse 包的 `AppxManifest.xml`:
|
||||
|
||||
```
|
||||
makepri.exe new ^
|
||||
/pr C:\PowerToys\src\settings-ui\Settings.UI ^
|
||||
/cf C:\PowerToys\src\settings-ui\Settings.UI\obj\ARM64\Debug\priconfig.xml ^
|
||||
/mn C:\PowerToys\src\PackageIdentity\AppxManifest.xml ^
|
||||
/of C:\PowerToys\ARM64\Debug\resources.pri ^
|
||||
/o
|
||||
```
|
||||
|
||||
### 4) 将 resources.pri 打进 sparse MSIX
|
||||
|
||||
在 `BuildSparsePackage.ps1` 中把 `resources.pri` 放入 staging(脚本已更新):
|
||||
- 优先取 `ARM64\Debug\resources.pri`
|
||||
- 如果不存在则回退 `ARM64\Debug\WinUI3Apps\PowerToys.Settings.pri`
|
||||
|
||||
重新打包:
|
||||
|
||||
```
|
||||
.\src\PackageIdentity\BuildSparsePackage.ps1 -Platform ARM64 -Configuration Debug
|
||||
```
|
||||
|
||||
### 5) 重新注册 sparse 包(如需先卸载)
|
||||
|
||||
如果因为内容变更被阻止,先卸载再安装:
|
||||
|
||||
```
|
||||
Get-AppxPackage -Name Microsoft.PowerToys.SparseApp | Remove-AppxPackage
|
||||
Add-AppxPackage -Path .\ARM64\Debug\PowerToysSparse.msix -ExternalLocation .\ARM64\Debug -ForceApplicationShutdown
|
||||
```
|
||||
|
||||
### 6) 启动 Settings(验证)
|
||||
|
||||
```
|
||||
Start-Process "shell:AppsFolder\Microsoft.PowerToys.SparseApp_djwsxzxb4ksa8!PowerToys.SettingsUI"
|
||||
```
|
||||
|
||||
验证要点:
|
||||
- Settings 正常启动,UI 文本显示
|
||||
- 日志正常写入:`%LOCALAPPDATA%\Microsoft\PowerToys\Settings\Logs\0.0.1.0\`
|
||||
|
||||
### 备注(可选)
|
||||
|
||||
如果出现 `ms-appx:///CommunityToolkit...` 资源缺失,可将对应的 `.pri`(从 NuGet 缓存)复制到 `ARM64\Debug\WinUI3Apps`,但在 **resources.pri 已正确打包** 后通常不再需要。
|
||||
|
||||
## 待确认事项
|
||||
|
||||
1. [ ] WinUI 3 + Sparse Package 的兼容性问题是否有官方文档说明?
|
||||
2. [ ] 是否有其他项目成功实现 WinUI 3 + Sparse Package?
|
||||
3. [ ] Windows App SDK GitHub 上是否有相关 issue?
|
||||
4. [ ] 修正依赖版本后,Settings 是否能在 sparse identity 下正常启动?
|
||||
5. [ ] framework-dependent(Bootstrap)方式是否能在 sparse identity 下启动?
|
||||
|
||||
## 相关文件
|
||||
|
||||
- [SemanticSearchIndex.cs](../../src/common/Common.Search/SemanticSearch/SemanticSearchIndex.cs) - Semantic Search 实现
|
||||
- [AppxManifest.xml](../../src/PackageIdentity/AppxManifest.xml) - Sparse package manifest
|
||||
- [BuildSparsePackage.ps1](../../src/PackageIdentity/BuildSparsePackage.ps1) - 构建脚本
|
||||
- [app.manifest](../../src/settings-ui/Settings.UI/app.manifest) - Settings 应用 manifest
|
||||
- [PowerToys.Settings.csproj](../../src/settings-ui/Settings.UI/PowerToys.Settings.csproj) - Settings 项目文件
|
||||
|
||||
## 参考链接
|
||||
|
||||
- [Grant package identity by packaging with external location](https://learn.microsoft.com/en-us/windows/apps/desktop/modernize/grant-identity-to-nonpackaged-apps)
|
||||
- [Windows App SDK - Content Search API](https://learn.microsoft.com/en-us/windows/ai/apis/content-search)
|
||||
- [Windows App SDK GitHub Issues](https://github.com/microsoft/WindowsAppSDK/issues)
|
||||
496
doc/specs/common-search-library.md
Normal file
496
doc/specs/common-search-library.md
Normal file
@@ -0,0 +1,496 @@
|
||||
# Common.Search Library Specification
|
||||
|
||||
## Overview
|
||||
|
||||
本文档描述 `Common.Search` 库的重构设计,目标是提供一个通用的、可插拔的搜索框架,支持多种搜索引擎实现(Fuzzy Match、Semantic Search 等)。
|
||||
|
||||
## Goals
|
||||
|
||||
1. **解耦** - 搜索引擎与数据源完全解耦
|
||||
2. **可插拔** - 支持替换不同的搜索引擎实现
|
||||
3. **泛型** - 不绑定特定业务类型(如 SettingEntry)
|
||||
4. **可组合** - 支持多引擎组合(即时 Fuzzy + 延迟 Semantic)
|
||||
5. **可复用** - 可被 PowerToys 多个模块使用
|
||||
|
||||
## Architecture
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────────┐
|
||||
│ Consumer (e.g., Settings.UI) │
|
||||
├─────────────────────────────────────────────────────────────────┤
|
||||
│ SettingsDataProvider ← 业务特定的数据加载 │
|
||||
│ SettingsSearchService ← 业务特定的搜索服务 │
|
||||
│ SettingEntry : ISearchable ← 业务实体实现搜索契约 │
|
||||
└─────────────────────────────────────────────────────────────────┘
|
||||
│ uses
|
||||
▼
|
||||
┌─────────────────────────────────────────────────────────────────┐
|
||||
│ Common.Search (Library) │
|
||||
├─────────────────────────────────────────────────────────────────┤
|
||||
│ │
|
||||
│ ┌─────────────────────────────────────────────────────────┐ │
|
||||
│ │ Core Abstractions │ │
|
||||
│ ├─────────────────────────────────────────────────────────┤ │
|
||||
│ │ ISearchable ← 可搜索内容契约 │ │
|
||||
│ │ ISearchEngine<T> ← 搜索引擎接口 │ │
|
||||
│ │ SearchResult<T> ← 统一结果模型 │ │
|
||||
│ │ SearchOptions ← 搜索选项 │ │
|
||||
│ └─────────────────────────────────────────────────────────┘ │
|
||||
│ │
|
||||
│ ┌─────────────────────────────────────────────────────────┐ │
|
||||
│ │ Implementations │ │
|
||||
│ ├─────────────────────────────────────────────────────────┤ │
|
||||
│ │ FuzzSearch/ │ │
|
||||
│ │ ├── FuzzSearchEngine<T> ← 内存 Fuzzy 搜索 │ │
|
||||
│ │ ├── StringMatcher ← 现有的模糊匹配算法 │ │
|
||||
│ │ └── MatchResult ← Fuzzy 匹配结果 │ │
|
||||
│ │ │ │
|
||||
│ │ SemanticSearch/ │ │
|
||||
│ │ ├── SemanticSearchEngine ← Windows AI Search 封装 │ │
|
||||
│ │ └── SemanticSearchCapabilities │ │
|
||||
│ │ │ │
|
||||
│ │ CompositeSearch/ │ │
|
||||
│ │ └── CompositeSearchEngine<T> ← 多引擎组合 │ │
|
||||
│ └─────────────────────────────────────────────────────────┘ │
|
||||
│ │
|
||||
└─────────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
## Core Interfaces
|
||||
|
||||
### ISearchable
|
||||
|
||||
定义可搜索内容的最小契约。
|
||||
|
||||
```csharp
|
||||
namespace Common.Search;
|
||||
|
||||
/// <summary>
|
||||
/// Defines a searchable item that can be indexed and searched.
|
||||
/// </summary>
|
||||
public interface ISearchable
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets the unique identifier for this item.
|
||||
/// </summary>
|
||||
string Id { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the primary searchable text (e.g., title, header).
|
||||
/// </summary>
|
||||
string SearchableText { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets optional secondary searchable text (e.g., description).
|
||||
/// Returns null if not available.
|
||||
/// </summary>
|
||||
string? SecondarySearchableText { get; }
|
||||
}
|
||||
```
|
||||
|
||||
### ISearchEngine<T>
|
||||
|
||||
搜索引擎核心接口。
|
||||
|
||||
```csharp
|
||||
namespace Common.Search;
|
||||
|
||||
/// <summary>
|
||||
/// Defines a pluggable search engine that can index and search items.
|
||||
/// </summary>
|
||||
/// <typeparam name="T">The type of items to search, must implement ISearchable.</typeparam>
|
||||
public interface ISearchEngine<T> : IDisposable
|
||||
where T : ISearchable
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets a value indicating whether the engine is ready to search.
|
||||
/// </summary>
|
||||
bool IsReady { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the engine capabilities.
|
||||
/// </summary>
|
||||
SearchEngineCapabilities Capabilities { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Initializes the search engine.
|
||||
/// </summary>
|
||||
Task InitializeAsync(CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Indexes a single item.
|
||||
/// </summary>
|
||||
Task IndexAsync(T item, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Indexes multiple items in batch.
|
||||
/// </summary>
|
||||
Task IndexBatchAsync(IEnumerable<T> items, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Removes an item from the index by its ID.
|
||||
/// </summary>
|
||||
Task RemoveAsync(string id, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Clears all indexed items.
|
||||
/// </summary>
|
||||
Task ClearAsync(CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Searches for items matching the query.
|
||||
/// </summary>
|
||||
Task<IReadOnlyList<SearchResult<T>>> SearchAsync(
|
||||
string query,
|
||||
SearchOptions? options = null,
|
||||
CancellationToken cancellationToken = default);
|
||||
}
|
||||
```
|
||||
|
||||
### SearchResult<T>
|
||||
|
||||
统一的搜索结果模型。
|
||||
|
||||
```csharp
|
||||
namespace Common.Search;
|
||||
|
||||
/// <summary>
|
||||
/// Represents a search result with the matched item and scoring information.
|
||||
/// </summary>
|
||||
public sealed class SearchResult<T>
|
||||
where T : ISearchable
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets the matched item.
|
||||
/// </summary>
|
||||
public required T Item { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the relevance score (higher is more relevant).
|
||||
/// </summary>
|
||||
public required double Score { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the type of match that produced this result.
|
||||
/// </summary>
|
||||
public required SearchMatchKind MatchKind { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the match details for highlighting (optional).
|
||||
/// </summary>
|
||||
public IReadOnlyList<MatchSpan>? MatchSpans { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Represents a span of matched text for highlighting.
|
||||
/// </summary>
|
||||
public readonly record struct MatchSpan(int Start, int Length);
|
||||
|
||||
/// <summary>
|
||||
/// Specifies the kind of match that produced a search result.
|
||||
/// </summary>
|
||||
public enum SearchMatchKind
|
||||
{
|
||||
/// <summary>Exact text match.</summary>
|
||||
Exact,
|
||||
|
||||
/// <summary>Fuzzy/approximate text match.</summary>
|
||||
Fuzzy,
|
||||
|
||||
/// <summary>Semantic/AI-based match.</summary>
|
||||
Semantic,
|
||||
|
||||
/// <summary>Combined match from multiple engines.</summary>
|
||||
Composite,
|
||||
}
|
||||
```
|
||||
|
||||
### SearchOptions
|
||||
|
||||
搜索配置选项。
|
||||
|
||||
```csharp
|
||||
namespace Common.Search;
|
||||
|
||||
/// <summary>
|
||||
/// Options for configuring search behavior.
|
||||
/// </summary>
|
||||
public sealed class SearchOptions
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets or sets the maximum number of results to return.
|
||||
/// Default is 20.
|
||||
/// </summary>
|
||||
public int MaxResults { get; set; } = 20;
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the minimum score threshold (0.0 to 1.0).
|
||||
/// Results below this score are filtered out.
|
||||
/// Default is 0.0 (no filtering).
|
||||
/// </summary>
|
||||
public double MinScore { get; set; } = 0.0;
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the language hint for the search (e.g., "en-US").
|
||||
/// </summary>
|
||||
public string? Language { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets a value indicating whether to include match spans for highlighting.
|
||||
/// Default is false.
|
||||
/// </summary>
|
||||
public bool IncludeMatchSpans { get; set; } = false;
|
||||
}
|
||||
```
|
||||
|
||||
### SearchEngineCapabilities
|
||||
|
||||
引擎能力描述。
|
||||
|
||||
```csharp
|
||||
namespace Common.Search;
|
||||
|
||||
/// <summary>
|
||||
/// Describes the capabilities of a search engine.
|
||||
/// </summary>
|
||||
public sealed class SearchEngineCapabilities
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets a value indicating whether the engine supports fuzzy matching.
|
||||
/// </summary>
|
||||
public bool SupportsFuzzyMatch { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets a value indicating whether the engine supports semantic search.
|
||||
/// </summary>
|
||||
public bool SupportsSemanticSearch { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets a value indicating whether the engine persists the index to disk.
|
||||
/// </summary>
|
||||
public bool PersistsIndex { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets a value indicating whether the engine supports incremental indexing.
|
||||
/// </summary>
|
||||
public bool SupportsIncrementalIndex { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets a value indicating whether the engine supports match span highlighting.
|
||||
/// </summary>
|
||||
public bool SupportsMatchSpans { get; init; }
|
||||
}
|
||||
```
|
||||
|
||||
## Implementations
|
||||
|
||||
### FuzzSearchEngine<T>
|
||||
|
||||
基于现有 StringMatcher 的内存搜索引擎。
|
||||
|
||||
**特点:**
|
||||
- 纯内存,无持久化
|
||||
- 即时响应(毫秒级)
|
||||
- 支持 match spans 高亮
|
||||
- 基于字符的模糊匹配
|
||||
|
||||
**Capabilities:**
|
||||
```csharp
|
||||
new SearchEngineCapabilities
|
||||
{
|
||||
SupportsFuzzyMatch = true,
|
||||
SupportsSemanticSearch = false,
|
||||
PersistsIndex = false,
|
||||
SupportsIncrementalIndex = true,
|
||||
SupportsMatchSpans = true,
|
||||
}
|
||||
```
|
||||
|
||||
### SemanticSearchEngine
|
||||
|
||||
基于 Windows App SDK AI Search API 的语义搜索引擎。
|
||||
|
||||
**特点:**
|
||||
- 系统管理的持久化索引
|
||||
- AI 驱动的语义理解
|
||||
- 需要模型初始化(可能较慢)
|
||||
- 可能不可用(依赖系统支持)
|
||||
|
||||
**Capabilities:**
|
||||
```csharp
|
||||
new SearchEngineCapabilities
|
||||
{
|
||||
SupportsFuzzyMatch = true, // API 同时提供 lexical + semantic
|
||||
SupportsSemanticSearch = true,
|
||||
PersistsIndex = true,
|
||||
SupportsIncrementalIndex = true,
|
||||
SupportsMatchSpans = false, // API 不返回详细位置
|
||||
}
|
||||
```
|
||||
|
||||
**注意:** SemanticSearchEngine 不是泛型的,因为它需要将内容转换为字符串存入系统索引。实现时通过 `ISearchable` 接口提取文本。
|
||||
|
||||
### CompositeSearchEngine<T>
|
||||
|
||||
组合多个搜索引擎,支持 fallback 和结果合并。
|
||||
|
||||
```csharp
|
||||
namespace Common.Search;
|
||||
|
||||
/// <summary>
|
||||
/// A search engine that combines results from multiple engines.
|
||||
/// </summary>
|
||||
public sealed class CompositeSearchEngine<T> : ISearchEngine<T>
|
||||
where T : ISearchable
|
||||
{
|
||||
/// <summary>
|
||||
/// Strategy for combining results from multiple engines.
|
||||
/// </summary>
|
||||
public enum CombineStrategy
|
||||
{
|
||||
/// <summary>Use first ready engine only.</summary>
|
||||
FirstReady,
|
||||
|
||||
/// <summary>Merge results from all ready engines.</summary>
|
||||
MergeAll,
|
||||
|
||||
/// <summary>Use primary, fallback to secondary if primary not ready.</summary>
|
||||
PrimaryWithFallback,
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**典型用法:** Fuzzy 作为即时响应,Semantic 准备好后增强结果。
|
||||
|
||||
## Directory Structure
|
||||
|
||||
```
|
||||
src/common/Common.Search/
|
||||
├── Common.Search.csproj
|
||||
├── GlobalSuppressions.cs
|
||||
├── ISearchable.cs
|
||||
├── ISearchEngine.cs
|
||||
├── SearchResult.cs
|
||||
├── SearchOptions.cs
|
||||
├── SearchEngineCapabilities.cs
|
||||
├── SearchMatchKind.cs
|
||||
├── MatchSpan.cs
|
||||
│
|
||||
├── FuzzSearch/
|
||||
│ ├── FuzzSearchEngine.cs
|
||||
│ ├── StringMatcher.cs (existing)
|
||||
│ ├── MatchOption.cs (existing)
|
||||
│ ├── MatchResult.cs (existing)
|
||||
│ └── SearchPrecisionScore.cs (existing)
|
||||
│
|
||||
├── SemanticSearch/
|
||||
│ ├── SemanticSearchEngine.cs
|
||||
│ ├── SemanticSearchCapabilities.cs
|
||||
│ └── SemanticSearchAdapter.cs (adapts ISearchable to Windows API)
|
||||
│
|
||||
└── CompositeSearch/
|
||||
└── CompositeSearchEngine.cs
|
||||
```
|
||||
|
||||
## Consumer Usage (Settings.UI)
|
||||
|
||||
### SettingEntry 实现 ISearchable
|
||||
|
||||
```csharp
|
||||
// Settings.UI.Library/SettingEntry.cs
|
||||
public struct SettingEntry : ISearchable
|
||||
{
|
||||
// Existing properties...
|
||||
|
||||
// ISearchable implementation
|
||||
public string Id => ElementUid ?? $"{PageTypeName}|{ElementName}";
|
||||
public string SearchableText => Header ?? string.Empty;
|
||||
public string? SecondarySearchableText => Description;
|
||||
}
|
||||
```
|
||||
|
||||
### SettingsSearchService
|
||||
|
||||
```csharp
|
||||
// Settings.UI/Services/SettingsSearchService.cs
|
||||
public sealed class SettingsSearchService : IDisposable
|
||||
{
|
||||
private readonly ISearchEngine<SettingEntry> _engine;
|
||||
|
||||
public SettingsSearchService(ISearchEngine<SettingEntry> engine)
|
||||
{
|
||||
_engine = engine;
|
||||
}
|
||||
|
||||
public async Task InitializeAsync(IEnumerable<SettingEntry> entries)
|
||||
{
|
||||
await _engine.InitializeAsync();
|
||||
await _engine.IndexBatchAsync(entries);
|
||||
}
|
||||
|
||||
public async Task<List<SettingEntry>> SearchAsync(string query, CancellationToken ct = default)
|
||||
{
|
||||
var results = await _engine.SearchAsync(query, cancellationToken: ct);
|
||||
return results.Select(r => r.Item).ToList();
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Startup Configuration
|
||||
|
||||
```csharp
|
||||
// Option 1: Fuzzy only (default, immediate)
|
||||
var engine = new FuzzSearchEngine<SettingEntry>();
|
||||
|
||||
// Option 2: Semantic only (requires Windows AI)
|
||||
var engine = new SemanticSearchAdapter<SettingEntry>("PowerToysSettings");
|
||||
|
||||
// Option 3: Composite (best of both worlds)
|
||||
var engine = new CompositeSearchEngine<SettingEntry>(
|
||||
primary: new SemanticSearchAdapter<SettingEntry>("PowerToysSettings"),
|
||||
fallback: new FuzzSearchEngine<SettingEntry>(),
|
||||
strategy: CombineStrategy.PrimaryWithFallback
|
||||
);
|
||||
|
||||
var searchService = new SettingsSearchService(engine);
|
||||
await searchService.InitializeAsync(settingEntries);
|
||||
```
|
||||
|
||||
## Migration Plan
|
||||
|
||||
### Phase 1: Core Abstractions
|
||||
1. 创建 `ISearchable`, `ISearchEngine<T>`, `SearchResult<T>` 等核心接口
|
||||
2. 保持现有 FuzzSearch 代码不变
|
||||
|
||||
### Phase 2: FuzzSearchEngine<T>
|
||||
1. 创建泛型 `FuzzSearchEngine<T>` 实现
|
||||
2. 内部复用现有 `StringMatcher`
|
||||
|
||||
### Phase 3: SemanticSearchEngine
|
||||
1. 完善现有 `SemanticSearchEngine` 实现
|
||||
2. 创建 `SemanticSearchAdapter<T>` 桥接泛型接口
|
||||
|
||||
### Phase 4: Settings.UI Migration
|
||||
1. `SettingEntry` 实现 `ISearchable`
|
||||
2. 创建 `SettingsSearchService`
|
||||
3. 迁移 `SearchIndexService` 到新架构
|
||||
4. 保持 API 兼容,逐步废弃旧方法
|
||||
|
||||
### Phase 5: CompositeSearchEngine (Optional)
|
||||
1. 实现组合引擎
|
||||
2. 支持 Fuzzy + Semantic 混合搜索
|
||||
|
||||
## Open Questions
|
||||
|
||||
1. **是否需要支持图片搜索?** 当前 SemanticSearchEngine 支持 `IndexImage`,但 `ISearchable` 只有文本。如果需要图片,可能需要 `IImageSearchable` 扩展。
|
||||
|
||||
2. **结果去重策略?** CompositeEngine 合并结果时,同一个 Item 可能被多个引擎匹配,如何去重和合并分数?
|
||||
|
||||
3. **异步 vs 同步?** FuzzSearch 完全可以同步执行,但接口统一用 `Task` 是否合适?考虑提供同步重载?
|
||||
|
||||
4. **索引更新策略?** 当 Settings 内容变化时(例如用户切换语言),如何高效更新索引?
|
||||
|
||||
---
|
||||
|
||||
*Document Version: 1.0*
|
||||
*Last Updated: 2026-01-21*
|
||||
@@ -147,7 +147,7 @@
|
||||
<Custom Action="UnRegisterCmdPalPackage" Before="RemoveFiles" Condition="Installed AND (NOT UPGRADINGPRODUCTCODE) AND (REMOVE="ALL")" />
|
||||
<Custom Action="UninstallCommandNotFound" Before="RemoveFiles" Condition="Installed AND (NOT UPGRADINGPRODUCTCODE) AND (REMOVE="ALL")" />
|
||||
<Custom Action="UpgradeCommandNotFound" After="InstallFiles" Condition="WIX_UPGRADE_DETECTED" />
|
||||
<Custom Action="UninstallPackageIdentityMSIX" Before="RemoveFiles" Condition="Installed AND (NOT UPGRADINGPRODUCTCODE) AND (REMOVE="ALL")" />
|
||||
<Custom Action="UninstallPackageIdentityMSIX" Before="RemoveFiles" Condition="Installed AND (REMOVE="ALL")" />
|
||||
<Custom Action="UninstallServicesTask" After="InstallFinalize" Condition="Installed AND (NOT UPGRADINGPRODUCTCODE) AND (REMOVE="ALL")" />
|
||||
<!-- TODO: Use to activate embedded MSIX -->
|
||||
<!--<Custom Action="UninstallEmbeddedMSIXTask" After="InstallFinalize">
|
||||
|
||||
@@ -29,6 +29,7 @@
|
||||
</Resources>
|
||||
<Dependencies>
|
||||
<TargetDeviceFamily Name="Windows.Desktop" MinVersion="10.0.19000.0" MaxVersionTested="10.0.26226.0" />
|
||||
<PackageDependency Name="Microsoft.WindowsAppRuntime.2.0-experimental4" MinVersion="0.738.2207.0" Publisher="CN=Microsoft Corporation, O=Microsoft Corporation, L=Redmond, S=Washington, C=US" />
|
||||
</Dependencies>
|
||||
<Capabilities>
|
||||
<Capability Name="internetClient" />
|
||||
@@ -80,6 +81,7 @@
|
||||
<Extensions>
|
||||
<com:Extension Category="windows.comServer">
|
||||
<com:ComServer>
|
||||
|
||||
<com:ExeServer Executable="Microsoft.CmdPal.Ext.PowerToys.exe" Arguments="-RegisterProcessAsComServer" DisplayName="PowerToys Command Palette Extension">
|
||||
<com:Class Id="7EC02C7D-8F98-4A2E-9F23-B58C2C2F2B17" DisplayName="PowerToys Command Palette Extension" />
|
||||
</com:ExeServer>
|
||||
|
||||
@@ -320,6 +320,19 @@ try {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
# Include resources.pri in the sparse MSIX if available to enable MRT in packaged mode.
|
||||
# Prefer a prebuilt resources.pri in output root; fall back to Settings pri.
|
||||
$resourcesPriSource = Join-Path $outDir "resources.pri"
|
||||
if (-not (Test-Path $resourcesPriSource)) {
|
||||
$resourcesPriSource = Join-Path $outDir "WinUI3Apps\\PowerToys.Settings.pri"
|
||||
}
|
||||
if (Test-Path $resourcesPriSource) {
|
||||
Copy-Item -Path $resourcesPriSource -Destination (Join-Path $stagingDir "resources.pri") -Force -ErrorAction SilentlyContinue
|
||||
Write-BuildLog "Including resources.pri from: $resourcesPriSource" -Level Info
|
||||
} else {
|
||||
Write-BuildLog "resources.pri not found; strings may be missing in sparse identity." -Level Warning
|
||||
}
|
||||
|
||||
# Ensure publisher matches the dev certificate for local builds
|
||||
$manifestStagingPath = Join-Path $stagingDir 'AppxManifest.xml'
|
||||
|
||||
@@ -4,5 +4,16 @@
|
||||
|
||||
<PropertyGroup>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<WarningsNotAsErrors>$(WarningsNotAsErrors);CS8305</WarningsNotAsErrors>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.WindowsAppSDK" />
|
||||
<PackageReference Include="Microsoft.WindowsAppSDK.AI" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\ManagedCommon\ManagedCommon.csproj" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
|
||||
306
src/common/Common.Search/FuzzSearch/FuzzSearchEngine`1.cs
Normal file
306
src/common/Common.Search/FuzzSearch/FuzzSearchEngine`1.cs
Normal file
@@ -0,0 +1,306 @@
|
||||
// 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.Collections.Concurrent;
|
||||
using System.Globalization;
|
||||
using System.Text;
|
||||
|
||||
namespace Common.Search.FuzzSearch;
|
||||
|
||||
/// <summary>
|
||||
/// A search engine that uses fuzzy string matching for search.
|
||||
/// </summary>
|
||||
/// <typeparam name="T">The type of items to search.</typeparam>
|
||||
public sealed class FuzzSearchEngine<T> : ISearchEngine<T>
|
||||
where T : ISearchable
|
||||
{
|
||||
private readonly object _lockObject = new();
|
||||
private readonly Dictionary<string, T> _itemsById = new();
|
||||
private readonly Dictionary<string, (string PrimaryNorm, string? SecondaryNorm)> _normalizedCache = new();
|
||||
private bool _isReady;
|
||||
private bool _disposed;
|
||||
|
||||
/// <inheritdoc/>
|
||||
public bool IsReady
|
||||
{
|
||||
get
|
||||
{
|
||||
lock (_lockObject)
|
||||
{
|
||||
return _isReady;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public SearchEngineCapabilities Capabilities { get; } = new()
|
||||
{
|
||||
SupportsFuzzyMatch = true,
|
||||
SupportsSemanticSearch = false,
|
||||
PersistsIndex = false,
|
||||
SupportsIncrementalIndex = true,
|
||||
SupportsMatchSpans = true,
|
||||
};
|
||||
|
||||
/// <inheritdoc/>
|
||||
public Task InitializeAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
lock (_lockObject)
|
||||
{
|
||||
_isReady = true;
|
||||
}
|
||||
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public Task IndexAsync(T item, CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(item);
|
||||
ThrowIfDisposed();
|
||||
|
||||
lock (_lockObject)
|
||||
{
|
||||
_itemsById[item.Id] = item;
|
||||
_normalizedCache[item.Id] = (
|
||||
NormalizeString(item.SearchableText),
|
||||
item.SecondarySearchableText != null ? NormalizeString(item.SecondarySearchableText) : null
|
||||
);
|
||||
}
|
||||
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public Task IndexBatchAsync(IEnumerable<T> items, CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(items);
|
||||
ThrowIfDisposed();
|
||||
|
||||
lock (_lockObject)
|
||||
{
|
||||
foreach (var item in items)
|
||||
{
|
||||
if (cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
break;
|
||||
}
|
||||
|
||||
_itemsById[item.Id] = item;
|
||||
_normalizedCache[item.Id] = (
|
||||
NormalizeString(item.SearchableText),
|
||||
item.SecondarySearchableText != null ? NormalizeString(item.SecondarySearchableText) : null
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public Task RemoveAsync(string id, CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(id);
|
||||
ThrowIfDisposed();
|
||||
|
||||
lock (_lockObject)
|
||||
{
|
||||
_itemsById.Remove(id);
|
||||
_normalizedCache.Remove(id);
|
||||
}
|
||||
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public Task ClearAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
ThrowIfDisposed();
|
||||
|
||||
lock (_lockObject)
|
||||
{
|
||||
_itemsById.Clear();
|
||||
_normalizedCache.Clear();
|
||||
}
|
||||
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public Task<IReadOnlyList<SearchResult<T>>> SearchAsync(
|
||||
string query,
|
||||
SearchOptions? options = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ThrowIfDisposed();
|
||||
|
||||
if (string.IsNullOrWhiteSpace(query))
|
||||
{
|
||||
return Task.FromResult<IReadOnlyList<SearchResult<T>>>(Array.Empty<SearchResult<T>>());
|
||||
}
|
||||
|
||||
options ??= new SearchOptions();
|
||||
var normalizedQuery = NormalizeString(query);
|
||||
|
||||
List<KeyValuePair<string, T>> snapshot;
|
||||
lock (_lockObject)
|
||||
{
|
||||
if (_itemsById.Count == 0)
|
||||
{
|
||||
return Task.FromResult<IReadOnlyList<SearchResult<T>>>(Array.Empty<SearchResult<T>>());
|
||||
}
|
||||
|
||||
snapshot = _itemsById.ToList();
|
||||
}
|
||||
|
||||
var bag = new ConcurrentBag<SearchResult<T>>();
|
||||
var po = new ParallelOptions
|
||||
{
|
||||
CancellationToken = cancellationToken,
|
||||
MaxDegreeOfParallelism = Math.Max(1, Environment.ProcessorCount - 1),
|
||||
};
|
||||
|
||||
try
|
||||
{
|
||||
Parallel.ForEach(snapshot, po, kvp =>
|
||||
{
|
||||
var (primaryNorm, secondaryNorm) = GetNormalizedTexts(kvp.Key);
|
||||
|
||||
var primaryResult = StringMatcher.FuzzyMatch(normalizedQuery, primaryNorm);
|
||||
double score = primaryResult.Score;
|
||||
List<int>? matchData = primaryResult.MatchData;
|
||||
|
||||
if (!string.IsNullOrEmpty(secondaryNorm))
|
||||
{
|
||||
var secondaryResult = StringMatcher.FuzzyMatch(normalizedQuery, secondaryNorm);
|
||||
if (secondaryResult.Success && secondaryResult.Score * 0.8 > score)
|
||||
{
|
||||
score = secondaryResult.Score * 0.8;
|
||||
matchData = null; // Secondary matches don't have primary text spans
|
||||
}
|
||||
}
|
||||
|
||||
if (score > options.MinScore)
|
||||
{
|
||||
var result = new SearchResult<T>
|
||||
{
|
||||
Item = kvp.Value,
|
||||
Score = score,
|
||||
MatchKind = SearchMatchKind.Fuzzy,
|
||||
MatchSpans = options.IncludeMatchSpans && matchData != null
|
||||
? ConvertToMatchSpans(matchData)
|
||||
: null,
|
||||
};
|
||||
|
||||
bag.Add(result);
|
||||
}
|
||||
});
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
return Task.FromResult<IReadOnlyList<SearchResult<T>>>(Array.Empty<SearchResult<T>>());
|
||||
}
|
||||
|
||||
var results = bag
|
||||
.OrderByDescending(r => r.Score)
|
||||
.Take(options.MaxResults)
|
||||
.ToList();
|
||||
|
||||
return Task.FromResult<IReadOnlyList<SearchResult<T>>>(results);
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public void Dispose()
|
||||
{
|
||||
if (_disposed)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
lock (_lockObject)
|
||||
{
|
||||
_itemsById.Clear();
|
||||
_normalizedCache.Clear();
|
||||
_isReady = false;
|
||||
}
|
||||
|
||||
_disposed = true;
|
||||
}
|
||||
|
||||
private (string PrimaryNorm, string? SecondaryNorm) GetNormalizedTexts(string id)
|
||||
{
|
||||
lock (_lockObject)
|
||||
{
|
||||
if (_normalizedCache.TryGetValue(id, out var cached))
|
||||
{
|
||||
return cached;
|
||||
}
|
||||
}
|
||||
|
||||
return (string.Empty, null);
|
||||
}
|
||||
|
||||
private static IReadOnlyList<MatchSpan> ConvertToMatchSpans(List<int> matchData)
|
||||
{
|
||||
if (matchData == null || matchData.Count == 0)
|
||||
{
|
||||
return Array.Empty<MatchSpan>();
|
||||
}
|
||||
|
||||
// Convert individual match indices to spans
|
||||
var spans = new List<MatchSpan>();
|
||||
var sortedIndices = matchData.OrderBy(i => i).ToList();
|
||||
|
||||
int start = sortedIndices[0];
|
||||
int length = 1;
|
||||
|
||||
for (int i = 1; i < sortedIndices.Count; i++)
|
||||
{
|
||||
if (sortedIndices[i] == sortedIndices[i - 1] + 1)
|
||||
{
|
||||
// Consecutive index, extend the span
|
||||
length++;
|
||||
}
|
||||
else
|
||||
{
|
||||
// Gap found, save current span and start new one
|
||||
spans.Add(new MatchSpan(start, length));
|
||||
start = sortedIndices[i];
|
||||
length = 1;
|
||||
}
|
||||
}
|
||||
|
||||
// Add the last span
|
||||
spans.Add(new MatchSpan(start, length));
|
||||
|
||||
return spans;
|
||||
}
|
||||
|
||||
private static string NormalizeString(string? input)
|
||||
{
|
||||
if (string.IsNullOrEmpty(input))
|
||||
{
|
||||
return string.Empty;
|
||||
}
|
||||
|
||||
var normalized = input.ToLowerInvariant().Normalize(NormalizationForm.FormKD);
|
||||
var sb = new StringBuilder(normalized.Length);
|
||||
|
||||
foreach (var c in normalized)
|
||||
{
|
||||
var category = CharUnicodeInfo.GetUnicodeCategory(c);
|
||||
if (category != UnicodeCategory.NonSpacingMark)
|
||||
{
|
||||
sb.Append(c);
|
||||
}
|
||||
}
|
||||
|
||||
return sb.ToString();
|
||||
}
|
||||
|
||||
private void ThrowIfDisposed()
|
||||
{
|
||||
ObjectDisposedException.ThrowIf(_disposed, this);
|
||||
}
|
||||
}
|
||||
@@ -25,9 +25,9 @@ public class StringMatcher
|
||||
/// 6. Move onto the next substring's characters until all substrings are checked.
|
||||
/// 7. Consider success and move onto scoring if every char or substring without whitespaces matched
|
||||
/// </summary>
|
||||
public static MatchResult FuzzyMatch(string query, string stringToCompare, MatchOption opt = null)
|
||||
public static MatchResult FuzzyMatch(string query, string stringToCompare, MatchOption? opt = null)
|
||||
{
|
||||
opt = opt ?? new MatchOption();
|
||||
opt ??= new MatchOption();
|
||||
|
||||
if (string.IsNullOrEmpty(stringToCompare))
|
||||
{
|
||||
|
||||
@@ -11,6 +11,10 @@ using System.Diagnostics.CodeAnalysis;
|
||||
[assembly: SuppressMessage("StyleCop.CSharp.NamingRules", "SA1309:Field names should not begin with underscore", Justification = "coding style", Scope = "member", Target = "~F:Common.Search.MatchResult._rawScore")]
|
||||
[assembly: SuppressMessage("StyleCop.CSharp.NamingRules", "SA1309:Field names should not begin with underscore", Justification = "coding style", Scope = "member", Target = "~F:Common.Search.StringMatcher._defaultMatchOption")]
|
||||
[assembly: SuppressMessage("StyleCop.CSharp.NamingRules", "SA1309:Field names should not begin with underscore", Justification = "coding style", Scope = "member", Target = "~F:Common.Search.StringMatcher._instance")]
|
||||
[assembly: SuppressMessage("StyleCop.CSharp.NamingRules", "SA1309:Field names should not begin with underscore", Justification = "coding style", Scope = "member", Target = "~F:Common.Search.SemanticSearch.SemanticSearchIndex._indexName")]
|
||||
[assembly: SuppressMessage("StyleCop.CSharp.NamingRules", "SA1309:Field names should not begin with underscore", Justification = "coding style", Scope = "member", Target = "~F:Common.Search.SemanticSearch.SemanticSearchIndex._indexer")]
|
||||
[assembly: SuppressMessage("StyleCop.CSharp.NamingRules", "SA1309:Field names should not begin with underscore", Justification = "coding style", Scope = "member", Target = "~F:Common.Search.SemanticSearch.SemanticSearchIndex._disposed")]
|
||||
[assembly: SuppressMessage("StyleCop.CSharp.NamingRules", "SA1309:Field names should not begin with underscore", Justification = "coding style", Scope = "member", Target = "~F:Common.Search.SemanticSearch.SemanticSearchIndex._capabilities")]
|
||||
[assembly: SuppressMessage("StyleCop.CSharp.ReadabilityRules", "SA1101:Prefix local calls with this", Justification = "coding style", Scope = "member", Target = "~M:Common.Search.MatchResult.#ctor(System.Boolean,Common.Search.SearchPrecisionScore)")]
|
||||
[assembly: SuppressMessage("StyleCop.CSharp.ReadabilityRules", "SA1101:Prefix local calls with this", Justification = "coding style", Scope = "member", Target = "~M:Common.Search.MatchResult.#ctor(System.Boolean,Common.Search.SearchPrecisionScore,System.Collections.Generic.List{System.Int32},System.Int32)")]
|
||||
[assembly: SuppressMessage("Compiler", "CS8618:Non-nullable field must contain a non-null value when exiting constructor. Consider adding the 'required' modifier or declaring as nullable.", Justification = "Coding style", Scope = "member", Target = "~F:Common.Search.StringMatcher._instance")]
|
||||
|
||||
73
src/common/Common.Search/ISearchEngine`1.cs
Normal file
73
src/common/Common.Search/ISearchEngine`1.cs
Normal file
@@ -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.
|
||||
|
||||
namespace Common.Search;
|
||||
|
||||
/// <summary>
|
||||
/// Defines a pluggable search engine that can index and search items.
|
||||
/// </summary>
|
||||
/// <typeparam name="T">The type of items to search, must implement ISearchable.</typeparam>
|
||||
public interface ISearchEngine<T> : IDisposable
|
||||
where T : ISearchable
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets a value indicating whether the engine is ready to search.
|
||||
/// </summary>
|
||||
bool IsReady { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the engine capabilities.
|
||||
/// </summary>
|
||||
SearchEngineCapabilities Capabilities { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Initializes the search engine.
|
||||
/// </summary>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>A task representing the async operation.</returns>
|
||||
Task InitializeAsync(CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Indexes a single item.
|
||||
/// </summary>
|
||||
/// <param name="item">The item to index.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>A task representing the async operation.</returns>
|
||||
Task IndexAsync(T item, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Indexes multiple items in batch.
|
||||
/// </summary>
|
||||
/// <param name="items">The items to index.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>A task representing the async operation.</returns>
|
||||
Task IndexBatchAsync(IEnumerable<T> items, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Removes an item from the index by its ID.
|
||||
/// </summary>
|
||||
/// <param name="id">The ID of the item to remove.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>A task representing the async operation.</returns>
|
||||
Task RemoveAsync(string id, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Clears all indexed items.
|
||||
/// </summary>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>A task representing the async operation.</returns>
|
||||
Task ClearAsync(CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Searches for items matching the query.
|
||||
/// </summary>
|
||||
/// <param name="query">The search query.</param>
|
||||
/// <param name="options">Optional search options.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>A list of search results ordered by relevance.</returns>
|
||||
Task<IReadOnlyList<SearchResult<T>>> SearchAsync(
|
||||
string query,
|
||||
SearchOptions? options = null,
|
||||
CancellationToken cancellationToken = default);
|
||||
}
|
||||
27
src/common/Common.Search/ISearchable.cs
Normal file
27
src/common/Common.Search/ISearchable.cs
Normal file
@@ -0,0 +1,27 @@
|
||||
// Copyright (c) Microsoft Corporation
|
||||
// The Microsoft Corporation licenses this file to you under the MIT license.
|
||||
// See the LICENSE file in the project root for more information.
|
||||
|
||||
namespace Common.Search;
|
||||
|
||||
/// <summary>
|
||||
/// Defines a searchable item that can be indexed and searched.
|
||||
/// </summary>
|
||||
public interface ISearchable
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets the unique identifier for this item.
|
||||
/// </summary>
|
||||
string Id { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the primary searchable text (e.g., title, header).
|
||||
/// </summary>
|
||||
string SearchableText { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets optional secondary searchable text (e.g., description).
|
||||
/// Returns null if not available.
|
||||
/// </summary>
|
||||
string? SecondarySearchableText { get; }
|
||||
}
|
||||
12
src/common/Common.Search/MatchSpan.cs
Normal file
12
src/common/Common.Search/MatchSpan.cs
Normal file
@@ -0,0 +1,12 @@
|
||||
// Copyright (c) Microsoft Corporation
|
||||
// The Microsoft Corporation licenses this file to you under the MIT license.
|
||||
// See the LICENSE file in the project root for more information.
|
||||
|
||||
namespace Common.Search;
|
||||
|
||||
/// <summary>
|
||||
/// Represents a span of matched text for highlighting.
|
||||
/// </summary>
|
||||
/// <param name="Start">The starting index of the match.</param>
|
||||
/// <param name="Length">The length of the match.</param>
|
||||
public readonly record struct MatchSpan(int Start, int Length);
|
||||
36
src/common/Common.Search/SearchEngineCapabilities.cs
Normal file
36
src/common/Common.Search/SearchEngineCapabilities.cs
Normal file
@@ -0,0 +1,36 @@
|
||||
// Copyright (c) Microsoft Corporation
|
||||
// The Microsoft Corporation licenses this file to you under the MIT license.
|
||||
// See the LICENSE file in the project root for more information.
|
||||
|
||||
namespace Common.Search;
|
||||
|
||||
/// <summary>
|
||||
/// Describes the capabilities of a search engine.
|
||||
/// </summary>
|
||||
public sealed class SearchEngineCapabilities
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets a value indicating whether the engine supports fuzzy matching.
|
||||
/// </summary>
|
||||
public bool SupportsFuzzyMatch { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets a value indicating whether the engine supports semantic search.
|
||||
/// </summary>
|
||||
public bool SupportsSemanticSearch { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets a value indicating whether the engine persists the index to disk.
|
||||
/// </summary>
|
||||
public bool PersistsIndex { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets a value indicating whether the engine supports incremental indexing.
|
||||
/// </summary>
|
||||
public bool SupportsIncrementalIndex { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets a value indicating whether the engine supports match span highlighting.
|
||||
/// </summary>
|
||||
public bool SupportsMatchSpans { get; init; }
|
||||
}
|
||||
134
src/common/Common.Search/SearchError.cs
Normal file
134
src/common/Common.Search/SearchError.cs
Normal file
@@ -0,0 +1,134 @@
|
||||
// Copyright (c) Microsoft Corporation
|
||||
// The Microsoft Corporation licenses this file to you under the MIT license.
|
||||
// See the LICENSE file in the project root for more information.
|
||||
|
||||
namespace Common.Search;
|
||||
|
||||
/// <summary>
|
||||
/// Represents an error that occurred during a search operation.
|
||||
/// </summary>
|
||||
public sealed class SearchError
|
||||
{
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="SearchError"/> class.
|
||||
/// </summary>
|
||||
/// <param name="code">The error code.</param>
|
||||
/// <param name="message">The error message.</param>
|
||||
/// <param name="details">Optional additional details.</param>
|
||||
/// <param name="exception">Optional exception that caused the error.</param>
|
||||
public SearchError(SearchErrorCode code, string message, string? details = null, Exception? exception = null)
|
||||
{
|
||||
Code = code;
|
||||
Message = message;
|
||||
Details = details;
|
||||
Exception = exception;
|
||||
Timestamp = DateTime.UtcNow;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the error code.
|
||||
/// </summary>
|
||||
public SearchErrorCode Code { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the error message.
|
||||
/// </summary>
|
||||
public string Message { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets additional details about the error.
|
||||
/// </summary>
|
||||
public string? Details { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the exception that caused the error, if any.
|
||||
/// </summary>
|
||||
public Exception? Exception { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the timestamp when the error occurred.
|
||||
/// </summary>
|
||||
public DateTime Timestamp { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Creates an error for initialization failure.
|
||||
/// </summary>
|
||||
/// <param name="indexName">The name of the index.</param>
|
||||
/// <param name="details">Optional details.</param>
|
||||
/// <param name="exception">Optional exception.</param>
|
||||
/// <returns>A new SearchError instance.</returns>
|
||||
public static SearchError InitializationFailed(string indexName, string? details = null, Exception? exception = null)
|
||||
=> new(SearchErrorCode.InitializationFailed, $"Failed to initialize search index '{indexName}'.", details, exception);
|
||||
|
||||
/// <summary>
|
||||
/// Creates an error for indexing failure.
|
||||
/// </summary>
|
||||
/// <param name="contentId">The ID of the content that failed to index.</param>
|
||||
/// <param name="details">Optional details.</param>
|
||||
/// <param name="exception">Optional exception.</param>
|
||||
/// <returns>A new SearchError instance.</returns>
|
||||
public static SearchError IndexingFailed(string contentId, string? details = null, Exception? exception = null)
|
||||
=> new(SearchErrorCode.IndexingFailed, $"Failed to index content '{contentId}'.", details, exception);
|
||||
|
||||
/// <summary>
|
||||
/// Creates an error for search query failure.
|
||||
/// </summary>
|
||||
/// <param name="query">The search query that failed.</param>
|
||||
/// <param name="details">Optional details.</param>
|
||||
/// <param name="exception">Optional exception.</param>
|
||||
/// <returns>A new SearchError instance.</returns>
|
||||
public static SearchError SearchFailed(string query, string? details = null, Exception? exception = null)
|
||||
=> new(SearchErrorCode.SearchFailed, $"Search query '{query}' failed.", details, exception);
|
||||
|
||||
/// <summary>
|
||||
/// Creates an error for engine not ready.
|
||||
/// </summary>
|
||||
/// <param name="operation">The operation that was attempted.</param>
|
||||
/// <returns>A new SearchError instance.</returns>
|
||||
public static SearchError EngineNotReady(string operation)
|
||||
=> new(SearchErrorCode.EngineNotReady, $"Search engine is not ready. Operation '{operation}' cannot be performed.");
|
||||
|
||||
/// <summary>
|
||||
/// Creates an error for capability unavailable.
|
||||
/// </summary>
|
||||
/// <param name="capability">The capability that is unavailable.</param>
|
||||
/// <param name="details">Optional details.</param>
|
||||
/// <returns>A new SearchError instance.</returns>
|
||||
public static SearchError CapabilityUnavailable(string capability, string? details = null)
|
||||
=> new(SearchErrorCode.CapabilityUnavailable, $"Search capability '{capability}' is not available.", details);
|
||||
|
||||
/// <summary>
|
||||
/// Creates an error for timeout.
|
||||
/// </summary>
|
||||
/// <param name="operation">The operation that timed out.</param>
|
||||
/// <param name="timeout">The timeout duration.</param>
|
||||
/// <returns>A new SearchError instance.</returns>
|
||||
public static SearchError Timeout(string operation, TimeSpan timeout)
|
||||
=> new(SearchErrorCode.Timeout, $"Operation '{operation}' timed out after {timeout.TotalSeconds:F1} seconds.");
|
||||
|
||||
/// <summary>
|
||||
/// Creates an error for an unexpected error.
|
||||
/// </summary>
|
||||
/// <param name="operation">The operation that failed.</param>
|
||||
/// <param name="exception">The exception that occurred.</param>
|
||||
/// <returns>A new SearchError instance.</returns>
|
||||
public static SearchError Unexpected(string operation, Exception exception)
|
||||
=> new(SearchErrorCode.Unexpected, $"Unexpected error during '{operation}'.", exception.Message, exception);
|
||||
|
||||
/// <inheritdoc/>
|
||||
public override string ToString()
|
||||
{
|
||||
var result = $"[{Code}] {Message}";
|
||||
if (!string.IsNullOrEmpty(Details))
|
||||
{
|
||||
result += $" Details: {Details}";
|
||||
}
|
||||
|
||||
if (Exception != null)
|
||||
{
|
||||
result += $" Exception: {Exception.GetType().Name}: {Exception.Message}";
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
}
|
||||
51
src/common/Common.Search/SearchErrorCode.cs
Normal file
51
src/common/Common.Search/SearchErrorCode.cs
Normal file
@@ -0,0 +1,51 @@
|
||||
// Copyright (c) Microsoft Corporation
|
||||
// The Microsoft Corporation licenses this file to you under the MIT license.
|
||||
// See the LICENSE file in the project root for more information.
|
||||
|
||||
namespace Common.Search;
|
||||
|
||||
/// <summary>
|
||||
/// Defines error codes for search operations.
|
||||
/// </summary>
|
||||
public enum SearchErrorCode
|
||||
{
|
||||
/// <summary>
|
||||
/// No error occurred.
|
||||
/// </summary>
|
||||
None = 0,
|
||||
|
||||
/// <summary>
|
||||
/// The search engine failed to initialize.
|
||||
/// </summary>
|
||||
InitializationFailed = 1,
|
||||
|
||||
/// <summary>
|
||||
/// Failed to index content.
|
||||
/// </summary>
|
||||
IndexingFailed = 2,
|
||||
|
||||
/// <summary>
|
||||
/// The search query failed to execute.
|
||||
/// </summary>
|
||||
SearchFailed = 3,
|
||||
|
||||
/// <summary>
|
||||
/// The search engine is not ready to perform the operation.
|
||||
/// </summary>
|
||||
EngineNotReady = 4,
|
||||
|
||||
/// <summary>
|
||||
/// A required capability is not available.
|
||||
/// </summary>
|
||||
CapabilityUnavailable = 5,
|
||||
|
||||
/// <summary>
|
||||
/// The operation timed out.
|
||||
/// </summary>
|
||||
Timeout = 6,
|
||||
|
||||
/// <summary>
|
||||
/// An unexpected error occurred.
|
||||
/// </summary>
|
||||
Unexpected = 99,
|
||||
}
|
||||
31
src/common/Common.Search/SearchMatchKind.cs
Normal file
31
src/common/Common.Search/SearchMatchKind.cs
Normal file
@@ -0,0 +1,31 @@
|
||||
// Copyright (c) Microsoft Corporation
|
||||
// The Microsoft Corporation licenses this file to you under the MIT license.
|
||||
// See the LICENSE file in the project root for more information.
|
||||
|
||||
namespace Common.Search;
|
||||
|
||||
/// <summary>
|
||||
/// Specifies the kind of match that produced a search result.
|
||||
/// </summary>
|
||||
public enum SearchMatchKind
|
||||
{
|
||||
/// <summary>
|
||||
/// Exact text match.
|
||||
/// </summary>
|
||||
Exact,
|
||||
|
||||
/// <summary>
|
||||
/// Fuzzy/approximate text match.
|
||||
/// </summary>
|
||||
Fuzzy,
|
||||
|
||||
/// <summary>
|
||||
/// Semantic/AI-based match.
|
||||
/// </summary>
|
||||
Semantic,
|
||||
|
||||
/// <summary>
|
||||
/// Combined match from multiple engines.
|
||||
/// </summary>
|
||||
Composite,
|
||||
}
|
||||
53
src/common/Common.Search/SearchOperationResult.cs
Normal file
53
src/common/Common.Search/SearchOperationResult.cs
Normal file
@@ -0,0 +1,53 @@
|
||||
// Copyright (c) Microsoft Corporation
|
||||
// The Microsoft Corporation licenses this file to you under the MIT license.
|
||||
// See the LICENSE file in the project root for more information.
|
||||
|
||||
namespace Common.Search;
|
||||
|
||||
/// <summary>
|
||||
/// Represents the result of a search operation that may have errors.
|
||||
/// </summary>
|
||||
public sealed class SearchOperationResult
|
||||
{
|
||||
private SearchOperationResult(bool success, SearchError? error = null)
|
||||
{
|
||||
IsSuccess = success;
|
||||
Error = error;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets a value indicating whether the operation was successful.
|
||||
/// </summary>
|
||||
public bool IsSuccess { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets a value indicating whether the operation failed.
|
||||
/// </summary>
|
||||
public bool IsFailure => !IsSuccess;
|
||||
|
||||
/// <summary>
|
||||
/// Gets the error if the operation failed, null otherwise.
|
||||
/// </summary>
|
||||
public SearchError? Error { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Creates a successful result.
|
||||
/// </summary>
|
||||
/// <returns>A successful SearchOperationResult.</returns>
|
||||
public static SearchOperationResult Success() => new(true);
|
||||
|
||||
/// <summary>
|
||||
/// Creates a failed result with the specified error.
|
||||
/// </summary>
|
||||
/// <param name="error">The error that caused the failure.</param>
|
||||
/// <returns>A failed SearchOperationResult.</returns>
|
||||
public static SearchOperationResult Failure(SearchError error)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(error);
|
||||
return new SearchOperationResult(false, error);
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public override string ToString()
|
||||
=> IsSuccess ? "Success" : $"Failure: {Error}";
|
||||
}
|
||||
87
src/common/Common.Search/SearchOperationResult1.cs
Normal file
87
src/common/Common.Search/SearchOperationResult1.cs
Normal file
@@ -0,0 +1,87 @@
|
||||
// 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.Diagnostics.CodeAnalysis;
|
||||
|
||||
#pragma warning disable SA1649 // File name should match first type name - Generic type file naming convention
|
||||
|
||||
namespace Common.Search;
|
||||
|
||||
/// <summary>
|
||||
/// Represents the result of a search operation that returns a value and may have errors.
|
||||
/// </summary>
|
||||
/// <typeparam name="T">The type of the result value.</typeparam>
|
||||
public sealed class SearchOperationResult<T>
|
||||
{
|
||||
private SearchOperationResult(bool success, T? value, SearchError? error)
|
||||
{
|
||||
IsSuccess = success;
|
||||
Value = value;
|
||||
Error = error;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets a value indicating whether the operation was successful.
|
||||
/// </summary>
|
||||
public bool IsSuccess { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets a value indicating whether the operation failed.
|
||||
/// </summary>
|
||||
public bool IsFailure => !IsSuccess;
|
||||
|
||||
/// <summary>
|
||||
/// Gets the result value if the operation was successful.
|
||||
/// </summary>
|
||||
public T? Value { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the error if the operation failed, null otherwise.
|
||||
/// </summary>
|
||||
public SearchError? Error { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the value or a default if the operation failed.
|
||||
/// </summary>
|
||||
/// <param name="defaultValue">The default value to return if the operation failed.</param>
|
||||
/// <returns>The value if successful, otherwise the default value.</returns>
|
||||
public T GetValueOrDefault(T defaultValue) => IsSuccess && Value is not null ? Value : defaultValue;
|
||||
|
||||
/// <inheritdoc/>
|
||||
public override string ToString()
|
||||
=> IsSuccess ? $"Success: {Value}" : $"Failure: {Error}";
|
||||
|
||||
/// <summary>
|
||||
/// Creates a successful result with the specified value.
|
||||
/// </summary>
|
||||
/// <param name="value">The result value.</param>
|
||||
/// <returns>A successful SearchOperationResult.</returns>
|
||||
[SuppressMessage("Design", "CA1000:Do not declare static members on generic types", Justification = "Factory method pattern is the idiomatic way to create instances of generic result types")]
|
||||
public static SearchOperationResult<T> Success(T value) => new(true, value, null);
|
||||
|
||||
/// <summary>
|
||||
/// Creates a failed result with the specified error.
|
||||
/// </summary>
|
||||
/// <param name="error">The error that caused the failure.</param>
|
||||
/// <returns>A failed SearchOperationResult.</returns>
|
||||
[SuppressMessage("Design", "CA1000:Do not declare static members on generic types", Justification = "Factory method pattern is the idiomatic way to create instances of generic result types")]
|
||||
public static SearchOperationResult<T> Failure(SearchError error)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(error);
|
||||
return new SearchOperationResult<T>(false, default, error);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates a failed result with the specified error and a fallback value.
|
||||
/// </summary>
|
||||
/// <param name="error">The error that caused the failure.</param>
|
||||
/// <param name="fallbackValue">A fallback value to use despite the failure.</param>
|
||||
/// <returns>A failed SearchOperationResult with a fallback value.</returns>
|
||||
[SuppressMessage("Design", "CA1000:Do not declare static members on generic types", Justification = "Factory method pattern is the idiomatic way to create instances of generic result types")]
|
||||
public static SearchOperationResult<T> FailureWithFallback(SearchError error, T fallbackValue)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(error);
|
||||
return new SearchOperationResult<T>(false, fallbackValue, error);
|
||||
}
|
||||
}
|
||||
35
src/common/Common.Search/SearchOptions.cs
Normal file
35
src/common/Common.Search/SearchOptions.cs
Normal file
@@ -0,0 +1,35 @@
|
||||
// Copyright (c) Microsoft Corporation
|
||||
// The Microsoft Corporation licenses this file to you under the MIT license.
|
||||
// See the LICENSE file in the project root for more information.
|
||||
|
||||
namespace Common.Search;
|
||||
|
||||
/// <summary>
|
||||
/// Options for configuring search behavior.
|
||||
/// </summary>
|
||||
public sealed class SearchOptions
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets or sets the maximum number of results to return.
|
||||
/// Default is 20.
|
||||
/// </summary>
|
||||
public int MaxResults { get; set; } = 20;
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the minimum score threshold.
|
||||
/// Results below this score are filtered out.
|
||||
/// Default is 0.0 (no filtering).
|
||||
/// </summary>
|
||||
public double MinScore { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the language hint for the search (e.g., "en-US").
|
||||
/// </summary>
|
||||
public string? Language { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets a value indicating whether to include match spans for highlighting.
|
||||
/// Default is false.
|
||||
/// </summary>
|
||||
public bool IncludeMatchSpans { get; set; }
|
||||
}
|
||||
33
src/common/Common.Search/SearchResult`1.cs
Normal file
33
src/common/Common.Search/SearchResult`1.cs
Normal file
@@ -0,0 +1,33 @@
|
||||
// Copyright (c) Microsoft Corporation
|
||||
// The Microsoft Corporation licenses this file to you under the MIT license.
|
||||
// See the LICENSE file in the project root for more information.
|
||||
|
||||
namespace Common.Search;
|
||||
|
||||
/// <summary>
|
||||
/// Represents a search result with the matched item and scoring information.
|
||||
/// </summary>
|
||||
/// <typeparam name="T">The type of the matched item.</typeparam>
|
||||
public sealed class SearchResult<T>
|
||||
where T : ISearchable
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets the matched item.
|
||||
/// </summary>
|
||||
public required T Item { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the relevance score (higher is more relevant).
|
||||
/// </summary>
|
||||
public required double Score { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the type of match that produced this result.
|
||||
/// </summary>
|
||||
public required SearchMatchKind MatchKind { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the match details for highlighting (optional).
|
||||
/// </summary>
|
||||
public IReadOnlyList<MatchSpan>? MatchSpans { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
// Copyright (c) Microsoft Corporation
|
||||
// The Microsoft Corporation licenses this file to you under the MIT license.
|
||||
// See the LICENSE file in the project root for more information.
|
||||
|
||||
namespace Common.Search.SemanticSearch;
|
||||
|
||||
/// <summary>
|
||||
/// Represents the capabilities of the semantic search index.
|
||||
/// </summary>
|
||||
public class SemanticSearchCapabilities
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets or sets a value indicating whether text lexical (keyword) search is available.
|
||||
/// </summary>
|
||||
public bool TextLexicalAvailable { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets a value indicating whether text semantic (AI embedding) search is available.
|
||||
/// </summary>
|
||||
public bool TextSemanticAvailable { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets a value indicating whether image semantic search is available.
|
||||
/// </summary>
|
||||
public bool ImageSemanticAvailable { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets a value indicating whether image OCR search is available.
|
||||
/// </summary>
|
||||
public bool ImageOcrAvailable { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets a value indicating whether any search capability is available.
|
||||
/// </summary>
|
||||
public bool AnyAvailable => TextLexicalAvailable || TextSemanticAvailable || ImageSemanticAvailable || ImageOcrAvailable;
|
||||
|
||||
/// <summary>
|
||||
/// Gets a value indicating whether text search (lexical or semantic) is available.
|
||||
/// </summary>
|
||||
public bool TextSearchAvailable => TextLexicalAvailable || TextSemanticAvailable;
|
||||
|
||||
/// <summary>
|
||||
/// Gets a value indicating whether image search (semantic or OCR) is available.
|
||||
/// </summary>
|
||||
public bool ImageSearchAvailable => ImageSemanticAvailable || ImageOcrAvailable;
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
// Copyright (c) Microsoft Corporation
|
||||
// The Microsoft Corporation licenses this file to you under the MIT license.
|
||||
// See the LICENSE file in the project root for more information.
|
||||
|
||||
namespace Common.Search.SemanticSearch;
|
||||
|
||||
/// <summary>
|
||||
/// Specifies the kind of content in a semantic search result.
|
||||
/// </summary>
|
||||
public enum SemanticSearchContentKind
|
||||
{
|
||||
/// <summary>
|
||||
/// Text content.
|
||||
/// </summary>
|
||||
Text,
|
||||
|
||||
/// <summary>
|
||||
/// Image content.
|
||||
/// </summary>
|
||||
Image,
|
||||
}
|
||||
@@ -0,0 +1,406 @@
|
||||
// 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 ManagedCommon;
|
||||
|
||||
namespace Common.Search.SemanticSearch;
|
||||
|
||||
/// <summary>
|
||||
/// A semantic search engine that implements the common search interface.
|
||||
/// </summary>
|
||||
/// <typeparam name="T">The type of items to search.</typeparam>
|
||||
public sealed class SemanticSearchEngine<T> : ISearchEngine<T>
|
||||
where T : ISearchable
|
||||
{
|
||||
private readonly SemanticSearchIndex _index;
|
||||
private readonly Dictionary<string, T> _itemsById = new();
|
||||
private readonly object _lockObject = new();
|
||||
private bool _disposed;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="SemanticSearchEngine{T}"/> class.
|
||||
/// </summary>
|
||||
/// <param name="indexName">The name of the search index.</param>
|
||||
public SemanticSearchEngine(string indexName)
|
||||
{
|
||||
Logger.LogDebug($"[SemanticSearchEngine] Creating engine. IndexName={indexName}, ItemType={typeof(T).Name}");
|
||||
_index = new SemanticSearchIndex(indexName);
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public bool IsReady => _index.IsInitialized;
|
||||
|
||||
/// <inheritdoc/>
|
||||
public SearchEngineCapabilities Capabilities { get; } = new()
|
||||
{
|
||||
SupportsFuzzyMatch = true,
|
||||
SupportsSemanticSearch = true,
|
||||
PersistsIndex = true,
|
||||
SupportsIncrementalIndex = true,
|
||||
SupportsMatchSpans = false,
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Gets the underlying semantic search capabilities.
|
||||
/// </summary>
|
||||
public SemanticSearchCapabilities? SemanticCapabilities => _index.Capabilities;
|
||||
|
||||
/// <summary>
|
||||
/// Gets the last error that occurred during a search operation, or null if no error occurred.
|
||||
/// </summary>
|
||||
public SearchError? LastError => _index.LastError;
|
||||
|
||||
/// <summary>
|
||||
/// Occurs when the semantic search capabilities change.
|
||||
/// </summary>
|
||||
public event EventHandler<SemanticSearchCapabilities>? CapabilitiesChanged
|
||||
{
|
||||
add => _index.CapabilitiesChanged += value;
|
||||
remove => _index.CapabilitiesChanged -= value;
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public async Task InitializeAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
ThrowIfDisposed();
|
||||
Logger.LogInfo($"[SemanticSearchEngine] InitializeAsync starting. ItemType={typeof(T).Name}");
|
||||
var result = await _index.InitializeAsync().ConfigureAwait(false);
|
||||
|
||||
if (result.IsFailure)
|
||||
{
|
||||
Logger.LogWarning($"[SemanticSearchEngine] InitializeAsync failed. ItemType={typeof(T).Name}, Error={result.Error?.Message}");
|
||||
}
|
||||
else
|
||||
{
|
||||
Logger.LogInfo($"[SemanticSearchEngine] InitializeAsync completed. ItemType={typeof(T).Name}");
|
||||
}
|
||||
|
||||
// Note: We don't throw here to maintain backward compatibility,
|
||||
// but callers can check LastError for details if initialization failed.
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Initializes the search engine and returns the result with error details if any.
|
||||
/// </summary>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>A result indicating success or failure with error details.</returns>
|
||||
public async Task<SearchOperationResult> InitializeWithResultAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
ThrowIfDisposed();
|
||||
return await _index.InitializeAsync().ConfigureAwait(false);
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public Task IndexAsync(T item, CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(item);
|
||||
ThrowIfDisposed();
|
||||
|
||||
var text = BuildSearchableText(item);
|
||||
if (string.IsNullOrWhiteSpace(text))
|
||||
{
|
||||
Logger.LogDebug($"[SemanticSearchEngine] IndexAsync skipped (empty text). Id={item.Id}");
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
lock (_lockObject)
|
||||
{
|
||||
_itemsById[item.Id] = item;
|
||||
}
|
||||
|
||||
Logger.LogDebug($"[SemanticSearchEngine] IndexAsync. Id={item.Id}, TextLength={text.Length}");
|
||||
|
||||
// Note: Errors are captured in LastError for external logging
|
||||
_ = _index.IndexText(item.Id, text);
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Indexes a single item and returns the result with error details if any.
|
||||
/// </summary>
|
||||
/// <param name="item">The item to index.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>A result indicating success or failure with error details.</returns>
|
||||
public Task<SearchOperationResult> IndexWithResultAsync(T item, CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(item);
|
||||
ThrowIfDisposed();
|
||||
|
||||
var text = BuildSearchableText(item);
|
||||
if (string.IsNullOrWhiteSpace(text))
|
||||
{
|
||||
return Task.FromResult(SearchOperationResult.Success());
|
||||
}
|
||||
|
||||
lock (_lockObject)
|
||||
{
|
||||
_itemsById[item.Id] = item;
|
||||
}
|
||||
|
||||
return Task.FromResult(_index.IndexText(item.Id, text));
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public Task IndexBatchAsync(IEnumerable<T> items, CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(items);
|
||||
ThrowIfDisposed();
|
||||
|
||||
var batch = new List<(string Id, string Text)>();
|
||||
|
||||
lock (_lockObject)
|
||||
{
|
||||
foreach (var item in items)
|
||||
{
|
||||
if (cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
Logger.LogDebug($"[SemanticSearchEngine] IndexBatchAsync cancelled. ItemsProcessed={batch.Count}");
|
||||
break;
|
||||
}
|
||||
|
||||
var text = BuildSearchableText(item);
|
||||
if (!string.IsNullOrWhiteSpace(text))
|
||||
{
|
||||
_itemsById[item.Id] = item;
|
||||
batch.Add((item.Id, text));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Logger.LogInfo($"[SemanticSearchEngine] IndexBatchAsync. BatchSize={batch.Count}");
|
||||
|
||||
// Note: Errors are captured in LastError for external logging
|
||||
_ = _index.IndexTextBatch(batch);
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Indexes multiple items in batch and returns the result with error details if any.
|
||||
/// </summary>
|
||||
/// <param name="items">The items to index.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>A result indicating success or failure with error details.</returns>
|
||||
public Task<SearchOperationResult> IndexBatchWithResultAsync(IEnumerable<T> items, CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(items);
|
||||
ThrowIfDisposed();
|
||||
|
||||
var batch = new List<(string Id, string Text)>();
|
||||
|
||||
lock (_lockObject)
|
||||
{
|
||||
foreach (var item in items)
|
||||
{
|
||||
if (cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
break;
|
||||
}
|
||||
|
||||
var text = BuildSearchableText(item);
|
||||
if (!string.IsNullOrWhiteSpace(text))
|
||||
{
|
||||
_itemsById[item.Id] = item;
|
||||
batch.Add((item.Id, text));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return Task.FromResult(_index.IndexTextBatch(batch));
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public Task RemoveAsync(string id, CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(id);
|
||||
ThrowIfDisposed();
|
||||
|
||||
lock (_lockObject)
|
||||
{
|
||||
_itemsById.Remove(id);
|
||||
}
|
||||
|
||||
Logger.LogDebug($"[SemanticSearchEngine] RemoveAsync. Id={id}");
|
||||
|
||||
// Note: Errors are captured in LastError for external logging
|
||||
_ = _index.Remove(id);
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public Task ClearAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
ThrowIfDisposed();
|
||||
|
||||
int count;
|
||||
lock (_lockObject)
|
||||
{
|
||||
count = _itemsById.Count;
|
||||
_itemsById.Clear();
|
||||
}
|
||||
|
||||
Logger.LogInfo($"[SemanticSearchEngine] ClearAsync. ItemsCleared={count}");
|
||||
|
||||
// Note: Errors are captured in LastError for external logging
|
||||
_ = _index.RemoveAll();
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public Task<IReadOnlyList<SearchResult<T>>> SearchAsync(
|
||||
string query,
|
||||
SearchOptions? options = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ThrowIfDisposed();
|
||||
|
||||
if (string.IsNullOrWhiteSpace(query))
|
||||
{
|
||||
Logger.LogDebug($"[SemanticSearchEngine] SearchAsync skipped (empty query).");
|
||||
return Task.FromResult<IReadOnlyList<SearchResult<T>>>(Array.Empty<SearchResult<T>>());
|
||||
}
|
||||
|
||||
options ??= new SearchOptions();
|
||||
Logger.LogDebug($"[SemanticSearchEngine] SearchAsync starting. Query={query}, MaxResults={options.MaxResults}");
|
||||
|
||||
var semanticOptions = new SemanticSearchOptions
|
||||
{
|
||||
MaxResults = options.MaxResults,
|
||||
Language = options.Language,
|
||||
MatchScope = SemanticSearchMatchScope.Unconstrained,
|
||||
TextMatchType = SemanticSearchTextMatchType.Fuzzy,
|
||||
};
|
||||
|
||||
var searchResult = _index.SearchText(query, semanticOptions);
|
||||
|
||||
// Note: Errors are captured in LastError for external logging
|
||||
var matches = searchResult.Value ?? Array.Empty<SemanticSearchResult>();
|
||||
var results = new List<SearchResult<T>>();
|
||||
|
||||
lock (_lockObject)
|
||||
{
|
||||
foreach (var match in matches)
|
||||
{
|
||||
if (_itemsById.TryGetValue(match.ContentId, out var item))
|
||||
{
|
||||
results.Add(new SearchResult<T>
|
||||
{
|
||||
Item = item,
|
||||
Score = 100.0, // Semantic search doesn't return scores, use fixed value
|
||||
MatchKind = SearchMatchKind.Semantic,
|
||||
MatchSpans = null,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Logger.LogDebug($"[SemanticSearchEngine] SearchAsync completed. Query={query}, Matches={matches.Count}, Results={results.Count}");
|
||||
return Task.FromResult<IReadOnlyList<SearchResult<T>>>(results);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Searches for items matching the query and returns the result with error details if any.
|
||||
/// </summary>
|
||||
/// <param name="query">The search query.</param>
|
||||
/// <param name="options">Optional search options.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>A result containing search results or error details.</returns>
|
||||
public Task<SearchOperationResult<IReadOnlyList<SearchResult<T>>>> SearchWithResultAsync(
|
||||
string query,
|
||||
SearchOptions? options = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ThrowIfDisposed();
|
||||
|
||||
if (string.IsNullOrWhiteSpace(query))
|
||||
{
|
||||
return Task.FromResult(SearchOperationResult<IReadOnlyList<SearchResult<T>>>.Success(Array.Empty<SearchResult<T>>()));
|
||||
}
|
||||
|
||||
options ??= new SearchOptions();
|
||||
|
||||
var semanticOptions = new SemanticSearchOptions
|
||||
{
|
||||
MaxResults = options.MaxResults,
|
||||
Language = options.Language,
|
||||
MatchScope = SemanticSearchMatchScope.Unconstrained,
|
||||
TextMatchType = SemanticSearchTextMatchType.Fuzzy,
|
||||
};
|
||||
|
||||
var searchResult = _index.SearchText(query, semanticOptions);
|
||||
var matches = searchResult.Value ?? Array.Empty<SemanticSearchResult>();
|
||||
var results = new List<SearchResult<T>>();
|
||||
|
||||
lock (_lockObject)
|
||||
{
|
||||
foreach (var match in matches)
|
||||
{
|
||||
if (_itemsById.TryGetValue(match.ContentId, out var item))
|
||||
{
|
||||
results.Add(new SearchResult<T>
|
||||
{
|
||||
Item = item,
|
||||
Score = 100.0,
|
||||
MatchKind = SearchMatchKind.Semantic,
|
||||
MatchSpans = null,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (searchResult.IsFailure)
|
||||
{
|
||||
return Task.FromResult(SearchOperationResult<IReadOnlyList<SearchResult<T>>>.FailureWithFallback(searchResult.Error!, results));
|
||||
}
|
||||
|
||||
return Task.FromResult(SearchOperationResult<IReadOnlyList<SearchResult<T>>>.Success(results));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Waits for the indexing process to complete.
|
||||
/// </summary>
|
||||
/// <param name="timeout">The maximum time to wait.</param>
|
||||
/// <returns>A task representing the async operation.</returns>
|
||||
public async Task WaitForIndexingCompleteAsync(TimeSpan timeout)
|
||||
{
|
||||
ThrowIfDisposed();
|
||||
await _index.WaitForIndexingCompleteAsync(timeout).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public void Dispose()
|
||||
{
|
||||
if (_disposed)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
Logger.LogDebug($"[SemanticSearchEngine] Disposing. ItemType={typeof(T).Name}");
|
||||
_index.Dispose();
|
||||
|
||||
lock (_lockObject)
|
||||
{
|
||||
_itemsById.Clear();
|
||||
}
|
||||
|
||||
_disposed = true;
|
||||
}
|
||||
|
||||
private static string BuildSearchableText(T item)
|
||||
{
|
||||
var primary = item.SearchableText ?? string.Empty;
|
||||
var secondary = item.SecondarySearchableText;
|
||||
|
||||
if (string.IsNullOrWhiteSpace(secondary))
|
||||
{
|
||||
return primary;
|
||||
}
|
||||
|
||||
return $"{primary} {secondary}";
|
||||
}
|
||||
|
||||
private void ThrowIfDisposed()
|
||||
{
|
||||
ObjectDisposedException.ThrowIf(_disposed, this);
|
||||
}
|
||||
}
|
||||
455
src/common/Common.Search/SemanticSearch/SemanticSearchIndex.cs
Normal file
455
src/common/Common.Search/SemanticSearch/SemanticSearchIndex.cs
Normal file
@@ -0,0 +1,455 @@
|
||||
// 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 ManagedCommon;
|
||||
using Microsoft.Windows.Search.AppContentIndex;
|
||||
using Windows.Graphics.Imaging;
|
||||
using SearchOperationResult = Common.Search.SearchOperationResult;
|
||||
using SearchOperationResultT = Common.Search.SearchOperationResult<System.Collections.Generic.IReadOnlyList<Common.Search.SemanticSearch.SemanticSearchResult>>;
|
||||
|
||||
namespace Common.Search.SemanticSearch;
|
||||
|
||||
/// <summary>
|
||||
/// A semantic search engine powered by Windows App SDK AI Search APIs.
|
||||
/// Provides text and image indexing with lexical and semantic search capabilities.
|
||||
/// </summary>
|
||||
public sealed class SemanticSearchIndex : IDisposable
|
||||
{
|
||||
private readonly string _indexName;
|
||||
private AppContentIndexer? _indexer;
|
||||
private bool _disposed;
|
||||
private SemanticSearchCapabilities? _capabilities;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="SemanticSearchIndex"/> class.
|
||||
/// </summary>
|
||||
/// <param name="indexName">The name of the search index.</param>
|
||||
public SemanticSearchIndex(string indexName)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(indexName);
|
||||
_indexName = indexName;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the last error that occurred during an operation, or null if no error occurred.
|
||||
/// </summary>
|
||||
public SearchError? LastError { get; private set; }
|
||||
|
||||
/// <summary>
|
||||
/// Occurs when the index capabilities change.
|
||||
/// </summary>
|
||||
public event EventHandler<SemanticSearchCapabilities>? CapabilitiesChanged;
|
||||
|
||||
/// <summary>
|
||||
/// Gets a value indicating whether the search engine is initialized.
|
||||
/// </summary>
|
||||
public bool IsInitialized => _indexer != null;
|
||||
|
||||
/// <summary>
|
||||
/// Gets the current index capabilities, or null if not initialized.
|
||||
/// </summary>
|
||||
public SemanticSearchCapabilities? Capabilities => _capabilities;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes the search engine and creates or opens the index.
|
||||
/// </summary>
|
||||
/// <returns>A task that represents the asynchronous operation. Returns a result indicating success or failure with error details.</returns>
|
||||
public async Task<SearchOperationResult> InitializeAsync()
|
||||
{
|
||||
ThrowIfDisposed();
|
||||
LastError = null;
|
||||
|
||||
if (_indexer != null)
|
||||
{
|
||||
Logger.LogDebug($"[SemanticSearchIndex] Already initialized. IndexName={_indexName}");
|
||||
return SearchOperationResult.Success();
|
||||
}
|
||||
|
||||
Logger.LogInfo($"[SemanticSearchIndex] Initializing. IndexName={_indexName}");
|
||||
|
||||
try
|
||||
{
|
||||
var result = AppContentIndexer.GetOrCreateIndex(_indexName);
|
||||
if (!result.Succeeded)
|
||||
{
|
||||
var errorDetails = $"Succeeded={result.Succeeded}, ExtendedError={result.ExtendedError}";
|
||||
Logger.LogError($"[SemanticSearchIndex] GetOrCreateIndex failed. IndexName={_indexName}, {errorDetails}");
|
||||
LastError = SearchError.InitializationFailed(_indexName, errorDetails);
|
||||
return SearchOperationResult.Failure(LastError);
|
||||
}
|
||||
|
||||
_indexer = result.Indexer;
|
||||
|
||||
// Wait for index capabilities to be ready
|
||||
Logger.LogDebug($"[SemanticSearchIndex] Waiting for index capabilities. IndexName={_indexName}");
|
||||
await _indexer.WaitForIndexCapabilitiesAsync();
|
||||
|
||||
// Load capabilities
|
||||
_capabilities = LoadCapabilities();
|
||||
Logger.LogInfo($"[SemanticSearchIndex] Initialized successfully. IndexName={_indexName}, TextLexical={_capabilities.TextLexicalAvailable}, TextSemantic={_capabilities.TextSemanticAvailable}, ImageSemantic={_capabilities.ImageSemanticAvailable}, ImageOcr={_capabilities.ImageOcrAvailable}");
|
||||
|
||||
// Subscribe to capability changes
|
||||
_indexer.Listener.IndexCapabilitiesChanged += OnIndexCapabilitiesChanged;
|
||||
|
||||
return SearchOperationResult.Success();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Logger.LogError($"[SemanticSearchIndex] Initialization failed with exception. IndexName={_indexName}", ex);
|
||||
LastError = SearchError.InitializationFailed(_indexName, ex.Message, ex);
|
||||
return SearchOperationResult.Failure(LastError);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Waits for the indexing process to complete.
|
||||
/// </summary>
|
||||
/// <param name="timeout">The maximum time to wait for indexing to complete.</param>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
public async Task WaitForIndexingCompleteAsync(TimeSpan timeout)
|
||||
{
|
||||
ThrowIfDisposed();
|
||||
ThrowIfNotInitialized();
|
||||
|
||||
await _indexer!.WaitForIndexingIdleAsync(timeout);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the current index capabilities.
|
||||
/// </summary>
|
||||
/// <returns>The current capabilities of the search index.</returns>
|
||||
public SemanticSearchCapabilities GetCapabilities()
|
||||
{
|
||||
ThrowIfDisposed();
|
||||
ThrowIfNotInitialized();
|
||||
|
||||
return _capabilities ?? LoadCapabilities();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Adds or updates text content in the index.
|
||||
/// </summary>
|
||||
/// <param name="id">The unique identifier for the content.</param>
|
||||
/// <param name="text">The text content to index.</param>
|
||||
/// <returns>A result indicating success or failure with error details.</returns>
|
||||
public SearchOperationResult IndexText(string id, string text)
|
||||
{
|
||||
ThrowIfDisposed();
|
||||
ThrowIfNotInitialized();
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(id);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(text);
|
||||
|
||||
try
|
||||
{
|
||||
var content = AppManagedIndexableAppContent.CreateFromString(id, text);
|
||||
_indexer!.AddOrUpdate(content);
|
||||
return SearchOperationResult.Success();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Logger.LogError($"[SemanticSearchIndex] IndexText failed. Id={id}", ex);
|
||||
LastError = SearchError.IndexingFailed(id, ex.Message, ex);
|
||||
return SearchOperationResult.Failure(LastError);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Adds or updates multiple text contents in the index.
|
||||
/// </summary>
|
||||
/// <param name="items">A collection of id-text pairs to index.</param>
|
||||
/// <returns>A result indicating success or failure with error details. Contains the first error encountered if any.</returns>
|
||||
public SearchOperationResult IndexTextBatch(IEnumerable<(string Id, string Text)> items)
|
||||
{
|
||||
ThrowIfDisposed();
|
||||
ThrowIfNotInitialized();
|
||||
ArgumentNullException.ThrowIfNull(items);
|
||||
|
||||
SearchError? firstError = null;
|
||||
|
||||
foreach (var (id, text) in items)
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(id) && !string.IsNullOrWhiteSpace(text))
|
||||
{
|
||||
try
|
||||
{
|
||||
var content = AppManagedIndexableAppContent.CreateFromString(id, text);
|
||||
_indexer!.AddOrUpdate(content);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Logger.LogError($"[SemanticSearchIndex] IndexTextBatch item failed. Id={id}", ex);
|
||||
firstError ??= SearchError.IndexingFailed(id, ex.Message, ex);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (firstError != null)
|
||||
{
|
||||
LastError = firstError;
|
||||
return SearchOperationResult.Failure(firstError);
|
||||
}
|
||||
|
||||
return SearchOperationResult.Success();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Adds or updates image content in the index.
|
||||
/// </summary>
|
||||
/// <param name="id">The unique identifier for the image.</param>
|
||||
/// <param name="bitmap">The image bitmap to index.</param>
|
||||
/// <returns>A result indicating success or failure with error details.</returns>
|
||||
public SearchOperationResult IndexImage(string id, SoftwareBitmap bitmap)
|
||||
{
|
||||
ThrowIfDisposed();
|
||||
ThrowIfNotInitialized();
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(id);
|
||||
ArgumentNullException.ThrowIfNull(bitmap);
|
||||
|
||||
try
|
||||
{
|
||||
var content = AppManagedIndexableAppContent.CreateFromBitmap(id, bitmap);
|
||||
_indexer!.AddOrUpdate(content);
|
||||
return SearchOperationResult.Success();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Logger.LogError($"[SemanticSearchIndex] IndexImage failed. Id={id}", ex);
|
||||
LastError = SearchError.IndexingFailed(id, ex.Message, ex);
|
||||
return SearchOperationResult.Failure(LastError);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Removes content from the index by its identifier.
|
||||
/// </summary>
|
||||
/// <param name="id">The unique identifier of the content to remove.</param>
|
||||
/// <returns>A result indicating success or failure with error details.</returns>
|
||||
public SearchOperationResult Remove(string id)
|
||||
{
|
||||
ThrowIfDisposed();
|
||||
ThrowIfNotInitialized();
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(id);
|
||||
|
||||
try
|
||||
{
|
||||
_indexer!.Remove(id);
|
||||
return SearchOperationResult.Success();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Logger.LogError($"[SemanticSearchIndex] Remove failed. Id={id}", ex);
|
||||
LastError = SearchError.Unexpected("Remove", ex);
|
||||
return SearchOperationResult.Failure(LastError);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Removes all content from the index.
|
||||
/// </summary>
|
||||
/// <returns>A result indicating success or failure with error details.</returns>
|
||||
public SearchOperationResult RemoveAll()
|
||||
{
|
||||
ThrowIfDisposed();
|
||||
ThrowIfNotInitialized();
|
||||
|
||||
try
|
||||
{
|
||||
_indexer!.RemoveAll();
|
||||
return SearchOperationResult.Success();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Logger.LogError($"[SemanticSearchIndex] RemoveAll failed.", ex);
|
||||
LastError = SearchError.Unexpected("RemoveAll", ex);
|
||||
return SearchOperationResult.Failure(LastError);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Searches for text content in the index.
|
||||
/// </summary>
|
||||
/// <param name="searchText">The text to search for.</param>
|
||||
/// <param name="options">Optional search options.</param>
|
||||
/// <returns>A result containing search results or error details.</returns>
|
||||
public SearchOperationResultT SearchText(string searchText, SemanticSearchOptions? options = null)
|
||||
{
|
||||
ThrowIfDisposed();
|
||||
ThrowIfNotInitialized();
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(searchText);
|
||||
|
||||
options ??= new SemanticSearchOptions();
|
||||
|
||||
try
|
||||
{
|
||||
var queryOptions = new TextQueryOptions
|
||||
{
|
||||
MatchScope = ConvertMatchScope(options.MatchScope),
|
||||
TextMatchType = ConvertTextMatchType(options.TextMatchType),
|
||||
};
|
||||
|
||||
if (!string.IsNullOrEmpty(options.Language))
|
||||
{
|
||||
queryOptions.Language = options.Language;
|
||||
}
|
||||
|
||||
var query = _indexer!.CreateTextQuery(searchText, queryOptions);
|
||||
var matches = query.GetNextMatches(options.MaxResults);
|
||||
|
||||
return SearchOperationResultT.Success(ConvertTextMatches(matches));
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Logger.LogError($"[SemanticSearchIndex] SearchText failed. Query={searchText}", ex);
|
||||
LastError = SearchError.SearchFailed(searchText, ex.Message, ex);
|
||||
return SearchOperationResultT.FailureWithFallback(LastError, Array.Empty<SemanticSearchResult>());
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Searches for image content in the index using text.
|
||||
/// </summary>
|
||||
/// <param name="searchText">The text to search for in images.</param>
|
||||
/// <param name="options">Optional search options.</param>
|
||||
/// <returns>A result containing search results or error details.</returns>
|
||||
public SearchOperationResultT SearchImages(string searchText, SemanticSearchOptions? options = null)
|
||||
{
|
||||
ThrowIfDisposed();
|
||||
ThrowIfNotInitialized();
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(searchText);
|
||||
|
||||
options ??= new SemanticSearchOptions();
|
||||
|
||||
try
|
||||
{
|
||||
var queryOptions = new ImageQueryOptions
|
||||
{
|
||||
MatchScope = ConvertMatchScope(options.MatchScope),
|
||||
ImageOcrTextMatchType = ConvertTextMatchType(options.TextMatchType),
|
||||
};
|
||||
|
||||
var query = _indexer!.CreateImageQuery(searchText, queryOptions);
|
||||
var matches = query.GetNextMatches(options.MaxResults);
|
||||
|
||||
return SearchOperationResultT.Success(ConvertImageMatches(matches));
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Logger.LogError($"[SemanticSearchIndex] SearchImages failed. Query={searchText}", ex);
|
||||
LastError = SearchError.SearchFailed(searchText, ex.Message, ex);
|
||||
return SearchOperationResultT.FailureWithFallback(LastError, Array.Empty<SemanticSearchResult>());
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public void Dispose()
|
||||
{
|
||||
if (_disposed)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (_indexer != null)
|
||||
{
|
||||
_indexer.Listener.IndexCapabilitiesChanged -= OnIndexCapabilitiesChanged;
|
||||
_indexer.Dispose();
|
||||
_indexer = null;
|
||||
}
|
||||
|
||||
_disposed = true;
|
||||
}
|
||||
|
||||
private SemanticSearchCapabilities LoadCapabilities()
|
||||
{
|
||||
var capabilities = _indexer!.GetIndexCapabilities();
|
||||
|
||||
return new SemanticSearchCapabilities
|
||||
{
|
||||
TextLexicalAvailable = IsCapabilityInitialized(capabilities, IndexCapability.TextLexical),
|
||||
TextSemanticAvailable = IsCapabilityInitialized(capabilities, IndexCapability.TextSemantic),
|
||||
ImageSemanticAvailable = IsCapabilityInitialized(capabilities, IndexCapability.ImageSemantic),
|
||||
ImageOcrAvailable = IsCapabilityInitialized(capabilities, IndexCapability.ImageOcr),
|
||||
};
|
||||
}
|
||||
|
||||
private static bool IsCapabilityInitialized(IndexCapabilities capabilities, IndexCapability capability)
|
||||
{
|
||||
var state = capabilities.GetCapabilityState(capability);
|
||||
return state.InitializationStatus == IndexCapabilityInitializationStatus.Initialized;
|
||||
}
|
||||
|
||||
private void OnIndexCapabilitiesChanged(AppContentIndexer indexer, IndexCapabilities capabilities)
|
||||
{
|
||||
_capabilities = LoadCapabilities();
|
||||
Logger.LogInfo($"[SemanticSearchIndex] Capabilities changed. IndexName={_indexName}, TextLexical={_capabilities.TextLexicalAvailable}, TextSemantic={_capabilities.TextSemanticAvailable}, ImageSemantic={_capabilities.ImageSemanticAvailable}, ImageOcr={_capabilities.ImageOcrAvailable}");
|
||||
CapabilitiesChanged?.Invoke(this, _capabilities);
|
||||
}
|
||||
|
||||
private static QueryMatchScope ConvertMatchScope(SemanticSearchMatchScope scope)
|
||||
{
|
||||
return scope switch
|
||||
{
|
||||
SemanticSearchMatchScope.Unconstrained => QueryMatchScope.Unconstrained,
|
||||
SemanticSearchMatchScope.Region => QueryMatchScope.Region,
|
||||
SemanticSearchMatchScope.ContentItem => QueryMatchScope.ContentItem,
|
||||
_ => QueryMatchScope.Unconstrained,
|
||||
};
|
||||
}
|
||||
|
||||
private static TextLexicalMatchType ConvertTextMatchType(SemanticSearchTextMatchType matchType)
|
||||
{
|
||||
return matchType switch
|
||||
{
|
||||
SemanticSearchTextMatchType.Fuzzy => TextLexicalMatchType.Fuzzy,
|
||||
SemanticSearchTextMatchType.Exact => TextLexicalMatchType.Exact,
|
||||
_ => TextLexicalMatchType.Fuzzy,
|
||||
};
|
||||
}
|
||||
|
||||
private static IReadOnlyList<SemanticSearchResult> ConvertTextMatches(IReadOnlyList<TextQueryMatch> matches)
|
||||
{
|
||||
var results = new List<SemanticSearchResult>();
|
||||
|
||||
foreach (var match in matches)
|
||||
{
|
||||
var result = new SemanticSearchResult(match.ContentId, SemanticSearchContentKind.Text);
|
||||
|
||||
if (match.ContentKind == QueryMatchContentKind.AppManagedText &&
|
||||
match is AppManagedTextQueryMatch textMatch)
|
||||
{
|
||||
result.TextOffset = textMatch.TextOffset;
|
||||
result.TextLength = textMatch.TextLength;
|
||||
}
|
||||
|
||||
results.Add(result);
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
private static IReadOnlyList<SemanticSearchResult> ConvertImageMatches(IReadOnlyList<ImageQueryMatch> matches)
|
||||
{
|
||||
var results = new List<SemanticSearchResult>();
|
||||
|
||||
foreach (var match in matches)
|
||||
{
|
||||
var result = new SemanticSearchResult(match.ContentId, SemanticSearchContentKind.Image);
|
||||
results.Add(result);
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
private void ThrowIfDisposed()
|
||||
{
|
||||
ObjectDisposedException.ThrowIf(_disposed, this);
|
||||
}
|
||||
|
||||
private void ThrowIfNotInitialized()
|
||||
{
|
||||
if (_indexer == null)
|
||||
{
|
||||
throw new InvalidOperationException("Search engine is not initialized. Call InitializeAsync() first.");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
// Copyright (c) Microsoft Corporation
|
||||
// The Microsoft Corporation licenses this file to you under the MIT license.
|
||||
// See the LICENSE file in the project root for more information.
|
||||
|
||||
namespace Common.Search.SemanticSearch;
|
||||
|
||||
/// <summary>
|
||||
/// Specifies the scope for semantic search matching.
|
||||
/// </summary>
|
||||
public enum SemanticSearchMatchScope
|
||||
{
|
||||
/// <summary>
|
||||
/// No constraints, uses both Lexical and Semantic matching.
|
||||
/// </summary>
|
||||
Unconstrained,
|
||||
|
||||
/// <summary>
|
||||
/// Restrict matching to a specific region.
|
||||
/// </summary>
|
||||
Region,
|
||||
|
||||
/// <summary>
|
||||
/// Restrict matching to a single content item.
|
||||
/// </summary>
|
||||
ContentItem,
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
// Copyright (c) Microsoft Corporation
|
||||
// The Microsoft Corporation licenses this file to you under the MIT license.
|
||||
// See the LICENSE file in the project root for more information.
|
||||
|
||||
namespace Common.Search.SemanticSearch;
|
||||
|
||||
/// <summary>
|
||||
/// Options for configuring semantic search queries.
|
||||
/// </summary>
|
||||
public class SemanticSearchOptions
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets or sets the language for the search query (e.g., "en-US").
|
||||
/// </summary>
|
||||
public string? Language { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the match scope for the search.
|
||||
/// </summary>
|
||||
public SemanticSearchMatchScope MatchScope { get; set; } = SemanticSearchMatchScope.Unconstrained;
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the text match type for lexical matching.
|
||||
/// </summary>
|
||||
public SemanticSearchTextMatchType TextMatchType { get; set; } = SemanticSearchTextMatchType.Fuzzy;
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the maximum number of results to return.
|
||||
/// </summary>
|
||||
public int MaxResults { get; set; } = 10;
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
// Copyright (c) Microsoft Corporation
|
||||
// The Microsoft Corporation licenses this file to you under the MIT license.
|
||||
// See the LICENSE file in the project root for more information.
|
||||
|
||||
namespace Common.Search.SemanticSearch;
|
||||
|
||||
/// <summary>
|
||||
/// Represents a search result from the semantic search engine.
|
||||
/// </summary>
|
||||
public class SemanticSearchResult
|
||||
{
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="SemanticSearchResult"/> class.
|
||||
/// </summary>
|
||||
/// <param name="contentId">The unique identifier of the matched content.</param>
|
||||
/// <param name="contentKind">The kind of content matched (text or image).</param>
|
||||
public SemanticSearchResult(string contentId, SemanticSearchContentKind contentKind)
|
||||
{
|
||||
ContentId = contentId;
|
||||
ContentKind = contentKind;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the unique identifier of the matched content.
|
||||
/// </summary>
|
||||
public string ContentId { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the kind of content that was matched.
|
||||
/// </summary>
|
||||
public SemanticSearchContentKind ContentKind { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the text offset where the match was found (for text matches only).
|
||||
/// </summary>
|
||||
public int TextOffset { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the length of the matched text (for text matches only).
|
||||
/// </summary>
|
||||
public int TextLength { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
// Copyright (c) Microsoft Corporation
|
||||
// The Microsoft Corporation licenses this file to you under the MIT license.
|
||||
// See the LICENSE file in the project root for more information.
|
||||
|
||||
namespace Common.Search.SemanticSearch;
|
||||
|
||||
/// <summary>
|
||||
/// Specifies the type of text matching for lexical searches.
|
||||
/// </summary>
|
||||
public enum SemanticSearchTextMatchType
|
||||
{
|
||||
/// <summary>
|
||||
/// Fuzzy matching allows spelling errors and approximate words.
|
||||
/// </summary>
|
||||
Fuzzy,
|
||||
|
||||
/// <summary>
|
||||
/// Exact matching requires exact text matches.
|
||||
/// </summary>
|
||||
Exact,
|
||||
}
|
||||
@@ -30,7 +30,7 @@
|
||||
<ApplicationType>Windows Store</ApplicationType>
|
||||
<ApplicationTypeRevision>10.0</ApplicationTypeRevision>
|
||||
<UseWinUI>true</UseWinUI>
|
||||
<WindowsAppSDKSelfContained>true</WindowsAppSDKSelfContained>
|
||||
<WindowsAppSDKSelfContained>false</WindowsAppSDKSelfContained>
|
||||
<EnablePreviewMsixTooling>true</EnablePreviewMsixTooling>
|
||||
</PropertyGroup>
|
||||
<ItemGroup>
|
||||
|
||||
@@ -163,8 +163,22 @@ void CursorWrapCore::UpdateMonitorInfo()
|
||||
Logger::info(L"======= UPDATE MONITOR INFO END =======");
|
||||
}
|
||||
|
||||
POINT CursorWrapCore::HandleMouseMove(const POINT& currentPos, bool disableWrapDuringDrag, int wrapMode)
|
||||
POINT CursorWrapCore::HandleMouseMove(const POINT& currentPos, bool disableWrapDuringDrag, int wrapMode, bool disableOnSingleMonitor)
|
||||
{
|
||||
// Check if wrapping should be disabled on single monitor
|
||||
if (disableOnSingleMonitor && m_monitors.size() <= 1)
|
||||
{
|
||||
#ifdef _DEBUG
|
||||
static bool loggedOnce = false;
|
||||
if (!loggedOnce)
|
||||
{
|
||||
OutputDebugStringW(L"[CursorWrap] Single monitor detected - cursor wrapping disabled\n");
|
||||
loggedOnce = true;
|
||||
}
|
||||
#endif
|
||||
return currentPos;
|
||||
}
|
||||
|
||||
// Check if wrapping should be disabled during drag
|
||||
if (disableWrapDuringDrag && (GetAsyncKeyState(VK_LBUTTON) & 0x8000))
|
||||
{
|
||||
|
||||
@@ -18,9 +18,11 @@ public:
|
||||
|
||||
// Handle mouse move with wrap mode filtering
|
||||
// wrapMode: 0=Both, 1=VerticalOnly, 2=HorizontalOnly
|
||||
POINT HandleMouseMove(const POINT& currentPos, bool disableWrapDuringDrag, int wrapMode);
|
||||
// disableOnSingleMonitor: if true, cursor wrapping is disabled when only one monitor is connected
|
||||
POINT HandleMouseMove(const POINT& currentPos, bool disableWrapDuringDrag, int wrapMode, bool disableOnSingleMonitor);
|
||||
|
||||
const std::vector<MonitorInfo>& GetMonitors() const { return m_monitors; }
|
||||
size_t GetMonitorCount() const { return m_monitors.size(); }
|
||||
const MonitorTopology& GetTopology() const { return m_topology; }
|
||||
|
||||
private:
|
||||
|
||||
@@ -54,6 +54,7 @@ namespace
|
||||
const wchar_t JSON_KEY_AUTO_ACTIVATE[] = L"auto_activate";
|
||||
const wchar_t JSON_KEY_DISABLE_WRAP_DURING_DRAG[] = L"disable_wrap_during_drag";
|
||||
const wchar_t JSON_KEY_WRAP_MODE[] = L"wrap_mode";
|
||||
const wchar_t JSON_KEY_DISABLE_ON_SINGLE_MONITOR[] = L"disable_cursor_wrap_on_single_monitor";
|
||||
}
|
||||
|
||||
// The PowerToy name that will be shown in the settings.
|
||||
@@ -80,6 +81,7 @@ private:
|
||||
bool m_enabled = false;
|
||||
bool m_autoActivate = false;
|
||||
bool m_disableWrapDuringDrag = true; // Default to true to prevent wrap during drag
|
||||
bool m_disableOnSingleMonitor = false; // Default to false
|
||||
int m_wrapMode = 0; // 0=Both (default), 1=VerticalOnly, 2=HorizontalOnly
|
||||
|
||||
// Mouse hook
|
||||
@@ -196,6 +198,10 @@ public:
|
||||
// Start listening for external trigger event so we can invoke the same logic as the activation hotkey.
|
||||
m_triggerEventHandle = CreateEventW(nullptr, false, false, CommonSharedConstants::CURSOR_WRAP_TRIGGER_EVENT);
|
||||
m_terminateEventHandle = CreateEventW(nullptr, false, false, nullptr);
|
||||
if (m_triggerEventHandle)
|
||||
{
|
||||
ResetEvent(m_triggerEventHandle);
|
||||
}
|
||||
if (m_triggerEventHandle && m_terminateEventHandle)
|
||||
{
|
||||
m_listening = true;
|
||||
@@ -210,8 +216,16 @@ public:
|
||||
// Create message window for display change notifications
|
||||
RegisterForDisplayChanges();
|
||||
|
||||
StartMouseHook();
|
||||
Logger::info("CursorWrap enabled - mouse hook started");
|
||||
// Only start the mouse hook automatically if auto-activate is enabled
|
||||
if (m_autoActivate)
|
||||
{
|
||||
StartMouseHook();
|
||||
Logger::info("CursorWrap enabled - mouse hook started (auto-activate on)");
|
||||
}
|
||||
else
|
||||
{
|
||||
Logger::info("CursorWrap enabled - waiting for activation hotkey (auto-activate off)");
|
||||
}
|
||||
|
||||
while (m_listening)
|
||||
{
|
||||
@@ -415,6 +429,21 @@ private:
|
||||
{
|
||||
Logger::warn("Failed to initialize CursorWrap wrap mode from settings. Will use default value (0=Both)");
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
// Parse disable on single monitor
|
||||
auto propertiesObject = settingsObject.GetNamedObject(JSON_KEY_PROPERTIES);
|
||||
if (propertiesObject.HasKey(JSON_KEY_DISABLE_ON_SINGLE_MONITOR))
|
||||
{
|
||||
auto disableOnSingleMonitorObject = propertiesObject.GetNamedObject(JSON_KEY_DISABLE_ON_SINGLE_MONITOR);
|
||||
m_disableOnSingleMonitor = disableOnSingleMonitorObject.GetNamedBoolean(JSON_KEY_VALUE);
|
||||
}
|
||||
}
|
||||
catch (...)
|
||||
{
|
||||
Logger::warn("Failed to initialize CursorWrap disable on single monitor from settings. Will use default value (false)");
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
@@ -646,7 +675,8 @@ private:
|
||||
POINT newPos = g_cursorWrapInstance->m_core.HandleMouseMove(
|
||||
currentPos,
|
||||
g_cursorWrapInstance->m_disableWrapDuringDrag,
|
||||
g_cursorWrapInstance->m_wrapMode);
|
||||
g_cursorWrapInstance->m_wrapMode,
|
||||
g_cursorWrapInstance->m_disableOnSingleMonitor);
|
||||
|
||||
if (newPos.x != currentPos.x || newPos.y != currentPos.y)
|
||||
{
|
||||
|
||||
@@ -21,7 +21,7 @@
|
||||
<CppWinRTEnableComponentProjection>false</CppWinRTEnableComponentProjection>
|
||||
<CppWinRTGenerateWindowsMetadata>false</CppWinRTGenerateWindowsMetadata>
|
||||
<WindowsAppSdkBootstrapInitialize>false</WindowsAppSdkBootstrapInitialize>
|
||||
<WindowsAppSDKSelfContained>true</WindowsAppSDKSelfContained>
|
||||
<WindowsAppSDKSelfContained>false</WindowsAppSDKSelfContained>
|
||||
<WindowsAppSDKVerifyTransitiveDependencies>false</WindowsAppSDKVerifyTransitiveDependencies>
|
||||
</PropertyGroup>
|
||||
<ItemGroup>
|
||||
|
||||
@@ -311,6 +311,14 @@ void AudioSampleGenerator::Stop()
|
||||
// Stop the audio graph - no more quantum callbacks will run
|
||||
m_audioGraph.Stop();
|
||||
|
||||
// Close the microphone input node to release the device so Windows no longer
|
||||
// reports the microphone as in use by ZoomIt.
|
||||
if (m_audioInputNode)
|
||||
{
|
||||
m_audioInputNode.Close();
|
||||
m_audioInputNode = nullptr;
|
||||
}
|
||||
|
||||
// Mark as stopped
|
||||
m_started.store(false);
|
||||
|
||||
|
||||
@@ -121,7 +121,7 @@ FONT 8, "MS Shell Dlg", 0, 0, 0x0
|
||||
BEGIN
|
||||
DEFPUSHBUTTON "OK",IDOK,186,306,50,14
|
||||
PUSHBUTTON "Cancel",IDCANCEL,243,306,50,14
|
||||
LTEXT "ZoomIt v10.0",IDC_VERSION,42,7,73,10
|
||||
LTEXT "ZoomIt v10.1",IDC_VERSION,42,7,73,10
|
||||
LTEXT "Copyright \251 2006-2026 Mark Russinovich",IDC_COPYRIGHT,42,17,251,8
|
||||
CONTROL "<a HREF=""https://www.sysinternals.com"">Sysinternals - www.sysinternals.com</a>",IDC_LINK,
|
||||
"SysLink",WS_TABSTOP,42,26,150,9
|
||||
|
||||
@@ -614,166 +614,4 @@ void AlwaysOnTop::RefreshBorders()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
HWND AlwaysOnTop::ResolveTransparencyTargetWindow(HWND window)
|
||||
{
|
||||
if (!window || !IsWindow(window))
|
||||
{
|
||||
return nullptr;
|
||||
}
|
||||
|
||||
// Only allow transparency changes on pinned windows
|
||||
if (!IsPinned(window))
|
||||
{
|
||||
return nullptr;
|
||||
}
|
||||
|
||||
return window;
|
||||
}
|
||||
|
||||
|
||||
void AlwaysOnTop::StepWindowTransparency(HWND window, int delta)
|
||||
{
|
||||
HWND targetWindow = ResolveTransparencyTargetWindow(window);
|
||||
if (!targetWindow)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
int currentTransparency = Settings::maxTransparencyPercentage;
|
||||
LONG exStyle = GetWindowLong(targetWindow, GWL_EXSTYLE);
|
||||
if (exStyle & WS_EX_LAYERED)
|
||||
{
|
||||
BYTE alpha = 255;
|
||||
if (GetLayeredWindowAttributes(targetWindow, nullptr, &alpha, nullptr))
|
||||
{
|
||||
currentTransparency = (alpha * 100) / 255;
|
||||
}
|
||||
}
|
||||
|
||||
int newTransparency = (std::max)(Settings::minTransparencyPercentage,
|
||||
(std::min)(Settings::maxTransparencyPercentage, currentTransparency + delta));
|
||||
|
||||
if (newTransparency != currentTransparency)
|
||||
{
|
||||
ApplyWindowAlpha(targetWindow, newTransparency);
|
||||
|
||||
if (AlwaysOnTopSettings::settings().enableSound)
|
||||
{
|
||||
m_sound.Play(delta > 0 ? Sound::Type::IncreaseOpacity : Sound::Type::DecreaseOpacity);
|
||||
}
|
||||
|
||||
Logger::debug(L"Transparency adjusted to {}%", newTransparency);
|
||||
}
|
||||
}
|
||||
|
||||
void AlwaysOnTop::ApplyWindowAlpha(HWND window, int percentage)
|
||||
{
|
||||
if (!window || !IsWindow(window))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
percentage = (std::max)(Settings::minTransparencyPercentage,
|
||||
(std::min)(Settings::maxTransparencyPercentage, percentage));
|
||||
|
||||
LONG exStyle = GetWindowLong(window, GWL_EXSTYLE);
|
||||
bool isCurrentlyLayered = (exStyle & WS_EX_LAYERED) != 0;
|
||||
|
||||
// Cache original state on first transparency application
|
||||
if (m_windowOriginalLayeredState.find(window) == m_windowOriginalLayeredState.end())
|
||||
{
|
||||
WindowLayeredState state;
|
||||
state.hadLayeredStyle = isCurrentlyLayered;
|
||||
|
||||
if (isCurrentlyLayered)
|
||||
{
|
||||
BYTE alpha = 255;
|
||||
COLORREF colorKey = 0;
|
||||
DWORD flags = 0;
|
||||
if (GetLayeredWindowAttributes(window, &colorKey, &alpha, &flags))
|
||||
{
|
||||
state.originalAlpha = alpha;
|
||||
state.usedColorKey = (flags & LWA_COLORKEY) != 0;
|
||||
state.colorKey = colorKey;
|
||||
}
|
||||
else
|
||||
{
|
||||
Logger::warn(L"GetLayeredWindowAttributes failed for layered window, skipping");
|
||||
return;
|
||||
}
|
||||
}
|
||||
m_windowOriginalLayeredState[window] = state;
|
||||
}
|
||||
|
||||
// Clear WS_EX_LAYERED first to ensure SetLayeredWindowAttributes works
|
||||
if (isCurrentlyLayered)
|
||||
{
|
||||
SetWindowLong(window, GWL_EXSTYLE, exStyle & ~WS_EX_LAYERED);
|
||||
SetWindowPos(window, nullptr, 0, 0, 0, 0, SWP_NOMOVE | SWP_NOSIZE | SWP_NOZORDER | SWP_FRAMECHANGED);
|
||||
exStyle = GetWindowLong(window, GWL_EXSTYLE);
|
||||
}
|
||||
|
||||
BYTE alphaValue = static_cast<BYTE>((255 * percentage) / 100);
|
||||
SetWindowLong(window, GWL_EXSTYLE, exStyle | WS_EX_LAYERED);
|
||||
SetLayeredWindowAttributes(window, 0, alphaValue, LWA_ALPHA);
|
||||
}
|
||||
|
||||
void AlwaysOnTop::RestoreWindowAlpha(HWND window)
|
||||
{
|
||||
if (!window || !IsWindow(window))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
LONG exStyle = GetWindowLong(window, GWL_EXSTYLE);
|
||||
auto it = m_windowOriginalLayeredState.find(window);
|
||||
|
||||
if (it != m_windowOriginalLayeredState.end())
|
||||
{
|
||||
const auto& originalState = it->second;
|
||||
|
||||
if (originalState.hadLayeredStyle)
|
||||
{
|
||||
// Window originally had WS_EX_LAYERED - restore original attributes
|
||||
// Clear and re-add to ensure clean state
|
||||
if (exStyle & WS_EX_LAYERED)
|
||||
{
|
||||
SetWindowLong(window, GWL_EXSTYLE, exStyle & ~WS_EX_LAYERED);
|
||||
exStyle = GetWindowLong(window, GWL_EXSTYLE);
|
||||
}
|
||||
SetWindowLong(window, GWL_EXSTYLE, exStyle | WS_EX_LAYERED);
|
||||
|
||||
// Restore original alpha and/or color key
|
||||
DWORD flags = LWA_ALPHA;
|
||||
if (originalState.usedColorKey)
|
||||
{
|
||||
flags |= LWA_COLORKEY;
|
||||
}
|
||||
SetLayeredWindowAttributes(window, originalState.colorKey, originalState.originalAlpha, flags);
|
||||
SetWindowPos(window, nullptr, 0, 0, 0, 0, SWP_NOMOVE | SWP_NOSIZE | SWP_NOZORDER | SWP_FRAMECHANGED);
|
||||
}
|
||||
else
|
||||
{
|
||||
// Window originally didn't have WS_EX_LAYERED - remove it completely
|
||||
if (exStyle & WS_EX_LAYERED)
|
||||
{
|
||||
SetLayeredWindowAttributes(window, 0, 255, LWA_ALPHA);
|
||||
SetWindowLong(window, GWL_EXSTYLE, exStyle & ~WS_EX_LAYERED);
|
||||
SetWindowPos(window, nullptr, 0, 0, 0, 0, SWP_NOMOVE | SWP_NOSIZE | SWP_NOZORDER | SWP_FRAMECHANGED);
|
||||
}
|
||||
}
|
||||
|
||||
m_windowOriginalLayeredState.erase(it);
|
||||
}
|
||||
else
|
||||
{
|
||||
// Fallback: no cached state, just remove layered style
|
||||
if (exStyle & WS_EX_LAYERED)
|
||||
{
|
||||
SetLayeredWindowAttributes(window, 0, 255, LWA_ALPHA);
|
||||
SetWindowLong(window, GWL_EXSTYLE, exStyle & ~WS_EX_LAYERED);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -5,12 +5,12 @@
|
||||
<ItemGroup>
|
||||
<PackageVersion Include="Microsoft.CommandPalette.Extensions" Version="0.5.250829002" />
|
||||
<PackageVersion Include="Microsoft.CodeAnalysis.NetAnalyzers" Version="9.0.0-preview.24508.2" />
|
||||
<PackageVersion Include="Microsoft.Web.WebView2" Version="1.0.2903.40" />
|
||||
<PackageVersion Include="Microsoft.Web.WebView2" Version="1.0.3405.78" />
|
||||
<PackageVersion Include="Microsoft.Windows.CsWin32" Version="0.3.183" />
|
||||
<PackageVersion Include="Microsoft.Windows.CsWinRT" Version="2.2.0" />
|
||||
<PackageVersion Include="Microsoft.Windows.SDK.BuildTools" Version="10.0.26100.4188" />
|
||||
<PackageVersion Include="Microsoft.Windows.SDK.BuildTools.MSIX" Version="1.7.20250829.1" />
|
||||
<PackageVersion Include="Microsoft.WindowsAppSDK" Version="1.8.250907003" />
|
||||
<PackageVersion Include="Microsoft.WindowsAppSDK" Version="2.0.0-experimental3" />
|
||||
<PackageVersion Include="Shmuelie.WinRTServer" Version="2.1.1" />
|
||||
<PackageVersion Include="StyleCop.Analyzers" Version="1.2.0-beta.556" />
|
||||
<PackageVersion Include="System.Text.Json" Version="9.0.8" />
|
||||
|
||||
@@ -107,7 +107,7 @@ public partial class AliasManager : ObservableObject
|
||||
}
|
||||
|
||||
// Look for the alias belonging to another command, and remove it
|
||||
if (newAlias is not null && kv.Value.Alias == newAlias.Alias)
|
||||
if (newAlias is not null && kv.Value.Alias == newAlias.Alias && kv.Value.CommandId != commandId)
|
||||
{
|
||||
toRemove.Add(kv.Value);
|
||||
|
||||
|
||||
@@ -140,7 +140,7 @@
|
||||
<TextBlock
|
||||
x:Name="FormatNameTextBlock"
|
||||
VerticalAlignment="Center"
|
||||
Foreground="{DynamicResource TextFillColorSecondaryBrush}"
|
||||
Foreground="{DynamicResource TextFillColorPrimaryBrush}"
|
||||
Style="{StaticResource CaptionTextBlockStyle}"
|
||||
TextTrimming="CharacterEllipsis" />
|
||||
|
||||
|
||||
@@ -27,13 +27,13 @@
|
||||
<ApplicationIcon>Resources\ImageResizer.ico</ApplicationIcon>
|
||||
</PropertyGroup>
|
||||
|
||||
<PropertyGroup>
|
||||
<!-- <PropertyGroup>
|
||||
<ApplicationManifest>ImageResizerUI.dev.manifest</ApplicationManifest>
|
||||
</PropertyGroup>
|
||||
|
||||
<PropertyGroup Condition="'$(CIBuild)'=='true'">
|
||||
<ApplicationManifest>ImageResizerUI.prod.manifest</ApplicationManifest>
|
||||
</PropertyGroup>
|
||||
</PropertyGroup> -->
|
||||
|
||||
<ItemGroup>
|
||||
<EmbeddedResource Update="Properties\Resources.resx">
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
// 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.Text.Json.Serialization;
|
||||
using PowerDisplay.Common.Utils;
|
||||
|
||||
namespace PowerDisplay.Common.Models
|
||||
{
|
||||
/// <summary>
|
||||
/// Represents a custom name mapping for a VCP code value.
|
||||
/// Used to override the default VCP value names with user-defined names.
|
||||
/// This class is shared between PowerDisplay app and Settings UI.
|
||||
/// </summary>
|
||||
public class CustomVcpValueMapping
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets or sets the VCP code (e.g., 0x14 for color temperature, 0x60 for input source).
|
||||
/// </summary>
|
||||
[JsonPropertyName("vcpCode")]
|
||||
public byte VcpCode { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the VCP value to map (e.g., 0x11 for HDMI-1).
|
||||
/// </summary>
|
||||
[JsonPropertyName("value")]
|
||||
public int Value { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the custom name to display instead of the default name.
|
||||
/// </summary>
|
||||
[JsonPropertyName("customName")]
|
||||
public string CustomName { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets a value indicating whether this mapping applies to all monitors.
|
||||
/// When true, the mapping is applied globally. When false, only applies to TargetMonitorId.
|
||||
/// </summary>
|
||||
[JsonPropertyName("applyToAll")]
|
||||
public bool ApplyToAll { get; set; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the target monitor ID when ApplyToAll is false.
|
||||
/// This is the monitor's unique identifier.
|
||||
/// </summary>
|
||||
[JsonPropertyName("targetMonitorId")]
|
||||
public string TargetMonitorId { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the target monitor display name (for UI display only, not serialized).
|
||||
/// </summary>
|
||||
[JsonIgnore]
|
||||
public string TargetMonitorName { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Gets the display name for the VCP code (for UI display).
|
||||
/// Uses VcpNames.GetCodeName() to get the standard MCCS VCP code name.
|
||||
/// Note: For localized display in Settings UI, use VcpCodeToDisplayNameConverter instead.
|
||||
/// </summary>
|
||||
[JsonIgnore]
|
||||
public string VcpCodeDisplayName => VcpNames.GetCodeName(VcpCode);
|
||||
|
||||
/// <summary>
|
||||
/// Gets the display name for the VCP value (using built-in mapping).
|
||||
/// </summary>
|
||||
[JsonIgnore]
|
||||
public string ValueDisplayName => VcpNames.GetFormattedValueName(VcpCode, Value);
|
||||
|
||||
/// <summary>
|
||||
/// Gets a summary string for display in the UI list.
|
||||
/// Format: "OriginalValue → CustomName" or "OriginalValue → CustomName (MonitorName)"
|
||||
/// </summary>
|
||||
[JsonIgnore]
|
||||
public string DisplaySummary
|
||||
{
|
||||
get
|
||||
{
|
||||
var baseSummary = $"{VcpNames.GetValueName(VcpCode, Value) ?? $"0x{Value:X2}"} → {CustomName}";
|
||||
if (!ApplyToAll && !string.IsNullOrEmpty(TargetMonitorName))
|
||||
{
|
||||
return $"{baseSummary} ({TargetMonitorName})";
|
||||
}
|
||||
|
||||
return baseSummary;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2,16 +2,27 @@
|
||||
// The Microsoft Corporation licenses this file to you under the MIT license.
|
||||
// See the LICENSE file in the project root for more information.
|
||||
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using PowerDisplay.Common.Models;
|
||||
|
||||
namespace PowerDisplay.Common.Utils
|
||||
{
|
||||
/// <summary>
|
||||
/// Provides human-readable names for VCP codes and their values based on MCCS v2.2a specification.
|
||||
/// Combines VCP code names (e.g., 0x10 = "Brightness") and VCP value names (e.g., 0x14:0x05 = "6500K").
|
||||
/// Supports localization through the LocalizedCodeNameProvider delegate.
|
||||
/// </summary>
|
||||
public static class VcpNames
|
||||
{
|
||||
/// <summary>
|
||||
/// Optional delegate to provide localized VCP code names.
|
||||
/// Set this at application startup to enable localization.
|
||||
/// The delegate receives a VCP code and should return the localized name, or null to use the default.
|
||||
/// </summary>
|
||||
public static Func<byte, string?>? LocalizedCodeNameProvider { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// VCP code to name mapping
|
||||
/// </summary>
|
||||
@@ -237,12 +248,21 @@ namespace PowerDisplay.Common.Utils
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Get the friendly name for a VCP code
|
||||
/// Get the friendly name for a VCP code.
|
||||
/// Uses LocalizedCodeNameProvider if set; falls back to built-in MCCS names if not.
|
||||
/// </summary>
|
||||
/// <param name="code">VCP code (e.g., 0x10)</param>
|
||||
/// <returns>Friendly name, or hex representation if unknown</returns>
|
||||
public static string GetCodeName(byte code)
|
||||
{
|
||||
// Try localized name first
|
||||
var localizedName = LocalizedCodeNameProvider?.Invoke(code);
|
||||
if (!string.IsNullOrEmpty(localizedName))
|
||||
{
|
||||
return localizedName;
|
||||
}
|
||||
|
||||
// Fallback to built-in MCCS names
|
||||
return CodeNames.TryGetValue(code, out var name) ? name : $"Unknown (0x{code:X2})";
|
||||
}
|
||||
|
||||
@@ -389,6 +409,16 @@ namespace PowerDisplay.Common.Utils
|
||||
},
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Get all known values for a VCP code
|
||||
/// </summary>
|
||||
/// <param name="vcpCode">VCP code (e.g., 0x14)</param>
|
||||
/// <returns>Dictionary of value to name mappings, or null if no mappings exist</returns>
|
||||
public static IReadOnlyDictionary<int, string>? GetValueMappings(byte vcpCode)
|
||||
{
|
||||
return ValueNames.TryGetValue(vcpCode, out var values) ? values : null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Get human-readable name for a VCP value
|
||||
/// </summary>
|
||||
@@ -424,5 +454,59 @@ namespace PowerDisplay.Common.Utils
|
||||
|
||||
return $"0x{value:X2}";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Get human-readable name for a VCP value with custom mapping support.
|
||||
/// Custom mappings take priority over built-in mappings.
|
||||
/// Monitor ID is required to properly filter monitor-specific mappings.
|
||||
/// </summary>
|
||||
/// <param name="vcpCode">VCP code (e.g., 0x14)</param>
|
||||
/// <param name="value">Value to translate</param>
|
||||
/// <param name="customMappings">Optional custom mappings that take priority</param>
|
||||
/// <param name="monitorId">Monitor ID to filter mappings</param>
|
||||
/// <returns>Name string like "sRGB" or null if unknown</returns>
|
||||
public static string? GetValueName(byte vcpCode, int value, IEnumerable<CustomVcpValueMapping>? customMappings, string monitorId)
|
||||
{
|
||||
// 1. Priority: Check custom mappings first
|
||||
if (customMappings != null)
|
||||
{
|
||||
// Find a matching custom mapping:
|
||||
// - ApplyToAll = true (global), OR
|
||||
// - ApplyToAll = false AND TargetMonitorId matches the given monitorId
|
||||
var custom = customMappings.FirstOrDefault(m =>
|
||||
m.VcpCode == vcpCode &&
|
||||
m.Value == value &&
|
||||
(m.ApplyToAll || (!m.ApplyToAll && m.TargetMonitorId == monitorId)));
|
||||
|
||||
if (custom != null && !string.IsNullOrEmpty(custom.CustomName))
|
||||
{
|
||||
return custom.CustomName;
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Fallback to built-in mappings
|
||||
return GetValueName(vcpCode, value);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Get formatted display name for a VCP value with custom mapping support.
|
||||
/// Custom mappings take priority over built-in mappings.
|
||||
/// Monitor ID is required to properly filter monitor-specific mappings.
|
||||
/// </summary>
|
||||
/// <param name="vcpCode">VCP code (e.g., 0x14)</param>
|
||||
/// <param name="value">Value to translate</param>
|
||||
/// <param name="customMappings">Optional custom mappings that take priority</param>
|
||||
/// <param name="monitorId">Monitor ID to filter mappings</param>
|
||||
/// <returns>Formatted string like "sRGB (0x01)" or "0x01" if unknown</returns>
|
||||
public static string GetFormattedValueName(byte vcpCode, int value, IEnumerable<CustomVcpValueMapping>? customMappings, string monitorId)
|
||||
{
|
||||
var name = GetValueName(vcpCode, value, customMappings, monitorId);
|
||||
if (name != null)
|
||||
{
|
||||
return $"{name} (0x{value:X2})";
|
||||
}
|
||||
|
||||
return $"0x{value:X2}";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -125,14 +125,27 @@ namespace PowerDisplay
|
||||
|
||||
/// <summary>
|
||||
/// Called when an existing instance is activated by another process.
|
||||
/// This happens when EnsureProcessRunning() launches a new process while one is already running.
|
||||
/// We intentionally don't show the window here - window visibility should only be controlled via:
|
||||
/// - Toggle event (hotkey, tray icon click, Settings UI Launch button)
|
||||
/// - Standalone mode startup (handled in OnLaunched)
|
||||
/// This happens when Quick Access or other launchers start the process while one is already running.
|
||||
/// We toggle the window to show it - this allows Quick Access launch to work properly.
|
||||
/// </summary>
|
||||
private static void OnActivated(object? sender, AppActivationArguments args)
|
||||
{
|
||||
Logger.LogInfo("OnActivated: Redirect activation received - window visibility unchanged");
|
||||
Logger.LogInfo("OnActivated: Redirect activation received - toggling window");
|
||||
|
||||
// Toggle the main window on redirect activation
|
||||
if (_app?.MainWindow is MainWindow mainWindow)
|
||||
{
|
||||
// Dispatch to UI thread since OnActivated may be called from a different thread
|
||||
mainWindow.DispatcherQueue.TryEnqueue(() =>
|
||||
{
|
||||
Logger.LogTrace("OnActivated: Toggling window from redirect activation");
|
||||
mainWindow.ToggleWindow();
|
||||
});
|
||||
}
|
||||
else
|
||||
{
|
||||
Logger.LogWarning("OnActivated: MainWindow not available for toggle");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -43,6 +43,10 @@ public partial class MainViewModel
|
||||
// UpdateMonitorList already handles filtering hidden monitors
|
||||
UpdateMonitorList(_monitorManager.Monitors, isInitialLoad: false);
|
||||
|
||||
// Reload UI display settings first (includes custom VCP mappings)
|
||||
// Must be loaded before ApplyUIConfiguration so names are available for UI refresh
|
||||
LoadUIDisplaySettings();
|
||||
|
||||
// Apply UI configuration changes only (feature visibility toggles, etc.)
|
||||
// Hardware parameters (brightness, color temperature) are applied via custom actions
|
||||
var settings = _settingsUtils.GetSettingsOrDefault<PowerDisplaySettings>("PowerDisplay");
|
||||
@@ -51,8 +55,11 @@ public partial class MainViewModel
|
||||
// Reload profiles in case they were added/updated/deleted in Settings UI
|
||||
LoadProfiles();
|
||||
|
||||
// Reload UI display settings (profile switcher, identify button, color temp switcher)
|
||||
LoadUIDisplaySettings();
|
||||
// Notify MonitorViewModels to refresh their custom VCP name displays
|
||||
foreach (var monitor in Monitors)
|
||||
{
|
||||
monitor.RefreshCustomVcpNames();
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
@@ -304,7 +311,8 @@ public partial class MainViewModel
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Apply feature visibility settings to a monitor ViewModel
|
||||
/// Apply feature visibility settings to a monitor ViewModel.
|
||||
/// Only shows features that are both enabled by user AND supported by hardware.
|
||||
/// </summary>
|
||||
private void ApplyFeatureVisibility(MonitorViewModel monitorVm, PowerDisplaySettings settings)
|
||||
{
|
||||
@@ -313,12 +321,13 @@ public partial class MainViewModel
|
||||
|
||||
if (monitorSettings != null)
|
||||
{
|
||||
monitorVm.ShowContrast = monitorSettings.EnableContrast;
|
||||
monitorVm.ShowVolume = monitorSettings.EnableVolume;
|
||||
monitorVm.ShowInputSource = monitorSettings.EnableInputSource;
|
||||
// Only show features that are both enabled by user AND supported by hardware
|
||||
monitorVm.ShowContrast = monitorSettings.EnableContrast && monitorVm.SupportsContrast;
|
||||
monitorVm.ShowVolume = monitorSettings.EnableVolume && monitorVm.SupportsVolume;
|
||||
monitorVm.ShowInputSource = monitorSettings.EnableInputSource && monitorVm.SupportsInputSource;
|
||||
monitorVm.ShowRotation = monitorSettings.EnableRotation;
|
||||
monitorVm.ShowColorTemperature = monitorSettings.EnableColorTemperature;
|
||||
monitorVm.ShowPowerState = monitorSettings.EnablePowerState;
|
||||
monitorVm.ShowColorTemperature = monitorSettings.EnableColorTemperature && monitorVm.SupportsColorTemperature;
|
||||
monitorVm.ShowPowerState = monitorSettings.EnablePowerState && monitorVm.SupportsPowerState;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -163,6 +163,23 @@ public partial class MainViewModel : INotifyPropertyChanged, IDisposable
|
||||
}
|
||||
}
|
||||
|
||||
// Custom VCP mappings - loaded from settings
|
||||
private List<CustomVcpValueMapping> _customVcpMappings = new();
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the custom VCP value name mappings.
|
||||
/// These mappings override the default VCP value names for color temperature and input source.
|
||||
/// </summary>
|
||||
public List<CustomVcpValueMapping> CustomVcpMappings
|
||||
{
|
||||
get => _customVcpMappings;
|
||||
set
|
||||
{
|
||||
_customVcpMappings = value ?? new List<CustomVcpValueMapping>();
|
||||
OnPropertyChanged();
|
||||
}
|
||||
}
|
||||
|
||||
public bool IsScanning
|
||||
{
|
||||
get => _isScanning;
|
||||
@@ -389,6 +406,10 @@ public partial class MainViewModel : INotifyPropertyChanged, IDisposable
|
||||
var settings = _settingsUtils.GetSettingsOrDefault<PowerDisplaySettings>(PowerDisplaySettings.ModuleName);
|
||||
ShowProfileSwitcher = settings.Properties.ShowProfileSwitcher;
|
||||
ShowIdentifyMonitorsButton = settings.Properties.ShowIdentifyMonitorsButton;
|
||||
|
||||
// Load custom VCP mappings (now using shared type from PowerDisplay.Common.Models)
|
||||
CustomVcpMappings = settings.Properties.CustomVcpMappings?.ToList() ?? new List<CustomVcpValueMapping>();
|
||||
Logger.LogInfo($"[Settings] Loaded {CustomVcpMappings.Count} custom VCP mappings");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
|
||||
@@ -279,6 +279,16 @@ public partial class MonitorViewModel : INotifyPropertyChanged, IDisposable
|
||||
// Advanced control display logic
|
||||
public bool HasAdvancedControls => ShowContrast || ShowVolume;
|
||||
|
||||
/// <summary>
|
||||
/// Gets a value indicating whether this monitor supports contrast control via VCP 0x12
|
||||
/// </summary>
|
||||
public bool SupportsContrast => _monitor.SupportsContrast;
|
||||
|
||||
/// <summary>
|
||||
/// Gets a value indicating whether this monitor supports volume control via VCP 0x62
|
||||
/// </summary>
|
||||
public bool SupportsVolume => _monitor.SupportsVolume;
|
||||
|
||||
public bool ShowContrast
|
||||
{
|
||||
get => _showContrast;
|
||||
@@ -456,8 +466,10 @@ public partial class MonitorViewModel : INotifyPropertyChanged, IDisposable
|
||||
|
||||
/// <summary>
|
||||
/// Gets human-readable color temperature preset name (e.g., "6500K", "sRGB")
|
||||
/// Uses custom mappings if available; falls back to built-in names if not.
|
||||
/// </summary>
|
||||
public string ColorTemperaturePresetName => _monitor.ColorTemperaturePresetName;
|
||||
public string ColorTemperaturePresetName =>
|
||||
Common.Utils.VcpNames.GetFormattedValueName(0x14, _monitor.CurrentColorTemperature, _mainViewModel?.CustomVcpMappings, _monitor.Id);
|
||||
|
||||
/// <summary>
|
||||
/// Gets a value indicating whether this monitor supports color temperature via VCP 0x14
|
||||
@@ -537,7 +549,7 @@ public partial class MonitorViewModel : INotifyPropertyChanged, IDisposable
|
||||
_availableColorPresets = presetValues.Select(value => new ColorTemperatureItem
|
||||
{
|
||||
VcpValue = value,
|
||||
DisplayName = Common.Utils.VcpNames.GetFormattedValueName(0x14, value),
|
||||
DisplayName = Common.Utils.VcpNames.GetFormattedValueName(0x14, value, _mainViewModel?.CustomVcpMappings, _monitor.Id),
|
||||
IsSelected = value == _monitor.CurrentColorTemperature,
|
||||
MonitorId = _monitor.Id,
|
||||
}).ToList();
|
||||
@@ -557,8 +569,11 @@ public partial class MonitorViewModel : INotifyPropertyChanged, IDisposable
|
||||
|
||||
/// <summary>
|
||||
/// Gets human-readable current input source name (e.g., "HDMI-1", "DisplayPort-1")
|
||||
/// Uses custom mappings if available; falls back to built-in names if not.
|
||||
/// </summary>
|
||||
public string CurrentInputSourceName => _monitor.InputSourceName;
|
||||
public string CurrentInputSourceName =>
|
||||
Common.Utils.VcpNames.GetValueName(0x60, _monitor.CurrentInputSource, _mainViewModel?.CustomVcpMappings, _monitor.Id)
|
||||
?? $"Source 0x{_monitor.CurrentInputSource:X2}";
|
||||
|
||||
private List<InputSourceItem>? _availableInputSources;
|
||||
|
||||
@@ -593,7 +608,7 @@ public partial class MonitorViewModel : INotifyPropertyChanged, IDisposable
|
||||
_availableInputSources = supportedSources.Select(value => new InputSourceItem
|
||||
{
|
||||
Value = value,
|
||||
Name = Common.Utils.VcpNames.GetValueName(0x60, value) ?? $"Source 0x{value:X2}",
|
||||
Name = Common.Utils.VcpNames.GetValueName(0x60, value, _mainViewModel?.CustomVcpMappings, _monitor.Id) ?? $"Source 0x{value:X2}",
|
||||
SelectionVisibility = value == _monitor.CurrentInputSource ? Visibility.Visible : Visibility.Collapsed,
|
||||
MonitorId = _monitor.Id,
|
||||
}).ToList();
|
||||
@@ -601,6 +616,23 @@ public partial class MonitorViewModel : INotifyPropertyChanged, IDisposable
|
||||
OnPropertyChanged(nameof(AvailableInputSources));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Refresh custom VCP name displays after settings change.
|
||||
/// Called when CustomVcpMappings is updated from Settings UI.
|
||||
/// </summary>
|
||||
public void RefreshCustomVcpNames()
|
||||
{
|
||||
// Refresh color temperature names
|
||||
OnPropertyChanged(nameof(ColorTemperaturePresetName));
|
||||
_availableColorPresets = null; // Force rebuild with new custom names
|
||||
OnPropertyChanged(nameof(AvailableColorPresets));
|
||||
|
||||
// Refresh input source names
|
||||
OnPropertyChanged(nameof(CurrentInputSourceName));
|
||||
_availableInputSources = null; // Force rebuild with new custom names
|
||||
OnPropertyChanged(nameof(AvailableInputSources));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Set input source for this monitor
|
||||
/// </summary>
|
||||
|
||||
@@ -52,8 +52,11 @@ void PowerDisplayProcessManager::send_message(const std::wstring& message_type,
|
||||
{
|
||||
submit_task([this, message_type, message_arg] {
|
||||
// Ensure process is running before sending message
|
||||
if (!is_process_running() && m_enabled)
|
||||
// If process is not running, enable and start it - this allows Quick Access launch
|
||||
// to work even when the module was not previously enabled
|
||||
if (!is_process_running())
|
||||
{
|
||||
m_enabled = true;
|
||||
refresh();
|
||||
}
|
||||
send_named_pipe_message(message_type, message_arg);
|
||||
|
||||
@@ -9,6 +9,8 @@
|
||||
#include <common/utils/winapi_error.h>
|
||||
#include <common/utils/logger_helper.h>
|
||||
#include <common/utils/resources.h>
|
||||
#include <thread>
|
||||
#include <atomic>
|
||||
|
||||
#include "resource.h"
|
||||
|
||||
@@ -48,6 +50,11 @@ private:
|
||||
HANDLE m_hRefreshEvent = nullptr;
|
||||
HANDLE m_hSendSettingsTelemetryEvent = nullptr;
|
||||
|
||||
// Toggle event handle and listener thread for Quick Access support
|
||||
HANDLE m_hToggleEvent = nullptr;
|
||||
HANDLE m_hStopEvent = nullptr; // Manual-reset event to signal thread termination
|
||||
std::thread m_toggleEventThread;
|
||||
|
||||
public:
|
||||
PowerDisplayModule()
|
||||
{
|
||||
@@ -62,16 +69,29 @@ public:
|
||||
m_hSendSettingsTelemetryEvent = CreateDefaultEvent(CommonSharedConstants::POWER_DISPLAY_SEND_SETTINGS_TELEMETRY_EVENT);
|
||||
Logger::trace(L"Created SEND_SETTINGS_TELEMETRY_EVENT: handle={}", reinterpret_cast<void*>(m_hSendSettingsTelemetryEvent));
|
||||
|
||||
if (!m_hRefreshEvent || !m_hSendSettingsTelemetryEvent)
|
||||
// Create Toggle event for Quick Access support
|
||||
// This allows Quick Access to launch PowerDisplay even when module is not enabled
|
||||
m_hToggleEvent = CreateDefaultEvent(CommonSharedConstants::TOGGLE_POWER_DISPLAY_EVENT);
|
||||
Logger::trace(L"Created TOGGLE_EVENT: handle={}", reinterpret_cast<void*>(m_hToggleEvent));
|
||||
|
||||
// Create manual-reset stop event for clean thread termination
|
||||
m_hStopEvent = CreateEventW(nullptr, TRUE, FALSE, nullptr);
|
||||
Logger::trace(L"Created STOP_EVENT: handle={}", reinterpret_cast<void*>(m_hStopEvent));
|
||||
|
||||
if (!m_hRefreshEvent || !m_hSendSettingsTelemetryEvent || !m_hToggleEvent || !m_hStopEvent)
|
||||
{
|
||||
Logger::error(L"Failed to create one or more event handles: Refresh={}, SettingsTelemetry={}",
|
||||
Logger::error(L"Failed to create one or more event handles: Refresh={}, SettingsTelemetry={}, Toggle={}",
|
||||
reinterpret_cast<void*>(m_hRefreshEvent),
|
||||
reinterpret_cast<void*>(m_hSendSettingsTelemetryEvent));
|
||||
reinterpret_cast<void*>(m_hSendSettingsTelemetryEvent),
|
||||
reinterpret_cast<void*>(m_hToggleEvent));
|
||||
}
|
||||
else
|
||||
{
|
||||
Logger::info(L"All Windows Events created successfully");
|
||||
}
|
||||
|
||||
// Start toggle event listener thread for Quick Access support
|
||||
StartToggleEventListener();
|
||||
}
|
||||
|
||||
~PowerDisplayModule()
|
||||
@@ -81,6 +101,9 @@ public:
|
||||
disable();
|
||||
}
|
||||
|
||||
// Stop toggle event listener thread
|
||||
StopToggleEventListener();
|
||||
|
||||
// Clean up event handles
|
||||
if (m_hRefreshEvent)
|
||||
{
|
||||
@@ -92,6 +115,99 @@ public:
|
||||
CloseHandle(m_hSendSettingsTelemetryEvent);
|
||||
m_hSendSettingsTelemetryEvent = nullptr;
|
||||
}
|
||||
if (m_hToggleEvent)
|
||||
{
|
||||
CloseHandle(m_hToggleEvent);
|
||||
m_hToggleEvent = nullptr;
|
||||
}
|
||||
if (m_hStopEvent)
|
||||
{
|
||||
CloseHandle(m_hStopEvent);
|
||||
m_hStopEvent = nullptr;
|
||||
}
|
||||
}
|
||||
|
||||
void StartToggleEventListener()
|
||||
{
|
||||
if (!m_hToggleEvent || !m_hStopEvent)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
// Reset stop event before starting thread
|
||||
ResetEvent(m_hStopEvent);
|
||||
|
||||
m_toggleEventThread = std::thread([this]() {
|
||||
Logger::info(L"Toggle event listener thread started");
|
||||
|
||||
HANDLE handles[] = { m_hToggleEvent, m_hStopEvent };
|
||||
constexpr DWORD TOGGLE_EVENT_INDEX = 0;
|
||||
constexpr DWORD STOP_EVENT_INDEX = 1;
|
||||
|
||||
while (true)
|
||||
{
|
||||
// Wait indefinitely for either toggle event or stop event
|
||||
DWORD result = WaitForMultipleObjects(2, handles, FALSE, INFINITE);
|
||||
|
||||
if (result == WAIT_OBJECT_0 + TOGGLE_EVENT_INDEX)
|
||||
{
|
||||
Logger::trace(L"Toggle event received");
|
||||
TogglePowerDisplay();
|
||||
}
|
||||
else if (result == WAIT_OBJECT_0 + STOP_EVENT_INDEX)
|
||||
{
|
||||
// Stop event signaled - exit the loop
|
||||
Logger::trace(L"Stop event received, exiting toggle listener");
|
||||
break;
|
||||
}
|
||||
else
|
||||
{
|
||||
// WAIT_FAILED or unexpected result
|
||||
Logger::warn(L"WaitForMultipleObjects returned unexpected result: {}", result);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
Logger::info(L"Toggle event listener thread stopped");
|
||||
});
|
||||
}
|
||||
|
||||
void StopToggleEventListener()
|
||||
{
|
||||
if (m_hStopEvent)
|
||||
{
|
||||
// Signal the stop event to wake up the waiting thread
|
||||
SetEvent(m_hStopEvent);
|
||||
}
|
||||
|
||||
if (m_toggleEventThread.joinable())
|
||||
{
|
||||
m_toggleEventThread.join();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Toggle PowerDisplay window visibility.
|
||||
/// If process is running, launches again to trigger redirect activation (OnActivated handles toggle).
|
||||
/// If process is not running, starts it via Named Pipe and sends toggle message.
|
||||
/// </summary>
|
||||
void TogglePowerDisplay()
|
||||
{
|
||||
if (m_processManager.is_running())
|
||||
{
|
||||
// Process running - launch to trigger single instance redirect, OnActivated will toggle
|
||||
SHELLEXECUTEINFOW sei{ sizeof(sei) };
|
||||
sei.fMask = SEE_MASK_FLAG_NO_UI;
|
||||
sei.lpFile = L"WinUI3Apps\\PowerToys.PowerDisplay.exe";
|
||||
sei.nShow = SW_SHOWNORMAL;
|
||||
ShellExecuteExW(&sei);
|
||||
}
|
||||
else
|
||||
{
|
||||
// Process not running - start and send toggle via Named Pipe
|
||||
m_processManager.send_message(CommonSharedConstants::POWER_DISPLAY_TOGGLE_MESSAGE);
|
||||
}
|
||||
Trace::ActivatePowerDisplay();
|
||||
}
|
||||
|
||||
virtual void destroy() override
|
||||
@@ -135,10 +251,7 @@ public:
|
||||
if (action_object.get_name() == L"Launch")
|
||||
{
|
||||
Logger::trace(L"Launch action received");
|
||||
|
||||
// Send Toggle message via Named Pipe (will start process if needed)
|
||||
m_processManager.send_message(CommonSharedConstants::POWER_DISPLAY_TOGGLE_MESSAGE);
|
||||
Trace::ActivatePowerDisplay();
|
||||
TogglePowerDisplay();
|
||||
}
|
||||
else if (action_object.get_name() == L"RefreshMonitors")
|
||||
{
|
||||
|
||||
@@ -7,6 +7,8 @@
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<UseWindowsForms>true</UseWindowsForms>
|
||||
<UseWindowsForms>true</UseWindowsForms>
|
||||
<!-- Force classic WebView2 managed references (Core/WinForms/Wpf) instead of CsWinRT projection. -->
|
||||
<WebView2EnableCsWinRTProjection>false</WebView2EnableCsWinRTProjection>
|
||||
<AssemblyTitle>PowerToys.SvgPreviewHandler</AssemblyTitle>
|
||||
<AssemblyDescription>PowerToys SvgPreviewHandler</AssemblyDescription>
|
||||
<Description>PowerToys SvgPreviewHandler</Description>
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
#include <Sddl.h>
|
||||
#include <sstream>
|
||||
#include <aclapi.h>
|
||||
#include <shobjidl.h>
|
||||
|
||||
#include "powertoy_module.h"
|
||||
#include <common/interop/two_way_pipe_message_ipc.h>
|
||||
@@ -64,6 +65,74 @@ json::JsonObject get_all_settings()
|
||||
return result;
|
||||
}
|
||||
|
||||
namespace
|
||||
{
|
||||
constexpr wchar_t SettingsApplicationId[] = L"PowerToys.SettingsUI";
|
||||
constexpr wchar_t SparseAppFamilyDev[] = L"Microsoft.PowerToys.SparseApp_djwsxzxb4ksa8";
|
||||
constexpr wchar_t SparseAppFamilyStore[] = L"Microsoft.PowerToys.SparseApp_8wekyb3d8bbwe";
|
||||
|
||||
bool try_activate_settings_with_identity(const std::wstring& arguments, PROCESS_INFORMATION& process_info)
|
||||
{
|
||||
HRESULT hr = CoInitializeEx(nullptr, COINIT_APARTMENTTHREADED);
|
||||
const bool should_uninit = SUCCEEDED(hr);
|
||||
if (hr == RPC_E_CHANGED_MODE)
|
||||
{
|
||||
hr = S_OK;
|
||||
}
|
||||
|
||||
if (FAILED(hr))
|
||||
{
|
||||
Logger::warn(L"Settings: CoInitializeEx failed. hr=0x{:x}", hr);
|
||||
return false;
|
||||
}
|
||||
|
||||
IApplicationActivationManager* activation_manager = nullptr;
|
||||
hr = CoCreateInstance(CLSID_ApplicationActivationManager,
|
||||
nullptr,
|
||||
CLSCTX_INPROC_SERVER,
|
||||
IID_PPV_ARGS(&activation_manager));
|
||||
if (FAILED(hr))
|
||||
{
|
||||
Logger::warn(L"Settings: CoCreateInstance(ApplicationActivationManager) failed. hr=0x{:x}", hr);
|
||||
if (should_uninit)
|
||||
{
|
||||
CoUninitialize();
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
auto try_activate = [&](const wchar_t* family) -> bool {
|
||||
std::wstring aumid = std::wstring(family) + L"!" + SettingsApplicationId;
|
||||
DWORD pid = 0;
|
||||
HRESULT hr_activate = activation_manager->ActivateApplication(aumid.c_str(),
|
||||
arguments.c_str(),
|
||||
AO_NONE,
|
||||
&pid);
|
||||
if (SUCCEEDED(hr_activate) && pid != 0)
|
||||
{
|
||||
process_info = {};
|
||||
process_info.dwProcessId = pid;
|
||||
process_info.hProcess = OpenProcess(SYNCHRONIZE | PROCESS_QUERY_LIMITED_INFORMATION, FALSE, pid);
|
||||
Logger::info(L"Settings: Activated via AUMID {} (pid {}).", aumid, pid);
|
||||
return true;
|
||||
}
|
||||
|
||||
Logger::warn(L"Settings: ActivateApplication failed for {}. hr=0x{:x}", aumid, hr_activate);
|
||||
return false;
|
||||
};
|
||||
|
||||
const bool activated = try_activate(SparseAppFamilyDev) || try_activate(SparseAppFamilyStore);
|
||||
|
||||
activation_manager->Release();
|
||||
if (should_uninit)
|
||||
{
|
||||
CoUninitialize();
|
||||
}
|
||||
|
||||
return activated;
|
||||
}
|
||||
}
|
||||
|
||||
std::optional<std::wstring> dispatch_json_action_to_module(const json::JsonObject& powertoys_configs)
|
||||
{
|
||||
std::optional<std::wstring> result;
|
||||
@@ -522,15 +591,34 @@ void run_settings_window(bool show_oobe_window, bool show_scoobe_window, std::op
|
||||
settings_showOobe,
|
||||
settings_showScoobe,
|
||||
settings_containsSettingsWindow);
|
||||
std::wstring activation_args = fmt::format(L"{} {} {} {} {} {} {} {} {}",
|
||||
powertoys_pipe_name,
|
||||
settings_pipe_name,
|
||||
std::to_wstring(powertoys_pid),
|
||||
settings_theme,
|
||||
settings_elevatedStatus,
|
||||
settings_isUserAnAdmin,
|
||||
settings_showOobe,
|
||||
settings_showScoobe,
|
||||
settings_containsSettingsWindow);
|
||||
|
||||
if (settings_window.has_value())
|
||||
{
|
||||
executable_args.append(L" ");
|
||||
executable_args.append(settings_window.value());
|
||||
activation_args.append(L" ");
|
||||
activation_args.append(settings_window.value());
|
||||
}
|
||||
|
||||
BOOL process_created = false;
|
||||
|
||||
// Prefer activating via package identity so the package graph (framework deps) is applied.
|
||||
if (try_activate_settings_with_identity(activation_args, process_info))
|
||||
{
|
||||
process_created = true;
|
||||
g_isLaunchInProgress = false;
|
||||
}
|
||||
|
||||
// Commented out to fix #22659
|
||||
// Running settings non-elevated and modules elevated when PowerToys is running elevated results
|
||||
// in settings making changes in one file (non-elevated user dir) and modules are reading settings
|
||||
|
||||
@@ -119,6 +119,13 @@ namespace Microsoft.PowerToys.Settings.UI.Controls
|
||||
eventHandle.Set();
|
||||
}
|
||||
|
||||
return true;
|
||||
case ModuleType.PowerDisplay:
|
||||
using (var eventHandle = new EventWaitHandle(false, EventResetMode.AutoReset, Constants.TogglePowerDisplayEvent()))
|
||||
{
|
||||
eventHandle.Set();
|
||||
}
|
||||
|
||||
return true;
|
||||
default:
|
||||
return false;
|
||||
|
||||
@@ -10,6 +10,10 @@ namespace Microsoft.PowerToys.Settings.UI.Library;
|
||||
|
||||
public sealed class AdvancedPasteAdditionalActions
|
||||
{
|
||||
private AdvancedPasteAdditionalAction _imageToText = new();
|
||||
private AdvancedPastePasteAsFileAction _pasteAsFile = new();
|
||||
private AdvancedPasteTranscodeAction _transcode = new();
|
||||
|
||||
public static class PropertyNames
|
||||
{
|
||||
public const string ImageToText = "image-to-text";
|
||||
@@ -18,13 +22,25 @@ public sealed class AdvancedPasteAdditionalActions
|
||||
}
|
||||
|
||||
[JsonPropertyName(PropertyNames.ImageToText)]
|
||||
public AdvancedPasteAdditionalAction ImageToText { get; init; } = new();
|
||||
public AdvancedPasteAdditionalAction ImageToText
|
||||
{
|
||||
get => _imageToText;
|
||||
init => _imageToText = value ?? new();
|
||||
}
|
||||
|
||||
[JsonPropertyName(PropertyNames.PasteAsFile)]
|
||||
public AdvancedPastePasteAsFileAction PasteAsFile { get; init; } = new();
|
||||
public AdvancedPastePasteAsFileAction PasteAsFile
|
||||
{
|
||||
get => _pasteAsFile;
|
||||
init => _pasteAsFile = value ?? new();
|
||||
}
|
||||
|
||||
[JsonPropertyName(PropertyNames.Transcode)]
|
||||
public AdvancedPasteTranscodeAction Transcode { get; init; } = new();
|
||||
public AdvancedPasteTranscodeAction Transcode
|
||||
{
|
||||
get => _transcode;
|
||||
init => _transcode = value ?? new();
|
||||
}
|
||||
|
||||
public IEnumerable<IAdvancedPasteAction> GetAllActions()
|
||||
{
|
||||
|
||||
@@ -25,12 +25,16 @@ namespace Microsoft.PowerToys.Settings.UI.Library
|
||||
[JsonPropertyName("wrap_mode")]
|
||||
public IntProperty WrapMode { get; set; }
|
||||
|
||||
[JsonPropertyName("disable_cursor_wrap_on_single_monitor")]
|
||||
public BoolProperty DisableCursorWrapOnSingleMonitor { get; set; }
|
||||
|
||||
public CursorWrapProperties()
|
||||
{
|
||||
ActivationShortcut = DefaultActivationShortcut;
|
||||
AutoActivate = new BoolProperty(false);
|
||||
DisableWrapDuringDrag = new BoolProperty(true);
|
||||
WrapMode = new IntProperty(0); // 0=Both (default), 1=VerticalOnly, 2=HorizontalOnly
|
||||
DisableCursorWrapOnSingleMonitor = new BoolProperty(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -56,6 +56,13 @@ namespace Microsoft.PowerToys.Settings.UI.Library
|
||||
settingsUpgraded = true;
|
||||
}
|
||||
|
||||
// Add DisableCursorWrapOnSingleMonitor property if it doesn't exist (for users upgrading from older versions)
|
||||
if (Properties.DisableCursorWrapOnSingleMonitor == null)
|
||||
{
|
||||
Properties.DisableCursorWrapOnSingleMonitor = new BoolProperty(false); // Default to false
|
||||
settingsUpgraded = true;
|
||||
}
|
||||
|
||||
return settingsUpgraded;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -23,6 +23,7 @@ namespace Microsoft.PowerToys.Settings.UI.Library
|
||||
ShowSystemTrayIcon = true;
|
||||
ShowProfileSwitcher = true;
|
||||
ShowIdentifyMonitorsButton = true;
|
||||
CustomVcpMappings = new List<CustomVcpValueMapping>();
|
||||
|
||||
// Note: saved_monitor_settings has been moved to monitor_state.json
|
||||
// which is managed separately by PowerDisplay app
|
||||
@@ -61,5 +62,12 @@ namespace Microsoft.PowerToys.Settings.UI.Library
|
||||
/// </summary>
|
||||
[JsonPropertyName("show_identify_monitors_button")]
|
||||
public bool ShowIdentifyMonitorsButton { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets custom VCP value name mappings shared across all monitors.
|
||||
/// Allows users to define custom names for color temperature presets and input sources.
|
||||
/// </summary>
|
||||
[JsonPropertyName("custom_vcp_mappings")]
|
||||
public List<CustomVcpValueMapping> CustomVcpMappings { get; set; }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
#nullable enable
|
||||
|
||||
using Common.Search;
|
||||
|
||||
namespace Settings.UI.Library
|
||||
{
|
||||
public enum EntryType
|
||||
@@ -11,7 +15,7 @@ namespace Settings.UI.Library
|
||||
SettingsExpander,
|
||||
}
|
||||
|
||||
public struct SettingEntry
|
||||
public struct SettingEntry : ISearchable
|
||||
{
|
||||
public EntryType Type { get; set; }
|
||||
|
||||
@@ -29,16 +33,23 @@ namespace Settings.UI.Library
|
||||
|
||||
public string Icon { get; set; }
|
||||
|
||||
public SettingEntry(EntryType type, string header, string pageTypeName, string elementName, string elementUid, string parentElementName = null, string description = null, string icon = null)
|
||||
public SettingEntry(EntryType type, string header, string pageTypeName, string elementName, string elementUid, string? parentElementName = null, string? description = null, string? icon = null)
|
||||
{
|
||||
Type = type;
|
||||
Header = header;
|
||||
PageTypeName = pageTypeName;
|
||||
ElementName = elementName;
|
||||
ElementUid = elementUid;
|
||||
ParentElementName = parentElementName;
|
||||
Description = description;
|
||||
Icon = icon;
|
||||
ParentElementName = parentElementName ?? string.Empty;
|
||||
Description = description ?? string.Empty;
|
||||
Icon = icon ?? string.Empty;
|
||||
}
|
||||
|
||||
// ISearchable implementation
|
||||
public readonly string Id => ElementUid ?? $"{PageTypeName}|{ElementName}";
|
||||
|
||||
public readonly string SearchableText => Header ?? string.Empty;
|
||||
|
||||
public readonly string? SecondarySearchableText => Description;
|
||||
}
|
||||
}
|
||||
|
||||
25
src/settings-ui/Settings.UI.Library/SettingSearchResult.cs
Normal file
25
src/settings-ui/Settings.UI.Library/SettingSearchResult.cs
Normal file
@@ -0,0 +1,25 @@
|
||||
// Copyright (c) Microsoft Corporation
|
||||
// The Microsoft Corporation licenses this file to you under the MIT license.
|
||||
// See the LICENSE file in the project root for more information.
|
||||
|
||||
#nullable enable
|
||||
|
||||
using System.Collections.Generic;
|
||||
using Common.Search;
|
||||
|
||||
namespace Settings.UI.Library
|
||||
{
|
||||
/// <summary>
|
||||
/// Represents a search result for a settings entry with scoring metadata.
|
||||
/// </summary>
|
||||
public sealed class SettingSearchResult
|
||||
{
|
||||
public required SettingEntry Entry { get; init; }
|
||||
|
||||
public required double Score { get; init; }
|
||||
|
||||
public required SearchMatchKind MatchKind { get; init; }
|
||||
|
||||
public IReadOnlyList<MatchSpan>? MatchSpans { get; init; }
|
||||
}
|
||||
}
|
||||
@@ -23,6 +23,7 @@
|
||||
<ProjectReference Include="..\..\common\ManagedCommon\ManagedCommon.csproj" />
|
||||
<ProjectReference Include="..\..\common\ManagedTelemetry\Telemetry\ManagedTelemetry.csproj" />
|
||||
<ProjectReference Include="..\..\modules\MouseUtils\MouseJump.Common\MouseJump.Common.csproj" />
|
||||
<ProjectReference Include="..\..\common\Common.Search\Common.Search.csproj" />
|
||||
<ProjectReference Include="..\..\modules\powerdisplay\PowerDisplay.Lib\PowerDisplay.Lib.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
// 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.Collections.Generic;
|
||||
using System.Threading.Tasks;
|
||||
using Common.Search.FuzzSearch;
|
||||
using Microsoft.PowerToys.Settings.UI.Services;
|
||||
using Microsoft.VisualStudio.TestTools.UnitTesting;
|
||||
using Settings.UI.Library;
|
||||
|
||||
namespace Microsoft.PowerToys.Settings.UI.UnitTests.Services
|
||||
{
|
||||
[TestClass]
|
||||
[DoNotParallelize]
|
||||
public class SettingsSearchTests
|
||||
{
|
||||
[TestMethod]
|
||||
public void LoadIndexFromJson_ReturnsEntries()
|
||||
{
|
||||
const string json = @"
|
||||
[
|
||||
{
|
||||
""type"": 0,
|
||||
""header"": ""General"",
|
||||
""pageTypeName"": ""GeneralPage"",
|
||||
""elementName"": """",
|
||||
""elementUid"": ""General_Page"",
|
||||
""parentElementName"": """",
|
||||
""description"": ""General settings"",
|
||||
""icon"": """"
|
||||
},
|
||||
{
|
||||
""type"": 1,
|
||||
""header"": ""Mouse Utilities"",
|
||||
""pageTypeName"": ""MouseUtilsPage"",
|
||||
""elementName"": ""MouseUtilsSetting"",
|
||||
""elementUid"": ""MouseUtils_Setting"",
|
||||
""parentElementName"": """",
|
||||
""description"": ""Adjust mouse settings"",
|
||||
""icon"": """"
|
||||
}
|
||||
]
|
||||
";
|
||||
|
||||
var entries = SettingsSearch.LoadIndexFromJson(json);
|
||||
|
||||
Assert.AreEqual(2, entries.Count);
|
||||
Assert.AreEqual(EntryType.SettingsPage, entries[0].Type);
|
||||
Assert.AreEqual("GeneralPage", entries[0].PageTypeName);
|
||||
Assert.AreEqual("MouseUtilsPage", entries[1].PageTypeName);
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public async Task InitializeIndexAsync_InitializesSearchAndReturnsResults()
|
||||
{
|
||||
var entries = new List<SettingEntry>
|
||||
{
|
||||
new(EntryType.SettingsPage, "General", "GeneralPage", string.Empty, "General_Page"),
|
||||
new(EntryType.SettingsCard, "Mouse Utilities", "MouseUtilsPage", "MouseUtilsSetting", "MouseUtils_Setting", description: "Adjust mouse settings"),
|
||||
};
|
||||
|
||||
using var search = new SettingsSearch(new FuzzSearchEngine<SettingEntry>());
|
||||
await search.InitializeIndexAsync(entries);
|
||||
|
||||
Assert.IsTrue(search.IsReady);
|
||||
|
||||
var results = await search.SearchAsync("mouse", options: null);
|
||||
|
||||
Assert.IsTrue(results.Count > 0);
|
||||
Assert.AreEqual("Mouse Utilities", results[0].Entry.Header);
|
||||
Assert.IsTrue(results[0].Score > 0);
|
||||
Assert.IsNotNull(results[0].MatchSpans);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,10 @@
|
||||
<Import Project="..\..\Common.Dotnet.CsWinRT.props" />
|
||||
<Import Project="..\..\Common.SelfContained.props" />
|
||||
|
||||
<PropertyGroup Condition="'$(UseSparseIdentity)'==''">
|
||||
<UseSparseIdentity>true</UseSparseIdentity>
|
||||
</PropertyGroup>
|
||||
|
||||
<PropertyGroup>
|
||||
<OutputType>WinExe</OutputType>
|
||||
<RootNamespace>Microsoft.PowerToys.Settings.UI</RootNamespace>
|
||||
@@ -14,11 +18,15 @@
|
||||
<AppendRuntimeIdentifierToOutputPath>false</AppendRuntimeIdentifierToOutputPath>
|
||||
<ApplicationIcon>Assets\Settings\icon.ico</ApplicationIcon>
|
||||
<WindowsAppSDKSelfContained>true</WindowsAppSDKSelfContained>
|
||||
<!-- OutputPath looks like this because it has to be called both by settings and publish.cmd -->
|
||||
<OutputPath>..\..\..\$(Platform)\$(Configuration)\WinUI3Apps</OutputPath>
|
||||
<!-- MRT from windows app sdk will search for a pri file with the same name of the module before defaulting to resources.pri -->
|
||||
<ProjectPriFileName>PowerToys.Settings.pri</ProjectPriFileName>
|
||||
</PropertyGroup>
|
||||
<!-- Framework-dependent for sparse identity + no-op bootstrap when identity exists -->
|
||||
<PropertyGroup Condition="'$(UseSparseIdentity)'=='true'">
|
||||
<WindowsAppSDKSelfContained>false</WindowsAppSDKSelfContained>
|
||||
<WindowsAppSDKBootstrapAutoInitializeOptions_OnPackageIdentity_NoOp>true</WindowsAppSDKBootstrapAutoInitializeOptions_OnPackageIdentity_NoOp>
|
||||
</PropertyGroup>
|
||||
<ItemGroup>
|
||||
<None Remove="Assets\Settings\Icons\Models\Azure.svg" />
|
||||
<None Remove="Assets\Settings\Icons\Models\FoundryLocal.svg" />
|
||||
@@ -219,4 +227,23 @@
|
||||
<Message Importance="high" Text="[Settings] Building XamlIndexBuilder prior to compile. Views='$(MSBuildProjectDirectory)\SettingsXAML\Views' Out='$(GeneratedJsonFile)'" />
|
||||
<MSBuild Projects="..\Settings.UI.XamlIndexBuilder\Settings.UI.XamlIndexBuilder.csproj" Targets="Build" Properties="Configuration=$(Configuration);Platform=Any CPU;TargetFramework=net9.0;XamlViewsDir=$(MSBuildProjectDirectory)\SettingsXAML\Views;GeneratedJsonFile=$(GeneratedJsonFile)" />
|
||||
</Target>
|
||||
</Project>
|
||||
|
||||
<!--
|
||||
When Settings is activated via sparse package identity, package resource lookup can resolve from
|
||||
the external location root (..\$(Platform)\$(Configuration)). Mirror Settings assets there so
|
||||
ms-appx:///Assets/Settings/... resolves consistently in both identity and non-identity launches.
|
||||
-->
|
||||
<Target Name="MirrorSettingsAssetsToSparseRoot" AfterTargets="Build" Condition="'$(DesignTimeBuild)' != 'true' and '$(UseSparseIdentity)'=='true'">
|
||||
<PropertyGroup>
|
||||
<_SparseRootDir>$([System.IO.Path]::GetFullPath('$(OutputPath)..\'))</_SparseRootDir>
|
||||
<_SparseSettingsAssetsDir>$(_SparseRootDir)Assets\Settings\</_SparseSettingsAssetsDir>
|
||||
</PropertyGroup>
|
||||
<ItemGroup>
|
||||
<_SettingsAssetsFiles Include="$(MSBuildProjectDirectory)\Assets\Settings\**\*" />
|
||||
</ItemGroup>
|
||||
<Copy
|
||||
SourceFiles="@(_SettingsAssetsFiles)"
|
||||
DestinationFiles="@(_SettingsAssetsFiles->'$(_SparseSettingsAssetsDir)%(RecursiveDir)%(Filename)%(Extension)')"
|
||||
SkipUnchangedFiles="true" />
|
||||
</Target>
|
||||
</Project>
|
||||
|
||||
@@ -1,338 +0,0 @@
|
||||
// Copyright (c) Microsoft Corporation
|
||||
// The Microsoft Corporation licenses this file to you under the MIT license.
|
||||
// See the LICENSE file in the project root for more information.
|
||||
|
||||
using System;
|
||||
using System.Collections.Concurrent;
|
||||
using System.Collections.Generic;
|
||||
using System.Collections.Immutable;
|
||||
using System.Diagnostics;
|
||||
using System.Globalization;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Reflection;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Common.Search.FuzzSearch;
|
||||
using Microsoft.PowerToys.Settings.UI.Helpers;
|
||||
using Microsoft.PowerToys.Settings.UI.Views;
|
||||
using Microsoft.Windows.ApplicationModel.Resources;
|
||||
using Settings.UI.Library;
|
||||
|
||||
namespace Microsoft.PowerToys.Settings.UI.Services
|
||||
{
|
||||
public static class SearchIndexService
|
||||
{
|
||||
private static readonly object _lockObject = new();
|
||||
private static readonly Dictionary<string, string> _pageNameCache = [];
|
||||
private static readonly Dictionary<string, (string HeaderNorm, string DescNorm)> _normalizedTextCache = new();
|
||||
private static readonly Dictionary<string, Type> _pageTypeCache = new();
|
||||
private static ImmutableArray<SettingEntry> _index = [];
|
||||
private static bool _isIndexBuilt;
|
||||
private static bool _isIndexBuilding;
|
||||
private const string PrebuiltIndexResourceName = "Microsoft.PowerToys.Settings.UI.Assets.search.index.json";
|
||||
private static JsonSerializerOptions _serializerOptions = new() { PropertyNameCaseInsensitive = true };
|
||||
|
||||
public static ImmutableArray<SettingEntry> Index
|
||||
{
|
||||
get
|
||||
{
|
||||
lock (_lockObject)
|
||||
{
|
||||
return _index;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public static bool IsIndexReady
|
||||
{
|
||||
get
|
||||
{
|
||||
lock (_lockObject)
|
||||
{
|
||||
return _isIndexBuilt;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public static void BuildIndex()
|
||||
{
|
||||
lock (_lockObject)
|
||||
{
|
||||
if (_isIndexBuilt || _isIndexBuilding)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_isIndexBuilding = true;
|
||||
|
||||
// Clear caches on rebuild
|
||||
_normalizedTextCache.Clear();
|
||||
_pageTypeCache.Clear();
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var builder = ImmutableArray.CreateBuilder<SettingEntry>();
|
||||
LoadIndexFromPrebuiltData(builder);
|
||||
|
||||
lock (_lockObject)
|
||||
{
|
||||
_index = builder.ToImmutable();
|
||||
_isIndexBuilt = true;
|
||||
_isIndexBuilding = false;
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Debug.WriteLine($"[SearchIndexService] CRITICAL ERROR building search index: {ex.Message}\n{ex.StackTrace}");
|
||||
lock (_lockObject)
|
||||
{
|
||||
_isIndexBuilding = false;
|
||||
_isIndexBuilt = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static void LoadIndexFromPrebuiltData(ImmutableArray<SettingEntry>.Builder builder)
|
||||
{
|
||||
var assembly = Assembly.GetExecutingAssembly();
|
||||
var resourceLoader = ResourceLoaderInstance.ResourceLoader;
|
||||
SettingEntry[] metadataList;
|
||||
|
||||
Debug.WriteLine($"[SearchIndexService] Attempting to load prebuilt index from: {PrebuiltIndexResourceName}");
|
||||
|
||||
try
|
||||
{
|
||||
using Stream stream = assembly.GetManifestResourceStream(PrebuiltIndexResourceName);
|
||||
if (stream == null)
|
||||
{
|
||||
Debug.WriteLine($"[SearchIndexService] ERROR: Embedded resource '{PrebuiltIndexResourceName}' not found. Ensure it's correctly embedded and the name matches.");
|
||||
return;
|
||||
}
|
||||
|
||||
using StreamReader reader = new(stream);
|
||||
string json = reader.ReadToEnd();
|
||||
if (string.IsNullOrWhiteSpace(json))
|
||||
{
|
||||
Debug.WriteLine("[SearchIndexService] ERROR: Embedded resource was empty.");
|
||||
return;
|
||||
}
|
||||
|
||||
metadataList = JsonSerializer.Deserialize<SettingEntry[]>(json, _serializerOptions);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Debug.WriteLine($"[SearchIndexService] ERROR: Failed to load or deserialize prebuilt index: {ex.Message}");
|
||||
return;
|
||||
}
|
||||
|
||||
if (metadataList == null || metadataList.Length == 0)
|
||||
{
|
||||
Debug.WriteLine("[SearchIndexService] Prebuilt index is empty or deserialization failed.");
|
||||
return;
|
||||
}
|
||||
|
||||
foreach (ref var metadata in metadataList.AsSpan())
|
||||
{
|
||||
if (metadata.Type == EntryType.SettingsPage)
|
||||
{
|
||||
(metadata.Header, metadata.Description) = GetLocalizedModuleTitleAndDescription(resourceLoader, metadata.ElementUid);
|
||||
}
|
||||
else
|
||||
{
|
||||
(metadata.Header, metadata.Description) = GetLocalizedSettingHeaderAndDescription(resourceLoader, metadata.ElementUid);
|
||||
}
|
||||
|
||||
if (string.IsNullOrEmpty(metadata.Header))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
builder.Add(metadata);
|
||||
|
||||
// Cache the page name mapping for SettingsPage entries
|
||||
if (metadata.Type == EntryType.SettingsPage && !string.IsNullOrEmpty(metadata.Header))
|
||||
{
|
||||
_pageNameCache[metadata.PageTypeName] = metadata.Header;
|
||||
}
|
||||
}
|
||||
|
||||
Debug.WriteLine($"[SearchIndexService] Finished loading index. Total entries: {builder.Count}");
|
||||
}
|
||||
|
||||
private static (string Header, string Description) GetLocalizedSettingHeaderAndDescription(ResourceLoader resourceLoader, string elementUid)
|
||||
{
|
||||
string header = GetString(resourceLoader, $"{elementUid}/Header");
|
||||
string description = GetString(resourceLoader, $"{elementUid}/Description");
|
||||
|
||||
if (string.IsNullOrEmpty(header))
|
||||
{
|
||||
header = GetString(resourceLoader, $"{elementUid}/Content");
|
||||
}
|
||||
|
||||
return (header, description);
|
||||
}
|
||||
|
||||
private static (string Title, string Description) GetLocalizedModuleTitleAndDescription(ResourceLoader resourceLoader, string elementUid)
|
||||
{
|
||||
string title = GetString(resourceLoader, $"{elementUid}/ModuleTitle");
|
||||
string description = GetString(resourceLoader, $"{elementUid}/ModuleDescription");
|
||||
|
||||
return (title, description);
|
||||
}
|
||||
|
||||
private static string GetString(ResourceLoader rl, string key)
|
||||
{
|
||||
try
|
||||
{
|
||||
string value = rl.GetString(key);
|
||||
return string.IsNullOrWhiteSpace(value) ? string.Empty : value;
|
||||
}
|
||||
catch (Exception)
|
||||
{
|
||||
return string.Empty;
|
||||
}
|
||||
}
|
||||
|
||||
public static List<SettingEntry> Search(string query)
|
||||
{
|
||||
return Search(query, CancellationToken.None);
|
||||
}
|
||||
|
||||
public static List<SettingEntry> Search(string query, CancellationToken token)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(query))
|
||||
{
|
||||
return [];
|
||||
}
|
||||
|
||||
var currentIndex = Index;
|
||||
if (currentIndex.IsEmpty)
|
||||
{
|
||||
Debug.WriteLine("[SearchIndexService] Search called but index is empty.");
|
||||
return [];
|
||||
}
|
||||
|
||||
var normalizedQuery = NormalizeString(query);
|
||||
var bag = new ConcurrentBag<(SettingEntry Hit, double Score)>();
|
||||
var po = new ParallelOptions
|
||||
{
|
||||
CancellationToken = token,
|
||||
MaxDegreeOfParallelism = Math.Max(1, Environment.ProcessorCount - 1),
|
||||
};
|
||||
|
||||
try
|
||||
{
|
||||
Parallel.ForEach(currentIndex, po, entry =>
|
||||
{
|
||||
var (headerNorm, descNorm) = GetNormalizedTexts(entry);
|
||||
var captionScoreResult = StringMatcher.FuzzyMatch(normalizedQuery, headerNorm);
|
||||
double score = captionScoreResult.Score;
|
||||
|
||||
if (!string.IsNullOrEmpty(descNorm))
|
||||
{
|
||||
var descriptionScoreResult = StringMatcher.FuzzyMatch(normalizedQuery, descNorm);
|
||||
if (descriptionScoreResult.Success)
|
||||
{
|
||||
score = Math.Max(score, descriptionScoreResult.Score * 0.8);
|
||||
}
|
||||
}
|
||||
|
||||
if (score > 0)
|
||||
{
|
||||
var pageType = GetPageTypeFromName(entry.PageTypeName);
|
||||
if (pageType != null)
|
||||
{
|
||||
bag.Add((entry, score));
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
return [];
|
||||
}
|
||||
|
||||
return bag
|
||||
.OrderByDescending(r => r.Score)
|
||||
.Select(r => r.Hit)
|
||||
.ToList();
|
||||
}
|
||||
|
||||
private static Type GetPageTypeFromName(string pageTypeName)
|
||||
{
|
||||
if (string.IsNullOrEmpty(pageTypeName))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
lock (_lockObject)
|
||||
{
|
||||
if (_pageTypeCache.TryGetValue(pageTypeName, out var cached))
|
||||
{
|
||||
return cached;
|
||||
}
|
||||
|
||||
var assembly = typeof(GeneralPage).Assembly;
|
||||
var type = assembly.GetType($"Microsoft.PowerToys.Settings.UI.Views.{pageTypeName}");
|
||||
_pageTypeCache[pageTypeName] = type;
|
||||
return type;
|
||||
}
|
||||
}
|
||||
|
||||
private static (string HeaderNorm, string DescNorm) GetNormalizedTexts(SettingEntry entry)
|
||||
{
|
||||
if (entry.ElementUid == null && entry.Header == null)
|
||||
{
|
||||
return (NormalizeString(entry.Header), NormalizeString(entry.Description));
|
||||
}
|
||||
|
||||
var key = entry.ElementUid ?? $"{entry.PageTypeName}|{entry.ElementName}";
|
||||
lock (_lockObject)
|
||||
{
|
||||
if (_normalizedTextCache.TryGetValue(key, out var cached))
|
||||
{
|
||||
return cached;
|
||||
}
|
||||
}
|
||||
|
||||
var headerNorm = NormalizeString(entry.Header);
|
||||
var descNorm = NormalizeString(entry.Description);
|
||||
lock (_lockObject)
|
||||
{
|
||||
_normalizedTextCache[key] = (headerNorm, descNorm);
|
||||
}
|
||||
|
||||
return (headerNorm, descNorm);
|
||||
}
|
||||
|
||||
private static string NormalizeString(string input)
|
||||
{
|
||||
if (string.IsNullOrEmpty(input))
|
||||
{
|
||||
return string.Empty;
|
||||
}
|
||||
|
||||
var normalized = input.ToLowerInvariant().Normalize(NormalizationForm.FormKD);
|
||||
var stringBuilder = new StringBuilder();
|
||||
foreach (var c in normalized)
|
||||
{
|
||||
var unicodeCategory = CharUnicodeInfo.GetUnicodeCategory(c);
|
||||
if (unicodeCategory != UnicodeCategory.NonSpacingMark)
|
||||
{
|
||||
stringBuilder.Append(c);
|
||||
}
|
||||
}
|
||||
|
||||
return stringBuilder.ToString();
|
||||
}
|
||||
|
||||
public static string GetLocalizedPageName(string pageTypeName)
|
||||
{
|
||||
return _pageNameCache.TryGetValue(pageTypeName, out string cachedName) ? cachedName : string.Empty;
|
||||
}
|
||||
}
|
||||
}
|
||||
434
src/settings-ui/Settings.UI/Services/SettingsSearch.cs
Normal file
434
src/settings-ui/Settings.UI/Services/SettingsSearch.cs
Normal file
@@ -0,0 +1,434 @@
|
||||
// Copyright (c) Microsoft Corporation
|
||||
// The Microsoft Corporation licenses this file to you under the MIT license.
|
||||
// See the LICENSE file in the project root for more information.
|
||||
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Collections.Immutable;
|
||||
using System.Diagnostics;
|
||||
using System.IO;
|
||||
using System.Reflection;
|
||||
using System.Text.Json;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Common.Search;
|
||||
using Common.Search.SemanticSearch;
|
||||
using ManagedCommon;
|
||||
using Microsoft.PowerToys.Settings.UI.Helpers;
|
||||
using Microsoft.PowerToys.Settings.UI.Views;
|
||||
using Microsoft.Windows.ApplicationModel.Resources;
|
||||
using Settings.UI.Library;
|
||||
|
||||
namespace Microsoft.PowerToys.Settings.UI.Services
|
||||
{
|
||||
public sealed class SettingsSearch : IDisposable
|
||||
{
|
||||
private static readonly Lazy<SettingsSearch> DefaultInstance = new(() => new SettingsSearch());
|
||||
|
||||
private readonly object _lockObject = new();
|
||||
private readonly Dictionary<string, string> _pageNameCache = [];
|
||||
private readonly Dictionary<string, Type> _pageTypeCache = new();
|
||||
private readonly ISearchEngine<SettingEntry> _searchEngine;
|
||||
private ImmutableArray<SettingEntry> _index = [];
|
||||
private bool _isIndexBuilt;
|
||||
private bool _isIndexBuilding;
|
||||
private bool _disposed;
|
||||
private Task _buildTask;
|
||||
|
||||
private const string PrebuiltIndexResourceName = "Microsoft.PowerToys.Settings.UI.Assets.search.index.json";
|
||||
private static readonly JsonSerializerOptions SerializerOptions = new() { PropertyNameCaseInsensitive = true };
|
||||
|
||||
private const string DefaultIndexName = "PowerToys.Settings";
|
||||
|
||||
public SettingsSearch()
|
||||
: this(new SemanticSearchEngine<SettingEntry>(DefaultIndexName))
|
||||
{
|
||||
}
|
||||
|
||||
public SettingsSearch(ISearchEngine<SettingEntry> searchEngine)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(searchEngine);
|
||||
_searchEngine = searchEngine;
|
||||
}
|
||||
|
||||
public static SettingsSearch Default => DefaultInstance.Value;
|
||||
|
||||
public ImmutableArray<SettingEntry> Index
|
||||
{
|
||||
get
|
||||
{
|
||||
lock (_lockObject)
|
||||
{
|
||||
return _index;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public bool IsReady
|
||||
{
|
||||
get
|
||||
{
|
||||
lock (_lockObject)
|
||||
{
|
||||
return _isIndexBuilt && _searchEngine.IsReady;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public Task BuildIndexAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
ThrowIfDisposed();
|
||||
|
||||
lock (_lockObject)
|
||||
{
|
||||
if (_isIndexBuilt)
|
||||
{
|
||||
Logger.LogDebug("[SettingsSearch] BuildIndexAsync skipped: index already built.");
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
if (_isIndexBuilding)
|
||||
{
|
||||
Logger.LogDebug("[SettingsSearch] BuildIndexAsync skipped: index build already in progress.");
|
||||
return _buildTask ?? Task.CompletedTask;
|
||||
}
|
||||
|
||||
if (_buildTask != null)
|
||||
{
|
||||
Logger.LogDebug("[SettingsSearch] BuildIndexAsync skipped: build task already scheduled.");
|
||||
return _buildTask;
|
||||
}
|
||||
|
||||
Logger.LogInfo("[SettingsSearch] BuildIndexAsync started.");
|
||||
_buildTask = BuildIndexInternalAsync(cancellationToken);
|
||||
return _buildTask;
|
||||
}
|
||||
}
|
||||
|
||||
public async Task InitializeIndexAsync(IEnumerable<SettingEntry> entries, CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(entries);
|
||||
ThrowIfDisposed();
|
||||
|
||||
var builtIndex = entries is ImmutableArray<SettingEntry> immutableEntries
|
||||
? immutableEntries
|
||||
: ImmutableArray.CreateRange(entries);
|
||||
|
||||
lock (_lockObject)
|
||||
{
|
||||
_isIndexBuilding = true;
|
||||
_isIndexBuilt = false;
|
||||
_index = builtIndex;
|
||||
_pageNameCache.Clear();
|
||||
_pageTypeCache.Clear();
|
||||
}
|
||||
|
||||
Logger.LogInfo($"[SettingsSearch] Initializing index. Entries={builtIndex.Length}, Engine={_searchEngine.GetType().Name}.");
|
||||
CachePageNames(builtIndex);
|
||||
|
||||
try
|
||||
{
|
||||
if (_searchEngine.IsReady)
|
||||
{
|
||||
Logger.LogDebug("[SettingsSearch] Clearing existing search engine index.");
|
||||
await _searchEngine.ClearAsync(cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
else
|
||||
{
|
||||
Logger.LogDebug("[SettingsSearch] Initializing search engine.");
|
||||
await _searchEngine.InitializeAsync(cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
if (!_searchEngine.IsReady)
|
||||
{
|
||||
Logger.LogWarning("[SettingsSearch] Search engine not ready after initialization. Skipping indexing.");
|
||||
return;
|
||||
}
|
||||
|
||||
await _searchEngine.IndexBatchAsync(builtIndex, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
lock (_lockObject)
|
||||
{
|
||||
_isIndexBuilt = true;
|
||||
}
|
||||
|
||||
Logger.LogInfo($"[SettingsSearch] Index initialized. Entries={builtIndex.Length}, EngineReady={_searchEngine.IsReady}.");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Logger.LogError($"[SettingsSearch] CRITICAL ERROR initializing search engine: {ex.Message}", ex);
|
||||
}
|
||||
finally
|
||||
{
|
||||
lock (_lockObject)
|
||||
{
|
||||
_isIndexBuilding = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<SettingSearchResult>> SearchAsync(
|
||||
string query,
|
||||
SearchOptions options = null,
|
||||
CancellationToken token = default)
|
||||
{
|
||||
ThrowIfDisposed();
|
||||
|
||||
if (string.IsNullOrWhiteSpace(query))
|
||||
{
|
||||
return Array.Empty<SettingSearchResult>();
|
||||
}
|
||||
|
||||
if (!IsReady)
|
||||
{
|
||||
Logger.LogWarning("[SettingsSearch] Search called but index is not ready.");
|
||||
return Array.Empty<SettingSearchResult>();
|
||||
}
|
||||
|
||||
var effectiveOptions = options ?? new SearchOptions
|
||||
{
|
||||
MaxResults = Index.Length,
|
||||
IncludeMatchSpans = true,
|
||||
};
|
||||
|
||||
try
|
||||
{
|
||||
var sw = Stopwatch.StartNew();
|
||||
Logger.LogDebug($"[SettingsSearch] Search start. QueryLength={query.Length}, MaxResults={effectiveOptions.MaxResults}.");
|
||||
var results = await Task.Run(
|
||||
() => _searchEngine.SearchAsync(query, effectiveOptions, token),
|
||||
token).ConfigureAwait(false);
|
||||
var filtered = FilterValidPageTypes(results);
|
||||
sw.Stop();
|
||||
Logger.LogDebug($"[SettingsSearch] Search complete. Results={filtered.Count}, ElapsedMs={sw.ElapsedMilliseconds}.");
|
||||
return filtered;
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
Logger.LogDebug("[SettingsSearch] Search canceled.");
|
||||
return Array.Empty<SettingSearchResult>();
|
||||
}
|
||||
}
|
||||
|
||||
public static IReadOnlyList<SettingEntry> LoadIndexFromJson(string json)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(json))
|
||||
{
|
||||
return Array.Empty<SettingEntry>();
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
return JsonSerializer.Deserialize<SettingEntry[]>(json, SerializerOptions) ?? Array.Empty<SettingEntry>();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Logger.LogError($"[SettingsSearch] ERROR: Failed to load index from json: {ex.Message}", ex);
|
||||
return Array.Empty<SettingEntry>();
|
||||
}
|
||||
}
|
||||
|
||||
public string GetLocalizedPageName(string pageTypeName)
|
||||
{
|
||||
lock (_lockObject)
|
||||
{
|
||||
return _pageNameCache.TryGetValue(pageTypeName, out string cachedName) ? cachedName : string.Empty;
|
||||
}
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
if (_disposed)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_searchEngine.Dispose();
|
||||
_disposed = true;
|
||||
}
|
||||
|
||||
private async Task BuildIndexInternalAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
try
|
||||
{
|
||||
var sw = Stopwatch.StartNew();
|
||||
var entries = LoadIndexFromPrebuiltData();
|
||||
Logger.LogInfo($"[SettingsSearch] Prebuilt index loaded. Entries={entries.Length}.");
|
||||
await InitializeIndexAsync(entries, cancellationToken).ConfigureAwait(false);
|
||||
sw.Stop();
|
||||
Logger.LogInfo($"[SettingsSearch] BuildIndexAsync finished. ElapsedMs={sw.ElapsedMilliseconds}, Ready={IsReady}.");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Logger.LogError($"[SettingsSearch] CRITICAL ERROR building search index: {ex.Message}", ex);
|
||||
}
|
||||
finally
|
||||
{
|
||||
lock (_lockObject)
|
||||
{
|
||||
_buildTask = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void CachePageNames(ImmutableArray<SettingEntry> entries)
|
||||
{
|
||||
lock (_lockObject)
|
||||
{
|
||||
foreach (var entry in entries)
|
||||
{
|
||||
if (entry.Type == EntryType.SettingsPage && !string.IsNullOrEmpty(entry.Header))
|
||||
{
|
||||
_pageNameCache[entry.PageTypeName] = entry.Header;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private ImmutableArray<SettingEntry> LoadIndexFromPrebuiltData()
|
||||
{
|
||||
var assembly = Assembly.GetExecutingAssembly();
|
||||
var resourceLoader = ResourceLoaderInstance.ResourceLoader;
|
||||
|
||||
Logger.LogInfo($"[SettingsSearch] Attempting to load prebuilt index from: {PrebuiltIndexResourceName}");
|
||||
|
||||
string json;
|
||||
try
|
||||
{
|
||||
using Stream stream = assembly.GetManifestResourceStream(PrebuiltIndexResourceName);
|
||||
if (stream == null)
|
||||
{
|
||||
Logger.LogError($"[SettingsSearch] ERROR: Embedded resource '{PrebuiltIndexResourceName}' not found. Ensure it's correctly embedded and the name matches.");
|
||||
return ImmutableArray<SettingEntry>.Empty;
|
||||
}
|
||||
|
||||
using StreamReader reader = new(stream);
|
||||
json = reader.ReadToEnd();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Logger.LogError($"[SettingsSearch] ERROR: Failed to read prebuilt index: {ex.Message}", ex);
|
||||
return ImmutableArray<SettingEntry>.Empty;
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(json))
|
||||
{
|
||||
Logger.LogError("[SettingsSearch] ERROR: Embedded resource was empty.");
|
||||
return ImmutableArray<SettingEntry>.Empty;
|
||||
}
|
||||
|
||||
var metadataList = LoadIndexFromJson(json);
|
||||
if (metadataList == null || metadataList.Count == 0)
|
||||
{
|
||||
Logger.LogWarning("[SettingsSearch] Prebuilt index is empty or deserialization failed.");
|
||||
return ImmutableArray<SettingEntry>.Empty;
|
||||
}
|
||||
|
||||
var builder = ImmutableArray.CreateBuilder<SettingEntry>(metadataList.Count);
|
||||
|
||||
foreach (var metadata in metadataList)
|
||||
{
|
||||
var entry = metadata;
|
||||
if (entry.Type == EntryType.SettingsPage)
|
||||
{
|
||||
(entry.Header, entry.Description) = GetLocalizedModuleTitleAndDescription(resourceLoader, entry.ElementUid);
|
||||
}
|
||||
else
|
||||
{
|
||||
(entry.Header, entry.Description) = GetLocalizedSettingHeaderAndDescription(resourceLoader, entry.ElementUid);
|
||||
}
|
||||
|
||||
if (string.IsNullOrEmpty(entry.Header))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
builder.Add(entry);
|
||||
}
|
||||
|
||||
Logger.LogInfo($"[SettingsSearch] Finished loading index. Total entries: {builder.Count}");
|
||||
return builder.ToImmutable();
|
||||
}
|
||||
|
||||
private static (string Header, string Description) GetLocalizedSettingHeaderAndDescription(ResourceLoader resourceLoader, string elementUid)
|
||||
{
|
||||
string header = GetString(resourceLoader, $"{elementUid}/Header");
|
||||
string description = GetString(resourceLoader, $"{elementUid}/Description");
|
||||
|
||||
if (string.IsNullOrEmpty(header))
|
||||
{
|
||||
header = GetString(resourceLoader, $"{elementUid}/Content");
|
||||
}
|
||||
|
||||
return (header, description);
|
||||
}
|
||||
|
||||
private static (string Title, string Description) GetLocalizedModuleTitleAndDescription(ResourceLoader resourceLoader, string elementUid)
|
||||
{
|
||||
string title = GetString(resourceLoader, $"{elementUid}/ModuleTitle");
|
||||
string description = GetString(resourceLoader, $"{elementUid}/ModuleDescription");
|
||||
|
||||
return (title, description);
|
||||
}
|
||||
|
||||
private static string GetString(ResourceLoader rl, string key)
|
||||
{
|
||||
try
|
||||
{
|
||||
string value = rl.GetString(key);
|
||||
return string.IsNullOrWhiteSpace(value) ? string.Empty : value;
|
||||
}
|
||||
catch (Exception)
|
||||
{
|
||||
return string.Empty;
|
||||
}
|
||||
}
|
||||
|
||||
private IReadOnlyList<SettingSearchResult> FilterValidPageTypes(IReadOnlyList<SearchResult<SettingEntry>> results)
|
||||
{
|
||||
var filtered = new List<SettingSearchResult>(results.Count);
|
||||
foreach (var result in results)
|
||||
{
|
||||
var entry = result.Item;
|
||||
if (GetPageTypeFromName(entry.PageTypeName) != null)
|
||||
{
|
||||
filtered.Add(new SettingSearchResult
|
||||
{
|
||||
Entry = entry,
|
||||
Score = result.Score,
|
||||
MatchKind = result.MatchKind,
|
||||
MatchSpans = result.MatchSpans,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return filtered;
|
||||
}
|
||||
|
||||
private Type GetPageTypeFromName(string pageTypeName)
|
||||
{
|
||||
if (string.IsNullOrEmpty(pageTypeName))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
lock (_lockObject)
|
||||
{
|
||||
if (_pageTypeCache.TryGetValue(pageTypeName, out var cached))
|
||||
{
|
||||
return cached;
|
||||
}
|
||||
|
||||
var assembly = typeof(GeneralPage).Assembly;
|
||||
var type = assembly.GetType($"Microsoft.PowerToys.Settings.UI.Views.{pageTypeName}");
|
||||
_pageTypeCache[pageTypeName] = type;
|
||||
return type;
|
||||
}
|
||||
}
|
||||
|
||||
private void ThrowIfDisposed()
|
||||
{
|
||||
ObjectDisposedException.ThrowIf(_disposed, this);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
<ContentDialog
|
||||
x:Class="Microsoft.PowerToys.Settings.UI.Views.CustomVcpMappingEditorDialog"
|
||||
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
|
||||
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
|
||||
Width="400"
|
||||
MinWidth="400"
|
||||
DefaultButton="Primary"
|
||||
IsPrimaryButtonEnabled="{x:Bind CanSave, Mode=OneWay}"
|
||||
PrimaryButtonClick="ContentDialog_PrimaryButtonClick"
|
||||
Style="{StaticResource DefaultContentDialogStyle}"
|
||||
mc:Ignorable="d">
|
||||
|
||||
<StackPanel MinWidth="350" Spacing="16">
|
||||
<!-- VCP Code Selection -->
|
||||
<ComboBox
|
||||
x:Name="VcpCodeComboBox"
|
||||
x:Uid="PowerDisplay_CustomMappingEditor_VcpCode"
|
||||
HorizontalAlignment="Stretch"
|
||||
SelectionChanged="VcpCodeComboBox_SelectionChanged">
|
||||
<ComboBoxItem x:Name="VcpCodeItem_0x14" Tag="20" />
|
||||
<ComboBoxItem x:Name="VcpCodeItem_0x60" Tag="96" />
|
||||
</ComboBox>
|
||||
|
||||
<!-- Value Selection from monitors -->
|
||||
<StackPanel Spacing="8">
|
||||
<ComboBox
|
||||
x:Name="ValueComboBox"
|
||||
x:Uid="PowerDisplay_CustomMappingEditor_ValueComboBox"
|
||||
HorizontalAlignment="Stretch"
|
||||
DisplayMemberPath="DisplayName"
|
||||
ItemsSource="{x:Bind AvailableValues, Mode=OneWay}"
|
||||
SelectedValuePath="Value"
|
||||
SelectionChanged="ValueComboBox_SelectionChanged" />
|
||||
|
||||
<!-- Custom Value Input (shown when "Custom value" is selected) -->
|
||||
<TextBox
|
||||
x:Name="CustomValueTextBox"
|
||||
x:Uid="PowerDisplay_CustomMappingEditor_CustomValueInput"
|
||||
HorizontalAlignment="Stretch"
|
||||
PlaceholderText="0x11"
|
||||
TextChanged="CustomValueTextBox_TextChanged"
|
||||
Visibility="{x:Bind ShowCustomValueInput, Mode=OneWay}" />
|
||||
</StackPanel>
|
||||
|
||||
<!-- Custom Name Input -->
|
||||
<TextBox
|
||||
x:Name="CustomNameTextBox"
|
||||
x:Uid="PowerDisplay_CustomMappingEditor_CustomName"
|
||||
HorizontalAlignment="Stretch"
|
||||
MaxLength="50"
|
||||
TextChanged="CustomNameTextBox_TextChanged" />
|
||||
|
||||
<!-- Apply Scope -->
|
||||
<StackPanel Spacing="8">
|
||||
<ToggleSwitch
|
||||
x:Name="ApplyToAllToggle"
|
||||
x:Uid="PowerDisplay_CustomMappingEditor_ApplyToAll"
|
||||
IsOn="True"
|
||||
Toggled="ApplyToAllToggle_Toggled" />
|
||||
|
||||
<!-- Monitor Selection (shown when ApplyToAll is off) -->
|
||||
<ComboBox
|
||||
x:Name="MonitorComboBox"
|
||||
x:Uid="PowerDisplay_CustomMappingEditor_SelectMonitor"
|
||||
HorizontalAlignment="Stretch"
|
||||
DisplayMemberPath="DisplayName"
|
||||
ItemsSource="{x:Bind AvailableMonitors, Mode=OneWay}"
|
||||
SelectedValuePath="Id"
|
||||
SelectionChanged="MonitorComboBox_SelectionChanged"
|
||||
Visibility="{x:Bind ShowMonitorSelector, Mode=OneWay}" />
|
||||
</StackPanel>
|
||||
</StackPanel>
|
||||
</ContentDialog>
|
||||
@@ -0,0 +1,421 @@
|
||||
// Copyright (c) Microsoft Corporation
|
||||
// The Microsoft Corporation licenses this file to you under the MIT license.
|
||||
// See the LICENSE file in the project root for more information.
|
||||
|
||||
#nullable enable
|
||||
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Collections.ObjectModel;
|
||||
using System.ComponentModel;
|
||||
using System.Globalization;
|
||||
using System.Linq;
|
||||
using System.Runtime.CompilerServices;
|
||||
using Microsoft.PowerToys.Settings.UI.Helpers;
|
||||
using Microsoft.PowerToys.Settings.UI.Library;
|
||||
using Microsoft.UI.Xaml;
|
||||
using Microsoft.UI.Xaml.Controls;
|
||||
using PowerDisplay.Common.Models;
|
||||
using PowerDisplay.Common.Utils;
|
||||
|
||||
namespace Microsoft.PowerToys.Settings.UI.Views
|
||||
{
|
||||
/// <summary>
|
||||
/// Dialog for creating/editing custom VCP value name mappings
|
||||
/// </summary>
|
||||
public sealed partial class CustomVcpMappingEditorDialog : ContentDialog, INotifyPropertyChanged
|
||||
{
|
||||
/// <summary>
|
||||
/// Special value to indicate "Custom value" option in the ComboBox
|
||||
/// </summary>
|
||||
private const int CustomValueMarker = -1;
|
||||
|
||||
/// <summary>
|
||||
/// Represents a selectable VCP value item in the Value ComboBox
|
||||
/// </summary>
|
||||
public class VcpValueItem
|
||||
{
|
||||
public int Value { get; set; }
|
||||
|
||||
public string DisplayName { get; set; } = string.Empty;
|
||||
|
||||
public bool IsCustomOption => Value == CustomValueMarker;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Represents a selectable monitor item in the Monitor ComboBox
|
||||
/// </summary>
|
||||
public class MonitorItem
|
||||
{
|
||||
public string Id { get; set; } = string.Empty;
|
||||
|
||||
public string DisplayName { get; set; } = string.Empty;
|
||||
}
|
||||
|
||||
private readonly IEnumerable<MonitorInfo>? _monitors;
|
||||
private ObservableCollection<VcpValueItem> _availableValues = new();
|
||||
private ObservableCollection<MonitorItem> _availableMonitors = new();
|
||||
private byte _selectedVcpCode;
|
||||
private int _selectedValue;
|
||||
private string _customName = string.Empty;
|
||||
private bool _canSave;
|
||||
private bool _showCustomValueInput;
|
||||
private bool _showMonitorSelector;
|
||||
private int _customValueParsed;
|
||||
private bool _applyToAll = true;
|
||||
private string _selectedMonitorId = string.Empty;
|
||||
private string _selectedMonitorName = string.Empty;
|
||||
|
||||
public CustomVcpMappingEditorDialog(IEnumerable<MonitorInfo>? monitors)
|
||||
{
|
||||
_monitors = monitors;
|
||||
this.InitializeComponent();
|
||||
|
||||
// Set localized strings for ContentDialog
|
||||
var resourceLoader = ResourceLoaderInstance.ResourceLoader;
|
||||
Title = resourceLoader.GetString("PowerDisplay_CustomMappingEditor_Title");
|
||||
PrimaryButtonText = resourceLoader.GetString("PowerDisplay_Dialog_Save");
|
||||
CloseButtonText = resourceLoader.GetString("PowerDisplay_Dialog_Cancel");
|
||||
|
||||
// Set VCP code ComboBox items content dynamically using localized names
|
||||
VcpCodeItem_0x14.Content = GetFormattedVcpCodeName(resourceLoader, 0x14);
|
||||
VcpCodeItem_0x60.Content = GetFormattedVcpCodeName(resourceLoader, 0x60);
|
||||
|
||||
// Populate monitor list
|
||||
PopulateMonitorList();
|
||||
|
||||
// Default to Color Temperature (0x14)
|
||||
VcpCodeComboBox.SelectedIndex = 0;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the result mapping after dialog closes with Primary button
|
||||
/// </summary>
|
||||
public CustomVcpValueMapping? ResultMapping { get; private set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the available values for the selected VCP code
|
||||
/// </summary>
|
||||
public ObservableCollection<VcpValueItem> AvailableValues
|
||||
{
|
||||
get => _availableValues;
|
||||
private set
|
||||
{
|
||||
_availableValues = value;
|
||||
OnPropertyChanged();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the available monitors for selection
|
||||
/// </summary>
|
||||
public ObservableCollection<MonitorItem> AvailableMonitors
|
||||
{
|
||||
get => _availableMonitors;
|
||||
private set
|
||||
{
|
||||
_availableMonitors = value;
|
||||
OnPropertyChanged();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets a value indicating whether the dialog can be saved
|
||||
/// </summary>
|
||||
public bool CanSave
|
||||
{
|
||||
get => _canSave;
|
||||
private set
|
||||
{
|
||||
if (_canSave != value)
|
||||
{
|
||||
_canSave = value;
|
||||
OnPropertyChanged();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets a value indicating whether to show the custom value input TextBox
|
||||
/// </summary>
|
||||
public Visibility ShowCustomValueInput => _showCustomValueInput ? Visibility.Visible : Visibility.Collapsed;
|
||||
|
||||
/// <summary>
|
||||
/// Gets a value indicating whether to show the monitor selector ComboBox
|
||||
/// </summary>
|
||||
public Visibility ShowMonitorSelector => _showMonitorSelector ? Visibility.Visible : Visibility.Collapsed;
|
||||
|
||||
private void SetShowCustomValueInput(bool value)
|
||||
{
|
||||
if (_showCustomValueInput != value)
|
||||
{
|
||||
_showCustomValueInput = value;
|
||||
OnPropertyChanged(nameof(ShowCustomValueInput));
|
||||
}
|
||||
}
|
||||
|
||||
private void SetShowMonitorSelector(bool value)
|
||||
{
|
||||
if (_showMonitorSelector != value)
|
||||
{
|
||||
_showMonitorSelector = value;
|
||||
OnPropertyChanged(nameof(ShowMonitorSelector));
|
||||
}
|
||||
}
|
||||
|
||||
private void PopulateMonitorList()
|
||||
{
|
||||
AvailableMonitors = new ObservableCollection<MonitorItem>(
|
||||
_monitors?.Select(m => new MonitorItem { Id = m.Id, DisplayName = m.DisplayName })
|
||||
?? Enumerable.Empty<MonitorItem>());
|
||||
|
||||
if (AvailableMonitors.Count > 0)
|
||||
{
|
||||
MonitorComboBox.SelectedIndex = 0;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Pre-fill the dialog with existing mapping data for editing
|
||||
/// </summary>
|
||||
public void PreFillMapping(CustomVcpValueMapping mapping)
|
||||
{
|
||||
if (mapping is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
// Select the VCP code
|
||||
VcpCodeComboBox.SelectedIndex = mapping.VcpCode == 0x14 ? 0 : 1;
|
||||
|
||||
// Populate values for the selected VCP code
|
||||
PopulateValuesForVcpCode(mapping.VcpCode);
|
||||
|
||||
// Try to select the value in the ComboBox
|
||||
var matchingItem = AvailableValues.FirstOrDefault(v => !v.IsCustomOption && v.Value == mapping.Value);
|
||||
if (matchingItem is not null)
|
||||
{
|
||||
ValueComboBox.SelectedItem = matchingItem;
|
||||
}
|
||||
else
|
||||
{
|
||||
// Value not found in list, select "Custom value" option and fill the TextBox
|
||||
ValueComboBox.SelectedItem = AvailableValues.FirstOrDefault(v => v.IsCustomOption);
|
||||
CustomValueTextBox.Text = $"0x{mapping.Value:X2}";
|
||||
_customValueParsed = mapping.Value;
|
||||
}
|
||||
|
||||
// Set the custom name
|
||||
CustomNameTextBox.Text = mapping.CustomName;
|
||||
_customName = mapping.CustomName;
|
||||
|
||||
// Set apply scope
|
||||
_applyToAll = mapping.ApplyToAll;
|
||||
ApplyToAllToggle.IsOn = mapping.ApplyToAll;
|
||||
SetShowMonitorSelector(!mapping.ApplyToAll);
|
||||
|
||||
// Select the target monitor if not applying to all
|
||||
if (!mapping.ApplyToAll && !string.IsNullOrEmpty(mapping.TargetMonitorId))
|
||||
{
|
||||
var targetMonitor = AvailableMonitors.FirstOrDefault(m => m.Id == mapping.TargetMonitorId);
|
||||
if (targetMonitor is not null)
|
||||
{
|
||||
MonitorComboBox.SelectedItem = targetMonitor;
|
||||
_selectedMonitorId = targetMonitor.Id;
|
||||
_selectedMonitorName = targetMonitor.DisplayName;
|
||||
}
|
||||
}
|
||||
|
||||
UpdateCanSave();
|
||||
}
|
||||
|
||||
private void VcpCodeComboBox_SelectionChanged(object sender, SelectionChangedEventArgs e)
|
||||
{
|
||||
if (VcpCodeComboBox.SelectedItem is ComboBoxItem selectedItem &&
|
||||
selectedItem.Tag is string tagValue &&
|
||||
byte.TryParse(tagValue, out byte vcpCode))
|
||||
{
|
||||
_selectedVcpCode = vcpCode;
|
||||
PopulateValuesForVcpCode(vcpCode);
|
||||
UpdateCanSave();
|
||||
}
|
||||
}
|
||||
|
||||
private void PopulateValuesForVcpCode(byte vcpCode)
|
||||
{
|
||||
var values = new ObservableCollection<VcpValueItem>();
|
||||
var seenValues = new HashSet<int>();
|
||||
|
||||
// Collect values from all monitors
|
||||
if (_monitors is not null)
|
||||
{
|
||||
foreach (var monitor in _monitors)
|
||||
{
|
||||
if (monitor.VcpCodesFormatted is null)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
// Find the VCP code entry
|
||||
var vcpEntry = monitor.VcpCodesFormatted.FirstOrDefault(v =>
|
||||
!string.IsNullOrEmpty(v.Code) &&
|
||||
TryParseHexCode(v.Code, out int code) &&
|
||||
code == vcpCode);
|
||||
|
||||
if (vcpEntry?.ValueList is null)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
// Add each value from this monitor
|
||||
foreach (var valueInfo in vcpEntry.ValueList)
|
||||
{
|
||||
if (TryParseHexCode(valueInfo.Value, out int vcpValue) && !seenValues.Contains(vcpValue))
|
||||
{
|
||||
seenValues.Add(vcpValue);
|
||||
var displayName = !string.IsNullOrEmpty(valueInfo.Name)
|
||||
? $"{valueInfo.Name} (0x{vcpValue:X2})"
|
||||
: VcpNames.GetFormattedValueName(vcpCode, vcpValue);
|
||||
values.Add(new VcpValueItem
|
||||
{
|
||||
Value = vcpValue,
|
||||
DisplayName = displayName,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// If no values found from monitors, fall back to built-in values from VcpNames
|
||||
if (values.Count == 0)
|
||||
{
|
||||
var builtInValues = VcpNames.GetValueMappings(vcpCode);
|
||||
if (builtInValues is not null)
|
||||
{
|
||||
foreach (var kvp in builtInValues)
|
||||
{
|
||||
values.Add(new VcpValueItem
|
||||
{
|
||||
Value = kvp.Key,
|
||||
DisplayName = $"{kvp.Value} (0x{kvp.Key:X2})",
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Sort by value
|
||||
var sortedValues = new ObservableCollection<VcpValueItem>(values.OrderBy(v => v.Value));
|
||||
|
||||
// Add "Custom value" option at the end
|
||||
var resourceLoader = ResourceLoaderInstance.ResourceLoader;
|
||||
sortedValues.Add(new VcpValueItem
|
||||
{
|
||||
Value = CustomValueMarker,
|
||||
DisplayName = resourceLoader.GetString("PowerDisplay_CustomMappingEditor_CustomValueOption"),
|
||||
});
|
||||
|
||||
AvailableValues = sortedValues;
|
||||
|
||||
// Select first item if available
|
||||
if (sortedValues.Count > 0)
|
||||
{
|
||||
ValueComboBox.SelectedIndex = 0;
|
||||
}
|
||||
}
|
||||
|
||||
private static bool TryParseHexCode(string? hex, out int result)
|
||||
{
|
||||
result = 0;
|
||||
if (string.IsNullOrEmpty(hex))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var cleanHex = hex.StartsWith("0x", StringComparison.OrdinalIgnoreCase) ? hex[2..] : hex;
|
||||
return int.TryParse(cleanHex, NumberStyles.HexNumber, CultureInfo.InvariantCulture, out result);
|
||||
}
|
||||
|
||||
private static string GetFormattedVcpCodeName(Windows.ApplicationModel.Resources.ResourceLoader resourceLoader, byte vcpCode)
|
||||
{
|
||||
var resourceKey = $"PowerDisplay_VcpCode_Name_0x{vcpCode:X2}";
|
||||
var localizedName = resourceLoader.GetString(resourceKey);
|
||||
var name = string.IsNullOrEmpty(localizedName) ? VcpNames.GetCodeName(vcpCode) : localizedName;
|
||||
return $"{name} (0x{vcpCode:X2})";
|
||||
}
|
||||
|
||||
private void ValueComboBox_SelectionChanged(object sender, SelectionChangedEventArgs e)
|
||||
{
|
||||
if (ValueComboBox.SelectedItem is VcpValueItem selectedItem)
|
||||
{
|
||||
SetShowCustomValueInput(selectedItem.IsCustomOption);
|
||||
_selectedValue = selectedItem.IsCustomOption ? 0 : selectedItem.Value;
|
||||
UpdateCanSave();
|
||||
}
|
||||
}
|
||||
|
||||
private void CustomValueTextBox_TextChanged(object sender, TextChangedEventArgs e)
|
||||
{
|
||||
_customValueParsed = TryParseHexCode(CustomValueTextBox.Text?.Trim(), out int parsed) ? parsed : 0;
|
||||
UpdateCanSave();
|
||||
}
|
||||
|
||||
private void CustomNameTextBox_TextChanged(object sender, TextChangedEventArgs e)
|
||||
{
|
||||
_customName = CustomNameTextBox.Text?.Trim() ?? string.Empty;
|
||||
UpdateCanSave();
|
||||
}
|
||||
|
||||
private void ApplyToAllToggle_Toggled(object sender, RoutedEventArgs e)
|
||||
{
|
||||
_applyToAll = ApplyToAllToggle.IsOn;
|
||||
SetShowMonitorSelector(!_applyToAll);
|
||||
UpdateCanSave();
|
||||
}
|
||||
|
||||
private void MonitorComboBox_SelectionChanged(object sender, SelectionChangedEventArgs e)
|
||||
{
|
||||
if (MonitorComboBox.SelectedItem is MonitorItem selectedMonitor)
|
||||
{
|
||||
_selectedMonitorId = selectedMonitor.Id;
|
||||
_selectedMonitorName = selectedMonitor.DisplayName;
|
||||
UpdateCanSave();
|
||||
}
|
||||
}
|
||||
|
||||
private void UpdateCanSave()
|
||||
{
|
||||
var hasValidValue = _showCustomValueInput
|
||||
? _customValueParsed > 0
|
||||
: ValueComboBox.SelectedItem is VcpValueItem item && !item.IsCustomOption;
|
||||
|
||||
CanSave = _selectedVcpCode > 0 &&
|
||||
hasValidValue &&
|
||||
!string.IsNullOrWhiteSpace(_customName) &&
|
||||
(_applyToAll || !string.IsNullOrEmpty(_selectedMonitorId));
|
||||
}
|
||||
|
||||
private void ContentDialog_PrimaryButtonClick(ContentDialog sender, ContentDialogButtonClickEventArgs args)
|
||||
{
|
||||
if (CanSave)
|
||||
{
|
||||
int finalValue = _showCustomValueInput ? _customValueParsed : _selectedValue;
|
||||
ResultMapping = new CustomVcpValueMapping
|
||||
{
|
||||
VcpCode = _selectedVcpCode,
|
||||
Value = finalValue,
|
||||
CustomName = _customName,
|
||||
ApplyToAll = _applyToAll,
|
||||
TargetMonitorId = _applyToAll ? string.Empty : _selectedMonitorId,
|
||||
TargetMonitorName = _applyToAll ? string.Empty : _selectedMonitorName,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
public event PropertyChangedEventHandler? PropertyChanged;
|
||||
|
||||
private void OnPropertyChanged([CallerMemberName] string? propertyName = null)
|
||||
{
|
||||
PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -54,6 +54,9 @@
|
||||
<ComboBoxItem x:Uid="MouseUtils_CursorWrap_WrapMode_HorizontalOnly" />
|
||||
</ComboBox>
|
||||
</tkcontrols:SettingsCard>
|
||||
<tkcontrols:SettingsCard ContentAlignment="Left" IsEnabled="{x:Bind ViewModel.IsCursorWrapEnabled, Mode=OneWay}">
|
||||
<CheckBox x:Uid="MouseUtils_CursorWrap_DisableOnSingleMonitor" IsChecked="{x:Bind ViewModel.CursorWrapDisableOnSingleMonitor, Mode=TwoWay}" />
|
||||
</tkcontrols:SettingsCard>
|
||||
</tkcontrols:SettingsExpander.Items>
|
||||
</tkcontrols:SettingsExpander>
|
||||
</controls:SettingsGroup>
|
||||
|
||||
@@ -63,11 +63,51 @@
|
||||
</tkcontrols:SettingsCard>
|
||||
</controls:SettingsGroup>
|
||||
|
||||
<!-- Custom VCP Name Mappings -->
|
||||
<controls:SettingsGroup x:Uid="PowerDisplay_CustomVcpMappings_GroupSettings" IsEnabled="{x:Bind ViewModel.IsEnabled, Mode=OneWay}">
|
||||
<tkcontrols:SettingsExpander
|
||||
x:Uid="PowerDisplay_CustomVcpMappings"
|
||||
HeaderIcon="{ui:FontIcon Glyph=}"
|
||||
IsExpanded="{x:Bind ViewModel.HasCustomVcpMappings, Mode=OneWay}"
|
||||
ItemsSource="{x:Bind ViewModel.CustomVcpMappings, Mode=OneWay}">
|
||||
<tkcontrols:SettingsExpander.ItemTemplate>
|
||||
<DataTemplate x:DataType="pdmodels:CustomVcpValueMapping">
|
||||
<tkcontrols:SettingsCard Description="{x:Bind VcpCodeDisplayName}" Header="{x:Bind DisplaySummary}">
|
||||
<StackPanel Orientation="Horizontal" Spacing="8">
|
||||
<Button
|
||||
Click="EditCustomMapping_Click"
|
||||
Content="{ui:FontIcon Glyph=,
|
||||
FontSize=14}"
|
||||
Style="{StaticResource SubtleButtonStyle}"
|
||||
Tag="{x:Bind}"
|
||||
ToolTipService.ToolTip="Edit" />
|
||||
<Button
|
||||
Click="DeleteCustomMapping_Click"
|
||||
Content="{ui:FontIcon Glyph=,
|
||||
FontSize=14}"
|
||||
Style="{StaticResource SubtleButtonStyle}"
|
||||
Tag="{x:Bind}"
|
||||
ToolTipService.ToolTip="Delete" />
|
||||
</StackPanel>
|
||||
</tkcontrols:SettingsCard>
|
||||
</DataTemplate>
|
||||
</tkcontrols:SettingsExpander.ItemTemplate>
|
||||
|
||||
<!-- Add mapping button -->
|
||||
<Button x:Uid="PowerDisplay_AddCustomMappingButton" Click="AddCustomMapping_Click">
|
||||
<StackPanel Orientation="Horizontal" Spacing="6">
|
||||
<FontIcon FontSize="14" Glyph="" />
|
||||
<TextBlock x:Uid="PowerDisplay_AddCustomMapping_Text" />
|
||||
</StackPanel>
|
||||
</Button>
|
||||
</tkcontrols:SettingsExpander>
|
||||
</controls:SettingsGroup>
|
||||
|
||||
<controls:SettingsGroup x:Uid="PowerDisplay_Profiles_GroupSettings" IsEnabled="{x:Bind ViewModel.IsEnabled, Mode=OneWay}">
|
||||
<tkcontrols:SettingsExpander
|
||||
x:Uid="PowerDisplay_QuickProfiles"
|
||||
HeaderIcon="{ui:FontIcon Glyph=}"
|
||||
IsExpanded="True"
|
||||
IsExpanded="{x:Bind ViewModel.HasProfiles, Mode=OneWay}"
|
||||
ItemsSource="{x:Bind ViewModel.Profiles, Mode=OneWay}">
|
||||
<tkcontrols:SettingsExpander.ItemTemplate>
|
||||
<DataTemplate x:DataType="pdmodels:PowerDisplayProfile">
|
||||
|
||||
@@ -133,6 +133,65 @@ namespace Microsoft.PowerToys.Settings.UI.Views
|
||||
return ProfileHelper.GenerateUniqueProfileName(existingNames, baseName);
|
||||
}
|
||||
|
||||
// Custom VCP Mapping event handlers
|
||||
private async void AddCustomMapping_Click(object sender, RoutedEventArgs e)
|
||||
{
|
||||
var dialog = new CustomVcpMappingEditorDialog(ViewModel.Monitors);
|
||||
dialog.XamlRoot = this.XamlRoot;
|
||||
|
||||
var result = await dialog.ShowAsync();
|
||||
|
||||
if (result == ContentDialogResult.Primary && dialog.ResultMapping != null)
|
||||
{
|
||||
ViewModel.AddCustomVcpMapping(dialog.ResultMapping);
|
||||
}
|
||||
}
|
||||
|
||||
private async void EditCustomMapping_Click(object sender, RoutedEventArgs e)
|
||||
{
|
||||
if (sender is not Button button || button.Tag is not CustomVcpValueMapping mapping)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var dialog = new CustomVcpMappingEditorDialog(ViewModel.Monitors);
|
||||
dialog.XamlRoot = this.XamlRoot;
|
||||
dialog.PreFillMapping(mapping);
|
||||
|
||||
var result = await dialog.ShowAsync();
|
||||
|
||||
if (result == ContentDialogResult.Primary && dialog.ResultMapping != null)
|
||||
{
|
||||
ViewModel.UpdateCustomVcpMapping(mapping, dialog.ResultMapping);
|
||||
}
|
||||
}
|
||||
|
||||
private async void DeleteCustomMapping_Click(object sender, RoutedEventArgs e)
|
||||
{
|
||||
if (sender is not Button button || button.Tag is not CustomVcpValueMapping mapping)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var resourceLoader = ResourceLoaderInstance.ResourceLoader;
|
||||
var dialog = new ContentDialog
|
||||
{
|
||||
XamlRoot = this.XamlRoot,
|
||||
Title = resourceLoader.GetString("PowerDisplay_CustomMapping_Delete_Title"),
|
||||
Content = resourceLoader.GetString("PowerDisplay_CustomMapping_Delete_Message"),
|
||||
PrimaryButtonText = resourceLoader.GetString("Yes"),
|
||||
CloseButtonText = resourceLoader.GetString("No"),
|
||||
DefaultButton = ContentDialogButton.Close,
|
||||
};
|
||||
|
||||
var result = await dialog.ShowAsync();
|
||||
|
||||
if (result == ContentDialogResult.Primary)
|
||||
{
|
||||
ViewModel.DeleteCustomVcpMapping(mapping);
|
||||
}
|
||||
}
|
||||
|
||||
// Flag to prevent reentrant handling during programmatic checkbox changes
|
||||
private bool _isRestoringColorTempCheckbox;
|
||||
|
||||
|
||||
@@ -32,12 +32,12 @@
|
||||
IsTabStop="False"
|
||||
ItemsSource="{x:Bind ViewModel.ModuleResults, Mode=OneWay}">
|
||||
<ItemsControl.ItemTemplate>
|
||||
<DataTemplate x:DataType="models:SettingEntry">
|
||||
<DataTemplate x:DataType="models:SettingSearchResult">
|
||||
<tkcontrols:SettingsCard
|
||||
Margin="0,0,0,2"
|
||||
Click="ModuleButton_Click"
|
||||
Header="{x:Bind Header}"
|
||||
HeaderIcon="{x:Bind Icon, Converter={StaticResource IconConverter}, ConverterParameter=}"
|
||||
Header="{x:Bind Entry.Header}"
|
||||
HeaderIcon="{x:Bind Entry.Icon, Converter={StaticResource IconConverter}, ConverterParameter=}"
|
||||
IsClickEnabled="True" />
|
||||
</DataTemplate>
|
||||
</ItemsControl.ItemTemplate>
|
||||
@@ -52,19 +52,19 @@
|
||||
<ItemsControl.ItemTemplate>
|
||||
<DataTemplate x:DataType="vm:SettingsGroup">
|
||||
<controls:SettingsGroup Header="{x:Bind GroupName}">
|
||||
<ItemsControl IsTabStop="False" ItemsSource="{x:Bind Settings}">
|
||||
<ItemsControl.ItemTemplate>
|
||||
<DataTemplate x:DataType="models:SettingEntry">
|
||||
<tkcontrols:SettingsCard
|
||||
Margin="0,0,0,2"
|
||||
Click="SettingButton_Click"
|
||||
Description="{x:Bind Description}"
|
||||
Header="{x:Bind Header}"
|
||||
HeaderIcon="{x:Bind Icon, Converter={StaticResource IconConverter}, ConverterParameter=}"
|
||||
IsClickEnabled="True" />
|
||||
</DataTemplate>
|
||||
</ItemsControl.ItemTemplate>
|
||||
</ItemsControl>
|
||||
<ItemsControl IsTabStop="False" ItemsSource="{x:Bind Settings}">
|
||||
<ItemsControl.ItemTemplate>
|
||||
<DataTemplate x:DataType="models:SettingSearchResult">
|
||||
<tkcontrols:SettingsCard
|
||||
Margin="0,0,0,2"
|
||||
Click="SettingButton_Click"
|
||||
Description="{x:Bind Entry.Description}"
|
||||
Header="{x:Bind Entry.Header}"
|
||||
HeaderIcon="{x:Bind Entry.Icon, Converter={StaticResource IconConverter}, ConverterParameter=}"
|
||||
IsClickEnabled="True" />
|
||||
</DataTemplate>
|
||||
</ItemsControl.ItemTemplate>
|
||||
</ItemsControl>
|
||||
</controls:SettingsGroup>
|
||||
</DataTemplate>
|
||||
</ItemsControl.ItemTemplate>
|
||||
|
||||
@@ -44,17 +44,17 @@ namespace Microsoft.PowerToys.Settings.UI.Views
|
||||
|
||||
private void ModuleButton_Click(object sender, RoutedEventArgs e)
|
||||
{
|
||||
if (sender is SettingsCard card && card.DataContext is SettingEntry tagEntry)
|
||||
if (sender is SettingsCard card && card.DataContext is SettingSearchResult tagResult)
|
||||
{
|
||||
NavigateToModule(tagEntry);
|
||||
NavigateToModule(tagResult.Entry);
|
||||
}
|
||||
}
|
||||
|
||||
private void SettingButton_Click(object sender, RoutedEventArgs e)
|
||||
{
|
||||
if (sender is SettingsCard card && card.DataContext is SettingEntry tagEntry)
|
||||
if (sender is SettingsCard card && card.DataContext is SettingSearchResult tagResult)
|
||||
{
|
||||
NavigateToSetting(tagEntry);
|
||||
NavigateToSetting(tagResult.Entry);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -98,9 +98,9 @@ namespace Microsoft.PowerToys.Settings.UI.Views
|
||||
{
|
||||
public string Query { get; set; }
|
||||
|
||||
public List<SettingEntry> Results { get; set; }
|
||||
public List<SettingSearchResult> Results { get; set; }
|
||||
|
||||
public SearchResultsNavigationParams(string query, List<SettingEntry> results)
|
||||
public SearchResultsNavigationParams(string query, List<SettingSearchResult> results)
|
||||
{
|
||||
Query = query;
|
||||
Results = results;
|
||||
|
||||
@@ -8,7 +8,6 @@ using System.Linq;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Common.Search;
|
||||
using Common.Search.FuzzSearch;
|
||||
using ManagedCommon;
|
||||
using Microsoft.PowerToys.Settings.UI.Controls;
|
||||
using Microsoft.PowerToys.Settings.UI.Helpers;
|
||||
@@ -385,11 +384,7 @@ namespace Microsoft.PowerToys.Settings.UI.Views
|
||||
|
||||
private void ShellPage_Loaded(object sender, RoutedEventArgs e)
|
||||
{
|
||||
Task.Run(() =>
|
||||
{
|
||||
SearchIndexService.BuildIndex();
|
||||
})
|
||||
.ContinueWith(_ => { });
|
||||
_ = Task.Run(() => SettingsSearch.Default.BuildIndexAsync());
|
||||
}
|
||||
|
||||
private void NavigationView_DisplayModeChanged(NavigationView sender, NavigationViewDisplayModeChangedEventArgs args)
|
||||
@@ -427,7 +422,7 @@ namespace Microsoft.PowerToys.Settings.UI.Views
|
||||
NativeMethods.SendMessage(hWnd, NativeMethods.WM_COMMAND, ID_CLOSE_MENU_COMMAND, 0);
|
||||
}
|
||||
|
||||
private List<SettingEntry> _lastSearchResults = new();
|
||||
private List<SettingSearchResult> _lastSearchResults = new();
|
||||
private string _lastQueryText = string.Empty;
|
||||
|
||||
private async void SearchBox_TextChanged(AutoSuggestBox sender, AutoSuggestBoxTextChangedEventArgs args)
|
||||
@@ -470,11 +465,10 @@ namespace Microsoft.PowerToys.Settings.UI.Views
|
||||
}
|
||||
|
||||
// Query the index on a background thread to avoid blocking UI
|
||||
List<SettingEntry> results = null;
|
||||
List<SettingSearchResult> results = null;
|
||||
try
|
||||
{
|
||||
// If the token is already canceled before scheduling, the task won't start.
|
||||
results = await Task.Run(() => SearchIndexService.Search(query, token), token);
|
||||
results = (await SettingsSearch.Default.SearchAsync(query, options: null, token)).ToList();
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
@@ -578,7 +572,7 @@ namespace Microsoft.PowerToys.Settings.UI.Views
|
||||
}
|
||||
|
||||
// Centralized suggestion projection logic used by TextChanged & GotFocus restore.
|
||||
private List<SuggestionItem> BuildSuggestionItems(string query, List<SettingEntry> results)
|
||||
private List<SuggestionItem> BuildSuggestionItems(string query, List<SettingSearchResult> results)
|
||||
{
|
||||
results ??= new();
|
||||
if (results.Count == 0)
|
||||
@@ -601,16 +595,17 @@ namespace Microsoft.PowerToys.Settings.UI.Views
|
||||
};
|
||||
}
|
||||
|
||||
var list = results.Take(5).Select(e =>
|
||||
var list = results.Take(5).Select(result =>
|
||||
{
|
||||
var entry = result.Entry;
|
||||
string subtitle = string.Empty;
|
||||
if (e.Type != EntryType.SettingsPage)
|
||||
if (entry.Type != EntryType.SettingsPage)
|
||||
{
|
||||
subtitle = SearchIndexService.GetLocalizedPageName(e.PageTypeName);
|
||||
subtitle = SettingsSearch.Default.GetLocalizedPageName(entry.PageTypeName);
|
||||
if (string.IsNullOrEmpty(subtitle))
|
||||
{
|
||||
subtitle = SearchIndexService.Index
|
||||
.Where(x => x.Type == EntryType.SettingsPage && x.PageTypeName == e.PageTypeName)
|
||||
subtitle = SettingsSearch.Default.Index
|
||||
.Where(x => x.Type == EntryType.SettingsPage && x.PageTypeName == entry.PageTypeName)
|
||||
.Select(x => x.Header)
|
||||
.FirstOrDefault() ?? string.Empty;
|
||||
}
|
||||
@@ -618,12 +613,15 @@ namespace Microsoft.PowerToys.Settings.UI.Views
|
||||
|
||||
return new SuggestionItem
|
||||
{
|
||||
Header = e.Header,
|
||||
Icon = e.Icon,
|
||||
PageTypeName = e.PageTypeName,
|
||||
ElementName = e.ElementName,
|
||||
ParentElementName = e.ParentElementName,
|
||||
Header = entry.Header,
|
||||
Icon = entry.Icon,
|
||||
PageTypeName = entry.PageTypeName,
|
||||
ElementName = entry.ElementName,
|
||||
ParentElementName = entry.ParentElementName,
|
||||
Subtitle = subtitle,
|
||||
Score = result.Score,
|
||||
MatchKind = result.MatchKind,
|
||||
MatchSpans = result.MatchSpans ?? Array.Empty<MatchSpan>(),
|
||||
IsShowAll = false,
|
||||
};
|
||||
}).ToList();
|
||||
@@ -655,7 +653,7 @@ namespace Microsoft.PowerToys.Settings.UI.Views
|
||||
// Prefer cached results (from live search); if empty, perform a fresh search
|
||||
var matched = _lastSearchResults?.Count > 0 && string.Equals(_lastQueryText, queryText, StringComparison.Ordinal)
|
||||
? _lastSearchResults
|
||||
: await Task.Run(() => SearchIndexService.Search(queryText));
|
||||
: (await SettingsSearch.Default.SearchAsync(queryText, options: null)).ToList();
|
||||
|
||||
var searchParams = new SearchResultsNavigationParams(queryText, matched);
|
||||
NavigationService.Navigate<SearchResultsPage>(searchParams);
|
||||
|
||||
@@ -2726,6 +2726,9 @@ From there, simply click on one of the supported files in the File Explorer and
|
||||
<data name="MouseUtils_CursorWrap_DisableWrapDuringDrag.Content" xml:space="preserve">
|
||||
<value>Disable wrapping while dragging</value>
|
||||
</data>
|
||||
<data name="MouseUtils_CursorWrap_DisableOnSingleMonitor.Content" xml:space="preserve">
|
||||
<value>Disable wrapping when using a single monitor</value>
|
||||
</data>
|
||||
<data name="MouseUtils_CursorWrap_AutoActivate.Header" xml:space="preserve">
|
||||
<value>Auto-activate on startup</value>
|
||||
</data>
|
||||
@@ -5995,6 +5998,69 @@ The break timer font matches the text font.</value>
|
||||
<data name="PowerDisplay_ShowIdentifyMonitorsButton.Description" xml:space="preserve">
|
||||
<value>Show or hide the identify monitors button in the Power Display flyout</value>
|
||||
</data>
|
||||
<data name="PowerDisplay_CustomVcpMappings_GroupSettings.Header" xml:space="preserve">
|
||||
<value>Custom VCP Name Mappings</value>
|
||||
</data>
|
||||
<data name="PowerDisplay_CustomVcpMappings.Header" xml:space="preserve">
|
||||
<value>Custom name mappings</value>
|
||||
</data>
|
||||
<data name="PowerDisplay_CustomVcpMappings.Description" xml:space="preserve">
|
||||
<value>Define custom display names for color temperature presets and input sources</value>
|
||||
</data>
|
||||
<data name="PowerDisplay_AddCustomMappingButton.ToolTipService.ToolTip" xml:space="preserve">
|
||||
<value>Add custom mapping</value>
|
||||
</data>
|
||||
<data name="PowerDisplay_AddCustomMapping_Text.Text" xml:space="preserve">
|
||||
<value>Add mapping</value>
|
||||
</data>
|
||||
<data name="PowerDisplay_CustomMappingEditor_Title" xml:space="preserve">
|
||||
<value>Custom VCP Name Mapping</value>
|
||||
</data>
|
||||
<data name="PowerDisplay_CustomMappingEditor_VcpCode.Header" xml:space="preserve">
|
||||
<value>VCP Code</value>
|
||||
</data>
|
||||
<data name="PowerDisplay_VcpCode_Name_0x14" xml:space="preserve">
|
||||
<value>Color Temperature</value>
|
||||
</data>
|
||||
<data name="PowerDisplay_VcpCode_Name_0x60" xml:space="preserve">
|
||||
<value>Input Source</value>
|
||||
</data>
|
||||
<data name="PowerDisplay_CustomMappingEditor_CustomName.Header" xml:space="preserve">
|
||||
<value>Custom Name</value>
|
||||
</data>
|
||||
<data name="PowerDisplay_CustomMappingEditor_CustomName.PlaceholderText" xml:space="preserve">
|
||||
<value>Enter custom name</value>
|
||||
</data>
|
||||
<data name="PowerDisplay_CustomMappingEditor_ValueComboBox.Header" xml:space="preserve">
|
||||
<value>Value</value>
|
||||
</data>
|
||||
<data name="PowerDisplay_CustomMappingEditor_CustomValueOption" xml:space="preserve">
|
||||
<value>Custom value...</value>
|
||||
</data>
|
||||
<data name="PowerDisplay_CustomMappingEditor_CustomValueInput.Header" xml:space="preserve">
|
||||
<value>Enter custom value (hex)</value>
|
||||
</data>
|
||||
<data name="PowerDisplay_CustomMappingEditor_CustomValueInput.PlaceholderText" xml:space="preserve">
|
||||
<value>e.g., 0x11 or 17</value>
|
||||
</data>
|
||||
<data name="PowerDisplay_CustomMappingEditor_ApplyToAll.Header" xml:space="preserve">
|
||||
<value>Apply to all monitors</value>
|
||||
</data>
|
||||
<data name="PowerDisplay_CustomMappingEditor_ApplyToAll.OnContent" xml:space="preserve">
|
||||
<value>On</value>
|
||||
</data>
|
||||
<data name="PowerDisplay_CustomMappingEditor_ApplyToAll.OffContent" xml:space="preserve">
|
||||
<value>Off</value>
|
||||
</data>
|
||||
<data name="PowerDisplay_CustomMappingEditor_SelectMonitor.Header" xml:space="preserve">
|
||||
<value>Select monitor</value>
|
||||
</data>
|
||||
<data name="PowerDisplay_CustomMapping_Delete_Title" xml:space="preserve">
|
||||
<value>Delete custom mapping?</value>
|
||||
</data>
|
||||
<data name="PowerDisplay_CustomMapping_Delete_Message" xml:space="preserve">
|
||||
<value>This custom name mapping will be permanently removed.</value>
|
||||
</data>
|
||||
<data name="Hosts_Backup_GroupSettings.Header" xml:space="preserve">
|
||||
<value>Backup</value>
|
||||
</data>
|
||||
|
||||
@@ -116,6 +116,9 @@ namespace Microsoft.PowerToys.Settings.UI.ViewModels
|
||||
// Null-safe access in case property wasn't upgraded yet - default to 0 (Both)
|
||||
_cursorWrapWrapMode = CursorWrapSettingsConfig.Properties.WrapMode?.Value ?? 0;
|
||||
|
||||
// Null-safe access in case property wasn't upgraded yet - default to false
|
||||
_cursorWrapDisableOnSingleMonitor = CursorWrapSettingsConfig.Properties.DisableCursorWrapOnSingleMonitor?.Value ?? false;
|
||||
|
||||
int isEnabled = 0;
|
||||
|
||||
Utilities.NativeMethods.SystemParametersInfo(Utilities.NativeMethods.SPI_GETCLIENTAREAANIMATION, 0, ref isEnabled, 0);
|
||||
@@ -1003,13 +1006,6 @@ namespace Microsoft.PowerToys.Settings.UI.ViewModels
|
||||
GeneralSettingsConfig.Enabled.CursorWrap = value;
|
||||
OnPropertyChanged(nameof(IsCursorWrapEnabled));
|
||||
|
||||
// Auto-enable the AutoActivate setting when CursorWrap is enabled
|
||||
// This ensures cursor wrapping is active immediately after enabling
|
||||
if (value && !_cursorWrapAutoActivate)
|
||||
{
|
||||
CursorWrapAutoActivate = true;
|
||||
}
|
||||
|
||||
OutGoingGeneralSettings outgoing = new OutGoingGeneralSettings(GeneralSettingsConfig);
|
||||
SendConfigMSG(outgoing.ToString());
|
||||
|
||||
@@ -1114,6 +1110,34 @@ namespace Microsoft.PowerToys.Settings.UI.ViewModels
|
||||
}
|
||||
}
|
||||
|
||||
public bool CursorWrapDisableOnSingleMonitor
|
||||
{
|
||||
get
|
||||
{
|
||||
return _cursorWrapDisableOnSingleMonitor;
|
||||
}
|
||||
|
||||
set
|
||||
{
|
||||
if (value != _cursorWrapDisableOnSingleMonitor)
|
||||
{
|
||||
_cursorWrapDisableOnSingleMonitor = value;
|
||||
|
||||
// Ensure the property exists before setting value
|
||||
if (CursorWrapSettingsConfig.Properties.DisableCursorWrapOnSingleMonitor == null)
|
||||
{
|
||||
CursorWrapSettingsConfig.Properties.DisableCursorWrapOnSingleMonitor = new BoolProperty(value);
|
||||
}
|
||||
else
|
||||
{
|
||||
CursorWrapSettingsConfig.Properties.DisableCursorWrapOnSingleMonitor.Value = value;
|
||||
}
|
||||
|
||||
NotifyCursorWrapPropertyChanged();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public void NotifyCursorWrapPropertyChanged([CallerMemberName] string propertyName = null)
|
||||
{
|
||||
OnPropertyChanged(propertyName);
|
||||
@@ -1186,5 +1210,6 @@ namespace Microsoft.PowerToys.Settings.UI.ViewModels
|
||||
private bool _cursorWrapAutoActivate;
|
||||
private bool _cursorWrapDisableWrapDuringDrag; // Will be initialized in constructor from settings
|
||||
private int _cursorWrapWrapMode; // 0=Both, 1=VerticalOnly, 2=HorizontalOnly
|
||||
private bool _cursorWrapDisableOnSingleMonitor; // Disable cursor wrap when only one monitor is connected
|
||||
}
|
||||
}
|
||||
|
||||
@@ -36,6 +36,9 @@ namespace Microsoft.PowerToys.Settings.UI.ViewModels
|
||||
|
||||
public PowerDisplayViewModel(SettingsUtils settingsUtils, ISettingsRepository<GeneralSettings> settingsRepository, ISettingsRepository<PowerDisplaySettings> powerDisplaySettingsRepository, Func<string, int> ipcMSGCallBackFunc)
|
||||
{
|
||||
// Set up localized VCP code names for UI display
|
||||
VcpNames.LocalizedCodeNameProvider = GetLocalizedVcpCodeName;
|
||||
|
||||
// To obtain the general settings configurations of PowerToys Settings.
|
||||
ArgumentNullException.ThrowIfNull(settingsRepository);
|
||||
|
||||
@@ -56,9 +59,15 @@ namespace Microsoft.PowerToys.Settings.UI.ViewModels
|
||||
// set the callback functions value to handle outgoing IPC message.
|
||||
SendConfigMSG = ipcMSGCallBackFunc;
|
||||
|
||||
// Subscribe to collection changes for HasProfiles binding
|
||||
_profiles.CollectionChanged += (s, e) => OnPropertyChanged(nameof(HasProfiles));
|
||||
|
||||
// Load profiles
|
||||
LoadProfiles();
|
||||
|
||||
// Load custom VCP mappings
|
||||
LoadCustomVcpMappings();
|
||||
|
||||
// Listen for monitor refresh events from PowerDisplay.exe
|
||||
NativeEventWaiter.WaitForEventLoop(
|
||||
Constants.RefreshPowerDisplayMonitorsEvent(),
|
||||
@@ -446,21 +455,28 @@ namespace Microsoft.PowerToys.Settings.UI.ViewModels
|
||||
// Profile-related fields
|
||||
private ObservableCollection<PowerDisplayProfile> _profiles = new ObservableCollection<PowerDisplayProfile>();
|
||||
|
||||
// Custom VCP mapping fields
|
||||
private ObservableCollection<CustomVcpValueMapping> _customVcpMappings;
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets collection of available profiles (for button display)
|
||||
/// Gets collection of custom VCP value name mappings
|
||||
/// </summary>
|
||||
public ObservableCollection<PowerDisplayProfile> Profiles
|
||||
{
|
||||
get => _profiles;
|
||||
set
|
||||
{
|
||||
if (_profiles != value)
|
||||
{
|
||||
_profiles = value;
|
||||
OnPropertyChanged();
|
||||
}
|
||||
}
|
||||
}
|
||||
public ObservableCollection<CustomVcpValueMapping> CustomVcpMappings => _customVcpMappings;
|
||||
|
||||
/// <summary>
|
||||
/// Gets whether there are any custom VCP mappings (for UI binding)
|
||||
/// </summary>
|
||||
public bool HasCustomVcpMappings => _customVcpMappings?.Count > 0;
|
||||
|
||||
/// <summary>
|
||||
/// Gets collection of available profiles (for button display)
|
||||
/// </summary>
|
||||
public ObservableCollection<PowerDisplayProfile> Profiles => _profiles;
|
||||
|
||||
/// <summary>
|
||||
/// Gets whether there are any profiles (for UI binding)
|
||||
/// </summary>
|
||||
public bool HasProfiles => _profiles?.Count > 0;
|
||||
|
||||
public void RefreshEnabledState()
|
||||
{
|
||||
@@ -646,6 +662,109 @@ namespace Microsoft.PowerToys.Settings.UI.ViewModels
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Load custom VCP mappings from settings
|
||||
/// </summary>
|
||||
private void LoadCustomVcpMappings()
|
||||
{
|
||||
List<CustomVcpValueMapping> mappings;
|
||||
try
|
||||
{
|
||||
mappings = _settings.Properties.CustomVcpMappings ?? new List<CustomVcpValueMapping>();
|
||||
Logger.LogInfo($"Loaded {mappings.Count} custom VCP mappings");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Logger.LogError($"Failed to load custom VCP mappings: {ex.Message}");
|
||||
mappings = new List<CustomVcpValueMapping>();
|
||||
}
|
||||
|
||||
_customVcpMappings = new ObservableCollection<CustomVcpValueMapping>(mappings);
|
||||
_customVcpMappings.CollectionChanged += (s, e) => OnPropertyChanged(nameof(HasCustomVcpMappings));
|
||||
OnPropertyChanged(nameof(CustomVcpMappings));
|
||||
OnPropertyChanged(nameof(HasCustomVcpMappings));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Add a new custom VCP mapping.
|
||||
/// No duplicate checking - mappings are resolved by order (first match wins in VcpNames).
|
||||
/// </summary>
|
||||
public void AddCustomVcpMapping(CustomVcpValueMapping mapping)
|
||||
{
|
||||
if (mapping == null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
CustomVcpMappings.Add(mapping);
|
||||
Logger.LogInfo($"Added custom VCP mapping: VCP=0x{mapping.VcpCode:X2}, Value=0x{mapping.Value:X2} -> {mapping.CustomName}");
|
||||
SaveCustomVcpMappings();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Update an existing custom VCP mapping
|
||||
/// </summary>
|
||||
public void UpdateCustomVcpMapping(CustomVcpValueMapping oldMapping, CustomVcpValueMapping newMapping)
|
||||
{
|
||||
if (oldMapping == null || newMapping == null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var index = CustomVcpMappings.IndexOf(oldMapping);
|
||||
if (index >= 0)
|
||||
{
|
||||
CustomVcpMappings[index] = newMapping;
|
||||
Logger.LogInfo($"Updated custom VCP mapping at index {index}");
|
||||
SaveCustomVcpMappings();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Delete a custom VCP mapping
|
||||
/// </summary>
|
||||
public void DeleteCustomVcpMapping(CustomVcpValueMapping mapping)
|
||||
{
|
||||
if (mapping == null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (CustomVcpMappings.Remove(mapping))
|
||||
{
|
||||
Logger.LogInfo($"Deleted custom VCP mapping: VCP=0x{mapping.VcpCode:X2}, Value=0x{mapping.Value:X2}");
|
||||
SaveCustomVcpMappings();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Save custom VCP mappings to settings
|
||||
/// </summary>
|
||||
private void SaveCustomVcpMappings()
|
||||
{
|
||||
_settings.Properties.CustomVcpMappings = CustomVcpMappings.ToList();
|
||||
NotifySettingsChanged();
|
||||
|
||||
// Signal PowerDisplay to reload settings
|
||||
SignalSettingsUpdated();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Provides localized VCP code names for UI display.
|
||||
/// Looks for resource string with pattern "PowerDisplay_VcpCode_Name_0xXX".
|
||||
/// Returns null for unknown codes to use the default MCCS name.
|
||||
/// </summary>
|
||||
#nullable enable
|
||||
private static string? GetLocalizedVcpCodeName(byte vcpCode)
|
||||
{
|
||||
var resourceKey = $"PowerDisplay_VcpCode_Name_0x{vcpCode:X2}";
|
||||
var localizedName = ResourceLoaderInstance.ResourceLoader.GetString(resourceKey);
|
||||
|
||||
// ResourceLoader returns empty string if key not found
|
||||
return string.IsNullOrEmpty(localizedName) ? null : localizedName;
|
||||
}
|
||||
#nullable restore
|
||||
|
||||
private void NotifySettingsChanged()
|
||||
{
|
||||
// Skip during initialization when SendConfigMSG is not yet set
|
||||
|
||||
@@ -14,11 +14,11 @@ namespace Microsoft.PowerToys.Settings.UI.ViewModels
|
||||
{
|
||||
public class SearchResultsViewModel : INotifyPropertyChanged
|
||||
{
|
||||
private ObservableCollection<SettingEntry> _moduleResults = new();
|
||||
private ObservableCollection<SettingSearchResult> _moduleResults = new();
|
||||
private ObservableCollection<SettingsGroup> _groupedSettingsResults = new();
|
||||
private bool _hasNoResults;
|
||||
|
||||
public ObservableCollection<SettingEntry> ModuleResults
|
||||
public ObservableCollection<SettingSearchResult> ModuleResults
|
||||
{
|
||||
get => _moduleResults;
|
||||
set
|
||||
@@ -48,7 +48,7 @@ namespace Microsoft.PowerToys.Settings.UI.ViewModels
|
||||
}
|
||||
}
|
||||
|
||||
public void SetSearchResults(string query, List<SettingEntry> results)
|
||||
public void SetSearchResults(string query, List<SettingSearchResult> results)
|
||||
{
|
||||
if (results == null || results.Count == 0)
|
||||
{
|
||||
@@ -61,8 +61,8 @@ namespace Microsoft.PowerToys.Settings.UI.ViewModels
|
||||
HasNoResults = false;
|
||||
|
||||
// Separate modules and settings
|
||||
var modules = results.Where(r => r.Type == EntryType.SettingsPage).ToList();
|
||||
var settings = results.Where(r => r.Type == EntryType.SettingsCard).ToList();
|
||||
var modules = results.Where(r => r.Entry.Type == EntryType.SettingsPage).ToList();
|
||||
var settings = results.Where(r => r.Entry.Type == EntryType.SettingsCard).ToList();
|
||||
|
||||
// Update module results
|
||||
ModuleResults.Clear();
|
||||
@@ -73,11 +73,11 @@ namespace Microsoft.PowerToys.Settings.UI.ViewModels
|
||||
|
||||
// Group settings by their page/module
|
||||
var groupedSettings = settings
|
||||
.GroupBy(s => SearchIndexService.GetLocalizedPageName(s.PageTypeName))
|
||||
.GroupBy(s => SettingsSearch.Default.GetLocalizedPageName(s.Entry.PageTypeName))
|
||||
.Select(g => new SettingsGroup
|
||||
{
|
||||
GroupName = g.Key,
|
||||
Settings = new ObservableCollection<SettingEntry>(g),
|
||||
Settings = new ObservableCollection<SettingSearchResult>(g),
|
||||
})
|
||||
.ToList();
|
||||
|
||||
@@ -101,7 +101,7 @@ namespace Microsoft.PowerToys.Settings.UI.ViewModels
|
||||
#pragma warning restore SA1402 // File may only contain a single type
|
||||
{
|
||||
private string _groupName;
|
||||
private ObservableCollection<SettingEntry> _settings;
|
||||
private ObservableCollection<SettingSearchResult> _settings;
|
||||
|
||||
public string GroupName
|
||||
{
|
||||
@@ -113,7 +113,7 @@ namespace Microsoft.PowerToys.Settings.UI.ViewModels
|
||||
}
|
||||
}
|
||||
|
||||
public ObservableCollection<SettingEntry> Settings
|
||||
public ObservableCollection<SettingSearchResult> Settings
|
||||
{
|
||||
get => _settings;
|
||||
set
|
||||
|
||||
@@ -2,6 +2,9 @@
|
||||
// The Microsoft Corporation licenses this file to you under the MIT license.
|
||||
// See the LICENSE file in the project root for more information.
|
||||
|
||||
using System.Collections.Generic;
|
||||
using Common.Search;
|
||||
|
||||
namespace Microsoft.PowerToys.Settings.UI.ViewModels
|
||||
{
|
||||
public sealed partial class SuggestionItem
|
||||
@@ -18,6 +21,12 @@ namespace Microsoft.PowerToys.Settings.UI.ViewModels
|
||||
|
||||
public string Subtitle { get; init; }
|
||||
|
||||
public double Score { get; init; }
|
||||
|
||||
public SearchMatchKind? MatchKind { get; init; }
|
||||
|
||||
public IReadOnlyList<MatchSpan> MatchSpans { get; init; }
|
||||
|
||||
public bool IsShowAll { get; init; }
|
||||
|
||||
public bool IsNoResults { get; init; }
|
||||
|
||||
@@ -2,6 +2,11 @@
|
||||
<assembly manifestVersion="1.0" xmlns="urn:schemas-microsoft-com:asm.v1">
|
||||
<assemblyIdentity version="1.0.0.0" name="PowerToys.Settings.app"/>
|
||||
|
||||
<msix xmlns="urn:schemas-microsoft-com:msix.v1"
|
||||
packageName="Microsoft.PowerToys.SparseApp"
|
||||
applicationId="PowerToys.SettingsUI"
|
||||
publisher="CN=PowerToys Dev, O=PowerToys, L=Redmond, S=Washington, C=US" />
|
||||
|
||||
<application xmlns="urn:schemas-microsoft-com:asm.v3">
|
||||
<windowsSettings>
|
||||
<!-- The combination of below two tags have the following effect:
|
||||
|
||||
@@ -0,0 +1,130 @@
|
||||
// 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.IO;
|
||||
using Microsoft.VisualStudio.TestTools.UnitTesting;
|
||||
|
||||
namespace SettingsSearchEvaluation.Tests;
|
||||
|
||||
[TestClass]
|
||||
public class EvaluationDataLoaderTests
|
||||
{
|
||||
[TestMethod]
|
||||
public void LoadEntriesFromJson_NormalizesHeaderAndDetectsDuplicates()
|
||||
{
|
||||
const string json = """
|
||||
[
|
||||
{
|
||||
"type": 0,
|
||||
"header": null,
|
||||
"pageTypeName": "ColorPickerPage",
|
||||
"elementName": "",
|
||||
"elementUid": "Activation_Shortcut",
|
||||
"parentElementName": "",
|
||||
"description": null,
|
||||
"icon": null
|
||||
},
|
||||
{
|
||||
"type": 0,
|
||||
"header": null,
|
||||
"pageTypeName": "FancyZonesPage",
|
||||
"elementName": "",
|
||||
"elementUid": "Activation_Shortcut",
|
||||
"parentElementName": "",
|
||||
"description": null,
|
||||
"icon": null
|
||||
}
|
||||
]
|
||||
""";
|
||||
|
||||
var (entries, diagnostics) = EvaluationDataLoader.LoadEntriesFromJson(json);
|
||||
|
||||
Assert.AreEqual(2, entries.Count);
|
||||
Assert.AreEqual("Activation Shortcut", entries[0].Header);
|
||||
Assert.AreEqual(1, diagnostics.DuplicateIdBucketCount);
|
||||
Assert.IsTrue(diagnostics.DuplicateIdCounts.TryGetValue("Activation_Shortcut", out var count));
|
||||
Assert.AreEqual(2, count);
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void LoadCases_GeneratesFallbackCases_WhenNoCasesFileSpecified()
|
||||
{
|
||||
const string json = """
|
||||
[
|
||||
{
|
||||
"type": 0,
|
||||
"header": "Fancy Zones",
|
||||
"pageTypeName": "FancyZonesPage",
|
||||
"elementName": "",
|
||||
"elementUid": "FancyZones",
|
||||
"parentElementName": "",
|
||||
"description": "",
|
||||
"icon": null
|
||||
}
|
||||
]
|
||||
""";
|
||||
|
||||
var (entries, _) = EvaluationDataLoader.LoadEntriesFromJson(json);
|
||||
var cases = EvaluationDataLoader.LoadCases(null, entries);
|
||||
|
||||
Assert.AreEqual(1, cases.Count);
|
||||
Assert.AreEqual("Fancy Zones", cases[0].Query);
|
||||
Assert.AreEqual("FancyZones", cases[0].ExpectedIds[0]);
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void LoadCases_LoadsAndNormalizesCasesFile()
|
||||
{
|
||||
const string entriesJson = """
|
||||
[
|
||||
{
|
||||
"type": 0,
|
||||
"header": "Fancy Zones",
|
||||
"pageTypeName": "FancyZonesPage",
|
||||
"elementName": "",
|
||||
"elementUid": "FancyZones",
|
||||
"parentElementName": "",
|
||||
"description": "",
|
||||
"icon": null
|
||||
}
|
||||
]
|
||||
""";
|
||||
|
||||
const string casesJson = """
|
||||
[
|
||||
{
|
||||
"query": " fancy zones ",
|
||||
"expectedIds": [ "FancyZones", " fancyzones ", "" ],
|
||||
"notes": "normalization test"
|
||||
},
|
||||
{
|
||||
"query": "",
|
||||
"expectedIds": [ "FancyZones" ]
|
||||
},
|
||||
{
|
||||
"query": "missing expected",
|
||||
"expectedIds": [ "" ]
|
||||
}
|
||||
]
|
||||
""";
|
||||
|
||||
var (entries, _) = EvaluationDataLoader.LoadEntriesFromJson(entriesJson);
|
||||
var casesFile = Path.GetTempFileName();
|
||||
try
|
||||
{
|
||||
File.WriteAllText(casesFile, casesJson);
|
||||
var cases = EvaluationDataLoader.LoadCases(casesFile, entries);
|
||||
|
||||
Assert.AreEqual(1, cases.Count);
|
||||
Assert.AreEqual("fancy zones", cases[0].Query);
|
||||
Assert.AreEqual(1, cases[0].ExpectedIds.Count);
|
||||
Assert.AreEqual("FancyZones", cases[0].ExpectedIds[0]);
|
||||
Assert.AreEqual("normalization test", cases[0].Notes);
|
||||
}
|
||||
finally
|
||||
{
|
||||
File.Delete(casesFile);
|
||||
}
|
||||
}
|
||||
}
|
||||
50
tools/SettingsSearchEvaluation.Tests/EvaluationMathTests.cs
Normal file
50
tools/SettingsSearchEvaluation.Tests/EvaluationMathTests.cs
Normal file
@@ -0,0 +1,50 @@
|
||||
// Copyright (c) Microsoft Corporation
|
||||
// The Microsoft Corporation licenses this file to you under the MIT license.
|
||||
// See the LICENSE file in the project root for more information.
|
||||
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using Microsoft.VisualStudio.TestTools.UnitTesting;
|
||||
|
||||
namespace SettingsSearchEvaluation.Tests;
|
||||
|
||||
[TestClass]
|
||||
public class EvaluationMathTests
|
||||
{
|
||||
private static readonly double[] LatencySamples = { 10.0, 20.0, 30.0, 40.0, 50.0 };
|
||||
|
||||
[TestMethod]
|
||||
public void FindBestRank_ReturnsExpectedRank()
|
||||
{
|
||||
var ranked = new[] { "a", "b", "c", "d" };
|
||||
var expected = new HashSet<string>(StringComparer.OrdinalIgnoreCase) { "c" };
|
||||
|
||||
var rank = EvaluationMath.FindBestRank(ranked, expected);
|
||||
|
||||
Assert.AreEqual(3, rank);
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void FindBestRank_ReturnsZero_WhenMissing()
|
||||
{
|
||||
var ranked = new[] { "a", "b", "c", "d" };
|
||||
var expected = new HashSet<string>(StringComparer.OrdinalIgnoreCase) { "x", "y" };
|
||||
|
||||
var rank = EvaluationMath.FindBestRank(ranked, expected);
|
||||
|
||||
Assert.AreEqual(0, rank);
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void ComputeLatencySummary_ComputesQuantiles()
|
||||
{
|
||||
var summary = EvaluationMath.ComputeLatencySummary(LatencySamples);
|
||||
|
||||
Assert.AreEqual(5, summary.Samples);
|
||||
Assert.AreEqual(10.0, summary.MinMs);
|
||||
Assert.AreEqual(30.0, summary.P50Ms);
|
||||
Assert.AreEqual(50.0, summary.P95Ms);
|
||||
Assert.AreEqual(50.0, summary.MaxMs);
|
||||
Assert.AreEqual(30.0, summary.AverageMs, 0.0001);
|
||||
}
|
||||
}
|
||||
129
tools/SettingsSearchEvaluation.Tests/EvaluatorTests.cs
Normal file
129
tools/SettingsSearchEvaluation.Tests/EvaluatorTests.cs
Normal file
@@ -0,0 +1,129 @@
|
||||
// Copyright (c) Microsoft Corporation
|
||||
// The Microsoft Corporation licenses this file to you under the MIT license.
|
||||
// See the LICENSE file in the project root for more information.
|
||||
|
||||
using System;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.VisualStudio.TestTools.UnitTesting;
|
||||
|
||||
namespace SettingsSearchEvaluation.Tests;
|
||||
|
||||
[TestClass]
|
||||
public class EvaluatorTests
|
||||
{
|
||||
[TestMethod]
|
||||
public async Task RunAsync_BasicEngine_ReturnsExpectedMetricsForExactSingleEntry()
|
||||
{
|
||||
const string json = """
|
||||
[
|
||||
{
|
||||
"type": 0,
|
||||
"header": "Fancy Zones",
|
||||
"pageTypeName": "FancyZonesPage",
|
||||
"elementName": "",
|
||||
"elementUid": "FancyZones",
|
||||
"parentElementName": "",
|
||||
"description": "",
|
||||
"icon": null
|
||||
}
|
||||
]
|
||||
""";
|
||||
|
||||
var (entries, diagnostics) = EvaluationDataLoader.LoadEntriesFromJson(json);
|
||||
var cases = new[]
|
||||
{
|
||||
new EvaluationCase
|
||||
{
|
||||
Query = "Fancy Zones",
|
||||
ExpectedIds = new[] { "FancyZones" },
|
||||
Notes = "Exact query should be rank 1.",
|
||||
},
|
||||
};
|
||||
|
||||
var options = new RunnerOptions
|
||||
{
|
||||
IndexJsonPath = "test-index.json",
|
||||
CasesJsonPath = null,
|
||||
Engines = new[] { SearchEngineKind.Basic },
|
||||
MaxResults = 5,
|
||||
TopK = 5,
|
||||
Iterations = 1,
|
||||
WarmupIterations = 0,
|
||||
SemanticIndexTimeout = TimeSpan.FromSeconds(1),
|
||||
OutputJsonPath = null,
|
||||
};
|
||||
|
||||
var report = await Evaluator.RunAsync(options, entries, diagnostics, cases);
|
||||
|
||||
Assert.AreEqual(1, report.Engines.Count);
|
||||
var engine = report.Engines[0];
|
||||
Assert.AreEqual(SearchEngineKind.Basic, engine.Engine);
|
||||
Assert.IsTrue(engine.IsAvailable);
|
||||
Assert.AreEqual(1, engine.QueryCount);
|
||||
Assert.AreEqual(1.0, engine.RecallAtK, 0.0001);
|
||||
Assert.AreEqual(1.0, engine.Mrr, 0.0001);
|
||||
Assert.AreEqual(1, engine.CaseResults.Count);
|
||||
Assert.IsTrue(engine.CaseResults[0].HitAtK);
|
||||
Assert.AreEqual(1, engine.CaseResults[0].BestRank);
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public async Task RunAsync_SemanticEngine_ReturnsReportWithoutThrowing()
|
||||
{
|
||||
const string json = """
|
||||
[
|
||||
{
|
||||
"type": 0,
|
||||
"header": "Fancy Zones",
|
||||
"pageTypeName": "FancyZonesPage",
|
||||
"elementName": "",
|
||||
"elementUid": "FancyZones",
|
||||
"parentElementName": "",
|
||||
"description": "",
|
||||
"icon": null
|
||||
}
|
||||
]
|
||||
""";
|
||||
|
||||
var (entries, diagnostics) = EvaluationDataLoader.LoadEntriesFromJson(json);
|
||||
var cases = new[]
|
||||
{
|
||||
new EvaluationCase
|
||||
{
|
||||
Query = "Fancy Zones",
|
||||
ExpectedIds = new[] { "FancyZones" },
|
||||
Notes = "Semantic smoke test.",
|
||||
},
|
||||
};
|
||||
|
||||
var options = new RunnerOptions
|
||||
{
|
||||
IndexJsonPath = "test-index.json",
|
||||
CasesJsonPath = null,
|
||||
Engines = new[] { SearchEngineKind.Semantic },
|
||||
MaxResults = 5,
|
||||
TopK = 5,
|
||||
Iterations = 1,
|
||||
WarmupIterations = 0,
|
||||
SemanticIndexTimeout = TimeSpan.FromSeconds(3),
|
||||
OutputJsonPath = null,
|
||||
};
|
||||
|
||||
var report = await Evaluator.RunAsync(options, entries, diagnostics, cases);
|
||||
|
||||
Assert.AreEqual(1, report.Engines.Count);
|
||||
var engine = report.Engines[0];
|
||||
Assert.AreEqual(SearchEngineKind.Semantic, engine.Engine);
|
||||
|
||||
if (!engine.IsAvailable)
|
||||
{
|
||||
Assert.IsFalse(string.IsNullOrWhiteSpace(engine.AvailabilityError));
|
||||
Assert.AreEqual(0, engine.QueryCount);
|
||||
}
|
||||
else
|
||||
{
|
||||
Assert.AreEqual(1, engine.QueryCount);
|
||||
Assert.AreEqual(1, engine.CaseResults.Count);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<!-- Look at Directory.Build.props in root for common stuff as well -->
|
||||
<Import Project="..\..\src\Common.Dotnet.CsWinRT.props" />
|
||||
|
||||
<PropertyGroup>
|
||||
<IsPackable>false</IsPackable>
|
||||
<OutputPath>..\..\$(Configuration)\$(Platform)\tests\SettingsSearchEvaluationTests\</OutputPath>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="MSTest" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\SettingsSearchEvaluation\SettingsSearchEvaluation.csproj" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
26
tools/SettingsSearchEvaluation/DatasetDiagnostics.cs
Normal file
26
tools/SettingsSearchEvaluation/DatasetDiagnostics.cs
Normal file
@@ -0,0 +1,26 @@
|
||||
// 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.Collections.ObjectModel;
|
||||
|
||||
namespace SettingsSearchEvaluation;
|
||||
|
||||
internal sealed class DatasetDiagnostics
|
||||
{
|
||||
public required int TotalEntries { get; init; }
|
||||
|
||||
public required int DistinctIds { get; init; }
|
||||
|
||||
public required int DuplicateIdBucketCount { get; init; }
|
||||
|
||||
public required IReadOnlyDictionary<string, int> DuplicateIdCounts { get; init; }
|
||||
|
||||
public static DatasetDiagnostics Empty { get; } = new()
|
||||
{
|
||||
TotalEntries = 0,
|
||||
DistinctIds = 0,
|
||||
DuplicateIdBucketCount = 0,
|
||||
DuplicateIdCounts = new ReadOnlyDictionary<string, int>(new Dictionary<string, int>()),
|
||||
};
|
||||
}
|
||||
30
tools/SettingsSearchEvaluation/EngineEvaluationReport.cs
Normal file
30
tools/SettingsSearchEvaluation/EngineEvaluationReport.cs
Normal file
@@ -0,0 +1,30 @@
|
||||
// Copyright (c) Microsoft Corporation
|
||||
// The Microsoft Corporation licenses this file to you under the MIT license.
|
||||
// See the LICENSE file in the project root for more information.
|
||||
|
||||
namespace SettingsSearchEvaluation;
|
||||
|
||||
internal sealed class EngineEvaluationReport
|
||||
{
|
||||
public required SearchEngineKind Engine { get; init; }
|
||||
|
||||
public required bool IsAvailable { get; init; }
|
||||
|
||||
public string? AvailabilityError { get; init; }
|
||||
|
||||
public string? CapabilitiesSummary { get; init; }
|
||||
|
||||
public int IndexedEntries { get; init; }
|
||||
|
||||
public int QueryCount { get; init; }
|
||||
|
||||
public double IndexingTimeMs { get; init; }
|
||||
|
||||
public double RecallAtK { get; init; }
|
||||
|
||||
public double Mrr { get; init; }
|
||||
|
||||
public required LatencySummary SearchLatencyMs { get; init; }
|
||||
|
||||
public required IReadOnlyList<QueryEvaluationResult> CaseResults { get; init; }
|
||||
}
|
||||
14
tools/SettingsSearchEvaluation/EvaluationCase.cs
Normal file
14
tools/SettingsSearchEvaluation/EvaluationCase.cs
Normal file
@@ -0,0 +1,14 @@
|
||||
// Copyright (c) Microsoft Corporation
|
||||
// The Microsoft Corporation licenses this file to you under the MIT license.
|
||||
// See the LICENSE file in the project root for more information.
|
||||
|
||||
namespace SettingsSearchEvaluation;
|
||||
|
||||
internal sealed class EvaluationCase
|
||||
{
|
||||
public required string Query { get; init; }
|
||||
|
||||
public required IReadOnlyList<string> ExpectedIds { get; init; }
|
||||
|
||||
public string? Notes { get; init; }
|
||||
}
|
||||
180
tools/SettingsSearchEvaluation/EvaluationDataLoader.cs
Normal file
180
tools/SettingsSearchEvaluation/EvaluationDataLoader.cs
Normal file
@@ -0,0 +1,180 @@
|
||||
// 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.Collections.ObjectModel;
|
||||
using System.Text.Json;
|
||||
using System.Text.RegularExpressions;
|
||||
using Settings.UI.Library;
|
||||
|
||||
namespace SettingsSearchEvaluation;
|
||||
|
||||
internal static partial class EvaluationDataLoader
|
||||
{
|
||||
private static readonly JsonSerializerOptions JsonOptions = new()
|
||||
{
|
||||
PropertyNameCaseInsensitive = true,
|
||||
ReadCommentHandling = JsonCommentHandling.Skip,
|
||||
};
|
||||
|
||||
public static (IReadOnlyList<SettingEntry> Entries, DatasetDiagnostics Diagnostics) LoadEntriesFromFile(string path)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(path);
|
||||
var json = File.ReadAllText(path);
|
||||
return LoadEntriesFromJson(json);
|
||||
}
|
||||
|
||||
public static (IReadOnlyList<SettingEntry> Entries, DatasetDiagnostics Diagnostics) LoadEntriesFromJson(string json)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(json))
|
||||
{
|
||||
return (Array.Empty<SettingEntry>(), DatasetDiagnostics.Empty);
|
||||
}
|
||||
|
||||
var rawEntries = JsonSerializer.Deserialize<List<RawSettingEntry>>(json, JsonOptions) ?? new List<RawSettingEntry>();
|
||||
var normalized = new List<SettingEntry>(rawEntries.Count);
|
||||
|
||||
foreach (var raw in rawEntries)
|
||||
{
|
||||
var pageType = raw.PageTypeName?.Trim() ?? string.Empty;
|
||||
var elementName = raw.ElementName?.Trim() ?? string.Empty;
|
||||
var elementUid = raw.ElementUid?.Trim() ?? string.Empty;
|
||||
|
||||
if (string.IsNullOrEmpty(elementUid))
|
||||
{
|
||||
elementUid = $"{pageType}|{elementName}";
|
||||
}
|
||||
|
||||
var header = raw.Header?.Trim();
|
||||
if (string.IsNullOrEmpty(header))
|
||||
{
|
||||
header = BuildFallbackHeader(elementUid, elementName, pageType);
|
||||
}
|
||||
|
||||
var description = raw.Description?.Trim() ?? string.Empty;
|
||||
var parent = raw.ParentElementName?.Trim() ?? string.Empty;
|
||||
var icon = raw.Icon?.Trim() ?? string.Empty;
|
||||
|
||||
normalized.Add(new SettingEntry(
|
||||
raw.Type,
|
||||
header,
|
||||
pageType,
|
||||
elementName,
|
||||
elementUid,
|
||||
parent,
|
||||
description,
|
||||
icon));
|
||||
}
|
||||
|
||||
return (normalized, BuildDiagnostics(normalized));
|
||||
}
|
||||
|
||||
public static IReadOnlyList<EvaluationCase> LoadCases(string? casesPath, IReadOnlyList<SettingEntry> entries)
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(casesPath))
|
||||
{
|
||||
var json = File.ReadAllText(casesPath);
|
||||
var parsed = JsonSerializer.Deserialize<List<RawEvaluationCase>>(json, JsonOptions) ?? new List<RawEvaluationCase>();
|
||||
var normalized = parsed
|
||||
.Where(c => !string.IsNullOrWhiteSpace(c.Query))
|
||||
.Select(c => new EvaluationCase
|
||||
{
|
||||
Query = c.Query!.Trim(),
|
||||
ExpectedIds = c.ExpectedIds?
|
||||
.Where(id => !string.IsNullOrWhiteSpace(id))
|
||||
.Select(id => id.Trim())
|
||||
.Distinct(StringComparer.OrdinalIgnoreCase)
|
||||
.ToArray() ?? Array.Empty<string>(),
|
||||
Notes = c.Notes,
|
||||
})
|
||||
.Where(c => c.ExpectedIds.Count > 0)
|
||||
.ToList();
|
||||
|
||||
if (normalized.Count > 0)
|
||||
{
|
||||
return normalized;
|
||||
}
|
||||
}
|
||||
|
||||
return GenerateFallbackCases(entries);
|
||||
}
|
||||
|
||||
private static DatasetDiagnostics BuildDiagnostics(IReadOnlyList<SettingEntry> entries)
|
||||
{
|
||||
var duplicateBuckets = entries
|
||||
.GroupBy(x => x.Id, StringComparer.OrdinalIgnoreCase)
|
||||
.Where(group => group.Count() > 1)
|
||||
.OrderByDescending(group => group.Count())
|
||||
.ToDictionary(group => group.Key, group => group.Count(), StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
return new DatasetDiagnostics
|
||||
{
|
||||
TotalEntries = entries.Count,
|
||||
DistinctIds = entries.Select(x => x.Id).Distinct(StringComparer.OrdinalIgnoreCase).Count(),
|
||||
DuplicateIdBucketCount = duplicateBuckets.Count,
|
||||
DuplicateIdCounts = new ReadOnlyDictionary<string, int>(duplicateBuckets),
|
||||
};
|
||||
}
|
||||
|
||||
private static IReadOnlyList<EvaluationCase> GenerateFallbackCases(IReadOnlyList<SettingEntry> entries)
|
||||
{
|
||||
return entries
|
||||
.Where(entry => !string.IsNullOrWhiteSpace(entry.Header) && !string.IsNullOrWhiteSpace(entry.Id))
|
||||
.GroupBy(entry => entry.Id, StringComparer.OrdinalIgnoreCase)
|
||||
.Select(group => group.First())
|
||||
.Take(40)
|
||||
.Select(entry => new EvaluationCase
|
||||
{
|
||||
Query = entry.Header,
|
||||
ExpectedIds = new[] { entry.Id },
|
||||
Notes = "Autogenerated case from index entry header.",
|
||||
})
|
||||
.ToArray();
|
||||
}
|
||||
|
||||
private static string BuildFallbackHeader(string elementUid, string elementName, string pageTypeName)
|
||||
{
|
||||
var candidate = !string.IsNullOrWhiteSpace(elementUid)
|
||||
? elementUid
|
||||
: (!string.IsNullOrWhiteSpace(elementName) ? elementName : pageTypeName);
|
||||
|
||||
candidate = candidate.Replace('_', ' ').Trim();
|
||||
candidate = ConsecutiveWhitespaceRegex().Replace(candidate, " ");
|
||||
candidate = CamelBoundaryRegex().Replace(candidate, "$1 $2");
|
||||
return candidate;
|
||||
}
|
||||
|
||||
[GeneratedRegex(@"\s+")]
|
||||
private static partial Regex ConsecutiveWhitespaceRegex();
|
||||
|
||||
[GeneratedRegex("([a-z0-9])([A-Z])")]
|
||||
private static partial Regex CamelBoundaryRegex();
|
||||
|
||||
private sealed class RawSettingEntry
|
||||
{
|
||||
public EntryType Type { get; init; }
|
||||
|
||||
public string? Header { get; init; }
|
||||
|
||||
public string? PageTypeName { get; init; }
|
||||
|
||||
public string? ElementName { get; init; }
|
||||
|
||||
public string? ElementUid { get; init; }
|
||||
|
||||
public string? ParentElementName { get; init; }
|
||||
|
||||
public string? Description { get; init; }
|
||||
|
||||
public string? Icon { get; init; }
|
||||
}
|
||||
|
||||
private sealed class RawEvaluationCase
|
||||
{
|
||||
public string? Query { get; init; }
|
||||
|
||||
public List<string>? ExpectedIds { get; init; }
|
||||
|
||||
public string? Notes { get; init; }
|
||||
}
|
||||
}
|
||||
65
tools/SettingsSearchEvaluation/EvaluationMath.cs
Normal file
65
tools/SettingsSearchEvaluation/EvaluationMath.cs
Normal file
@@ -0,0 +1,65 @@
|
||||
// Copyright (c) Microsoft Corporation
|
||||
// The Microsoft Corporation licenses this file to you under the MIT license.
|
||||
// See the LICENSE file in the project root for more information.
|
||||
|
||||
namespace SettingsSearchEvaluation;
|
||||
|
||||
internal static class EvaluationMath
|
||||
{
|
||||
public static int FindBestRank(IReadOnlyList<string> rankedResultIds, IReadOnlySet<string> expectedIds)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(rankedResultIds);
|
||||
ArgumentNullException.ThrowIfNull(expectedIds);
|
||||
|
||||
if (expectedIds.Count == 0 || rankedResultIds.Count == 0)
|
||||
{
|
||||
return 0;
|
||||
}
|
||||
|
||||
for (int index = 0; index < rankedResultIds.Count; index++)
|
||||
{
|
||||
if (expectedIds.Contains(rankedResultIds[index]))
|
||||
{
|
||||
return index + 1;
|
||||
}
|
||||
}
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
public static LatencySummary ComputeLatencySummary(IReadOnlyList<double> samplesMs)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(samplesMs);
|
||||
|
||||
if (samplesMs.Count == 0)
|
||||
{
|
||||
return LatencySummary.Empty;
|
||||
}
|
||||
|
||||
var sorted = samplesMs.OrderBy(x => x).ToArray();
|
||||
var total = samplesMs.Sum();
|
||||
|
||||
return new LatencySummary
|
||||
{
|
||||
Samples = sorted.Length,
|
||||
MinMs = sorted[0],
|
||||
P50Ms = Percentile(sorted, 0.50),
|
||||
P95Ms = Percentile(sorted, 0.95),
|
||||
MaxMs = sorted[^1],
|
||||
AverageMs = total / sorted.Length,
|
||||
};
|
||||
}
|
||||
|
||||
private static double Percentile(IReadOnlyList<double> sortedSamples, double percentile)
|
||||
{
|
||||
if (sortedSamples.Count == 0)
|
||||
{
|
||||
return 0;
|
||||
}
|
||||
|
||||
var clamped = Math.Clamp(percentile, 0, 1);
|
||||
var rank = (int)Math.Ceiling(clamped * sortedSamples.Count) - 1;
|
||||
rank = Math.Clamp(rank, 0, sortedSamples.Count - 1);
|
||||
return sortedSamples[rank];
|
||||
}
|
||||
}
|
||||
18
tools/SettingsSearchEvaluation/EvaluationReport.cs
Normal file
18
tools/SettingsSearchEvaluation/EvaluationReport.cs
Normal file
@@ -0,0 +1,18 @@
|
||||
// Copyright (c) Microsoft Corporation
|
||||
// The Microsoft Corporation licenses this file to you under the MIT license.
|
||||
// See the LICENSE file in the project root for more information.
|
||||
|
||||
namespace SettingsSearchEvaluation;
|
||||
|
||||
internal sealed class EvaluationReport
|
||||
{
|
||||
public required DateTimeOffset GeneratedAtUtc { get; init; }
|
||||
|
||||
public required string IndexJsonPath { get; init; }
|
||||
|
||||
public required DatasetDiagnostics Dataset { get; init; }
|
||||
|
||||
public required int CaseCount { get; init; }
|
||||
|
||||
public required IReadOnlyList<EngineEvaluationReport> Engines { get; init; }
|
||||
}
|
||||
292
tools/SettingsSearchEvaluation/Evaluator.cs
Normal file
292
tools/SettingsSearchEvaluation/Evaluator.cs
Normal file
@@ -0,0 +1,292 @@
|
||||
// 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.Diagnostics;
|
||||
using Common.Search;
|
||||
using Common.Search.FuzzSearch;
|
||||
using Common.Search.SemanticSearch;
|
||||
using Settings.UI.Library;
|
||||
|
||||
namespace SettingsSearchEvaluation;
|
||||
|
||||
internal static class Evaluator
|
||||
{
|
||||
public static async Task<EvaluationReport> RunAsync(
|
||||
RunnerOptions options,
|
||||
IReadOnlyList<SettingEntry> entries,
|
||||
DatasetDiagnostics dataset,
|
||||
IReadOnlyList<EvaluationCase> cases,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(options);
|
||||
ArgumentNullException.ThrowIfNull(entries);
|
||||
ArgumentNullException.ThrowIfNull(dataset);
|
||||
ArgumentNullException.ThrowIfNull(cases);
|
||||
|
||||
var reports = new List<EngineEvaluationReport>(options.Engines.Count);
|
||||
foreach (var engine in options.Engines)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
reports.Add(engine switch
|
||||
{
|
||||
SearchEngineKind.Basic => await EvaluateBasicAsync(options, entries, cases, cancellationToken),
|
||||
SearchEngineKind.Semantic => await EvaluateSemanticAsync(options, entries, cases, cancellationToken),
|
||||
_ => throw new InvalidOperationException($"Unsupported engine '{engine}'."),
|
||||
});
|
||||
}
|
||||
|
||||
return new EvaluationReport
|
||||
{
|
||||
GeneratedAtUtc = DateTimeOffset.UtcNow,
|
||||
IndexJsonPath = options.IndexJsonPath,
|
||||
Dataset = dataset,
|
||||
CaseCount = cases.Count,
|
||||
Engines = reports,
|
||||
};
|
||||
}
|
||||
|
||||
private static async Task<EngineEvaluationReport> EvaluateBasicAsync(
|
||||
RunnerOptions options,
|
||||
IReadOnlyList<SettingEntry> entries,
|
||||
IReadOnlyList<EvaluationCase> cases,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
using var engine = new FuzzSearchEngine<SettingEntry>();
|
||||
|
||||
var indexingStopwatch = Stopwatch.StartNew();
|
||||
await engine.InitializeAsync(cancellationToken);
|
||||
await engine.IndexBatchAsync(entries, cancellationToken);
|
||||
indexingStopwatch.Stop();
|
||||
|
||||
var metrics = await EvaluateQueryLoopAsync(
|
||||
cases,
|
||||
options,
|
||||
(query, searchOptions, token) => engine.SearchAsync(query, searchOptions, token),
|
||||
cancellationToken);
|
||||
|
||||
return new EngineEvaluationReport
|
||||
{
|
||||
Engine = SearchEngineKind.Basic,
|
||||
IsAvailable = true,
|
||||
AvailabilityError = null,
|
||||
CapabilitiesSummary = "Fuzzy text search engine",
|
||||
IndexedEntries = entries.Count,
|
||||
QueryCount = cases.Count,
|
||||
IndexingTimeMs = indexingStopwatch.Elapsed.TotalMilliseconds,
|
||||
RecallAtK = metrics.RecallAtK,
|
||||
Mrr = metrics.Mrr,
|
||||
SearchLatencyMs = metrics.Latency,
|
||||
CaseResults = metrics.CaseResults,
|
||||
};
|
||||
}
|
||||
|
||||
private static async Task<EngineEvaluationReport> EvaluateSemanticAsync(
|
||||
RunnerOptions options,
|
||||
IReadOnlyList<SettingEntry> entries,
|
||||
IReadOnlyList<EvaluationCase> cases,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var indexName = $"PowerToys.Settings.Eval.{Environment.ProcessId}.{Guid.NewGuid():N}";
|
||||
using var engine = new SemanticSearchEngine<SettingEntry>(indexName);
|
||||
|
||||
var initResult = await engine.InitializeWithResultAsync(cancellationToken);
|
||||
if (initResult.IsFailure || !engine.IsReady)
|
||||
{
|
||||
return new EngineEvaluationReport
|
||||
{
|
||||
Engine = SearchEngineKind.Semantic,
|
||||
IsAvailable = false,
|
||||
AvailabilityError = FormatError(initResult.Error) ?? "Semantic engine is not ready.",
|
||||
CapabilitiesSummary = null,
|
||||
IndexedEntries = 0,
|
||||
QueryCount = 0,
|
||||
IndexingTimeMs = 0,
|
||||
RecallAtK = 0,
|
||||
Mrr = 0,
|
||||
SearchLatencyMs = LatencySummary.Empty,
|
||||
CaseResults = Array.Empty<QueryEvaluationResult>(),
|
||||
};
|
||||
}
|
||||
|
||||
var indexingStopwatch = Stopwatch.StartNew();
|
||||
var indexResult = await engine.IndexBatchWithResultAsync(entries, cancellationToken);
|
||||
if (indexResult.IsFailure)
|
||||
{
|
||||
return new EngineEvaluationReport
|
||||
{
|
||||
Engine = SearchEngineKind.Semantic,
|
||||
IsAvailable = false,
|
||||
AvailabilityError = FormatError(indexResult.Error) ?? "Semantic indexing failed.",
|
||||
CapabilitiesSummary = BuildCapabilitiesSummary(engine.SemanticCapabilities),
|
||||
IndexedEntries = 0,
|
||||
QueryCount = 0,
|
||||
IndexingTimeMs = indexingStopwatch.Elapsed.TotalMilliseconds,
|
||||
RecallAtK = 0,
|
||||
Mrr = 0,
|
||||
SearchLatencyMs = LatencySummary.Empty,
|
||||
CaseResults = Array.Empty<QueryEvaluationResult>(),
|
||||
};
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
await engine.WaitForIndexingCompleteAsync(options.SemanticIndexTimeout);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return new EngineEvaluationReport
|
||||
{
|
||||
Engine = SearchEngineKind.Semantic,
|
||||
IsAvailable = false,
|
||||
AvailabilityError = $"Semantic indexing did not become idle: {ex.Message}",
|
||||
CapabilitiesSummary = BuildCapabilitiesSummary(engine.SemanticCapabilities),
|
||||
IndexedEntries = 0,
|
||||
QueryCount = 0,
|
||||
IndexingTimeMs = indexingStopwatch.Elapsed.TotalMilliseconds,
|
||||
RecallAtK = 0,
|
||||
Mrr = 0,
|
||||
SearchLatencyMs = LatencySummary.Empty,
|
||||
CaseResults = Array.Empty<QueryEvaluationResult>(),
|
||||
};
|
||||
}
|
||||
|
||||
indexingStopwatch.Stop();
|
||||
var metrics = await EvaluateQueryLoopAsync(
|
||||
cases,
|
||||
options,
|
||||
async (query, searchOptions, token) =>
|
||||
{
|
||||
var result = await engine.SearchWithResultAsync(query, searchOptions, token);
|
||||
return result.Value ?? Array.Empty<SearchResult<SettingEntry>>();
|
||||
},
|
||||
cancellationToken);
|
||||
|
||||
return new EngineEvaluationReport
|
||||
{
|
||||
Engine = SearchEngineKind.Semantic,
|
||||
IsAvailable = true,
|
||||
AvailabilityError = null,
|
||||
CapabilitiesSummary = BuildCapabilitiesSummary(engine.SemanticCapabilities),
|
||||
IndexedEntries = entries.Count,
|
||||
QueryCount = cases.Count,
|
||||
IndexingTimeMs = indexingStopwatch.Elapsed.TotalMilliseconds,
|
||||
RecallAtK = metrics.RecallAtK,
|
||||
Mrr = metrics.Mrr,
|
||||
SearchLatencyMs = metrics.Latency,
|
||||
CaseResults = metrics.CaseResults,
|
||||
};
|
||||
}
|
||||
|
||||
private static async Task<QueryRunMetrics> EvaluateQueryLoopAsync(
|
||||
IReadOnlyList<EvaluationCase> cases,
|
||||
RunnerOptions options,
|
||||
Func<string, SearchOptions, CancellationToken, Task<IReadOnlyList<SearchResult<SettingEntry>>>> searchAsync,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var caseResults = new List<QueryEvaluationResult>(cases.Count);
|
||||
var latencySamples = new List<double>(Math.Max(1, cases.Count * options.Iterations));
|
||||
|
||||
var hits = 0;
|
||||
var reciprocalRankSum = 0.0;
|
||||
var searchOptions = new SearchOptions
|
||||
{
|
||||
MaxResults = options.MaxResults,
|
||||
IncludeMatchSpans = false,
|
||||
};
|
||||
|
||||
foreach (var queryCase in cases)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
|
||||
for (int warmup = 0; warmup < options.WarmupIterations; warmup++)
|
||||
{
|
||||
_ = await searchAsync(queryCase.Query, searchOptions, cancellationToken);
|
||||
}
|
||||
|
||||
IReadOnlyList<SearchResult<SettingEntry>> firstMeasuredResult = Array.Empty<SearchResult<SettingEntry>>();
|
||||
for (int iteration = 0; iteration < options.Iterations; iteration++)
|
||||
{
|
||||
var sw = Stopwatch.StartNew();
|
||||
var queryResult = await searchAsync(queryCase.Query, searchOptions, cancellationToken);
|
||||
sw.Stop();
|
||||
latencySamples.Add(sw.Elapsed.TotalMilliseconds);
|
||||
|
||||
if (iteration == 0)
|
||||
{
|
||||
firstMeasuredResult = queryResult;
|
||||
}
|
||||
}
|
||||
|
||||
var rankedIds = firstMeasuredResult.Select(result => result.Item.Id).ToArray();
|
||||
var expected = new HashSet<string>(queryCase.ExpectedIds, StringComparer.OrdinalIgnoreCase);
|
||||
var bestRank = EvaluationMath.FindBestRank(rankedIds, expected);
|
||||
var hit = bestRank > 0 && bestRank <= options.TopK;
|
||||
|
||||
if (hit)
|
||||
{
|
||||
hits++;
|
||||
}
|
||||
|
||||
if (bestRank > 0)
|
||||
{
|
||||
reciprocalRankSum += 1.0 / bestRank;
|
||||
}
|
||||
|
||||
caseResults.Add(new QueryEvaluationResult
|
||||
{
|
||||
Query = queryCase.Query,
|
||||
ExpectedIds = queryCase.ExpectedIds,
|
||||
TopResultIds = rankedIds.Take(options.TopK).ToArray(),
|
||||
BestRank = bestRank,
|
||||
HitAtK = hit,
|
||||
Notes = queryCase.Notes,
|
||||
});
|
||||
}
|
||||
|
||||
var totalCases = Math.Max(1, cases.Count);
|
||||
return new QueryRunMetrics
|
||||
{
|
||||
CaseResults = caseResults,
|
||||
RecallAtK = hits / (double)totalCases,
|
||||
Mrr = reciprocalRankSum / totalCases,
|
||||
Latency = EvaluationMath.ComputeLatencySummary(latencySamples),
|
||||
};
|
||||
}
|
||||
|
||||
private static string? FormatError(SearchError? error)
|
||||
{
|
||||
if (error == null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(error.Details))
|
||||
{
|
||||
return $"{error.Message} ({error.Details})";
|
||||
}
|
||||
|
||||
return error.Message;
|
||||
}
|
||||
|
||||
private static string BuildCapabilitiesSummary(SemanticSearchCapabilities? capabilities)
|
||||
{
|
||||
if (capabilities == null)
|
||||
{
|
||||
return "Capabilities unavailable";
|
||||
}
|
||||
|
||||
return $"TextLexical={capabilities.TextLexicalAvailable}, TextSemantic={capabilities.TextSemanticAvailable}, ImageSemantic={capabilities.ImageSemanticAvailable}, ImageOcr={capabilities.ImageOcrAvailable}";
|
||||
}
|
||||
|
||||
private sealed class QueryRunMetrics
|
||||
{
|
||||
public required IReadOnlyList<QueryEvaluationResult> CaseResults { get; init; }
|
||||
|
||||
public required double RecallAtK { get; init; }
|
||||
|
||||
public required double Mrr { get; init; }
|
||||
|
||||
public required LatencySummary Latency { get; init; }
|
||||
}
|
||||
}
|
||||
22
tools/SettingsSearchEvaluation/LatencySummary.cs
Normal file
22
tools/SettingsSearchEvaluation/LatencySummary.cs
Normal file
@@ -0,0 +1,22 @@
|
||||
// Copyright (c) Microsoft Corporation
|
||||
// The Microsoft Corporation licenses this file to you under the MIT license.
|
||||
// See the LICENSE file in the project root for more information.
|
||||
|
||||
namespace SettingsSearchEvaluation;
|
||||
|
||||
internal sealed class LatencySummary
|
||||
{
|
||||
public int Samples { get; init; }
|
||||
|
||||
public double MinMs { get; init; }
|
||||
|
||||
public double P50Ms { get; init; }
|
||||
|
||||
public double P95Ms { get; init; }
|
||||
|
||||
public double MaxMs { get; init; }
|
||||
|
||||
public double AverageMs { get; init; }
|
||||
|
||||
public static LatencySummary Empty { get; } = new();
|
||||
}
|
||||
361
tools/SettingsSearchEvaluation/Program.cs
Normal file
361
tools/SettingsSearchEvaluation/Program.cs
Normal file
@@ -0,0 +1,361 @@
|
||||
// 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.Text.Json;
|
||||
|
||||
namespace SettingsSearchEvaluation;
|
||||
|
||||
internal static class Program
|
||||
{
|
||||
private static readonly JsonSerializerOptions OutputJsonOptions = new()
|
||||
{
|
||||
WriteIndented = true,
|
||||
};
|
||||
|
||||
private static int Main(string[] args)
|
||||
{
|
||||
try
|
||||
{
|
||||
return MainAsync(args).GetAwaiter().GetResult();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.Error.WriteLine($"Unhandled error: {ex.Message}");
|
||||
return 99;
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task<int> MainAsync(string[] args)
|
||||
{
|
||||
if (args.Any(arg => string.Equals(arg, "--help", StringComparison.OrdinalIgnoreCase) || string.Equals(arg, "-h", StringComparison.OrdinalIgnoreCase)))
|
||||
{
|
||||
PrintUsage();
|
||||
return 0;
|
||||
}
|
||||
|
||||
if (!TryParseArgs(args, out var options, out var parseError))
|
||||
{
|
||||
Console.Error.WriteLine(parseError);
|
||||
Console.Error.WriteLine();
|
||||
PrintUsage();
|
||||
return 2;
|
||||
}
|
||||
|
||||
if (!File.Exists(options.IndexJsonPath))
|
||||
{
|
||||
Console.Error.WriteLine($"Index file not found: {options.IndexJsonPath}");
|
||||
return 3;
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(options.CasesJsonPath) && !File.Exists(options.CasesJsonPath))
|
||||
{
|
||||
Console.Error.WriteLine($"Cases file not found: {options.CasesJsonPath}");
|
||||
return 3;
|
||||
}
|
||||
|
||||
var (entries, dataset) = EvaluationDataLoader.LoadEntriesFromFile(options.IndexJsonPath);
|
||||
var cases = EvaluationDataLoader.LoadCases(options.CasesJsonPath, entries);
|
||||
if (cases.Count == 0)
|
||||
{
|
||||
Console.Error.WriteLine("No valid evaluation cases were found.");
|
||||
return 3;
|
||||
}
|
||||
|
||||
Console.WriteLine($"Loaded {entries.Count} entries from '{options.IndexJsonPath}'.");
|
||||
Console.WriteLine($"Cases: {cases.Count}");
|
||||
Console.WriteLine($"Duplicate id buckets: {dataset.DuplicateIdBucketCount}");
|
||||
if (dataset.DuplicateIdBucketCount > 0)
|
||||
{
|
||||
var largest = dataset.DuplicateIdCounts
|
||||
.OrderByDescending(x => x.Value)
|
||||
.Take(5)
|
||||
.Select(x => $"{x.Key} x{x.Value}");
|
||||
Console.WriteLine($"Top duplicate ids: {string.Join(", ", largest)}");
|
||||
}
|
||||
|
||||
var report = await Evaluator.RunAsync(options, entries, dataset, cases);
|
||||
PrintSummary(report, options.TopK);
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(options.OutputJsonPath))
|
||||
{
|
||||
var outputDirectory = Path.GetDirectoryName(options.OutputJsonPath);
|
||||
if (!string.IsNullOrWhiteSpace(outputDirectory))
|
||||
{
|
||||
Directory.CreateDirectory(outputDirectory);
|
||||
}
|
||||
|
||||
var json = JsonSerializer.Serialize(report, OutputJsonOptions);
|
||||
File.WriteAllText(options.OutputJsonPath, json);
|
||||
Console.WriteLine($"Wrote report to '{options.OutputJsonPath}'.");
|
||||
}
|
||||
|
||||
return report.Engines.Any(engine => engine.IsAvailable) ? 0 : 4;
|
||||
}
|
||||
|
||||
private static bool TryParseArgs(string[] args, out RunnerOptions options, out string error)
|
||||
{
|
||||
string defaultIndex = GetDefaultIndexPath();
|
||||
string? indexPath = null;
|
||||
string? casesPath = null;
|
||||
string? outputPath = null;
|
||||
var maxResults = 10;
|
||||
var topK = 5;
|
||||
var iterations = 5;
|
||||
var warmup = 1;
|
||||
var semanticTimeoutMs = 15000;
|
||||
IReadOnlyList<SearchEngineKind> engines = new[] { SearchEngineKind.Basic, SearchEngineKind.Semantic };
|
||||
|
||||
for (int i = 0; i < args.Length; i++)
|
||||
{
|
||||
var arg = args[i];
|
||||
switch (arg.ToLowerInvariant())
|
||||
{
|
||||
case "--index-json":
|
||||
if (!TryReadValue(args, ref i, out indexPath))
|
||||
{
|
||||
options = null!;
|
||||
error = "Missing value for --index-json";
|
||||
return false;
|
||||
}
|
||||
|
||||
break;
|
||||
case "--cases-json":
|
||||
if (!TryReadValue(args, ref i, out casesPath))
|
||||
{
|
||||
options = null!;
|
||||
error = "Missing value for --cases-json";
|
||||
return false;
|
||||
}
|
||||
|
||||
break;
|
||||
case "--output-json":
|
||||
if (!TryReadValue(args, ref i, out outputPath))
|
||||
{
|
||||
options = null!;
|
||||
error = "Missing value for --output-json";
|
||||
return false;
|
||||
}
|
||||
|
||||
break;
|
||||
case "--engine":
|
||||
if (!TryReadValue(args, ref i, out var engineText))
|
||||
{
|
||||
options = null!;
|
||||
error = "Missing value for --engine";
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!TryParseEngines(engineText!, out engines))
|
||||
{
|
||||
options = null!;
|
||||
error = "Invalid --engine value. Allowed values: basic, semantic, both.";
|
||||
return false;
|
||||
}
|
||||
|
||||
break;
|
||||
case "--max-results":
|
||||
if (!TryReadInt(args, ref i, out maxResults) || maxResults <= 0)
|
||||
{
|
||||
options = null!;
|
||||
error = "Invalid --max-results value. Must be a positive integer.";
|
||||
return false;
|
||||
}
|
||||
|
||||
break;
|
||||
case "--top-k":
|
||||
if (!TryReadInt(args, ref i, out topK) || topK <= 0)
|
||||
{
|
||||
options = null!;
|
||||
error = "Invalid --top-k value. Must be a positive integer.";
|
||||
return false;
|
||||
}
|
||||
|
||||
break;
|
||||
case "--iterations":
|
||||
if (!TryReadInt(args, ref i, out iterations) || iterations <= 0)
|
||||
{
|
||||
options = null!;
|
||||
error = "Invalid --iterations value. Must be a positive integer.";
|
||||
return false;
|
||||
}
|
||||
|
||||
break;
|
||||
case "--warmup":
|
||||
if (!TryReadInt(args, ref i, out warmup) || warmup < 0)
|
||||
{
|
||||
options = null!;
|
||||
error = "Invalid --warmup value. Must be a non-negative integer.";
|
||||
return false;
|
||||
}
|
||||
|
||||
break;
|
||||
case "--semantic-timeout-ms":
|
||||
if (!TryReadInt(args, ref i, out semanticTimeoutMs) || semanticTimeoutMs <= 0)
|
||||
{
|
||||
options = null!;
|
||||
error = "Invalid --semantic-timeout-ms value. Must be a positive integer.";
|
||||
return false;
|
||||
}
|
||||
|
||||
break;
|
||||
default:
|
||||
options = null!;
|
||||
error = $"Unknown argument: {arg}";
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
options = new RunnerOptions
|
||||
{
|
||||
IndexJsonPath = Path.GetFullPath(indexPath ?? defaultIndex),
|
||||
CasesJsonPath = string.IsNullOrWhiteSpace(casesPath) ? null : Path.GetFullPath(casesPath),
|
||||
Engines = engines,
|
||||
MaxResults = maxResults,
|
||||
TopK = topK,
|
||||
Iterations = iterations,
|
||||
WarmupIterations = warmup,
|
||||
SemanticIndexTimeout = TimeSpan.FromMilliseconds(semanticTimeoutMs),
|
||||
OutputJsonPath = string.IsNullOrWhiteSpace(outputPath) ? null : Path.GetFullPath(outputPath),
|
||||
};
|
||||
error = string.Empty;
|
||||
return true;
|
||||
}
|
||||
|
||||
private static string GetDefaultIndexPath()
|
||||
{
|
||||
var repoRoot = FindRepoRoot(AppContext.BaseDirectory) ?? Environment.CurrentDirectory;
|
||||
return Path.GetFullPath(Path.Combine(repoRoot, "src", "settings-ui", "Settings.UI", "Assets", "Settings", "search.index.json"));
|
||||
}
|
||||
|
||||
private static string? FindRepoRoot(string startingDirectory)
|
||||
{
|
||||
var current = new DirectoryInfo(startingDirectory);
|
||||
while (current != null)
|
||||
{
|
||||
var markerPath = Path.Combine(current.FullName, "PowerToys.slnx");
|
||||
if (File.Exists(markerPath))
|
||||
{
|
||||
return current.FullName;
|
||||
}
|
||||
|
||||
current = current.Parent;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private static bool TryParseEngines(string value, out IReadOnlyList<SearchEngineKind> engines)
|
||||
{
|
||||
if (string.Equals(value, "both", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
engines = new[] { SearchEngineKind.Basic, SearchEngineKind.Semantic };
|
||||
return true;
|
||||
}
|
||||
|
||||
if (string.Equals(value, "basic", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
engines = new[] { SearchEngineKind.Basic };
|
||||
return true;
|
||||
}
|
||||
|
||||
if (string.Equals(value, "semantic", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
engines = new[] { SearchEngineKind.Semantic };
|
||||
return true;
|
||||
}
|
||||
|
||||
engines = Array.Empty<SearchEngineKind>();
|
||||
return false;
|
||||
}
|
||||
|
||||
private static bool TryReadValue(string[] args, ref int index, out string? value)
|
||||
{
|
||||
if (index + 1 >= args.Length)
|
||||
{
|
||||
value = null;
|
||||
return false;
|
||||
}
|
||||
|
||||
index++;
|
||||
value = args[index];
|
||||
return true;
|
||||
}
|
||||
|
||||
private static bool TryReadInt(string[] args, ref int index, out int value)
|
||||
{
|
||||
value = 0;
|
||||
if (!TryReadValue(args, ref index, out var text))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
return int.TryParse(text, out value);
|
||||
}
|
||||
|
||||
private static void PrintSummary(EvaluationReport report, int topK)
|
||||
{
|
||||
Console.WriteLine();
|
||||
Console.WriteLine("=== Evaluation Summary ===");
|
||||
Console.WriteLine($"Generated: {report.GeneratedAtUtc:O}");
|
||||
Console.WriteLine($"Dataset entries: {report.Dataset.TotalEntries} ({report.Dataset.DistinctIds} distinct ids)");
|
||||
Console.WriteLine($"Cases: {report.CaseCount}");
|
||||
Console.WriteLine();
|
||||
|
||||
foreach (var engine in report.Engines)
|
||||
{
|
||||
Console.WriteLine($"[{engine.Engine}]");
|
||||
if (!engine.IsAvailable)
|
||||
{
|
||||
Console.WriteLine($" Unavailable: {engine.AvailabilityError}");
|
||||
Console.WriteLine();
|
||||
continue;
|
||||
}
|
||||
|
||||
Console.WriteLine($" Capabilities: {engine.CapabilitiesSummary}");
|
||||
Console.WriteLine($" Indexed entries: {engine.IndexedEntries}");
|
||||
Console.WriteLine($" Indexing time (ms): {engine.IndexingTimeMs:F2}");
|
||||
Console.WriteLine($" Recall@{topK}: {engine.RecallAtK:F4}");
|
||||
Console.WriteLine($" MRR: {engine.Mrr:F4}");
|
||||
Console.WriteLine($" Search latency ms (avg/p50/p95/max): {engine.SearchLatencyMs.AverageMs:F2}/{engine.SearchLatencyMs.P50Ms:F2}/{engine.SearchLatencyMs.P95Ms:F2}/{engine.SearchLatencyMs.MaxMs:F2}");
|
||||
|
||||
var misses = engine.CaseResults
|
||||
.Where(result => !result.HitAtK)
|
||||
.Take(3)
|
||||
.ToList();
|
||||
|
||||
if (misses.Count > 0)
|
||||
{
|
||||
Console.WriteLine(" Sample misses:");
|
||||
foreach (var miss in misses)
|
||||
{
|
||||
var top = miss.TopResultIds.Count == 0 ? "(none)" : string.Join(", ", miss.TopResultIds);
|
||||
Console.WriteLine($" Query='{miss.Query}', expected='{string.Join("|", miss.ExpectedIds)}', top='{top}'");
|
||||
}
|
||||
}
|
||||
|
||||
Console.WriteLine();
|
||||
}
|
||||
}
|
||||
|
||||
private static void PrintUsage()
|
||||
{
|
||||
Console.WriteLine("SettingsSearchEvaluation");
|
||||
Console.WriteLine("Evaluates basic and semantic settings search for recall and performance.");
|
||||
Console.WriteLine();
|
||||
Console.WriteLine("Usage:");
|
||||
Console.WriteLine(" SettingsSearchEvaluation [options]");
|
||||
Console.WriteLine();
|
||||
Console.WriteLine("Options:");
|
||||
Console.WriteLine(" --index-json <path> Path to settings search index JSON.");
|
||||
Console.WriteLine(" --cases-json <path> Optional path to evaluation cases JSON.");
|
||||
Console.WriteLine(" --engine <basic|semantic|both> Engine selection. Default: both.");
|
||||
Console.WriteLine(" --max-results <n> Maximum returned results per query. Default: 10.");
|
||||
Console.WriteLine(" --top-k <n> Recall cut-off K. Default: 5.");
|
||||
Console.WriteLine(" --iterations <n> Measured runs per query. Default: 5.");
|
||||
Console.WriteLine(" --warmup <n> Warmup runs per query. Default: 1.");
|
||||
Console.WriteLine(" --semantic-timeout-ms <n> Semantic index idle wait timeout in ms. Default: 15000.");
|
||||
Console.WriteLine(" --output-json <path> Optional output report file.");
|
||||
Console.WriteLine(" --help Show this help.");
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user