Compare commits

...

26 Commits

Author SHA1 Message Date
Mike Griese
d0946ca3ac PRE-Merge branch 'dev/migrie/b/context-menu-page-on-dock' into dev/migrie/selfhost-11-002 2026-05-19 13:31:09 -05:00
Mike Griese
df2c146b30 PRE-Merge branch 'dev/migrie/f/Bookmark-On-Doch' into dev/migrie/selfhost-11-002 2026-05-19 13:30:44 -05:00
Jessica Dene Earley-Cha
b893d633d9 [TEST Version] Event PR Check (#47889)
<!-- Enter a brief description/summary of your PR here. What does it
fix/what does it change/how was it tested (even manually, if necessary)?
-->
## Summary of the Pull Request

This PR checks if future PRs has added or refines telemetry events, if
so, the bot will add a message to the PR about the needed steps
depending on the PR.

**NOTE**: This PR is submitting a test version, which is only manually
triggered, once tested and confirmed then will move to it being
automatic


<!-- Please review the items on the PR checklist before submitting-->
## PR Checklist

- [ ] Closes: #xxx
<!-- - [ ] Closes: #yyy (add separate lines for additional resolved
issues) -->
- [ ] **Communication:** I've discussed this with core contributors
already. If the work hasn't been agreed, this work might be rejected
- [ ] **Tests:** Added/updated and all pass
- [ ] **Localization:** All end-user-facing strings can be localized
- [ ] **Dev docs:** Added/updated
- [ ] **New binaries:** Added on the required places
- [ ] [JSON for
signing](https://github.com/microsoft/PowerToys/blob/main/.pipelines/ESRPSigning_core.json)
for new binaries
- [ ] [WXS for
installer](https://github.com/microsoft/PowerToys/blob/main/installer/PowerToysSetup/Product.wxs)
for new binaries and localization folder
- [ ] [YML for CI
pipeline](https://github.com/microsoft/PowerToys/blob/main/.pipelines/ci/templates/build-powertoys-steps.yml)
for new test projects
- [ ] [YML for signed
pipeline](https://github.com/microsoft/PowerToys/blob/main/.pipelines/release.yml)
- [ ] **Documentation updated:** If checked, please file a pull request
on [our docs
repo](https://github.com/MicrosoftDocs/windows-uwp/tree/docs/hub/powertoys)
and link it here: #xxx

<!-- Provide a more detailed description of the PR, other things fixed,
or any additional comments/features here -->
## Detailed Description of the Pull Request / Additional comments

<!-- Describe how you validated the behavior. Add automated tests
wherever possible, but list manual validation steps taken as well -->
## Validation Steps Performed
2026-05-19 17:28:57 +00:00
Mike Griese
8026df4d16 context menus and confirmations from the dock 2026-05-19 11:42:11 -05:00
Mike Griese
eb4792f942 fix opening a page from a context menu on a dock item 2026-05-19 11:42:11 -05:00
Dave Rayment
a7bc09a87a [QuickAccent] Fix UI glitches, DPI-related issues, selection bugs, and add hardware shift key state fallback (#46593)
## Summary of the Pull Request
This PR fixes several issues around the popup selection window's size
and position, selection-related issues which result in flashing or
glitching, and includes more reliable detection of the Shift key.

<!-- Please review the items on the PR checklist before submitting-->
## PR Checklist

- [x] Closes: #44332
- [x] Closes: #44980
- [x] Closes: #35094 
- [x] Closes: #40498
- [ ] **Communication:** I've discussed this with core contributors
already. If the work hasn't been agreed, this work might be rejected
- [ ] **Tests:** Added/updated and all pass
- [ ] **Localization:** All end-user-facing strings can be localized
- [ ] **Dev docs:** Added/updated
- [ ] **New binaries:** Added on the required places
- [ ] [JSON for
signing](https://github.com/microsoft/PowerToys/blob/main/.pipelines/ESRPSigning_core.json)
for new binaries
- [ ] [WXS for
installer](https://github.com/microsoft/PowerToys/blob/main/installer/PowerToysSetup/Product.wxs)
for new binaries and localization folder
- [ ] [YML for CI
pipeline](https://github.com/microsoft/PowerToys/blob/main/.pipelines/ci/templates/build-powertoys-steps.yml)
for new test projects
- [ ] [YML for signed
pipeline](https://github.com/microsoft/PowerToys/blob/main/.pipelines/release.yml)
- [ ] **Documentation updated:** If checked, please file a pull request
on [our docs
repo](https://github.com/MicrosoftDocs/windows-uwp/tree/docs/hub/powertoys)
and link it here: #xxx

<!-- Provide a more detailed description of the PR, other things fixed,
or any additional comments/features here -->
## Detailed Description of the Pull Request / Additional comments

This PR includes fixes for the Quick Accent's selection window position,
its width measurement, and letter selection-related issues. In addition,
glitches such as the window flashing the selection colour and the window
appearing blank should be reduced or eliminated entirely.

### Popup width bug

When opening Quick Accent from a letter with many mappings, it would
appear too wide for the display. Even though letters could be selected,
they may be entirely off-screen:

<img width="1578" height="134" alt="image"
src="https://github.com/user-attachments/assets/cfcb2ddb-3cf3-47d5-9386-133a2fc70550"
/>

This was because of this flaw in `GetDisplayMaxWidth`, which is used
directly by the popup to set the maximum width of the characters area:

```csharp
    // In Selector.xaml.cs
    private void SetWindowsSize()
    {
        this.characters.MaxWidth = _powerAccent.GetDisplayMaxWidth();
    }

...
    // In PowerAccent.cs
    public double GetDisplayMaxWidth()
    {
        return WindowsFunctions.GetActiveDisplay().Size.Width - ScreenMinPadding;
    }
```

`GetActiveDisplay` uses the `GetMonitorInfo` API, which exposes the
working area of the display. It returns its values in _raw unscaled
pixel_ values:

```csharp
    public static (Point Location, Size Size, double Dpi) GetActiveDisplay()
    {
        ...
        var res = PInvoke.MonitorFromWindow(guiInfo.hwndActive, MONITOR_FROM_FLAGS.MONITOR_DEFAULTTONEAREST);
        MONITORINFO monitorInfo = default;
        monitorInfo.cbSize = (uint)Marshal.SizeOf(monitorInfo);
        PInvoke.GetMonitorInfo(res, ref monitorInfo);

        ...

        return (location, monitorInfo.rcWork.Size, dpi);
    }
```

However, the `MaxWidth` property must be a _pre-scaled_ value, i.e. in
logical WPF units not physical pixels. The fix is straightforward:

```csharp
    public double GetDisplayMaxWidth()
    {
        var activeDisplay = WindowsFunctions.GetActiveDisplay();
        return (activeDisplay.Size.Width / activeDisplay.Dpi) - ScreenMinPadding;
    }
```

### Popup positioning bug

This is related to a subtle DPI issue in `GetActiveDisplay()`:

```csharp
    public static (Point Location, Size Size, double Dpi) GetActiveDisplay()
    {
        GUITHREADINFO guiInfo = default;
        guiInfo.cbSize = (uint)Marshal.SizeOf(guiInfo);
        PInvoke.GetGUIThreadInfo(0, ref guiInfo);
        var res = PInvoke.MonitorFromWindow(guiInfo.hwndActive, MONITOR_FROM_FLAGS.MONITOR_DEFAULTTONEAREST);

        MONITORINFO monitorInfo = default;
        monitorInfo.cbSize = (uint)Marshal.SizeOf(monitorInfo);
        PInvoke.GetMonitorInfo(res, ref monitorInfo);

        double dpi = PInvoke.GetDpiForWindow(guiInfo.hwndActive) / 96d;
        var location = new Point(monitorInfo.rcWork.left, monitorInfo.rcWork.top);
        return (location, monitorInfo.rcWork.Size, dpi);
    }
```

Here, the application window's DPI is returned. Unfortunately, the
window may report a value which is different from the monitor's own DPI
value. This will consistently happen if the application is not
Per-Monitor DPI-Aware, and the monitor is not at 100% Scale. The effects
are that the Quick Accent popup can appear misaligned or even off-screen
entirely. Quick Accent can still be used, but the user may not be able
to see what they are selecting.

As Quick Accent is using monitor coordinates for setting its location,
the solution is to use the monitor's own DPI value. The fix is to add
this in place of the `GetDpiForWindow` line:

```csharp
        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;
```

### Selection bugs

After dismissing the Quick Accent window, the `_selectedIndex` state was
not properly reset. The next time the window opened, it could attempt to
scroll to or highlight an index that was out-of-bounds for the new
character set. This could result in glitching, such as the window
flashing the selection colour or the initial selection being incorrect.
In this fix, I:

1. Explicitly set `_selectedIndex` to `-1` when the UI hides.
2. Reset the `SelectedIndex` inside Selector.xaml.cs before updating the
`ItemsSource`.

### Shift key activation

In certain cases, a quick press of Shift could fail to move back through
the character list. In this fix, I:

1. Added a native fallback usign GetAsyncKeyState(VK_SHIFT).
2. Updated `ProcessNextChar()` to evaluate `shiftPressed ||
WindowsFunctions.IsShiftState()`.
3. Updated the multiple `if`s in `ProcessNextChar` to be an if/else
structure, to prevent bugs when more than one trigger key is pressed.

### Support added for multi-codepoint graphemes

The current code loops through each `char` of a mapping, calling
`SendInput` multiple times for multi-char sequences. This will fail for
multi-codepoint graphemes, i.e. where the mapping 'letter' is more than
one UTF-16 codepoint. Those characters may appear as `[]`. The amended
`Insert()` in `WindowsFunctions` appends all characters before calling
`SendInput`.

### Miscelleneous

- Added an `OnDpiChanged` handler for the Selector control, so changing
the DPI of the screen should be picked up automatically. (It's
questionable whether this is essential, as the DPI would have to change
while the control was displayed, but it's worth having for robustness.)
- Now using `SetWindowPos` instead of setting the `Left` and `Top` of
the popup control. Also now initialising the popup offscreen to attempt
to reduce flicker and the occurrence of blank window flashes.
- Changed the `Focusable` property of the characters `ListBox` to
`False`, to attempt to reduce flicker and the window flashing the
selector colour.
- Removed `Width` and added `MinWidth` to the letter control in
Selector.xaml. This allows for wider letters or longer multi-letter
mappings.
- Changed the `VirtualizingStackPanel` to a regular `StackPanel`. We do
not have mappings with enough entries for a virtual control to be
necessary, and using StackPanel seemed to have a positive effect on the
appearance of blank window glitches.
- Added `TextTrimming`, `TextWrapping` and `MaxHeight` to the unicode
description `TextBlock`. This helps support extremely long unicode
descriptions. Again, this will enable us to support longer
multi-character mappings in the future.
- Added CsWin32 to the PowerAccent.UI project, to support the
`SetWindowPos` call.

<!-- Describe how you validated the behavior. Add automated tests
wherever possible, but list manual validation steps taken as well -->
## Validation Steps Performed

See separate doc for full test details:


https://docs.google.com/document/d/19uClcUiv7RUDRlbFhazG-Cmu46oNmrAVoJf9bHSjSJU/edit?usp=sharing

---------

Co-authored-by: Muyuan Li (from Dev Box) <muyuanli@microsoft.com>
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Co-authored-by: Niels Laute <niels.laute@live.nl>
2026-05-20 00:20:03 +08:00
Marco Guido
6e9b3b1536 [PowerAccent] adding greek polytonic (#47021)
Adds Greek Polytonic characters set to power accent, based on
https://github.com/microsoft/PowerToys/pull/29709
### PR Checklist

- [x]  Closes #46941
- [x] Communication: I've discussed this with core contributors already.
- [ ] Tests: Not sure if there are specific tests for this
- [ ] Documentation updated: Power accent docs

### Detailed Description of the Pull Request / Additional comments
Added all greek polytonic letters to their corresponding english letter
(some duplicated)
(if you wondered about GRC -> ISO 639-3)

### Validation Steps Performed
Compiled and Observed Power Accent

---------

Co-authored-by: Niels Laute <niels.laute@live.nl>
Co-authored-by: Dave Rayment <dave.rayment@gmail.com>
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-05-20 00:19:06 +08:00
Mike Griese
8352afbb65 less logging 2026-05-19 10:55:20 -05:00
Mike Griese
242ec2020c more surgical 2026-05-19 10:45:42 -05:00
Mike Griese
2927ffa8b7 debounce updates to dock 2026-05-19 10:06:08 -05:00
Mike Griese
f492cda5d5 REVERTME: far too much logging 2026-05-19 09:19:07 -05:00
Mike Griese
e2248a6e1e Merge remote-tracking branch 'origin/main' into dev/migrie/f/Bookmark-On-Doch 2026-05-19 06:57:05 -05:00
Mike Griese
20306ed599 Revert "ABSOLUTELY CONTRIVED, DO NOT USE"
This reverts commit a2be31e5f0.
2026-05-19 06:13:54 -05:00
Gordon Lam
d973bcbcaa Update Microsoft.SemanticKernel packages from 1.66.0 to 1.71.0 (#47819)
## Summary

Updates the `Microsoft.SemanticKernel` package family from 1.66.0 to
1.71.0 to pick up upstream improvements and bug fixes from the Semantic
Kernel project.

> Note: the `Connectors.AzureAIInference` (`-beta`), `Connectors.Google`
/ `Connectors.MistralAI` / `Connectors.Ollama` (`-alpha`) packages have
no stable upstream release yet, but are required to keep the existing
Advanced Paste AI provider options working.


## Packages updated

**SemanticKernel family:**

| Package | From | To |
|---------|------|----|
| `Microsoft.SemanticKernel` | 1.66.0 | 1.71.0 |
| `Microsoft.SemanticKernel.Connectors.OpenAI` | 1.66.0 | 1.71.0 |
| `Microsoft.SemanticKernel.Connectors.AzureAIInference` | 1.66.0-beta |
1.71.0-beta |
| `Microsoft.SemanticKernel.Connectors.Google` | 1.66.0-alpha |
1.71.0-alpha |
| `Microsoft.SemanticKernel.Connectors.MistralAI` | 1.66.0-alpha |
1.71.0-alpha |
| `Microsoft.SemanticKernel.Connectors.Ollama` | 1.66.0-alpha |
1.71.0-alpha |

**Transitive dependencies bumped to satisfy SK 1.71's resolution
constraints:**

| Package | From | To |
|---------|------|----|
| `Microsoft.Extensions.AI` | 9.9.1 | 10.2.0 |
| `Microsoft.Extensions.AI.OpenAI` | 9.9.1-preview.1.25474.6 |
10.0.1-preview.1.25571.5 |
| `System.Numerics.Tensors` | 9.0.11 | 10.0.2 |
| `Newtonsoft.Json` | 13.0.3 | 13.0.4 |
| `OpenAI` | 2.5.0 | 2.7.0 |
| `System.ClientModel` | 1.7.0 | 1.8.0 |

These transitive bumps were required to avoid `NU1109` package-downgrade
errors after the SK upgrade.

## Scope

- `Directory.Packages.props` only  central package version bumps.
- No source-code changes.

## Consumers

- AdvancedPaste uses the SK `Kernel` / `IChatCompletionService` /
connector surfaces unchanged.
- `LanguageModelProvider` and `FoundryLocalPasteProvider` use the stable
`Microsoft.Extensions.AI` `IChatClient` / `ChatMessage` / `ChatRole` /
`ChatResponse` / `AsIChatClient()` types unchanged across 9.x to 10.x.

## Validation

- Static API-surface review of all SK / `Microsoft.Extensions.AI` call
sites only stable types in use.
- CI build pipeline will provide the full restore + compile verification
across the solution.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

---------

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-05-19 10:25:41 +02:00
Niels Laute
02fbb916a7 [Quick Accent] Remove wpfui (#46604)
## Summary of the Pull Request

<img
src="https://github.com/user-attachments/assets/8756671f-642a-4bbd-a174-eb13b02cfe59">

This PR removes the dependency on the WpfUI library and uses plain WPF.

## PR Checklist

- [ ] **Communication:** I've discussed this with core contributors
already. If the work hasn't been agreed, this work might be rejected
- [ ] **Tests:** Added/updated and all pass
- [ ] **Localization:** All end-user-facing strings can be localized
- [ ] **Dev docs:** Added/updated
- [ ] **New binaries:** Added on the required places
- [ ] [JSON for
signing](https://github.com/microsoft/PowerToys/blob/main/.pipelines/ESRPSigning_core.json)
for new binaries
- [ ] [WXS for
installer](https://github.com/microsoft/PowerToys/blob/main/installer/PowerToysSetup/Product.wxs)
for new binaries and localization folder
- [ ] [YML for CI
pipeline](https://github.com/microsoft/PowerToys/blob/main/.pipelines/ci/templates/build-powertoys-steps.yml)
for new test projects
- [ ] [YML for signed
pipeline](https://github.com/microsoft/PowerToys/blob/main/.pipelines/release.yml)
- [ ] **Documentation updated:** If checked, please file a pull request
on [our docs
repo](https://github.com/MicrosoftDocs/windows-uwp/tree/docs/hub/powertoys)
and link it here: #xxx

## Detailed Description of the Pull Request / Additional comments

- Replaced `ui:FluentWindow` with a standard WPF `Window` and removed
the `xmlns:ui` WpfUI namespace
- Replaced `<Rectangle.Fill><SolidColorBrush /></Rectangle.Fill>` with
an inline `Fill=` attribute on the selection indicator rectangle
- Simplified `App.xaml` by removing WpfUI theme/controls resource
dictionaries and using `ThemeMode="System"` instead
- Fixed XAML formatting: converted empty `<Application>` to a
self-closing tag, removed extra blank lines in `Window.Resources` and
inside `ControlTemplate`

## Validation Steps Performed

- Manually verified the Quick Accent overlay renders correctly with
accent character selection and character name display

<!-- START COPILOT CODING AGENT TIPS -->
---

 Let Copilot coding agent [set things up for
you](https://github.com/microsoft/PowerToys/issues/new?title=+Set+up+Copilot+instructions&body=Configure%20instructions%20for%20this%20repository%20as%20documented%20in%20%5BBest%20practices%20for%20Copilot%20coding%20agent%20in%20your%20repository%5D%28https://gh.io/copilot-coding-agent-tips%29%2E%0A%0A%3COnboard%20this%20repo%3E&assignees=copilot)
— coding agent works faster and does higher quality work when set up for
your repo.

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: niels9001 <9866362+niels9001@users.noreply.github.com>
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-05-19 08:13:00 +00:00
Mike Griese
a2be31e5f0 ABSOLUTELY CONTRIVED, DO NOT USE 2026-04-20 09:59:40 -05:00
Mike Griese
ca627134b3 Merge remote-tracking branch 'origin/main' into dev/migrie/f/Bookmark-On-Doch 2026-04-20 09:59:19 -05:00
Mike Griese
4b8cbde9e6 more cleanup 2026-04-01 11:22:01 -05:00
Mike Griese
0728481923 Yep I think this is ready to push 2026-03-31 16:38:24 -05:00
Mike Griese
158e2f8d8a Wait am I the dummy 2026-03-27 13:45:51 -05:00
Mike Griese
380489122a you needed a lot of help today dummy 2026-03-27 13:39:09 -05:00
Mike Griese
869a1f0560 i'm still smarter than you, dumb clanker 2026-03-27 12:41:50 -05:00
Mike Griese
3185267804 Revert "lost the track"
This reverts commit d2ceeccc6e.
2026-03-27 12:28:17 -05:00
Mike Griese
d2ceeccc6e lost the track 2026-03-27 12:28:13 -05:00
Mike Griese
ee014d06b8 This is pretty close but there's a fuckin storm of events raised when this happens 2026-03-27 10:46:19 -05:00
Mike Griese
2a2a6cc9f5 Adds support for drag-droping files to bookmark to the dock, but we need more 2026-03-27 08:49:51 -05:00
35 changed files with 1143 additions and 207 deletions

View File

@@ -631,6 +631,7 @@ gpu
grabandmove
GRABANDMOVEMODULEINTERFACE
gradians
GRC
grctlext
GRGX
Gridcustomlayout

View File

@@ -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
View 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);
});

View 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

View File

@@ -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" />

View File

@@ -1600,5 +1600,4 @@ SOFTWARE.
- UTF.Unknown
- WinUIEx
- WmiLight
- WPF-UI
- WyHash

View File

@@ -1,4 +1,4 @@
// Copyright (c) Microsoft Corporation
// Copyright (c) Microsoft Corporation
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
@@ -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)
{

View File

@@ -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;
}
}

View File

@@ -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)

View File

@@ -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;
}
}

View File

@@ -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;

View File

@@ -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,

View File

@@ -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;

View File

@@ -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)

View File

@@ -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>();

View File

@@ -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

View File

@@ -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.

View File

@@ -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>

View File

@@ -1,4 +1,4 @@
// Copyright (c) Microsoft Corporation
// Copyright (c) Microsoft Corporation
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
@@ -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;
}
}

View File

@@ -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)
{

View File

@@ -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

View File

@@ -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;

View File

@@ -1,4 +1,4 @@
// Copyright (c) Microsoft Corporation
// Copyright (c) Microsoft Corporation
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
@@ -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:

View File

@@ -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()

View File

@@ -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)
{

View File

@@ -1,6 +1,7 @@
GetDpiForWindow
GetGUIThreadInfo
GetKeyState
GetMonitorInfo
MonitorFromWindow
SendInput
SendInput
GetAsyncKeyState
GetDpiForMonitor

View File

@@ -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;

View File

@@ -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;
}
}

View File

@@ -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" />

View File

@@ -0,0 +1,2 @@
SetWindowPos
GetSystemMetrics

View File

@@ -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>

View File

@@ -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>

View File

@@ -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)

View File

@@ -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>

View File

@@ -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),