mirror of
https://github.com/microsoft/PowerToys.git
synced 2026-07-04 17:39:57 +02:00
Compare commits
8 Commits
workspaces
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
cccd2b7510 | ||
|
|
e4ef90d168 | ||
|
|
e0fe3c48cf | ||
|
|
029dd04ce4 | ||
|
|
3d3cef73da | ||
|
|
03e5f3e837 | ||
|
|
9039451e2f | ||
|
|
af45c3ec7c |
1
.github/actions/spell-check/expect.txt
vendored
1
.github/actions/spell-check/expect.txt
vendored
@@ -838,6 +838,7 @@ INTRESOURCE
|
||||
INVALIDARG
|
||||
invalidoperatioexception
|
||||
invokecommand
|
||||
iOS
|
||||
ipcmanager
|
||||
ipreviewhandlervisualssetfont
|
||||
IPTC
|
||||
|
||||
@@ -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",
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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>"
|
||||
@@ -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
|
||||
@@ -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();
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
9
src/modules/Workspaces/WorkspacesLauncherUI/App.config
Normal file
9
src/modules/Workspaces/WorkspacesLauncherUI/App.config
Normal 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>
|
||||
57
src/modules/Workspaces/WorkspacesLauncherUI/App.xaml
Normal file
57
src/modules/Workspaces/WorkspacesLauncherUI/App.xaml
Normal 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>
|
||||
147
src/modules/Workspaces/WorkspacesLauncherUI/App.xaml.cs
Normal file
147
src/modules/Workspaces/WorkspacesLauncherUI/App.xaml.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)),
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
90
src/modules/Workspaces/WorkspacesLauncherUI/Properties/Resources.Designer.cs
generated
Normal file
90
src/modules/Workspaces/WorkspacesLauncherUI/Properties/Resources.Designer.cs
generated
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
26
src/modules/Workspaces/WorkspacesLauncherUI/Properties/Settings.Designer.cs
generated
Normal file
26
src/modules/Workspaces/WorkspacesLauncherUI/Properties/Settings.Designer.cs
generated
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
<?xml version='1.0' encoding='utf-8'?>
|
||||
<SettingsFile xmlns="uri:settings" CurrentProfile="(Default)">
|
||||
<Profiles>
|
||||
<Profile Name="(Default)" />
|
||||
</Profiles>
|
||||
<Settings />
|
||||
</SettingsFile>
|
||||
103
src/modules/Workspaces/WorkspacesLauncherUI/StatusWindow.xaml
Normal file
103
src/modules/Workspaces/WorkspacesLauncherUI/StatusWindow.xaml
Normal 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>
|
||||
@@ -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)
|
||||
{
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
74
src/modules/Workspaces/WorkspacesLauncherUI/app.manifest
Normal file
74
src/modules/Workspaces/WorkspacesLauncherUI/app.manifest
Normal 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>
|
||||
@@ -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.
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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" />
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
]
|
||||
@@ -1,2 +0,0 @@
|
||||
SetWindowPos
|
||||
GetSystemMetrics
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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]);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
36
src/modules/poweraccent/PowerAccent.UI/SelectorViewModel.cs
Normal file
36
src/modules/poweraccent/PowerAccent.UI/SelectorViewModel.cs
Normal file
@@ -0,0 +1,36 @@
|
||||
// Copyright (c) Microsoft Corporation
|
||||
// The Microsoft Corporation licenses this file to you under the MIT license.
|
||||
// See the LICENSE file in the project root for more information.
|
||||
|
||||
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;
|
||||
}
|
||||
@@ -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");
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -9,5 +9,6 @@ namespace Microsoft.PowerToys.Settings.UI.Library.Enumerations
|
||||
LeftRightArrow,
|
||||
Space,
|
||||
Both,
|
||||
PressAndHold,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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; }
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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; }
|
||||
|
||||
@@ -46,6 +46,18 @@
|
||||
HeaderIcon="{ui:FontIcon Glyph=}">
|
||||
<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=}">
|
||||
<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=}">
|
||||
<ToggleSwitch x:Uid="ToggleSwitch" IsOn="{x:Bind ViewModel.ShowTaskbarIcon, Mode=TwoWay}" />
|
||||
</tkcontrols:SettingsCard>
|
||||
<tkcontrols:SettingsCard
|
||||
Name="PeekCloseAfterLosingFocus"
|
||||
x:Uid="Peek_CloseAfterLosingFocus"
|
||||
|
||||
@@ -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=}"
|
||||
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=}">
|
||||
HeaderIcon="{ui:FontIcon Glyph=}"
|
||||
Visibility="{x:Bind ViewModel.IsPressAndHoldActivation, Mode=OneWay, Converter={StaticResource ReverseBoolToVisibilityConverter}}">
|
||||
<NumberBox
|
||||
MinWidth="{StaticResource SettingActionControlMinWidth}"
|
||||
LargeChange="100"
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user