mirror of
https://github.com/microsoft/PowerToys.git
synced 2026-07-02 00:19:16 +02:00
Compare commits
26 Commits
dependabot
...
dev/migrie
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d0946ca3ac | ||
|
|
df2c146b30 | ||
|
|
b893d633d9 | ||
|
|
8026df4d16 | ||
|
|
eb4792f942 | ||
|
|
a7bc09a87a | ||
|
|
6e9b3b1536 | ||
|
|
8352afbb65 | ||
|
|
242ec2020c | ||
|
|
2927ffa8b7 | ||
|
|
f492cda5d5 | ||
|
|
e2248a6e1e | ||
|
|
20306ed599 | ||
|
|
d973bcbcaa | ||
|
|
02fbb916a7 | ||
|
|
a2be31e5f0 | ||
|
|
ca627134b3 | ||
|
|
4b8cbde9e6 | ||
|
|
0728481923 | ||
|
|
158e2f8d8a | ||
|
|
380489122a | ||
|
|
869a1f0560 | ||
|
|
3185267804 | ||
|
|
d2ceeccc6e | ||
|
|
ee014d06b8 | ||
|
|
2a2a6cc9f5 |
1
.github/actions/spell-check/expect.txt
vendored
1
.github/actions/spell-check/expect.txt
vendored
@@ -631,6 +631,7 @@ gpu
|
||||
grabandmove
|
||||
GRABANDMOVEMODULEINTERFACE
|
||||
gradians
|
||||
GRC
|
||||
grctlext
|
||||
GRGX
|
||||
Gridcustomlayout
|
||||
|
||||
7
.github/actions/spell-check/patterns.txt
vendored
7
.github/actions/spell-check/patterns.txt
vendored
@@ -18,6 +18,13 @@ MIcrosoftEdgeLauncherCsharp
|
||||
# marker for ignoring a comment to the end of the line
|
||||
// #no-spell-check.*$
|
||||
|
||||
# JavaScript regex literals that start with \b can be reported as "b..." words.
|
||||
# Example: /\bclass\s+.../
|
||||
^\s*/\\[b].{3,}?/[gim]*\s*(?:\)(?:;|$)|,$)
|
||||
|
||||
# GitHub API header token used in code (not natural language).
|
||||
\bx-ratelimit-reset\b
|
||||
|
||||
# Gaelic
|
||||
Gàidhlig
|
||||
|
||||
|
||||
324
.github/scripts/telemetry-pr-check.js
vendored
Normal file
324
.github/scripts/telemetry-pr-check.js
vendored
Normal file
@@ -0,0 +1,324 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
/**
|
||||
* Detects telemetry-event additions/modifications in a pull request and
|
||||
* posts (or updates) a PR comment when telemetry-related changes are found.
|
||||
*
|
||||
* This script is executed by .github/workflows/telemetry-pr-check.yml.
|
||||
* Keep both files aligned when changing trigger behavior, env usage, or messaging.
|
||||
*/
|
||||
|
||||
const fs = require('node:fs');
|
||||
|
||||
const COMMENT_MARKER = '<!-- telemetry-event-check -->';
|
||||
const COMMENT_BODY_WITH_PRIVACY_UPDATE = `${COMMENT_MARKER}
|
||||
THIS IS A TEST | @chatasweetie is testing this functionality
|
||||
Thanks for contributing to PowerToys. This change might include a new or modified telemetry event, and we want to help make sure you can get your data end to end.
|
||||
|
||||
1. Reach out to Jessica (@chatasweetie) to follow up on the next steps to add these telemetry events to our pipelines.`;
|
||||
|
||||
const COMMENT_BODY_WITHOUT_PRIVACY_UPDATE = `${COMMENT_MARKER}
|
||||
THIS IS A TEST | @chatasweetie is testing this functionality
|
||||
Thanks for contributing to PowerToys. This change might include a new or modified telemetry event, and we want to help make sure you can get your data end to end.
|
||||
|
||||
1. Make sure to add your telemetry events to DATA_AND_PRIVACY.md.
|
||||
|
||||
2. Reach out to Jessica (@chatasweetie) to follow up on the next steps to add these telemetry events to our pipelines.`;
|
||||
|
||||
const TELEMETRY_PATH_PATTERNS = [
|
||||
/(^|\/)trace\.(h|hpp|cpp|cs)$/i,
|
||||
/(^|\/)telemetry\//i,
|
||||
/(^|\/)events\/.+event\.cs$/i,
|
||||
/^src\/common\/Telemetry\//i,
|
||||
/^src\/common\/ManagedTelemetry\//i,
|
||||
/^src\/runner\/trace\.(h|cpp)$/i,
|
||||
/^src\/settings-ui\/.+\/Telemetry\//i,
|
||||
];
|
||||
|
||||
const TELEMETRY_LINE_PATTERNS = [
|
||||
/TraceLoggingWriteWrapper\s*\(/,
|
||||
/\bTraceLoggingWrite\s*\(/,
|
||||
/\bTRACELOGGING_DEFINE_PROVIDER\b/,
|
||||
/\bTraceLoggingOptionProjectTelemetry\b/,
|
||||
/\bProjectTelemetryPrivacyDataTag\b/,
|
||||
/\bPROJECT_KEYWORD_MEASURE\b/,
|
||||
/\bRegisterProvider\s*\(/,
|
||||
/\bUnregisterProvider\s*\(/,
|
||||
/\bPowerToysTelemetry\.Log\.WriteEvent\s*\(/,
|
||||
/\bclass\s+\w+\s*:\s*EventBase\s*,\s*IEvent\b/,
|
||||
/\bclass\s+\w+\s*:\s*TelemetryBase\b/,
|
||||
/\bPartA_PrivTags\b/,
|
||||
/\[EventData\]/,
|
||||
/\bEventName\b/,
|
||||
];
|
||||
|
||||
function requireEnv(name) {
|
||||
const value = process.env[name];
|
||||
if (!value) {
|
||||
throw new Error(`Missing required environment variable: ${name}`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function validateRepository(repository) {
|
||||
if (!/^[^/]+\/[^/]+$/.test(repository)) {
|
||||
throw new Error(
|
||||
`GITHUB_REPOSITORY must be in owner/repo format, received: ${JSON.stringify(repository)}`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function readEventPayload(eventPath) {
|
||||
let raw;
|
||||
try {
|
||||
raw = fs.readFileSync(eventPath, 'utf8');
|
||||
} catch (error) {
|
||||
throw new Error(`Failed to read event payload at ${eventPath}: ${error.message}`);
|
||||
}
|
||||
|
||||
try {
|
||||
return JSON.parse(raw);
|
||||
} catch (error) {
|
||||
throw new Error(`Failed to parse JSON from ${eventPath}: ${error.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
function resolvePullNumber(event) {
|
||||
const fromPullRequest = event?.pull_request?.number;
|
||||
const fromWorkflowDispatch = event?.inputs?.pr_number;
|
||||
const rawPullNumber = fromPullRequest ?? fromWorkflowDispatch;
|
||||
|
||||
if (rawPullNumber === undefined || rawPullNumber === null || rawPullNumber === '') {
|
||||
throw new Error(
|
||||
'Unable to determine pull request number from event payload. Expected pull_request.number or inputs.pr_number.'
|
||||
);
|
||||
}
|
||||
|
||||
const pullNumber = Number.parseInt(String(rawPullNumber), 10);
|
||||
if (!Number.isInteger(pullNumber) || pullNumber <= 0) {
|
||||
throw new Error(`Invalid pull request number: ${JSON.stringify(rawPullNumber)}`);
|
||||
}
|
||||
|
||||
return pullNumber;
|
||||
}
|
||||
|
||||
function isTelemetryPath(filePath) {
|
||||
return TELEMETRY_PATH_PATTERNS.some((pattern) => pattern.test(filePath));
|
||||
}
|
||||
|
||||
function changedLinesFromPatch(patch) {
|
||||
if (!patch) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return patch
|
||||
.split('\n')
|
||||
.filter((line) => {
|
||||
if (line.startsWith('+++') || line.startsWith('---')) {
|
||||
return false;
|
||||
}
|
||||
return line.startsWith('+') || line.startsWith('-');
|
||||
})
|
||||
.map((line) => line.slice(1));
|
||||
}
|
||||
|
||||
function hasTelemetryLineSignal(lines) {
|
||||
return lines.some((line) => TELEMETRY_LINE_PATTERNS.some((pattern) => pattern.test(line)));
|
||||
}
|
||||
|
||||
async function apiRequest(url, method = 'GET', body) {
|
||||
const token = requireEnv('GITHUB_TOKEN');
|
||||
let response;
|
||||
try {
|
||||
response = await fetch(url, {
|
||||
method,
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
Accept: 'application/vnd.github+json',
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: body ? JSON.stringify(body) : undefined,
|
||||
});
|
||||
} catch (error) {
|
||||
throw new Error(`Network error during ${method} ${url}: ${error.message}`);
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
const text = await response.text();
|
||||
const rateLimitReset = response.headers.get('x-ratelimit-reset');
|
||||
const rateLimitHint =
|
||||
response.status === 403 && rateLimitReset
|
||||
? ` (rate limit reset at epoch ${rateLimitReset})`
|
||||
: '';
|
||||
throw new Error(`${method} ${url} failed (${response.status})${rateLimitHint}: ${text}`);
|
||||
}
|
||||
|
||||
if (response.status === 204) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
return await response.json();
|
||||
} catch (error) {
|
||||
throw new Error(`Failed to parse JSON response for ${method} ${url}: ${error.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
async function getAllPullFiles(apiBaseUrl, repository, pullNumber) {
|
||||
const files = [];
|
||||
let page = 1;
|
||||
|
||||
while (true) {
|
||||
const url = `${apiBaseUrl}/repos/${repository}/pulls/${pullNumber}/files?per_page=100&page=${page}`;
|
||||
const batch = await apiRequest(url);
|
||||
if (!Array.isArray(batch)) {
|
||||
throw new Error(`Unexpected response while listing PR files on page ${page}.`);
|
||||
}
|
||||
|
||||
if (batch.length === 0) {
|
||||
break;
|
||||
}
|
||||
|
||||
files.push(...batch);
|
||||
|
||||
if (batch.length < 100) {
|
||||
break;
|
||||
}
|
||||
|
||||
page += 1;
|
||||
}
|
||||
|
||||
return files;
|
||||
}
|
||||
|
||||
async function findExistingTelemetryComment(apiBaseUrl, repository, pullNumber) {
|
||||
let page = 1;
|
||||
|
||||
while (true) {
|
||||
const commentsUrl = `${apiBaseUrl}/repos/${repository}/issues/${pullNumber}/comments?per_page=100&page=${page}`;
|
||||
const comments = await apiRequest(commentsUrl);
|
||||
|
||||
if (!Array.isArray(comments)) {
|
||||
throw new Error(`Unexpected response while listing issue comments on page ${page}.`);
|
||||
}
|
||||
|
||||
const existing = comments.find(
|
||||
(comment) => typeof comment.body === 'string' && comment.body.includes(COMMENT_MARKER)
|
||||
);
|
||||
if (existing) {
|
||||
return existing;
|
||||
}
|
||||
|
||||
if (comments.length < 100) {
|
||||
return null;
|
||||
}
|
||||
|
||||
page += 1;
|
||||
}
|
||||
}
|
||||
|
||||
function detectTelemetryChanges(files) {
|
||||
const matches = [];
|
||||
|
||||
for (const file of files) {
|
||||
const filename = file.filename || '';
|
||||
const telemetryPath = isTelemetryPath(filename);
|
||||
const changedLines = changedLinesFromPatch(file.patch);
|
||||
const telemetryLineSignal = hasTelemetryLineSignal(changedLines);
|
||||
|
||||
// Some large diffs omit patch content. If the file path is telemetry-centric,
|
||||
// treat it as a telemetry modification to avoid false negatives.
|
||||
const patchUnavailable = !file.patch && telemetryPath;
|
||||
|
||||
if (telemetryPath || telemetryLineSignal || patchUnavailable) {
|
||||
matches.push({
|
||||
filename,
|
||||
telemetryPath,
|
||||
telemetryLineSignal,
|
||||
patchUnavailable,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return matches;
|
||||
}
|
||||
|
||||
function hasDataAndPrivacyChange(files) {
|
||||
return files.some((file) => {
|
||||
const filename = (file.filename || '').toLowerCase();
|
||||
return filename === 'data_and_privacy.md';
|
||||
});
|
||||
}
|
||||
|
||||
async function upsertPrComment(apiBaseUrl, repository, pullNumber, body) {
|
||||
const existing = await findExistingTelemetryComment(apiBaseUrl, repository, pullNumber);
|
||||
|
||||
if (existing) {
|
||||
const updateUrl = `${apiBaseUrl}/repos/${repository}/issues/comments/${existing.id}`;
|
||||
await apiRequest(updateUrl, 'PATCH', { body });
|
||||
console.log(`Updated existing telemetry comment (id: ${existing.id}).`);
|
||||
return;
|
||||
}
|
||||
|
||||
const createUrl = `${apiBaseUrl}/repos/${repository}/issues/${pullNumber}/comments`;
|
||||
await apiRequest(createUrl, 'POST', { body });
|
||||
console.log('Created telemetry comment on PR.');
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const eventPath = requireEnv('GITHUB_EVENT_PATH');
|
||||
const repository = requireEnv('GITHUB_REPOSITORY');
|
||||
const apiBaseUrl = process.env.GITHUB_API_URL || 'https://api.github.com';
|
||||
validateRepository(repository);
|
||||
|
||||
let parsedApiBaseUrl;
|
||||
try {
|
||||
parsedApiBaseUrl = new URL(apiBaseUrl);
|
||||
} catch {
|
||||
throw new Error(`Invalid GITHUB_API_URL: ${JSON.stringify(apiBaseUrl)}`);
|
||||
}
|
||||
|
||||
const event = readEventPayload(eventPath);
|
||||
const pullNumber = resolvePullNumber(event);
|
||||
|
||||
console.log(`Event name: ${process.env.GITHUB_EVENT_NAME || 'unknown'}`);
|
||||
console.log(`Repository: ${repository}`);
|
||||
console.log(`PR number: ${pullNumber}`);
|
||||
|
||||
const files = await getAllPullFiles(parsedApiBaseUrl.origin, repository, pullNumber);
|
||||
|
||||
if (files.length === 0) {
|
||||
console.log('No changed files found for PR; skipping telemetry comment update.');
|
||||
return;
|
||||
}
|
||||
|
||||
const matches = detectTelemetryChanges(files);
|
||||
const dataAndPrivacyChanged = hasDataAndPrivacyChange(files);
|
||||
|
||||
console.log(`Scanned ${files.length} changed files.`);
|
||||
console.log(`Telemetry matches found: ${matches.length}.`);
|
||||
console.log(`DATA_AND_PRIVACY.md changed: ${dataAndPrivacyChanged}.`);
|
||||
|
||||
if (matches.length === 0) {
|
||||
console.log('No telemetry-related additions/modifications detected.');
|
||||
return;
|
||||
}
|
||||
|
||||
for (const match of matches) {
|
||||
console.log(
|
||||
`- ${match.filename} (telemetryPath=${match.telemetryPath}, telemetryLineSignal=${match.telemetryLineSignal}, patchUnavailable=${match.patchUnavailable})`
|
||||
);
|
||||
}
|
||||
|
||||
const commentBody = dataAndPrivacyChanged
|
||||
? COMMENT_BODY_WITH_PRIVACY_UPDATE
|
||||
: COMMENT_BODY_WITHOUT_PRIVACY_UPDATE;
|
||||
|
||||
await upsertPrComment(apiBaseUrl, repository, pullNumber, commentBody);
|
||||
}
|
||||
|
||||
main().catch((error) => {
|
||||
console.error('Telemetry PR check failed.');
|
||||
console.error(error instanceof Error ? error.stack || error.message : error);
|
||||
process.exit(1);
|
||||
});
|
||||
35
.github/workflows/telemetry-pr-check.yml
vendored
Normal file
35
.github/workflows/telemetry-pr-check.yml
vendored
Normal file
@@ -0,0 +1,35 @@
|
||||
# NOTE: This workflow depends on .github/scripts/telemetry-pr-check.js for telemetry detection and PR comments.
|
||||
# Keep this workflow and script behavior in sync when making changes.
|
||||
name: Telemetry PR Check
|
||||
|
||||
on:
|
||||
pull_request_target:
|
||||
types: [opened, reopened, synchronize, ready_for_review]
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
pr_number:
|
||||
description: "Pull Request Number to test against"
|
||||
required: true
|
||||
type: string
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: write
|
||||
|
||||
concurrency:
|
||||
group: telemetry-pr-check-${{ github.event.pull_request.number }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
detect-telemetry-events:
|
||||
if: ${{ github.event.pull_request.draft == false }}
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- name: Detect telemetry event changes and comment PR
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: node .github/scripts/telemetry-pr-check.js
|
||||
@@ -47,8 +47,8 @@
|
||||
<PackageVersion Include="Microsoft.Graphics.Win2D" Version="1.3.2" />
|
||||
<PackageVersion Include="Microsoft.Windows.CppWinRT" Version="2.0.250303.1" />
|
||||
<PackageVersion Include="Microsoft.Diagnostics.Tracing.TraceEvent" Version="3.1.16" />
|
||||
<PackageVersion Include="Microsoft.Extensions.AI" Version="9.9.1" />
|
||||
<PackageVersion Include="Microsoft.Extensions.AI.OpenAI" Version="9.9.1-preview.1.25474.6" />
|
||||
<PackageVersion Include="Microsoft.Extensions.AI" Version="10.2.0" />
|
||||
<PackageVersion Include="Microsoft.Extensions.AI.OpenAI" Version="10.0.1-preview.1.25571.5" />
|
||||
<PackageVersion Include="Microsoft.Extensions.Caching.Abstractions" Version="10.0.7" />
|
||||
<PackageVersion Include="Microsoft.Extensions.Caching.Memory" Version="10.0.7" />
|
||||
<PackageVersion Include="Microsoft.Extensions.DependencyInjection" Version="10.0.7" />
|
||||
@@ -57,12 +57,12 @@
|
||||
<PackageVersion Include="Microsoft.Extensions.Hosting" Version="10.0.7" />
|
||||
<PackageVersion Include="Microsoft.Extensions.Hosting.WindowsServices" Version="10.0.7" />
|
||||
<PackageVersion Include="Microsoft.AI.Foundry.Local" Version="0.3.0" />
|
||||
<PackageVersion Include="Microsoft.SemanticKernel" Version="1.66.0" />
|
||||
<PackageVersion Include="Microsoft.SemanticKernel.Connectors.OpenAI" Version="1.66.0" />
|
||||
<PackageVersion Include="Microsoft.SemanticKernel.Connectors.AzureAIInference" Version="1.66.0-beta" />
|
||||
<PackageVersion Include="Microsoft.SemanticKernel.Connectors.Google" Version="1.66.0-alpha" />
|
||||
<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.SemanticKernel" Version="1.71.0" />
|
||||
<PackageVersion Include="Microsoft.SemanticKernel.Connectors.OpenAI" Version="1.71.0" />
|
||||
<PackageVersion Include="Microsoft.SemanticKernel.Connectors.AzureAIInference" Version="1.71.0-beta" />
|
||||
<PackageVersion Include="Microsoft.SemanticKernel.Connectors.Google" Version="1.71.0-alpha" />
|
||||
<PackageVersion Include="Microsoft.SemanticKernel.Connectors.MistralAI" Version="1.71.0-alpha" />
|
||||
<PackageVersion Include="Microsoft.SemanticKernel.Connectors.Ollama" Version="1.71.0-alpha" />
|
||||
<PackageVersion Include="Microsoft.Toolkit.Uwp.Notifications" Version="7.1.2" />
|
||||
<PackageVersion Include="Microsoft.Web.WebView2" Version="1.0.3719.77" />
|
||||
<!-- 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. -->
|
||||
@@ -90,11 +90,12 @@
|
||||
<PackageVersion Include="MSTest" Version="$(MSTestVersion)" />
|
||||
<PackageVersion Include="MSTest.TestFramework" Version="$(MSTestVersion)" />
|
||||
<PackageVersion Include="NJsonSchema" Version="11.4.0" />
|
||||
<PackageVersion Include="Newtonsoft.Json" Version="13.0.3" />
|
||||
<PackageVersion Include="Newtonsoft.Json" Version="13.0.4" />
|
||||
<PackageVersion Include="NLog" Version="5.2.8" />
|
||||
<PackageVersion Include="NLog.Extensions.Logging" Version="5.3.8" />
|
||||
<PackageVersion Include="NLog.Schema" Version="5.2.8" />
|
||||
<PackageVersion Include="OpenAI" Version="2.5.0" />
|
||||
<PackageVersion Include="OpenAI" Version="2.7.0" />
|
||||
<PackageVersion Include="Polly.Core" Version="8.6.5" />
|
||||
<PackageVersion Include="ReverseMarkdown" Version="4.1.0" />
|
||||
<PackageVersion Include="RtfPipe" Version="2.0.7677.4303" />
|
||||
<PackageVersion Include="ScipBe.Common.Office.OneNote" Version="3.0.1" />
|
||||
@@ -114,13 +115,13 @@
|
||||
<PackageVersion Include="System.Diagnostics.EventLog" Version="10.0.7" />
|
||||
<!-- Package System.Diagnostics.PerformanceCounter added as a hack for being able to exclude the runtime assets so they don't conflict with 8.0.11. -->
|
||||
<PackageVersion Include="System.Diagnostics.PerformanceCounter" Version="10.0.7" />
|
||||
<PackageVersion Include="System.ClientModel" Version="1.7.0" />
|
||||
<PackageVersion Include="System.ClientModel" Version="1.8.1" />
|
||||
<PackageVersion Include="System.Drawing.Common" Version="10.0.7" />
|
||||
<PackageVersion Include="System.IO.Abstractions" Version="22.0.13" />
|
||||
<PackageVersion Include="System.IO.Abstractions.TestingHelpers" Version="22.0.13" />
|
||||
<PackageVersion Include="System.Management" Version="10.0.7" />
|
||||
<PackageVersion Include="System.Net.Http" Version="4.3.4" />
|
||||
<PackageVersion Include="System.Numerics.Tensors" Version="9.0.11" />
|
||||
<PackageVersion Include="System.Numerics.Tensors" Version="10.0.2" />
|
||||
<PackageVersion Include="System.Private.Uri" Version="4.3.2" />
|
||||
<PackageVersion Include="System.Reactive" Version="6.0.1" />
|
||||
<PackageVersion Include="System.Runtime.Caching" Version="10.0.7" />
|
||||
@@ -134,7 +135,6 @@
|
||||
<PackageVersion Include="UTF.Unknown" Version="2.6.0" />
|
||||
<PackageVersion Include="WinUIEx" Version="2.8.0" />
|
||||
<PackageVersion Include="WmiLight" Version="6.14.0" />
|
||||
<PackageVersion Include="WPF-UI" Version="3.0.5" />
|
||||
<PackageVersion Include="WyHash" Version="1.0.5" />
|
||||
<PackageVersion Include="WixToolset.Heat" Version="5.0.2" />
|
||||
<PackageVersion Include="WixToolset.Firewall.wixext" Version="5.0.2" />
|
||||
|
||||
@@ -1600,5 +1600,4 @@ SOFTWARE.
|
||||
- UTF.Unknown
|
||||
- WinUIEx
|
||||
- WmiLight
|
||||
- WPF-UI
|
||||
- WyHash
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
// Copyright (c) Microsoft Corporation
|
||||
// Copyright (c) Microsoft Corporation
|
||||
// The Microsoft Corporation licenses this file to you under the MIT license.
|
||||
// See the LICENSE file in the project root for more information.
|
||||
|
||||
@@ -481,8 +481,9 @@ public sealed class CommandProviderWrapper : ICommandProviderContext
|
||||
this.CommandsChanged?.Invoke(this, new ItemsChangedEventArgs(-1));
|
||||
}
|
||||
|
||||
public void PinDockBand(string commandId, IServiceProvider serviceProvider, Dock.DockPinSide side = Dock.DockPinSide.Start, bool? showTitles = null, bool? showSubtitles = null, string? monitorDeviceId = null)
|
||||
public void PinDockBand(string commandId, IServiceProvider serviceProvider, bool withReload, Dock.DockPinSide side = Dock.DockPinSide.Start, bool? showTitles = null, bool? showSubtitles = null, string? monitorDeviceId = null)
|
||||
{
|
||||
Logger.LogDebug($"CommandProviderWrapper.PinDockBand(commandId): provider='{ProviderId}', commandId='{commandId}', withReload={withReload}, side={side}, monitor='{monitorDeviceId ?? "<global>"}'");
|
||||
var settingsService = serviceProvider.GetRequiredService<ISettingsService>();
|
||||
var settings = settingsService.Settings;
|
||||
var dockSettings = settings.DockSettings;
|
||||
@@ -535,7 +536,10 @@ public sealed class CommandProviderWrapper : ICommandProviderContext
|
||||
}
|
||||
|
||||
// Raise CommandsChanged so the TopLevelCommandManager reloads our commands
|
||||
this.CommandsChanged?.Invoke(this, new ItemsChangedEventArgs(-1));
|
||||
if (withReload)
|
||||
{
|
||||
this.CommandsChanged?.Invoke(this, new ItemsChangedEventArgs(-1));
|
||||
}
|
||||
}
|
||||
|
||||
private static void PinDockBandGlobal(ISettingsService settingsService, DockBandSettings bandSettings, Dock.DockPinSide side)
|
||||
@@ -610,7 +614,7 @@ public sealed class CommandProviderWrapper : ICommandProviderContext
|
||||
hotReload: false);
|
||||
}
|
||||
|
||||
public void UnpinDockBand(string commandId, IServiceProvider serviceProvider)
|
||||
public void UnpinDockBand(string commandId, IServiceProvider serviceProvider, bool withReload)
|
||||
{
|
||||
var settingsService = serviceProvider.GetRequiredService<ISettingsService>();
|
||||
settingsService.UpdateSettings(
|
||||
@@ -630,7 +634,10 @@ public sealed class CommandProviderWrapper : ICommandProviderContext
|
||||
hotReload: false);
|
||||
|
||||
// Raise CommandsChanged so the TopLevelCommandManager reloads our commands
|
||||
this.CommandsChanged?.Invoke(this, new ItemsChangedEventArgs(-1));
|
||||
if (withReload)
|
||||
{
|
||||
this.CommandsChanged?.Invoke(this, new ItemsChangedEventArgs(-1));
|
||||
}
|
||||
}
|
||||
|
||||
public ICommandProviderContext GetProviderContext() => this;
|
||||
@@ -639,8 +646,8 @@ public sealed class CommandProviderWrapper : ICommandProviderContext
|
||||
|
||||
public override int GetHashCode() => _commandProvider.GetHashCode();
|
||||
|
||||
private void CommandProvider_ItemsChanged(object sender, IItemsChangedEventArgs args) =>
|
||||
|
||||
private void CommandProvider_ItemsChanged(object sender, IItemsChangedEventArgs args)
|
||||
{
|
||||
// We don't want to handle this ourselves - we want the
|
||||
// TopLevelCommandManager to know about this, so they can remove
|
||||
// our old commands from their own list.
|
||||
@@ -648,6 +655,7 @@ public sealed class CommandProviderWrapper : ICommandProviderContext
|
||||
// In handling this, a call will be made to `LoadTopLevelCommands` to
|
||||
// retrieve the new items.
|
||||
this.CommandsChanged?.Invoke(this, args);
|
||||
}
|
||||
|
||||
internal void PinDockBand(TopLevelViewModel bandVm)
|
||||
{
|
||||
|
||||
@@ -228,6 +228,20 @@ public partial class ContextMenuViewModel : ObservableObject,
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Raised after a command is actually invoked (i.e. sent as a <see cref="PerformCommandMessage"/>)
|
||||
/// from this context menu. Not raised when the user navigates into a submenu.
|
||||
/// </summary>
|
||||
public event EventHandler<CommandItemViewModel>? CommandInvoked;
|
||||
|
||||
/// <summary>
|
||||
/// Raised immediately before the <see cref="PerformCommandMessage"/> is sent.
|
||||
/// Subscribers can decorate the message (for example, to attach an
|
||||
/// <see cref="PerformCommandMessage.OnBeforeShowConfirmation"/> callback).
|
||||
/// Not raised when the user navigates into a submenu.
|
||||
/// </summary>
|
||||
public event EventHandler<PerformCommandMessage>? CommandInvoking;
|
||||
|
||||
public ContextKeybindingResult InvokeCommand(CommandItemViewModel? command)
|
||||
{
|
||||
if (command is null)
|
||||
@@ -245,8 +259,11 @@ public partial class ContextMenuViewModel : ObservableObject,
|
||||
}
|
||||
else
|
||||
{
|
||||
WeakReferenceMessenger.Default.Send<PerformCommandMessage>(new(command.Command.Model, command.Model));
|
||||
var message = new PerformCommandMessage(command.Command.Model, command.Model);
|
||||
CommandInvoking?.Invoke(this, message);
|
||||
WeakReferenceMessenger.Default.Send(message);
|
||||
UpdateContextItems();
|
||||
CommandInvoked?.Invoke(this, command);
|
||||
return ContextKeybindingResult.Hide;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -66,29 +66,23 @@ public sealed partial class DockViewModel : IDisposable
|
||||
{
|
||||
if (_isEditing)
|
||||
{
|
||||
Logger.LogDebug("Skipping DockBands_CollectionChanged during edit mode");
|
||||
return;
|
||||
}
|
||||
|
||||
Logger.LogDebug("Starting DockBands_CollectionChanged");
|
||||
|
||||
// Refresh settings so newly pinned/unpinned bands are visible.
|
||||
// Pin/unpin operations save with hotReload:false (to avoid
|
||||
// double-updates), so _settings can be stale here.
|
||||
_settings = _settingsService.Settings.DockSettings;
|
||||
SetupBands();
|
||||
Logger.LogDebug("Ended DockBands_CollectionChanged");
|
||||
}
|
||||
|
||||
public void UpdateSettings(DockSettings settings)
|
||||
{
|
||||
if (_isEditing)
|
||||
{
|
||||
Logger.LogDebug("DockViewModel.UpdateSettings skipped (edit in progress)");
|
||||
return;
|
||||
}
|
||||
|
||||
Logger.LogDebug($"DockViewModel.UpdateSettings");
|
||||
_settings = settings;
|
||||
SetupBands();
|
||||
}
|
||||
@@ -239,7 +233,6 @@ public sealed partial class DockViewModel : IDisposable
|
||||
|
||||
private void SetupBands()
|
||||
{
|
||||
Logger.LogDebug($"Setting up dock bands");
|
||||
var (start, center, end) = GetActiveBands();
|
||||
SetupBands(start, StartItems);
|
||||
SetupBands(center, CenterItems);
|
||||
@@ -258,7 +251,7 @@ public sealed partial class DockViewModel : IDisposable
|
||||
|
||||
if (topLevelCommand is null)
|
||||
{
|
||||
Logger.LogWarning($"Failed to find band {commandId}");
|
||||
Logger.LogWarning($"[DockDrop] DockViewModel.SetupBands: failed to find band command '{commandId}' (provider='{band.ProviderId}')");
|
||||
}
|
||||
|
||||
if (topLevelCommand is not null)
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
// 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.Collections.Specialized;
|
||||
using System.ComponentModel;
|
||||
|
||||
namespace Microsoft.CmdPal.UI.ViewModels;
|
||||
|
||||
/// <summary>
|
||||
/// An <see cref="ObservableCollection{T}"/> of <see cref="TopLevelViewModel"/>
|
||||
/// that supports replacing the entire contents atomically with a single
|
||||
/// <see cref="NotifyCollectionChangedAction.Reset"/> notification.
|
||||
///
|
||||
/// <para>
|
||||
/// Using <see cref="ObservableCollection{T}"/>'s built-in Add/Remove/Insert
|
||||
/// mutations (or helpers like <c>ListHelpers.InPlaceUpdateList</c>) fires one
|
||||
/// <see cref="INotifyCollectionChanged.CollectionChanged"/> event per item
|
||||
/// mutation. The dock subscribes to that event and does a full rebuild for
|
||||
/// each, so a single provider reload (which can churn dozens of band entries)
|
||||
/// turns into dozens of full dock rebuilds.
|
||||
/// </para>
|
||||
///
|
||||
/// <para>
|
||||
/// <see cref="ReplaceWith"/> bypasses the per-item notifications by mutating
|
||||
/// the protected <see cref="Collection{T}.Items"/> list directly and then
|
||||
/// raising one <c>Reset</c> at the end.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public sealed partial class DockBandsCollection : ObservableCollection<TopLevelViewModel>
|
||||
{
|
||||
/// <summary>
|
||||
/// Replaces the contents of this collection with <paramref name="newItems"/>
|
||||
/// and raises exactly one <see cref="NotifyCollectionChangedAction.Reset"/>
|
||||
/// event (plus the standard <c>Count</c> / <c>Item[]</c> property change
|
||||
/// notifications). If the new contents are reference-equal to the current
|
||||
/// contents, no notification is raised.
|
||||
/// </summary>
|
||||
public void ReplaceWith(IEnumerable<TopLevelViewModel> newItems)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(newItems);
|
||||
|
||||
// Materialize once so we can compare and iterate without re-enumerating.
|
||||
var snapshot = newItems as IList<TopLevelViewModel> ?? [.. newItems];
|
||||
|
||||
// Cheap short-circuit: same length and same instances in the same
|
||||
// order means there is nothing to broadcast.
|
||||
if (SequenceReferenceEquals(Items, snapshot))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
Items.Clear();
|
||||
for (var i = 0; i < snapshot.Count; i++)
|
||||
{
|
||||
Items.Add(snapshot[i]);
|
||||
}
|
||||
|
||||
OnPropertyChanged(new PropertyChangedEventArgs(nameof(Count)));
|
||||
OnPropertyChanged(new PropertyChangedEventArgs("Item[]"));
|
||||
OnCollectionChanged(new NotifyCollectionChangedEventArgs(NotifyCollectionChangedAction.Reset));
|
||||
}
|
||||
|
||||
private static bool SequenceReferenceEquals(IList<TopLevelViewModel> a, IList<TopLevelViewModel> b)
|
||||
{
|
||||
if (a.Count != b.Count)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
for (var i = 0; i < a.Count; i++)
|
||||
{
|
||||
if (!ReferenceEquals(a[i], b[i]))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -20,6 +20,15 @@ public record PerformCommandMessage
|
||||
|
||||
public bool TransientPage { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Optional callback raised by <see cref="ShellViewModel"/> just before a
|
||||
/// <see cref="ShowConfirmationMessage"/> is dispatched for this command's
|
||||
/// result. Lets the sender prepare UI (for example, the dock uses this to
|
||||
/// open the cmdpal window anchored at the invoking dock item so that the
|
||||
/// confirmation dialog appears in the right place).
|
||||
/// </summary>
|
||||
public Action? OnBeforeShowConfirmation { get; set; }
|
||||
|
||||
public PerformCommandMessage(ExtensionObject<ICommand> command)
|
||||
{
|
||||
Command = command;
|
||||
|
||||
@@ -10,6 +10,7 @@ public record PinToDockMessage(
|
||||
string ProviderId,
|
||||
string CommandId,
|
||||
bool Pin,
|
||||
bool WithReload = true,
|
||||
DockPinSide Side = DockPinSide.Start,
|
||||
bool? ShowTitles = null,
|
||||
bool? ShowSubtitles = null,
|
||||
|
||||
@@ -386,7 +386,7 @@ public partial class ShellViewModel : ObservableObject,
|
||||
var result = invokable.Invoke(message.Context);
|
||||
|
||||
// But if it did succeed, we need to handle the result.
|
||||
UnsafeHandleCommandResult(result);
|
||||
UnsafeHandleCommandResult(result, message.OnBeforeShowConfirmation);
|
||||
|
||||
success = true;
|
||||
_handleInvokeTask = null;
|
||||
@@ -412,7 +412,7 @@ public partial class ShellViewModel : ObservableObject,
|
||||
}
|
||||
}
|
||||
|
||||
private void UnsafeHandleCommandResult(ICommandResult? result)
|
||||
private void UnsafeHandleCommandResult(ICommandResult? result, Action? onBeforeShowConfirmation = null)
|
||||
{
|
||||
if (result is null)
|
||||
{
|
||||
@@ -464,6 +464,17 @@ public partial class ShellViewModel : ObservableObject,
|
||||
{
|
||||
if (result.Args is IConfirmationArgs a)
|
||||
{
|
||||
// Give the original sender (e.g. the dock) a chance to
|
||||
// prepare UI before the confirmation dialog surfaces.
|
||||
try
|
||||
{
|
||||
onBeforeShowConfirmation?.Invoke();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
CoreLogger.LogError(ex.ToString());
|
||||
}
|
||||
|
||||
WeakReferenceMessenger.Default.Send<ShowConfirmationMessage>(new(a));
|
||||
}
|
||||
|
||||
@@ -475,7 +486,7 @@ public partial class ShellViewModel : ObservableObject,
|
||||
if (result.Args is IToastArgs a)
|
||||
{
|
||||
WeakReferenceMessenger.Default.Send<ShowToastMessage>(new(a.Message));
|
||||
UnsafeHandleCommandResult(a.Result);
|
||||
UnsafeHandleCommandResult(a.Result, onBeforeShowConfirmation);
|
||||
}
|
||||
|
||||
break;
|
||||
|
||||
@@ -68,7 +68,12 @@ public sealed partial class TopLevelCommandManager : ObservableObject,
|
||||
|
||||
public ObservableCollection<TopLevelViewModel> TopLevelCommands { get; set; } = [];
|
||||
|
||||
public ObservableCollection<TopLevelViewModel> DockBands { get; set; } = [];
|
||||
// DockBands uses a custom collection so that bulk rewrites (see
|
||||
// UpdateCommandsForProvider) raise a single Reset notification instead of
|
||||
// one event per inserted/removed/moved item. The dock subscribes to this
|
||||
// collection and does a full rebuild per event, so collapsing the burst
|
||||
// here avoids dozens of redundant rebuilds for one provider reload.
|
||||
public DockBandsCollection DockBands { get; } = new();
|
||||
|
||||
[ObservableProperty]
|
||||
public partial bool IsLoading { get; private set; } = true;
|
||||
@@ -194,8 +199,10 @@ public sealed partial class TopLevelCommandManager : ObservableObject,
|
||||
// By all accounts, we're already on a background thread (the COM call
|
||||
// to handle the event shouldn't be on the main thread.). But just to
|
||||
// be sure we don't block the caller, hop off this thread
|
||||
private void CommandProvider_CommandsChanged(CommandProviderWrapper sender, IItemsChangedEventArgs args) =>
|
||||
private void CommandProvider_CommandsChanged(CommandProviderWrapper sender, IItemsChangedEventArgs args)
|
||||
{
|
||||
_ = Task.Run(async () => await UpdateCommandsForProvider(sender, args));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Called when a command provider raises its ItemsChanged event. We'll
|
||||
@@ -240,12 +247,14 @@ public sealed partial class TopLevelCommandManager : ObservableObject,
|
||||
|
||||
lock (_dockBandsLock)
|
||||
{
|
||||
// same idea for DockBands
|
||||
// Same idea as TopLevelCommands above, but we deliberately use
|
||||
// ReplaceWith so the dock only sees one CollectionChanged event
|
||||
// for the whole rewrite instead of one per item.
|
||||
List<TopLevelViewModel> dockClone = [.. DockBands];
|
||||
var dockStartIndex = FindIndexForFirstProviderItem(dockClone, sender.ProviderId);
|
||||
dockClone.RemoveAll(item => item.CommandProviderId == sender.ProviderId);
|
||||
dockClone.InsertRange(dockStartIndex, newBands);
|
||||
ListHelpers.InPlaceUpdateList(DockBands, dockClone);
|
||||
DockBands.ReplaceWith(dockClone);
|
||||
}
|
||||
|
||||
return;
|
||||
@@ -726,13 +735,17 @@ public sealed partial class TopLevelCommandManager : ObservableObject,
|
||||
{
|
||||
if (message.Pin)
|
||||
{
|
||||
wrapper?.PinDockBand(message.CommandId, _serviceProvider, message.Side, message.ShowTitles, message.ShowSubtitles, message.MonitorDeviceId);
|
||||
wrapper?.PinDockBand(message.CommandId, _serviceProvider, message.WithReload, message.Side, message.ShowTitles, message.ShowSubtitles, message.MonitorDeviceId);
|
||||
}
|
||||
else
|
||||
{
|
||||
wrapper?.UnpinDockBand(message.CommandId, _serviceProvider);
|
||||
wrapper?.UnpinDockBand(message.CommandId, _serviceProvider, message.WithReload);
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
Logger.LogWarning($"[DockDrop] PinToDockMessage: no provider found for '{message.ProviderId}'");
|
||||
}
|
||||
}
|
||||
|
||||
public CommandProviderWrapper? LookupProvider(string providerId)
|
||||
|
||||
@@ -162,7 +162,10 @@ public partial class App : Application, IDisposable
|
||||
services.AddSingleton<ICommandProvider, ShellCommandsProvider>();
|
||||
services.AddSingleton<ICommandProvider, CalculatorCommandProvider>();
|
||||
services.AddSingleton<ICommandProvider>(files);
|
||||
services.AddSingleton<ICommandProvider, BookmarksCommandProvider>(_ => BookmarksCommandProvider.CreateWithDefaultStore());
|
||||
|
||||
var bookmarks = BookmarksCommandProvider.CreateWithDefaultStore();
|
||||
services.AddSingleton<ICommandProvider>(bookmarks);
|
||||
services.AddSingleton<IBookmarksManager>(bookmarks.BookmarksManager);
|
||||
|
||||
services.AddSingleton<ICommandProvider, WindowWalkerCommandsProvider>();
|
||||
services.AddSingleton<ICommandProvider, WebSearchCommandsProvider>();
|
||||
|
||||
@@ -206,9 +206,12 @@
|
||||
|
||||
<Grid
|
||||
x:Name="RootGrid"
|
||||
AllowDrop="True"
|
||||
Background="Transparent"
|
||||
BorderBrush="{ThemeResource SurfaceStrokeColorDefaultBrush}"
|
||||
BorderThickness="0,0,0,1"
|
||||
DragOver="RootGrid_DragOver"
|
||||
Drop="RootGrid_Drop"
|
||||
RightTapped="RootGrid_RightTapped">
|
||||
<!-- Dock content with Start / Center / End sections -->
|
||||
<local:DockContentControl
|
||||
|
||||
@@ -7,12 +7,14 @@ using System.Collections.Specialized;
|
||||
using System.Runtime.InteropServices;
|
||||
using CommunityToolkit.Mvvm.Messaging;
|
||||
using ManagedCommon;
|
||||
using Microsoft.CmdPal.Ext.Bookmarks;
|
||||
using Microsoft.CmdPal.UI.Messages;
|
||||
using Microsoft.CmdPal.UI.ViewModels;
|
||||
using Microsoft.CmdPal.UI.ViewModels.Dock;
|
||||
using Microsoft.CmdPal.UI.ViewModels.Messages;
|
||||
using Microsoft.CmdPal.UI.ViewModels.Settings;
|
||||
using Microsoft.CommandPalette.Extensions;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.UI;
|
||||
using Microsoft.UI.Xaml;
|
||||
using Microsoft.UI.Xaml.Controls;
|
||||
@@ -21,6 +23,8 @@ using Microsoft.UI.Xaml.Media;
|
||||
using Windows.ApplicationModel.DataTransfer;
|
||||
using Windows.Foundation;
|
||||
|
||||
using RS_ = Microsoft.CmdPal.UI.Helpers.ResourceLoaderInstance;
|
||||
|
||||
namespace Microsoft.CmdPal.UI.Dock;
|
||||
|
||||
public sealed partial class DockControl : UserControl, IRecipient<CloseContextMenuMessage>, IRecipient<EnterDockEditModeMessage>, IRecipient<ExitDockEditModeMessage>, IRecipient<CrossMonitorBandDropMessage>
|
||||
@@ -30,7 +34,7 @@ public sealed partial class DockControl : UserControl, IRecipient<CloseContextMe
|
||||
internal DockViewModel ViewModel => _viewModel;
|
||||
|
||||
/// <summary>
|
||||
/// The HWND of the parent DockWindow that owns this control.
|
||||
/// Gets or sets the HWND of the parent DockWindow that owns this control.
|
||||
/// Used to target palette-show messages to the correct DockWindow in multi-monitor setups.
|
||||
/// </summary>
|
||||
internal IntPtr OwnerHwnd { get; set; }
|
||||
@@ -98,6 +102,11 @@ public sealed partial class DockControl : UserControl, IRecipient<CloseContextMe
|
||||
WeakReferenceMessenger.Default.Register<ExitDockEditModeMessage>(this);
|
||||
WeakReferenceMessenger.Default.Register<CrossMonitorBandDropMessage>(this);
|
||||
|
||||
ContextControl.ViewModel.CommandInvoked -= ContextMenu_CommandInvoked;
|
||||
ContextControl.ViewModel.CommandInvoked += ContextMenu_CommandInvoked;
|
||||
ContextControl.ViewModel.CommandInvoking -= ContextMenu_CommandInvoking;
|
||||
ContextControl.ViewModel.CommandInvoking += ContextMenu_CommandInvoking;
|
||||
|
||||
ViewModel.CenterItems.CollectionChanged -= CenterItems_CollectionChanged;
|
||||
ViewModel.CenterItems.CollectionChanged += CenterItems_CollectionChanged;
|
||||
|
||||
@@ -108,6 +117,9 @@ public sealed partial class DockControl : UserControl, IRecipient<CloseContextMe
|
||||
{
|
||||
WeakReferenceMessenger.Default.UnregisterAll(this);
|
||||
|
||||
ContextControl.ViewModel.CommandInvoked -= ContextMenu_CommandInvoked;
|
||||
ContextControl.ViewModel.CommandInvoking -= ContextMenu_CommandInvoking;
|
||||
|
||||
ViewModel.CenterItems.CollectionChanged -= CenterItems_CollectionChanged;
|
||||
|
||||
if (EditButtonsTeachingTip.IsOpen)
|
||||
@@ -291,10 +303,7 @@ public sealed partial class DockControl : UserControl, IRecipient<CloseContextMe
|
||||
if (sender is DockItemControl dockItem && dockItem.DataContext is DockBandViewModel band && dockItem.Tag is DockItemViewModel item)
|
||||
{
|
||||
// Use the center of the border as the point to open at
|
||||
var borderPos = dockItem.TransformToVisual(null).TransformPoint(new Point(0, 0));
|
||||
var borderCenter = new Point(
|
||||
borderPos.X + (dockItem.ActualWidth / 2),
|
||||
borderPos.Y + (dockItem.ActualHeight / 2));
|
||||
var borderCenter = GetDockItemCenter(dockItem);
|
||||
|
||||
InvokeItem(item, borderCenter);
|
||||
e.Handled = true;
|
||||
@@ -311,6 +320,11 @@ public sealed partial class DockControl : UserControl, IRecipient<CloseContextMe
|
||||
// Stores the band that was right-clicked for edit mode context menu
|
||||
private DockBandViewModel? _editModeContextBand;
|
||||
|
||||
// Position (in window coords) of the dock item whose context menu is currently
|
||||
// open, used to anchor the cmdpal palette when a Page command is invoked from
|
||||
// the context menu. Null when the open context menu is not anchored to a band.
|
||||
private Point? _bandContextMenuPalettePos;
|
||||
|
||||
private void BandItem_RightTapped(object sender, Microsoft.UI.Xaml.Input.RightTappedRoutedEventArgs e)
|
||||
{
|
||||
if (sender is DockItemControl dockItem && dockItem.DataContext is DockBandViewModel band && dockItem.Tag is DockItemViewModel item)
|
||||
@@ -348,6 +362,10 @@ public sealed partial class DockControl : UserControl, IRecipient<CloseContextMe
|
||||
// Normal mode - show the command context menu
|
||||
if (item.CanOpenContextMenu)
|
||||
{
|
||||
// Remember where to anchor the palette if the user picks a Page
|
||||
// command from the context menu.
|
||||
_bandContextMenuPalettePos = GetDockItemCenter(dockItem);
|
||||
|
||||
ContextControl.ViewModel.SelectedItem = item;
|
||||
ContextControl.ShowFilterBox = true;
|
||||
ContextControl.PrepareForOpen(GetDockContextMenuFilterLocation());
|
||||
@@ -392,17 +410,25 @@ public sealed partial class DockControl : UserControl, IRecipient<CloseContextMe
|
||||
private void InvokeItem(DockItemViewModel item, Point pos)
|
||||
{
|
||||
var command = item.Command;
|
||||
var hwnd = OwnerHwnd;
|
||||
try
|
||||
{
|
||||
PerformCommandMessage m = new(command.Model);
|
||||
m.WithAnimation = false;
|
||||
m.TransientPage = true;
|
||||
PerformCommandMessage m = new(command.Model)
|
||||
{
|
||||
WithAnimation = false,
|
||||
TransientPage = true,
|
||||
|
||||
// If the command is invokable and its result asks for a
|
||||
// confirmation dialog, surface the cmdpal window anchored at
|
||||
// this dock item before the dialog appears.
|
||||
OnBeforeShowConfirmation = () =>
|
||||
WeakReferenceMessenger.Default.Send<RequestShowPaletteAtMessage>(new(pos, hwnd)),
|
||||
};
|
||||
WeakReferenceMessenger.Default.Send(m);
|
||||
|
||||
var isPage = command.Model.Unsafe is not IInvokableCommand invokable;
|
||||
if (isPage)
|
||||
if (IsPageCommand(command.Model.Unsafe))
|
||||
{
|
||||
WeakReferenceMessenger.Default.Send<RequestShowPaletteAtMessage>(new(pos, OwnerHwnd));
|
||||
WeakReferenceMessenger.Default.Send<RequestShowPaletteAtMessage>(new(pos, hwnd));
|
||||
}
|
||||
}
|
||||
catch (COMException e)
|
||||
@@ -411,6 +437,59 @@ public sealed partial class DockControl : UserControl, IRecipient<CloseContextMe
|
||||
}
|
||||
}
|
||||
|
||||
private static bool IsPageCommand(ICommand? command)
|
||||
{
|
||||
// A Page command is one that's not directly invokable - selecting it
|
||||
// navigates into a page rather than performing an action in place.
|
||||
return command is not null and not IInvokableCommand;
|
||||
}
|
||||
|
||||
private static Point GetDockItemCenter(FrameworkElement dockItem)
|
||||
{
|
||||
var borderPos = dockItem.TransformToVisual(null).TransformPoint(new Point(0, 0));
|
||||
return new Point(
|
||||
borderPos.X + (dockItem.ActualWidth / 2),
|
||||
borderPos.Y + (dockItem.ActualHeight / 2));
|
||||
}
|
||||
|
||||
private void ContextMenu_CommandInvoked(object? sender, CommandItemViewModel command)
|
||||
{
|
||||
// The context menu just invoked a command. If it came from a dock band
|
||||
// (i.e. _bandContextMenuPalettePos is set) and the command is a Page,
|
||||
// open the cmdpal palette anchored at the dock item — mirroring what
|
||||
// a direct click on the band does.
|
||||
var pos = _bandContextMenuPalettePos;
|
||||
_bandContextMenuPalettePos = null;
|
||||
|
||||
if (pos is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (IsPageCommand(command.Command.Model.Unsafe))
|
||||
{
|
||||
WeakReferenceMessenger.Default.Send<RequestShowPaletteAtMessage>(new(pos.Value, OwnerHwnd));
|
||||
}
|
||||
}
|
||||
|
||||
private void ContextMenu_CommandInvoking(object? sender, PerformCommandMessage message)
|
||||
{
|
||||
// The context menu is about to dispatch a command. If it was opened
|
||||
// from a dock band, attach a callback so that an invokable command
|
||||
// whose result is a Confirm surfaces the cmdpal window anchored at the
|
||||
// dock item before the confirmation dialog appears.
|
||||
var pos = _bandContextMenuPalettePos;
|
||||
if (pos is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var hwnd = OwnerHwnd;
|
||||
var capturedPos = pos.Value;
|
||||
message.OnBeforeShowConfirmation = () =>
|
||||
WeakReferenceMessenger.Default.Send<RequestShowPaletteAtMessage>(new(capturedPos, hwnd));
|
||||
}
|
||||
|
||||
private void ContextMenuFlyout_Opened(object sender, object e)
|
||||
{
|
||||
// We need to wait until our flyout is opened to try and toss focus
|
||||
@@ -434,6 +513,10 @@ public sealed partial class DockControl : UserControl, IRecipient<CloseContextMe
|
||||
return;
|
||||
}
|
||||
|
||||
// This context menu is for the dock itself (not a band), so the palette
|
||||
// should not be opened on invocation.
|
||||
_bandContextMenuPalettePos = null;
|
||||
|
||||
var pos = e.GetPosition(null);
|
||||
var item = this.ViewModel.GetContextMenuForDock();
|
||||
if (item.HasMoreCommands)
|
||||
@@ -725,6 +808,102 @@ public sealed partial class DockControl : UserControl, IRecipient<CloseContextMe
|
||||
}
|
||||
}
|
||||
|
||||
private void RootGrid_DragOver(object sender, DragEventArgs e)
|
||||
{
|
||||
// Don't intercept internal band drag-drop during edit mode
|
||||
if (_draggedBand != null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (e.DataView.Contains(StandardDataFormats.StorageItems) ||
|
||||
e.DataView.Contains(StandardDataFormats.Uri))
|
||||
{
|
||||
e.AcceptedOperation = DataPackageOperation.Link;
|
||||
e.DragUIOverride.Caption = RS_.GetString("Dock_DropFile_Caption");
|
||||
e.DragUIOverride.IsGlyphVisible = true;
|
||||
e.DragUIOverride.IsCaptionVisible = true;
|
||||
|
||||
// DON'T mark the event as handled - if you do, we won't get the Drop event.
|
||||
}
|
||||
}
|
||||
|
||||
private async void RootGrid_Drop(object sender, DragEventArgs e)
|
||||
{
|
||||
// Don't intercept internal band drag-drop during edit mode
|
||||
if (_draggedBand != null)
|
||||
{
|
||||
Logger.LogDebug("[DockDrop] RootGrid_Drop: ignoring (internal band drag in progress)");
|
||||
return;
|
||||
}
|
||||
|
||||
var hasStorageItems = e.DataView.Contains(StandardDataFormats.StorageItems);
|
||||
var hasUri = e.DataView.Contains(StandardDataFormats.Uri);
|
||||
|
||||
if (!hasStorageItems && !hasUri)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
e.Handled = true;
|
||||
|
||||
try
|
||||
{
|
||||
var bookmarksManager = App.Current.Services.GetService<IBookmarksManager>();
|
||||
if (bookmarksManager == null)
|
||||
{
|
||||
Logger.LogWarning("[DockDrop] IBookmarksManager service is not registered; cannot pin dropped item");
|
||||
return;
|
||||
}
|
||||
|
||||
var foundItem = false;
|
||||
if (hasStorageItems)
|
||||
{
|
||||
var items = await e.DataView.GetStorageItemsAsync();
|
||||
foreach (var item in items)
|
||||
{
|
||||
var path = item.Path;
|
||||
if (string.IsNullOrEmpty(path))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var name = Path.GetFileNameWithoutExtension(path);
|
||||
AddBookmarkAndPinToDock(bookmarksManager, name, path);
|
||||
foundItem = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (foundItem)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (hasUri)
|
||||
{
|
||||
var uri = await e.DataView.GetUriAsync();
|
||||
var url = uri.AbsoluteUri;
|
||||
var name = uri.Host;
|
||||
AddBookmarkAndPinToDock(bookmarksManager, name, url);
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Logger.LogError("[DockDrop] Error handling file drop on dock", ex);
|
||||
}
|
||||
}
|
||||
|
||||
private static void AddBookmarkAndPinToDock(IBookmarksManager bookmarksManager, string name, string bookmarkValue)
|
||||
{
|
||||
var bookmark = bookmarksManager.Add(name, bookmarkValue);
|
||||
|
||||
// Make the command ID exactly the same as the ID it would have in the
|
||||
// top-level list, so that pinning to the dock from the top-level is seamless.
|
||||
var commandId = Ext.Bookmarks.Helpers.CommandIds.GetLaunchBookmarkItemId(bookmark.Id);
|
||||
Logger.LogDebug($"[DockDrop] Pinning dropped item '{name}' as bookmark id={bookmark.Id} (commandId='{commandId}')");
|
||||
WeakReferenceMessenger.Default.Send(new PinToDockMessage("Bookmarks", commandId, true, WithReload: false));
|
||||
}
|
||||
|
||||
public void Receive(CrossMonitorBandDropMessage message)
|
||||
{
|
||||
// Only match if this dock has a real monitor ID that matches the source.
|
||||
|
||||
@@ -1018,6 +1018,10 @@ Right-click to remove the key combination, thereby deactivating the shortcut.</v
|
||||
<data name="Dock_EditMode_Unpin.Text" xml:space="preserve">
|
||||
<value>Unpin</value>
|
||||
</data>
|
||||
<data name="Dock_DropFile_Caption" xml:space="preserve">
|
||||
<value>Pin to Dock</value>
|
||||
<comment>Drag-over caption shown when dragging a file or shortcut onto the dock to bookmark and pin it</comment>
|
||||
</data>
|
||||
<data name="Dock_AddBand_NoCommandsAvailable.Text" xml:space="preserve">
|
||||
<value>All available bands are already pinned.</value>
|
||||
</data>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
// Copyright (c) Microsoft Corporation
|
||||
// Copyright (c) Microsoft Corporation
|
||||
// The Microsoft Corporation licenses this file to you under the MIT license.
|
||||
// See the LICENSE file in the project root for more information.
|
||||
|
||||
@@ -6,6 +6,7 @@ using System.Diagnostics.Contracts;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
using Microsoft.CmdPal.Ext.Bookmarks.Helpers;
|
||||
using Microsoft.CmdPal.Ext.Bookmarks.Pages;
|
||||
using Microsoft.CmdPal.Ext.Bookmarks.Persistence;
|
||||
using Microsoft.CmdPal.Ext.Bookmarks.Services;
|
||||
@@ -24,6 +25,8 @@ public sealed partial class BookmarksCommandProvider : CommandProvider
|
||||
private readonly IBookmarkResolver _commandResolver;
|
||||
private readonly IBookmarkIconLocator _iconLocator = new IconLocator();
|
||||
|
||||
public IBookmarksManager BookmarksManager => _bookmarksManager;
|
||||
|
||||
private readonly ListItem _addNewItem;
|
||||
private readonly Lock _bookmarksLock = new();
|
||||
|
||||
@@ -127,4 +130,42 @@ public sealed partial class BookmarksCommandProvider : CommandProvider
|
||||
|
||||
[Pure]
|
||||
private ICommandItem[] BuildTopLevelCommandsUnsafe() => [_addNewItem, .. _bookmarks];
|
||||
|
||||
public override ICommandItem[]? GetDockBands()
|
||||
{
|
||||
BookmarkListItem[] bookmarks;
|
||||
|
||||
lock (_bookmarksLock)
|
||||
{
|
||||
// Here we're creating an entirely different set of items to return
|
||||
// as bands.
|
||||
//
|
||||
// These items will have the same ID, but bookmarks to directories
|
||||
// will have their default command be the "DirectoryPage", so that
|
||||
// clicking it will automatically open the palette with the files in
|
||||
// that dir.
|
||||
bookmarks = [.. _bookmarksManager.Bookmarks
|
||||
.Select(bookmark => new BookmarkListItem(
|
||||
bookmark,
|
||||
_bookmarksManager,
|
||||
_commandResolver,
|
||||
_iconLocator,
|
||||
_placeholderParser,
|
||||
asBand: true))];
|
||||
}
|
||||
|
||||
var bands = new List<ICommandItem>();
|
||||
|
||||
// Now take all those commands, and stick them into individual bands. We
|
||||
// don't want one band with all bookmarks, we want one band per
|
||||
// bookmark.
|
||||
foreach (var b in bookmarks)
|
||||
{
|
||||
var id = CommandIds.GetLaunchBookmarkItemId(b.BookmarkId);
|
||||
var wrapped = new WrappedDockItem(items: [b], id: id, displayTitle: b.Title);
|
||||
bands.Add(wrapped);
|
||||
}
|
||||
|
||||
return bands.Count > 0 ? bands.ToArray() : null;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -47,6 +47,7 @@ internal sealed partial class BookmarksManager : IDisposable, IBookmarksManager
|
||||
public BookmarkData Add(string name, string bookmark)
|
||||
{
|
||||
var newBookmark = new BookmarkData(name, bookmark);
|
||||
Logger.LogDebug($"BookmarksManager.Add: created bookmark id={newBookmark.Id} name='{name}' value='{bookmark}'");
|
||||
|
||||
lock (_lock)
|
||||
{
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
// Copyright (c) Microsoft Corporation
|
||||
// Copyright (c) Microsoft Corporation
|
||||
// The Microsoft Corporation licenses this file to you under the MIT license.
|
||||
// See the LICENSE file in the project root for more information.
|
||||
|
||||
namespace Microsoft.CmdPal.Ext.Bookmarks.Helpers;
|
||||
|
||||
internal static class CommandIds
|
||||
public static class CommandIds
|
||||
{
|
||||
/// <summary>
|
||||
/// Returns id of a command associated with a bookmark item. This id is for a command that launches the bookmark - regardless of whether
|
||||
|
||||
@@ -6,7 +6,7 @@ using Microsoft.CmdPal.Ext.Bookmarks.Persistence;
|
||||
|
||||
namespace Microsoft.CmdPal.Ext.Bookmarks;
|
||||
|
||||
internal interface IBookmarksManager
|
||||
public interface IBookmarksManager
|
||||
{
|
||||
event Action<BookmarkData>? BookmarkAdded;
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
// Copyright (c) Microsoft Corporation
|
||||
// Copyright (c) Microsoft Corporation
|
||||
// The Microsoft Corporation licenses this file to you under the MIT license.
|
||||
// See the LICENSE file in the project root for more information.
|
||||
|
||||
@@ -26,6 +26,7 @@ internal sealed partial class BookmarkListItem : ListItem, IDisposable
|
||||
private readonly IPlaceholderParser _placeholderParser;
|
||||
private readonly SupersedingAsyncValueGate<BookmarkListItemReclassifyResult> _classificationGate;
|
||||
private readonly TaskCompletionSource _initializationTcs = new();
|
||||
private readonly bool _isBandItem;
|
||||
|
||||
private BookmarkData _bookmark;
|
||||
|
||||
@@ -37,12 +38,18 @@ internal sealed partial class BookmarkListItem : ListItem, IDisposable
|
||||
|
||||
public Guid BookmarkId => _bookmark.Id;
|
||||
|
||||
public BookmarkListItem(BookmarkData bookmark, IBookmarksManager bookmarksManager, IBookmarkResolver commandResolver, IBookmarkIconLocator iconLocator, IPlaceholderParser placeholderParser)
|
||||
public BookmarkListItem(
|
||||
BookmarkData bookmark,
|
||||
IBookmarksManager bookmarksManager,
|
||||
IBookmarkResolver commandResolver,
|
||||
IBookmarkIconLocator iconLocator,
|
||||
IPlaceholderParser placeholderParser,
|
||||
bool asBand = false)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(bookmark);
|
||||
ArgumentNullException.ThrowIfNull(bookmarksManager);
|
||||
ArgumentNullException.ThrowIfNull(commandResolver);
|
||||
|
||||
_isBandItem = asBand;
|
||||
_bookmark = bookmark;
|
||||
_bookmarksManager = bookmarksManager;
|
||||
_bookmarksManager.BookmarkUpdated += BookmarksManagerOnBookmarkUpdated;
|
||||
@@ -107,6 +114,23 @@ internal sealed partial class BookmarkListItem : ListItem, IDisposable
|
||||
BuildSpecificContextMenuItems(classification, contextMenu);
|
||||
AddCommonContextMenuItems(_bookmark, _bookmarksManager, bookmarkSavedHandler, contextMenu);
|
||||
|
||||
// If we're a band AND the classification kind was directory , then flip
|
||||
// the command and the first contextMenu item
|
||||
if (_isBandItem && classification.Kind == CommandKind.Directory && contextMenu.Count > 0)
|
||||
{
|
||||
var firstContextCommand = contextMenu[0] as CommandContextItem;
|
||||
if (firstContextCommand != null)
|
||||
{
|
||||
var browseCommand = firstContextCommand.Command;
|
||||
if (browseCommand != null)
|
||||
{
|
||||
contextMenu.RemoveAt(0);
|
||||
contextMenu.Insert(0, new CommandContextItem(command));
|
||||
command = browseCommand;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return new BookmarkListItemReclassifyResult(
|
||||
command,
|
||||
title,
|
||||
@@ -155,7 +179,8 @@ internal sealed partial class BookmarkListItem : ListItem, IDisposable
|
||||
{
|
||||
case CommandKind.Directory:
|
||||
directoryPath = targetPath;
|
||||
contextMenu.Add(new CommandContextItem(new DirectoryPage(directoryPath))); // Browse
|
||||
var c = new DirectoryPage(directoryPath);
|
||||
contextMenu.Add(new CommandContextItem(c)); // Browse
|
||||
break;
|
||||
case CommandKind.FileExecutable:
|
||||
case CommandKind.FileDocument:
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
// Copyright (c) Microsoft Corporation
|
||||
// Copyright (c) Microsoft Corporation
|
||||
// The Microsoft Corporation licenses this file to you under the MIT license.
|
||||
// See the LICENSE file in the project root for more information.
|
||||
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.CmdPal.Common.Helpers;
|
||||
using Microsoft.CmdPal.Ext.Indexer.Data;
|
||||
using Microsoft.CmdPal.Ext.Indexer.Properties;
|
||||
using Microsoft.CommandPalette.Extensions;
|
||||
@@ -15,9 +17,10 @@ using Windows.Storage.Streams;
|
||||
#nullable enable
|
||||
namespace Microsoft.CmdPal.Ext.Indexer;
|
||||
|
||||
public sealed partial class DirectoryPage : ListPage
|
||||
public sealed partial class DirectoryPage : ListPage, IDisposable
|
||||
{
|
||||
private readonly string _path;
|
||||
private readonly SupersedingAsyncValueGate<IconInfo?> _iconReloadGate;
|
||||
|
||||
private List<IndexerListItem>? _directoryContents;
|
||||
|
||||
@@ -27,6 +30,28 @@ public sealed partial class DirectoryPage : ListPage
|
||||
Icon = Icons.FileExplorerIcon;
|
||||
Name = Resources.Indexer_Command_Browse;
|
||||
Title = path;
|
||||
|
||||
_iconReloadGate = new(
|
||||
async ct =>
|
||||
{
|
||||
var stream = await ThumbnailHelper.GetThumbnail(path);
|
||||
return stream is not null ? IconInfo.FromStream(stream) : null;
|
||||
},
|
||||
icon =>
|
||||
{
|
||||
if (icon is not null)
|
||||
{
|
||||
Icon = icon;
|
||||
}
|
||||
});
|
||||
|
||||
_ = _iconReloadGate.ExecuteAsync();
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
_iconReloadGate.Dispose();
|
||||
GC.SuppressFinalize(this);
|
||||
}
|
||||
|
||||
public override IListItem[] GetItems()
|
||||
|
||||
@@ -22,6 +22,7 @@ namespace PowerAccent.Core
|
||||
GD,
|
||||
DE,
|
||||
EL,
|
||||
GRC,
|
||||
EST,
|
||||
EPO,
|
||||
FI,
|
||||
@@ -86,6 +87,7 @@ namespace PowerAccent.Core
|
||||
Language.GD => GetDefaultLetterKeyGD(letter), // Gàidhlig (Scottish Gaelic)
|
||||
Language.DE => GetDefaultLetterKeyDE(letter), // German
|
||||
Language.EL => GetDefaultLetterKeyEL(letter), // Greek
|
||||
Language.GRC => GetDefaultLetterKeyGRC(letter), // Greek Polytonic
|
||||
Language.EST => GetDefaultLetterKeyEST(letter), // Estonian
|
||||
Language.EPO => GetDefaultLetterKeyEPO(letter), // Esperanto
|
||||
Language.FI => GetDefaultLetterKeyFI(letter), // Finnish
|
||||
@@ -143,6 +145,7 @@ namespace PowerAccent.Core
|
||||
.Union(GetDefaultLetterKeyGD(letter))
|
||||
.Union(GetDefaultLetterKeyDE(letter))
|
||||
.Union(GetDefaultLetterKeyEL(letter))
|
||||
.Union(GetDefaultLetterKeyGRC(letter))
|
||||
.Union(GetDefaultLetterKeyEST(letter))
|
||||
.Union(GetDefaultLetterKeyEPO(letter))
|
||||
.Union(GetDefaultLetterKeyFI(letter))
|
||||
@@ -710,6 +713,42 @@ namespace PowerAccent.Core
|
||||
};
|
||||
}
|
||||
|
||||
// Greek Polytonic
|
||||
private static string[] GetDefaultLetterKeyGRC(LetterKey letter)
|
||||
{
|
||||
return letter switch
|
||||
{
|
||||
LetterKey.VK_A => new string[] { "α", "ἀ", "ἁ", "ὰ", "ά", "ᾶ", "ᾱ", "ᾰ", "ἂ", "ἃ", "ἄ", "ἅ", "ἆ", "ἇ", "ᾳ", "ᾀ", "ᾁ", "ᾴ", "ᾲ", "ᾷ", "ᾄ", "ᾅ", "ᾂ", "ᾃ", "ᾆ", "ᾇ" },
|
||||
LetterKey.VK_B => new string[] { "β" },
|
||||
LetterKey.VK_C => new string[] { "χ", "ϲ" },
|
||||
LetterKey.VK_D => new string[] { "δ" },
|
||||
LetterKey.VK_E => new string[] { "ε", "ἐ", "ἑ", "ὲ", "έ", "ἒ", "ἓ", "ἔ", "ἕ" },
|
||||
LetterKey.VK_F => new string[] { "φ", "ϝ" },
|
||||
LetterKey.VK_G => new string[] { "γ" },
|
||||
LetterKey.VK_H => new string[] { "η", "ἠ", "ἡ", "ὴ", "ή", "ῆ", "ἢ", "ἣ", "ἤ", "ἥ", "ἦ", "ἧ", "ῃ", "ᾐ", "ᾑ", "ῄ", "ῂ", "ῇ", "ᾔ", "ᾕ", "ᾒ", "ᾓ", "ᾖ", "ᾗ" },
|
||||
LetterKey.VK_I => new string[] { "ι", "ἰ", "ἱ", "ὶ", "ί", "ῖ", "ῑ", "ῐ", "ἲ", "ἳ", "ἴ", "ἵ", "ἶ", "ἷ", "ϊ", "ΐ", "ῒ", "ῗ" },
|
||||
LetterKey.VK_K => new string[] { "κ" },
|
||||
LetterKey.VK_L => new string[] { "λ" },
|
||||
LetterKey.VK_M => new string[] { "μ" },
|
||||
LetterKey.VK_N => new string[] { "ν" },
|
||||
LetterKey.VK_O => new string[] { "ο", "ὀ", "ὁ", "ὸ", "ό", "ὂ", "ὃ", "ὄ", "ὅ" },
|
||||
LetterKey.VK_P => new string[] { "π", "φ", "ψ", "ρ" },
|
||||
LetterKey.VK_Q => new string[] { "ϙ", "ϟ" },
|
||||
LetterKey.VK_R => new string[] { "ρ", "ῤ", "ῥ" },
|
||||
LetterKey.VK_S => new string[] { "σ", "ς", "ϛ", "ϲ", "ϡ" },
|
||||
LetterKey.VK_T => new string[] { "τ", "θ", "ϑ" },
|
||||
LetterKey.VK_U => new string[] { "υ", "ὐ", "ὑ", "ὺ", "ύ", "ῦ", "ῡ", "ῠ", "ὒ", "ὓ", "ὔ", "ὕ", "ὖ", "ὗ", "ϋ", "ΰ", "ῢ", "ῧ" },
|
||||
LetterKey.VK_V => new string[] { "β", "ϝ" },
|
||||
LetterKey.VK_W => new string[] { "ω", "ὠ", "ὡ", "ὼ", "ώ", "ῶ", "ὢ", "ὣ", "ὤ", "ὥ", "ὦ", "ὧ", "ῳ", "ᾠ", "ᾡ", "ῴ", "ῲ", "ῷ", "ᾤ", "ᾥ", "ᾢ", "ᾣ", "ᾦ", "ᾧ" },
|
||||
LetterKey.VK_X => new string[] { "ξ", "χ" },
|
||||
LetterKey.VK_Y => new string[] { "υ", "ὐ", "ὑ", "ὺ", "ύ", "ῦ", "ῡ", "ῠ", "ὒ", "ὓ", "ὔ", "ὕ", "ὖ", "ὗ", "ϋ", "ΰ", "ῢ", "ῧ" },
|
||||
LetterKey.VK_Z => new string[] { "ζ" },
|
||||
LetterKey.VK_COMMA => new string[] { "“", "”", "‘", "’", ";", "`", "´" },
|
||||
LetterKey.VK_PERIOD => new string[] { "·" },
|
||||
_ => Array.Empty<string>(),
|
||||
};
|
||||
}
|
||||
|
||||
// Hebrew
|
||||
private static string[] GetDefaultLetterKeyHE(LetterKey letter)
|
||||
{
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
GetDpiForWindow
|
||||
GetGUIThreadInfo
|
||||
GetKeyState
|
||||
GetMonitorInfo
|
||||
MonitorFromWindow
|
||||
SendInput
|
||||
SendInput
|
||||
GetAsyncKeyState
|
||||
GetDpiForMonitor
|
||||
@@ -5,7 +5,6 @@
|
||||
using System.Globalization;
|
||||
using System.Text;
|
||||
using System.Unicode;
|
||||
using System.Windows;
|
||||
|
||||
using ManagedCommon;
|
||||
using PowerAccent.Core.Services;
|
||||
@@ -27,6 +26,7 @@ public partial class PowerAccent : IDisposable
|
||||
private string[] _characterDescriptions = Array.Empty<string>();
|
||||
private int _selectedIndex = -1;
|
||||
private bool _showUnicodeDescription;
|
||||
private bool _initialShiftState; // Was shift held down when the toolbar was summoned?
|
||||
|
||||
public LetterKey[] LetterKeysShowingDescription => _letterKeysShowingDescription;
|
||||
|
||||
@@ -95,6 +95,7 @@ public partial class PowerAccent : IDisposable
|
||||
|
||||
private void ShowToolbar(LetterKey letterKey)
|
||||
{
|
||||
_initialShiftState = WindowsFunctions.IsShiftState();
|
||||
_visible = true;
|
||||
|
||||
_characters = GetCharacters(letterKey);
|
||||
@@ -240,23 +241,32 @@ public partial class PowerAccent : IDisposable
|
||||
|
||||
private void ProcessNextChar(TriggerKey triggerKey, bool shiftPressed)
|
||||
{
|
||||
// Use an async hardware check as a fallback in case the keyboard hook misses a
|
||||
// quick Shift press. If the popup was opened while holding Shift (e.g., typing a
|
||||
// capital letter), ignore the hardware check so we don't accidentally trigger a
|
||||
// backwards navigation.
|
||||
bool isHardwareShiftPressed = WindowsFunctions.IsShiftState() && !_initialShiftState;
|
||||
shiftPressed = shiftPressed || isHardwareShiftPressed;
|
||||
|
||||
if (_visible && _selectedIndex == -1)
|
||||
{
|
||||
if (triggerKey == TriggerKey.Left)
|
||||
if (triggerKey == TriggerKey.Space)
|
||||
{
|
||||
_selectedIndex = shiftPressed ? (_characters.Length - 1) : 0;
|
||||
}
|
||||
else if (_settingService.StartSelectionFromTheLeft)
|
||||
{
|
||||
_selectedIndex = 0;
|
||||
}
|
||||
else if (triggerKey == TriggerKey.Left)
|
||||
{
|
||||
_selectedIndex = (_characters.Length / 2) - 1;
|
||||
}
|
||||
|
||||
if (triggerKey == TriggerKey.Right)
|
||||
else if (triggerKey == TriggerKey.Right)
|
||||
{
|
||||
_selectedIndex = _characters.Length / 2;
|
||||
}
|
||||
|
||||
if (triggerKey == TriggerKey.Space || _settingService.StartSelectionFromTheLeft)
|
||||
{
|
||||
_selectedIndex = 0;
|
||||
}
|
||||
|
||||
if (_selectedIndex < 0)
|
||||
{
|
||||
_selectedIndex = 0;
|
||||
@@ -321,22 +331,47 @@ public partial class PowerAccent : IDisposable
|
||||
OnSelectCharacter?.Invoke(_selectedIndex, _characters[_selectedIndex]);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Calculates the coordinates at which a window of the specified size should be
|
||||
/// displayed, based on the current display settings and user preferences.
|
||||
/// </summary>
|
||||
/// <remarks>The calculated coordinates take into account the active display's
|
||||
/// location, size, DPI, and the user's configured position preferences.</remarks>
|
||||
/// <param name="window">The size of the window for which to calculate display
|
||||
/// coordinates.</param>
|
||||
/// <returns>A point representing the top-left coordinates where the window should be
|
||||
/// positioned on the active display, in physical/raw coordinates suitable for Win32
|
||||
/// APIs like SetWindowPos.</returns>
|
||||
public Point GetDisplayCoordinates(Size window)
|
||||
{
|
||||
(Point Location, Size Size, double Dpi) activeDisplay = WindowsFunctions.GetActiveDisplay();
|
||||
Rect screen = new(activeDisplay.Location, activeDisplay.Size);
|
||||
Position position = _settingService.Position;
|
||||
|
||||
/* Debug.WriteLine("Dpi: " + activeDisplay.Dpi); */
|
||||
|
||||
return Calculation.GetRawCoordinatesFromPosition(position, screen, window, activeDisplay.Dpi) / activeDisplay.Dpi;
|
||||
return Calculation.GetRawCoordinatesFromPosition(position, screen, window, activeDisplay.Dpi);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the maximum width for the toolbar display based on the active screen
|
||||
/// dimensions.
|
||||
/// </summary>
|
||||
/// <returns>The maximum width in logical pixels, accounting for screen padding.
|
||||
/// </returns>
|
||||
public double GetDisplayMaxWidth()
|
||||
{
|
||||
return WindowsFunctions.GetActiveDisplay().Size.Width - ScreenMinPadding;
|
||||
// Note: activeDisplay.Size.Width is in raw physical pixels.
|
||||
// We divide by DPI to convert to WPF logical pixels (Device-Independent Pixels),
|
||||
// because ScreenMinPadding is a logical pixel value and WPF MaxWidth expects
|
||||
// logical pixels.
|
||||
var activeDisplay = WindowsFunctions.GetActiveDisplay();
|
||||
return (activeDisplay.Size.Width / activeDisplay.Dpi) - ScreenMinPadding;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the user-configured position preference for the toolbar display. For example
|
||||
/// <see cref="Position.TopLeft"/>.
|
||||
/// </summary>
|
||||
/// <returns>The preferred location for the toolbar.</returns>
|
||||
public Position GetToolbarPosition()
|
||||
{
|
||||
return _settingService.Position;
|
||||
|
||||
@@ -6,6 +6,7 @@ using System.Runtime.InteropServices;
|
||||
|
||||
using Windows.Win32;
|
||||
using Windows.Win32.Graphics.Gdi;
|
||||
using Windows.Win32.UI.HiDpi;
|
||||
using Windows.Win32.UI.Input.KeyboardAndMouse;
|
||||
using Windows.Win32.UI.WindowsAndMessaging;
|
||||
|
||||
@@ -51,36 +52,36 @@ internal static class WindowsFunctions
|
||||
Thread.Sleep(1); // Some apps, like Terminal, need a little wait to process the sent backspace or they'll ignore it.
|
||||
}
|
||||
|
||||
foreach (char c in s)
|
||||
if (s.Length > 0)
|
||||
{
|
||||
// Letter
|
||||
var inputsInsert = new INPUT[]
|
||||
var inputsInsert = new INPUT[s.Length * 2];
|
||||
for (int i = 0; i < s.Length; i++)
|
||||
{
|
||||
new INPUT
|
||||
inputsInsert[i * 2] = new INPUT
|
||||
{
|
||||
type = INPUT_TYPE.INPUT_KEYBOARD,
|
||||
Anonymous = new INPUT._Anonymous_e__Union
|
||||
{
|
||||
ki = new KEYBDINPUT
|
||||
{
|
||||
wScan = c,
|
||||
wScan = s[i],
|
||||
dwFlags = KEYBD_EVENT_FLAGS.KEYEVENTF_UNICODE,
|
||||
},
|
||||
},
|
||||
},
|
||||
new INPUT
|
||||
};
|
||||
inputsInsert[(i * 2) + 1] = new INPUT
|
||||
{
|
||||
type = INPUT_TYPE.INPUT_KEYBOARD,
|
||||
Anonymous = new INPUT._Anonymous_e__Union
|
||||
{
|
||||
ki = new KEYBDINPUT
|
||||
{
|
||||
wScan = c,
|
||||
wScan = s[i],
|
||||
dwFlags = KEYBD_EVENT_FLAGS.KEYEVENTF_UNICODE | KEYBD_EVENT_FLAGS.KEYEVENTF_KEYUP,
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
_ = PInvoke.SendInput(inputsInsert, Marshal.SizeOf<INPUT>());
|
||||
}
|
||||
@@ -98,7 +99,13 @@ internal static class WindowsFunctions
|
||||
monitorInfo.cbSize = (uint)Marshal.SizeOf(monitorInfo);
|
||||
PInvoke.GetMonitorInfo(res, ref monitorInfo);
|
||||
|
||||
double dpi = PInvoke.GetDpiForWindow(guiInfo.hwndActive) / 96d;
|
||||
uint dpiRaw = 96; // Safe default
|
||||
if (PInvoke.GetDpiForMonitor(res, MONITOR_DPI_TYPE.MDT_EFFECTIVE_DPI, out uint dpiX, out _) == 0)
|
||||
{
|
||||
dpiRaw = dpiX;
|
||||
}
|
||||
|
||||
double dpi = dpiRaw / 96d;
|
||||
var location = new Point(monitorInfo.rcWork.left, monitorInfo.rcWork.top);
|
||||
return (location, monitorInfo.rcWork.Size, dpi);
|
||||
}
|
||||
@@ -111,7 +118,7 @@ internal static class WindowsFunctions
|
||||
|
||||
public static bool IsShiftState()
|
||||
{
|
||||
var shift = PInvoke.GetKeyState((int)VIRTUAL_KEY.VK_SHIFT);
|
||||
var shift = PInvoke.GetAsyncKeyState((int)VIRTUAL_KEY.VK_SHIFT);
|
||||
return shift < 0;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,16 +2,5 @@
|
||||
x:Class="PowerAccent.UI.App"
|
||||
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
xmlns:ui="http://schemas.lepo.co/wpfui/2022/xaml"
|
||||
StartupUri="Selector.xaml">
|
||||
|
||||
<Application.Resources>
|
||||
<ResourceDictionary>
|
||||
<ResourceDictionary.MergedDictionaries>
|
||||
<ui:ThemesDictionary Theme="Dark" />
|
||||
<ui:ControlsDictionary />
|
||||
</ResourceDictionary.MergedDictionaries>
|
||||
<!-- Other app resources here -->
|
||||
</ResourceDictionary>
|
||||
</Application.Resources>
|
||||
</Application>
|
||||
StartupUri="Selector.xaml"
|
||||
ThemeMode="System" />
|
||||
2
src/modules/poweraccent/PowerAccent.UI/NativeMethods.txt
Normal file
2
src/modules/poweraccent/PowerAccent.UI/NativeMethods.txt
Normal file
@@ -0,0 +1,2 @@
|
||||
SetWindowPos
|
||||
GetSystemMetrics
|
||||
@@ -25,7 +25,10 @@
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="WPF-UI" />
|
||||
<PackageReference Include="Microsoft.Windows.CsWin32">
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
</PackageReference>
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
|
||||
@@ -1,24 +1,23 @@
|
||||
<ui:FluentWindow
|
||||
<Window
|
||||
x:Class="PowerAccent.UI.Selector"
|
||||
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"
|
||||
xmlns:ui="http://schemas.lepo.co/wpfui/2022/xaml"
|
||||
Title="MainWindow"
|
||||
MinWidth="0"
|
||||
MinHeight="0"
|
||||
ui:Design.Background="{DynamicResource ApplicationBackgroundBrush}"
|
||||
AllowsTransparency="True"
|
||||
Background="Transparent"
|
||||
DataContext="{Binding RelativeSource={RelativeSource Self}}"
|
||||
ExtendsContentIntoTitleBar="True"
|
||||
ResizeMode="NoResize"
|
||||
ShowInTaskbar="False"
|
||||
SizeChanged="Window_SizeChanged"
|
||||
SizeToContent="WidthAndHeight"
|
||||
Visibility="Collapsed"
|
||||
WindowBackdropType="None"
|
||||
WindowStyle="None"
|
||||
mc:Ignorable="d">
|
||||
<ui:FluentWindow.Resources>
|
||||
<Window.Resources>
|
||||
|
||||
<DataTemplate x:Key="DefaultKeyTemplate">
|
||||
<TextBlock
|
||||
@@ -38,85 +37,93 @@
|
||||
TextAlignment="Center" />
|
||||
</DataTemplate>
|
||||
|
||||
</Window.Resources>
|
||||
<Border
|
||||
Background="{DynamicResource SolidBackgroundFillColorBaseBrush}"
|
||||
BorderBrush="{DynamicResource SurfaceStrokeColorDefaultBrush}"
|
||||
BorderThickness="1"
|
||||
CornerRadius="8">
|
||||
<Grid>
|
||||
<Grid.RowDefinitions>
|
||||
<RowDefinition Height="Auto" />
|
||||
<RowDefinition Height="Auto" />
|
||||
</Grid.RowDefinitions>
|
||||
<ListBox
|
||||
x:Name="characters"
|
||||
HorizontalAlignment="Center"
|
||||
HorizontalContentAlignment="Stretch"
|
||||
VerticalContentAlignment="Stretch"
|
||||
Background="Transparent"
|
||||
Focusable="False"
|
||||
IsHitTestVisible="False"
|
||||
ScrollViewer.HorizontalScrollBarVisibility="Auto">
|
||||
<ListBox.ItemContainerStyle>
|
||||
<Style TargetType="ListBoxItem">
|
||||
<Setter Property="Focusable" Value="False" />
|
||||
<Setter Property="ContentTemplate" Value="{StaticResource DefaultKeyTemplate}" />
|
||||
<Setter Property="Template">
|
||||
<Setter.Value>
|
||||
<ControlTemplate TargetType="{x:Type ListBoxItem}">
|
||||
<Grid
|
||||
Height="48"
|
||||
MinWidth="48"
|
||||
Margin="0"
|
||||
HorizontalAlignment="Center"
|
||||
VerticalAlignment="Center"
|
||||
SnapsToDevicePixels="true">
|
||||
<Rectangle
|
||||
x:Name="SelectionIndicator"
|
||||
Margin="7"
|
||||
HorizontalAlignment="Stretch"
|
||||
VerticalAlignment="Stretch"
|
||||
Fill="{DynamicResource AccentFillColorDefaultBrush}"
|
||||
RadiusX="4"
|
||||
RadiusY="4"
|
||||
Stroke="{DynamicResource AccentControlElevationBorderBrush}"
|
||||
StrokeThickness="1"
|
||||
Visibility="Collapsed" />
|
||||
<ContentPresenter Margin="12" />
|
||||
</Grid>
|
||||
<ControlTemplate.Triggers>
|
||||
<Trigger Property="IsSelected" Value="true">
|
||||
<Setter TargetName="SelectionIndicator" Property="Visibility" Value="Visible" />
|
||||
<Setter Property="ContentTemplate" Value="{StaticResource SelectedKeyTemplate}" />
|
||||
</Trigger>
|
||||
</ControlTemplate.Triggers>
|
||||
</ControlTemplate>
|
||||
</Setter.Value>
|
||||
</Setter>
|
||||
</Style>
|
||||
</ListBox.ItemContainerStyle>
|
||||
<ListBox.ItemsPanel>
|
||||
<ItemsPanelTemplate>
|
||||
<StackPanel Orientation="Horizontal" />
|
||||
</ItemsPanelTemplate>
|
||||
</ListBox.ItemsPanel>
|
||||
</ListBox>
|
||||
|
||||
</ui:FluentWindow.Resources>
|
||||
<Grid>
|
||||
<Grid.RowDefinitions>
|
||||
<RowDefinition Height="Auto" />
|
||||
<RowDefinition Height="Auto" />
|
||||
</Grid.RowDefinitions>
|
||||
<ListBox
|
||||
x:Name="characters"
|
||||
HorizontalAlignment="Center"
|
||||
HorizontalContentAlignment="Stretch"
|
||||
VerticalContentAlignment="Stretch"
|
||||
Background="Transparent"
|
||||
IsHitTestVisible="False">
|
||||
<ListBox.ItemContainerStyle>
|
||||
<Style TargetType="ListBoxItem">
|
||||
<Setter Property="ContentTemplate" Value="{StaticResource DefaultKeyTemplate}" />
|
||||
<Setter Property="Template">
|
||||
<Setter.Value>
|
||||
<ControlTemplate TargetType="{x:Type ListBoxItem}">
|
||||
<Grid
|
||||
Width="48"
|
||||
Height="48"
|
||||
Margin="0"
|
||||
HorizontalAlignment="Center"
|
||||
VerticalAlignment="Center"
|
||||
SnapsToDevicePixels="true">
|
||||
<Rectangle
|
||||
x:Name="SelectionIndicator"
|
||||
Margin="7"
|
||||
HorizontalAlignment="Stretch"
|
||||
VerticalAlignment="Stretch"
|
||||
RadiusX="4"
|
||||
RadiusY="4"
|
||||
Stroke="{DynamicResource AccentControlElevationBorderBrush}"
|
||||
StrokeThickness="1"
|
||||
Visibility="Collapsed">
|
||||
<Rectangle.Fill>
|
||||
<SolidColorBrush Color="{DynamicResource SystemAccentColorPrimary}" />
|
||||
</Rectangle.Fill>
|
||||
</Rectangle>
|
||||
<ContentPresenter Margin="12" />
|
||||
|
||||
</Grid>
|
||||
<ControlTemplate.Triggers>
|
||||
<Trigger Property="IsSelected" Value="true">
|
||||
<Setter TargetName="SelectionIndicator" Property="Visibility" Value="Visible" />
|
||||
<Setter Property="ContentTemplate" Value="{StaticResource SelectedKeyTemplate}" />
|
||||
</Trigger>
|
||||
</ControlTemplate.Triggers>
|
||||
</ControlTemplate>
|
||||
</Setter.Value>
|
||||
</Setter>
|
||||
</Style>
|
||||
</ListBox.ItemContainerStyle>
|
||||
<ListBox.ItemsPanel>
|
||||
<ItemsPanelTemplate>
|
||||
<VirtualizingStackPanel IsItemsHost="False" Orientation="Horizontal" />
|
||||
</ItemsPanelTemplate>
|
||||
</ListBox.ItemsPanel>
|
||||
</ListBox>
|
||||
|
||||
<Grid
|
||||
Grid.Row="1"
|
||||
MinWidth="600"
|
||||
Background="{DynamicResource LayerOnAcrylicFillColorDefaultBrush}"
|
||||
Visibility="{Binding CharacterNameVisibility, UpdateSourceTrigger=PropertyChanged}">
|
||||
<TextBlock
|
||||
x:Name="characterName"
|
||||
Margin="8"
|
||||
FontSize="12"
|
||||
Foreground="{DynamicResource TextFillColorSecondaryBrush}"
|
||||
Text="(U+0000) A COOL LETTER NAME COMES HERE"
|
||||
TextAlignment="Center" />
|
||||
<Rectangle
|
||||
Height="1"
|
||||
HorizontalAlignment="Stretch"
|
||||
VerticalAlignment="Top"
|
||||
Fill="{DynamicResource DividerStrokeColorDefaultBrush}" />
|
||||
<Grid
|
||||
Grid.Row="1"
|
||||
MinWidth="600"
|
||||
MaxWidth="{Binding ActualWidth, ElementName=characters}"
|
||||
Background="{DynamicResource LayerOnAcrylicFillColorDefaultBrush}"
|
||||
Visibility="{Binding CharacterNameVisibility, UpdateSourceTrigger=PropertyChanged}">
|
||||
<TextBlock
|
||||
x:Name="characterName"
|
||||
MaxHeight="36"
|
||||
Margin="8"
|
||||
FontSize="12"
|
||||
Foreground="{DynamicResource TextFillColorSecondaryBrush}"
|
||||
Text="(U+0000) A COOL LETTER NAME COMES HERE"
|
||||
TextAlignment="Center"
|
||||
TextTrimming="CharacterEllipsis"
|
||||
TextWrapping="Wrap" />
|
||||
<Rectangle
|
||||
Height="1"
|
||||
HorizontalAlignment="Stretch"
|
||||
VerticalAlignment="Top"
|
||||
Fill="{DynamicResource DividerStrokeColorDefaultBrush}" />
|
||||
</Grid>
|
||||
</Grid>
|
||||
</Grid>
|
||||
</ui:FluentWindow>
|
||||
</Border>
|
||||
</Window>
|
||||
@@ -5,21 +5,26 @@
|
||||
using System;
|
||||
using System.ComponentModel;
|
||||
using System.Windows;
|
||||
|
||||
using Wpf.Ui.Controls;
|
||||
|
||||
using Windows.Win32;
|
||||
using Windows.Win32.Foundation;
|
||||
using Windows.Win32.UI.WindowsAndMessaging;
|
||||
using Point = PowerAccent.Core.Point;
|
||||
using Size = PowerAccent.Core.Size;
|
||||
|
||||
namespace PowerAccent.UI;
|
||||
|
||||
public partial class Selector : FluentWindow, IDisposable, INotifyPropertyChanged
|
||||
public partial class Selector : Window, IDisposable, INotifyPropertyChanged
|
||||
{
|
||||
// When setting the position for the selector window, we do not alter the z-order,
|
||||
// activation status, or size.
|
||||
private const SET_WINDOW_POS_FLAGS WindowPosFlags =
|
||||
SET_WINDOW_POS_FLAGS.SWP_NOZORDER | SET_WINDOW_POS_FLAGS.SWP_NOACTIVATE | SET_WINDOW_POS_FLAGS.SWP_NOSIZE;
|
||||
|
||||
private readonly Core.PowerAccent _powerAccent = new();
|
||||
|
||||
private Visibility _characterNameVisibility = Visibility.Visible;
|
||||
|
||||
private int _selectedIndex;
|
||||
private int _selectedIndex = -1;
|
||||
|
||||
public event PropertyChangedEventHandler PropertyChanged;
|
||||
|
||||
@@ -41,8 +46,6 @@ public partial class Selector : FluentWindow, IDisposable, INotifyPropertyChange
|
||||
{
|
||||
InitializeComponent();
|
||||
|
||||
Wpf.Ui.Appearance.SystemThemeWatcher.Watch(this);
|
||||
|
||||
Application.Current.MainWindow.ShowActivated = false;
|
||||
}
|
||||
|
||||
@@ -58,8 +61,16 @@ public partial class Selector : FluentWindow, IDisposable, INotifyPropertyChange
|
||||
{
|
||||
_selectedIndex = index;
|
||||
characters.SelectedIndex = _selectedIndex;
|
||||
characterName.Text = _powerAccent.CharacterDescriptions[_selectedIndex];
|
||||
characters.ScrollIntoView(character);
|
||||
|
||||
if (_selectedIndex >= 0 && _selectedIndex < _powerAccent.CharacterDescriptions.Length)
|
||||
{
|
||||
characterName.Text = _powerAccent.CharacterDescriptions[_selectedIndex];
|
||||
}
|
||||
|
||||
if (characters.Items.Count > _selectedIndex && _selectedIndex >= 0)
|
||||
{
|
||||
characters.ScrollIntoView(characters.Items[_selectedIndex]);
|
||||
}
|
||||
}
|
||||
|
||||
private void PowerAccent_OnChangeDisplay(bool isActive, string[] chars)
|
||||
@@ -71,17 +82,50 @@ public partial class Selector : FluentWindow, IDisposable, INotifyPropertyChange
|
||||
|
||||
if (isActive)
|
||||
{
|
||||
characters.ItemsSource = chars;
|
||||
characters.SelectedIndex = _selectedIndex;
|
||||
this.UpdateLayout(); // Required for filling the actual width/height before positioning.
|
||||
SetWindowsSize();
|
||||
SetWindowPosition();
|
||||
int offscreenX = PInvoke.GetSystemMetrics(SYSTEM_METRICS_INDEX.SM_XVIRTUALSCREEN) - 1000;
|
||||
int offscreenY = PInvoke.GetSystemMetrics(SYSTEM_METRICS_INDEX.SM_YVIRTUALSCREEN) - 1000;
|
||||
|
||||
var hwnd = new System.Windows.Interop.WindowInteropHelper(this).Handle;
|
||||
if (hwnd != IntPtr.Zero)
|
||||
{
|
||||
// Move off-screen to avoid flicker on previous monitor before Show() and
|
||||
// UpdateLayout().
|
||||
PInvoke.SetWindowPos((HWND)hwnd, (HWND)IntPtr.Zero, offscreenX, offscreenY, 0, 0, WindowPosFlags);
|
||||
}
|
||||
else
|
||||
{
|
||||
this.Left = offscreenX;
|
||||
this.Top = offscreenY;
|
||||
}
|
||||
|
||||
Show();
|
||||
SetWindowsSize();
|
||||
characters.ItemsSource = chars;
|
||||
characters.SelectedIndex = -1; // Reset before setting dynamically to avoid flashing
|
||||
|
||||
this.UpdateLayout(); // Required for filling the actual width/height before positioning.
|
||||
|
||||
characters.SelectedIndex = _selectedIndex;
|
||||
|
||||
if (_selectedIndex >= 0 && _selectedIndex < chars.Length)
|
||||
{
|
||||
characterName.Text = _powerAccent.CharacterDescriptions[_selectedIndex];
|
||||
characters.ScrollIntoView(characters.Items[_selectedIndex]);
|
||||
this.UpdateLayout(); // Re-layout after scrolling
|
||||
}
|
||||
else
|
||||
{
|
||||
characterName.Text = string.Empty;
|
||||
}
|
||||
|
||||
SetWindowPosition();
|
||||
Microsoft.PowerToys.Telemetry.PowerToysTelemetry.Log.WriteEvent(new PowerAccent.Core.Telemetry.PowerAccentShowAccentMenuEvent());
|
||||
}
|
||||
else
|
||||
{
|
||||
Hide();
|
||||
characters.ItemsSource = null;
|
||||
_selectedIndex = -1;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -92,15 +136,39 @@ public partial class Selector : FluentWindow, IDisposable, INotifyPropertyChange
|
||||
|
||||
private void SetWindowPosition()
|
||||
{
|
||||
Size windowSize = new(((System.Windows.Controls.Panel)Application.Current.MainWindow.Content).ActualWidth, ((System.Windows.Controls.Panel)Application.Current.MainWindow.Content).ActualHeight);
|
||||
Point position = _powerAccent.GetDisplayCoordinates(windowSize);
|
||||
this.Left = position.X;
|
||||
this.Top = position.Y;
|
||||
Size windowSize = new(((FrameworkElement)Application.Current.MainWindow.Content).ActualWidth, ((FrameworkElement)Application.Current.MainWindow.Content).ActualHeight);
|
||||
Point physicalPosition = _powerAccent.GetDisplayCoordinates(windowSize);
|
||||
|
||||
var hwnd = new System.Windows.Interop.WindowInteropHelper(this).Handle;
|
||||
if (hwnd != IntPtr.Zero)
|
||||
{
|
||||
PInvoke.SetWindowPos((HWND)hwnd, (HWND)IntPtr.Zero, (int)Math.Round(physicalPosition.X), (int)Math.Round(physicalPosition.Y), 0, 0, WindowPosFlags);
|
||||
}
|
||||
}
|
||||
|
||||
protected override void OnDpiChanged(DpiScale oldDpi, DpiScale newDpi)
|
||||
{
|
||||
base.OnDpiChanged(oldDpi, newDpi);
|
||||
if (this.Visibility == Visibility.Visible)
|
||||
{
|
||||
SetWindowsSize();
|
||||
SetWindowPosition();
|
||||
}
|
||||
}
|
||||
|
||||
private void SetWindowsSize()
|
||||
{
|
||||
this.characters.MaxWidth = _powerAccent.GetDisplayMaxWidth();
|
||||
double maxWidth = _powerAccent.GetDisplayMaxWidth();
|
||||
this.characters.MaxWidth = maxWidth;
|
||||
this.MaxWidth = maxWidth;
|
||||
}
|
||||
|
||||
private void Window_SizeChanged(object sender, SizeChangedEventArgs e)
|
||||
{
|
||||
if (this.Visibility == Visibility.Visible)
|
||||
{
|
||||
SetWindowPosition();
|
||||
}
|
||||
}
|
||||
|
||||
protected override void OnClosed(EventArgs e)
|
||||
|
||||
@@ -3579,6 +3579,9 @@ Activate by holding the key for the character you want to add an accent to, then
|
||||
<data name="QuickAccent_SelectedLanguage_Greek" xml:space="preserve">
|
||||
<value>Greek</value>
|
||||
</data>
|
||||
<data name="QuickAccent_SelectedLanguage_Greek_Polytonic" xml:space="preserve">
|
||||
<value>Greek Polytonic</value>
|
||||
</data>
|
||||
<data name="QuickAccent_SelectedLanguage_Hebrew" xml:space="preserve">
|
||||
<value>Hebrew</value>
|
||||
</data>
|
||||
|
||||
@@ -39,6 +39,7 @@ namespace Microsoft.PowerToys.Settings.UI.ViewModels
|
||||
new PowerAccentLanguageModel("GD", "QuickAccent_SelectedLanguage_Gaidhlig", LanguageGroup),
|
||||
new PowerAccentLanguageModel("NL", "QuickAccent_SelectedLanguage_Dutch", LanguageGroup),
|
||||
new PowerAccentLanguageModel("EL", "QuickAccent_SelectedLanguage_Greek", LanguageGroup),
|
||||
new PowerAccentLanguageModel("GRC", "QuickAccent_SelectedLanguage_Greek_Polytonic", LanguageGroup),
|
||||
new PowerAccentLanguageModel("EST", "QuickAccent_SelectedLanguage_Estonian", LanguageGroup),
|
||||
new PowerAccentLanguageModel("EPO", "QuickAccent_SelectedLanguage_Esperanto", LanguageGroup),
|
||||
new PowerAccentLanguageModel("FI", "QuickAccent_SelectedLanguage_Finnish", LanguageGroup),
|
||||
|
||||
Reference in New Issue
Block a user