Compare commits

..

8 Commits

Author SHA1 Message Date
Alex Mihaiuc
cccd2b7510 GrabAndMove drag maximized windows relative to the click point (#49118)
This aligns the behavior with resize.

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

When grabbing and moving a maximized icon, the old behavior was to first
restore the window with its title bar under the cursor, then proceed
with the move. Now the window is restored and moved proportionally
around the cursor, exactly like in the case of the grab and resize
behavior.

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

Ensure the window geometry/coordinates change exactly the same as for
grab and resize on it, when starting from a maximized state.
2026-07-04 14:09:07 +02:00
Renn
e4ef90d168 [Peek] Options for AlwaysOnTop and ShowOnTaskbar (#44645)
<!-- 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

* Add the option for Peek to allow the window to be always on top
* Add the option for Peek to allow to not show its icon on the taskbar

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

- [x] Closes: #26274 
- [x] Closes: #43093 
<!-- - [ ] 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
- [x] **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)
- [x] **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:
https://github.com/MicrosoftDocs/windows-dev-docs/pull/5824

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

AlwaysOnTop defaults to `false`, and ShowOnTaskbar defaults to `true` to
match the current behavior.

Peek settings after the change:
<img width="1642" height="714" alt="image"
src="https://github.com/user-attachments/assets/e9c8b390-8a8b-40aa-8990-c671b1fffd96"
/>

Peek window behavior with AlwaysOnTop set to `true` and ShowOnTaskbar
set to `false`:
<img width="1813" height="1161" alt="image"
src="https://github.com/user-attachments/assets/e5fbda14-0ba8-4a70-840c-2e8493b7d920"
/>

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

1. Start PowerToys with Peek enabled
2. In settings, turn off close window on focus loss for ease of
validation
3. Peek something
4. Turn on always on top, observe the window is now always on top
5. Turn off always on top, observe the window is no longer always on top
6. Turn off show icon on taskbar, observe the icon disappeared
immediately from the taskbar
7. Turn on show icon on taskbar, observe the icon re-appeared on the
taskbar
2026-07-04 06:02:27 +00:00
Jiří Polášek
e0fe3c48cf CmdPal: Stretch main window card vertically when in expanded mode (#49109)
## Summary of the Pull Request

This PR updates the behavior of CmdPal's main window in expanded
(non-compact) mode to match the previous behavior. The page content now
stretches across the entire window instead of collapsing to the actual
content height.

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

- [x] Closes: #48872 
<!-- - [ ] 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-07-03 22:20:16 -05:00
Jiří Polášek
029dd04ce4 CmdPal: Fix compact mode not toggling the list/command bar at runtime (#49111)
<!-- 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 hooks `ShellPage` to `ISettingsService` and triggers update of
expanded state when the Compact mode settings is changed.

Toggling the compact-mode setting while the palette was open
neverre-evaluated `ShellPage.ExpandedMode`, so disabling compact mode
left the palette collapsed (only the search box visible).

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

- [x] Closes: #48933 
<!-- - [ ] 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-07-03 22:18:32 -05:00
Copilot
3d3cef73da Add Zoom Workspace keyboard shortcut manifest for Shortcut Guide (#49062)
## Summary of the Pull Request

Adds a new Shortcut Guide manifest for Zoom Workspace so Zoom-specific
shortcuts are available in the overlay when `zoom.exe` is active.
The manifest follows existing in-repo schema and naming conventions for
app-specific keyboard shortcut files.

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

- **Manifest addition**
- Added
`src/modules/ShortcutGuide/ShortcutGuide.Ui/Assets/ShortcutGuide/Manifests/Zoom.Zoom.en-US.yml`.
  - Targets `WindowFilter: "zoom.exe"` with `PackageName: Zoom.Zoom`.

- **Shortcut coverage**
  - Added high-value Zoom shortcuts grouped by section:
    - `General`
    - `View`
    - `Meeting controls`
- Includes core actions such as mute/unmute, start/stop video, share,
full-screen, and meeting exit.

- **Schema alignment**
- Uses existing Shortcut Guide manifest structure (`SectionName` /
`Properties` / `Shortcut`) and key token conventions already used in
adjacent manifests.

```yaml
PackageName: Zoom.Zoom
Name: Zoom Workspace
WindowFilter: "zoom.exe"
BackgroundProcess: false
```

## Validation Steps Performed

- Manifest content was kept within existing Shortcut Guide manifest
schema and repository conventions for built-in app manifests.

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
2026-07-03 20:09:54 +02:00
Bryce Cindrich
03e5f3e837 feat(shortcut-guide): add 1Password manifest (#48793)
## Summary of the Pull Request

Adds a Shortcut Guide manifest for the **1Password** desktop app.

- **New manifest:**
`src/modules/ShortcutGuide/ShortcutGuide.Ui/Assets/ShortcutGuide/Manifests/AgileBits.1Password.en-US.yml`:
26 shortcuts for `1Password.exe`, grouped into the same four sections
1Password uses in its in-app Keyboard Shortcuts reference:
- **Basics:** View keyboard shortcuts, Show Quick Access, Lock 1Password
- **Navigation:** Find, Switch to all accounts, Switch accounts &
collections, Back, Forward, Focus next/previous row, Focus right/left
section
- **Selected item:** Copy primary field / password / one-time password,
Open & fill in web browser, Open item in new window, Edit item, Save
item, Reveal concealed fields, Archive item, Delete item
  - **View:** Show/hide sidebar, Zoom in, Zoom out, Actual size
- **No code changes.** The manifest is auto-included via the existing
`Manifests/*.yml` glob in `ShortcutGuide.Ui.csproj`, exactly like the
existing Postman, Slack, Discord, and browser manifests.
- The two literal-digit shortcuts (`Ctrl+1` switch to all accounts,
`Ctrl+0` actual size) use the `<N>` token (`<1>` / `<0>`) per the
manifest spec, and the "Switch accounts & collections" range renders as
`2 - 9`.
- **Documentation:** Added a note in `doc/specs/WinGet Manifest Keyboard
Shortcuts schema.md` documenting the existing **sentence-case** naming
convention for `Name` and `SectionName` (capitalize only the first word
plus proper nouns / product feature names), so future contributors do
not copy an application's title-case shortcut-list styling. The
1Password names in this PR follow that convention, keeping only
feature/product names capitalized (Show Quick Access, Lock 1Password).

## PR Checklist

- [x] Closes: #48792
- [ ] **Communication:** I've discussed this with core contributors
already. <!-- Filed #48792; the v0.100 announcement invites app-shortcut
contributions via PR. Follows the precedent set by #48461 (Postman). -->
- [ ] **Tests:** Added/updated and all pass <!-- N/A: data-only change,
no new code paths. The manifest was validated by deserializing it with
YamlDotNet (the same `Deserializer` used by `ManifestInterpreter`),
confirming all 26 entries and key tokens parse into `ShortcutFile`. -->
- [x] **Localization:** All end-user-facing strings can be localized
<!-- Shortcut names live in the per-language manifest (`*.en-US.yml`);
other locales fall back to en-US, consistent with every existing
manifest. -->
- [x] **Dev docs:** Added/updated <!-- Documented the sentence-case
naming convention for Name / SectionName in doc/specs/WinGet Manifest
Keyboard Shortcuts schema.md. -->
- [ ] **New binaries:** Added on the required places <!-- N/A: the new
manifest is a data asset under an already-shipped, globbed folder. No
new binaries or test projects. -->
- [ ] **Documentation updated:** <!-- N/A: user-facing docs unchanged.
-->
- [x] **Local run:** Built the Shortcut Guide projects and ran the Debug
build with 1Password focused (`Win+Shift+/`); screenshot of the rendered
guide is attached below.

## Detailed Description of the Pull Request / Additional comments

The Shortcut Guide displays per-app shortcuts from YAML manifests,
matched to the foreground window via `WindowFilter`. Adding support for
an app is purely additive: drop a `<PackageName>.<locale>.yml` file in
the `Manifests` folder and it is picked up by the existing build glob
and the index generator.

- `PackageName: AgileBits.1Password` (the WinGet package identifier) and
`WindowFilter: "1Password.exe"` (the desktop app process).
- `Name: 1Password` is the display name shown in the Shortcut Guide app
picker.
- Shortcut names follow the repo's sentence-case convention (now
documented in the schema spec). Recommended is set on the five
highest-frequency / signature actions: Show Quick Access, Lock
1Password, Copy primary field, Copy password, Copy one-time password.

## Validation Steps Performed

- **Schema/parse:** Deserialized the manifest with
`YamlDotNet.Serialization.Deserializer` (the same path
`ManifestInterpreter.YamlToShortcutList` uses). All four sections and 26
entries parse, with 5 marked Recommended. No parse errors.
- **Key rendering:** Verified every key token against `KeyVisual` and
`ShortcutDescriptionToKeysConverter`: `<Space>`/`<Delete>` strip to
their labels, `<Left>`/`<Right>`/`<Up>`/`<Down>` map to arrow glyphs,
`<1>`/`<0>` strip to the literal digit (matching the merged Postman
`<9>`/`<0>` handling), `2 - 9` renders verbatim, and `+` / `-` render as
the literal symbols (as in the bundled Windows Explorer and Shell
manifests).
- **Source fidelity:** The section grouping and every shortcut/modifier
combination match 1Password's in-app Keyboard Shortcuts reference
one-to-one.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

<img width="1462" height="2260" alt="image"
src="https://github.com/user-attachments/assets/e7824a38-cb56-4242-9a6a-31c7a93c03c9"
/>

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-03 14:32:37 +00:00
moooyo
9039451e2f [Quick Accent] Migrate UI to WinUI 3 (#48891)
## Summary of the Pull Request

Migrates the **Quick Accent (PowerAccent)** module's UI from WPF
(`System.Windows.*`) to **WinUI 3 (Windows App SDK)**, following the
pattern used by other migrated modules (ImageResizer, PowerDisplay). The
accent selector is now a self-contained WinUI 3 app
(`PowerToys.PowerAccent.exe`) shipped under `WinUI3Apps`, and
`PowerAccent.Core` is UI-framework-agnostic.


demo:

https://github.com/user-attachments/assets/400c33ee-0fc0-491e-841b-a546438edf91


## PR Checklist

- [x] Closes: #48889
- [x] **Communication:** Tracked task (#48889) agreed with core
contributors
- [x] **Tests:** Added/updated and all pass — new
`PowerAccent.Core.UnitTests` (21 tests for the positioning / DPI math);
existing `PowerAccent.Common.UnitTests` unaffected
- [x] **Localization:** No new localizable end-user strings — new
accessibility metadata uses non-localized
`AutomationProperties.AutomationId`, and the window title is the brand
name `"Quick Accent"` (literal, matching ColorPicker)
- [x] **Dev docs:** Updated `doc/devdocs/modules/quickaccent.md`
- [x] **New binaries:** Added on the required places
- [x] [JSON for
signing](https://github.com/microsoft/PowerToys/blob/main/.pipelines/ESRPSigning_core.json):
the `WinUI3Apps\` PowerAccent payloads are listed in
`ESRPSigning_core.json`
- [x] [WXS for
installer](https://github.com/microsoft/PowerToys/blob/main/installer/PowerToysSetup/Product.wxs):
no manual `Product.wxs` entry — the self-contained `WinUI3Apps` output
(exe + `.pri` + Windows App SDK runtime) is harvested by the
`WinUI3ApplicationsFiles` glob (same as ImageResizer)
- [x] [YML for CI
pipeline](https://github.com/microsoft/PowerToys/blob/main/.pipelines/ci/templates/build-powertoys-steps.yml):
no change needed — `PowerAccent.Core.UnitTests` is discovered by the
existing `**\*UnitTest*.dll` VSTest glob
- [x] [YML for signed
pipeline](https://github.com/microsoft/PowerToys/blob/main/.pipelines/release.yml):
covered by `ESRPSigning_core.json`; no `release.yml` change needed
- [ ] **Documentation updated:** N/A — internal UI-framework migration
with no user-facing behavior change

## Detailed Description of the Pull Request / Additional comments

The migration spans three areas (tracked in #48889):

**UI (WPF → WinUI 3)**
- `PowerAccent.UI` is now a WinUI 3 app shell (custom `Program.Main`,
`WindowsPackageType=None`, `WindowsAppSDKSelfContained=true`).
- The accent selector is a non-activating `TransparentWindow` overlay
shown with `SW_SHOWNA` (never steals focus). It is made always-on-top
only while shown — the WinUIEx `WindowEx.IsAlwaysOnTop` property is
toggled `true` on show / `false` on hide in `OnChangeDisplay` (matching
the WPF original's `Topmost = isActive`), so the dormant,
never-destroyed overlay does not pin a discrete GPU awake on
hybrid-graphics laptops (issue #34849 / PR #41044).
- The accent "pill" selection visual is reproduced with
`VisualStateManager` (WinUI 3 has no `Style.Triggers`).
- **WinUI 3 gotcha:** x:Bind on a Window-rooted XAML initializes only on
`Window.Activated`, which never fires for this `SW_SHOWNA` overlay — so
the selector calls `Bindings.Update()` after `InitializeComponent()`;
without it the `ListView` renders empty.
- **Theme:** the long-lived, never-activated process follows the system
app theme automatically — `App.xaml` leaves `Application.RequestedTheme`
unset, so WinUI re-resolves the `{ThemeResource}` brushes (and retints
the acrylic) on a live light/dark switch with no manual `ThemeListener`
needed.
- **Layout parity with the WPF original:** the bar width hugs its
content (`itemCount × 48`, clamped to the monitor width — computed, not
measured, to avoid a racy `ListView` measure), and each cell pins
`MinWidth=48` (WinUI's `ListViewItem` defaults to 88, which would
otherwise leave wide gaps).
- **Accessibility:** UIA window name + `AutomationId`s on the character
list and description.

**Dependency**
- `PowerAccent.Core` no longer depends on WPF — it raises events and
takes an injected UI-thread marshaller.
- WinForms `SendKeys` → `SendInput` (CsWin32 P/Invoke); WPF-UI (Lepo)
removed; language data moved to the UI-/WinRT-agnostic
`PowerAccent.Common`.
- MVVM via CommunityToolkit.Mvvm with `[ObservableProperty]` **partial
properties** (WinRT-correct, clears MVVMTK0045).

**CI / Build / Installer**
- Signing config, WinUI3Apps glob harvest, and the new unit-test project
— see the checklist above.

## Validation Steps Performed

- **Build:** `x64 Debug` builds with **0 warnings / 0 errors**.
- **Unit tests:** `PowerAccent.Core.UnitTests` — **21/21 pass** (9
anchor positions × DPI 1.0/1.5/2.0, the offset and negative-origin
monitors, caret centering + edge clamping + flip-below).
- **XamlStyler:** `PowerAccentXAML/MainWindow.xaml` passes the passive
format check (CI mode).
- **Manual (single monitor, Top-center, light theme):**
  - Accent popup appears with the full accent list rendered.
- Bar hugs the characters and is centered; cell spacing matches the WPF
original.
- Switching the system theme (light/dark) is followed live by the popup.
- With `show_description` enabled, the description row is wide (≥600px)
and readable, with the accent bar centered above it.
- **Remaining manual validation** (tracked in #48889): multi-monitor,
per-monitor DPI, all 9 `toolbar_position` values, and high-contrast
theme.

---------

Co-authored-by: Yu Leng <yuleng@microsoft.com>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-authored-by: Niels Laute <niels.laute@live.nl>
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-07-03 16:58:04 +08:00
Clint Rutkas
af45c3ec7c Add press-and-hold activation mode to Quick Accent (#48937)
## Summary of the Pull Request

Adds an opt-in **press-and-hold** activation mode to Quick Accent, like
iOS / macOS: hold an accent-capable letter (e.g. `a`) and after a short,
configurable delay the accent picker opens automatically — no separate
trigger key (Space/arrows) required.

This is purely additive. The existing trigger-key modes (`Left/Right
arrow`, `Space`, `Both`) and all serialized settings values are
unchanged.


https://github.com/user-attachments/assets/faec298c-e42c-4fd1-84bd-6e74d1b481a0


### What it does

- Holding a letter types the base letter immediately, then arms the
picker. After the **Hold duration** (default **500 ms**) the toolbar
appears.
- Navigate the options with the arrow keys / Space, then **release the
letter** to insert the selected accent (it replaces the base letter).
- A quick tap types just the letter. Holding and releasing without
selecting leaves the base letter as-is.
- `Ctrl` / `Alt` / `AltGr` / `Win` + letter combinations are left
untouched, so shortcuts like `Ctrl+A` still work.

## PR Checklist

- [ ] **Closes:** N/A — feature enhancement (happy to link a tracking
issue if one is preferred)
- [x] **Communication:** Discussed direction with maintainers;
coordinated with #48891 (see below)
- [ ] **Tests:** No automated tests added — the activation decision
lives in the C++ low-level keyboard hook and isn't reachable from the
existing managed unit-test project. Validated manually (steps below).
Open to guidance on the preferred test surface.
- [x] **Localization:** All new end-user strings are in
`Settings.UI/Strings/en-us/Resources.resw` with translator comments.
- [x] **Dev docs:** `doc/devdocs/modules/quickaccent.md` updated with
the new mode.
- [x] **New binaries:** None.
- [x] **Documentation updated:** Dev docs updated; public Learn docs can
follow once shipped.

## Detailed Description of the Pull Request / Additional comments

- **`PowerAccentKeyboardService` (C++ hook):**
- Append `PressAndHold` to the internal `PowerAccentActivationKey` enum
(value `3`, appended to preserve serialized `0/1/2`).
- Add a `holdDuration` setting and `UpdateHoldDuration(Int32)` to the
WinRT projection (`.idl`).
- In `OnKeyDown`, arm the picker on the held letter itself; the base
letter still types on first press and auto-repeat is swallowed (reuses
the existing `m_toolbarVisible` repeat guard from #36853).
- In `OnKeyUp`, use the hold duration as the minimum-hold release
threshold for this mode (trigger modes keep using `inputTime`).
  - Modifier guard: Ctrl/Alt/AltGr/Win do not arm the mode.
- **Settings model (`Settings.UI.Library`):** append `PressAndHold` to
`PowerAccentActivationKey`; add `hold_duration_ms` (`IntProperty`,
default 500). Existing `settings.json` without the field falls back to
the 500 ms default.
- **`PowerAccent.Core`:** read and forward the hold duration to the
hook, and use it as the popup delay when `PressAndHold` is active.
- **Settings UI:** add the **"Press and hold the letter"** activation
option and a **"Hold duration (ms)"** control that is shown only when
that mode is selected.

### Enum sync note

`PowerAccentActivationKey` exists in both the C++ hook and the managed
settings library and is kept in sync by integer value. `PressAndHold`
was **appended** (never reordered) so existing serialized settings
(`0/1/2`) keep their meaning.

### Coordination with #48891 (Quick Accent WinUI migration)

This lands as its own atomic change on `main`. The overlap with the
in-progress WinUI migration (#48891) is tiny: only `PowerAccent.cs`'s
mode-aware popup delay (a single `Task.Delay` line). The C++ hook,
settings enum/model, and Settings UI are not touched by #48891, so it
can rebase onto this with minimal effort.

## Validation Steps Performed

- Built the full chain in `Debug|x64`:
- `PowerAccent.UI.csproj` → rebuilds the C++
`PowerAccentKeyboardService` projection (incl. `UpdateHoldDuration`) +
`PowerAccent.Core` + `Settings.UI.Library`. **0 errors.**
- `PowerToys.Settings.csproj` → Settings UI XAML / ViewModel / `.resw`
(XamlIndexBuilder search index regenerated). **0 errors.**
- Manual trial of the running module (`PowerToys.PowerAccent.exe`) with
`activation_key = 3`:
- Hold `a` → base letter types immediately; picker opens after ~500 ms;
arrows/Space navigate; releasing inserts the accent (replacing the base
letter).
- Quick tap → base letter only. Hold + release without selecting → base
letter remains.
  - `Ctrl+A` / `Alt`+letter unaffected.

---------

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-07-03 03:43:37 +00:00
102 changed files with 2583 additions and 3473 deletions

View File

@@ -838,6 +838,7 @@ INTRESOURCE
INVALIDARG
invalidoperatioexception
invokecommand
iOS
ipcmanager
ipreviewhandlervisualssetfont
IPTC

View File

@@ -211,12 +211,12 @@
"WinUI3Apps\\NewPlusPackage.msix",
"WinUI3Apps\\PowerToys.NewPlus.ShellExtension.win10.dll",
"PowerAccent.Core.dll",
"PowerAccent.Common.dll",
"PowerToys.PowerAccent.dll",
"PowerToys.PowerAccent.exe",
"WinUI3Apps\\PowerAccent.Core.dll",
"WinUI3Apps\\PowerAccent.Common.dll",
"WinUI3Apps\\PowerToys.PowerAccent.dll",
"WinUI3Apps\\PowerToys.PowerAccent.exe",
"PowerToys.PowerAccentModuleInterface.dll",
"PowerToys.PowerAccentKeyboardService.dll",
"WinUI3Apps\\PowerToys.PowerAccentKeyboardService.dll",
"PowerToys.PowerDisplayModuleInterface.dll",
"WinUI3Apps\\PowerToys.PowerDisplay.dll",
@@ -234,8 +234,8 @@
"PowerToys.WorkspacesWindowArranger.exe",
"PowerToys.WorkspacesEditor.exe",
"PowerToys.WorkspacesEditor.dll",
"WinUI3Apps\\PowerToys.WorkspacesLauncherUI.exe",
"WinUI3Apps\\PowerToys.WorkspacesLauncherUI.dll",
"PowerToys.WorkspacesLauncherUI.exe",
"PowerToys.WorkspacesLauncherUI.dll",
"PowerToys.WorkspacesModuleInterface.dll",
"PowerToys.WorkspacesCsharpLibrary.dll",

View File

@@ -806,6 +806,10 @@
<Platform Solution="*|ARM64" Project="ARM64" />
<Platform Solution="*|x64" Project="x64" />
</Project>
<Project Path="src/modules/poweraccent/PowerAccent.Core.UnitTests/PowerAccent.Core.UnitTests.csproj">
<Platform Solution="*|ARM64" Project="ARM64" />
<Platform Solution="*|x64" Project="x64" />
</Project>
<Project Path="src/modules/poweraccent/PowerAccent.Common/PowerAccent.Common.csproj">
<Platform Solution="*|ARM64" Project="ARM64" />
<Platform Solution="*|x64" Project="x64" />
@@ -1027,7 +1031,7 @@
<Platform Solution="*|x64" Project="x64" />
</Project>
<Project Path="src/modules/Workspaces/WorkspacesLauncher/WorkspacesLauncher.vcxproj" Id="2cac093e-5fcf-4102-9c2c-ac7dd5d9eb96" />
<Project Path="src/modules/Workspaces/WorkspacesLauncherUI.WinUI/WorkspacesLauncherUI.WinUI.csproj">
<Project Path="src/modules/Workspaces/WorkspacesLauncherUI/WorkspacesLauncherUI.csproj">
<Platform Solution="*|ARM64" Project="ARM64" />
<Platform Solution="*|x64" Project="x64" />
</Project>
@@ -1110,14 +1114,6 @@
<File Path="src/Solution.props" />
<File Path="src/Version.props" />
</Folder>
<Folder Name="/src/" />
<Folder Name="/src/modules/" />
<Folder Name="/src/modules/Workspaces/">
<Project Path="src/modules/Workspaces/WorkspacesLauncherUI.UnitTests/WorkspacesLauncherUI.UnitTests.csproj">
<Platform Solution="*|ARM64" Project="ARM64" />
<Platform Solution="*|x64" Project="x64" />
</Project>
</Folder>
<Project Path="src/ActionRunner/ActionRunner.vcxproj" Id="d29ddd63-e2cf-4657-9fd5-2aede4257e5d">
<BuildDependency Project="src/common/updating/updating.vcxproj" />
</Project>

View File

@@ -15,14 +15,15 @@ Quick Accent (formerly known as Power Accent) is a PowerToys module that allows
## Architecture
The Quick Accent module consists of four main components:
The Quick Accent module consists of five projects:
```
poweraccent/
├── PowerAccent.Core/ # Core component containing Language Sets
├── PowerAccent.UI/ # The character selector UI
├── PowerAccentKeyboardService/ # Keyboard Hook
── PowerAccentModuleInterface/ # DLL interface
├── PowerAccent.Common/ # Language data, character mappings, LetterKey enum
├── PowerAccent.Core/ # Accent logic, settings, positioning, usage statistics
├── PowerAccent.UI/ # WinUI 3 character selector app (PowerToys.PowerAccent.exe)
── PowerAccentKeyboardService/ # WinRT keyboard-hook component
└── PowerAccentModuleInterface/ # Native runner module DLL
```
### Module Interface (PowerAccentModuleInterface)
@@ -32,21 +33,32 @@ The Module Interface, implemented in `PowerAccentModuleInterface/dllmain.cpp`, i
- Managing module lifecycle (enable/disable/settings)
- Launching and terminating the PowerToys.PowerAccent.exe process
### Shared Data (PowerAccent.Common)
`PowerAccent.Common` holds the UI- and runtime-agnostic data the other projects share:
- The language / character-set definitions and per-letter accent mappings
- The managed `LetterKey` enum (kept in sync with the WinRT `LetterKey` in `PowerAccentKeyboardService/KeyboardListener.idl`)
It has no UI or WinRT dependencies and is unit-tested in isolation (`PowerAccent.Common.UnitTests`).
### Core Logic (PowerAccent.Core)
The Core component contains:
- Main accent character logic
- Keyboard input detection
- Character mappings for different languages
- Management of language sets and special characters (currency, math symbols, etc.)
- Usage statistics for frequently used characters
- Main accent character logic, consuming the language data from `PowerAccent.Common`
- Toolbar positioning math (9 anchor points with per-monitor DPI) and settings handling
- Management of special characters (currency, math symbols, etc.) and usage statistics
Core carries no UI-framework dependency: it raises events and accepts a UI-thread marshaller delegate instead of touching WPF/WinUI directly, and its positioning math is covered by `PowerAccent.Core.UnitTests`.
### UI Layer (PowerAccent.UI)
The UI component is responsible for:
- Displaying the toolbar with accent options
- Handling user selection of accented characters
- Managing the visual positioning of the toolbar
The UI component is a self-contained **WinUI 3 (Windows App SDK)** app, migrated from WPF.
It is responsible for:
- Displaying the accent toolbar — a non-activating, always-on-top `TransparentWindow` overlay shown with `SW_SHOWNA` so it never steals focus from the app being typed into
- Handling selection and the toolbar's sizing / positioning
- Following the system theme while the long-lived process runs
It builds to `PowerToys.PowerAccent.exe` together with its `.pri` and the bundled Windows App SDK runtime, all under the `WinUI3Apps` output folder.
### Keyboard Service (PowerAccentKeyboardService)
@@ -59,13 +71,26 @@ This component:
### Activation Mechanism
The Quick Accent is activated when:
Quick Accent supports two activation styles, selected by the **Activation key** setting.
**Trigger-key modes** (`Left/Right arrow`, `Space`, or `Both` — the default):
1. A user presses and holds a character key (e.g., 'a')
2. User presses the trigger key
3. After a brief delay (around 300ms per setting), the accent toolbar appears
4. The user can select an accented variant using the trigger key
5. Upon releasing the keys, the selected accented character is inserted
**Press-and-hold mode** (`Press and hold the letter`, iOS/macOS style, opt-in):
1. A user presses and holds an accent-capable character key (e.g., 'a'); the base
letter is typed immediately
2. After the configured **Hold duration** (around 500ms per setting), the accent
toolbar appears automatically — no separate trigger key is required
3. The user navigates the options with the arrow keys or Space
4. Upon releasing the letter, the selected accent replaces the base letter; if no
option was selected, the base letter that was already typed simply remains
5. A quick tap (shorter than the Hold duration) types the base letter only, and
modifier combinations (Ctrl/Alt/AltGr/Win + letter) are left untouched
### Character Sets
The module includes multiple language-specific character sets and special character sets:
@@ -115,5 +140,5 @@ To directly debug the Quick Accent UI component:
5. Start debugging by pressing `F5` or clicking the "*Start*" button
6. Verify that the debugger breaks at your breakpoint and you can inspect variables and step through code
**Known issue**: You may encounter approximately 78 errors during the start of debugging.<br>
**Solution**: If you encounter errors, right-click on the **PowerAccent** folder in Solution Explorer and select "*Rebuild*". After rebuilding, start debugging again.
**Known issue**: A first incremental build can surface transient errors (for example from CsWinRT projection / WinUI XAML codegen ordering).<br>
**Solution**: Right-click the **PowerAccent** folder in Solution Explorer and select "*Rebuild*", then start debugging again.

View File

@@ -109,7 +109,7 @@ Per Application/Package one or more Keyboard manifests can be declared. Every ma
<details>
<summary><b>SectionName</b> - Name of the category of shortcuts</summary>
Name of the section of shortcuts.
Name of the section of shortcuts. Use sentence case, the same convention described under `Name` below.
**Special sections**:
@@ -126,6 +126,10 @@ Special sections start with an identifier enclosed between `<` and `>`. This dec
Name of the shortcut. This is the name that will be displayed in the interpreter.
**Casing**:
By convention, shortcut names (and `SectionName` values) use **sentence case**: capitalize only the first word plus any proper nouns or product/feature names. For example, prefer `Reopen last closed tab` over `Reopen Last Closed Tab`, but keep `Open History`, `Quit Slack`, and `Show Quick Access` capitalized because those are application feature names. Match the casing the application uses for its own features rather than copying the title-case styling some apps apply to their entire shortcut list.
</details>

View File

@@ -1359,6 +1359,7 @@ static void HandleDragMove(POINT pt)
RECT maxRect;
GetWindowRect(g_dragTarget, &maxRect);
int maxW = maxRect.right - maxRect.left;
int maxH = maxRect.bottom - maxRect.top;
ShowWindow(g_dragTarget, SW_RESTORE);
@@ -1366,9 +1367,12 @@ static void HandleDragMove(POINT pt)
int restoredW = g_dragWndRect.right - g_dragWndRect.left;
int restoredH = g_dragWndRect.bottom - g_dragWndRect.top;
float ratio = (maxW > 0) ? static_cast<float>(g_dragStart.x - maxRect.left) / maxW : 0.5f;
int newX = g_dragStart.x - static_cast<int>(restoredW * ratio);
int newY = g_dragStart.y - (GetSystemMetrics(SM_CYFRAME) + GetSystemMetrics(SM_CYCAPTION) / 2);
// Preserve the relative grab position in both axes so the cursor stays
// at the same proportional spot within the restored window.
float ratioL = (maxW > 0) ? static_cast<float>(g_dragStart.x - maxRect.left) / maxW : 0.5f;
float ratioT = (maxH > 0) ? static_cast<float>(g_dragStart.y - maxRect.top) / maxH : 0.5f;
int newX = g_dragStart.x - static_cast<int>(restoredW * ratioL);
int newY = g_dragStart.y - static_cast<int>(restoredH * ratioT);
SetWindowPos(g_dragTarget, nullptr, newX, newY, 0, 0,
SWP_NOSIZE | SWP_NOZORDER | SWP_NOACTIVATE | SWP_ASYNCWINDOWPOS);

View File

@@ -0,0 +1,226 @@
PackageName: AgileBits.1Password
Name: 1Password
WindowFilter: "1Password.exe"
BackgroundProcess: false
Shortcuts:
- SectionName: Basics
Properties:
- Name: View keyboard shortcuts
Shortcut:
- Win: false
Ctrl: true
Shift: true
Alt: false
Keys:
- "/"
- Name: Show Quick Access
Recommended: true
Shortcut:
- Win: false
Ctrl: true
Shift: true
Alt: false
Keys:
- "<Space>"
- Name: Lock 1Password
Recommended: true
Shortcut:
- Win: false
Ctrl: true
Shift: true
Alt: false
Keys:
- L
- SectionName: Navigation
Properties:
- Name: Find
Shortcut:
- Win: false
Ctrl: true
Shift: false
Alt: false
Keys:
- F
- Name: Switch to all accounts
Shortcut:
- Win: false
Ctrl: true
Shift: false
Alt: false
Keys:
- "<1>"
- Name: "Switch accounts & collections"
Shortcut:
- Win: false
Ctrl: true
Shift: false
Alt: false
Keys:
- '2 - 9'
- Name: Back
Shortcut:
- Win: false
Ctrl: false
Shift: false
Alt: true
Keys:
- "<Left>"
- Name: Forward
Shortcut:
- Win: false
Ctrl: false
Shift: false
Alt: true
Keys:
- "<Right>"
- Name: Focus next row
Shortcut:
- Win: false
Ctrl: false
Shift: false
Alt: false
Keys:
- "<Down>"
- Name: Focus previous row
Shortcut:
- Win: false
Ctrl: false
Shift: false
Alt: false
Keys:
- "<Up>"
- Name: Focus right section
Shortcut:
- Win: false
Ctrl: false
Shift: false
Alt: false
Keys:
- "<Right>"
- Name: Focus left section
Shortcut:
- Win: false
Ctrl: false
Shift: false
Alt: false
Keys:
- "<Left>"
- SectionName: Selected item
Properties:
- Name: Copy primary field
Recommended: true
Shortcut:
- Win: false
Ctrl: true
Shift: false
Alt: false
Keys:
- C
- Name: Copy password
Recommended: true
Shortcut:
- Win: false
Ctrl: true
Shift: true
Alt: false
Keys:
- C
- Name: Copy one-time password
Recommended: true
Shortcut:
- Win: false
Ctrl: true
Shift: false
Alt: true
Keys:
- C
- Name: "Open & fill in web browser"
Shortcut:
- Win: false
Ctrl: true
Shift: true
Alt: false
Keys:
- F
- Name: Open item in new window
Shortcut:
- Win: false
Ctrl: true
Shift: false
Alt: false
Keys:
- O
- Name: Edit item
Shortcut:
- Win: false
Ctrl: true
Shift: false
Alt: false
Keys:
- E
- Name: Save item
Shortcut:
- Win: false
Ctrl: true
Shift: false
Alt: false
Keys:
- S
- Name: Reveal concealed fields
Shortcut:
- Win: false
Ctrl: true
Shift: false
Alt: false
Keys:
- R
- Name: Archive item
Shortcut:
- Win: false
Ctrl: false
Shift: false
Alt: false
Keys:
- "<Delete>"
- Name: Delete item
Shortcut:
- Win: false
Ctrl: true
Shift: false
Alt: false
Keys:
- "<Delete>"
- SectionName: View
Properties:
- Name: Show/hide sidebar
Shortcut:
- Win: false
Ctrl: true
Shift: true
Alt: false
Keys:
- D
- Name: Zoom in
Shortcut:
- Win: false
Ctrl: true
Shift: false
Alt: false
Keys:
- "+"
- Name: Zoom out
Shortcut:
- Win: false
Ctrl: true
Shift: false
Alt: false
Keys:
- "-"
- Name: Actual size
Shortcut:
- Win: false
Ctrl: true
Shift: false
Alt: false
Keys:
- "<0>"

View File

@@ -0,0 +1,126 @@
PackageName: Zoom.Zoom
Name: Zoom Workspace
WindowFilter: "zoom.exe"
BackgroundProcess: false
Shortcuts:
- SectionName: General
Properties:
- Name: Navigate between Zoom windows
Shortcut:
- Win: false
Ctrl: false
Shift: false
Alt: false
Keys:
- F6
- Name: Show or hide floating meeting controls
Shortcut:
- Win: false
Ctrl: true
Shift: true
Alt: true
Keys:
- H
- SectionName: View
Properties:
- Name: Switch to active speaker view
Shortcut:
- Win: false
Ctrl: false
Shift: false
Alt: true
Keys:
- F1
- Name: Switch to gallery view
Shortcut:
- Win: false
Ctrl: false
Shift: false
Alt: true
Keys:
- F2
- Name: Enter or exit full screen
Shortcut:
- Win: false
Ctrl: false
Shift: false
Alt: true
Keys:
- F
- Name: View previous page in gallery
Shortcut:
- Win: false
Ctrl: false
Shift: false
Alt: false
Keys:
- PageUp
- Name: View next page in gallery
Shortcut:
- Win: false
Ctrl: false
Shift: false
Alt: false
Keys:
- PageDown
- SectionName: Meeting controls
Properties:
- Name: Mute or unmute audio
Recommended: true
Shortcut:
- Win: false
Ctrl: false
Shift: false
Alt: true
Keys:
- A
- Name: Start or stop video
Recommended: true
Shortcut:
- Win: false
Ctrl: false
Shift: false
Alt: true
Keys:
- V
- Name: Raise or lower hand
Shortcut:
- Win: false
Ctrl: false
Shift: false
Alt: true
Keys:
- Y
- Name: Open invite window
Shortcut:
- Win: false
Ctrl: false
Shift: false
Alt: true
Keys:
- I
- Name: Open share screen window or stop sharing
Recommended: true
Shortcut:
- Win: false
Ctrl: false
Shift: false
Alt: true
Keys:
- S
- Name: Pause or resume screen sharing
Shortcut:
- Win: false
Ctrl: false
Shift: false
Alt: true
Keys:
- T
- Name: Prompt to leave or end meeting
Shortcut:
- Win: false
Ctrl: false
Shift: false
Alt: true
Keys:
- Q

View File

@@ -46,7 +46,7 @@ void LauncherUIHelper::LaunchUI()
GetModuleFileName(NULL, buffer, MAX_PATH);
std::wstring path = std::filesystem::path(buffer).parent_path();
auto res = AppLauncher::LaunchApp(path + L"\\WinUI3Apps\\PowerToys.WorkspacesLauncherUI.exe", L"", false);
auto res = AppLauncher::LaunchApp(path + L"\\PowerToys.WorkspacesLauncherUI.exe", L"", false);
if (res.isOk())
{
auto value = res.value();

View File

@@ -1,235 +0,0 @@
// Copyright (c) Microsoft Corporation
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
using Microsoft.VisualStudio.TestTools.UnitTesting;
using WorkspacesLauncherUI.Data;
namespace WorkspacesLauncherUI.UnitTests
{
/// <summary>
/// Tests for ApplicationWrapper struct field mapping.
/// All fields must be accessible and hold correct values after deserialization.
/// </summary>
[TestClass]
public class ApplicationDataModelTests
{
[TestMethod]
[TestCategory("DataModel")]
public void AppField_ApplicationName_StoresDisplayName()
{
var app = new ApplicationWrapper { Application = "Visual Studio Code" };
Assert.AreEqual("Visual Studio Code", app.Application);
}
[TestMethod]
[TestCategory("DataModel")]
public void AppField_ExecutablePath_StoresFullPathWithSpaces()
{
var app = new ApplicationWrapper { ApplicationPath = @"C:\Users\test\AppData\Local\Programs\Microsoft VS Code\Code.exe" };
Assert.AreEqual(@"C:\Users\test\AppData\Local\Programs\Microsoft VS Code\Code.exe", app.ApplicationPath);
}
[TestMethod]
[TestCategory("DataModel")]
public void AppField_WindowTitle_StoresActiveWindowTitle()
{
var app = new ApplicationWrapper { Title = "MyProject - Visual Studio Code" };
Assert.AreEqual("MyProject - Visual Studio Code", app.Title);
}
[TestMethod]
[TestCategory("DataModel")]
public void AppField_PackageFullName_StoresUwpPackageIdentifier()
{
var app = new ApplicationWrapper { PackageFullName = "Microsoft.WindowsTerminal_1.21.0.0_x64__8wekyb3d8bbwe" };
Assert.AreEqual("Microsoft.WindowsTerminal_1.21.0.0_x64__8wekyb3d8bbwe", app.PackageFullName);
}
[TestMethod]
[TestCategory("DataModel")]
public void AppField_AppUserModelId_StoresAumidForPackagedApps()
{
var app = new ApplicationWrapper { AppUserModelId = "Microsoft.WindowsTerminal_8wekyb3d8bbwe!App" };
Assert.AreEqual("Microsoft.WindowsTerminal_8wekyb3d8bbwe!App", app.AppUserModelId);
}
[TestMethod]
[TestCategory("DataModel")]
public void AppField_PwaAppId_StoresChromeOrEdgePwaIdentifier()
{
var app = new ApplicationWrapper { PwaAppId = "fmgjjmmmlfnkbppncijlocphclkkleod" };
Assert.AreEqual("fmgjjmmmlfnkbppncijlocphclkkleod", app.PwaAppId);
}
[TestMethod]
[TestCategory("DataModel")]
public void AppField_CliArguments_StoresLaunchArgumentsExactly()
{
var app = new ApplicationWrapper { CommandLineArguments = "--reuse-window --goto file.ts:42" };
Assert.AreEqual("--reuse-window --goto file.ts:42", app.CommandLineArguments);
}
[TestMethod]
[TestCategory("DataModel")]
public void AppField_IsElevated_StoresAdminRunningState()
{
var app = new ApplicationWrapper { IsElevated = true };
Assert.IsTrue(app.IsElevated);
}
[TestMethod]
[TestCategory("DataModel")]
public void AppField_CanLaunchElevated_StoresElevationCapability()
{
var app = new ApplicationWrapper { CanLaunchElevated = true };
Assert.IsTrue(app.CanLaunchElevated);
}
[TestMethod]
[TestCategory("DataModel")]
public void AppField_Minimized_StoresMinimizedWindowState()
{
var app = new ApplicationWrapper { Minimized = true };
Assert.IsTrue(app.Minimized);
}
[TestMethod]
[TestCategory("DataModel")]
public void AppField_Maximized_StoresMaximizedWindowState()
{
var app = new ApplicationWrapper { Maximized = true };
Assert.IsTrue(app.Maximized);
}
[TestMethod]
[TestCategory("DataModel")]
public void AppField_MonitorIndex_StoresTargetDisplayNumber()
{
var app = new ApplicationWrapper { Monitor = 2 };
Assert.AreEqual(2, app.Monitor);
}
[TestMethod]
[TestCategory("DataModel")]
public void AppField_WindowPosition_StoresRectangleCoordinates()
{
var pos = new PositionWrapper { X = 100, Y = 200, Width = 800, Height = 600 };
var app = new ApplicationWrapper { Position = pos };
Assert.AreEqual(100, app.Position.X);
Assert.AreEqual(200, app.Position.Y);
Assert.AreEqual(800, app.Position.Width);
Assert.AreEqual(600, app.Position.Height);
}
[TestMethod]
[TestCategory("DataModel")]
public void AppDefaults_StringFields_AreNullBeforeDeserialization()
{
ApplicationWrapper app = default;
Assert.IsNull(app.Application);
Assert.IsNull(app.ApplicationPath);
Assert.IsNull(app.Title);
Assert.IsNull(app.PackageFullName);
Assert.IsNull(app.AppUserModelId);
Assert.IsNull(app.PwaAppId);
Assert.IsNull(app.CommandLineArguments);
}
[TestMethod]
[TestCategory("DataModel")]
public void AppDefaults_BooleanFields_AreFalseBeforeDeserialization()
{
ApplicationWrapper app = default;
Assert.IsFalse(app.IsElevated);
Assert.IsFalse(app.CanLaunchElevated);
Assert.IsFalse(app.Minimized);
Assert.IsFalse(app.Maximized);
}
[TestMethod]
[TestCategory("DataModel")]
public void AppDefaults_MonitorIndex_IsZeroPrimaryMonitor()
{
ApplicationWrapper app = default;
Assert.AreEqual(0, app.Monitor);
}
[TestMethod]
[TestCategory("DataModel")]
public void AppConfig_AdminAppOnSecondMonitor_AllFieldsPopulated()
{
var app = new ApplicationWrapper
{
Application = "Registry Editor",
ApplicationPath = @"C:\Windows\regedit.exe",
Title = "Registry Editor",
PackageFullName = string.Empty,
AppUserModelId = string.Empty,
PwaAppId = string.Empty,
CommandLineArguments = string.Empty,
IsElevated = true,
CanLaunchElevated = true,
Minimized = false,
Maximized = false,
Position = new PositionWrapper { X = 1920, Y = 0, Width = 1024, Height = 768 },
Monitor = 1,
};
Assert.IsTrue(app.IsElevated);
Assert.AreEqual(1, app.Monitor);
Assert.AreEqual(1920, app.Position.X);
}
[TestMethod]
[TestCategory("DataModel")]
public void AppConfig_MinimizedOnThirdMonitor_StateAndMonitorCorrect()
{
var app = new ApplicationWrapper
{
Application = "Notepad",
ApplicationPath = @"C:\Windows\System32\notepad.exe",
Minimized = true,
Maximized = false,
Position = new PositionWrapper { X = 3840, Y = 0, Width = 800, Height = 600 },
Monitor = 2,
};
Assert.IsTrue(app.Minimized);
Assert.IsFalse(app.Maximized);
Assert.AreEqual(2, app.Monitor);
}
[TestMethod]
[TestCategory("DataModel")]
public void AppConfig_PathWithParenthesesAndSpaces_PreservedExactly()
{
string complexPath = @"C:\Program Files (x86)\Microsoft Office\root\Office16\WINWORD.EXE";
var app = new ApplicationWrapper { ApplicationPath = complexPath };
Assert.AreEqual(complexPath, app.ApplicationPath);
}
[TestMethod]
[TestCategory("DataModel")]
public void AppConfig_ExplicitEmptyStrings_AreEmptyNotNull()
{
var app = new ApplicationWrapper
{
Application = string.Empty,
ApplicationPath = string.Empty,
Title = string.Empty,
PackageFullName = string.Empty,
AppUserModelId = string.Empty,
PwaAppId = string.Empty,
CommandLineArguments = string.Empty,
};
Assert.AreEqual(string.Empty, app.Application);
Assert.AreEqual(string.Empty, app.ApplicationPath);
Assert.AreEqual(string.Empty, app.PackageFullName);
}
}
}

View File

@@ -1,141 +0,0 @@
// Copyright (c) Microsoft Corporation
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
using Microsoft.VisualStudio.TestTools.UnitTesting;
using WorkspacesLauncherUI.Utils;
namespace WorkspacesLauncherUI.UnitTests
{
/// <summary>
/// Tests for DashCaseNamingPolicy and StringUtils.
/// These utilities control JSON property name mapping for IPC messages.
/// </summary>
[TestClass]
public class IpcJsonPropertyNamingTests
{
private readonly DashCaseNamingPolicy _policy = DashCaseNamingPolicy.Instance;
[TestMethod]
[TestCategory("Serialization")]
public void NamingPolicy_ApplicationPath_MapsTo_application_path()
{
Assert.AreEqual("application-path", _policy.ConvertName("ApplicationPath"));
}
[TestMethod]
[TestCategory("Serialization")]
public void NamingPolicy_Application_MapsTo_application()
{
Assert.AreEqual("application", _policy.ConvertName("Application"));
}
[TestMethod]
[TestCategory("Serialization")]
public void NamingPolicy_AppUserModelId_MapsTo_app_user_model_id()
{
Assert.AreEqual("app-user-model-id", _policy.ConvertName("AppUserModelId"));
}
[TestMethod]
[TestCategory("Serialization")]
public void NamingPolicy_LowercaseInput_RemainsUnchanged()
{
Assert.AreEqual("title", _policy.ConvertName("title"));
}
[TestMethod]
[TestCategory("Serialization")]
public void NamingPolicy_SingleUppercaseChar_PreservedAsIs()
{
Assert.AreEqual("X", _policy.ConvertName("X"));
}
[TestMethod]
[TestCategory("Serialization")]
public void NamingPolicy_SingleLowercaseChar_PreservedAsIs()
{
Assert.AreEqual("x", _policy.ConvertName("x"));
}
// Exact IPC property names that must match the C++ side
[TestMethod]
[TestCategory("Serialization")]
public void NamingPolicy_PackageFullName_MatchesCppIpcKey()
{
Assert.AreEqual("package-full-name", _policy.ConvertName("PackageFullName"));
}
[TestMethod]
[TestCategory("Serialization")]
public void NamingPolicy_AppUserModelId_MatchesCppIpcKey()
{
Assert.AreEqual("app-user-model-id", _policy.ConvertName("AppUserModelId"));
}
[TestMethod]
[TestCategory("Serialization")]
public void NamingPolicy_PwaAppId_MatchesCppIpcKey()
{
Assert.AreEqual("pwa-app-id", _policy.ConvertName("PwaAppId"));
}
[TestMethod]
[TestCategory("Serialization")]
public void NamingPolicy_CommandLineArguments_MatchesCppIpcKey()
{
Assert.AreEqual("command-line-arguments", _policy.ConvertName("CommandLineArguments"));
}
[TestMethod]
[TestCategory("Serialization")]
public void NamingPolicy_IsElevated_MatchesCppIpcKey()
{
Assert.AreEqual("is-elevated", _policy.ConvertName("IsElevated"));
}
[TestMethod]
[TestCategory("Serialization")]
public void NamingPolicy_CanLaunchElevated_MatchesCppIpcKey()
{
Assert.AreEqual("can-launch-elevated", _policy.ConvertName("CanLaunchElevated"));
}
[TestMethod]
[TestCategory("Serialization")]
public void NamingPolicy_ApplicationPath_MatchesCppIpcKey()
{
Assert.AreEqual("application-path", _policy.ConvertName("ApplicationPath"));
}
[TestMethod]
[TestCategory("Serialization")]
public void NamingPolicy_Singleton_ReturnsSameInstanceEveryTime()
{
var instance1 = DashCaseNamingPolicy.Instance;
var instance2 = DashCaseNamingPolicy.Instance;
Assert.AreSame(instance1, instance2);
}
[TestMethod]
[TestCategory("Serialization")]
public void StringConversion_TwoUppercaseLetters_InsertsDashBetween()
{
Assert.AreEqual("a-b", "AB".UpperCamelCaseToDashCase());
}
[TestMethod]
[TestCategory("Serialization")]
public void StringConversion_AllLowercase_NoTransformation()
{
Assert.AreEqual("alllowercase", "alllowercase".UpperCamelCaseToDashCase());
}
[TestMethod]
[TestCategory("Serialization")]
public void StringConversion_NumbersInMiddle_PreservedWithDashBeforeNextUpper()
{
Assert.AreEqual("version2-test", "Version2Test".UpperCamelCaseToDashCase());
}
}
}

View File

@@ -1,539 +0,0 @@
// Copyright (c) Microsoft Corporation
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
using System;
using System.Globalization;
using System.Text;
using System.Text.Json;
using Microsoft.VisualStudio.TestTools.UnitTesting;
using WorkspacesLauncherUI.Data;
namespace WorkspacesLauncherUI.UnitTests
{
/// <summary>
/// Tests for JSON deserialization of IPC messages received from the C++ launcher engine.
/// These messages drive the entire Launcher UI state and must remain stable
/// across any future UI or data layer changes.
/// </summary>
[TestClass]
public class IpcMessageDeserializationTests
{
private const string FullIpcMessage = @"{
""processId"": 12345,
""apps"": {
""appLaunchInfos"": [
{
""application"": {
""application"": ""Visual Studio Code"",
""application-path"": ""C:\\Users\\test\\AppData\\Local\\Programs\\Microsoft VS Code\\Code.exe"",
""title"": ""MyProject - Visual Studio Code"",
""package-full-name"": """",
""app-user-model-id"": """",
""pwa-app-id"": """",
""command-line-arguments"": ""--reuse-window"",
""is-elevated"": false,
""can-launch-elevated"": true,
""minimized"": false,
""maximized"": true,
""position"": { ""X"": 0, ""Y"": 0, ""width"": 1920, ""height"": 1080 },
""monitor"": 0
},
""state"": 2
},
{
""application"": {
""application"": ""Windows Terminal"",
""application-path"": ""C:\\Program Files\\WindowsApps\\Microsoft.WindowsTerminal_1.0.0.0_x64__8wekyb3d8bbwe\\wt.exe"",
""title"": ""PowerShell"",
""package-full-name"": ""Microsoft.WindowsTerminal_1.0.0.0_x64__8wekyb3d8bbwe"",
""app-user-model-id"": ""Microsoft.WindowsTerminal_8wekyb3d8bbwe!App"",
""pwa-app-id"": """",
""command-line-arguments"": """",
""is-elevated"": false,
""can-launch-elevated"": false,
""minimized"": false,
""maximized"": false,
""position"": { ""X"": 960, ""Y"": 0, ""width"": 960, ""height"": 540 },
""monitor"": 0
},
""state"": 0
},
{
""application"": {
""application"": ""Notepad"",
""application-path"": ""C:\\Windows\\System32\\notepad.exe"",
""title"": ""Untitled - Notepad"",
""package-full-name"": """",
""app-user-model-id"": """",
""pwa-app-id"": """",
""command-line-arguments"": """",
""is-elevated"": false,
""can-launch-elevated"": true,
""minimized"": true,
""maximized"": false,
""position"": { ""X"": 100, ""Y"": 100, ""width"": 800, ""height"": 600 },
""monitor"": 1
},
""state"": 3
}
]
}
}";
[TestMethod]
[TestCategory("Deserialization")]
public void IpcMessage_WithMultipleApps_ExtractsLauncherProcessId()
{
var parser = new AppLaunchData();
var result = parser.Deserialize(FullIpcMessage);
Assert.AreEqual(12345, result.LauncherProcessID);
}
[TestMethod]
[TestCategory("Deserialization")]
public void IpcMessage_WithThreeApps_DeserializesAllAppEntries()
{
var parser = new AppLaunchData();
var result = parser.Deserialize(FullIpcMessage);
Assert.AreEqual(3, result.AppLaunchInfos.AppLaunchInfoList.Count);
}
[TestMethod]
[TestCategory("Deserialization")]
public void IpcMessage_Win32Application_DeserializesAllApplicationFields()
{
var parser = new AppLaunchData();
var result = parser.Deserialize(FullIpcMessage);
var vscode = result.AppLaunchInfos.AppLaunchInfoList[0];
Assert.AreEqual("Visual Studio Code", vscode.Application.Application);
Assert.AreEqual(@"C:\Users\test\AppData\Local\Programs\Microsoft VS Code\Code.exe", vscode.Application.ApplicationPath);
Assert.AreEqual("MyProject - Visual Studio Code", vscode.Application.Title);
Assert.AreEqual(string.Empty, vscode.Application.PackageFullName);
Assert.AreEqual(string.Empty, vscode.Application.AppUserModelId);
Assert.AreEqual(string.Empty, vscode.Application.PwaAppId);
Assert.AreEqual("--reuse-window", vscode.Application.CommandLineArguments);
Assert.IsFalse(vscode.Application.IsElevated);
Assert.IsTrue(vscode.Application.CanLaunchElevated);
Assert.IsFalse(vscode.Application.Minimized);
Assert.IsTrue(vscode.Application.Maximized);
Assert.AreEqual(0, vscode.Application.Monitor);
}
[TestMethod]
[TestCategory("Deserialization")]
public void IpcMessage_Win32Application_DeserializesWindowPosition()
{
var parser = new AppLaunchData();
var result = parser.Deserialize(FullIpcMessage);
var pos = result.AppLaunchInfos.AppLaunchInfoList[0].Application.Position;
Assert.AreEqual(0, pos.X);
Assert.AreEqual(0, pos.Y);
Assert.AreEqual(1920, pos.Width);
Assert.AreEqual(1080, pos.Height);
}
[TestMethod]
[TestCategory("Deserialization")]
public void IpcMessage_PackagedUwpApp_DeserializesPackageIdentifiers()
{
var parser = new AppLaunchData();
var result = parser.Deserialize(FullIpcMessage);
var terminal = result.AppLaunchInfos.AppLaunchInfoList[1];
Assert.AreEqual("Windows Terminal", terminal.Application.Application);
Assert.AreEqual("Microsoft.WindowsTerminal_1.0.0.0_x64__8wekyb3d8bbwe", terminal.Application.PackageFullName);
Assert.AreEqual("Microsoft.WindowsTerminal_8wekyb3d8bbwe!App", terminal.Application.AppUserModelId);
}
[TestMethod]
[TestCategory("Deserialization")]
public void IpcMessage_StateValueTwo_MapsToLaunchedAndMovedEnum()
{
var parser = new AppLaunchData();
var result = parser.Deserialize(FullIpcMessage);
Assert.AreEqual(LaunchingState.LaunchedAndMoved, result.AppLaunchInfos.AppLaunchInfoList[0].State);
}
[TestMethod]
[TestCategory("Deserialization")]
public void IpcMessage_StateValueZero_MapsToWaitingEnum()
{
var parser = new AppLaunchData();
var result = parser.Deserialize(FullIpcMessage);
Assert.AreEqual(LaunchingState.Waiting, result.AppLaunchInfos.AppLaunchInfoList[1].State);
}
[TestMethod]
[TestCategory("Deserialization")]
public void IpcMessage_StateValueThree_MapsToFailedEnum()
{
var parser = new AppLaunchData();
var result = parser.Deserialize(FullIpcMessage);
Assert.AreEqual(LaunchingState.Failed, result.AppLaunchInfos.AppLaunchInfoList[2].State);
}
[TestMethod]
[TestCategory("Deserialization")]
public void IpcMessage_MinimizedWindow_DeserializesWindowStateFlags()
{
var parser = new AppLaunchData();
var result = parser.Deserialize(FullIpcMessage);
var notepad = result.AppLaunchInfos.AppLaunchInfoList[2];
Assert.IsTrue(notepad.Application.Minimized);
Assert.IsFalse(notepad.Application.Maximized);
}
[TestMethod]
[TestCategory("Deserialization")]
public void IpcMessage_SecondaryMonitor_DeserializesMonitorIndex()
{
var parser = new AppLaunchData();
var result = parser.Deserialize(FullIpcMessage);
Assert.AreEqual(1, result.AppLaunchInfos.AppLaunchInfoList[2].Application.Monitor);
}
[TestMethod]
[TestCategory("Deserialization")]
public void IpcMessage_ProgressiveWebApp_DeserializesPwaIdentifier()
{
string pwaMessage = @"{
""processId"": 100,
""apps"": {
""appLaunchInfos"": [
{
""application"": {
""application"": ""Gmail"",
""application-path"": ""C:\\Program Files\\Google\\Chrome\\Application\\chrome.exe"",
""title"": ""Gmail"",
""package-full-name"": """",
""app-user-model-id"": """",
""pwa-app-id"": ""fmgjjmmmlfnkbppncijlocphclkkleod"",
""command-line-arguments"": """",
""is-elevated"": false,
""can-launch-elevated"": false,
""minimized"": false,
""maximized"": false,
""position"": { ""X"": 0, ""Y"": 0, ""width"": 800, ""height"": 600 },
""monitor"": 0
},
""state"": 1
}
]
}
}";
var parser = new AppLaunchData();
var result = parser.Deserialize(pwaMessage);
var gmail = result.AppLaunchInfos.AppLaunchInfoList[0];
Assert.AreEqual("fmgjjmmmlfnkbppncijlocphclkkleod", gmail.Application.PwaAppId);
Assert.AreEqual(LaunchingState.Launched, gmail.State);
}
[TestMethod]
[TestCategory("Deserialization")]
public void IpcMessage_ElevatedProcess_DeserializesAdminFlags()
{
string elevatedMessage = @"{
""processId"": 200,
""apps"": {
""appLaunchInfos"": [
{
""application"": {
""application"": ""Registry Editor"",
""application-path"": ""C:\\Windows\\regedit.exe"",
""title"": ""Registry Editor"",
""package-full-name"": """",
""app-user-model-id"": """",
""pwa-app-id"": """",
""command-line-arguments"": """",
""is-elevated"": true,
""can-launch-elevated"": true,
""minimized"": false,
""maximized"": false,
""position"": { ""X"": 100, ""Y"": 100, ""width"": 1024, ""height"": 768 },
""monitor"": 0
},
""state"": 2
}
]
}
}";
var parser = new AppLaunchData();
var result = parser.Deserialize(elevatedMessage);
var regedit = result.AppLaunchInfos.AppLaunchInfoList[0];
Assert.IsTrue(regedit.Application.IsElevated);
Assert.IsTrue(regedit.Application.CanLaunchElevated);
}
[TestMethod]
[TestCategory("Deserialization")]
public void IpcMessage_SingleAppWorkspace_DeserializesSuccessfully()
{
string singleAppMessage = @"{
""processId"": 1,
""apps"": {
""appLaunchInfos"": [
{
""application"": {
""application"": ""Notepad"",
""application-path"": ""C:\\Windows\\System32\\notepad.exe"",
""title"": """",
""package-full-name"": """",
""app-user-model-id"": """",
""pwa-app-id"": """",
""command-line-arguments"": """",
""is-elevated"": false,
""can-launch-elevated"": false,
""minimized"": false,
""maximized"": false,
""position"": { ""X"": 0, ""Y"": 0, ""width"": 400, ""height"": 300 },
""monitor"": 0
},
""state"": 0
}
]
}
}";
var parser = new AppLaunchData();
var result = parser.Deserialize(singleAppMessage);
Assert.AreEqual(1, result.AppLaunchInfos.AppLaunchInfoList.Count);
Assert.AreEqual("Notepad", result.AppLaunchInfos.AppLaunchInfoList[0].Application.Application);
}
[TestMethod]
[TestCategory("Deserialization")]
public void IpcMessage_ZeroApps_ReturnsEmptyListWithValidProcessId()
{
string emptyAppsMessage = @"{
""processId"": 42,
""apps"": {
""appLaunchInfos"": []
}
}";
var parser = new AppLaunchData();
var result = parser.Deserialize(emptyAppsMessage);
Assert.AreEqual(42, result.LauncherProcessID);
Assert.AreEqual(0, result.AppLaunchInfos.AppLaunchInfoList.Count);
}
[TestMethod]
[TestCategory("Deserialization")]
[ExpectedException(typeof(JsonException))]
public void IpcMessage_MalformedJson_ThrowsJsonException()
{
var parser = new AppLaunchData();
parser.Deserialize("not valid json {{{");
}
[TestMethod]
[TestCategory("Deserialization")]
[ExpectedException(typeof(JsonException))]
public void IpcMessage_EmptyPayload_ThrowsJsonException()
{
var parser = new AppLaunchData();
parser.Deserialize(string.Empty);
}
[TestMethod]
[TestCategory("Deserialization")]
public void IpcMessage_LeftOfPrimaryMonitor_DeserializesNegativeCoordinates()
{
string negativePositionMessage = @"{
""processId"": 1,
""apps"": {
""appLaunchInfos"": [
{
""application"": {
""application"": ""Notepad"",
""application-path"": ""C:\\Windows\\System32\\notepad.exe"",
""title"": """",
""package-full-name"": """",
""app-user-model-id"": """",
""pwa-app-id"": """",
""command-line-arguments"": """",
""is-elevated"": false,
""can-launch-elevated"": false,
""minimized"": false,
""maximized"": false,
""position"": { ""X"": -1920, ""Y"": -200, ""width"": 800, ""height"": 600 },
""monitor"": 1
},
""state"": 0
}
]
}
}";
var parser = new AppLaunchData();
var result = parser.Deserialize(negativePositionMessage);
var pos = result.AppLaunchInfos.AppLaunchInfoList[0].Application.Position;
Assert.AreEqual(-1920, pos.X);
Assert.AreEqual(-200, pos.Y);
}
[TestMethod]
[TestCategory("Deserialization")]
public void IpcMessage_FourthMonitor_DeserializesHighMonitorIndex()
{
string multiMonitorMessage = @"{
""processId"": 1,
""apps"": {
""appLaunchInfos"": [
{
""application"": {
""application"": ""App"",
""application-path"": ""C:\\app.exe"",
""title"": """",
""package-full-name"": """",
""app-user-model-id"": """",
""pwa-app-id"": """",
""command-line-arguments"": """",
""is-elevated"": false,
""can-launch-elevated"": false,
""minimized"": false,
""maximized"": false,
""position"": { ""X"": 3840, ""Y"": 0, ""width"": 1920, ""height"": 1080 },
""monitor"": 3
},
""state"": 0
}
]
}
}";
var parser = new AppLaunchData();
var result = parser.Deserialize(multiMonitorMessage);
Assert.AreEqual(3, result.AppLaunchInfos.AppLaunchInfoList[0].Application.Monitor);
}
[TestMethod]
[TestCategory("Deserialization")]
public void IpcMessage_AllFiveStateValues_MapToCorrectEnumMembers()
{
for (int stateValue = 0; stateValue <= 4; stateValue++)
{
string template = @"{""processId"": 1,""apps"": {""appLaunchInfos"": [{""application"": {""application"": ""App"",""application-path"": ""C:\\app.exe"",""title"": """",""package-full-name"": """",""app-user-model-id"": """",""pwa-app-id"": """",""command-line-arguments"": """",""is-elevated"": false,""can-launch-elevated"": false,""minimized"": false,""maximized"": false,""position"": { ""X"": 0, ""Y"": 0, ""width"": 100, ""height"": 100 },""monitor"": 0},""state"": STATE_PLACEHOLDER}]}}";
string message = template.Replace("STATE_PLACEHOLDER", stateValue.ToString(CultureInfo.InvariantCulture));
var parser = new AppLaunchData();
var result = parser.Deserialize(message);
Assert.AreEqual((LaunchingState)stateValue, result.AppLaunchInfos.AppLaunchInfoList[0].State);
}
}
[TestMethod]
[TestCategory("Deserialization")]
public void IpcMessage_CommandLineWithSpecialChars_PreservesArgumentsExactly()
{
string cliMessage = @"{
""processId"": 1,
""apps"": {
""appLaunchInfos"": [
{
""application"": {
""application"": ""VS Code"",
""application-path"": ""C:\\Code.exe"",
""title"": """",
""package-full-name"": """",
""app-user-model-id"": """",
""pwa-app-id"": """",
""command-line-arguments"": ""--new-window --goto C:\\project\\file.ts:42"",
""is-elevated"": false,
""can-launch-elevated"": false,
""minimized"": false,
""maximized"": false,
""position"": { ""X"": 0, ""Y"": 0, ""width"": 100, ""height"": 100 },
""monitor"": 0
},
""state"": 0
}
]
}
}";
var parser = new AppLaunchData();
var result = parser.Deserialize(cliMessage);
Assert.AreEqual(@"--new-window --goto C:\project\file.ts:42", result.AppLaunchInfos.AppLaunchInfoList[0].Application.CommandLineArguments);
}
[TestMethod]
[TestCategory("Deserialization")]
public void IpcMessage_JapaneseAppName_DeserializesUnicodeCorrectly()
{
string unicodeMessage = @"{
""processId"": 1,
""apps"": {
""appLaunchInfos"": [
{
""application"": {
""application"": ""\u30E1\u30E2\u5E33"",
""application-path"": ""C:\\Windows\\System32\\notepad.exe"",
""title"": ""\u7121\u984C - \u30E1\u30E2\u5E33"",
""package-full-name"": """",
""app-user-model-id"": """",
""pwa-app-id"": """",
""command-line-arguments"": """",
""is-elevated"": false,
""can-launch-elevated"": false,
""minimized"": false,
""maximized"": false,
""position"": { ""X"": 0, ""Y"": 0, ""width"": 400, ""height"": 300 },
""monitor"": 0
},
""state"": 0
}
]
}
}";
var parser = new AppLaunchData();
var result = parser.Deserialize(unicodeMessage);
Assert.AreEqual("\u30E1\u30E2\u5E33", result.AppLaunchInfos.AppLaunchInfoList[0].Application.Application);
Assert.AreEqual("\u7121\u984C - \u30E1\u30E2\u5E33", result.AppLaunchInfos.AppLaunchInfoList[0].Application.Title);
}
[TestMethod]
[TestCategory("Deserialization")]
public void IpcMessage_TenAppWorkspace_DeserializesAllWithCorrectPositionsAndStates()
{
var appEntries = new StringBuilder();
for (int i = 0; i < 10; i++)
{
if (i > 0)
{
appEntries.Append(',');
}
string entry = string.Create(CultureInfo.InvariantCulture, $@"{{""application"": {{""application"": ""App{i}"",""application-path"": ""C:\\app{i}.exe"",""title"": ""Window {i}"",""package-full-name"": """",""app-user-model-id"": """",""pwa-app-id"": """",""command-line-arguments"": """",""is-elevated"": false,""can-launch-elevated"": false,""minimized"": false,""maximized"": false,""position"": {{ ""X"": {i * 100}, ""Y"": 0, ""width"": 400, ""height"": 300 }},""monitor"": {i % 3}}},""state"": {i % 5}}}");
appEntries.Append(entry);
}
string manyAppsMessage = string.Create(CultureInfo.InvariantCulture, $@"{{""processId"": 9999,""apps"": {{""appLaunchInfos"": [{appEntries}]}}}}");
var parser = new AppLaunchData();
var result = parser.Deserialize(manyAppsMessage);
Assert.AreEqual(10, result.AppLaunchInfos.AppLaunchInfoList.Count);
for (int i = 0; i < 10; i++)
{
Assert.AreEqual(string.Create(CultureInfo.InvariantCulture, $"App{i}"), result.AppLaunchInfos.AppLaunchInfoList[i].Application.Application);
Assert.AreEqual(i * 100, result.AppLaunchInfos.AppLaunchInfoList[i].Application.Position.X);
Assert.AreEqual(i % 3, result.AppLaunchInfos.AppLaunchInfoList[i].Application.Monitor);
Assert.AreEqual((LaunchingState)(i % 5), result.AppLaunchInfos.AppLaunchInfoList[i].State);
}
}
}
}

View File

@@ -1,73 +0,0 @@
// Copyright (c) Microsoft Corporation
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
using System;
using Microsoft.VisualStudio.TestTools.UnitTesting;
using WorkspacesLauncherUI.Data;
namespace WorkspacesLauncherUI.UnitTests
{
/// <summary>
/// Tests for the LaunchingState enum values and their integer mapping.
/// The C++ launcher engine sends state as integer values over IPC.
/// These integer values MUST remain stable across the migration.
/// </summary>
[TestClass]
public class LaunchStateEnumContractTests
{
[TestMethod]
[TestCategory("DataModel")]
public void EnumContract_WaitingState_MapsToIntegerZero()
{
Assert.AreEqual(0, (int)LaunchingState.Waiting);
}
[TestMethod]
[TestCategory("DataModel")]
public void EnumContract_LaunchedState_MapsToIntegerOne()
{
Assert.AreEqual(1, (int)LaunchingState.Launched);
}
[TestMethod]
[TestCategory("DataModel")]
public void EnumContract_LaunchedAndMovedState_MapsToIntegerTwo()
{
Assert.AreEqual(2, (int)LaunchingState.LaunchedAndMoved);
}
[TestMethod]
[TestCategory("DataModel")]
public void EnumContract_FailedState_MapsToIntegerThree()
{
Assert.AreEqual(3, (int)LaunchingState.Failed);
}
[TestMethod]
[TestCategory("DataModel")]
public void EnumContract_CanceledState_MapsToIntegerFour()
{
Assert.AreEqual(4, (int)LaunchingState.Canceled);
}
[TestMethod]
[TestCategory("DataModel")]
public void EnumContract_TotalMemberCount_IsExactlyFiveMatchingCppHeader()
{
var values = Enum.GetValues(typeof(LaunchingState));
Assert.AreEqual(5, values.Length, "LaunchingState must have exactly 5 values to match C++ LaunchingStateEnum.h");
}
[TestMethod]
[TestCategory("DataModel")]
public void EnumContract_IntToEnumCast_RoundTripsForAllValues()
{
for (int i = 0; i <= 4; i++)
{
var state = (LaunchingState)i;
Assert.AreEqual(i, (int)state);
}
}
}
}

View File

@@ -1,169 +0,0 @@
// Copyright (c) Microsoft Corporation
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
using Microsoft.VisualStudio.TestTools.UnitTesting;
using WorkspacesLauncherUI.Data;
using WorkspacesLauncherUI.Models;
namespace WorkspacesLauncherUI.UnitTests
{
/// <summary>
/// Tests for the AppLaunching model which drives UI display:
/// loading indicator, state glyph, and state color.
/// </summary>
[TestClass]
public class LaunchStatusDisplayLogicTests
{
[TestMethod]
[TestCategory("Model")]
public void LoadingSpinner_WhenStateIsWaiting_IsVisible()
{
var app = new AppLaunching { LaunchState = LaunchingState.Waiting };
Assert.IsTrue(app.Loading);
}
[TestMethod]
[TestCategory("Model")]
public void LoadingSpinner_WhenStateIsLaunched_RemainsVisibleUntilMoved()
{
var app = new AppLaunching { LaunchState = LaunchingState.Launched };
Assert.IsTrue(app.Loading);
}
[TestMethod]
[TestCategory("Model")]
public void LoadingSpinner_WhenStateIsLaunchedAndMoved_IsHidden()
{
var app = new AppLaunching { LaunchState = LaunchingState.LaunchedAndMoved };
Assert.IsFalse(app.Loading);
}
[TestMethod]
[TestCategory("Model")]
public void LoadingSpinner_WhenStateIsFailed_IsHidden()
{
var app = new AppLaunching { LaunchState = LaunchingState.Failed };
Assert.IsFalse(app.Loading);
}
[TestMethod]
[TestCategory("Model")]
public void LoadingSpinner_WhenStateIsCanceled_IsHidden()
{
var app = new AppLaunching { LaunchState = LaunchingState.Canceled };
Assert.IsFalse(app.Loading);
}
[TestMethod]
[TestCategory("Model")]
public void StatusIcon_WhenSuccessful_ShowsGreenCheckmarkGlyph()
{
var app = new AppLaunching { LaunchState = LaunchingState.LaunchedAndMoved };
Assert.AreEqual("\U0000F78C", app.StateGlyph, "LaunchedAndMoved should show checkmark glyph");
}
[TestMethod]
[TestCategory("Model")]
public void StatusIcon_WhenFailed_ShowsRedErrorGlyph()
{
var app = new AppLaunching { LaunchState = LaunchingState.Failed };
Assert.AreEqual("\U0000EF2C", app.StateGlyph, "Failed should show error glyph");
}
[TestMethod]
[TestCategory("Model")]
public void StatusIcon_WhenCanceled_ShowsRedErrorGlyph()
{
var app = new AppLaunching { LaunchState = LaunchingState.Canceled };
Assert.AreEqual("\U0000EF2C", app.StateGlyph, "Canceled should fall through to default error glyph");
}
[TestMethod]
[TestCategory("Model")]
public void StatusColor_WhenSuccessful_IsGreenRgb0_128_0()
{
var app = new AppLaunching { LaunchState = LaunchingState.LaunchedAndMoved };
var color = app.StateColorValue;
Assert.AreNotEqual(default(Windows.UI.Color), color);
Assert.AreEqual(0, color.R, "Green color R component");
Assert.AreEqual(128, color.G, "Green color G component");
Assert.AreEqual(0, color.B, "Green color B component");
Assert.AreEqual(255, color.A, "Green color A component");
}
[TestMethod]
[TestCategory("Model")]
public void StatusColor_WhenFailed_IsRedRgb254_0_0()
{
var app = new AppLaunching { LaunchState = LaunchingState.Failed };
var color = app.StateColorValue;
Assert.AreNotEqual(default(Windows.UI.Color), color);
Assert.AreEqual(254, color.R, "Red color R component");
Assert.AreEqual(0, color.G, "Red color G component");
Assert.AreEqual(0, color.B, "Red color B component");
}
[TestMethod]
[TestCategory("Model")]
public void StatusColor_WhenCanceled_IsRedRgb254_0_0()
{
var app = new AppLaunching { LaunchState = LaunchingState.Canceled };
var color = app.StateColorValue;
Assert.AreNotEqual(default(Windows.UI.Color), color);
Assert.AreEqual(254, color.R, "Canceled should fall through to red");
}
[TestMethod]
[TestCategory("Model")]
public void AppName_SetToString_ReturnsExactValue()
{
var app = new AppLaunching { Name = "Test Application" };
Assert.AreEqual("Test Application", app.Name);
}
[TestMethod]
[TestCategory("Model")]
public void AppName_SetToEmpty_ReturnsEmptyString()
{
var app = new AppLaunching { Name = string.Empty };
Assert.AreEqual(string.Empty, app.Name);
}
[TestMethod]
[TestCategory("Model")]
public void StateProgression_WaitingToSuccess_TransitionsSpinnerToGreenCheckmark()
{
var app = new AppLaunching { Name = "Test", LaunchState = LaunchingState.Waiting };
Assert.IsTrue(app.Loading);
app.LaunchState = LaunchingState.Launched;
Assert.IsTrue(app.Loading);
app.LaunchState = LaunchingState.LaunchedAndMoved;
Assert.IsFalse(app.Loading);
Assert.AreEqual("\U0000F78C", app.StateGlyph);
var color = app.StateColorValue;
Assert.AreEqual(0, color.R);
Assert.AreEqual(128, color.G);
}
[TestMethod]
[TestCategory("Model")]
public void StateProgression_WaitingToFailed_TransitionsSpinnerToRedError()
{
var app = new AppLaunching { Name = "Test", LaunchState = LaunchingState.Waiting };
Assert.IsTrue(app.Loading);
app.LaunchState = LaunchingState.Failed;
Assert.IsFalse(app.Loading);
Assert.AreEqual("\U0000EF2C", app.StateGlyph);
var color = app.StateColorValue;
Assert.AreEqual(254, color.R);
Assert.AreEqual(0, color.G);
}
}
}

View File

@@ -1,292 +0,0 @@
// Copyright (c) Microsoft Corporation
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
using System.Collections.Specialized;
using System.ComponentModel;
using System.Globalization;
using System.Text;
using Microsoft.VisualStudio.TestTools.UnitTesting;
using WorkspacesLauncherUI.Data;
using WorkspacesLauncherUI.Models;
using WorkspacesLauncherUI.ViewModels;
namespace WorkspacesLauncherUI.UnitTests
{
/// <summary>
/// Tests for MainViewModel IPC message handling and state management.
/// MainViewModel is the core of the Launcher UI — it receives IPC messages
/// from the C++ launcher engine and populates the AppsListed collection
/// that the UI binds to.
/// </summary>
[TestClass]
public class LauncherViewModelStateManagementTests
{
[TestMethod]
[TestCategory("ViewModel")]
public void ReceiveIpcMessage_ValidPayload_PopulatesAppsListedCollection()
{
using var vm = new MainViewModel();
string message = CreateIpcMessage(("App1", @"C:\app1.exe", LaunchingState.Waiting), ("App2", @"C:\app2.exe", LaunchingState.Launched));
SimulateIpcMessage(message);
Assert.AreEqual(2, vm.AppsListed.Count);
}
[TestMethod]
[TestCategory("ViewModel")]
public void ReceiveIpcMessage_ValidPayload_MapsAppNamesFromJson()
{
using var vm = new MainViewModel();
string message = CreateIpcMessage(("Visual Studio Code", @"C:\Code.exe", LaunchingState.Waiting), ("Windows Terminal", @"C:\wt.exe", LaunchingState.Launched));
SimulateIpcMessage(message);
Assert.AreEqual("Visual Studio Code", vm.AppsListed[0].Name);
Assert.AreEqual("Windows Terminal", vm.AppsListed[1].Name);
}
[TestMethod]
[TestCategory("ViewModel")]
public void ReceiveIpcMessage_MixedStates_MapsEachAppToCorrectState()
{
using var vm = new MainViewModel();
string message = CreateIpcMessage(
("App1", @"C:\app1.exe", LaunchingState.Waiting),
("App2", @"C:\app2.exe", LaunchingState.Launched),
("App3", @"C:\app3.exe", LaunchingState.LaunchedAndMoved),
("App4", @"C:\app4.exe", LaunchingState.Failed));
SimulateIpcMessage(message);
Assert.AreEqual(LaunchingState.Waiting, vm.AppsListed[0].LaunchState);
Assert.AreEqual(LaunchingState.Launched, vm.AppsListed[1].LaunchState);
Assert.AreEqual(LaunchingState.LaunchedAndMoved, vm.AppsListed[2].LaunchState);
Assert.AreEqual(LaunchingState.Failed, vm.AppsListed[3].LaunchState);
}
[TestMethod]
[TestCategory("ViewModel")]
public void ReceiveIpcMessage_ValidPayload_PreservesExecutablePaths()
{
using var vm = new MainViewModel();
string message = CreateIpcMessage(("Notepad", @"C:\Windows\System32\notepad.exe", LaunchingState.Waiting));
SimulateIpcMessage(message);
Assert.AreEqual(@"C:\Windows\System32\notepad.exe", vm.AppsListed[0].AppPath);
}
[TestMethod]
[TestCategory("ViewModel")]
public void ReceiveIpcMessage_PackagedApp_MapsPackageNameAndAumid()
{
using var vm = new MainViewModel();
string message = @"{
""processId"": 1,
""apps"": {
""appLaunchInfos"": [
{
""application"": {
""application"": ""Terminal"",
""application-path"": ""C:\\wt.exe"",
""title"": """",
""package-full-name"": ""Microsoft.WindowsTerminal_1.0_x64__8wekyb3d8bbwe"",
""app-user-model-id"": ""Microsoft.WindowsTerminal_8wekyb3d8bbwe!App"",
""pwa-app-id"": """",
""command-line-arguments"": """",
""is-elevated"": false,
""can-launch-elevated"": false,
""minimized"": false,
""maximized"": false,
""position"": { ""X"": 0, ""Y"": 0, ""width"": 100, ""height"": 100 },
""monitor"": 0
},
""state"": 0
}
]
}
}";
SimulateIpcMessage(message);
Assert.AreEqual("Microsoft.WindowsTerminal_1.0_x64__8wekyb3d8bbwe", vm.AppsListed[0].PackagedName);
Assert.AreEqual("Microsoft.WindowsTerminal_8wekyb3d8bbwe!App", vm.AppsListed[0].Aumid);
}
[TestMethod]
[TestCategory("ViewModel")]
public void ReceiveIpcMessage_PwaApp_MapsPwaAppIdentifier()
{
using var vm = new MainViewModel();
string message = @"{
""processId"": 1,
""apps"": {
""appLaunchInfos"": [
{
""application"": {
""application"": ""Gmail"",
""application-path"": ""C:\\chrome.exe"",
""title"": """",
""package-full-name"": """",
""app-user-model-id"": """",
""pwa-app-id"": ""abc123"",
""command-line-arguments"": """",
""is-elevated"": false,
""can-launch-elevated"": false,
""minimized"": false,
""maximized"": false,
""position"": { ""X"": 0, ""Y"": 0, ""width"": 100, ""height"": 100 },
""monitor"": 0
},
""state"": 0
}
]
}
}";
SimulateIpcMessage(message);
Assert.AreEqual("abc123", vm.AppsListed[0].PwaAppId);
}
[TestMethod]
[TestCategory("ViewModel")]
public void ReceiveIpcMessage_AnyUpdate_RaisesPropertyChangedForDataBinding()
{
using var vm = new MainViewModel();
bool propertyChangedFired = false;
string changedPropertyName = null;
vm.PropertyChanged += (sender, args) =>
{
propertyChangedFired = true;
changedPropertyName = args.PropertyName;
};
string message = CreateIpcMessage(("App", @"C:\app.exe", LaunchingState.Waiting));
SimulateIpcMessage(message);
Assert.IsTrue(propertyChangedFired, "PropertyChanged should fire when AppsListed is updated");
Assert.AreEqual("AppsListed", changedPropertyName);
}
[TestMethod]
[TestCategory("ViewModel")]
public void ReceiveIpcMessage_ProgressUpdates_ReplacesEntireCollectionEachTime()
{
using var vm = new MainViewModel();
string msg1 = CreateIpcMessage(("App1", @"C:\app1.exe", LaunchingState.Waiting), ("App2", @"C:\app2.exe", LaunchingState.Waiting));
SimulateIpcMessage(msg1);
Assert.AreEqual(2, vm.AppsListed.Count);
Assert.AreEqual(LaunchingState.Waiting, vm.AppsListed[0].LaunchState);
string msg2 = CreateIpcMessage(("App1", @"C:\app1.exe", LaunchingState.Launched), ("App2", @"C:\app2.exe", LaunchingState.Waiting));
SimulateIpcMessage(msg2);
Assert.AreEqual(2, vm.AppsListed.Count);
Assert.AreEqual(LaunchingState.Launched, vm.AppsListed[0].LaunchState);
string msg3 = CreateIpcMessage(("App1", @"C:\app1.exe", LaunchingState.LaunchedAndMoved), ("App2", @"C:\app2.exe", LaunchingState.LaunchedAndMoved));
SimulateIpcMessage(msg3);
Assert.AreEqual(LaunchingState.LaunchedAndMoved, vm.AppsListed[0].LaunchState);
Assert.AreEqual(LaunchingState.LaunchedAndMoved, vm.AppsListed[1].LaunchState);
}
[TestMethod]
[TestCategory("ViewModel")]
public void ReceiveIpcMessage_SomeAppsFail_AllowsMixedSuccessAndFailure()
{
using var vm = new MainViewModel();
string message = CreateIpcMessage(
("App1", @"C:\app1.exe", LaunchingState.LaunchedAndMoved),
("App2", @"C:\app2.exe", LaunchingState.Failed),
("App3", @"C:\app3.exe", LaunchingState.LaunchedAndMoved));
SimulateIpcMessage(message);
Assert.AreEqual(LaunchingState.LaunchedAndMoved, vm.AppsListed[0].LaunchState);
Assert.AreEqual(LaunchingState.Failed, vm.AppsListed[1].LaunchState);
Assert.AreEqual(LaunchingState.LaunchedAndMoved, vm.AppsListed[2].LaunchState);
}
[TestMethod]
[TestCategory("ViewModel")]
public void ReceiveIpcMessage_CanceledState_ReflectedInCollection()
{
using var vm = new MainViewModel();
string message = CreateIpcMessage(("App1", @"C:\app1.exe", LaunchingState.LaunchedAndMoved), ("App2", @"C:\app2.exe", LaunchingState.Canceled));
SimulateIpcMessage(message);
Assert.AreEqual(LaunchingState.Canceled, vm.AppsListed[1].LaunchState);
}
[TestMethod]
[TestCategory("ViewModel")]
public void ReceiveIpcMessage_EmptyAppList_SetsCollectionToEmpty()
{
using var vm = new MainViewModel();
string message = @"{ ""processId"": 1, ""apps"": { ""appLaunchInfos"": [] } }";
SimulateIpcMessage(message);
Assert.AreEqual(0, vm.AppsListed.Count);
}
[TestMethod]
[TestCategory("ViewModel")]
public void ReceiveIpcMessage_CorruptedPayload_GracefullyIgnoredWithoutCrash()
{
using var vm = new MainViewModel();
SimulateIpcMessage("this is not json");
Assert.AreEqual(0, vm.AppsListed.Count);
}
[TestMethod]
[TestCategory("ViewModel")]
public void ReceiveIpcMessage_EmptyString_GracefullyIgnoredWithoutCrash()
{
using var vm = new MainViewModel();
SimulateIpcMessage(string.Empty);
Assert.AreEqual(0, vm.AppsListed.Count);
}
[TestMethod]
[TestCategory("ViewModel")]
public void DisposeViewModel_SingleCall_CompletesWithoutException()
{
var vm = new MainViewModel();
vm.Dispose();
}
[TestMethod]
[TestCategory("ViewModel")]
public void DisposeViewModel_MultipleCalls_RemainsIdempotent()
{
var vm = new MainViewModel();
vm.Dispose();
vm.Dispose();
}
private static void SimulateIpcMessage(string message)
{
App.IPCMessageReceivedCallback?.Invoke(message);
}
private static string CreateIpcMessage(params (string Name, string Path, LaunchingState State)[] apps)
{
var sb = new StringBuilder();
sb.Append(@"{ ""processId"": 1, ""apps"": { ""appLaunchInfos"": [");
for (int i = 0; i < apps.Length; i++)
{
if (i > 0)
{
sb.Append(',');
}
var (name, path, state) = apps[i];
string escapedPath = path.Replace(@"\", @"\\");
string appJson = string.Create(CultureInfo.InvariantCulture, $@"{{""application"": {{""application"": ""{name}"",""application-path"": ""{escapedPath}"",""title"": """",""package-full-name"": """",""app-user-model-id"": """",""pwa-app-id"": """",""command-line-arguments"": """",""is-elevated"": false,""can-launch-elevated"": false,""minimized"": false,""maximized"": false,""position"": {{ ""X"": 0, ""Y"": 0, ""width"": 100, ""height"": 100 }},""monitor"": 0}},""state"": {(int)state}}}");
sb.Append(appJson);
}
sb.Append("]}}");
return sb.ToString();
}
}
}

View File

@@ -1,105 +0,0 @@
# WorkspacesLauncherUI Unit Tests
Unit tests for the Workspaces Launcher UI (WinUI 3). These validate the data layer, ViewModel, and display logic that drives the workspace launch progress window.
## Prerequisites
- Visual Studio 2022 17.4+ or Visual Studio 2026
- .NET SDK (see `global.json` in repo root)
- Submodules initialized: `git submodule update --init --recursive`
## Build
From this directory:
```powershell
# Quick build (auto-detects platform)
& "$env:RepoRoot\tools\build\build.cmd"
# Or with explicit options
& "$env:RepoRoot\tools\build\build.cmd" -Platform arm64 -Configuration Debug
```
If you get NuGet restore errors on first build:
```powershell
& "$env:RepoRoot\tools\build\build-essentials.cmd"
```
## Run Tests
### Option 1: dotnet test (recommended for CI)
```powershell
dotnet test "<output-dir>\tests\WorkspacesLauncherUI.Tests\PowerToys.WorkspacesLauncherUI.Tests.dll" --verbosity normal
```
The output directory depends on your platform/config. For arm64 Debug:
```powershell
dotnet test "arm64\Debug\tests\WorkspacesLauncherUI.Tests\PowerToys.WorkspacesLauncherUI.Tests.dll" --verbosity normal
```
### Option 2: Visual Studio Test Explorer
1. Open `PowerToys.slnx` in Visual Studio
2. Build the `WorkspacesLauncherUI.UnitTests` project
3. Open Test Explorer (`Ctrl+E, T`)
4. Run all tests in `PowerToys.WorkspacesLauncherUI.Tests`
### Option 3: Filter by category
```powershell
dotnet test <dll-path> --filter "TestCategory=Scenario"
dotnet test <dll-path> --filter "TestCategory=Deserialization"
dotnet test <dll-path> --filter "TestCategory=ViewModel"
dotnet test <dll-path> --filter "TestCategory=Model"
dotnet test <dll-path> --filter "TestCategory=Serialization"
dotnet test <dll-path> --filter "TestCategory=DataModel"
dotnet test <dll-path> --filter "TestCategory=Converter"
```
### Generate TRX Report
```powershell
dotnet test <dll-path> --logger "trx;LogFileName=TestResults.trx"
```
Report saved to `TestResults/TestResults.trx`.
## Test Categories
| Category | File | What It Validates |
|----------|------|-------------------|
| `Deserialization` | `IpcMessageDeserializationTests.cs` | C++ launcher engine JSON → C# data models |
| `ViewModel` | `LauncherViewModelStateManagementTests.cs` | IPC callback → ObservableCollection pipeline |
| `Model` | `LaunchStatusDisplayLogicTests.cs` | Spinner/glyph/color for each launch state |
| `Scenario` | `UserWorkflowIntegrationTests.cs` | Full user workflows (launch, cancel, fail) |
| `Serialization` | `IpcJsonPropertyNamingTests.cs` | JSON key names match C++ IPC protocol |
| `DataModel` | `WindowPositionDataTests.cs` | Window coordinates and equality |
| `DataModel` | `ApplicationDataModelTests.cs` | All application fields |
| `DataModel` | `LaunchStateEnumContractTests.cs` | Enum integers match `LaunchingStateEnum.h` |
| `Converter` | `StatusIndicatorVisibilityTests.cs` | Loading → Visibility toggle |
## When to Run
- **After IPC contract changes**: Deserialization + Serialization categories
- **After UI state changes**: Model + ViewModel categories
- **After dependency updates**: All tests to verify no regressions
## Adding New Tests
Follow the naming convention: `{WhatIsUnderTest}_{GivenCondition}_{ExpectedBehavior}`
Example:
```csharp
[TestMethod]
[TestCategory("ViewModel")]
public void ReceiveIpcMessage_NewFieldAdded_DeserializesWithoutBreakingExistingFields()
```
## Note on Color Assertions
Color tests use `AppLaunching.StateColorValue` (returns `Windows.UI.Color`) instead of
`StateColor` (returns `SolidColorBrush`) because WinUI brush creation requires a UI thread.
The `StateColorValue` property exposes the same ARGB values for headless test validation.

View File

@@ -1,343 +0,0 @@
// Copyright (c) Microsoft Corporation
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
using System;
using System.Globalization;
using System.Linq;
using System.Text;
using Microsoft.VisualStudio.TestTools.UnitTesting;
using WorkspacesLauncherUI.Data;
using WorkspacesLauncherUI.Models;
using WorkspacesLauncherUI.ViewModels;
namespace WorkspacesLauncherUI.UnitTests
{
/// <summary>
/// End-to-end scenario tests that simulate complete user workflows
/// through the Launcher UI. These verify the full pipeline:
/// IPC JSON message → Deserialization → ViewModel → Model properties.
/// </summary>
[TestClass]
public class UserWorkflowIntegrationTests
{
[TestMethod]
[TestCategory("Scenario")]
public void UserLaunchesWorkspace_ThreeApps_AllProgressFromWaitingToSuccess()
{
using var vm = new MainViewModel();
SimulateIpcMessage(BuildMessage(
1234,
App("Visual Studio Code", @"C:\Code.exe", LaunchingState.Waiting),
App("Windows Terminal", @"C:\wt.exe", LaunchingState.Waiting),
App("Microsoft Edge", @"C:\edge.exe", LaunchingState.Waiting)));
Assert.AreEqual(3, vm.AppsListed.Count);
Assert.IsTrue(vm.AppsListed.All(a => a.Loading), "All apps should show loading spinner initially");
SimulateIpcMessage(BuildMessage(
1234,
App("Visual Studio Code", @"C:\Code.exe", LaunchingState.Launched),
App("Windows Terminal", @"C:\wt.exe", LaunchingState.Waiting),
App("Microsoft Edge", @"C:\edge.exe", LaunchingState.Waiting)));
Assert.IsTrue(vm.AppsListed[0].Loading, "Launched but not yet moved — still loading");
SimulateIpcMessage(BuildMessage(
1234,
App("Visual Studio Code", @"C:\Code.exe", LaunchingState.LaunchedAndMoved),
App("Windows Terminal", @"C:\wt.exe", LaunchingState.Launched),
App("Microsoft Edge", @"C:\edge.exe", LaunchingState.Waiting)));
Assert.IsFalse(vm.AppsListed[0].Loading, "Moved app should stop loading");
Assert.AreEqual("\U0000F78C", vm.AppsListed[0].StateGlyph, "Moved app should show checkmark");
SimulateIpcMessage(BuildMessage(
1234,
App("Visual Studio Code", @"C:\Code.exe", LaunchingState.LaunchedAndMoved),
App("Windows Terminal", @"C:\wt.exe", LaunchingState.LaunchedAndMoved),
App("Microsoft Edge", @"C:\edge.exe", LaunchingState.LaunchedAndMoved)));
Assert.IsTrue(vm.AppsListed.All(a => !a.Loading), "All apps should stop loading");
Assert.IsTrue(vm.AppsListed.All(a => a.StateGlyph == "\U0000F78C"), "All apps should show checkmark");
}
[TestMethod]
[TestCategory("Scenario")]
public void UserLaunchesWorkspace_OneAppMissing_FailedShowsRedOthersShowGreen()
{
using var vm = new MainViewModel();
SimulateIpcMessage(BuildMessage(
1234,
App("Notepad", @"C:\Windows\notepad.exe", LaunchingState.LaunchedAndMoved),
App("Missing App", @"C:\nonexistent\app.exe", LaunchingState.Failed),
App("Calculator", @"C:\Windows\calc.exe", LaunchingState.LaunchedAndMoved)));
Assert.IsFalse(vm.AppsListed[0].Loading);
Assert.AreEqual("\U0000F78C", vm.AppsListed[0].StateGlyph);
Assert.IsFalse(vm.AppsListed[1].Loading);
Assert.AreEqual("\U0000EF2C", vm.AppsListed[1].StateGlyph);
var color = vm.AppsListed[1].StateColorValue;
Assert.AreEqual(254, color.R);
Assert.AreEqual("\U0000F78C", vm.AppsListed[2].StateGlyph);
}
[TestMethod]
[TestCategory("Scenario")]
public void UserCancelsLaunch_MidProgress_PartialAppsShowCanceledState()
{
using var vm = new MainViewModel();
SimulateIpcMessage(BuildMessage(
5678,
App("App1", @"C:\app1.exe", LaunchingState.LaunchedAndMoved),
App("App2", @"C:\app2.exe", LaunchingState.Canceled),
App("App3", @"C:\app3.exe", LaunchingState.Canceled)));
Assert.AreEqual(LaunchingState.LaunchedAndMoved, vm.AppsListed[0].LaunchState);
Assert.AreEqual(LaunchingState.Canceled, vm.AppsListed[1].LaunchState);
Assert.AreEqual(LaunchingState.Canceled, vm.AppsListed[2].LaunchState);
}
[TestMethod]
[TestCategory("Scenario")]
public void UserLaunchesWorkspace_SingleApp_CompletesFullLifecycle()
{
using var vm = new MainViewModel();
SimulateIpcMessage(BuildMessage(
100,
App("Notepad", @"C:\Windows\System32\notepad.exe", LaunchingState.Waiting)));
Assert.AreEqual(1, vm.AppsListed.Count);
Assert.AreEqual("Notepad", vm.AppsListed[0].Name);
Assert.IsTrue(vm.AppsListed[0].Loading);
SimulateIpcMessage(BuildMessage(
100,
App("Notepad", @"C:\Windows\System32\notepad.exe", LaunchingState.LaunchedAndMoved)));
Assert.IsFalse(vm.AppsListed[0].Loading);
}
[TestMethod]
[TestCategory("Scenario")]
public void UserLaunchesWorkspace_ChromeAndEdgePwa_PwaIdsPreserved()
{
using var vm = new MainViewModel();
SimulateIpcMessage(BuildMessageFull(
300,
AppFull("Gmail", @"C:\chrome.exe", string.Empty, string.Empty, "fmgjjmmmlfnkbppncijlocphclkkleod", LaunchingState.LaunchedAndMoved),
AppFull("Teams", @"C:\edge.exe", string.Empty, string.Empty, "cifhbcnohmdccbgoicgdjpfamggdegmo", LaunchingState.Launched)));
Assert.AreEqual("fmgjjmmmlfnkbppncijlocphclkkleod", vm.AppsListed[0].PwaAppId);
Assert.AreEqual("cifhbcnohmdccbgoicgdjpfamggdegmo", vm.AppsListed[1].PwaAppId);
}
[TestMethod]
[TestCategory("Scenario")]
public void UserLaunchesWorkspace_AdminApp_ElevatedFlagPreservedInUi()
{
using var vm = new MainViewModel();
string message = @"{
""processId"": 400,
""apps"": {
""appLaunchInfos"": [
{
""application"": {
""application"": ""Command Prompt (Admin)"",
""application-path"": ""C:\\Windows\\System32\\cmd.exe"",
""title"": ""Administrator: Command Prompt"",
""package-full-name"": """",
""app-user-model-id"": """",
""pwa-app-id"": """",
""command-line-arguments"": """",
""is-elevated"": true,
""can-launch-elevated"": true,
""minimized"": false,
""maximized"": false,
""position"": { ""X"": 0, ""Y"": 0, ""width"": 800, ""height"": 600 },
""monitor"": 0
},
""state"": 2
}
]
}
}";
SimulateIpcMessage(message);
Assert.AreEqual("Command Prompt (Admin)", vm.AppsListed[0].Name);
}
[TestMethod]
[TestCategory("Scenario")]
public void UserLaunchesWorkspace_FifteenApps_AllAppsDisplayedWithLoadingState()
{
using var vm = new MainViewModel();
var apps = new (string Name, string Path, LaunchingState State)[15];
for (int i = 0; i < 15; i++)
{
apps[i] = ($"App {i}", $@"C:\app{i}.exe", LaunchingState.Waiting);
}
SimulateIpcMessage(BuildMessage(500, apps));
Assert.AreEqual(15, vm.AppsListed.Count);
for (int i = 0; i < 15; i++)
{
Assert.AreEqual($"App {i}", vm.AppsListed[i].Name);
Assert.IsTrue(vm.AppsListed[i].Loading);
}
}
[TestMethod]
[TestCategory("Scenario")]
public void UserLaunchesWorkspace_AllAppsMissing_AllShowRedErrorState()
{
using var vm = new MainViewModel();
SimulateIpcMessage(BuildMessage(
800,
App("App1", @"C:\missing1.exe", LaunchingState.Failed),
App("App2", @"C:\missing2.exe", LaunchingState.Failed)));
Assert.IsTrue(vm.AppsListed.All(a => !a.Loading), "Failed apps should not show loading");
Assert.IsTrue(vm.AppsListed.All(a => a.StateGlyph == "\U0000EF2C"), "Failed apps should show error glyph");
}
[TestMethod]
[TestCategory("Scenario")]
public void UserLaunchesWorkspace_UwpStoreApp_PackageFieldsMappedToUi()
{
using var vm = new MainViewModel();
SimulateIpcMessage(BuildMessageFull(
900,
AppFull(
"Windows Settings",
@"C:\Program Files\WindowsApps\windows.immersivecontrolpanel\SystemSettings.exe",
"windows.immersivecontrolpanel_10.0.0.0_neutral_cw5n1h2txyewy",
"windows.immersivecontrolpanel_cw5n1h2txyewy!microsoft.windows.immersivecontrolpanel",
string.Empty,
LaunchingState.LaunchedAndMoved)));
Assert.AreEqual("Windows Settings", vm.AppsListed[0].Name);
Assert.AreEqual("windows.immersivecontrolpanel_10.0.0.0_neutral_cw5n1h2txyewy", vm.AppsListed[0].PackagedName);
}
[TestMethod]
[TestCategory("Scenario")]
public void UserLaunchesWorkspace_RapidIpcUpdates_FinalStateIsDisplayed()
{
using var vm = new MainViewModel();
for (int i = 0; i <= 4; i++)
{
SimulateIpcMessage(BuildMessage(
1000,
App("App", @"C:\app.exe", (LaunchingState)Math.Min(i, 2))));
}
Assert.AreEqual(1, vm.AppsListed.Count);
Assert.AreEqual(LaunchingState.LaunchedAndMoved, vm.AppsListed[0].LaunchState);
}
[TestMethod]
[TestCategory("Scenario")]
public void UserLaunchesWorkspace_Win32AndPackagedAndPwa_AllTypesCoexistInList()
{
using var vm = new MainViewModel();
SimulateIpcMessage(BuildMessageFull(
1100,
AppFull("Notepad", @"C:\Windows\notepad.exe", string.Empty, string.Empty, string.Empty, LaunchingState.LaunchedAndMoved),
AppFull("Terminal", @"C:\wt.exe", "Microsoft.WindowsTerminal_1.0_x64__8wekyb3d8bbwe", "Microsoft.WindowsTerminal_8wekyb3d8bbwe!App", string.Empty, LaunchingState.LaunchedAndMoved),
AppFull("Outlook", @"C:\edge.exe", string.Empty, string.Empty, "pwa_outlook_id", LaunchingState.Launched)));
Assert.AreEqual(3, vm.AppsListed.Count);
Assert.AreEqual(string.Empty, vm.AppsListed[0].PwaAppId);
Assert.AreEqual("Microsoft.WindowsTerminal_1.0_x64__8wekyb3d8bbwe", vm.AppsListed[1].PackagedName);
Assert.AreEqual("pwa_outlook_id", vm.AppsListed[2].PwaAppId);
}
[TestMethod]
[TestCategory("Scenario")]
public void UserLaunchesWorkspace_FiveUpdates_UiRefreshedOnEveryIpcMessage()
{
using var vm = new MainViewModel();
int fireCount = 0;
vm.PropertyChanged += (s, e) =>
{
if (e.PropertyName == "AppsListed")
{
fireCount++;
}
};
for (int i = 0; i < 5; i++)
{
SimulateIpcMessage(BuildMessage(
1200,
App("App", @"C:\app.exe", (LaunchingState)Math.Min(i, 2))));
}
Assert.AreEqual(5, fireCount, "PropertyChanged should fire once per IPC message");
}
private static (string Name, string Path, LaunchingState State) App(string name, string path, LaunchingState state)
{
return (name, path, state);
}
private static (string Name, string Path, string PackageFullName, string Aumid, string PwaAppId, LaunchingState State) AppFull(
string name, string path, string packageFullName, string aumid, string pwaAppId, LaunchingState state)
{
return (name, path, packageFullName, aumid, pwaAppId, state);
}
private static void SimulateIpcMessage(string message)
{
WorkspacesLauncherUI.App.IPCMessageReceivedCallback?.Invoke(message);
}
private static string BuildMessage(
int processId,
params (string Name, string Path, LaunchingState State)[] apps)
{
var fullApps = apps.Select(a => (a.Name, a.Path, string.Empty, string.Empty, string.Empty, a.State)).ToArray();
return BuildMessageFull(processId, fullApps);
}
private static string BuildMessageFull(
int processId,
params (string Name, string Path, string PackageFullName, string Aumid, string PwaAppId, LaunchingState State)[] apps)
{
var sb = new StringBuilder();
sb.Append(CultureInfo.InvariantCulture, $@"{{ ""processId"": {processId}, ""apps"": {{ ""appLaunchInfos"": [");
for (int i = 0; i < apps.Length; i++)
{
if (i > 0)
{
sb.Append(',');
}
var (name, path, packageFullName, aumid, pwaAppId, state) = apps[i];
string escapedPath = path.Replace(@"\", @"\\");
string appJson = string.Create(CultureInfo.InvariantCulture, $@"{{""application"": {{""application"": ""{name}"",""application-path"": ""{escapedPath}"",""title"": """",""package-full-name"": ""{packageFullName}"",""app-user-model-id"": ""{aumid}"",""pwa-app-id"": ""{pwaAppId}"",""command-line-arguments"": """",""is-elevated"": false,""can-launch-elevated"": false,""minimized"": false,""maximized"": false,""position"": {{ ""X"": 0, ""Y"": 0, ""width"": 100, ""height"": 100 }},""monitor"": 0}},""state"": {(int)state}}}");
sb.Append(appJson);
}
sb.Append("]}}");
return sb.ToString();
}
}
}

View File

@@ -1,155 +0,0 @@
// Copyright (c) Microsoft Corporation
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
using Microsoft.VisualStudio.TestTools.UnitTesting;
using WorkspacesLauncherUI.Data;
namespace WorkspacesLauncherUI.UnitTests
{
/// <summary>
/// Tests for PositionWrapper struct equality and operator behavior.
/// </summary>
[TestClass]
public class WindowPositionDataTests
{
[TestMethod]
[TestCategory("DataModel")]
public void PositionEquality_IdenticalCoordinates_ReturnsTrue()
{
var pos1 = new PositionWrapper { X = 100, Y = 200, Width = 800, Height = 600 };
var pos2 = new PositionWrapper { X = 100, Y = 200, Width = 800, Height = 600 };
Assert.IsTrue(pos1 == pos2);
}
[TestMethod]
[TestCategory("DataModel")]
public void PositionEquality_DifferentXCoordinate_ReturnsFalse()
{
var pos1 = new PositionWrapper { X = 100, Y = 200, Width = 800, Height = 600 };
var pos2 = new PositionWrapper { X = 101, Y = 200, Width = 800, Height = 600 };
Assert.IsFalse(pos1 == pos2);
}
[TestMethod]
[TestCategory("DataModel")]
public void PositionEquality_DifferentYCoordinate_ReturnsFalse()
{
var pos1 = new PositionWrapper { X = 100, Y = 200, Width = 800, Height = 600 };
var pos2 = new PositionWrapper { X = 100, Y = 201, Width = 800, Height = 600 };
Assert.IsFalse(pos1 == pos2);
}
[TestMethod]
[TestCategory("DataModel")]
public void PositionEquality_DifferentWidth_ReturnsFalse()
{
var pos1 = new PositionWrapper { X = 100, Y = 200, Width = 800, Height = 600 };
var pos2 = new PositionWrapper { X = 100, Y = 200, Width = 801, Height = 600 };
Assert.IsFalse(pos1 == pos2);
}
[TestMethod]
[TestCategory("DataModel")]
public void PositionEquality_DifferentHeight_ReturnsFalse()
{
var pos1 = new PositionWrapper { X = 100, Y = 200, Width = 800, Height = 600 };
var pos2 = new PositionWrapper { X = 100, Y = 200, Width = 800, Height = 601 };
Assert.IsFalse(pos1 == pos2);
}
[TestMethod]
[TestCategory("DataModel")]
public void PositionInequality_DifferentCoordinates_ReturnsTrue()
{
var pos1 = new PositionWrapper { X = 0, Y = 0, Width = 1920, Height = 1080 };
var pos2 = new PositionWrapper { X = 960, Y = 0, Width = 960, Height = 1080 };
Assert.IsTrue(pos1 != pos2);
}
[TestMethod]
[TestCategory("DataModel")]
public void PositionInequality_IdenticalCoordinates_ReturnsFalse()
{
var pos1 = new PositionWrapper { X = 0, Y = 0, Width = 1920, Height = 1080 };
var pos2 = new PositionWrapper { X = 0, Y = 0, Width = 1920, Height = 1080 };
Assert.IsFalse(pos1 != pos2);
}
[TestMethod]
[TestCategory("DataModel")]
public void PositionEquals_BoxedIdenticalValues_ReturnsTrue()
{
var pos1 = new PositionWrapper { X = 100, Y = 200, Width = 800, Height = 600 };
object pos2 = new PositionWrapper { X = 100, Y = 200, Width = 800, Height = 600 };
Assert.IsTrue(pos1.Equals(pos2));
}
[TestMethod]
[TestCategory("DataModel")]
public void PositionEquals_NullComparison_ReturnsFalse()
{
var pos = new PositionWrapper { X = 0, Y = 0, Width = 100, Height = 100 };
Assert.IsFalse(pos.Equals(null));
}
[TestMethod]
[TestCategory("DataModel")]
public void PositionEquals_DifferentObjectType_ReturnsFalse()
{
var pos = new PositionWrapper { X = 0, Y = 0, Width = 100, Height = 100 };
Assert.IsFalse(pos.Equals("not a position"));
}
[TestMethod]
[TestCategory("DataModel")]
public void WindowPosition_LeftOfPrimaryMonitor_StoresNegativeCoordinates()
{
var pos = new PositionWrapper { X = -1920, Y = -200, Width = 1920, Height = 1080 };
Assert.AreEqual(-1920, pos.X);
Assert.AreEqual(-200, pos.Y);
}
[TestMethod]
[TestCategory("DataModel")]
public void WindowPosition_AllZeroValues_IsValidState()
{
var pos = new PositionWrapper { X = 0, Y = 0, Width = 0, Height = 0 };
Assert.AreEqual(0, pos.X);
Assert.AreEqual(0, pos.Y);
Assert.AreEqual(0, pos.Width);
Assert.AreEqual(0, pos.Height);
}
[TestMethod]
[TestCategory("DataModel")]
public void WindowPosition_FourthMonitor4K_StoresLargeCoordinates()
{
var pos = new PositionWrapper { X = 11520, Y = 0, Width = 3840, Height = 2160 };
Assert.AreEqual(11520, pos.X);
Assert.AreEqual(3840, pos.Width);
Assert.AreEqual(2160, pos.Height);
}
[TestMethod]
[TestCategory("DataModel")]
public void WindowPosition_DefaultStruct_AllFieldsAreZero()
{
PositionWrapper pos = default;
Assert.AreEqual(0, pos.X);
Assert.AreEqual(0, pos.Y);
Assert.AreEqual(0, pos.Width);
Assert.AreEqual(0, pos.Height);
}
[TestMethod]
[TestCategory("DataModel")]
public void WindowPosition_TwoDefaultStructs_AreConsideredEqual()
{
PositionWrapper pos1 = default;
PositionWrapper pos2 = default;
Assert.IsTrue(pos1 == pos2);
Assert.IsTrue(pos1.Equals(pos2));
}
}
}

View File

@@ -1,32 +0,0 @@
<Project Sdk="Microsoft.NET.Sdk">
<!-- Look at Directory.Build.props in root for common stuff as well -->
<Import Project="$(RepoRoot)src\Common.Dotnet.CsWinRT.props" />
<Import Project="$(RepoRoot)src\Common.SelfContained.props" />
<PropertyGroup>
<IsPackable>false</IsPackable>
<AppendTargetFrameworkToOutputPath>false</AppendTargetFrameworkToOutputPath>
<AppendRuntimeIdentifierToOutputPath>false</AppendRuntimeIdentifierToOutputPath>
<OutputPath>$(SolutionDir)$(Platform)\$(Configuration)\tests\WorkspacesLauncherUI.Tests\</OutputPath>
<RootNamespace>WorkspacesLauncherUI.UnitTests</RootNamespace>
<AssemblyName>PowerToys.WorkspacesLauncherUI.Tests</AssemblyName>
<OutputType>Exe</OutputType>
<UseWinUI>true</UseWinUI>
<Platforms>x64;ARM64</Platforms>
<WindowsPackageType>None</WindowsPackageType>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="CommunityToolkit.Mvvm" />
<PackageReference Include="Microsoft.WindowsAppSDK" />
<PackageReference Include="Microsoft.Windows.SDK.BuildTools" />
<PackageReference Include="Moq" />
<PackageReference Include="MSTest" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\WorkspacesCsharpLibrary\WorkspacesCsharpLibrary.csproj" />
<ProjectReference Include="..\WorkspacesLauncherUI.WinUI\WorkspacesLauncherUI.WinUI.csproj" />
</ItemGroup>
</Project>

View File

@@ -1,50 +0,0 @@
// Copyright (c) Microsoft Corporation
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
using System;
using System.ComponentModel;
using System.Drawing;
using System.Drawing.Imaging;
using System.IO;
using Microsoft.UI.Xaml.Media.Imaging;
namespace WorkspacesLauncherUI.Helpers
{
internal static class IconHelper
{
public static BitmapImage TryGetExecutableIcon(string path)
{
if (string.IsNullOrEmpty(path) || !File.Exists(path))
{
return null;
}
try
{
using Icon icon = Icon.ExtractAssociatedIcon(path);
if (icon is null)
{
return null;
}
using Bitmap bitmap = icon.ToBitmap();
using MemoryStream stream = new();
bitmap.Save(stream, ImageFormat.Png);
stream.Position = 0;
BitmapImage bitmapImage = new();
bitmapImage.SetSource(stream.AsRandomAccessStream());
return bitmapImage;
}
catch (Exception ex) when (ex is FileNotFoundException
or UnauthorizedAccessException
or Win32Exception
or ArgumentException
or IOException)
{
return null;
}
}
}
}

View File

@@ -1,35 +0,0 @@
// Copyright (c) Microsoft Corporation
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
using System;
using ManagedCommon;
using Microsoft.Windows.ApplicationModel.Resources;
namespace WorkspacesLauncherUI
{
internal static class ResourceLoaderInstance
{
private static ResourceLoader _resourceLoader;
internal static ResourceLoader ResourceLoader
{
get
{
if (_resourceLoader == null)
{
try
{
_resourceLoader = new ResourceLoader("PowerToys.WorkspacesLauncherUI.pri");
}
catch (Exception ex)
{
Logger.LogError("Failed to load ResourceLoader: " + ex.Message);
}
}
return _resourceLoader;
}
}
}
}

View File

@@ -1,74 +0,0 @@
// Copyright (c) Microsoft Corporation
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
using CommunityToolkit.Mvvm.ComponentModel;
using Microsoft.UI.Xaml.Media;
using Microsoft.UI.Xaml.Media.Imaging;
using WorkspacesLauncherUI.Data;
namespace WorkspacesLauncherUI.Models
{
/// <summary>
/// Model representing an application's launch status in the Launcher UI.
/// Drives the display of the spinner (Loading), checkmark/X glyph (StateGlyph),
/// and color (StateColor) for each app row.
/// </summary>
public partial class AppLaunching : ObservableObject
{
public bool Loading => LaunchState == LaunchingState.Waiting || LaunchState == LaunchingState.Launched;
public string Name { get; set; }
public string AppPath { get; set; }
public BitmapImage IconImage { get; set; }
public string PackagedName { get; set; }
public string Aumid { get; set; }
public string PwaAppId { get; set; }
[ObservableProperty]
[NotifyPropertyChangedFor(nameof(Loading))]
[NotifyPropertyChangedFor(nameof(StateGlyph))]
[NotifyPropertyChangedFor(nameof(StateColor))]
[NotifyPropertyChangedFor(nameof(StateColorValue))]
private LaunchingState _launchState;
partial void OnLaunchStateChanged(LaunchingState value)
{
_stateColorBrush = null;
}
public string StateGlyph
{
get => LaunchState switch
{
LaunchingState.LaunchedAndMoved => "\U0000F78C",
LaunchingState.Failed => "\U0000EF2C",
_ => "\U0000EF2C",
};
}
private SolidColorBrush _stateColorBrush;
public Brush StateColor
{
get => _stateColorBrush ??= new SolidColorBrush(StateColorValue);
}
public Windows.UI.Color StateColorValue
{
get => LaunchState switch
{
LaunchingState.LaunchedAndMoved => Windows.UI.Color.FromArgb(255, 0, 128, 0),
LaunchingState.Failed => Windows.UI.Color.FromArgb(255, 254, 0, 0),
_ => Windows.UI.Color.FromArgb(255, 254, 0, 0),
};
}
}
}

View File

@@ -1,46 +0,0 @@
// Copyright (c) Microsoft Corporation
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
using System;
using System.Threading;
using ManagedCommon;
using Microsoft.UI.Dispatching;
namespace WorkspacesLauncherUI
{
public static class Program
{
[STAThread]
public static void Main(string[] args)
{
Logger.InitializeLogger("\\Workspaces\\WorkspacesLauncherUI");
WinRT.ComWrappersSupport.InitializeComWrappers();
if (PowerToys.GPOWrapper.GPOWrapper.GetConfiguredWorkspacesEnabledValue() == PowerToys.GPOWrapper.GpoRuleConfigured.Disabled)
{
Logger.LogWarning("Tried to start with a GPO policy setting the utility to always be disabled. Please contact your systems administrator.");
return;
}
const string mutexName = "Local\\PowerToys_Workspaces_LauncherUI_InstanceMutex";
bool createdNew;
using var mutex = new Mutex(true, mutexName, out createdNew);
if (!createdNew)
{
Logger.LogWarning("Another instance of Workspaces Launcher UI is already running. Exiting this instance.");
return;
}
Microsoft.UI.Xaml.Application.Start((p) =>
{
var context = new DispatcherQueueSynchronizationContext(DispatcherQueue.GetForCurrentThread());
SynchronizationContext.SetSynchronizationContext(context);
_ = new App();
});
}
}
}

View File

@@ -1,57 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<root>
<xsd:schema id="root" xmlns="" xmlns:xsd="http://www.w3.org/2001/XMLSchema" xmlns:msdata="urn:schemas-microsoft-com:xml-msdata">
<xsd:element name="root" msdata:IsDataSet="true">
<xsd:complexType>
<xsd:choice maxOccurs="unbounded">
<xsd:element name="data">
<xsd:complexType>
<xsd:sequence>
<xsd:element name="value" type="xsd:string" minOccurs="0" msdata:Ordinal="1" />
<xsd:element name="comment" type="xsd:string" minOccurs="0" msdata:Ordinal="2" />
</xsd:sequence>
<xsd:attribute name="name" type="xsd:string" use="required" msdata:Ordinal="1" />
<xsd:attribute name="type" type="xsd:string" />
<xsd:attribute name="mimetype" type="xsd:string" />
</xsd:complexType>
</xsd:element>
<xsd:element name="resheader">
<xsd:complexType>
<xsd:sequence>
<xsd:element name="value" type="xsd:string" minOccurs="0" msdata:Ordinal="1" />
</xsd:sequence>
<xsd:attribute name="name" type="xsd:string" use="required" />
</xsd:complexType>
</xsd:element>
</xsd:choice>
</xsd:complexType>
</xsd:element>
</xsd:schema>
<resheader name="resmimetype">
<value>text/microsoft-resx</value>
</resheader>
<resheader name="version">
<value>2.0</value>
</resheader>
<resheader name="reader">
<value>System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089</value>
</resheader>
<resheader name="writer">
<value>System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089</value>
</resheader>
<data name="CancelButton.Content" xml:space="preserve">
<value>Cancel launch</value>
</data>
<data name="CancelButton.AutomationProperties.Name" xml:space="preserve">
<value>Cancel launch</value>
</data>
<data name="DismissButton.Content" xml:space="preserve">
<value>Dismiss</value>
</data>
<data name="DismissButton.AutomationProperties.Name" xml:space="preserve">
<value>Dismiss</value>
</data>
<data name="LauncherWindowTitle" xml:space="preserve">
<value>Your workspace is launching. Waiting on ...</value>
</data>
</root>

View File

@@ -1,96 +0,0 @@
// Copyright (c) Microsoft Corporation
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
using System;
using System.Globalization;
using ManagedCommon;
using Microsoft.UI.Dispatching;
using Microsoft.UI.Xaml;
using PowerToys.Interop;
namespace WorkspacesLauncherUI
{
/// <summary>
/// WinUI 3 Application class for the Workspaces Launcher UI.
/// Manages the IPC pipe connection to the C++ launcher engine and hosts the status window.
/// </summary>
public partial class App : Application, IDisposable
{
private StatusWindow _mainWindow;
private TwoWayPipeMessageIPCManaged _ipcManager;
private bool _isDisposed;
public static Action<string> IPCMessageReceivedCallback { get; set; }
public static DispatcherQueue DispatcherQueue { get; private set; }
public App()
{
string languageTag = LanguageHelper.LoadLanguage();
if (!string.IsNullOrEmpty(languageTag))
{
try
{
Microsoft.Windows.Globalization.ApplicationLanguages.PrimaryLanguageOverride = languageTag;
}
catch (Exception ex)
{
Logger.LogError("Failed to set language override: " + ex.Message);
}
}
this.InitializeComponent();
this.UnhandledException += OnUnhandledException;
}
public static void SendIPCMessage(string message)
{
if ((Current as App)?._ipcManager != null)
{
(Current as App)._ipcManager.Send(message);
}
}
protected override void OnLaunched(LaunchActivatedEventArgs args)
{
DispatcherQueue = DispatcherQueue.GetForCurrentThread();
_ipcManager = new TwoWayPipeMessageIPCManaged(
"\\\\.\\pipe\\powertoys_workspaces_ui_",
"\\\\.\\pipe\\powertoys_workspaces_launcher_ui_",
(string message) =>
{
if (IPCMessageReceivedCallback != null && message.Length > 0)
{
DispatcherQueue.TryEnqueue(() =>
{
IPCMessageReceivedCallback(message);
});
}
});
_ipcManager.Start();
_mainWindow = new StatusWindow();
_mainWindow.Activate();
}
private void OnUnhandledException(object sender, Microsoft.UI.Xaml.UnhandledExceptionEventArgs e)
{
Logger.LogError("Unhandled exception occurred", e.Exception);
}
public void Dispose()
{
if (!_isDisposed)
{
_ipcManager?.End();
_ipcManager?.Dispose();
_isDisposed = true;
}
GC.SuppressFinalize(this);
}
}
}

View File

@@ -1,106 +0,0 @@
<?xml version="1.0" encoding="utf-8" ?>
<Page
x:Class="WorkspacesLauncherUI.Views.StatusPage"
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:models="using:WorkspacesLauncherUI.Models"
xmlns:tkcontrols="using:CommunityToolkit.WinUI.Controls"
mc:Ignorable="d">
<Grid Margin="16">
<Grid.RowDefinitions>
<RowDefinition Height="*" />
<RowDefinition Height="Auto" />
</Grid.RowDefinitions>
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*" />
<ColumnDefinition Width="*" />
</Grid.ColumnDefinitions>
<ScrollViewer
Grid.ColumnSpan="2"
AutomationProperties.Name="Application launch status list"
TabIndex="0">
<StackPanel AutomationProperties.AccessibilityView="Content" AutomationProperties.LiveSetting="Polite">
<ItemsControl AutomationProperties.Name="Applications" ItemsSource="{x:Bind ViewModel.AppsListed, Mode=OneWay}">
<ItemsControl.ItemTemplate>
<DataTemplate x:DataType="models:AppLaunching">
<Grid
Margin="0,4"
Padding="4"
AutomationProperties.Name="{x:Bind Name, Mode=OneWay}">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="Auto" />
<ColumnDefinition Width="*" />
<ColumnDefinition Width="Auto" />
</Grid.ColumnDefinitions>
<Image
Width="20"
Height="20"
Margin="4,0"
HorizontalAlignment="Center"
VerticalAlignment="Center"
Source="{x:Bind IconImage}" />
<TextBlock
Grid.Column="1"
VerticalAlignment="Center"
AutomationProperties.AccessibilityView="Raw"
Text="{x:Bind Name, Mode=OneWay}" />
<tkcontrols:SwitchPresenter
Grid.Column="2"
Margin="8"
HorizontalAlignment="Center"
VerticalAlignment="Center"
TargetType="x:Boolean"
Value="{x:Bind Loading, Mode=OneWay}">
<tkcontrols:Case Value="True">
<ProgressRing
Width="20"
Height="20"
AutomationProperties.Name="Loading"
IsActive="True" />
</tkcontrols:Case>
<tkcontrols:Case Value="False">
<TextBlock
Width="20"
Height="20"
HorizontalAlignment="Center"
VerticalAlignment="Center"
AutomationProperties.AccessibilityView="Raw"
FontFamily="{ThemeResource SymbolThemeFontFamily}"
FontSize="20"
Foreground="{x:Bind StateColor, Mode=OneWay}"
Text="{x:Bind StateGlyph, Mode=OneWay}" />
</tkcontrols:Case>
</tkcontrols:SwitchPresenter>
</Grid>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
</StackPanel>
</ScrollViewer>
<Button
x:Name="DismissButton"
x:Uid="DismissButton"
Grid.Row="1"
Margin="0,16,4,0"
HorizontalAlignment="Stretch"
Click="DismissButton_Click"
Style="{ThemeResource AccentButtonStyle}"
TabIndex="1" />
<Button
x:Name="CancelButton"
x:Uid="CancelButton"
Grid.Row="1"
Grid.Column="1"
Margin="4,16,0,0"
HorizontalAlignment="Stretch"
Click="CancelButton_Click"
Command="{x:Bind ViewModel.CancelLaunchCommand}"
TabIndex="2" />
</Grid>
</Page>

View File

@@ -1,45 +0,0 @@
// Copyright (c) Microsoft Corporation
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
using System;
using Microsoft.UI.Xaml;
using Microsoft.UI.Xaml.Controls;
using WorkspacesLauncherUI.ViewModels;
namespace WorkspacesLauncherUI.Views
{
/// <summary>
/// Page hosting the workspace launch progress content.
/// Displays a list of apps with their launch state (loading/success/failed).
/// Hosted inside <see cref="StatusWindow"/> so the content can use x:Bind.
/// </summary>
[System.Diagnostics.CodeAnalysis.SuppressMessage("Reliability", "CA1001:Types that own disposable fields should be disposable", Justification = "WinUI Page does not support IDisposable; ViewModel is disposed by the hosting window on close.")]
public sealed partial class StatusPage : Page
{
public MainViewModel ViewModel { get; }
/// <summary>
/// Raised when the user clicks Cancel or Dismiss and the hosting window should close.
/// </summary>
public event EventHandler CloseRequested;
public StatusPage()
{
ViewModel = new MainViewModel();
this.InitializeComponent();
}
private void CancelButton_Click(object sender, RoutedEventArgs e)
{
CloseRequested?.Invoke(this, EventArgs.Empty);
}
private void DismissButton_Click(object sender, RoutedEventArgs e)
{
CloseRequested?.Invoke(this, EventArgs.Empty);
}
}
}

View File

@@ -1,35 +0,0 @@
<?xml version="1.0" encoding="utf-8" ?>
<winuiex:WindowEx
x:Class="WorkspacesLauncherUI.StatusWindow"
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:views="using:WorkspacesLauncherUI.Views"
xmlns:winuiex="using:WinUIEx"
Title="Workspaces"
Width="360"
Height="360"
IsAlwaysOnTop="True"
IsMaximizable="False"
IsMinimizable="False"
IsResizable="False"
mc:Ignorable="d">
<Window.SystemBackdrop>
<MicaBackdrop />
</Window.SystemBackdrop>
<Grid x:Name="RootGrid">
<Grid.RowDefinitions>
<RowDefinition Height="Auto" />
<RowDefinition Height="*" />
</Grid.RowDefinitions>
<TitleBar x:Name="AppTitleBar" IsTabStop="False">
<TitleBar.IconSource>
<ImageIconSource ImageSource="/Assets/Workspaces/Workspaces.ico" />
</TitleBar.IconSource>
</TitleBar>
<views:StatusPage x:Name="StatusPageView" Grid.Row="1" />
</Grid>
</winuiex:WindowEx>

View File

@@ -1,62 +0,0 @@
// Copyright (c) Microsoft Corporation
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
using System;
using ManagedCommon;
using Microsoft.UI.Xaml;
using WinUIEx;
using WorkspacesLauncherUI.Views;
namespace WorkspacesLauncherUI
{
/// <summary>
/// Status window showing workspace launch progress.
/// Hosts <see cref="StatusPage"/> which owns the ViewModel and renders the app list.
/// </summary>
public sealed partial class StatusWindow : WindowEx
{
public StatusWindow()
{
this.InitializeComponent();
ExtendsContentIntoTitleBar = true;
SetTitleBar(AppTitleBar);
AppWindow.SetIcon("Assets/Workspaces/Workspaces.ico");
// Set title from resources
string title;
try
{
title = ResourceLoaderInstance.ResourceLoader?.GetString("LauncherWindowTitle") ?? "Workspaces";
}
catch (Exception ex)
{
Logger.LogError("Failed to load window title resource: " + ex.Message);
title = "Workspaces";
}
this.Title = title;
AppTitleBar.Title = title;
StatusPageView.CloseRequested += StatusPage_CloseRequested;
this.Closed += Window_Closed;
this.CenterOnScreen();
}
private void StatusPage_CloseRequested(object sender, EventArgs e)
{
Close();
}
private void Window_Closed(object sender, WindowEventArgs args)
{
StatusPageView.ViewModel?.Dispose();
(Application.Current as IDisposable)?.Dispose();
}
}
}

View File

@@ -1,67 +0,0 @@
<Project Sdk="Microsoft.NET.Sdk">
<!-- Look at Directory.Build.props in root for common stuff as well -->
<Import Project="$(RepoRoot)src\Common.Dotnet.CsWinRT.props" />
<Import Project="$(RepoRoot)src\Common.SelfContained.props" />
<PropertyGroup>
<OutputType>WinExe</OutputType>
<RootNamespace>WorkspacesLauncherUI</RootNamespace>
<ApplicationManifest>app.manifest</ApplicationManifest>
<UseWinUI>true</UseWinUI>
<Platforms>x64;ARM64</Platforms>
<EnableMsixTooling>true</EnableMsixTooling>
<EnablePreviewMsixTooling>true</EnablePreviewMsixTooling>
<WindowsPackageType>None</WindowsPackageType>
<AppendTargetFrameworkToOutputPath>false</AppendTargetFrameworkToOutputPath>
<AppendRuntimeIdentifierToOutputPath>false</AppendRuntimeIdentifierToOutputPath>
<WindowsAppSDKSelfContained>true</WindowsAppSDKSelfContained>
<DefineConstants>DISABLE_XAML_GENERATED_MAIN,TRACE</DefineConstants>
<OutputPath>$(RepoRoot)$(Platform)\$(Configuration)\WinUI3Apps</OutputPath>
<AssemblyName>PowerToys.WorkspacesLauncherUI</AssemblyName>
<ApplicationIcon>..\Assets\Workspaces\Workspaces.ico</ApplicationIcon>
<ProjectPriFileName>PowerToys.WorkspacesLauncherUI.pri</ProjectPriFileName>
<GenerateSatelliteAssembliesForCore>true</GenerateSatelliteAssembliesForCore>
</PropertyGroup>
<ItemGroup>
<Page Remove="Views\App.xaml" />
</ItemGroup>
<ItemGroup>
<ApplicationDefinition Include="Views\App.xaml" />
</ItemGroup>
<!-- See https://learn.microsoft.com/windows/apps/develop/platform/csharp-winrt/net-projection-from-cppwinrt-component for more info -->
<PropertyGroup>
<CsWinRTIncludes>PowerToys.GPOWrapper</CsWinRTIncludes>
<CsWinRTGeneratedFilesDir>$(OutDir)</CsWinRTGeneratedFilesDir>
<ErrorOnDuplicatePublishOutputFiles>false</ErrorOnDuplicatePublishOutputFiles>
</PropertyGroup>
<ItemGroup>
<Content Include="..\Assets\**\*.*">
<Link>Assets\Workspaces\%(Filename)%(Extension)</Link>
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</Content>
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\..\common\Common.UI\Common.UI.csproj" />
<ProjectReference Include="..\..\..\common\GPOWrapper\GPOWrapper.vcxproj" />
<ProjectReference Include="..\..\..\common\ManagedCommon\ManagedCommon.csproj" />
<ProjectReference Include="..\..\..\common\ManagedTelemetry\Telemetry\ManagedTelemetry.csproj" />
<ProjectReference Include="..\..\..\common\interop\PowerToys.Interop.vcxproj" />
<ProjectReference Include="..\..\..\settings-ui\Settings.UI.Library\Settings.UI.Library.csproj" />
<ProjectReference Include="..\WorkspacesCsharpLibrary\WorkspacesCsharpLibrary.csproj" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="CommunityToolkit.Mvvm" />
<PackageReference Include="CommunityToolkit.WinUI.Controls.Primitives" />
<PackageReference Include="Microsoft.WindowsAppSDK" />
<PackageReference Include="Microsoft.Windows.SDK.BuildTools" />
<PackageReference Include="System.Drawing.Common" />
<PackageReference Include="System.IO.Abstractions" />
<PackageReference Include="WinUIEx" />
</ItemGroup>
</Project>

View File

@@ -1,17 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<assembly manifestVersion="1.0" xmlns="urn:schemas-microsoft-com:asm.v1">
<assemblyIdentity version="1.0.0.0" name="PowerToys.WorkspacesLauncherUI.app"/>
<compatibility xmlns="urn:schemas-microsoft-com:compatibility.v1">
<application>
<!-- Windows 10 compatibility for unpackaged WinUI 3 apps -->
<supportedOS Id="{8e0f7a12-bfb3-4fe8-b9a5-48fd50a15a9a}" />
</application>
</compatibility>
<application xmlns="urn:schemas-microsoft-com:asm.v3">
<windowsSettings>
<dpiAwareness xmlns="http://schemas.microsoft.com/SMI/2016/WindowsSettings">PerMonitorV2</dpiAwareness>
</windowsSettings>
</application>
</assembly>

View File

@@ -0,0 +1,9 @@
<?xml version="1.0" encoding="utf-8"?>
<configuration>
<startup>
<supportedRuntime version="v4.0" sku=".NETFramework,Version=v4.7.2"/>
</startup>
<runtime>
<AppContextSwitchOverrides value = "Switch.System.Windows.DoNotScaleForDpiChanges=false"/>
</runtime>
</configuration>

View File

@@ -0,0 +1,57 @@
<Application
x:Class="WorkspacesLauncherUI.App"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:local="clr-namespace:WorkspacesLauncherUI"
Exit="OnExit"
Startup="OnStartup"
ThemeMode="System">
<Application.Resources>
<ResourceDictionary>
<FontFamily x:Key="SymbolThemeFontFamily">Segoe Fluent Icons, Segoe MDL2 Assets</FontFamily>
<Style x:Key="HeadingTextBlock" TargetType="TextBlock" />
<Style
x:Key="SubtleButtonStyle"
BasedOn="{StaticResource {x:Type Button}}"
TargetType="Button">
<Setter Property="Background" Value="Transparent" />
<Setter Property="BorderBrush" Value="Transparent" />
<Setter Property="Template">
<Setter.Value>
<ControlTemplate TargetType="Button">
<Border
x:Name="Border"
Padding="{TemplateBinding Padding}"
Background="{TemplateBinding Background}"
BorderBrush="{TemplateBinding BorderBrush}"
BorderThickness="{TemplateBinding BorderThickness}"
CornerRadius="4"
SnapsToDevicePixels="True">
<ContentPresenter
HorizontalAlignment="{TemplateBinding HorizontalContentAlignment}"
VerticalAlignment="{TemplateBinding VerticalContentAlignment}"
Focusable="False"
RecognizesAccessKey="True"
SnapsToDevicePixels="{TemplateBinding SnapsToDevicePixels}" />
</Border>
<ControlTemplate.Triggers>
<Trigger Property="IsMouseOver" Value="True">
<Setter TargetName="Border" Property="Background" Value="{DynamicResource SubtleFillColorSecondaryBrush}" />
<Setter TargetName="Border" Property="BorderBrush" Value="{DynamicResource SubtleFillColorSecondaryBrush}" />
</Trigger>
<Trigger Property="IsPressed" Value="True">
<Setter TargetName="Border" Property="Background" Value="{DynamicResource SubtleFillColorTertiaryBrush}" />
<Setter TargetName="Border" Property="BorderBrush" Value="{DynamicResource SubtleFillColorTertiaryBrush}" />
<Setter Property="Foreground" Value="{DynamicResource TextFillColorSecondaryBrush}" />
</Trigger>
<Trigger Property="IsEnabled" Value="False">
<Setter Property="Foreground" Value="{DynamicResource TextFillColorDisabledBrush}" />
</Trigger>
</ControlTemplate.Triggers>
</ControlTemplate>
</Setter.Value>
</Setter>
</Style>
</ResourceDictionary>
</Application.Resources>
</Application>

View File

@@ -0,0 +1,147 @@
// Copyright (c) Microsoft Corporation
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
using System;
using System.Globalization;
using System.Threading;
using System.Windows;
using ManagedCommon;
using PowerToys.Interop;
using WorkspacesLauncherUI.ViewModels;
namespace WorkspacesLauncherUI
{
/// <summary>
/// Interaction logic for App.xaml
/// </summary>
public partial class App : Application, IDisposable
{
private static Mutex _instanceMutex;
// Create an instance of the IPC wrapper.
private static TwoWayPipeMessageIPCManaged ipcmanager;
private StatusWindow _mainWindow;
private MainViewModel _mainViewModel;
private bool _isDisposed;
public static Action<string> IPCMessageReceivedCallback { get; set; }
public App()
{
}
public static void SendIPCMessage(string message)
{
if (ipcmanager != null)
{
ipcmanager.Send(message);
}
}
private void OnStartup(object sender, StartupEventArgs e)
{
Logger.InitializeLogger("\\Workspaces\\WorkspacesLauncherUI");
AppDomain.CurrentDomain.UnhandledException += OnUnhandledException;
var languageTag = LanguageHelper.LoadLanguage();
if (!string.IsNullOrEmpty(languageTag))
{
try
{
System.Threading.Thread.CurrentThread.CurrentUICulture = new CultureInfo(languageTag);
}
catch (CultureNotFoundException ex)
{
Logger.LogError("CultureNotFoundException: " + ex.Message);
}
}
const string appName = "Local\\PowerToys_Workspaces_LauncherUI_InstanceMutex";
bool createdNew;
_instanceMutex = new Mutex(true, appName, out createdNew);
if (!createdNew)
{
Logger.LogWarning("Another instance of Workspaces Launcher UI is already running. Exiting this instance.");
_instanceMutex = null;
Shutdown(0);
return;
}
if (PowerToys.GPOWrapperProjection.GPOWrapper.GetConfiguredWorkspacesEnabledValue() == PowerToys.GPOWrapperProjection.GpoRuleConfigured.Disabled)
{
Logger.LogWarning("Tried to start with a GPO policy setting the utility to always be disabled. Please contact your systems administrator.");
Shutdown(0);
return;
}
ipcmanager = new TwoWayPipeMessageIPCManaged("\\\\.\\pipe\\powertoys_workspaces_ui_", "\\\\.\\pipe\\powertoys_workspaces_launcher_ui_", (string message) =>
{
if (IPCMessageReceivedCallback != null && message.Length > 0)
{
IPCMessageReceivedCallback(message);
}
});
ipcmanager.Start();
if (_mainViewModel == null)
{
_mainViewModel = new MainViewModel();
}
// normal start of editor
if (_mainWindow == null)
{
_mainWindow = new StatusWindow(_mainViewModel);
}
// reset main window owner to keep it on the top
_mainWindow.ShowActivated = true;
_mainWindow.Topmost = true;
_mainWindow.Show();
}
private void OnExit(object sender, ExitEventArgs e)
{
if (_instanceMutex != null)
{
_instanceMutex.ReleaseMutex();
}
Dispose();
}
private void OnUnhandledException(object sender, UnhandledExceptionEventArgs args)
{
Logger.LogError("Unhandled exception occurred", args.ExceptionObject as Exception);
}
protected virtual void Dispose(bool disposing)
{
if (!_isDisposed)
{
if (disposing)
{
ipcmanager?.End();
ipcmanager?.Dispose();
_instanceMutex?.Dispose();
}
_isDisposed = true;
}
}
public void Dispose()
{
// Do not change this code. Put cleanup code in 'Dispose(bool disposing)' method
Dispose(disposing: true);
GC.SuppressFinalize(this);
}
}
}

View File

@@ -0,0 +1,29 @@
// Copyright (c) Microsoft Corporation
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
using System;
using System.Globalization;
using System.Windows;
using System.Windows.Data;
namespace WorkspacesLauncherUI.Converters
{
public class BooleanToInvertedVisibilityConverter : IValueConverter
{
public object Convert(object value, Type targetType, object parameter, CultureInfo culture)
{
if ((bool)value)
{
return Visibility.Collapsed;
}
return Visibility.Visible;
}
public object ConvertBack(object value, Type targetType, object parameter, CultureInfo culture)
{
throw new NotImplementedException();
}
}
}

View File

@@ -0,0 +1,30 @@
// Copyright (c) Microsoft Corporation
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
using System.Windows.Automation.Peers;
using System.Windows.Controls;
namespace WorkspacesLauncherUI
{
public class HeadingTextBlock : TextBlock
{
protected override AutomationPeer OnCreateAutomationPeer()
{
return new HeadingTextBlockAutomationPeer(this);
}
internal sealed class HeadingTextBlockAutomationPeer : TextBlockAutomationPeer
{
public HeadingTextBlockAutomationPeer(HeadingTextBlock owner)
: base(owner)
{
}
protected override AutomationControlType GetAutomationControlTypeCore()
{
return AutomationControlType.Header;
}
}
}
}

View File

@@ -0,0 +1,46 @@
// Copyright (c) Microsoft Corporation
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
using System;
using System.Drawing;
using System.Drawing.Drawing2D;
using System.Drawing.Imaging;
using System.IO;
using System.Windows.Media;
using System.Windows.Media.Imaging;
using ManagedCommon;
using WorkspacesCsharpLibrary.Models;
using WorkspacesLauncherUI.Data;
namespace WorkspacesLauncherUI.Models
{
public class AppLaunching : BaseApplication, IDisposable
{
public bool Loading => LaunchState == LaunchingState.Waiting || LaunchState == LaunchingState.Launched;
public string Name { get; set; }
public LaunchingState LaunchState { get; set; }
public string StateGlyph
{
get => LaunchState switch
{
LaunchingState.LaunchedAndMoved => "\U0000F78C",
LaunchingState.Failed => "\U0000EF2C",
_ => "\U0000EF2C",
};
}
public System.Windows.Media.Brush StateColor
{
get => LaunchState switch
{
LaunchingState.LaunchedAndMoved => new SolidColorBrush(System.Windows.Media.Color.FromArgb(255, 0, 128, 0)),
LaunchingState.Failed => new SolidColorBrush(System.Windows.Media.Color.FromArgb(255, 254, 0, 0)),
_ => new SolidColorBrush(System.Windows.Media.Color.FromArgb(255, 254, 0, 0)),
};
}
}
}

View File

@@ -0,0 +1,90 @@
//------------------------------------------------------------------------------
// <auto-generated>
// This code was generated by a tool.
// Runtime Version:4.0.30319.42000
//
// Changes to this file may cause incorrect behavior and will be lost if
// the code is regenerated.
// </auto-generated>
//------------------------------------------------------------------------------
namespace WorkspacesLauncherUI.Properties {
using System;
/// <summary>
/// A strongly-typed resource class, for looking up localized strings, etc.
/// </summary>
// This class was auto-generated by the StronglyTypedResourceBuilder
// class via a tool like ResGen or Visual Studio.
// To add or remove a member, edit your .ResX file then rerun ResGen
// with the /str option, or rebuild your VS project.
[global::System.CodeDom.Compiler.GeneratedCodeAttribute("System.Resources.Tools.StronglyTypedResourceBuilder", "18.0.0.0")]
[global::System.Diagnostics.DebuggerNonUserCodeAttribute()]
[global::System.Runtime.CompilerServices.CompilerGeneratedAttribute()]
public class Resources {
private static global::System.Resources.ResourceManager resourceMan;
private static global::System.Globalization.CultureInfo resourceCulture;
[global::System.Diagnostics.CodeAnalysis.SuppressMessageAttribute("Microsoft.Performance", "CA1811:AvoidUncalledPrivateCode")]
internal Resources() {
}
/// <summary>
/// Returns the cached ResourceManager instance used by this class.
/// </summary>
[global::System.ComponentModel.EditorBrowsableAttribute(global::System.ComponentModel.EditorBrowsableState.Advanced)]
public static global::System.Resources.ResourceManager ResourceManager {
get {
if (object.ReferenceEquals(resourceMan, null)) {
global::System.Resources.ResourceManager temp = new global::System.Resources.ResourceManager("WorkspacesLauncherUI.Properties.Resources", typeof(Resources).Assembly);
resourceMan = temp;
}
return resourceMan;
}
}
/// <summary>
/// Overrides the current thread's CurrentUICulture property for all
/// resource lookups using this strongly typed resource class.
/// </summary>
[global::System.ComponentModel.EditorBrowsableAttribute(global::System.ComponentModel.EditorBrowsableState.Advanced)]
public static global::System.Globalization.CultureInfo Culture {
get {
return resourceCulture;
}
set {
resourceCulture = value;
}
}
/// <summary>
/// Looks up a localized string similar to Cancel launch.
/// </summary>
public static string CancelLaunch {
get {
return ResourceManager.GetString("CancelLaunch", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to Dismiss.
/// </summary>
public static string Dismiss {
get {
return ResourceManager.GetString("Dismiss", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to Your workspace is launching. Waiting on ....
/// </summary>
public static string LauncherWindowTitle {
get {
return ResourceManager.GetString("LauncherWindowTitle", resourceCulture);
}
}
}
}

View File

@@ -0,0 +1,129 @@
<?xml version="1.0" encoding="utf-8"?>
<root>
<!--
Microsoft ResX Schema
Version 2.0
The primary goals of this format is to allow a simple XML format
that is mostly human readable. The generation and parsing of the
various data types are done through the TypeConverter classes
associated with the data types.
Example:
... ado.net/XML headers & schema ...
<resheader name="resmimetype">text/microsoft-resx</resheader>
<resheader name="version">2.0</resheader>
<resheader name="reader">System.Resources.ResXResourceReader, System.Windows.Forms, ...</resheader>
<resheader name="writer">System.Resources.ResXResourceWriter, System.Windows.Forms, ...</resheader>
<data name="Name1"><value>this is my long string</value><comment>this is a comment</comment></data>
<data name="Color1" type="System.Drawing.Color, System.Drawing">Blue</data>
<data name="Bitmap1" mimetype="application/x-microsoft.net.object.binary.base64">
<value>[base64 mime encoded serialized .NET Framework object]</value>
</data>
<data name="Icon1" type="System.Drawing.Icon, System.Drawing" mimetype="application/x-microsoft.net.object.bytearray.base64">
<value>[base64 mime encoded string representing a byte array form of the .NET Framework object]</value>
<comment>This is a comment</comment>
</data>
There are any number of "resheader" rows that contain simple
name/value pairs.
Each data row contains a name, and value. The row also contains a
type or mimetype. Type corresponds to a .NET class that support
text/value conversion through the TypeConverter architecture.
Classes that don't support this are serialized and stored with the
mimetype set.
The mimetype is used for serialized objects, and tells the
ResXResourceReader how to depersist the object. This is currently not
extensible. For a given mimetype the value must be set accordingly:
Note - application/x-microsoft.net.object.binary.base64 is the format
that the ResXResourceWriter will generate, however the reader can
read any of the formats listed below.
mimetype: application/x-microsoft.net.object.binary.base64
value : The object must be serialized with
: System.Runtime.Serialization.Formatters.Binary.BinaryFormatter
: and then encoded with base64 encoding.
mimetype: application/x-microsoft.net.object.soap.base64
value : The object must be serialized with
: System.Runtime.Serialization.Formatters.Soap.SoapFormatter
: and then encoded with base64 encoding.
mimetype: application/x-microsoft.net.object.bytearray.base64
value : The object must be serialized into a byte array
: using a System.ComponentModel.TypeConverter
: and then encoded with base64 encoding.
-->
<xsd:schema id="root" xmlns="" xmlns:xsd="http://www.w3.org/2001/XMLSchema" xmlns:msdata="urn:schemas-microsoft-com:xml-msdata">
<xsd:import namespace="http://www.w3.org/XML/1998/namespace" />
<xsd:element name="root" msdata:IsDataSet="true">
<xsd:complexType>
<xsd:choice maxOccurs="unbounded">
<xsd:element name="metadata">
<xsd:complexType>
<xsd:sequence>
<xsd:element name="value" type="xsd:string" minOccurs="0" />
</xsd:sequence>
<xsd:attribute name="name" use="required" type="xsd:string" />
<xsd:attribute name="type" type="xsd:string" />
<xsd:attribute name="mimetype" type="xsd:string" />
<xsd:attribute ref="xml:space" />
</xsd:complexType>
</xsd:element>
<xsd:element name="assembly">
<xsd:complexType>
<xsd:attribute name="alias" type="xsd:string" />
<xsd:attribute name="name" type="xsd:string" />
</xsd:complexType>
</xsd:element>
<xsd:element name="data">
<xsd:complexType>
<xsd:sequence>
<xsd:element name="value" type="xsd:string" minOccurs="0" msdata:Ordinal="1" />
<xsd:element name="comment" type="xsd:string" minOccurs="0" msdata:Ordinal="2" />
</xsd:sequence>
<xsd:attribute name="name" type="xsd:string" use="required" msdata:Ordinal="1" />
<xsd:attribute name="type" type="xsd:string" msdata:Ordinal="3" />
<xsd:attribute name="mimetype" type="xsd:string" msdata:Ordinal="4" />
<xsd:attribute ref="xml:space" />
</xsd:complexType>
</xsd:element>
<xsd:element name="resheader">
<xsd:complexType>
<xsd:sequence>
<xsd:element name="value" type="xsd:string" minOccurs="0" msdata:Ordinal="1" />
</xsd:sequence>
<xsd:attribute name="name" type="xsd:string" use="required" />
</xsd:complexType>
</xsd:element>
</xsd:choice>
</xsd:complexType>
</xsd:element>
</xsd:schema>
<resheader name="resmimetype">
<value>text/microsoft-resx</value>
</resheader>
<resheader name="version">
<value>2.0</value>
</resheader>
<resheader name="reader">
<value>System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089</value>
</resheader>
<resheader name="writer">
<value>System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089</value>
</resheader>
<data name="CancelLaunch" xml:space="preserve">
<value>Cancel launch</value>
</data>
<data name="Dismiss" xml:space="preserve">
<value>Dismiss</value>
</data>
<data name="LauncherWindowTitle" xml:space="preserve">
<value>Your workspace is launching. Waiting on ...</value>
</data>
</root>

View File

@@ -0,0 +1,26 @@
//------------------------------------------------------------------------------
// <auto-generated>
// This code was generated by a tool.
// Runtime Version:4.0.30319.42000
//
// Changes to this file may cause incorrect behavior and will be lost if
// the code is regenerated.
// </auto-generated>
//------------------------------------------------------------------------------
namespace WorkspacesLauncherUI.Properties {
[global::System.Runtime.CompilerServices.CompilerGeneratedAttribute()]
[global::System.CodeDom.Compiler.GeneratedCodeAttribute("Microsoft.VisualStudio.Editors.SettingsDesigner.SettingsSingleFileGenerator", "16.1.0.0")]
internal sealed partial class Settings : global::System.Configuration.ApplicationSettingsBase {
private static Settings defaultInstance = ((Settings)(global::System.Configuration.ApplicationSettingsBase.Synchronized(new Settings())));
public static Settings Default {
get {
return defaultInstance;
}
}
}
}

View File

@@ -0,0 +1,7 @@
<?xml version='1.0' encoding='utf-8'?>
<SettingsFile xmlns="uri:settings" CurrentProfile="(Default)">
<Profiles>
<Profile Name="(Default)" />
</Profiles>
<Settings />
</SettingsFile>

View File

@@ -0,0 +1,103 @@
<Window
x:Class="WorkspacesLauncherUI.StatusWindow"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:converters="clr-namespace:WorkspacesLauncherUI.Converters"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:local="clr-namespace:WorkspacesLauncherUI"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
xmlns:props="clr-namespace:WorkspacesLauncherUI.Properties"
Title="{x:Static props:Resources.LauncherWindowTitle}"
Width="360"
Height="340"
BorderBrush="Red"
BorderThickness="4"
Closing="Window_Closing"
ResizeMode="NoResize"
Topmost="True"
WindowStartupLocation="CenterScreen"
mc:Ignorable="d">
<Window.Resources>
<BooleanToVisibilityConverter x:Key="BoolToVis" />
<converters:BooleanToInvertedVisibilityConverter x:Key="BooleanToInvertedVisibilityConverter" />
</Window.Resources>
<Grid Margin="4" Background="Transparent">
<Grid.RowDefinitions>
<RowDefinition Height="1*" />
<RowDefinition Height="Auto" />
</Grid.RowDefinitions>
<Grid.ColumnDefinitions>
<ColumnDefinition Width="1*" />
<ColumnDefinition Width="1*" />
</Grid.ColumnDefinitions>
<ScrollViewer Grid.ColumnSpan="2">
<StackPanel>
<ItemsControl ItemsSource="{Binding AppsListed, Mode=OneWay, UpdateSourceTrigger=PropertyChanged}">
<ItemsControl.ItemTemplate>
<DataTemplate>
<Grid>
<Grid.ColumnDefinitions>
<ColumnDefinition Width="auto" />
<ColumnDefinition Width="1*" />
<ColumnDefinition Width="auto" />
</Grid.ColumnDefinitions>
<Image
Width="20"
Height="20"
Margin="10"
HorizontalAlignment="Center"
VerticalAlignment="Center"
Source="{Binding IconBitmapImage}" />
<TextBlock
Grid.Column="1"
VerticalAlignment="Center"
Foreground="{DynamicResource TextFillColorPrimaryBrush}"
Text="{Binding Name, Mode=OneWay, UpdateSourceTrigger=PropertyChanged}" />
<ProgressBar
Grid.Column="2"
Width="20"
Height="20"
Margin="10"
HorizontalAlignment="Center"
VerticalAlignment="Center"
IsIndeterminate="True"
Visibility="{Binding Loading, Mode=OneWay, Converter={StaticResource BoolToVis}, UpdateSourceTrigger=PropertyChanged}" />
<TextBlock
Grid.Column="2"
Width="20"
Height="20"
Margin="10"
HorizontalAlignment="Center"
VerticalAlignment="Center"
FontFamily="{DynamicResource SymbolThemeFontFamily}"
FontSize="20"
Foreground="{Binding StateColor}"
Text="{Binding StateGlyph}"
Visibility="{Binding Loading, Mode=OneWay, Converter={StaticResource BooleanToInvertedVisibilityConverter}, UpdateSourceTrigger=PropertyChanged}" />
</Grid>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
</StackPanel>
</ScrollViewer>
<Button
x:Name="CancelButton"
Grid.Row="1"
Margin="4"
HorizontalAlignment="Stretch"
AutomationProperties.Name="{x:Static props:Resources.CancelLaunch}"
Click="CancelButtonClicked"
Content="{x:Static props:Resources.CancelLaunch}" />
<Button
x:Name="DismissButton"
Grid.Row="1"
Grid.Column="1"
Margin="4"
HorizontalAlignment="Stretch"
AutomationProperties.Name="{x:Static props:Resources.Dismiss}"
Click="DismissButtonClicked"
Content="{x:Static props:Resources.Dismiss}"
Style="{DynamicResource AccentButtonStyle}" />
</Grid>
</Window>

View File

@@ -0,0 +1,41 @@
// 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.Windows;
using WorkspacesLauncherUI.ViewModels;
namespace WorkspacesLauncherUI
{
/// <summary>
/// Interaction logic for SnapshotWindow.xaml
/// </summary>
public partial class StatusWindow : Window
{
private MainViewModel _mainViewModel;
public StatusWindow(MainViewModel mainViewModel)
{
_mainViewModel = mainViewModel;
_mainViewModel.SetSnapshotWindow(this);
this.DataContext = _mainViewModel;
InitializeComponent();
}
private void CancelButtonClicked(object sender, RoutedEventArgs e)
{
_mainViewModel.CancelLaunch();
Close();
}
private void DismissButtonClicked(object sender, RoutedEventArgs e)
{
Close();
}
private void Window_Closing(object sender, System.ComponentModel.CancelEventArgs e)
{
}
}
}

View File

@@ -5,30 +5,35 @@
using System;
using System.Collections.Generic;
using System.Collections.ObjectModel;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using System.ComponentModel;
using ManagedCommon;
using WorkspacesCsharpLibrary;
using WorkspacesLauncherUI.Data;
using WorkspacesLauncherUI.Helpers;
using WorkspacesLauncherUI.Models;
namespace WorkspacesLauncherUI.ViewModels
{
public partial class MainViewModel : ObservableObject, IDisposable
public class MainViewModel : INotifyPropertyChanged, IDisposable
{
private readonly PwaHelper _pwaHelper;
private bool _isDisposed;
public ObservableCollection<AppLaunching> AppsListed { get; set; } = new ObservableCollection<AppLaunching>();
[ObservableProperty]
private ObservableCollection<AppLaunching> _appsListed = new ObservableCollection<AppLaunching>();
private StatusWindow _snapshotWindow;
private int launcherProcessID;
private PwaHelper _pwaHelper;
public event PropertyChangedEventHandler PropertyChanged;
public void OnPropertyChanged(PropertyChangedEventArgs e)
{
PropertyChanged?.Invoke(this, e);
}
public MainViewModel()
{
_pwaHelper = new PwaHelper();
// receive IPC Message
App.IPCMessageReceivedCallback = (string msg) =>
{
try
@@ -46,6 +51,7 @@ namespace WorkspacesLauncherUI.ViewModels
private void HandleAppLaunchingState(AppLaunchData.AppLaunchDataWrapper appLaunchData)
{
launcherProcessID = appLaunchData.LauncherProcessID;
List<AppLaunching> appLaunchingList = new List<AppLaunching>();
foreach (var app in appLaunchData.AppLaunchInfos.AppLaunchInfoList)
{
@@ -53,7 +59,6 @@ namespace WorkspacesLauncherUI.ViewModels
{
Name = app.Application.Application,
AppPath = app.Application.ApplicationPath,
IconImage = IconHelper.TryGetExecutableIcon(app.Application.ApplicationPath),
PackagedName = app.Application.PackageFullName,
Aumid = app.Application.AppUserModelId,
PwaAppId = app.Application.PwaAppId,
@@ -62,28 +67,30 @@ namespace WorkspacesLauncherUI.ViewModels
}
AppsListed = new ObservableCollection<AppLaunching>(appLaunchingList);
OnPropertyChanged(new PropertyChangedEventArgs(nameof(AppsListed)));
}
[RelayCommand]
private void CancelLaunch()
private void SelfDestroy(object source, System.Timers.ElapsedEventArgs e)
{
App.SendIPCMessage("cancel");
}
[RelayCommand]
private void Dismiss()
{
// Window close is handled by the view
_snapshotWindow.Dispatcher.Invoke(() =>
{
_snapshotWindow.Close();
});
}
public void Dispose()
{
if (!_isDisposed)
{
_isDisposed = true;
}
GC.SuppressFinalize(this);
}
internal void SetSnapshotWindow(StatusWindow snapshotWindow)
{
_snapshotWindow = snapshotWindow;
}
internal void CancelLaunch()
{
App.SendIPCMessage("cancel");
}
}
}

View File

@@ -0,0 +1,100 @@
<Project Sdk="Microsoft.NET.Sdk">
<!-- Look at Directory.Build.props in root for common stuff as well -->
<Import Project="$(RepoRoot)src\Common.Dotnet.CsWinRT.props" />
<Import Project="$(RepoRoot)src\Common.SelfContained.props" />
<PropertyGroup>
<AssemblyTitle>PowerToys.WorkspacesLauncherUI</AssemblyTitle>
<AssemblyDescription>PowerToys Workspaces Launcher UI</AssemblyDescription>
<Description>PowerToys Workspaces Launcher UI</Description>
<OutputType>WinExe</OutputType>
<UseWPF>true</UseWPF>
<UseWindowsForms>true</UseWindowsForms>
<AppendTargetFrameworkToOutputPath>false</AppendTargetFrameworkToOutputPath>
<AppendRuntimeIdentifierToOutputPath>false</AppendRuntimeIdentifierToOutputPath>
<GenerateSatelliteAssembliesForCore>true</GenerateSatelliteAssembliesForCore>
<OutputPath>$(RepoRoot)$(Platform)\$(Configuration)</OutputPath>
</PropertyGroup>
<PropertyGroup>
<ProjectGuid>{9C53CC25-0623-4569-95BC-B05410675EE3}</ProjectGuid>
<ProjectTypeGuids>{60dc8134-eba5-43b8-bcc9-bb4bc16c2548};{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}</ProjectTypeGuids>
<AutoGenerateBindingRedirects>true</AutoGenerateBindingRedirects>
</PropertyGroup>
<PropertyGroup>
<ApplicationIcon>..\Assets\Workspaces\Workspaces.ico</ApplicationIcon>
</PropertyGroup>
<PropertyGroup>
<ApplicationManifest>app.manifest</ApplicationManifest>
<AssemblyName>PowerToys.WorkspacesLauncherUI</AssemblyName>
</PropertyGroup>
<ItemGroup>
<Content Include="..\Assets\**\*.*">
<Link>Assets\Workspaces\%(Filename)%(Extension)</Link>
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</Content>
</ItemGroup>
<ItemGroup>
<COMReference Include="IWshRuntimeLibrary">
<WrapperTool>tlbimp</WrapperTool>
<VersionMinor>0</VersionMinor>
<VersionMajor>1</VersionMajor>
<Guid>f935dc20-1cf0-11d0-adb9-00c04fd58a0b</Guid>
<Lcid>0</Lcid>
<Isolated>false</Isolated>
<EmbedInteropTypes>true</EmbedInteropTypes>
</COMReference>
<COMReference Include="Shell32">
<WrapperTool>tlbimp</WrapperTool>
<VersionMinor>0</VersionMinor>
<VersionMajor>1</VersionMajor>
<Guid>50a7e9b0-70ef-11d1-b75a-00a0c90564fe</Guid>
<Lcid>0</Lcid>
<Isolated>false</Isolated>
<EmbedInteropTypes>true</EmbedInteropTypes>
</COMReference>
</ItemGroup>
<ItemGroup>
<EmbeddedResource Update="Properties\Resources.resx">
<Generator>PublicResXFileCodeGenerator</Generator>
<LastGenOutput>Resources.Designer.cs</LastGenOutput>
</EmbeddedResource>
<None Include="app.manifest" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="System.IO.Abstractions" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\..\common\Common.UI\Common.UI.csproj" />
<ProjectReference Include="..\..\..\common\GPOWrapperProjection\GPOWrapperProjection.csproj" />
<ProjectReference Include="..\..\..\common\interop\PowerToys.Interop.vcxproj" />
<ProjectReference Include="..\..\..\common\ManagedCommon\ManagedCommon.csproj" />
<ProjectReference Include="..\..\..\common\ManagedTelemetry\Telemetry\ManagedTelemetry.csproj" />
<ProjectReference Include="..\..\..\settings-ui\Settings.UI.Library\Settings.UI.Library.csproj" />
<ProjectReference Include="..\WorkspacesCsharpLibrary\WorkspacesCsharpLibrary.csproj" />
</ItemGroup>
<ItemGroup>
<Compile Update="Properties\Resources.Designer.cs">
<DesignTime>True</DesignTime>
<AutoGen>True</AutoGen>
<DependentUpon>Resources.resx</DependentUpon>
</Compile>
<Compile Update="Properties\Settings.Designer.cs">
<DesignTimeSharedInput>True</DesignTimeSharedInput>
<AutoGen>True</AutoGen>
<DependentUpon>Settings.settings</DependentUpon>
</Compile>
</ItemGroup>
<ItemGroup>
<None Update="Properties\Settings.settings">
<Generator>SettingsSingleFileGenerator</Generator>
<LastGenOutput>Settings.Designer.cs</LastGenOutput>
</None>
</ItemGroup>
</Project>

View File

@@ -0,0 +1,74 @@
<?xml version="1.0" encoding="utf-8"?>
<assembly manifestVersion="1.0" xmlns="urn:schemas-microsoft-com:asm.v1">
<assemblyIdentity version="1.0.0.0" name="MyApplication.app"/>
<trustInfo xmlns="urn:schemas-microsoft-com:asm.v2">
<security>
<requestedPrivileges xmlns="urn:schemas-microsoft-com:asm.v3">
<!-- UAC Manifest Options
If you want to change the Windows User Account Control level replace the
requestedExecutionLevel node with one of the following.
<requestedExecutionLevel level="asInvoker" uiAccess="false" />
<requestedExecutionLevel level="requireAdministrator" uiAccess="false" />
<requestedExecutionLevel level="highestAvailable" uiAccess="false" />
Specifying requestedExecutionLevel element will disable file and registry virtualization.
Remove this element if your application requires this virtualization for backwards
compatibility.
-->
<requestedExecutionLevel level="asInvoker" uiAccess="false" />
</requestedPrivileges>
</security>
</trustInfo>
<compatibility xmlns="urn:schemas-microsoft-com:compatibility.v1">
<application>
<!-- A list of the Windows versions that this application has been tested on
and is designed to work with. Uncomment the appropriate elements
and Windows will automatically select the most compatible environment. -->
<!-- Windows Vista -->
<!--<supportedOS Id="{e2011457-1546-43c5-a5fe-008deee3d3f0}" />-->
<!-- Windows 7 -->
<!--<supportedOS Id="{35138b9a-5d96-4fbd-8e2d-a2440225f93a}" />-->
<!-- Windows 8 -->
<!--<supportedOS Id="{4a2f28e3-53b9-4441-ba9c-d69d4a4a6e38}" />-->
<!-- Windows 8.1 -->
<!--<supportedOS Id="{1f676c76-80e1-4239-95bb-83d0f6d0da78}" />-->
<!-- Windows 10 -->
<supportedOS Id="{8e0f7a12-bfb3-4fe8-b9a5-48fd50a15a9a}" />
</application>
</compatibility>
<!-- Indicates that the application is DPI-aware and will not be automatically scaled by Windows at higher
DPIs. Windows Presentation Foundation (WPF) applications are automatically DPI-aware and do not need
to opt in. Windows Forms applications targeting .NET Framework 4.6 that opt into this setting, should
also set the 'EnableWindowsFormsHighDpiAutoResizing' setting to 'true' in their app.config. -->
<application xmlns="urn:schemas-microsoft-com:asm.v3">
<windowsSettings>
<dpiAwareness xmlns="http://schemas.microsoft.com/SMI/2016/WindowsSettings">PerMonitorV2</dpiAwareness>
</windowsSettings>
</application>
<!-- Enable themes for Windows common controls and dialogs (Windows XP and later) -->
<!--
<dependency>
<dependentAssembly>
<assemblyIdentity
type="win32"
name="Microsoft.Windows.Common-Controls"
version="6.0.0.0"
processorArchitecture="*"
publicKeyToken="6595b64144ccf1df"
language="*"
/>
</dependentAssembly>
</dependency>
-->
</assembly>

View File

@@ -126,6 +126,16 @@ public sealed partial class CmdPalMainControl : UserControl
return CardBorder.ActualHeight;
}
/// <summary>
/// When <paramref name="stretch"/> is <see langword="true"/>, the card stretches to fill
/// the entire window vertically (non-compact mode). When <see langword="false"/>, the card
/// sizes itself to its content and anchors to the top of the window (compact mode).
/// </summary>
public void SetCardStretch(bool stretch)
{
CardBorder.VerticalAlignment = stretch ? VerticalAlignment.Stretch : VerticalAlignment.Top;
}
/// <summary>
/// Forwards the host window's activation state to the current backdrop so the system can
/// render its active / inactive appearance correctly.

View File

@@ -1759,17 +1759,26 @@ public sealed partial class MainWindow : WindowEx,
{
var settings = App.Current.Services.GetRequiredService<ISettingsService>().Settings;
// Only the compact + centered configuration needs a screen-fit clamp. There the card
// is anchored near the vertical center of the display, so an expanded list could run
// off the bottom edge; cap its height so it always fits. In every other case the card
// is free to fill the (fixed-size) HWND as before.
if (expanded && settings.CompactMode && IsCenteringSummon(settings))
if (!settings.CompactMode)
{
RootElement.SetCardMaxHeight(ComputeExpandedCardMaxHeightDip());
// When compact mode is off the card is always static and fills the entire window,
// regardless of how much content is currently displayed.
RootElement.SetCardStretch(true);
RootElement.SetCardMaxHeight(double.PositiveInfinity);
}
else
{
RootElement.SetCardMaxHeight(double.PositiveInfinity);
// In compact mode the card sizes itself to its content and anchors to the top.
RootElement.SetCardStretch(false);
// Only the compact + centered configuration needs a screen-fit clamp. There the card
// is anchored near the vertical center of the display, so an expanded list could run
// off the bottom edge; cap its height so it always fits. In every other case the card
// is free to fill the (fixed-size) HWND as before.
var cardMaxHeight = expanded && IsCenteringSummon(settings)
? ComputeExpandedCardMaxHeightDip()
: double.PositiveInfinity;
RootElement.SetCardMaxHeight(cardMaxHeight);
}
}

View File

@@ -67,6 +67,12 @@ public sealed partial class ShellPage : Microsoft.UI.Xaml.Controls.Page,
private readonly CompositeFormat _pageNavigatedAnnouncement;
private readonly ISettingsService _settingsService;
// The last compact-mode setting we reacted to. Lets us ignore hot-reloads of unrelated
// settings and only re-evaluate the layout when compact mode itself changes.
private bool _compactMode;
private SettingsWindow? _settingsWindow;
private DockWindowManager? _dockWindowManager;
@@ -91,8 +97,9 @@ public sealed partial class ShellPage : Microsoft.UI.Xaml.Controls.Page,
public ShellPage()
{
var settings = App.Current.Services.GetRequiredService<ISettingsService>().Settings;
this.ExpandedMode = !settings.CompactMode;
_settingsService = App.Current.Services.GetRequiredService<ISettingsService>();
_compactMode = _settingsService.Settings.CompactMode;
this.ExpandedMode = !_compactMode;
this.InitializeComponent();
@@ -119,6 +126,11 @@ public sealed partial class ShellPage : Microsoft.UI.Xaml.Controls.Page,
WeakReferenceMessenger.Default.Register<ExpandCompactModeMessage>(this);
// The compact-mode setting can be toggled while the palette is open. React to the
// hot-reload so the expanded/collapsed layout updates immediately instead of waiting
// for the next navigation or search-text change.
_settingsService.SettingsChanged += OnSettingsChanged;
AddHandler(PreviewKeyDownEvent, new KeyEventHandler(ShellPage_OnPreviewKeyDown), true);
AddHandler(KeyDownEvent, new KeyEventHandler(ShellPage_OnKeyDown), false);
AddHandler(PointerPressedEvent, new PointerEventHandler(ShellPage_OnPointerPressed), true);
@@ -674,6 +686,11 @@ public sealed partial class ShellPage : Microsoft.UI.Xaml.Controls.Page,
var settings = App.Current.Services.GetRequiredService<ISettingsService>().Settings;
if (!settings.CompactMode)
{
// Compact mode is off: the shell always shows the full expanded UI. Set it
// explicitly (rather than trusting the constructor's initial value) so toggling
// the setting off at runtime restores the list and command bar when the palette
// was collapsed.
HandleExpandCompactOnUiThread(true);
return;
}
@@ -936,6 +953,24 @@ public sealed partial class ShellPage : Microsoft.UI.Xaml.Controls.Page,
this.DispatcherQueue.TryEnqueue(UpdateCompactModeForCurrentPage);
}
private void OnSettingsChanged(ISettingsService sender, SettingsModel args)
{
// Only the compact-mode setting affects the expanded/collapsed layout, so ignore
// hot-reloads that leave it unchanged. Comparing and updating _compactMode on the UI
// thread keeps it single-threaded regardless of which thread raises the event.
var compactMode = args.CompactMode;
this.DispatcherQueue.TryEnqueue(() =>
{
if (compactMode == _compactMode)
{
return;
}
_compactMode = compactMode;
UpdateCompactModeForCurrentPage();
});
}
private void HandleExpandCompactOnUiThread(bool expanded)
{
var settings = App.Current.Services.GetRequiredService<ISettingsService>().Settings;
@@ -979,6 +1014,7 @@ public sealed partial class ShellPage : Microsoft.UI.Xaml.Controls.Page,
_isDisposed = true;
WeakReferenceMessenger.Default.UnregisterAll(this);
_settingsService.SettingsChanged -= OnSettingsChanged;
_focusAfterLoadedCts?.Cancel();
_focusAfterLoadedCts?.Dispose();

View File

@@ -34,6 +34,7 @@ namespace Peek.UI
public MainWindowViewModel ViewModel { get; }
private readonly ThemeListener? themeListener;
private readonly IUserSettings userSettings;
/// <summary>
/// Whether the delete confirmation dialog is currently open. Used to ensure only one
@@ -66,6 +67,19 @@ namespace Peek.UI
AppWindow.SetIcon("Assets/Peek/Icon.ico");
AppWindow.Closing += AppWindow_Closing;
userSettings = Application.Current.GetService<IUserSettings>();
userSettings.Changed += UpdateWindowBySettings;
UpdateWindowBySettings(null, EventArgs.Empty);
}
private void UpdateWindowBySettings(object? sender, EventArgs e)
{
DispatcherQueue.TryEnqueue(() =>
{
IsAlwaysOnTop = userSettings.AlwaysOnTop;
IsShownInSwitchers = userSettings.ShowTaskbarIcon;
});
}
private async void Content_KeyUp(object sender, KeyRoutedEventArgs e)
@@ -88,7 +102,7 @@ namespace Peek.UI
{
_isDeleteInProgress = true;
if (Application.Current.GetService<IUserSettings>().ConfirmFileDelete)
if (userSettings.ConfirmFileDelete)
{
if (await ShowDeleteConfirmationDialogAsync() == ContentDialogResult.Primary)
{
@@ -341,6 +355,7 @@ namespace Peek.UI
public void Dispose()
{
themeListener?.Dispose();
userSettings.Changed -= UpdateWindowBySettings;
}
/// <summary>

View File

@@ -2,14 +2,22 @@
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
using System;
namespace Peek.UI
{
public interface IUserSettings
{
public bool AlwaysOnTop { get; }
public bool ShowTaskbarIcon { get; }
public bool CloseAfterLosingFocus { get; }
public bool ConfirmFileDelete { get; set; }
public bool ShowFilePreviewTooltip { get; }
public event EventHandler? Changed;
}
}

View File

@@ -36,13 +36,29 @@ namespace Peek.UI
lock (_settingsLock)
{
_settings = value;
AlwaysOnTop = _settings.Properties.AlwaysOnTop.Value;
ShowTaskbarIcon = _settings.Properties.ShowTaskbarIcon.Value;
CloseAfterLosingFocus = _settings.Properties.CloseAfterLosingFocus.Value;
ConfirmFileDelete = _settings.Properties.ConfirmFileDelete.Value;
ShowFilePreviewTooltip = _settings.Properties.ShowFilePreviewTooltip.Value;
}
Changed?.Invoke(this, EventArgs.Empty);
}
}
public event EventHandler? Changed;
/// <summary>
/// Gets a value indicating whether Peek shows its window on the top of the stack.
/// </summary>
public bool AlwaysOnTop { get; private set; }
/// <summary>
/// Gets a value indicating whether Peek shows its icon on the taskbar when activated.
/// </summary>
public bool ShowTaskbarIcon { get; private set; }
/// <summary>
/// Gets a value indicating whether Peek closes automatically when the window loses focus.
/// </summary>

View File

@@ -1,5 +1,6 @@
<Project Sdk="Microsoft.NET.Sdk">
<!-- Look at Directory.Build.props in root for common stuff as well -->
<Import Project="$(RepoRoot)src\Common.Dotnet.AotCompatibility.props" />
<PropertyGroup>
<!-- Currently hard-coded, as this project does not target WinRT.
@@ -8,6 +9,10 @@
<TargetFramework>net10.0-windows10.0.26100.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>disable</Nullable>
<!-- Required by the CsWinRT AOT optimizer: marshaling generic collections (e.g. the
Dictionary<Language, LanguageInfo> in CharacterMappings) across the WinRT ABI emits
unsafe code. Matches the sibling AOT-compatible library PowerDisplay.Lib. -->
<AllowUnsafeBlocks>true</AllowUnsafeBlocks>
</PropertyGroup>
</Project>

View File

@@ -0,0 +1,148 @@
// Copyright (c) Microsoft Corporation
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
using Microsoft.VisualStudio.TestTools.UnitTesting;
using PowerAccent.Core;
using PowerAccent.Core.Services;
using PowerAccent.Core.Tools;
namespace PowerAccent.Core.UnitTests;
/// <summary>
/// Exercises the pure anchor / DPI geometry in <see cref="Calculation"/>. These are the math that
/// the WinUI 3 Selector feeds into AppWindow.Move/Resize, so a regression here silently mis-places
/// the accent popup (the classic high-DPI / multi-monitor "double scaling" failure mode).
/// </summary>
[TestClass]
public sealed class CalculationTests
{
// offset baked into Calculation: the gap from the screen edge for the edge anchors.
private const int Offset = 24;
// A 1920x1080 primary monitor rooted at the virtual-desktop origin.
private static readonly Rect PrimaryScreen = new(0, 0, 1920, 1080);
// A one-row accent bar, in DIP.
private static readonly Size Window = new(200, 52);
// At 100% scaling (dpi = 1.0) the physical window size equals the DIP size, so each of the nine
// anchors lands at an easily hand-checkable coordinate.
[DataTestMethod]
[DataRow(Position.TopLeft, 24.0, 24.0)]
[DataRow(Position.Top, 860.0, 24.0)]
[DataRow(Position.TopRight, 1696.0, 24.0)]
[DataRow(Position.Left, 24.0, 514.0)]
[DataRow(Position.Center, 860.0, 514.0)]
[DataRow(Position.Right, 1696.0, 514.0)]
[DataRow(Position.BottomLeft, 24.0, 1004.0)]
[DataRow(Position.Bottom, 860.0, 1004.0)]
[DataRow(Position.BottomRight, 1696.0, 1004.0)]
public void GetRawCoordinatesFromPosition_AtDpi1_PlacesEachAnchor(Position position, double expectedX, double expectedY)
{
var point = Calculation.GetRawCoordinatesFromPosition(position, PrimaryScreen, Window, dpi: 1.0);
Assert.AreEqual(expectedX, point.X, "X for " + position);
Assert.AreEqual(expectedY, point.Y, "Y for " + position);
}
// At 150% scaling the physical window is 300x78. The centered anchors must subtract HALF of the
// scaled size (not the DIP size) and the right/bottom anchors must subtract the FULL scaled size
// plus the offset - this is exactly where a missing/extra dpi factor shows up.
[DataTestMethod]
[DataRow(Position.TopLeft, 24.0, 24.0)]
[DataRow(Position.Center, 810.0, 501.0)]
[DataRow(Position.BottomRight, 1596.0, 978.0)]
public void GetRawCoordinatesFromPosition_AtDpi150Percent_ScalesWindowFootprint(Position position, double expectedX, double expectedY)
{
var point = Calculation.GetRawCoordinatesFromPosition(position, PrimaryScreen, Window, dpi: 1.5);
Assert.AreEqual(expectedX, point.X, "X for " + position);
Assert.AreEqual(expectedY, point.Y, "Y for " + position);
}
// A secondary 2560x1440 monitor to the right of the primary at 200% scaling. Verifies the screen
// origin (screen.X / screen.Y) is honored for every anchor, not just the primary-at-origin case.
[DataTestMethod]
[DataRow(Position.TopLeft, 1944.0, 24.0)]
[DataRow(Position.Center, 3000.0, 668.0)]
[DataRow(Position.BottomRight, 4056.0, 1312.0)]
public void GetRawCoordinatesFromPosition_OnOffsetMonitor_HonorsScreenOrigin(Position position, double expectedX, double expectedY)
{
var secondaryScreen = new Rect(1920, 0, 2560, 1440);
var point = Calculation.GetRawCoordinatesFromPosition(position, secondaryScreen, Window, dpi: 2.0);
Assert.AreEqual(expectedX, point.X, "X for " + position);
Assert.AreEqual(expectedY, point.Y, "Y for " + position);
}
// A monitor positioned to the LEFT of the primary has a negative virtual-desktop X origin. The
// edge anchors must still be offset relative to that negative origin.
[TestMethod]
public void GetRawCoordinatesFromPosition_OnNegativeOriginMonitor_OffsetsFromScreenEdge()
{
var leftScreen = new Rect(-1920, 0, 1920, 1080);
var topLeft = Calculation.GetRawCoordinatesFromPosition(Position.TopLeft, leftScreen, Window, dpi: 1.0);
Assert.AreEqual(-1920 + Offset, topLeft.X);
Assert.AreEqual(Offset, topLeft.Y);
var bottomRight = Calculation.GetRawCoordinatesFromPosition(Position.BottomRight, leftScreen, Window, dpi: 1.0);
Assert.AreEqual(-1920 + 1920 - (Window.Width + Offset), bottomRight.X);
Assert.AreEqual(1080 - (Window.Height + Offset), bottomRight.Y);
}
[TestMethod]
public void GetRawCoordinatesFromPosition_UnknownPosition_Throws()
{
Assert.ThrowsException<NotImplementedException>(
() => Calculation.GetRawCoordinatesFromPosition((Position)999, PrimaryScreen, Window, dpi: 1.0));
}
// Caret-relative placement centers the window horizontally on the caret and sits it 20px above.
[TestMethod]
public void GetRawCoordinatesFromCaret_WithRoom_CentersAboveCaret()
{
var caret = new Point(960, 540);
var point = Calculation.GetRawCoordinatesFromCaret(caret, PrimaryScreen, Window);
Assert.AreEqual(960 - (Window.Width / 2), point.X); // 860
Assert.AreEqual(540 - Window.Height - 20, point.Y); // 468
}
// Near the left edge the window would overflow off-screen, so X clamps to the screen's left edge.
[TestMethod]
public void GetRawCoordinatesFromCaret_NearLeftEdge_ClampsToScreenLeft()
{
var caret = new Point(50, 540);
var point = Calculation.GetRawCoordinatesFromCaret(caret, PrimaryScreen, Window);
Assert.AreEqual(PrimaryScreen.X, point.X);
}
// Near the right edge X clamps so the window's right side sits on the screen's right edge.
[TestMethod]
public void GetRawCoordinatesFromCaret_NearRightEdge_ClampsToScreenRight()
{
var caret = new Point(1900, 540);
var point = Calculation.GetRawCoordinatesFromCaret(caret, PrimaryScreen, Window);
Assert.AreEqual(PrimaryScreen.X + PrimaryScreen.Width - Window.Width, point.X); // 1720
}
// When there is no room above the caret (top would land off-screen) the window flips to 20px
// BELOW the caret instead of being clipped at the top.
[TestMethod]
public void GetRawCoordinatesFromCaret_NoRoomAbove_FlipsBelowCaret()
{
var caret = new Point(960, 10);
var point = Calculation.GetRawCoordinatesFromCaret(caret, PrimaryScreen, Window);
Assert.AreEqual(caret.Y + 20, point.Y); // 30
}
}

View File

@@ -0,0 +1,34 @@
<Project Sdk="Microsoft.NET.Sdk">
<!-- Look at Directory.Build.props in root for common stuff as well -->
<Import Project="$(RepoRoot)src\Common.Dotnet.CsWinRT.props" />
<PropertyGroup>
<AssemblyName>PowerToys.PowerAccent.Core.UnitTests</AssemblyName>
<OutputType>Exe</OutputType>
<OutputPath>$(RepoRoot)$(Platform)\$(Configuration)\tests\PowerAccent.Core.UnitTests\</OutputPath>
<AppendTargetFrameworkToOutputPath>false</AppendTargetFrameworkToOutputPath>
<AppendRuntimeIdentifierToOutputPath>false</AppendRuntimeIdentifierToOutputPath>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
</PropertyGroup>
<PropertyGroup>
<!--
Do NOT set CsWinRTIncludes here. PowerAccent.Core already projects PowerToys.GPOWrapper and
PowerToys.PowerAccentKeyboardService, and those managed projections arrive transitively through
the PowerAccent.Core project reference. Listing either here generates a SECOND copy and breaks
the build with CS0436 (matches PowerAccent.UI, which references Core the same way).
-->
<CsWinRTGeneratedFilesDir>$(OutDir)</CsWinRTGeneratedFilesDir>
<ErrorOnDuplicatePublishOutputFiles>false</ErrorOnDuplicatePublishOutputFiles>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="MSTest" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\PowerAccent.Core\PowerAccent.Core.csproj" />
</ItemGroup>
</Project>

View File

@@ -6,12 +6,6 @@ namespace PowerAccent.Core;
public struct Point
{
public Point()
{
X = 0;
Y = 0;
}
public Point(double x, double y)
{
X = x;
@@ -24,35 +18,7 @@ public struct Point
Y = y;
}
public Point(System.Drawing.Point point)
{
X = point.X;
Y = point.Y;
}
public double X { get; init; }
public double Y { get; init; }
public static implicit operator Point(System.Drawing.Point point) => new Point(point.X, point.Y);
public static Point operator /(Point point, double divider)
{
if (divider == 0)
{
throw new DivideByZeroException();
}
return new Point(point.X / divider, point.Y / divider);
}
public static Point operator /(Point point, Point divider)
{
if (divider.X == 0 || divider.Y == 0)
{
throw new DivideByZeroException();
}
return new Point(point.X / divider.X, point.Y / divider.Y);
}
}

View File

@@ -6,14 +6,6 @@ namespace PowerAccent.Core;
public struct Rect
{
public Rect()
{
X = 0;
Y = 0;
Width = 0;
Height = 0;
}
public Rect(int x, int y, int width, int height)
{
X = x;
@@ -22,14 +14,6 @@ public struct Rect
Height = height;
}
public Rect(double x, double y, double width, double height)
{
X = x;
Y = y;
Width = width;
Height = height;
}
public Rect(Point coord, Size size)
{
X = coord.X;
@@ -45,24 +29,4 @@ public struct Rect
public double Width { get; init; }
public double Height { get; init; }
public static Rect operator /(Rect rect, double divider)
{
if (divider == 0)
{
throw new DivideByZeroException();
}
return new Rect(rect.X / divider, rect.Y / divider, rect.Width / divider, rect.Height / divider);
}
public static Rect operator /(Rect rect, Rect divider)
{
if (divider.X == 0 || divider.Y == 0)
{
throw new DivideByZeroException();
}
return new Rect(rect.X / divider.X, rect.Y / divider.Y, rect.Width / divider.Width, rect.Height / divider.Height);
}
}

View File

@@ -6,12 +6,6 @@ namespace PowerAccent.Core;
public struct Size
{
public Size()
{
Width = 0;
Height = 0;
}
public Size(double width, double height)
{
Width = width;
@@ -27,26 +21,4 @@ public struct Size
public double Width { get; init; }
public double Height { get; init; }
public static implicit operator Size(System.Drawing.Size size) => new Size(size.Width, size.Height);
public static Size operator /(Size size, double divider)
{
if (divider == 0)
{
throw new DivideByZeroException();
}
return new Size(size.Width / divider, size.Height / divider);
}
public static Size operator /(Size size, Size divider)
{
if (divider.Width == 0 || divider.Height == 0 || divider.Width == 0 || divider.Height == 0)
{
throw new DivideByZeroException();
}
return new Size(size.Width / divider.Width, size.Height / divider.Height);
}
}

View File

@@ -8,8 +8,6 @@
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>disable</Nullable>
<AllowUnsafeBlocks>True</AllowUnsafeBlocks>
<UseWPF>true</UseWPF>
<UseWindowsForms>true</UseWindowsForms>
</PropertyGroup>
<PropertyGroup>
@@ -26,6 +24,13 @@
<PackageReference Include="UnicodeInformation" />
</ItemGroup>
<ItemGroup>
<!-- Expose internal helpers (e.g. Tools.Calculation) to the unit-test assembly. -->
<AssemblyAttribute Include="System.Runtime.CompilerServices.InternalsVisibleToAttribute">
<_Parameter1>PowerToys.PowerAccent.Core.UnitTests</_Parameter1>
</AssemblyAttribute>
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\..\common\GPOWrapper\GPOWrapper.vcxproj" />
<ProjectReference Include="..\..\..\common\interop\PowerToys.Interop.vcxproj" />

View File

@@ -11,6 +11,8 @@ using PowerAccent.Core.Services;
using PowerAccent.Core.Tools;
using PowerToys.PowerAccentKeyboardService;
using PowerAccentActivationKey = Microsoft.PowerToys.Settings.UI.Library.Enumerations.PowerAccentActivationKey;
namespace PowerAccent.Core;
public partial class PowerAccent : IDisposable
@@ -43,8 +45,12 @@ public partial class PowerAccent : IDisposable
private readonly CharactersUsageInfo _usageInfo;
public PowerAccent()
private readonly Action<Action> _runOnUiThread;
public PowerAccent(Action<Action> runOnUiThread)
{
_runOnUiThread = runOnUiThread ?? throw new ArgumentNullException(nameof(runOnUiThread));
Logger.InitializeLogger("\\QuickAccent\\Logs");
LoadUnicodeInfoCache();
@@ -66,7 +72,7 @@ public partial class PowerAccent : IDisposable
{
_keyboardListener.SetShowToolbarEvent(new PowerToys.PowerAccentKeyboardService.ShowToolbar((LetterKey letterKey) =>
{
System.Windows.Application.Current.Dispatcher.Invoke(() =>
_runOnUiThread(() =>
{
ShowToolbar(letterKey);
});
@@ -74,7 +80,7 @@ public partial class PowerAccent : IDisposable
_keyboardListener.SetHideToolbarEvent(new PowerToys.PowerAccentKeyboardService.HideToolbar((InputType inputType) =>
{
System.Windows.Application.Current.Dispatcher.Invoke(() =>
_runOnUiThread(() =>
{
SendInputAndHideToolbar(inputType);
});
@@ -82,7 +88,7 @@ public partial class PowerAccent : IDisposable
_keyboardListener.SetNextCharEvent(new PowerToys.PowerAccentKeyboardService.NextChar((TriggerKey triggerKey, bool shiftPressed) =>
{
System.Windows.Application.Current.Dispatcher.Invoke(() =>
_runOnUiThread(() =>
{
ProcessNextChar(triggerKey, shiftPressed);
});
@@ -96,28 +102,49 @@ public partial class PowerAccent : IDisposable
private void ShowToolbar(LetterKey letterKey)
{
_initialShiftState = WindowsFunctions.IsShiftState();
_visible = true;
bool isPressAndHold = _settingService.ActivationKey == PowerAccentActivationKey.PressAndHold;
// Each summon gets a generation id so a delayed render queued by an earlier
// press can't fire for a newer one (or after the toolbar was hidden).
int generation = ++_showGeneration;
_characters = GetCharacters(letterKey);
_characterDescriptions = GetCharacterDescriptions(_characters);
_showUnicodeDescription = _settingService.ShowUnicodeDescription;
// Trigger modes navigate the instant the toolbar is summoned, so the character data must
// be ready synchronously. Press-and-hold can't navigate until the popup is actually shown,
// so defer the (relatively expensive) character/description build to the delayed render and
// keep quick taps off the keystroke hot path.
if (!isPressAndHold)
{
PrepareCharacters(letterKey);
}
Task.Delay(_settingService.InputTime).ContinueWith(
int displayDelay = isPressAndHold ? _settingService.HoldDuration : _settingService.InputTime;
Task.Delay(displayDelay).ContinueWith(
t =>
{
if (_visible && generation == _showGeneration)
{
if (isPressAndHold)
{
PrepareCharacters(letterKey);
}
OnChangeDisplay?.Invoke(true, _characters);
}
},
TaskScheduler.FromCurrentSynchronizationContext());
}
private void PrepareCharacters(LetterKey letterKey)
{
_initialShiftState = WindowsFunctions.IsShiftState();
_characters = GetCharacters(letterKey);
_characterDescriptions = GetCharacterDescriptions(_characters);
_showUnicodeDescription = _settingService.ShowUnicodeDescription;
}
private string[] GetCharacters(LetterKey letterKey)
{
var characters = CharacterMappings.GetCharacters(letterKey, _settingService.SelectedLang);
@@ -213,13 +240,13 @@ public partial class PowerAccent : IDisposable
case InputType.Right:
{
SendKeys.SendWait("{RIGHT}");
WindowsFunctions.SendArrowKey(left: false);
break;
}
case InputType.Left:
{
SendKeys.SendWait("{LEFT}");
WindowsFunctions.SendArrowKey(left: true);
break;
}
@@ -247,6 +274,13 @@ public partial class PowerAccent : IDisposable
private void ProcessNextChar(TriggerKey triggerKey, bool shiftPressed)
{
// Press-and-hold builds its character set lazily when the popup renders; ignore any
// navigation that races ahead of it (there is nothing to select yet).
if (_characters.Length == 0)
{
return;
}
// 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
@@ -361,14 +395,13 @@ public partial class PowerAccent : IDisposable
/// 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>
/// <returns>The maximum width in DIPs (device-independent pixels), accounting for
/// screen padding.</returns>
public double GetDisplayMaxWidth()
{
// 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.
// activeDisplay.Size.Width is in raw physical pixels; divide by the DPI scale to
// convert to DIPs (device-independent pixels), since ScreenMinPadding and the
// consuming window width are both expressed in DIPs.
var activeDisplay = WindowsFunctions.GetActiveDisplay();
return (activeDisplay.Size.Width / activeDisplay.Dpi) - ScreenMinPadding;
}

View File

@@ -59,6 +59,9 @@ public class SettingsService
InputTime = settings.Properties.InputTime.Value;
_keyboardListener.UpdateInputTime(InputTime);
HoldDuration = settings.Properties.HoldDuration.Value;
_keyboardListener.UpdateHoldDuration(HoldDuration);
ExcludedApps = settings.Properties.ExcludedApps.Value;
_keyboardListener.UpdateExcludedApps(ExcludedApps);
@@ -196,6 +199,8 @@ public class SettingsService
}
}
public int HoldDuration { get; set; } = PowerAccentSettings.DefaultHoldDurationMs;
private string _excludedApps;
public string ExcludedApps

View File

@@ -88,6 +88,40 @@ internal static class WindowsFunctions
}
}
public static void SendArrowKey(bool left)
{
var key = left ? VIRTUAL_KEY.VK_LEFT : VIRTUAL_KEY.VK_RIGHT;
var inputs = new INPUT[]
{
new INPUT
{
type = INPUT_TYPE.INPUT_KEYBOARD,
Anonymous = new INPUT._Anonymous_e__Union
{
ki = new KEYBDINPUT
{
wVk = key,
dwFlags = KEYBD_EVENT_FLAGS.KEYEVENTF_EXTENDEDKEY,
},
},
},
new INPUT
{
type = INPUT_TYPE.INPUT_KEYBOARD,
Anonymous = new INPUT._Anonymous_e__Union
{
ki = new KEYBDINPUT
{
wVk = key,
dwFlags = KEYBD_EVENT_FLAGS.KEYEVENTF_EXTENDEDKEY | KEYBD_EVENT_FLAGS.KEYEVENTF_KEYUP,
},
},
},
};
_ = PInvoke.SendInput(inputs, Marshal.SizeOf<INPUT>());
}
public static (Point Location, Size Size, double Dpi) GetActiveDisplay()
{
GUITHREADINFO guiInfo = default;
@@ -107,7 +141,8 @@ internal static class WindowsFunctions
double dpi = dpiRaw / 96d;
var location = new Point(monitorInfo.rcWork.left, monitorInfo.rcWork.top);
return (location, monitorInfo.rcWork.Size, dpi);
var size = new Size(monitorInfo.rcWork.right - monitorInfo.rcWork.left, monitorInfo.rcWork.bottom - monitorInfo.rcWork.top);
return (location, size, dpi);
}
public static bool IsCapsLockState()

View File

@@ -1,6 +0,0 @@
<Application
x:Class="PowerAccent.UI.App"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
StartupUri="Selector.xaml"
ThemeMode="System" />

View File

@@ -1,64 +0,0 @@
// Copyright (c) Microsoft Corporation
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
using System;
using System.Threading;
using System.Windows;
using ManagedCommon;
using Microsoft.PowerToys.Telemetry;
namespace PowerAccent.UI
{
/// <summary>
/// Interaction logic for App.xaml
/// </summary>
public partial class App : Application, IDisposable
{
private static Mutex _mutex;
private bool _disposed;
private ETWTrace _etwTrace = new ETWTrace();
protected override void OnStartup(StartupEventArgs e)
{
_mutex = new Mutex(true, "QuickAccent", out bool createdNew);
if (!createdNew)
{
Logger.LogWarning("Another running QuickAccent instance was detected. Exiting QuickAccent");
Application.Current.Shutdown();
}
base.OnStartup(e);
}
protected override void OnExit(ExitEventArgs e)
{
_mutex?.ReleaseMutex();
base.OnExit(e);
}
protected virtual void Dispose(bool disposing)
{
if (_disposed)
{
return;
}
if (disposing)
{
_mutex?.Dispose();
_etwTrace?.Dispose();
}
_disposed = true;
}
public void Dispose()
{
Dispose(disposing: true);
GC.SuppressFinalize(this);
}
}
}

View File

@@ -1,10 +0,0 @@
// Copyright (c) Microsoft Corporation
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
using System.Windows;
[assembly: ThemeInfo(
ResourceDictionaryLocation.None, // where theme specific resource dictionaries are located (used if a resource is not found in the page, or application resource dictionaries)
ResourceDictionaryLocation.SourceAssembly) // where the generic resource dictionary is locate (used if a resource is not found in the page, app, or any theme specific resource dictionaries)
]

View File

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

View File

@@ -2,39 +2,110 @@
<!-- Look at Directory.Build.props in root for common stuff as well -->
<Import Project="$(RepoRoot)src\Common.Dotnet.CsWinRT.props" />
<Import Project="$(RepoRoot)src\Common.SelfContained.props" />
<PropertyGroup>
<OutputType>WinExe</OutputType>
<Nullable>disable</Nullable>
<UseWPF>true</UseWPF>
<AllowUnsafeBlocks>True</AllowUnsafeBlocks>
<ApplicationIcon>icon.ico</ApplicationIcon>
<ApplicationManifest>app.manifest</ApplicationManifest>
<AssemblyName>PowerToys.PowerAccent</AssemblyName>
<XamlDebuggingInformation>True</XamlDebuggingInformation>
<StartupObject>PowerAccent.UI.Program</StartupObject>
<OutputPath>$(RepoRoot)$(Platform)\$(Configuration)</OutputPath>
<AppendTargetFrameworkToOutputPath>false</AppendTargetFrameworkToOutputPath>
<AppendRuntimeIdentifierToOutputPath>false</AppendRuntimeIdentifierToOutputPath>
</PropertyGroup>
<Import Project="$(RepoRoot)src\Common.Dotnet.AotCompatibility.props" />
<ItemGroup>
<Resource Include="icon.ico">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</Resource>
</ItemGroup>
<PropertyGroup>
<OutputType>WinExe</OutputType>
<RootNamespace>PowerAccent.UI</RootNamespace>
<AssemblyName>PowerToys.PowerAccent</AssemblyName>
<Nullable>disable</Nullable>
<AllowUnsafeBlocks>True</AllowUnsafeBlocks>
<!-- Required so CommunityToolkit.Mvvm's source generator emits the WinRT-correct partial
property implementations for [ObservableProperty] (avoids MVVMTK0045 / CS9248).
Matches the sibling WinUI 3 module PowerDisplay. -->
<LangVersion>preview</LangVersion>
<UseWinUI>true</UseWinUI>
<!--
App.xaml and the windows live under PowerAccentXAML\ (not the project root). Nesting the XAML
in a named subfolder is the repo convention for WinUI 3 apps that share the WinUI3Apps output
folder (see Peek's PeekXAML\, PowerDisplay's PowerDisplayXAML\): it keeps the compiled .xbf out
of the WinUI3Apps root, so the "Audit WinAppSDK applications path asset conflicts" pipeline step
passes. Disable the default ApplicationDefinition glob so the explicit
<ApplicationDefinition Include="PowerAccentXAML\App.xaml" /> below is the single one.
-->
<EnableDefaultApplicationDefinition>false</EnableDefaultApplicationDefinition>
<EnablePreviewMsixTooling>true</EnablePreviewMsixTooling>
<WindowsPackageType>None</WindowsPackageType>
<WindowsAppSDKSelfContained>true</WindowsAppSDKSelfContained>
<ApplicationIcon>icon.ico</ApplicationIcon>
<ApplicationManifest>app.manifest</ApplicationManifest>
<StartupObject>PowerAccent.UI.Program</StartupObject>
<DefineConstants>DISABLE_XAML_GENERATED_MAIN</DefineConstants>
<AppendTargetFrameworkToOutputPath>false</AppendTargetFrameworkToOutputPath>
<AppendRuntimeIdentifierToOutputPath>false</AppendRuntimeIdentifierToOutputPath>
<OutputPath>$(RepoRoot)$(Platform)\$(Configuration)\WinUI3Apps</OutputPath>
<ProjectPriFileName>PowerToys.PowerAccent.pri</ProjectPriFileName>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.Windows.CsWin32">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
</ItemGroup>
<!-- Native AOT Configuration. Mirrors the sibling WinUI 3 module PowerDisplay so the app is
compiled with ILC on publish, surfacing trim/AOT problems that the analyzers alone miss. -->
<PropertyGroup>
<PublishSingleFile>false</PublishSingleFile>
<DisableRuntimeMarshalling>false</DisableRuntimeMarshalling>
<PublishAot>true</PublishAot>
<TrimmerSingleWarn>false</TrimmerSingleWarn>
<SuppressTrimAnalysisWarnings>false</SuppressTrimAnalysisWarnings>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\..\..\common\Common.UI\Common.UI.csproj" />
<ProjectReference Include="..\..\..\common\interop\PowerToys.Interop.vcxproj" />
<ProjectReference Include="..\PowerAccent.Core\PowerAccent.Core.csproj" />
<ProjectReference Include="..\PowerAccentKeyboardService\PowerAccentKeyboardService.vcxproj" />
</ItemGroup>
<PropertyGroup>
<!--
Do NOT set CsWinRTIncludes here. Both WinRT components the UI touches - PowerToys.GPOWrapper
(called directly from Program.cs) and PowerToys.PowerAccentKeyboardService (used by Core) -
are already projected by PowerAccent.Core, which the UI references, so their managed
projections arrive transitively. Listing either here generates a SECOND copy of the same
types and breaks the build with CS0436 (e.g. GpoRuleConfigured defined both in this project's
generated files and in PowerAccent.Core). This matches the original WPF UI, which had no
CsWinRTIncludes at all.
-->
<CsWinRTGeneratedFilesDir>$(OutDir)</CsWinRTGeneratedFilesDir>
<ErrorOnDuplicatePublishOutputFiles>false</ErrorOnDuplicatePublishOutputFiles>
</PropertyGroup>
<ItemGroup>
<Resource Include="icon.ico">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</Resource>
</ItemGroup>
<Target Name="CopyPRIFileToOutputDir" AfterTargets="Build">
<ItemGroup>
<PRIFile Include="$(OutDir)**\PowerToys.PowerAccent.pri" />
</ItemGroup>
<Copy SourceFiles="@(PRIFile)" DestinationFolder="$(OutDir)" />
</Target>
<ItemGroup>
<Page Remove="PowerAccentXAML\App.xaml" />
</ItemGroup>
<ItemGroup>
<ApplicationDefinition Include="PowerAccentXAML\App.xaml" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="Microsoft.WindowsAppSDK" />
<PackageReference Include="Microsoft.Windows.SDK.BuildTools" />
<PackageReference Include="WinUIEx" />
<PackageReference Include="CommunityToolkit.Mvvm" />
<PackageReference Include="CommunityToolkit.WinUI.Animations" />
<PackageReference Include="CommunityToolkit.WinUI.Converters" />
<Manifest Include="$(ApplicationManifest)" />
</ItemGroup>
<ItemGroup Condition="'$(DisableMsixProjectCapabilityAddedByProject)'!='true' and '$(EnableMsixTooling)'=='true'">
<ProjectCapability Include="Msix" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\..\common\Common.UI\Common.UI.csproj" />
<ProjectReference Include="..\..\..\common\Common.UI.Controls\Common.UI.Controls.csproj" />
<ProjectReference Include="..\..\..\common\ManagedCommon\ManagedCommon.csproj" />
<ProjectReference Include="..\..\..\common\interop\PowerToys.Interop.vcxproj" />
<ProjectReference Include="..\PowerAccent.Core\PowerAccent.Core.csproj" />
<ProjectReference Include="..\PowerAccentKeyboardService\PowerAccentKeyboardService.vcxproj" />
</ItemGroup>
<PropertyGroup Condition="'$(DisableHasPackageAndPublishMenuAddedByProject)'!='true' and '$(EnableMsixTooling)'=='true'">
<HasPackageAndPublishMenu>true</HasPackageAndPublishMenu>
</PropertyGroup>
</Project>

View File

@@ -1,9 +1,8 @@
<?xml version="1.0" encoding="utf-8" ?>
<Application
x:Class="WorkspacesLauncherUI.App"
x:Class="PowerAccent.UI.App"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:local="using:WorkspacesLauncherUI">
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml">
<Application.Resources>
<ResourceDictionary>
<ResourceDictionary.MergedDictionaries>

View File

@@ -0,0 +1,60 @@
// 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 ManagedCommon;
using Microsoft.PowerToys.Telemetry;
using Microsoft.UI.Dispatching;
using Microsoft.UI.Xaml;
namespace PowerAccent.UI;
public partial class App : Application, IDisposable
{
private readonly ETWTrace _etwTrace = new ETWTrace();
private bool _disposed;
public static new App Current => (App)Application.Current;
public DispatcherQueue DispatcherQueueForApp { get; private set; }
public static MainWindow Window { get; private set; }
public App()
{
InitializeComponent();
UnhandledException += (s, e) => Logger.LogError("Unhandled exception", e.Exception);
}
protected override void OnLaunched(LaunchActivatedEventArgs args)
{
DispatcherQueueForApp = DispatcherQueue.GetForCurrentThread();
Window = new MainWindow();
// Quick Accent has no visible main window until summoned by the keyboard hook;
// the accent selector keeps itself hidden (TransparentWindow hides its AppWindow on init).
}
protected virtual void Dispose(bool disposing)
{
if (_disposed)
{
return;
}
if (disposing)
{
_etwTrace?.Dispose();
}
_disposed = true;
}
public void Dispose()
{
Dispose(disposing: true);
GC.SuppressFinalize(this);
}
}

View File

@@ -0,0 +1,19 @@
<?xml version="1.0" encoding="utf-8" ?>
<common:TransparentWindow
x:Class="PowerAccent.UI.MainWindow"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:common="using:Microsoft.PowerToys.Common.UI.Controls.Window"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:local="using:PowerAccent.UI"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
mc:Ignorable="d">
<!--
The content lives in a UserControl (SelectorControl) rather than inline here so its x:Bind
bindings initialize on the control's Loading pass - which fires when this SW_SHOWNA overlay is
first laid out - instead of on Window.Activated, which never fires for a window shown without
activation. That removes the need to call Bindings.Update() by hand.
-->
<local:SelectorControl x:Name="Selector" />
</common:TransparentWindow>

View File

@@ -0,0 +1,177 @@
// Copyright (c) Microsoft Corporation
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
using System;
using Microsoft.PowerToys.Common.UI.Controls.Window;
using Microsoft.UI.Dispatching;
using Microsoft.UI.Windowing;
using Windows.Graphics;
using CoreSize = PowerAccent.Core.Size;
namespace PowerAccent.UI;
public sealed partial class MainWindow : TransparentWindow, IDisposable
{
// Accent-bar geometry (DIP). Width is derived from the item count (count * ItemWidthDip), not
// measured from the ListView: its DesiredSize (wrapped in a ScrollViewer) is racy while item
// containers realize and intermittently reports 0, yielding a blank/clipped bar. The one-row bar
// hugs its content like the WPF original, capped at the monitor width; beyond that it scrolls
// and ScrollIntoView reveals the selected glyph.
private const double RowHeightDip = 92; // one row of accent pills (item Height=48 + card border)
private const double DescriptionHeightDip = 36; // extra row shown when the Unicode description is on
private const double ItemWidthDip = 48; // one accent cell (ListViewItem Grid MinWidth=48)
private const double DescriptionMinWidthDip = 648; // min bar width while the description row shows (WPF parity)
private readonly Core.PowerAccent _powerAccent;
private int _selectedIndex = -1;
private bool _active;
// The view model lives on the SelectorControl (the x:Bind target); expose it here for the
// PowerAccent event handlers that populate the accent list and description.
private SelectorViewModel ViewModel => Selector.ViewModel;
public MainWindow()
{
InitializeComponent();
// Give the overlay a stable UIA identity (window name) for accessibility tools (Narrator,
// Accessibility Insights) and the release-verification harness. "Quick Accent" is the
// user-facing feature name.
AppWindow.Title = "Quick Accent";
// The accent popup is shown/hidden instantly (no slide/fade) for typing-aid
// responsiveness. TransientSurface defaults to Transition.None (no animation);
// SubscribeSurfaceTo forwards to the inner surface so it follows this window's Show/Hide.
Selector.SubscribeSurfaceTo(this);
_powerAccent = new Core.PowerAccent(RunOnUiThread);
_powerAccent.OnChangeDisplay += PowerAccent_OnChangeDisplay;
_powerAccent.OnSelectCharacter += PowerAccent_OnSelectCharacter;
// No manual theme handling: App.xaml leaves RequestedTheme unset, so WinUI follows the system
// theme and re-resolves the {ThemeResource} brushes (and retints the acrylic) on a live
// light/dark switch, even for this never-activated SW_SHOWNA overlay.
}
// Marshal keyboard-hook callbacks (ShowToolbar / HideToolbar / NextChar) onto the UI thread. The
// hook runs on this UI thread, so callbacks arrive here already; run them inline (not via
// TryEnqueue, which would defer) so the accent injection stays ordered before the hook returns
// and the trigger key-up propagates. Fall back to enqueueing if ever called off-thread.
private void RunOnUiThread(Action action)
{
if (DispatcherQueue.HasThreadAccess)
{
action();
}
else
{
DispatcherQueue.TryEnqueue(() => action());
}
}
private void PowerAccent_OnChangeDisplay(bool isActive, string[] chars)
{
if (!isActive)
{
_active = false;
// Release always-on-top before hiding so the dormant overlay does not keep a discrete
// GPU awake on hybrid-graphics laptops (issue #34849 / PR #41044). IsAlwaysOnTop is the
// WinUIEx WindowEx property (same as the sibling PowerDisplay).
IsAlwaysOnTop = false;
Hide();
ViewModel.Characters.Clear();
_selectedIndex = -1;
return;
}
_active = true;
ViewModel.ShowDescription = _powerAccent.ShowUnicodeDescription;
ViewModel.Characters.Clear();
foreach (var c in chars)
{
ViewModel.Characters.Add(c);
}
Selector.SetSelectedIndex(_selectedIndex);
ViewModel.Description = (_selectedIndex >= 0 && _selectedIndex < _powerAccent.CharacterDescriptions.Length)
? _powerAccent.CharacterDescriptions[_selectedIndex]
: string.Empty;
// Always-on-top only while shown, so the overlay sits above the foreground app (Show uses
// SW_SHOWNA and never activates it); released on hide (see above). Then size and show.
IsAlwaysOnTop = true;
SizeAndPosition();
Show();
DispatcherQueue.TryEnqueue(DispatcherQueuePriority.Low, () =>
{
if (_active)
{
Selector.ScrollSelectedIntoView(_selectedIndex);
}
});
Microsoft.PowerToys.Telemetry.PowerToysTelemetry.Log.WriteEvent(new Core.Telemetry.PowerAccentShowAccentMenuEvent());
}
private void PowerAccent_OnSelectCharacter(int index, string character)
{
_selectedIndex = index;
Selector.SetSelectedIndex(index);
if (index >= 0 && index < _powerAccent.CharacterDescriptions.Length)
{
ViewModel.Description = _powerAccent.CharacterDescriptions[index];
}
Selector.ScrollSelectedIntoView(index);
}
private void SizeAndPosition()
{
// Width hugs the content: item count * ItemWidthDip (see the class-level note on why the
// ListView is not measured), capped at the monitor's max usable width so long lists scroll.
double maxWidthDip = _powerAccent.GetDisplayMaxWidth();
double contentWidthDip = ViewModel.Characters.Count * ItemWidthDip;
// The Unicode description row needs room for a readable line; the WPF original gave it a
// 600px MinWidth. Widen a short accent bar to match when the row is shown (the accent bar
// itself stays centered within the wider window).
if (ViewModel.ShowDescription)
{
contentWidthDip = Math.Max(contentWidthDip, DescriptionMinWidthDip);
}
double widthDip = Math.Clamp(contentWidthDip, ItemWidthDip, maxWidthDip);
double heightDip = RowHeightDip + (ViewModel.ShowDescription ? DescriptionHeightDip : 0);
// Calculation works in physical pixels; GetDisplayCoordinates multiplies the DIP size by
// the active monitor's DPI internally and returns the physical top-left for the anchor.
var coordinates = _powerAccent.GetDisplayCoordinates(new CoreSize(widthDip, heightDip));
var display = DisplayArea.GetFromPoint(
new PointInt32((int)Math.Round(coordinates.X), (int)Math.Round(coordinates.Y)),
DisplayAreaFallback.Nearest);
double dpiScale = FlyoutWindowHelper.GetDpiScale(display);
var rect = new RectInt32(
(int)Math.Round(coordinates.X),
(int)Math.Round(coordinates.Y),
(int)Math.Ceiling(widthDip * dpiScale),
(int)Math.Ceiling(heightDip * dpiScale));
FlyoutWindowHelper.MoveAndResizeOnDisplay(this, display, rect);
}
public void Dispose()
{
_powerAccent.SaveUsageInfo();
_powerAccent.Dispose();
GC.SuppressFinalize(this);
}
}

View File

@@ -0,0 +1,141 @@
<?xml version="1.0" encoding="utf-8" ?>
<UserControl
x:Class="PowerAccent.UI.SelectorControl"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:controls="using:Microsoft.PowerToys.Common.UI.Controls"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
mc:Ignorable="d">
<controls:TransientSurface x:Name="Surface" Margin="24,24,24,16">
<Grid x:Name="RootContent">
<Grid.RowDefinitions>
<RowDefinition Height="Auto" />
<RowDefinition Height="Auto" />
</Grid.RowDefinitions>
<Grid Background="{ThemeResource LayerOnAcrylicFillColorDefaultBrush}">
<ListView
x:Name="CharactersList"
Padding="0"
HorizontalAlignment="Center"
AutomationProperties.AutomationId="QuickAccentCharacterList"
IsHitTestVisible="False"
IsItemClickEnabled="False"
ItemsSource="{x:Bind ViewModel.Characters, Mode=OneWay}"
ScrollViewer.HorizontalScrollBarVisibility="Hidden"
ScrollViewer.HorizontalScrollMode="Enabled"
ScrollViewer.VerticalScrollBarVisibility="Disabled"
ScrollViewer.VerticalScrollMode="Disabled"
SelectionMode="Single">
<!--
Disable default ListView item animations: the bar is rebuilt on every keystroke,
and the built-in slide/fade transitions read as lag on a typing aid.
-->
<ListView.ItemContainerTransitions>
<TransitionCollection />
</ListView.ItemContainerTransitions>
<ListView.ItemsPanel>
<ItemsPanelTemplate>
<StackPanel Orientation="Horizontal" />
</ItemsPanelTemplate>
</ListView.ItemsPanel>
<!--
Custom container template reproducing the WPF accent "pill": an inset, rounded,
accent-filled rectangle shown only on selection (via VisualStateManager, since
WinUI 3 has no Style/ControlTemplate triggers), with the glyph turning on-accent.
-->
<ListView.ItemContainerStyle>
<Style TargetType="ListViewItem">
<Setter Property="IsTabStop" Value="False" />
<Setter Property="Margin" Value="0" />
<Setter Property="Padding" Value="0" />
<!--
WinUI's ListViewItem defaults MinWidth to 88; without pinning it to the
48px cell width each glyph is padded out, leaving wide gaps between accents.
-->
<Setter Property="MinWidth" Value="48" />
<Setter Property="Template">
<Setter.Value>
<ControlTemplate TargetType="ListViewItem">
<Grid
Height="48"
MinWidth="48"
HorizontalAlignment="Center"
VerticalAlignment="Center">
<Border
x:Name="SelectionIndicator"
Margin="7"
Background="{ThemeResource AccentFillColorDefaultBrush}"
BorderBrush="{ThemeResource AccentControlElevationBorderBrush}"
BorderThickness="1"
CornerRadius="4"
Opacity="0" />
<ContentPresenter
x:Name="ContentPresenter"
Margin="12"
HorizontalAlignment="Center"
VerticalAlignment="Center"
Foreground="{ThemeResource TextFillColorPrimaryBrush}" />
<VisualStateManager.VisualStateGroups>
<VisualStateGroup x:Name="CommonStates">
<VisualState x:Name="Normal" />
<VisualState x:Name="Selected">
<VisualState.Setters>
<Setter Target="SelectionIndicator.Opacity" Value="1" />
<Setter Target="ContentPresenter.Foreground" Value="{ThemeResource TextOnAccentFillColorPrimaryBrush}" />
</VisualState.Setters>
</VisualState>
<VisualState x:Name="PointerOverSelected">
<VisualState.Setters>
<Setter Target="SelectionIndicator.Opacity" Value="1" />
<Setter Target="ContentPresenter.Foreground" Value="{ThemeResource TextOnAccentFillColorPrimaryBrush}" />
</VisualState.Setters>
</VisualState>
<VisualState x:Name="PressedSelected">
<VisualState.Setters>
<Setter Target="SelectionIndicator.Opacity" Value="1" />
<Setter Target="ContentPresenter.Foreground" Value="{ThemeResource TextOnAccentFillColorPrimaryBrush}" />
</VisualState.Setters>
</VisualState>
</VisualStateGroup>
</VisualStateManager.VisualStateGroups>
</Grid>
</ControlTemplate>
</Setter.Value>
</Setter>
</Style>
</ListView.ItemContainerStyle>
<ListView.ItemTemplate>
<DataTemplate x:DataType="x:String">
<TextBlock
VerticalAlignment="Center"
FontSize="18"
Text="{x:Bind Mode=OneTime}"
TextAlignment="Center" />
</DataTemplate>
</ListView.ItemTemplate>
</ListView>
</Grid>
<Grid Grid.Row="1" Visibility="{x:Bind ViewModel.DescriptionVisibility, Mode=OneWay}">
<TextBlock
x:Name="CharacterName"
MaxHeight="36"
Margin="8"
AutomationProperties.AutomationId="QuickAccentDescription"
FontSize="12"
Foreground="{ThemeResource TextFillColorSecondaryBrush}"
Text="{x:Bind ViewModel.Description, Mode=OneWay}"
TextAlignment="Center"
TextTrimming="CharacterEllipsis"
TextWrapping="Wrap" />
<Rectangle
Height="1"
VerticalAlignment="Top"
Fill="{ThemeResource DividerStrokeColorDefaultBrush}" />
</Grid>
</Grid>
</controls:TransientSurface>
</UserControl>

View File

@@ -0,0 +1,41 @@
// Copyright (c) Microsoft Corporation
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
using Microsoft.PowerToys.Common.UI.Controls.Window;
using Microsoft.UI.Xaml.Controls;
namespace PowerAccent.UI;
/// <summary>
/// The accent selector content. Hosting it in a UserControl (rather than directly in the
/// TransparentWindow) lets x:Bind initialize on the control's Loading pass - which fires when the
/// SW_SHOWNA overlay is first laid out - instead of on Window.Activated (which never fires for a
/// never-activated overlay). That removes the need to call Bindings.Update() by hand.
/// </summary>
public sealed partial class SelectorControl : UserControl
{
public SelectorViewModel ViewModel { get; } = new();
public SelectorControl()
{
InitializeComponent();
}
// Number of items currently in the accent bar (mirrors the bound ObservableCollection).
public int ItemCount => CharactersList.Items.Count;
// Wire the inner TransientSurface to the hosting window's Show/Hide so it animates in/out.
// TransientSurface.SubscribeTo explicitly supports being "placed within" the window content.
public void SubscribeSurfaceTo(TransparentWindow host) => Surface.SubscribeTo(host);
public void SetSelectedIndex(int index) => CharactersList.SelectedIndex = index;
public void ScrollSelectedIntoView(int index)
{
if (index >= 0 && index < CharactersList.Items.Count)
{
CharactersList.ScrollIntoView(CharactersList.Items[index]);
}
}
}

View File

@@ -1,14 +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.Diagnostics;
using System.Threading;
using System.Threading.Tasks;
using System.Windows;
using ManagedCommon;
using Microsoft.UI.Dispatching;
using PowerToys.Interop;
namespace PowerAccent.UI;
@@ -16,13 +15,14 @@ namespace PowerAccent.UI;
internal static class Program
{
private static readonly CancellationTokenSource _tokenSource = new CancellationTokenSource();
private static App _application;
private static Mutex _mutex;
private static int _powerToysRunnerPid;
[STAThread]
public static void Main(string[] args)
{
Logger.InitializeLogger("\\QuickAccent\\Logs");
WinRT.ComWrappersSupport.InitializeComWrappers();
if (PowerToys.GPOWrapper.GPOWrapper.GetConfiguredQuickAccentEnabledValue() == PowerToys.GPOWrapper.GpoRuleConfigured.Disabled)
{
@@ -30,21 +30,32 @@ internal static class Program
return;
}
_mutex = new Mutex(true, "QuickAccent", out bool createdNew);
if (!createdNew)
{
Logger.LogWarning("Another running QuickAccent instance was detected. Exiting QuickAccent");
return;
}
Arguments(args);
InitExitListener();
InitEvents();
Microsoft.UI.Xaml.Application.Start((p) =>
{
var context = new DispatcherQueueSynchronizationContext(DispatcherQueue.GetForCurrentThread());
SynchronizationContext.SetSynchronizationContext(context);
_ = new App();
});
_application = new App();
_application.InitializeComponent();
_application.Run();
_mutex?.ReleaseMutex();
}
private static void InitEvents()
private static void InitExitListener()
{
Task.Run(
() =>
{
EventWaitHandle eventHandle = new EventWaitHandle(false, EventResetMode.AutoReset, Constants.PowerAccentExitEvent());
using EventWaitHandle eventHandle = new EventWaitHandle(false, EventResetMode.AutoReset, Constants.PowerAccentExitEvent());
if (eventHandle.WaitOne())
{
Terminate();
@@ -55,39 +66,41 @@ internal static class Program
private static void Arguments(string[] args)
{
if (args?.Length > 0)
if (args?.Length > 0 && int.TryParse(args[0], out _powerToysRunnerPid))
{
try
Logger.LogInfo($"QuickAccent started from the PowerToys Runner. Runner pid={_powerToysRunnerPid}");
RunnerHelper.WaitForPowerToysRunner(_powerToysRunnerPid, () =>
{
if (int.TryParse(args[0], out _powerToysRunnerPid))
{
Logger.LogInfo($"QuickAccent started from the PowerToys Runner. Runner pid={_powerToysRunnerPid}");
RunnerHelper.WaitForPowerToysRunner(_powerToysRunnerPid, () =>
{
Logger.LogInfo("PowerToys Runner exited. Exiting QuickAccent");
Terminate();
});
}
}
catch (Exception ex)
{
Debug.WriteLine(ex.Message);
}
Logger.LogInfo("PowerToys Runner exited. Exiting QuickAccent");
Terminate();
});
}
else
{
Logger.LogInfo($"QuickAccent started detached from PowerToys Runner.");
Logger.LogInfo("QuickAccent started detached from PowerToys Runner.");
_powerToysRunnerPid = -1;
}
}
private static void Terminate()
{
Application.Current.Dispatcher.BeginInvoke(() =>
var app = App.Current;
var queue = app?.DispatcherQueueForApp;
// If the exit signal arrives during the brief startup window before OnLaunched has set
// DispatcherQueueForApp (e.g. the runner dies, or disable() is called, right after launch),
// or the queue is already draining, TryEnqueue can't run our cleanup. Fall back to a hard
// exit so we never orphan the process with the low-level keyboard hook still installed. The
// OS releases the hook on process termination; usage stats are simply not saved on this path.
if (queue is null || !queue.TryEnqueue(() =>
{
_tokenSource.Cancel();
Application.Current.Shutdown();
});
App.Window?.Dispose(); // MainWindow.SaveUsageInfo + Core.PowerAccent.Dispose on the UI thread
app.Dispose(); // disposes ETWTrace (idempotent via _disposed guard)
app.Exit();
}))
{
Environment.Exit(0);
}
}
}

View File

@@ -1,129 +0,0 @@
<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"
Title="MainWindow"
MinWidth="0"
MinHeight="0"
AllowsTransparency="True"
Background="Transparent"
DataContext="{Binding RelativeSource={RelativeSource Self}}"
ResizeMode="NoResize"
ShowInTaskbar="False"
SizeChanged="Window_SizeChanged"
SizeToContent="WidthAndHeight"
Visibility="Collapsed"
WindowStyle="None"
mc:Ignorable="d">
<Window.Resources>
<DataTemplate x:Key="DefaultKeyTemplate">
<TextBlock
VerticalAlignment="Center"
FontSize="18"
Foreground="{DynamicResource TextFillColorPrimaryBrush}"
Text="{Binding}"
TextAlignment="Center" />
</DataTemplate>
<DataTemplate x:Key="SelectedKeyTemplate">
<TextBlock
VerticalAlignment="Center"
FontSize="18"
Foreground="{DynamicResource TextOnAccentFillColorPrimaryBrush}"
Text="{Binding}"
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>
<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>
</Border>
</Window>

View File

@@ -1,185 +0,0 @@
// Copyright (c) Microsoft Corporation
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
using System;
using System.ComponentModel;
using System.Windows;
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 : 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 = -1;
public event PropertyChangedEventHandler PropertyChanged;
public Visibility CharacterNameVisibility
{
get
{
return _characterNameVisibility;
}
set
{
_characterNameVisibility = value;
PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(CharacterNameVisibility)));
}
}
public Selector()
{
InitializeComponent();
Application.Current.MainWindow.ShowActivated = false;
}
protected override void OnSourceInitialized(EventArgs e)
{
base.OnSourceInitialized(e);
_powerAccent.OnChangeDisplay += PowerAccent_OnChangeDisplay;
_powerAccent.OnSelectCharacter += PowerAccent_OnSelectionCharacter;
this.Visibility = Visibility.Hidden;
}
private void PowerAccent_OnSelectionCharacter(int index, string character)
{
_selectedIndex = index;
characters.SelectedIndex = _selectedIndex;
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)
{
// Topmost is conditionally set here to address hybrid graphics issues on laptops.
this.Topmost = isActive;
CharacterNameVisibility = _powerAccent.ShowUnicodeDescription ? Visibility.Visible : Visibility.Collapsed;
if (isActive)
{
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;
}
}
private void MenuExit_Click(object sender, RoutedEventArgs e)
{
Application.Current.Shutdown();
}
private void SetWindowPosition()
{
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()
{
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)
{
_powerAccent.SaveUsageInfo();
_powerAccent.Dispose();
base.OnClosed(e);
}
public void Dispose()
{
GC.SuppressFinalize(this);
}
}

View File

@@ -0,0 +1,36 @@
// Copyright (c) Microsoft Corporation
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
using System.Collections.ObjectModel;
using CommunityToolkit.Mvvm.ComponentModel;
using Microsoft.UI.Xaml;
namespace PowerAccent.UI;
public partial class SelectorViewModel : ObservableObject
{
// Partial properties (not [ObservableProperty] fields): the CsWinRT generators need partial
// properties to emit correct WinRT marshalling for a WinUI 3 app (otherwise MVVMTK0045).
// Partial properties cannot carry field initializers, so initial values are set in the ctor.
[ObservableProperty]
public partial ObservableCollection<string> Characters { get; set; }
[ObservableProperty]
public partial string Description { get; set; }
// Exposed directly as a Visibility (rather than binding the bool through a
// BoolToVisibilityConverter) so the description row's visibility needs no converter resource.
[ObservableProperty]
[NotifyPropertyChangedFor(nameof(DescriptionVisibility))]
public partial bool ShowDescription { get; set; }
public SelectorViewModel()
{
Characters = new ObservableCollection<string>();
Description = string.Empty;
}
public Visibility DescriptionVisibility => ShowDescription ? Visibility.Visible : Visibility.Collapsed;
}

View File

@@ -96,6 +96,11 @@ namespace winrt::PowerToys::PowerAccentKeyboardService::implementation
m_settings.inputTime = std::chrono::milliseconds(inputTime);
}
void KeyboardListener::UpdateHoldDuration(int32_t holdDuration)
{
m_settings.holdDuration = std::chrono::milliseconds(holdDuration);
}
void KeyboardListener::UpdateExcludedApps(std::wstring_view excludedAppsView)
{
std::vector<std::wstring> excludedApps;
@@ -123,6 +128,17 @@ namespace winrt::PowerToys::PowerAccentKeyboardService::implementation
return m_settings.doNotActivateOnGameMode && detect_game_mode();
}
bool KeyboardListener::IsBlockingModifierDown()
{
// Ctrl / Alt (including AltGr = Ctrl+Alt) / Win turn a held letter into a shortcut,
// so they must not trigger press-and-hold. Shift is intentionally allowed so that
// uppercase accents still work.
return (GetAsyncKeyState(VK_CONTROL) & 0x8000) ||
(GetAsyncKeyState(VK_MENU) & 0x8000) ||
(GetAsyncKeyState(VK_LWIN) & 0x8000) ||
(GetAsyncKeyState(VK_RWIN) & 0x8000);
}
bool KeyboardListener::IsForegroundAppExcluded()
{
std::lock_guard<std::mutex> lock(m_mutex_excluded_apps);
@@ -181,6 +197,25 @@ namespace winrt::PowerToys::PowerAccentKeyboardService::implementation
letterPressed = letterKey;
}
// Press-and-hold activation: the held letter itself opens the toolbar after the hold
// duration. The base letter still types on first press; auto-repeats are swallowed above.
if (m_settings.activationKey == PowerAccentActivationKey::PressAndHold &&
!m_toolbarVisible &&
letterPressed != LetterKey::None &&
letterKey == letterPressed &&
!IsBlockingModifierDown() &&
!IsSuppressedByGameMode() &&
!IsForegroundAppExcluded())
{
Logger::debug(L"Show toolbar (press-and-hold). Letter: {}", letterPressed);
m_triggeredWithSpace = false;
m_triggeredWithLeftArrow = false;
m_triggeredWithRightArrow = false;
m_toolbarVisible = true;
m_showToolbarCb(letterPressed);
return false;
}
UINT triggerPressed = 0;
if (letterPressed != LetterKey::None)
{
@@ -199,7 +234,9 @@ namespace winrt::PowerToys::PowerAccentKeyboardService::implementation
}
}
if (!m_toolbarVisible && letterPressed != LetterKey::None && triggerPressed && !IsSuppressedByGameMode() && !IsForegroundAppExcluded())
// Trigger-key activation (letter + Space/arrow) is exclusive to the non-hold modes.
if (m_settings.activationKey != PowerAccentActivationKey::PressAndHold &&
!m_toolbarVisible && letterPressed != LetterKey::None && triggerPressed && !IsSuppressedByGameMode() && !IsForegroundAppExcluded())
{
Logger::debug(L"Show toolbar. Letter: {}, Trigger: {}", letterPressed, triggerPressed);
@@ -211,7 +248,14 @@ namespace winrt::PowerToys::PowerAccentKeyboardService::implementation
m_showToolbarCb(letterPressed);
}
if (m_toolbarVisible && triggerPressed)
// In press-and-hold the popup only appears once the hold duration elapses, so Space/arrow
// must pass through until then; treat the picker as interactive only once it is shown.
const bool pickerInteractive =
m_toolbarVisible &&
(m_settings.activationKey != PowerAccentActivationKey::PressAndHold ||
m_stopwatch.elapsed() >= m_settings.holdDuration);
if (pickerInteractive && triggerPressed)
{
if (triggerPressed == VK_LEFT)
{
@@ -247,13 +291,27 @@ namespace winrt::PowerToys::PowerAccentKeyboardService::implementation
m_rightShiftPressed = false;
}
if (std::find(std::begin(letters), end(letters), static_cast<LetterKey>(info.vkCode)) != end(letters) && m_isLanguageLetterCb(static_cast<LetterKey>(info.vkCode)))
const auto releasedLetter = static_cast<LetterKey>(info.vkCode);
if (std::find(std::begin(letters), end(letters), releasedLetter) != end(letters) && m_isLanguageLetterCb(releasedLetter))
{
// Only react to the key-up of the letter that owns the toolbar, so releasing a
// different held letter can't cancel or commit the active picker.
if (letterPressed != releasedLetter)
{
return false;
}
letterPressed = LetterKey::None;
if (m_toolbarVisible)
{
if (m_stopwatch.elapsed() < m_settings.inputTime)
// Press-and-hold uses its own (typically longer) hold duration as the
// minimum-hold threshold; the trigger-key modes use inputTime.
const auto activationThreshold =
m_settings.activationKey == PowerAccentActivationKey::PressAndHold
? m_settings.holdDuration
: m_settings.inputTime;
if (m_stopwatch.elapsed() < activationThreshold)
{
Logger::debug(L"Activation too fast. Do nothing.");
@@ -275,7 +333,11 @@ namespace winrt::PowerToys::PowerAccentKeyboardService::implementation
m_hideToolbarCb(InputType::None);
}
m_toolbarVisible = false;
return true;
// In press-and-hold the base letter already typed on key-down and no trigger
// key was consumed, so let this key-up pass through to avoid a stuck-key
// perception. Trigger modes keep swallowing it as before.
return m_settings.activationKey != PowerAccentActivationKey::PressAndHold;
}
Logger::debug(L"Hide toolbar event and input char");

View File

@@ -11,6 +11,7 @@ namespace winrt::PowerToys::PowerAccentKeyboardService::implementation
LeftRightArrow,
Space,
Both,
PressAndHold,
};
struct PowerAccentSettings
@@ -18,6 +19,7 @@ namespace winrt::PowerToys::PowerAccentKeyboardService::implementation
PowerAccentActivationKey activationKey{ PowerAccentActivationKey::Both };
bool doNotActivateOnGameMode{ true };
std::chrono::milliseconds inputTime{ 300 }; // Should match with UI.Library.PowerAccentSettings.DefaultInputTimeMs
std::chrono::milliseconds holdDuration{ 500 }; // Should match with UI.Library.PowerAccentSettings.DefaultHoldDurationMs
std::vector<std::wstring> excludedApps;
};
@@ -39,6 +41,7 @@ namespace winrt::PowerToys::PowerAccentKeyboardService::implementation
void UpdateActivationKey(int32_t activationKey);
void UpdateDoNotActivateOnGameMode(bool doNotActivateOnGameMode);
void UpdateInputTime(int32_t inputTime);
void UpdateHoldDuration(int32_t holdDuration);
void UpdateExcludedApps(std::wstring_view excludedApps);
static LRESULT CALLBACK LowLevelKeyboardProc(int nCode, WPARAM wParam, LPARAM lParam);
@@ -48,6 +51,7 @@ namespace winrt::PowerToys::PowerAccentKeyboardService::implementation
bool OnKeyUp(KBDLLHOOKSTRUCT info) noexcept;
bool IsSuppressedByGameMode();
bool IsForegroundAppExcluded();
bool IsBlockingModifierDown();
static inline KeyboardListener* s_instance;
HHOOK s_llKeyboardHook = nullptr;

View File

@@ -83,6 +83,7 @@ namespace PowerToys
void UpdateActivationKey(Int32 activationKey);
void UpdateDoNotActivateOnGameMode(Boolean doNotActivateOnGameMode);
void UpdateInputTime(Int32 inputTime);
void UpdateHoldDuration(Int32 holdDuration);
void UpdateExcludedApps(String excludedApps);
}
}

View File

@@ -46,7 +46,7 @@
<PropertyGroup Label="UserMacros" />
<PropertyGroup>
<TargetName>PowerToys.PowerAccentKeyboardService</TargetName>
<OutDir>$(RepoRoot)$(Platform)\$(Configuration)\</OutDir>
<OutDir>$(RepoRoot)$(Platform)\$(Configuration)\WinUI3Apps\</OutDir>
</PropertyGroup>
<ItemDefinitionGroup>
<ClCompile>

View File

@@ -59,7 +59,7 @@ private:
unsigned long powertoys_pid = GetCurrentProcessId();
std::wstring executable_args = L"" + std::to_wstring(powertoys_pid);
std::wstring application_path = L"PowerToys.PowerAccent.exe";
std::wstring application_path = L"WinUI3Apps\\PowerToys.PowerAccent.exe";
std::wstring full_command_path = application_path + L" " + executable_args.data();
Logger::trace(L"PowerToys QuickAccent launching: " + full_command_path);

View File

@@ -9,5 +9,6 @@ namespace Microsoft.PowerToys.Settings.UI.Library.Enumerations
LeftRightArrow,
Space,
Both,
PressAndHold,
}
}

View File

@@ -9,7 +9,6 @@ using System.Diagnostics;
using System.Globalization;
using System.IO;
using System.Linq;
using System.Management;
using System.Text.Json;
using System.Text.Json.Serialization;
using System.Threading;

View File

@@ -17,6 +17,8 @@ namespace Microsoft.PowerToys.Settings.UI.Library
{
ActivationShortcut = DefaultActivationShortcut;
AlwaysRunNotElevated = new BoolProperty(true);
AlwaysOnTop = new BoolProperty(false);
ShowTaskbarIcon = new BoolProperty(true);
CloseAfterLosingFocus = new BoolProperty(false);
ConfirmFileDelete = new BoolProperty(true);
EnableSpaceToActivate = new BoolProperty(true); // Toggle is ON by default for new users. No impact on existing users.
@@ -27,6 +29,10 @@ namespace Microsoft.PowerToys.Settings.UI.Library
public BoolProperty AlwaysRunNotElevated { get; set; }
public BoolProperty AlwaysOnTop { get; set; }
public BoolProperty ShowTaskbarIcon { get; set; }
public BoolProperty CloseAfterLosingFocus { get; set; }
public BoolProperty ConfirmFileDelete { get; set; }

View File

@@ -22,6 +22,9 @@ namespace Microsoft.PowerToys.Settings.UI.Library
[JsonPropertyName("input_time_ms")]
public IntProperty InputTime { get; set; }
[JsonPropertyName("hold_duration_ms")]
public IntProperty HoldDuration { get; set; }
[JsonPropertyName("selected_lang")]
public StringProperty SelectedLang { get; set; }
@@ -43,6 +46,7 @@ namespace Microsoft.PowerToys.Settings.UI.Library
DoNotActivateOnGameMode = true;
ToolbarPosition = "Top center";
InputTime = new IntProperty(PowerAccentSettings.DefaultInputTimeMs);
HoldDuration = new IntProperty(PowerAccentSettings.DefaultHoldDurationMs);
SelectedLang = "ALL";
ExcludedApps = new StringProperty();
ShowUnicodeDescription = false;

View File

@@ -13,6 +13,7 @@ namespace Microsoft.PowerToys.Settings.UI.Library
public const string ModuleName = "QuickAccent";
public const string ModuleVersion = "0.0.1";
public const int DefaultInputTimeMs = 300; // PowerAccentKeyboardService.PowerAccentSettings.inputTime should be the same
public const int DefaultHoldDurationMs = 500; // PowerAccentKeyboardService.PowerAccentSettings.holdDuration should be the same
[JsonPropertyName("properties")]
public PowerAccentProperties Properties { get; set; }

View File

@@ -46,6 +46,18 @@
HeaderIcon="{ui:FontIcon Glyph=&#xE7EF;}">
<ToggleSwitch x:Uid="ToggleSwitch" IsOn="{x:Bind ViewModel.AlwaysRunNotElevated, Mode=TwoWay}" />
</tkcontrols:SettingsCard>
<tkcontrols:SettingsCard
Name="PeekAlwaysOnTop"
x:Uid="Peek_AlwaysOnTop"
HeaderIcon="{ui:FontIcon Glyph=&#xE7C4;}">
<ToggleSwitch x:Uid="ToggleSwitch" IsOn="{x:Bind ViewModel.AlwaysOnTop, Mode=TwoWay}" />
</tkcontrols:SettingsCard>
<tkcontrols:SettingsCard
Name="PeekShowTaskbarIcon"
x:Uid="Peek_ShowTaskbarIcon"
HeaderIcon="{ui:FontIcon Glyph=&#xE8F9;}">
<ToggleSwitch x:Uid="ToggleSwitch" IsOn="{x:Bind ViewModel.ShowTaskbarIcon, Mode=TwoWay}" />
</tkcontrols:SettingsCard>
<tkcontrols:SettingsCard
Name="PeekCloseAfterLosingFocus"
x:Uid="Peek_CloseAfterLosingFocus"

View File

@@ -45,8 +45,22 @@
<ComboBoxItem x:Uid="QuickAccent_Activation_Key_Arrows" />
<ComboBoxItem x:Uid="QuickAccent_Activation_Key_Space" />
<ComboBoxItem x:Uid="QuickAccent_Activation_Key_Either" />
<ComboBoxItem x:Uid="QuickAccent_Activation_Key_PressAndHold" />
</ComboBox>
<tkcontrols:SettingsExpander.Items>
<tkcontrols:SettingsCard
x:Uid="QuickAccent_HoldDurationMs"
HeaderIcon="{ui:FontIcon Glyph=&#xE916;}"
Visibility="{x:Bind ViewModel.IsPressAndHoldActivation, Mode=OneWay, Converter={StaticResource BoolToVisibilityConverter}}">
<NumberBox
MinWidth="{StaticResource SettingActionControlMinWidth}"
LargeChange="100"
Maximum="3000"
Minimum="100"
SmallChange="10"
SpinButtonPlacementMode="Compact"
Value="{x:Bind ViewModel.HoldDurationMs, Mode=TwoWay}" />
</tkcontrols:SettingsCard>
<tkcontrols:SettingsCard ContentAlignment="Left">
<CheckBox x:Uid="QuickAccent_Prevent_Activation_On_Game_Mode" IsChecked="{x:Bind ViewModel.DoNotActivateOnGameMode, Mode=TwoWay}" />
</tkcontrols:SettingsCard>
@@ -191,7 +205,8 @@
<tkcontrols:SettingsCard
Name="QuickAccentInputTimeMs"
x:Uid="QuickAccent_InputTimeMs"
HeaderIcon="{ui:FontIcon Glyph=&#xE916;}">
HeaderIcon="{ui:FontIcon Glyph=&#xE916;}"
Visibility="{x:Bind ViewModel.IsPressAndHoldActivation, Mode=OneWay, Converter={StaticResource ReverseBoolToVisibilityConverter}}">
<NumberBox
MinWidth="{StaticResource SettingActionControlMinWidth}"
LargeChange="100"

View File

@@ -1,17 +1,17 @@
<?xml version="1.0" encoding="utf-8"?>
<root>
<!--
Microsoft ResX Schema
<!--
Microsoft ResX Schema
Version 2.0
The primary goals of this format is to allow a simple XML format
that is mostly human readable. The generation and parsing of the
various data types are done through the TypeConverter classes
The primary goals of this format is to allow a simple XML format
that is mostly human readable. The generation and parsing of the
various data types are done through the TypeConverter classes
associated with the data types.
Example:
... ado.net/XML headers & schema ...
<resheader name="resmimetype">text/microsoft-resx</resheader>
<resheader name="version">2.0</resheader>
@@ -26,36 +26,36 @@
<value>[base64 mime encoded string representing a byte array form of the .NET Framework object]</value>
<comment>This is a comment</comment>
</data>
There are any number of "resheader" rows that contain simple
There are any number of "resheader" rows that contain simple
name/value pairs.
Each data row contains a name, and value. The row also contains a
type or mimetype. Type corresponds to a .NET class that support
text/value conversion through the TypeConverter architecture.
Classes that don't support this are serialized and stored with the
Each data row contains a name, and value. The row also contains a
type or mimetype. Type corresponds to a .NET class that support
text/value conversion through the TypeConverter architecture.
Classes that don't support this are serialized and stored with the
mimetype set.
The mimetype is used for serialized objects, and tells the
ResXResourceReader how to depersist the object. This is currently not
The mimetype is used for serialized objects, and tells the
ResXResourceReader how to depersist the object. This is currently not
extensible. For a given mimetype the value must be set accordingly:
Note - application/x-microsoft.net.object.binary.base64 is the format
that the ResXResourceWriter will generate, however the reader can
Note - application/x-microsoft.net.object.binary.base64 is the format
that the ResXResourceWriter will generate, however the reader can
read any of the formats listed below.
mimetype: application/x-microsoft.net.object.binary.base64
value : The object must be serialized with
value : The object must be serialized with
: System.Runtime.Serialization.Formatters.Binary.BinaryFormatter
: and then encoded with base64 encoding.
mimetype: application/x-microsoft.net.object.soap.base64
value : The object must be serialized with
value : The object must be serialized with
: System.Runtime.Serialization.Formatters.Soap.SoapFormatter
: and then encoded with base64 encoding.
mimetype: application/x-microsoft.net.object.bytearray.base64
value : The object must be serialized into a byte array
value : The object must be serialized into a byte array
: using a System.ComponentModel.TypeConverter
: and then encoded with base64 encoding.
-->
@@ -3156,6 +3156,14 @@ Right-click to remove the key combination, thereby deactivating the shortcut.</v
<value>Runs Peek without admin permissions to improve access to network shares. To apply this change, you must disable and re-enable Peek.</value>
<comment>Peek is a product name, do not loc</comment>
</data>
<data name="Peek_AlwaysOnTop.Header" xml:space="preserve">
<value>Peek window is always on top</value>
<comment>Peek is a product name, do not loc</comment>
</data>
<data name="Peek_ShowTaskbarIcon.Header" xml:space="preserve">
<value>Show Peek icon on the taskbar</value>
<comment>Peek is a product name, do not loc</comment>
</data>
<data name="Peek_CloseAfterLosingFocus.Header" xml:space="preserve">
<value>Automatically close the Peek window after it loses focus</value>
<comment>Peek is a product name, do not loc</comment>
@@ -3354,8 +3362,8 @@ Activate by holding the key for the character you want to add an accent to, then
<comment>key refers to a physical key on a keyboard</comment>
</data>
<data name="QuickAccent_Activation_Shortcut.Description" xml:space="preserve">
<value>Press this key after holding down the target letter</value>
<comment>key refers to a physical key on a keyboard</comment>
<value>Choose how the accent menu opens</value>
<comment>The accent menu is the Quick Accent character picker</comment>
</data>
<data name="QuickAccent_Activation_Key_Arrows.Content" xml:space="preserve">
<value>Left/Right Arrow</value>
@@ -3369,6 +3377,10 @@ Activate by holding the key for the character you want to add an accent to, then
<value>Left, Right or Space</value>
<comment>All are keys on a keyboard</comment>
</data>
<data name="QuickAccent_Activation_Key_PressAndHold.Content" xml:space="preserve">
<value>Press and hold the letter</value>
<comment>Activation mode where holding the letter key itself opens the accent menu, like on iOS or macOS</comment>
</data>
<data name="QuickAccent_Toolbar.Header" xml:space="preserve">
<value>Toolbar</value>
</data>
@@ -3413,6 +3425,14 @@ Activate by holding the key for the character you want to add an accent to, then
<value>How long a key must be held before the accent menu appears</value>
<comment>ms = milliseconds</comment>
</data>
<data name="QuickAccent_HoldDurationMs.Header" xml:space="preserve">
<value>Hold duration (ms)</value>
<comment>ms = milliseconds</comment>
</data>
<data name="QuickAccent_HoldDurationMs.Description" xml:space="preserve">
<value>How long to hold the letter before the accent menu appears</value>
<comment>ms = milliseconds</comment>
</data>
<data name="QuickAccent_ExcludedApps.Description" xml:space="preserve">
<value>Prevents module activation if a foreground application is excluded. Add one application name per line.</value>
</data>

View File

@@ -748,6 +748,8 @@ namespace Microsoft.PowerToys.Settings.UI.ViewModels
case Library.Enumerations.PowerAccentActivationKey.LeftRightArrow: activation = resourceLoader.GetString("QuickAccent_Activation_Key_Arrows/Content"); break;
case Library.Enumerations.PowerAccentActivationKey.Space: activation = resourceLoader.GetString("QuickAccent_Activation_Key_Space/Content"); break;
case Library.Enumerations.PowerAccentActivationKey.Both: activation = resourceLoader.GetString("QuickAccent_Activation_Key_Either/Content"); break;
case Library.Enumerations.PowerAccentActivationKey.PressAndHold: activation = resourceLoader.GetString("QuickAccent_Activation_Key_PressAndHold/Content"); break;
default: activation = string.Empty; break;
}
var list = new List<DashboardModuleItem>

View File

@@ -197,6 +197,34 @@ namespace Microsoft.PowerToys.Settings.UI.ViewModels
}
}
public bool AlwaysOnTop
{
get => _peekSettings.Properties.AlwaysOnTop.Value;
set
{
if (_peekSettings.Properties.AlwaysOnTop.Value != value)
{
_peekSettings.Properties.AlwaysOnTop.Value = value;
OnPropertyChanged(nameof(AlwaysOnTop));
NotifySettingsChanged();
}
}
}
public bool ShowTaskbarIcon
{
get => _peekSettings.Properties.ShowTaskbarIcon.Value;
set
{
if (_peekSettings.Properties.ShowTaskbarIcon.Value != value)
{
_peekSettings.Properties.ShowTaskbarIcon.Value = value;
OnPropertyChanged(nameof(ShowTaskbarIcon));
NotifySettingsChanged();
}
}
}
public bool CloseAfterLosingFocus
{
get => _peekSettings.Properties.CloseAfterLosingFocus.Value;

Some files were not shown because too many files have changed in this diff Show More