mirror of
https://github.com/microsoft/PowerToys.git
synced 2026-07-07 19:09:43 +02:00
Compare commits
21 Commits
crutkas/pe
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3ab6e182f8 | ||
|
|
c1ef511697 | ||
|
|
188f6a58a8 | ||
|
|
9449ae3686 | ||
|
|
a32c75928b | ||
|
|
c7e53c6ad9 | ||
|
|
52df5882a3 | ||
|
|
0790674ddf | ||
|
|
fb656bf040 | ||
|
|
b329b3f243 | ||
|
|
b6f0a7ae91 | ||
|
|
3df0535473 | ||
|
|
23a26428d3 | ||
|
|
6da052247f | ||
|
|
cccd2b7510 | ||
|
|
e4ef90d168 | ||
|
|
e0fe3c48cf | ||
|
|
029dd04ce4 | ||
|
|
3d3cef73da | ||
|
|
03e5f3e837 | ||
|
|
9039451e2f |
@@ -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",
|
||||
|
||||
@@ -786,10 +786,6 @@
|
||||
<Platform Solution="*|ARM64" Project="ARM64" />
|
||||
<Platform Solution="*|x64" Project="x64" />
|
||||
</Project>
|
||||
<Project Path="src/modules/peek/Peek.Common.UnitTests/Peek.Common.UnitTests.csproj">
|
||||
<Platform Solution="*|ARM64" Project="ARM64" />
|
||||
<Platform Solution="*|x64" Project="x64" />
|
||||
</Project>
|
||||
<Project Path="src/modules/peek/Peek.FilePreviewer/Peek.FilePreviewer.csproj">
|
||||
<Platform Solution="*|ARM64" Project="ARM64" />
|
||||
<Platform Solution="*|x64" Project="x64" />
|
||||
@@ -810,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" />
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -128,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>
|
||||
|
||||
|
||||
|
||||
@@ -54,6 +54,7 @@ Contact the developers of a plugin directly for assistance with a specific plugi
|
||||
| [Project Launcher Plugin](https://github.com/artickc/ProjectLauncherPowerToysPlugin) | [artickc](https://github.com/artickc) | Access your projects using Project Launcher and PowerToys Run |
|
||||
| [CheatSheets](https://github.com/ruslanlap/PowerToysRun-CheatSheets) | [ruslanlap](https://github.com/ruslanlap) | 📚 Find cheat sheets and command examples instantly from tldr pages, cheat.sh, and devhints.io. Features include favorites system, categories, offline mode, and smart caching. |
|
||||
| [QuickAI](https://github.com/ruslanlap/PowerToysRun-QuickAi) | [ruslanlap](https://github.com/ruslanlap) | AI-powered assistance with instant, smart responses from multiple providers (Groq, Together, Fireworks, OpenRouter, Cohere) |
|
||||
| [Launchy](https://github.com/PsychodelEKS/PowerToysRun-Launchy) | [PsychodelEKS](https://github.com/PsychodelEKS) | Index and launch files from configured folders |
|
||||
|
||||
## Extending software plugins
|
||||
|
||||
|
||||
@@ -38,6 +38,7 @@ static GrabAndMoveModifier g_modifierKey = GrabAndMoveModifier::Alt;
|
||||
|
||||
static bool g_altPressed = false;
|
||||
static bool g_winPressed = false; // true while LWIN or RWIN is held (Win modifier mode)
|
||||
static bool g_ignoreModifier = false; // true if the user pressed the modifier then clicked the mouse without dragging
|
||||
static bool g_winAbsorbed = false; // true if we absorbed a Win keydown
|
||||
static bool g_dragging = false;
|
||||
static bool g_dragFirstMove = false; // true until first WM_MOUSEMOVE of a drag
|
||||
@@ -116,6 +117,30 @@ static POINT g_resizeLast = {}; // cursor pos from previous frame
|
||||
static RECT g_resizeWndRect = {}; // current window rect (updated each frame)
|
||||
static ResizeHandle g_currentHandle = RESIZE_NONE;
|
||||
|
||||
// Deferred activation state: a modifier+button press is held "pending" until the
|
||||
// cursor moves past the drag threshold (promoting it to a real drag/resize) or the
|
||||
// button is released first (replaying a normal modifier+click to the target).
|
||||
static bool g_pendingDrag = false;
|
||||
static bool g_pendingResize = false;
|
||||
static HWND g_pendingTarget = nullptr;
|
||||
static POINT g_pendingDownPt = {};
|
||||
|
||||
// Set when a pending press was replayed to the target on modifier release while the
|
||||
// physical button was still down; the matching physical button-up is then swallowed.
|
||||
static bool g_swallowNextLButtonUp = false;
|
||||
static bool g_swallowNextRButtonUp = false;
|
||||
|
||||
// Set once ReplayPendingClick has replayed the absorbed modifier key-down for a pending
|
||||
// click. The absorbed-modifier flags stay set so the keyboard hook still forwards the
|
||||
// real modifier key-up; this guard stops the key-down from being replayed a second time.
|
||||
static bool g_pendingModifierReplayed = false;
|
||||
|
||||
// Set once a drag or resize has actually started during the current modifier hold.
|
||||
// While set, subsequent modifier+button presses skip the deferred/pending phase and
|
||||
// begin the drag/resize immediately - the user has already committed to Grab and Move.
|
||||
// Cleared when the modifier key is released.
|
||||
static bool g_activatedDuringHold = false;
|
||||
|
||||
static const int MIN_WINDOW_WIDTH = 150;
|
||||
static const int MIN_WINDOW_HEIGHT = 50;
|
||||
|
||||
@@ -225,6 +250,14 @@ static void CALLBACK WinEventProc(HWINEVENTHOOK, DWORD, HWND hwnd, LONG, LONG, D
|
||||
g_winAbsorbed = false;
|
||||
g_altAbsorbed = false;
|
||||
g_dragConsumedAlt = false;
|
||||
g_ignoreModifier = false;
|
||||
g_activatedDuringHold = false;
|
||||
g_pendingDrag = false;
|
||||
g_pendingResize = false;
|
||||
g_pendingTarget = nullptr;
|
||||
g_swallowNextLButtonUp = false;
|
||||
g_swallowNextRButtonUp = false;
|
||||
g_pendingModifierReplayed = false;
|
||||
EndInteraction(true, true);
|
||||
}
|
||||
}
|
||||
@@ -242,6 +275,10 @@ static bool IsSuppressedByGameMode()
|
||||
|
||||
static bool IsActivationModifierPressed()
|
||||
{
|
||||
if (g_ignoreModifier)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
if (g_modifierKey == GrabAndMoveModifier::Win)
|
||||
return g_winPressed;
|
||||
return g_altPressed;
|
||||
@@ -780,6 +817,85 @@ static void ReplayAbsorbedModifier(bool alsoKeyUp)
|
||||
SendInput(alsoKeyUp ? 2 : 1, inputs, sizeof(INPUT));
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Deferred activation helpers.
|
||||
// A modifier+button press does not start an interaction immediately; it is held
|
||||
// "pending" until the cursor moves past the drag threshold (then it becomes a
|
||||
// real drag/resize) or the button is released first (then the absorbed input is
|
||||
// replayed so the target application receives a normal modifier+click).
|
||||
// ---------------------------------------------------------------------------
|
||||
static void BeginDrag(HWND hwnd, POINT pt)
|
||||
{
|
||||
g_dragging = true;
|
||||
g_dragFirstMove = true;
|
||||
g_dragConsumedAlt = true;
|
||||
g_activatedDuringHold = true;
|
||||
g_dragTarget = hwnd;
|
||||
g_dragStart = pt;
|
||||
GetWindowRect(hwnd, &g_dragWndRect);
|
||||
PrepareOverlayMetrics(hwnd);
|
||||
ShowOverlay(g_dragWndRect, g_curSizeAll);
|
||||
TraceShortcutUse(true, GrabAndMoveShortcutAction::Move, L"started");
|
||||
}
|
||||
|
||||
static void BeginResize(HWND hwnd, POINT pt)
|
||||
{
|
||||
g_resizing = true;
|
||||
g_resizeFirstMove = true;
|
||||
g_dragConsumedAlt = true;
|
||||
g_activatedDuringHold = true;
|
||||
g_resizeTarget = hwnd;
|
||||
g_resizeLast = pt;
|
||||
GetWindowRect(hwnd, &g_resizeWndRect);
|
||||
PrepareOverlayMetrics(hwnd);
|
||||
g_currentHandle = GetClosestHandle(pt, g_resizeWndRect);
|
||||
ShowOverlay(g_resizeWndRect, CursorForHandle(g_currentHandle));
|
||||
TraceShortcutUse(true, GrabAndMoveShortcutAction::Resize, L"started");
|
||||
}
|
||||
|
||||
// Replays a modifier+click to the target: first the absorbed modifier key-down
|
||||
// (if it was swallowed), then the button click. The modifier key-up is not
|
||||
// synthesized here - the real key-up still reaches the target when the user
|
||||
// releases the modifier. The absorbed-modifier flags are intentionally left set so
|
||||
// the keyboard hook forwards that real key-up; g_pendingModifierReplayed guards
|
||||
// against replaying the key-down more than once (e.g. across repeated clicks).
|
||||
static void ReplayPendingClick(bool isRight)
|
||||
{
|
||||
if ((g_altAbsorbed || g_winAbsorbed) && !g_pendingModifierReplayed)
|
||||
{
|
||||
g_pendingModifierReplayed = true;
|
||||
ReplayAbsorbedModifier(false); // modifier down only
|
||||
}
|
||||
|
||||
INPUT inputs[2] = {};
|
||||
inputs[0].type = INPUT_MOUSE;
|
||||
inputs[0].mi.dwFlags = isRight ? MOUSEEVENTF_RIGHTDOWN : MOUSEEVENTF_LEFTDOWN;
|
||||
inputs[1].type = INPUT_MOUSE;
|
||||
inputs[1].mi.dwFlags = isRight ? MOUSEEVENTF_RIGHTUP : MOUSEEVENTF_LEFTUP;
|
||||
SendInput(2, inputs, sizeof(INPUT));
|
||||
}
|
||||
|
||||
// Called when the modifier is released while a press is still pending (button
|
||||
// held, no move yet). Delivers the click to the target now and swallows the
|
||||
// matching physical button-up that will arrive later.
|
||||
static void FlushPendingClickOnModifierRelease()
|
||||
{
|
||||
if (g_pendingDrag)
|
||||
{
|
||||
g_pendingDrag = false;
|
||||
g_pendingTarget = nullptr;
|
||||
ReplayPendingClick(false);
|
||||
g_swallowNextLButtonUp = true;
|
||||
}
|
||||
else if (g_pendingResize)
|
||||
{
|
||||
g_pendingResize = false;
|
||||
g_pendingTarget = nullptr;
|
||||
ReplayPendingClick(true);
|
||||
g_swallowNextRButtonUp = true;
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// System tray helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -1000,6 +1116,7 @@ static LRESULT CALLBACK KeyboardProc(int nCode, WPARAM wParam, LPARAM lParam)
|
||||
{
|
||||
g_winAbsorbed = true;
|
||||
g_dragConsumedAlt = false;
|
||||
g_pendingModifierReplayed = false;
|
||||
g_absorbedVk = kb->vkCode;
|
||||
g_absorbedScanCode = kb->scanCode;
|
||||
g_absorbedFlags = kb->flags;
|
||||
@@ -1009,6 +1126,10 @@ static LRESULT CALLBACK KeyboardProc(int nCode, WPARAM wParam, LPARAM lParam)
|
||||
else if (wParam == WM_KEYUP || wParam == WM_SYSKEYUP)
|
||||
{
|
||||
g_winPressed = false;
|
||||
g_ignoreModifier = false;
|
||||
g_activatedDuringHold = false;
|
||||
// If a press was still pending (button held, no move), deliver it now.
|
||||
FlushPendingClickOnModifierRelease();
|
||||
bool wasDragging = g_dragging;
|
||||
bool wasResizing = g_resizing;
|
||||
EndInteraction(true, true);
|
||||
@@ -1021,9 +1142,18 @@ static LRESULT CALLBACK KeyboardProc(int nCode, WPARAM wParam, LPARAM lParam)
|
||||
g_dragConsumedAlt = false;
|
||||
return 1;
|
||||
}
|
||||
// No drag happened; replay the keydown, THEN the keyup
|
||||
ReplayAbsorbedModifier(true);
|
||||
return 1; // swallow this keyup since the replay already sent one
|
||||
if (g_pendingModifierReplayed)
|
||||
{
|
||||
// The key-down was already replayed for a pending click; let the
|
||||
// real key-up reach the target (falls through to goto forward).
|
||||
g_pendingModifierReplayed = false;
|
||||
}
|
||||
else
|
||||
{
|
||||
// No drag happened; replay the keydown, THEN the keyup
|
||||
ReplayAbsorbedModifier(true);
|
||||
return 1; // swallow this keyup since the replay already sent one
|
||||
}
|
||||
}
|
||||
}
|
||||
goto forward; // let Win keyup pass through
|
||||
@@ -1057,6 +1187,7 @@ static LRESULT CALLBACK KeyboardProc(int nCode, WPARAM wParam, LPARAM lParam)
|
||||
{
|
||||
g_altAbsorbed = true;
|
||||
g_dragConsumedAlt = false;
|
||||
g_pendingModifierReplayed = false;
|
||||
g_absorbedVk = kb->vkCode;
|
||||
g_absorbedScanCode = kb->scanCode;
|
||||
g_absorbedFlags = kb->flags;
|
||||
@@ -1067,6 +1198,10 @@ static LRESULT CALLBACK KeyboardProc(int nCode, WPARAM wParam, LPARAM lParam)
|
||||
else if (wParam == WM_KEYUP || wParam == WM_SYSKEYUP)
|
||||
{
|
||||
g_altPressed = false;
|
||||
g_ignoreModifier = false;
|
||||
g_activatedDuringHold = false;
|
||||
// If a press was still pending (button held, no move), deliver it now.
|
||||
FlushPendingClickOnModifierRelease();
|
||||
bool wasDragging = g_dragging;
|
||||
bool wasResizing = g_resizing;
|
||||
EndInteraction(true, true);
|
||||
@@ -1079,15 +1214,24 @@ static LRESULT CALLBACK KeyboardProc(int nCode, WPARAM wParam, LPARAM lParam)
|
||||
g_dragConsumedAlt = false;
|
||||
return 1;
|
||||
}
|
||||
// No drag happened; replay the keydown, then let keyup through
|
||||
ReplayAbsorbedModifier(false);
|
||||
if (g_pendingModifierReplayed)
|
||||
{
|
||||
// The key-down was already replayed for a pending click; let
|
||||
// the real key-up reach the target.
|
||||
g_pendingModifierReplayed = false;
|
||||
}
|
||||
else
|
||||
{
|
||||
// No drag happened; replay the keydown, then let keyup through
|
||||
ReplayAbsorbedModifier(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
// Non-Alt key while Alt was absorbed without a drag: replay Alt first
|
||||
if (g_altAbsorbed && !g_dragConsumedAlt)
|
||||
if (g_altAbsorbed && !g_dragConsumedAlt && !g_pendingModifierReplayed)
|
||||
{
|
||||
g_altAbsorbed = false;
|
||||
g_altPressed = false;
|
||||
@@ -1097,7 +1241,7 @@ static LRESULT CALLBACK KeyboardProc(int nCode, WPARAM wParam, LPARAM lParam)
|
||||
}
|
||||
|
||||
// Non-Win key while Win was absorbed without a drag: replay Win first
|
||||
if (g_winAbsorbed && !g_dragConsumedAlt && !isWinKey)
|
||||
if (g_winAbsorbed && !g_dragConsumedAlt && !isWinKey && !g_pendingModifierReplayed)
|
||||
{
|
||||
g_winAbsorbed = false;
|
||||
g_winPressed = false;
|
||||
@@ -1199,9 +1343,26 @@ static LRESULT CALLBACK MouseProc(int nCode, WPARAM wParam, LPARAM lParam)
|
||||
if (ms->flags & LLMHF_INJECTED)
|
||||
goto forward;
|
||||
|
||||
// Recovery path: if a non-modifier click occurs while stale drag/resize state exists, clear it.
|
||||
// Deliver-as-click bookkeeping: swallow the physical button-up that matches a
|
||||
// press we already replayed to the target on modifier release.
|
||||
if (wParam == WM_LBUTTONUP && g_swallowNextLButtonUp)
|
||||
{
|
||||
g_swallowNextLButtonUp = false;
|
||||
return 1;
|
||||
}
|
||||
if (wParam == WM_RBUTTONUP && g_swallowNextRButtonUp)
|
||||
{
|
||||
g_swallowNextRButtonUp = false;
|
||||
return 1;
|
||||
}
|
||||
|
||||
// Recovery path: if a non-modifier click occurs while stale drag/resize/pending state exists, clear it.
|
||||
if ((wParam == WM_LBUTTONDOWN || wParam == WM_RBUTTONDOWN) && !IsActivationModifierPressed())
|
||||
{
|
||||
g_pendingDrag = false;
|
||||
g_pendingResize = false;
|
||||
g_pendingTarget = nullptr;
|
||||
g_pendingModifierReplayed = false;
|
||||
EndInteraction(true, true);
|
||||
}
|
||||
|
||||
@@ -1210,7 +1371,7 @@ static LRESULT CALLBACK MouseProc(int nCode, WPARAM wParam, LPARAM lParam)
|
||||
HideOverlay();
|
||||
}
|
||||
|
||||
// ----- Alt + Left Button Down: start drag -----
|
||||
// ----- Modifier + Left Button Down: arm a pending drag (deferred until move) -----
|
||||
if (wParam == WM_LBUTTONDOWN && IsActivationModifierPressed())
|
||||
{
|
||||
if (IsSuppressedByGameMode())
|
||||
@@ -1228,23 +1389,60 @@ static LRESULT CALLBACK MouseProc(int nCode, WPARAM wParam, LPARAM lParam)
|
||||
goto forward;
|
||||
}
|
||||
|
||||
g_dragging = true;
|
||||
g_dragFirstMove = true;
|
||||
g_dragConsumedAlt = true;
|
||||
g_dragTarget = hwnd;
|
||||
g_dragStart = pt;
|
||||
GetWindowRect(hwnd, &g_dragWndRect);
|
||||
PrepareOverlayMetrics(hwnd);
|
||||
// Already committed to Grab and Move this hold: start dragging immediately.
|
||||
if (g_activatedDuringHold)
|
||||
{
|
||||
BeginDrag(hwnd, pt);
|
||||
return 1; // swallow the press; the drag is now active
|
||||
}
|
||||
|
||||
// Show the semi-transparent overlay on top of the target (persistent window – fix #9)
|
||||
ShowOverlay(g_dragWndRect, g_curSizeAll);
|
||||
// A press is already pending (the other button is still held): don't
|
||||
// overwrite it. Forward this one so we never strand the already-swallowed
|
||||
// button-down (which would leak an unmatched button-up to the target).
|
||||
if (g_pendingDrag || g_pendingResize)
|
||||
{
|
||||
goto forward;
|
||||
}
|
||||
|
||||
// Swallow the click so the target window doesn't get it
|
||||
TraceShortcutUse(true, GrabAndMoveShortcutAction::Move, L"started");
|
||||
return 1;
|
||||
// Defer: absorb the button-down but do nothing until the cursor moves.
|
||||
g_pendingDrag = true;
|
||||
g_pendingResize = false;
|
||||
g_pendingTarget = hwnd;
|
||||
g_pendingDownPt = pt;
|
||||
return 1; // swallow the press for now; replayed on release if no move
|
||||
}
|
||||
}
|
||||
|
||||
// ----- Promote a pending press to a real drag/resize once past the threshold -----
|
||||
if (wParam == WM_MOUSEMOVE && (g_pendingDrag || g_pendingResize))
|
||||
{
|
||||
int ddx = ms->pt.x - g_pendingDownPt.x;
|
||||
int ddy = ms->pt.y - g_pendingDownPt.y;
|
||||
if (ddx < 0) ddx = -ddx;
|
||||
if (ddy < 0) ddy = -ddy;
|
||||
if (ddx >= GetSystemMetrics(SM_CXDRAG) || ddy >= GetSystemMetrics(SM_CYDRAG))
|
||||
{
|
||||
bool wasResize = g_pendingResize;
|
||||
HWND target = g_pendingTarget;
|
||||
POINT downPt = g_pendingDownPt;
|
||||
g_pendingDrag = false;
|
||||
g_pendingResize = false;
|
||||
g_pendingTarget = nullptr;
|
||||
|
||||
if (wasResize)
|
||||
{
|
||||
BeginResize(target, downPt);
|
||||
HandleDragResize(ms->pt);
|
||||
}
|
||||
else
|
||||
{
|
||||
BeginDrag(target, downPt);
|
||||
HandleDragMove(ms->pt);
|
||||
}
|
||||
}
|
||||
return 0; // keep the cursor moving while waiting for the threshold
|
||||
}
|
||||
|
||||
// ----- Mouse Move while dragging -----
|
||||
if (wParam == WM_MOUSEMOVE && g_dragging && g_dragTarget)
|
||||
{
|
||||
@@ -1257,6 +1455,16 @@ static LRESULT CALLBACK MouseProc(int nCode, WPARAM wParam, LPARAM lParam)
|
||||
return 0;
|
||||
}
|
||||
|
||||
// ----- Left Button Up while a drag is pending (no move): deliver the click -----
|
||||
if (wParam == WM_LBUTTONUP && g_pendingDrag)
|
||||
{
|
||||
g_ignoreModifier = true;
|
||||
g_pendingDrag = false;
|
||||
g_pendingTarget = nullptr;
|
||||
ReplayPendingClick(false);
|
||||
return 1; // swallow the physical up; the injected click replaces it
|
||||
}
|
||||
|
||||
// ----- Left Button Up: end drag -----
|
||||
if (wParam == WM_LBUTTONUP && g_dragging)
|
||||
{
|
||||
@@ -1273,7 +1481,7 @@ static LRESULT CALLBACK MouseProc(int nCode, WPARAM wParam, LPARAM lParam)
|
||||
return 1; // swallow the release
|
||||
}
|
||||
|
||||
// ----- Alt + Right Button Down: start resize -----
|
||||
// ----- Modifier + Right Button Down: arm a pending resize (deferred until move) -----
|
||||
if (wParam == WM_RBUTTONDOWN && g_useAltResize && IsActivationModifierPressed())
|
||||
{
|
||||
if (IsSuppressedByGameMode())
|
||||
@@ -1296,19 +1504,27 @@ static LRESULT CALLBACK MouseProc(int nCode, WPARAM wParam, LPARAM lParam)
|
||||
goto forward;
|
||||
}
|
||||
|
||||
g_resizing = true;
|
||||
g_resizeFirstMove = true;
|
||||
g_dragConsumedAlt = true;
|
||||
g_resizeTarget = hwnd;
|
||||
g_resizeLast = pt;
|
||||
GetWindowRect(hwnd, &g_resizeWndRect);
|
||||
PrepareOverlayMetrics(hwnd);
|
||||
// Already committed to Grab and Move this hold: start resizing immediately.
|
||||
if (g_activatedDuringHold)
|
||||
{
|
||||
BeginResize(hwnd, pt);
|
||||
return 1; // swallow the press; the resize is now active
|
||||
}
|
||||
|
||||
g_currentHandle = GetClosestHandle(pt, g_resizeWndRect);
|
||||
ShowOverlay(g_resizeWndRect, CursorForHandle(g_currentHandle));
|
||||
// A press is already pending (the other button is still held): don't
|
||||
// overwrite it. Forward this one so we never strand the already-swallowed
|
||||
// button-down (which would leak an unmatched button-up to the target).
|
||||
if (g_pendingDrag || g_pendingResize)
|
||||
{
|
||||
goto forward;
|
||||
}
|
||||
|
||||
TraceShortcutUse(true, GrabAndMoveShortcutAction::Resize, L"started");
|
||||
return 1; // swallow the right button press
|
||||
// Defer: absorb the button-down but do nothing until the cursor moves.
|
||||
g_pendingResize = true;
|
||||
g_pendingDrag = false;
|
||||
g_pendingTarget = hwnd;
|
||||
g_pendingDownPt = pt;
|
||||
return 1; // swallow the press for now; replayed on release if no move
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1324,6 +1540,16 @@ static LRESULT CALLBACK MouseProc(int nCode, WPARAM wParam, LPARAM lParam)
|
||||
return 0;
|
||||
}
|
||||
|
||||
// ----- Right Button Up while a resize is pending (no move): deliver the click -----
|
||||
if (wParam == WM_RBUTTONUP && g_pendingResize)
|
||||
{
|
||||
g_ignoreModifier = true;
|
||||
g_pendingResize = false;
|
||||
g_pendingTarget = nullptr;
|
||||
ReplayPendingClick(true);
|
||||
return 1; // swallow the physical up; the injected click replaces it
|
||||
}
|
||||
|
||||
// ----- Right Button Up: end resize -----
|
||||
if (wParam == WM_RBUTTONUP && g_resizing)
|
||||
{
|
||||
@@ -1359,6 +1585,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 +1593,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);
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,295 @@
|
||||
PackageName: +Microsoft.OutlookForWindows
|
||||
Name: Outlook (new)
|
||||
WindowFilter: "olk.exe"
|
||||
BackgroundProcess: false
|
||||
Shortcuts:
|
||||
- SectionName: Frequently used
|
||||
Properties:
|
||||
- Name: Create new message or calendar event
|
||||
Recommended: true
|
||||
Shortcut:
|
||||
- Win: false
|
||||
Ctrl: true
|
||||
Shift: false
|
||||
Alt: false
|
||||
Keys:
|
||||
- N
|
||||
- Name: Send email
|
||||
Recommended: true
|
||||
Shortcut:
|
||||
- Win: false
|
||||
Ctrl: true
|
||||
Shift: false
|
||||
Alt: false
|
||||
Keys:
|
||||
- "<Enter>"
|
||||
- Name: Reply to email
|
||||
Recommended: true
|
||||
Shortcut:
|
||||
- Win: false
|
||||
Ctrl: true
|
||||
Shift: false
|
||||
Alt: false
|
||||
Keys:
|
||||
- R
|
||||
- Name: Reply all
|
||||
Shortcut:
|
||||
- Win: false
|
||||
Ctrl: true
|
||||
Shift: true
|
||||
Alt: false
|
||||
Keys:
|
||||
- R
|
||||
- Name: Forward message
|
||||
Shortcut:
|
||||
- Win: false
|
||||
Ctrl: true
|
||||
Shift: false
|
||||
Alt: false
|
||||
Keys:
|
||||
- F
|
||||
- Name: Delete message or item
|
||||
Shortcut:
|
||||
- Win: false
|
||||
Ctrl: false
|
||||
Shift: false
|
||||
Alt: false
|
||||
Keys:
|
||||
- "<Delete>"
|
||||
- Name: Permanently delete message
|
||||
Shortcut:
|
||||
- Win: false
|
||||
Ctrl: false
|
||||
Shift: true
|
||||
Alt: false
|
||||
Keys:
|
||||
- "<Delete>"
|
||||
- Name: Mark as read
|
||||
Shortcut:
|
||||
- Win: false
|
||||
Ctrl: true
|
||||
Shift: false
|
||||
Alt: false
|
||||
Keys:
|
||||
- Q
|
||||
- Name: Mark as unread
|
||||
Shortcut:
|
||||
- Win: false
|
||||
Ctrl: true
|
||||
Shift: false
|
||||
Alt: false
|
||||
Keys:
|
||||
- U
|
||||
- Name: Search
|
||||
Recommended: true
|
||||
Shortcut:
|
||||
- Win: false
|
||||
Ctrl: true
|
||||
Shift: false
|
||||
Alt: false
|
||||
Keys:
|
||||
- E
|
||||
- SectionName: Navigate Outlook
|
||||
Properties:
|
||||
- Name: Go to Mail
|
||||
Recommended: true
|
||||
Shortcut:
|
||||
- Win: false
|
||||
Ctrl: true
|
||||
Shift: false
|
||||
Alt: false
|
||||
Keys:
|
||||
- "<1>"
|
||||
- Name: Go to Calendar
|
||||
Recommended: true
|
||||
Shortcut:
|
||||
- Win: false
|
||||
Ctrl: true
|
||||
Shift: false
|
||||
Alt: false
|
||||
Keys:
|
||||
- "<2>"
|
||||
- Name: Go to People
|
||||
Shortcut:
|
||||
- Win: false
|
||||
Ctrl: true
|
||||
Shift: false
|
||||
Alt: false
|
||||
Keys:
|
||||
- "<3>"
|
||||
- Name: Go to To Do
|
||||
Shortcut:
|
||||
- Win: false
|
||||
Ctrl: true
|
||||
Shift: false
|
||||
Alt: false
|
||||
Keys:
|
||||
- "<4>"
|
||||
- Name: Go to next message
|
||||
Shortcut:
|
||||
- Win: false
|
||||
Ctrl: true
|
||||
Shift: false
|
||||
Alt: false
|
||||
Keys:
|
||||
- ">"
|
||||
- Name: Go to previous message
|
||||
Description: Press Ctrl+Shift+Comma (rendered as Ctrl+<) to go back.
|
||||
Shortcut:
|
||||
- Win: false
|
||||
Ctrl: true
|
||||
Shift: true
|
||||
Alt: false
|
||||
Keys:
|
||||
- ","
|
||||
- SectionName: Text editing
|
||||
Properties:
|
||||
- Name: Cut
|
||||
Shortcut:
|
||||
- Win: false
|
||||
Ctrl: true
|
||||
Shift: false
|
||||
Alt: false
|
||||
Keys:
|
||||
- X
|
||||
- Name: Copy
|
||||
Shortcut:
|
||||
- Win: false
|
||||
Ctrl: true
|
||||
Shift: false
|
||||
Alt: false
|
||||
Keys:
|
||||
- C
|
||||
- Name: Paste
|
||||
Shortcut:
|
||||
- Win: false
|
||||
Ctrl: true
|
||||
Shift: false
|
||||
Alt: false
|
||||
Keys:
|
||||
- V
|
||||
- Name: Undo
|
||||
Recommended: true
|
||||
Shortcut:
|
||||
- Win: false
|
||||
Ctrl: true
|
||||
Shift: false
|
||||
Alt: false
|
||||
Keys:
|
||||
- Z
|
||||
- Name: Redo
|
||||
Shortcut:
|
||||
- Win: false
|
||||
Ctrl: true
|
||||
Shift: false
|
||||
Alt: false
|
||||
Keys:
|
||||
- Y
|
||||
- Name: Insert hyperlink
|
||||
Shortcut:
|
||||
- Win: false
|
||||
Ctrl: true
|
||||
Shift: false
|
||||
Alt: false
|
||||
Keys:
|
||||
- K
|
||||
- Name: Delete word to the left
|
||||
Shortcut:
|
||||
- Win: false
|
||||
Ctrl: true
|
||||
Shift: false
|
||||
Alt: false
|
||||
Keys:
|
||||
- "<Backspace>"
|
||||
- SectionName: Format text
|
||||
Properties:
|
||||
- Name: Bold
|
||||
Shortcut:
|
||||
- Win: false
|
||||
Ctrl: true
|
||||
Shift: false
|
||||
Alt: false
|
||||
Keys:
|
||||
- B
|
||||
- Name: Italic
|
||||
Shortcut:
|
||||
- Win: false
|
||||
Ctrl: true
|
||||
Shift: false
|
||||
Alt: false
|
||||
Keys:
|
||||
- I
|
||||
- Name: Underline
|
||||
Shortcut:
|
||||
- Win: false
|
||||
Ctrl: true
|
||||
Shift: false
|
||||
Alt: false
|
||||
Keys:
|
||||
- U
|
||||
- SectionName: Calendar
|
||||
Properties:
|
||||
- Name: Create new calendar item
|
||||
Shortcut:
|
||||
- Win: false
|
||||
Ctrl: true
|
||||
Shift: false
|
||||
Alt: false
|
||||
Keys:
|
||||
- N
|
||||
- Name: Switch to Day view
|
||||
Shortcut:
|
||||
- Win: false
|
||||
Ctrl: true
|
||||
Shift: false
|
||||
Alt: true
|
||||
Keys:
|
||||
- "<1>"
|
||||
- Name: Switch to Work week view
|
||||
Shortcut:
|
||||
- Win: false
|
||||
Ctrl: true
|
||||
Shift: false
|
||||
Alt: true
|
||||
Keys:
|
||||
- "<2>"
|
||||
- Name: Switch to full Week view
|
||||
Shortcut:
|
||||
- Win: false
|
||||
Ctrl: true
|
||||
Shift: false
|
||||
Alt: true
|
||||
Keys:
|
||||
- "<3>"
|
||||
- Name: Switch to Month view
|
||||
Shortcut:
|
||||
- Win: false
|
||||
Ctrl: true
|
||||
Shift: false
|
||||
Alt: true
|
||||
Keys:
|
||||
- "<4>"
|
||||
- Name: Go to previous time period
|
||||
Shortcut:
|
||||
- Win: false
|
||||
Ctrl: true
|
||||
Shift: false
|
||||
Alt: true
|
||||
Keys:
|
||||
- "<Left>"
|
||||
- Name: Go to next time period
|
||||
Shortcut:
|
||||
- Win: false
|
||||
Ctrl: true
|
||||
Shift: false
|
||||
Alt: true
|
||||
Keys:
|
||||
- "<Right>"
|
||||
- Name: Go to today
|
||||
Shortcut:
|
||||
- Win: false
|
||||
Ctrl: false
|
||||
Shift: true
|
||||
Alt: true
|
||||
Keys:
|
||||
- Y
|
||||
@@ -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,196 @@
|
||||
PackageName: Obsidian.Obsidian
|
||||
Name: Obsidian
|
||||
WindowFilter: "Obsidian.exe"
|
||||
BackgroundProcess: false
|
||||
Shortcuts:
|
||||
- SectionName: Navigation
|
||||
Properties:
|
||||
- Name: Open command palette
|
||||
Recommended: true
|
||||
Shortcut:
|
||||
- Win: false
|
||||
Ctrl: true
|
||||
Shift: false
|
||||
Alt: false
|
||||
Keys:
|
||||
- P
|
||||
- Name: Open quick switcher
|
||||
Recommended: true
|
||||
Shortcut:
|
||||
- Win: false
|
||||
Ctrl: true
|
||||
Shift: false
|
||||
Alt: false
|
||||
Keys:
|
||||
- O
|
||||
- Name: Search in all files
|
||||
Recommended: true
|
||||
Shortcut:
|
||||
- Win: false
|
||||
Ctrl: true
|
||||
Shift: true
|
||||
Alt: false
|
||||
Keys:
|
||||
- F
|
||||
- Name: Toggle reading/editing view
|
||||
Shortcut:
|
||||
- Win: false
|
||||
Ctrl: true
|
||||
Shift: false
|
||||
Alt: false
|
||||
Keys:
|
||||
- E
|
||||
- SectionName: Tabs
|
||||
Properties:
|
||||
- Name: New tab
|
||||
Shortcut:
|
||||
- Win: false
|
||||
Ctrl: true
|
||||
Shift: false
|
||||
Alt: false
|
||||
Keys:
|
||||
- T
|
||||
- Name: Next tab
|
||||
Shortcut:
|
||||
- Win: false
|
||||
Ctrl: true
|
||||
Shift: false
|
||||
Alt: false
|
||||
Keys:
|
||||
- "<Tab>"
|
||||
- Name: Previous tab
|
||||
Shortcut:
|
||||
- Win: false
|
||||
Ctrl: true
|
||||
Shift: true
|
||||
Alt: false
|
||||
Keys:
|
||||
- "<Tab>"
|
||||
- Name: Switch to tab at position (1–8)
|
||||
Shortcut:
|
||||
- Win: false
|
||||
Ctrl: true
|
||||
Shift: false
|
||||
Alt: false
|
||||
Keys:
|
||||
- '1 - 8'
|
||||
- Name: Switch to last tab
|
||||
Shortcut:
|
||||
- Win: false
|
||||
Ctrl: true
|
||||
Shift: false
|
||||
Alt: false
|
||||
Keys:
|
||||
- "<9>"
|
||||
- Name: Reopen last closed tab
|
||||
Shortcut:
|
||||
- Win: false
|
||||
Ctrl: true
|
||||
Shift: true
|
||||
Alt: false
|
||||
Keys:
|
||||
- T
|
||||
- SectionName: Editing
|
||||
Properties:
|
||||
- Name: Bold
|
||||
Shortcut:
|
||||
- Win: false
|
||||
Ctrl: true
|
||||
Shift: false
|
||||
Alt: false
|
||||
Keys:
|
||||
- B
|
||||
- Name: Italic
|
||||
Shortcut:
|
||||
- Win: false
|
||||
Ctrl: true
|
||||
Shift: false
|
||||
Alt: false
|
||||
Keys:
|
||||
- I
|
||||
- Name: Undo
|
||||
Shortcut:
|
||||
- Win: false
|
||||
Ctrl: true
|
||||
Shift: false
|
||||
Alt: false
|
||||
Keys:
|
||||
- Z
|
||||
- Name: Redo
|
||||
Shortcut:
|
||||
- Win: false
|
||||
Ctrl: true
|
||||
Shift: false
|
||||
Alt: false
|
||||
Keys:
|
||||
- Y
|
||||
- Win: false
|
||||
Ctrl: true
|
||||
Shift: true
|
||||
Alt: false
|
||||
Keys:
|
||||
- Z
|
||||
- Name: Paste without formatting
|
||||
Shortcut:
|
||||
- Win: false
|
||||
Ctrl: true
|
||||
Shift: true
|
||||
Alt: false
|
||||
Keys:
|
||||
- V
|
||||
- Name: Select all
|
||||
Shortcut:
|
||||
- Win: false
|
||||
Ctrl: true
|
||||
Shift: false
|
||||
Alt: false
|
||||
Keys:
|
||||
- A
|
||||
- Name: Delete current line
|
||||
Shortcut:
|
||||
- Win: false
|
||||
Ctrl: true
|
||||
Shift: true
|
||||
Alt: false
|
||||
Keys:
|
||||
- K
|
||||
- Name: Delete previous word
|
||||
Shortcut:
|
||||
- Win: false
|
||||
Ctrl: true
|
||||
Shift: false
|
||||
Alt: false
|
||||
Keys:
|
||||
- "<Backspace>"
|
||||
- Name: Delete next word
|
||||
Shortcut:
|
||||
- Win: false
|
||||
Ctrl: true
|
||||
Shift: false
|
||||
Alt: false
|
||||
Keys:
|
||||
- "<Delete>"
|
||||
- Name: Move cursor to beginning of note
|
||||
Shortcut:
|
||||
- Win: false
|
||||
Ctrl: true
|
||||
Shift: false
|
||||
Alt: false
|
||||
Keys:
|
||||
- "<Home>"
|
||||
- Name: Move cursor to end of note
|
||||
Shortcut:
|
||||
- Win: false
|
||||
Ctrl: true
|
||||
Shift: false
|
||||
Alt: false
|
||||
Keys:
|
||||
- "<End>"
|
||||
- Name: Add file property
|
||||
Shortcut:
|
||||
- Win: false
|
||||
Ctrl: true
|
||||
Shift: false
|
||||
Alt: false
|
||||
Keys:
|
||||
- ";"
|
||||
@@ -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
|
||||
@@ -7,9 +7,11 @@ using Microsoft.CommandPalette.Extensions;
|
||||
|
||||
namespace Microsoft.CmdPal.UI.ViewModels;
|
||||
|
||||
public partial class DetailsViewModel(IDetails _details, WeakReference<IPageContext> context) : ExtensionObjectViewModel(context)
|
||||
public partial class DetailsViewModel : ExtensionObjectViewModel
|
||||
{
|
||||
private readonly ExtensionObject<IDetails> _detailsModel = new(_details);
|
||||
private readonly ExtensionObject<IDetails> _detailsModel;
|
||||
private INotifyPropChanged? _observableDetails;
|
||||
private bool _isSubscribed;
|
||||
|
||||
// Remember - "observable" properties from the model (via PropChanged)
|
||||
// cannot be marked [ObservableProperty]
|
||||
@@ -25,6 +27,81 @@ public partial class DetailsViewModel(IDetails _details, WeakReference<IPageCont
|
||||
// where IDetailsElement = {IDetailsTags, IDetailsLink, IDetailsSeparator}
|
||||
public List<DetailsElementViewModel> Metadata { get; private set; } = [];
|
||||
|
||||
public DetailsViewModel(IDetails details, WeakReference<IPageContext> context)
|
||||
: base(context)
|
||||
{
|
||||
_detailsModel = new(details);
|
||||
}
|
||||
|
||||
private void Model_PropChanged(object sender, IPropChangedEventArgs args)
|
||||
{
|
||||
try
|
||||
{
|
||||
FetchProperty(args.PropertyName);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
ShowException(ex);
|
||||
}
|
||||
}
|
||||
|
||||
private void FetchProperty(string propertyName)
|
||||
{
|
||||
var model = _detailsModel.Unsafe;
|
||||
if (model is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
switch (propertyName)
|
||||
{
|
||||
case nameof(IDetails.Title):
|
||||
Title = model.Title ?? string.Empty;
|
||||
UpdateProperty(nameof(Title));
|
||||
break;
|
||||
case nameof(IDetails.Body):
|
||||
Body = model.Body ?? string.Empty;
|
||||
UpdateProperty(nameof(Body));
|
||||
break;
|
||||
case nameof(IDetails.HeroImage):
|
||||
HeroImage = new(model.HeroImage);
|
||||
HeroImage.InitializeProperties();
|
||||
UpdateProperty(nameof(HeroImage));
|
||||
break;
|
||||
case nameof(IDetails.Metadata):
|
||||
RebuildMetadata(model);
|
||||
UpdateProperty(nameof(Metadata));
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
private void RebuildMetadata(IDetails model)
|
||||
{
|
||||
var newMetadata = new List<DetailsElementViewModel>();
|
||||
var meta = model.Metadata;
|
||||
if (meta is not null)
|
||||
{
|
||||
foreach (var element in meta)
|
||||
{
|
||||
DetailsElementViewModel? vm = element.Data switch
|
||||
{
|
||||
IDetailsSeparator => new DetailsSeparatorViewModel(element, this.PageContext),
|
||||
IDetailsLink => new DetailsLinkViewModel(element, this.PageContext),
|
||||
IDetailsCommands => new DetailsCommandsViewModel(element, this.PageContext),
|
||||
IDetailsTags => new DetailsTagsViewModel(element, this.PageContext),
|
||||
_ => null,
|
||||
};
|
||||
if (vm is not null)
|
||||
{
|
||||
vm.InitializeProperties();
|
||||
newMetadata.Add(vm);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Metadata = newMetadata;
|
||||
}
|
||||
|
||||
public override void InitializeProperties()
|
||||
{
|
||||
var model = _detailsModel.Unsafe;
|
||||
@@ -33,6 +110,14 @@ public partial class DetailsViewModel(IDetails _details, WeakReference<IPageCont
|
||||
return;
|
||||
}
|
||||
|
||||
// Subscribe to PropChanged if the model supports it (only subscribe once)
|
||||
if (!_isSubscribed && model is INotifyPropChanged observable)
|
||||
{
|
||||
observable.PropChanged += Model_PropChanged;
|
||||
_observableDetails = observable;
|
||||
_isSubscribed = true;
|
||||
}
|
||||
|
||||
Title = model.Title ?? string.Empty;
|
||||
Body = model.Body ?? string.Empty;
|
||||
HeroImage = new(model.HeroImage);
|
||||
@@ -57,25 +142,18 @@ public partial class DetailsViewModel(IDetails _details, WeakReference<IPageCont
|
||||
|
||||
UpdateProperty(nameof(Size));
|
||||
|
||||
var meta = model.Metadata;
|
||||
if (meta is not null)
|
||||
RebuildMetadata(model);
|
||||
}
|
||||
|
||||
protected override void UnsafeCleanup()
|
||||
{
|
||||
base.UnsafeCleanup();
|
||||
|
||||
if (_isSubscribed && _observableDetails is not null)
|
||||
{
|
||||
foreach (var element in meta)
|
||||
{
|
||||
DetailsElementViewModel? vm = element.Data switch
|
||||
{
|
||||
IDetailsSeparator => new DetailsSeparatorViewModel(element, this.PageContext),
|
||||
IDetailsLink => new DetailsLinkViewModel(element, this.PageContext),
|
||||
IDetailsCommands => new DetailsCommandsViewModel(element, this.PageContext),
|
||||
IDetailsTags => new DetailsTagsViewModel(element, this.PageContext),
|
||||
_ => null,
|
||||
};
|
||||
if (vm is not null)
|
||||
{
|
||||
vm.InitializeProperties();
|
||||
Metadata.Add(vm);
|
||||
}
|
||||
}
|
||||
_observableDetails.PropChanged -= Model_PropChanged;
|
||||
_observableDetails = null;
|
||||
_isSubscribed = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -83,6 +83,11 @@ public sealed partial class DockViewModel : IDisposable
|
||||
return;
|
||||
}
|
||||
|
||||
if (_settings == settings)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_settings = settings;
|
||||
SetupBands();
|
||||
}
|
||||
|
||||
@@ -158,11 +158,13 @@ public partial class ListItemViewModel : CommandItemViewModel
|
||||
UpdateProperty(nameof(Type), nameof(IsInteractive));
|
||||
break;
|
||||
case nameof(Details):
|
||||
var existingReference = Details;
|
||||
var extensionDetails = model.Details;
|
||||
Details = extensionDetails is not null ? new(extensionDetails, PageContext) : null;
|
||||
Details?.InitializeProperties();
|
||||
UpdateProperty(nameof(Details), nameof(HasDetails));
|
||||
UpdateShowDetailsCommand();
|
||||
existingReference?.SafeCleanup();
|
||||
break;
|
||||
case nameof(model.MoreCommands):
|
||||
AddShowDetailsCommands();
|
||||
|
||||
@@ -48,7 +48,10 @@ public record DockSettings
|
||||
public string? BackgroundImagePath { get; init; }
|
||||
|
||||
// </Theme settings>
|
||||
private ImmutableList<DockBandSettings>? _startBands = ImmutableList.Create(
|
||||
|
||||
// Band lists use EquatableList backing fields so the compiler-synthesized record equality
|
||||
// compares them by content, not by reference. See EquatableList<T> for why this matters.
|
||||
private readonly EquatableList<DockBandSettings> _startBands = new(ImmutableList.Create(
|
||||
new DockBandSettings
|
||||
{
|
||||
ProviderId = "com.microsoft.cmdpal.builtin.core",
|
||||
@@ -59,23 +62,23 @@ public record DockSettings
|
||||
ProviderId = "WinGet",
|
||||
CommandId = "com.microsoft.cmdpal.winget",
|
||||
ShowTitles = false,
|
||||
});
|
||||
}));
|
||||
|
||||
public ImmutableList<DockBandSettings> StartBands
|
||||
{
|
||||
get => _startBands ?? ImmutableList<DockBandSettings>.Empty;
|
||||
init => _startBands = value;
|
||||
get => _startBands.List;
|
||||
init => _startBands = new(value);
|
||||
}
|
||||
|
||||
private ImmutableList<DockBandSettings>? _centerBands = ImmutableList<DockBandSettings>.Empty;
|
||||
private readonly EquatableList<DockBandSettings> _centerBands = new(ImmutableList<DockBandSettings>.Empty);
|
||||
|
||||
public ImmutableList<DockBandSettings> CenterBands
|
||||
{
|
||||
get => _centerBands ?? ImmutableList<DockBandSettings>.Empty;
|
||||
init => _centerBands = value;
|
||||
get => _centerBands.List;
|
||||
init => _centerBands = new(value);
|
||||
}
|
||||
|
||||
private ImmutableList<DockBandSettings>? _endBands = ImmutableList.Create(
|
||||
private readonly EquatableList<DockBandSettings> _endBands = new(ImmutableList.Create(
|
||||
new DockBandSettings
|
||||
{
|
||||
ProviderId = "PerformanceMonitor",
|
||||
@@ -85,12 +88,12 @@ public record DockSettings
|
||||
{
|
||||
ProviderId = "com.microsoft.cmdpal.builtin.datetime",
|
||||
CommandId = "com.microsoft.cmdpal.timedate.dockBand",
|
||||
});
|
||||
}));
|
||||
|
||||
public ImmutableList<DockBandSettings> EndBands
|
||||
{
|
||||
get => _endBands ?? ImmutableList<DockBandSettings>.Empty;
|
||||
init => _endBands = value;
|
||||
get => _endBands.List;
|
||||
init => _endBands = new(value);
|
||||
}
|
||||
|
||||
public bool ShowLabels { get; init; } = true;
|
||||
@@ -99,12 +102,12 @@ public record DockSettings
|
||||
/// Gets the per-monitor dock configurations. Each entry overrides global
|
||||
/// settings for a specific display. Empty by default (all monitors use global).
|
||||
/// </summary>
|
||||
private ImmutableList<DockMonitorConfig>? _monitorConfigs = ImmutableList<DockMonitorConfig>.Empty;
|
||||
private readonly EquatableList<DockMonitorConfig> _monitorConfigs = new(ImmutableList<DockMonitorConfig>.Empty);
|
||||
|
||||
public ImmutableList<DockMonitorConfig> MonitorConfigs
|
||||
{
|
||||
get => _monitorConfigs ?? ImmutableList<DockMonitorConfig>.Empty;
|
||||
init => _monitorConfigs = value ?? ImmutableList<DockMonitorConfig>.Empty;
|
||||
get => _monitorConfigs.List;
|
||||
init => _monitorConfigs = new(value);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -192,20 +195,41 @@ public sealed record DockMonitorConfig
|
||||
/// </summary>
|
||||
public bool IsCustomized { get; init; }
|
||||
|
||||
// Nullable EquatableList backing fields give the synthesized record equality structural
|
||||
// comparison of the per-monitor bands while preserving null ("inherit global") vs. an
|
||||
// explicit (possibly empty) list. See EquatableList<T>.
|
||||
private readonly EquatableList<DockBandSettings>? _startBands;
|
||||
|
||||
/// <summary>
|
||||
/// Gets the per-monitor start bands. Only used when <see cref="IsCustomized"/> is <c>true</c>.
|
||||
/// </summary>
|
||||
public ImmutableList<DockBandSettings>? StartBands { get; init; }
|
||||
public ImmutableList<DockBandSettings>? StartBands
|
||||
{
|
||||
get => _startBands?.List;
|
||||
init => _startBands = value is null ? null : new EquatableList<DockBandSettings>(value);
|
||||
}
|
||||
|
||||
private readonly EquatableList<DockBandSettings>? _centerBands;
|
||||
|
||||
/// <summary>
|
||||
/// Gets the per-monitor center bands. Only used when <see cref="IsCustomized"/> is <c>true</c>.
|
||||
/// </summary>
|
||||
public ImmutableList<DockBandSettings>? CenterBands { get; init; }
|
||||
public ImmutableList<DockBandSettings>? CenterBands
|
||||
{
|
||||
get => _centerBands?.List;
|
||||
init => _centerBands = value is null ? null : new EquatableList<DockBandSettings>(value);
|
||||
}
|
||||
|
||||
private readonly EquatableList<DockBandSettings>? _endBands;
|
||||
|
||||
/// <summary>
|
||||
/// Gets the per-monitor end bands. Only used when <see cref="IsCustomized"/> is <c>true</c>.
|
||||
/// </summary>
|
||||
public ImmutableList<DockBandSettings>? EndBands { get; init; }
|
||||
public ImmutableList<DockBandSettings>? EndBands
|
||||
{
|
||||
get => _endBands?.List;
|
||||
init => _endBands = value is null ? null : new EquatableList<DockBandSettings>(value);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the UTC timestamp when this monitor was last seen connected. Used for
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
// 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.Immutable;
|
||||
|
||||
namespace Microsoft.CmdPal.UI.ViewModels.Settings;
|
||||
|
||||
/// <summary>
|
||||
/// A thin wrapper around <see cref="ImmutableList{T}"/> that provides <em>structural</em>
|
||||
/// (element-by-element) equality. <see cref="ImmutableList{T}"/> itself only implements
|
||||
/// reference equality, which means a record holding one compares unequal to an otherwise
|
||||
/// identical record whenever the list was rebuilt into a fresh instance (e.g. after loading
|
||||
/// settings from disk).
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Used as the <em>backing field</em> type for record list properties (the public properties
|
||||
/// still expose <see cref="ImmutableList{T}"/>). Because the compiler-synthesized record
|
||||
/// equality compares backing fields, swapping the field type here makes that synthesized
|
||||
/// equality structural — with no hand-written <c>Equals</c> to keep in sync as new properties
|
||||
/// are added.
|
||||
/// </remarks>
|
||||
internal readonly struct EquatableList<T> : IEquatable<EquatableList<T>>
|
||||
{
|
||||
private readonly ImmutableList<T>? _list;
|
||||
|
||||
public EquatableList(ImmutableList<T>? list) => _list = list;
|
||||
|
||||
public ImmutableList<T> List => _list ?? ImmutableList<T>.Empty;
|
||||
|
||||
public bool Equals(EquatableList<T> other)
|
||||
{
|
||||
var a = List;
|
||||
var b = other.List;
|
||||
|
||||
if (ReferenceEquals(a, b))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
if (a.Count != b.Count)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var comparer = EqualityComparer<T>.Default;
|
||||
for (var i = 0; i < a.Count; i++)
|
||||
{
|
||||
if (!comparer.Equals(a[i], b[i]))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
public override bool Equals(object? obj) => obj is EquatableList<T> other && Equals(other);
|
||||
|
||||
public override int GetHashCode()
|
||||
{
|
||||
var hash = default(HashCode);
|
||||
foreach (var item in List)
|
||||
{
|
||||
hash.Add(item);
|
||||
}
|
||||
|
||||
return hash.ToHashCode();
|
||||
}
|
||||
}
|
||||
@@ -11,6 +11,10 @@ namespace Microsoft.CmdPal.UI.ViewModels;
|
||||
|
||||
public record SettingsModel
|
||||
{
|
||||
// LOAD BEARNING: Some SettingsChanged subscribers react selectively (e.g.
|
||||
// MainWindow.MainWindowSettingsComparer, DockWindowManager.OnSettingsChanged). If a new
|
||||
// setting needs a live reaction, add it there too - otherwise it won't take effect.
|
||||
|
||||
///////////////////////////////////////////////////////////////////////////
|
||||
// SETTINGS HERE
|
||||
public static HotkeySettings DefaultActivationShortcut { get; } = new HotkeySettings(true, false, true, false, 0x20); // win+alt+space
|
||||
|
||||
@@ -55,6 +55,18 @@ public partial class ShellViewModel : ObservableObject,
|
||||
oldValue.PropertyChanged -= CurrentPage_PropertyChanged;
|
||||
value.PropertyChanged += CurrentPage_PropertyChanged;
|
||||
|
||||
// Re-evaluate search-box visibility for the page we're switching to.
|
||||
// CurrentPage_PropertyChanged only reacts to a *change* of HasSearchBox, so
|
||||
// switching to a page whose HasSearchBox already holds its final value (e.g.
|
||||
// navigating back to a list from a ContentPage) would otherwise never restore
|
||||
// the search box. Only force it visible here; hiding it on content pages is
|
||||
// deliberately deferred (see ShellPage.FocusAfterLoaded) so focus doesn't jump
|
||||
// around for screen readers.
|
||||
if (value.HasSearchBox)
|
||||
{
|
||||
IsSearchBoxVisible = true;
|
||||
}
|
||||
|
||||
if (oldValue is IDisposable disposable)
|
||||
{
|
||||
try
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -27,7 +27,6 @@ namespace Microsoft.CmdPal.UI.Controls;
|
||||
public sealed partial class SearchBar : UserControl,
|
||||
INotifyPropertyChanged,
|
||||
IRecipient<GoHomeMessage>,
|
||||
IRecipient<FocusSearchBoxMessage>,
|
||||
IRecipient<UpdateSuggestionMessage>,
|
||||
IRecipient<FocusParamMessage>,
|
||||
ICurrentPageAware
|
||||
@@ -72,6 +71,8 @@ public sealed partial class SearchBar : UserControl,
|
||||
|
||||
public event PropertyChangedEventHandler? PropertyChanged;
|
||||
|
||||
public event EventHandler? ActiveFocusTargetChanged;
|
||||
|
||||
private static void OnCurrentPageViewModelChanged(DependencyObject d, DependencyPropertyChangedEventArgs e)
|
||||
{
|
||||
//// TODO: If the Debounce timer hasn't fired, we may want to store the current Filter in the OldValue/prior VM, but we don't want that to go actually do work...
|
||||
@@ -102,8 +103,9 @@ public sealed partial class SearchBar : UserControl,
|
||||
@this?.PropertyChanged?.Invoke(@this, new(nameof(PageType)));
|
||||
@this?.PropertyChanged?.Invoke(@this, new(nameof(Parameters)));
|
||||
|
||||
// Attempt to focus us again, once we evaluate what input is visible
|
||||
@this?.Focus();
|
||||
// Let the shell decide if it's safe to restore focus after the
|
||||
// SwitchPresenter swaps to a different top-bar input.
|
||||
@this?.ActiveFocusTargetChanged?.Invoke(@this, EventArgs.Empty);
|
||||
}
|
||||
|
||||
public string PageType => CurrentPageViewModel switch
|
||||
@@ -120,7 +122,6 @@ public sealed partial class SearchBar : UserControl,
|
||||
{
|
||||
this.InitializeComponent();
|
||||
WeakReferenceMessenger.Default.Register<GoHomeMessage>(this);
|
||||
WeakReferenceMessenger.Default.Register<FocusSearchBoxMessage>(this);
|
||||
WeakReferenceMessenger.Default.Register<UpdateSuggestionMessage>(this);
|
||||
WeakReferenceMessenger.Default.Register<FocusParamMessage>(this);
|
||||
}
|
||||
@@ -497,9 +498,13 @@ public sealed partial class SearchBar : UserControl,
|
||||
}
|
||||
}
|
||||
|
||||
public void Receive(FocusSearchBoxMessage message) => Focus();
|
||||
|
||||
private void Focus()
|
||||
/// <summary>
|
||||
/// Moves focus to the inner search text box using <see cref="FocusState.Keyboard"/>, which
|
||||
/// (unlike a programmatic <c>Focus</c> on the control) lets the screen reader announce the
|
||||
/// box and its placeholder. Used for summon and post-navigation focus alike so both paths
|
||||
/// are announced consistently.
|
||||
/// </summary>
|
||||
internal void FocusActiveControl()
|
||||
{
|
||||
this.DispatcherQueue.TryEnqueue(DispatcherQueuePriority.Low, () =>
|
||||
{
|
||||
|
||||
@@ -48,6 +48,7 @@
|
||||
|
||||
<Style x:Key="DefaultDockItemControlStyle" TargetType="local:DockItemControl">
|
||||
<Style.Setters>
|
||||
<Setter Property="AutomationProperties.LiveSetting" Value="Off" />
|
||||
<Setter Property="Background" Value="{ThemeResource DockItemBackground}" />
|
||||
<Setter Property="BorderBrush" Value="{ThemeResource DockItemBorderBrush}" />
|
||||
<Setter Property="Padding" Value="{StaticResource DockItemPadding}" />
|
||||
|
||||
@@ -219,6 +219,11 @@ public sealed partial class DockWindow : WindowEx,
|
||||
return;
|
||||
}
|
||||
|
||||
if (_settings == args.DockSettings)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_settings = args.DockSettings;
|
||||
RefreshSideOverride();
|
||||
DispatcherQueue.TryEnqueue(UpdateSettingsOnUiThread);
|
||||
|
||||
@@ -26,6 +26,9 @@ public sealed partial class DockWindowManager : IDisposable
|
||||
private bool _disposed;
|
||||
private int _syncing;
|
||||
|
||||
private bool? _lastSyncedEnableDock;
|
||||
private DockSettings? _lastSyncedDockSettings;
|
||||
|
||||
public DockWindowManager(
|
||||
IMonitorService monitorService,
|
||||
ISettingsService settingsService,
|
||||
@@ -217,6 +220,14 @@ public sealed partial class DockWindowManager : IDisposable
|
||||
|
||||
private void OnSettingsChanged(ISettingsService sender, SettingsModel args)
|
||||
{
|
||||
if (args.EnableDock == _lastSyncedEnableDock && args.DockSettings == _lastSyncedDockSettings)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_lastSyncedEnableDock = args.EnableDock;
|
||||
_lastSyncedDockSettings = args.DockSettings;
|
||||
|
||||
_dispatcherQueue.TryEnqueue(() =>
|
||||
{
|
||||
if (!_disposed)
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
// 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.Immutable;
|
||||
using System.Diagnostics;
|
||||
using System.Runtime.InteropServices;
|
||||
using CmdPalKeyboardService;
|
||||
@@ -9,7 +10,6 @@ using CommunityToolkit.Mvvm.Messaging;
|
||||
using ManagedCommon;
|
||||
using Microsoft.CmdPal.Common.Helpers;
|
||||
using Microsoft.CmdPal.Common.Messages;
|
||||
using Microsoft.CmdPal.Common.Services;
|
||||
using Microsoft.CmdPal.UI.Controls;
|
||||
using Microsoft.CmdPal.UI.Dock;
|
||||
using Microsoft.CmdPal.UI.Events;
|
||||
@@ -81,6 +81,11 @@ public sealed partial class MainWindow : WindowEx,
|
||||
private bool _suppressDpiChange;
|
||||
private bool _themeServiceInitialized;
|
||||
|
||||
// The snapshot of settings last consumed by HotReloadSettings. Used to skip redundant
|
||||
// hot-reloads when a SettingsChanged notification touches settings this window doesn't
|
||||
// care about (theme, provider config, aliases, and so on).
|
||||
private SettingsModel? _lastAppliedSettings;
|
||||
|
||||
// Session tracking for telemetry
|
||||
private Stopwatch? _sessionStopwatch;
|
||||
private int _sessionCommandsExecuted;
|
||||
@@ -113,6 +118,8 @@ public sealed partial class MainWindow : WindowEx,
|
||||
|
||||
public bool IsVisibleToUser { get; private set; } = true;
|
||||
|
||||
public event EventHandler? IsVisibleToUserChanged;
|
||||
|
||||
public MainWindow()
|
||||
{
|
||||
InitializeComponent();
|
||||
@@ -237,6 +244,14 @@ public sealed partial class MainWindow : WindowEx,
|
||||
|
||||
private void SettingsChangedHandler(ISettingsService sender, SettingsModel args)
|
||||
{
|
||||
// Only rebuild window state when a setting HotReloadSettings actually consumes has
|
||||
// changed. Most SettingsChanged notifications (theme, provider config, aliases, ...)
|
||||
// don't affect the host window, so hot-reloading on every one is wasteful.
|
||||
if (MainWindowSettingsComparer.Instance.Equals(_lastAppliedSettings, args))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
DispatcherQueue.TryEnqueue(HotReloadSettings);
|
||||
}
|
||||
|
||||
@@ -462,7 +477,11 @@ public sealed partial class MainWindow : WindowEx,
|
||||
|
||||
private void HotReloadSettings()
|
||||
{
|
||||
// NOTE: SettingsChangedHandler skips this method when nothing relevant changed, using
|
||||
// MainWindowSettingsComparer. When you start consuming a new setting below, add it to
|
||||
// that comparer too — otherwise changes to it won't trigger a hot-reload.
|
||||
var settings = App.Current.Services.GetRequiredService<ISettingsService>().Settings;
|
||||
_lastAppliedSettings = settings;
|
||||
|
||||
SetupHotkey(settings);
|
||||
App.Current.Services.GetService<TrayIconService>()!.SetupTrayIcon(settings.ShowSystemTrayIcon);
|
||||
@@ -487,6 +506,87 @@ public sealed partial class MainWindow : WindowEx,
|
||||
private static bool ShouldShowHwndFrame(SettingsModel settings) =>
|
||||
!BuildInfo.IsCiBuild && settings.ShowHwndFrame;
|
||||
|
||||
/// <summary>
|
||||
/// Compares two <see cref="SettingsModel"/> instances by only the settings that
|
||||
/// <see cref="HotReloadSettings"/> (and the methods it calls) actually consume. Any change
|
||||
/// to a setting outside this set is invisible to the host window, so treating such models
|
||||
/// as equal lets <see cref="SettingsChangedHandler"/> skip a redundant hot-reload.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Keep this in sync with the settings read by <see cref="HotReloadSettings"/>,
|
||||
/// <see cref="SetupHotkey"/>, <see cref="ShouldShowHwndFrame"/>, and
|
||||
/// <see cref="HandleExpandCompactOnUiThread"/>.
|
||||
/// </remarks>
|
||||
private sealed class MainWindowSettingsComparer : IEqualityComparer<SettingsModel>
|
||||
{
|
||||
public static MainWindowSettingsComparer Instance { get; } = new();
|
||||
|
||||
public bool Equals(SettingsModel? x, SettingsModel? y)
|
||||
{
|
||||
if (ReferenceEquals(x, y))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
if (x is null || y is null)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
return x.UseLowLevelGlobalHotkey == y.UseLowLevelGlobalHotkey
|
||||
&& x.ShowSystemTrayIcon == y.ShowSystemTrayIcon
|
||||
&& x.IgnoreShortcutWhenFullscreen == y.IgnoreShortcutWhenFullscreen
|
||||
&& x.IgnoreShortcutWhenBusy == y.IgnoreShortcutWhenBusy
|
||||
&& x.AllowBreakthroughShortcut == y.AllowBreakthroughShortcut
|
||||
&& x.AutoGoHomeInterval == y.AutoGoHomeInterval
|
||||
&& x.ShowHwndFrame == y.ShowHwndFrame
|
||||
&& x.CompactMode == y.CompactMode
|
||||
&& x.Hotkey == y.Hotkey // HotkeySettings is a record (value equality)
|
||||
&& CommandHotkeysEqual(x.CommandHotkeys, y.CommandHotkeys);
|
||||
}
|
||||
|
||||
// TopLevelHotkey is a record (value equality); compare element-wise. ImmutableList
|
||||
// itself only implements reference equality, so we can't rely on ==.
|
||||
private static bool CommandHotkeysEqual(ImmutableList<TopLevelHotkey> x, ImmutableList<TopLevelHotkey> y)
|
||||
{
|
||||
if (ReferenceEquals(x, y))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
if (x.Count != y.Count)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
for (var i = 0; i < x.Count; i++)
|
||||
{
|
||||
if (x[i] != y[i])
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
public int GetHashCode(SettingsModel obj)
|
||||
{
|
||||
var hash = default(HashCode);
|
||||
hash.Add(obj.UseLowLevelGlobalHotkey);
|
||||
hash.Add(obj.ShowSystemTrayIcon);
|
||||
hash.Add(obj.IgnoreShortcutWhenFullscreen);
|
||||
hash.Add(obj.IgnoreShortcutWhenBusy);
|
||||
hash.Add(obj.AllowBreakthroughShortcut);
|
||||
hash.Add(obj.AutoGoHomeInterval);
|
||||
hash.Add(obj.ShowHwndFrame);
|
||||
hash.Add(obj.CompactMode);
|
||||
hash.Add(obj.Hotkey);
|
||||
hash.Add(obj.CommandHotkeys.Count);
|
||||
return hash.ToHashCode();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Configures the HWND for the borderless / transparent main-window mode and (when
|
||||
/// the internal debug toggle is enabled) overlays the OS-drawn chrome so the HWND's
|
||||
@@ -562,6 +662,8 @@ public sealed partial class MainWindow : WindowEx,
|
||||
var borderColor = showFrame ? DWMWA_COLOR_DEFAULT : DWMWA_COLOR_NONE;
|
||||
PInvoke.DwmSetWindowAttribute(_hwnd, DWMWINDOWATTRIBUTE.DWMWA_BORDER_COLOR, &borderColor, sizeof(uint));
|
||||
}
|
||||
|
||||
RedrawWindow(_hwnd);
|
||||
}
|
||||
|
||||
private void InitializeBackdropSupport()
|
||||
@@ -626,7 +728,7 @@ public sealed partial class MainWindow : WindowEx,
|
||||
var positionWindowForAnchor = (HWND hwnd) =>
|
||||
{
|
||||
PInvoke.GetWindowRect(hwnd, out var bounds);
|
||||
var swpFlags = SET_WINDOW_POS_FLAGS.SWP_NOSIZE | SET_WINDOW_POS_FLAGS.SWP_NOACTIVATE | SET_WINDOW_POS_FLAGS.SWP_NOZORDER;
|
||||
var swpFlags = SET_WINDOW_POS_FLAGS.SWP_NOSIZE | SET_WINDOW_POS_FLAGS.SWP_NOACTIVATE | SET_WINDOW_POS_FLAGS.SWP_NOZORDER | SET_WINDOW_POS_FLAGS.SWP_FRAMECHANGED;
|
||||
switch (anchorCorner)
|
||||
{
|
||||
case AnchorPoint.TopLeft:
|
||||
@@ -726,11 +828,31 @@ public sealed partial class MainWindow : WindowEx,
|
||||
// topmost status when we hide the window (because we cloak it instead
|
||||
// of hiding it).
|
||||
//
|
||||
// SWP_FRAMECHANGED is load-bearing for the borderless look on a cold
|
||||
// start. Asking for SWP_FRAMECHANGED here re-sends WM_NCCALCSIZE and
|
||||
// forces the NC repaint every time we show, so the frame is gone from
|
||||
// the very first summon.
|
||||
// SWP_FRAMECHANGED re-sends WM_NCCALCSIZE so the OS recomputes the
|
||||
// (zero-width) frame. But on this cloak->show path it doesn't reliably
|
||||
// *repaint* the non-client area, so the WS_THICKFRAME border that was
|
||||
// painted earlier survives on the top/left/right edges (the bottom is
|
||||
// clipped away by the card region). A real resize fixes it because it
|
||||
// forces that NC repaint - so we do the same explicitly below.
|
||||
PInvoke.SetWindowPos(hwnd, HWND.HWND_TOPMOST, 0, 0, 0, 0, SET_WINDOW_POS_FLAGS.SWP_NOMOVE | SET_WINDOW_POS_FLAGS.SWP_NOSIZE | SET_WINDOW_POS_FLAGS.SWP_FRAMECHANGED);
|
||||
|
||||
// Force the non-client frame to actually redraw (RDW_FRAME) and the
|
||||
// client to repaint over wherever it used to be. Without this the stale
|
||||
// border lingers until the user resizes the window.
|
||||
RedrawWindow(hwnd);
|
||||
|
||||
// Treat the overall show/hide lifecycle as the authoritative
|
||||
// visibility transition, not the lower-level cloak/uncloak helpers.
|
||||
SetIsVisibleToUser(true);
|
||||
}
|
||||
|
||||
private static void RedrawWindow(HWND hwnd)
|
||||
{
|
||||
const uint RDW_INVALIDATE = 0x0001;
|
||||
const uint RDW_UPDATENOW = 0x0100;
|
||||
const uint RDW_ALLCHILDREN = 0x0080;
|
||||
const uint RDW_FRAME = 0x0400;
|
||||
_ = RedrawWindow(hwnd, IntPtr.Zero, IntPtr.Zero, RDW_INVALIDATE | RDW_UPDATENOW | RDW_ALLCHILDREN | RDW_FRAME);
|
||||
}
|
||||
|
||||
private static DisplayArea GetScreen(HWND currentHwnd, MonitorBehavior target)
|
||||
@@ -915,6 +1037,10 @@ public sealed partial class MainWindow : WindowEx,
|
||||
// Sure, it's not ideal, but at least it's not visible.
|
||||
}
|
||||
|
||||
// Treat the overall show/hide lifecycle as the authoritative
|
||||
// visibility transition, not the lower-level cloak/uncloak helpers.
|
||||
SetIsVisibleToUser(false);
|
||||
|
||||
WeakReferenceMessenger.Default.Send(new WindowHiddenMessage());
|
||||
|
||||
// Start auto-go-home timer
|
||||
@@ -948,10 +1074,6 @@ public sealed partial class MainWindow : WindowEx,
|
||||
{
|
||||
Logger.LogWarning($"DWM cloaking of the main window failed. HRESULT: {hr.Value}.");
|
||||
}
|
||||
else
|
||||
{
|
||||
IsVisibleToUser = false;
|
||||
}
|
||||
|
||||
wasCloaked = hr.Succeeded;
|
||||
}
|
||||
@@ -965,10 +1087,20 @@ public sealed partial class MainWindow : WindowEx,
|
||||
{
|
||||
BOOL value = false;
|
||||
PInvoke.DwmSetWindowAttribute(_hwnd, DWMWINDOWATTRIBUTE.DWMWA_CLOAK, &value, (uint)sizeof(BOOL));
|
||||
IsVisibleToUser = true;
|
||||
}
|
||||
}
|
||||
|
||||
private void SetIsVisibleToUser(bool isVisibleToUser)
|
||||
{
|
||||
if (IsVisibleToUser == isVisibleToUser)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
IsVisibleToUser = isVisibleToUser;
|
||||
IsVisibleToUserChanged?.Invoke(this, EventArgs.Empty);
|
||||
}
|
||||
|
||||
internal void MainWindow_Closed(object sender, WindowEventArgs args)
|
||||
{
|
||||
var serviceProvider = App.Current.Services;
|
||||
@@ -1187,6 +1319,10 @@ public sealed partial class MainWindow : WindowEx,
|
||||
[return: MarshalAs(UnmanagedType.Bool)]
|
||||
private static extern bool DeleteObject(IntPtr hObject);
|
||||
|
||||
[DllImport("user32.dll", SetLastError = true)]
|
||||
[return: MarshalAs(UnmanagedType.Bool)]
|
||||
private static extern bool RedrawWindow(IntPtr hWnd, IntPtr lprcUpdate, IntPtr hrgnUpdate, uint flags);
|
||||
|
||||
internal void MainWindow_Activated(object sender, WindowActivatedEventArgs args)
|
||||
{
|
||||
if (!_themeServiceInitialized && args.WindowActivationState != WindowActivationState.Deactivated)
|
||||
@@ -1502,6 +1638,7 @@ public sealed partial class MainWindow : WindowEx,
|
||||
// but that's the price to pay for having the HWND not light-dismiss while we're debugging.
|
||||
Cloak();
|
||||
this.Hide();
|
||||
SetIsVisibleToUser(false);
|
||||
WeakReferenceMessenger.Default.Send(new WindowHiddenMessage());
|
||||
|
||||
return;
|
||||
@@ -1552,6 +1689,17 @@ public sealed partial class MainWindow : WindowEx,
|
||||
case PInvoke.WM_NCCALCSIZE when wParam.Value != 0 && _hwndFrameVisible != true:
|
||||
return (LRESULT)0;
|
||||
|
||||
// On every activation change the OS repaints the non-client frame to
|
||||
// flip between the active / inactive caption. With WS_THICKFRAME still
|
||||
// present that paints the OS border back over our borderless window each
|
||||
// time we lose (or gain) focus - which is the frame that reappears when
|
||||
// another window steals focus, and the one that shows up shortly after
|
||||
// startup (the first activation flip). Passing -1 as the update-region
|
||||
// (lParam) to DefWindowProc keeps the correct activation result while
|
||||
// telling it to skip the non-client repaint entirely.
|
||||
case PInvoke.WM_NCACTIVATE when _hwndFrameVisible != true:
|
||||
return PInvoke.CallWindowProc(_originalWndProc, hwnd, uMsg, wParam, new LPARAM(-1));
|
||||
|
||||
case PInvoke.WM_HOTKEY:
|
||||
{
|
||||
var hotkeyIndex = (int)wParam.Value;
|
||||
@@ -1759,17 +1907,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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -105,6 +105,7 @@ WM_ACTIVATE
|
||||
WM_ACTIVATEAPP
|
||||
WA_INACTIVE
|
||||
WM_DISPLAYCHANGE
|
||||
WM_NCACTIVATE
|
||||
WM_SYSCOMMAND
|
||||
WM_SETTINGCHANGE
|
||||
WM_WINDOWPOSCHANGING
|
||||
|
||||
@@ -353,6 +353,7 @@
|
||||
<cpcontrols:SearchBar
|
||||
x:Name="SearchBox"
|
||||
HorizontalAlignment="Stretch"
|
||||
ActiveFocusTargetChanged="SearchBox_ActiveFocusTargetChanged"
|
||||
CurrentPageViewModel="{x:Bind ViewModel.CurrentPage, Mode=OneWay}" />
|
||||
<Grid.Transitions>
|
||||
<TransitionCollection>
|
||||
@@ -601,4 +602,4 @@
|
||||
</VisualStateGroup>
|
||||
</VisualStateManager.VisualStateGroups>
|
||||
</Grid>
|
||||
</Page>
|
||||
</Page>
|
||||
|
||||
@@ -38,6 +38,7 @@ public sealed partial class ShellPage : Microsoft.UI.Xaml.Controls.Page,
|
||||
IRecipient<NavigateBackMessage>,
|
||||
IRecipient<OpenSettingsMessage>,
|
||||
IRecipient<HotkeySummonMessage>,
|
||||
IRecipient<FocusSearchBoxMessage>,
|
||||
IRecipient<ShowDetailsMessage>,
|
||||
IRecipient<HideDetailsMessage>,
|
||||
IRecipient<ClearSearchMessage>,
|
||||
@@ -67,6 +68,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;
|
||||
|
||||
@@ -79,20 +86,49 @@ public sealed partial class ShellPage : Microsoft.UI.Xaml.Controls.Page,
|
||||
// select-all the character the user just typed (which triggered the expand). This
|
||||
// one-shot flag suppresses that select for the expand-driven load.
|
||||
private bool _suppressSelectOnNextLoad;
|
||||
private bool _pendingTopBarFocusRestore;
|
||||
private bool _isDisposed;
|
||||
|
||||
public ShellViewModel ViewModel { get; private set; } = App.Current.Services.GetService<ShellViewModel>()!;
|
||||
|
||||
public event PropertyChangedEventHandler? PropertyChanged;
|
||||
|
||||
public IHostWindow? HostWindow { get; set; }
|
||||
private IHostWindow? _hostWindow;
|
||||
|
||||
public IHostWindow? HostWindow
|
||||
{
|
||||
get => _hostWindow;
|
||||
set
|
||||
{
|
||||
if (ReferenceEquals(_hostWindow, value))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (_hostWindow is not null)
|
||||
{
|
||||
_hostWindow.IsVisibleToUserChanged -= HostWindow_IsVisibleToUserChanged;
|
||||
}
|
||||
|
||||
_hostWindow = value;
|
||||
|
||||
if (_hostWindow is not null)
|
||||
{
|
||||
_hostWindow.IsVisibleToUserChanged += HostWindow_IsVisibleToUserChanged;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public bool ExpandedMode { get; set; }
|
||||
|
||||
// Item keybindings act on the selected item, which is hidden while collapsed — only honor them when expanded.
|
||||
private bool ItemActionsAllowed => !_compactMode || ExpandedMode;
|
||||
|
||||
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();
|
||||
|
||||
@@ -100,6 +136,7 @@ public sealed partial class ShellPage : Microsoft.UI.Xaml.Controls.Page,
|
||||
WeakReferenceMessenger.Default.Register<NavigateBackMessage>(this);
|
||||
WeakReferenceMessenger.Default.Register<OpenSettingsMessage>(this);
|
||||
WeakReferenceMessenger.Default.Register<HotkeySummonMessage>(this);
|
||||
WeakReferenceMessenger.Default.Register<FocusSearchBoxMessage>(this);
|
||||
WeakReferenceMessenger.Default.Register<SettingsWindowClosedMessage>(this);
|
||||
|
||||
WeakReferenceMessenger.Default.Register<ShowDetailsMessage>(this);
|
||||
@@ -119,6 +156,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);
|
||||
@@ -430,6 +472,8 @@ public sealed partial class ShellPage : Microsoft.UI.Xaml.Controls.Page,
|
||||
|
||||
public void Receive(ClearSearchMessage message) => SearchBox.ClearSearch();
|
||||
|
||||
public void Receive(FocusSearchBoxMessage message) => RequestTopBarFocusRestore();
|
||||
|
||||
public void Receive(HotkeySummonMessage message) => _ = DispatcherQueue.TryEnqueue(() => SummonOnUiThread(message));
|
||||
|
||||
public void Receive(SettingsWindowClosedMessage message) => _settingsWindow = null;
|
||||
@@ -543,9 +587,12 @@ public sealed partial class ShellPage : Microsoft.UI.Xaml.Controls.Page,
|
||||
ViewModel.GoHome(withAnimation, focusSearch);
|
||||
}
|
||||
|
||||
if (focusSearch)
|
||||
// Only move focus when the palette is actually on screen. FocusActiveControl uses keyboard
|
||||
// focus so the screen reader announces the box; doing that while the window is hidden
|
||||
// would announce it prematurely.
|
||||
if (focusSearch && HostWindow?.IsVisibleToUser == true)
|
||||
{
|
||||
SearchBox.Focus(Microsoft.UI.Xaml.FocusState.Programmatic);
|
||||
SearchBox.FocusActiveControl();
|
||||
SearchBox.SelectSearch();
|
||||
}
|
||||
}
|
||||
@@ -560,10 +607,11 @@ public sealed partial class ShellPage : Microsoft.UI.Xaml.Controls.Page,
|
||||
GoBack(withAnimation, focusSearch: false);
|
||||
}
|
||||
|
||||
// focus search box, even if we were already home
|
||||
if (focusSearch)
|
||||
// focus search box, even if we were already home (but only when the palette is on
|
||||
// screen - see GoBack; keyboard focus while hidden announces prematurely).
|
||||
if (focusSearch && HostWindow?.IsVisibleToUser == true)
|
||||
{
|
||||
SearchBox.Focus(Microsoft.UI.Xaml.FocusState.Programmatic);
|
||||
SearchBox.FocusActiveControl();
|
||||
SearchBox.SelectSearch();
|
||||
}
|
||||
}
|
||||
@@ -610,6 +658,40 @@ public sealed partial class ShellPage : Microsoft.UI.Xaml.Controls.Page,
|
||||
}
|
||||
}
|
||||
|
||||
private void SearchBox_ActiveFocusTargetChanged(object? sender, EventArgs e)
|
||||
{
|
||||
RequestTopBarFocusRestore();
|
||||
}
|
||||
|
||||
private void HostWindow_IsVisibleToUserChanged(object? sender, EventArgs e)
|
||||
{
|
||||
if (HostWindow?.IsVisibleToUser == true &&
|
||||
_pendingTopBarFocusRestore &&
|
||||
ViewModel.CurrentPage?.HasSearchBox == true)
|
||||
{
|
||||
_pendingTopBarFocusRestore = false;
|
||||
SearchBox.FocusActiveControl();
|
||||
}
|
||||
}
|
||||
|
||||
private void RequestTopBarFocusRestore()
|
||||
{
|
||||
if (ViewModel.CurrentPage?.HasSearchBox != true)
|
||||
{
|
||||
_pendingTopBarFocusRestore = false;
|
||||
return;
|
||||
}
|
||||
|
||||
if (HostWindow?.IsVisibleToUser == true)
|
||||
{
|
||||
_pendingTopBarFocusRestore = false;
|
||||
SearchBox.FocusActiveControl();
|
||||
return;
|
||||
}
|
||||
|
||||
_pendingTopBarFocusRestore = true;
|
||||
}
|
||||
|
||||
private void BackButton_Clicked(object sender, Microsoft.UI.Xaml.RoutedEventArgs e) => WeakReferenceMessenger.Default.Send<NavigateBackMessage>(new());
|
||||
|
||||
private void RootFrame_Navigated(object sender, Microsoft.UI.Xaml.Navigation.NavigationEventArgs e)
|
||||
@@ -674,6 +756,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;
|
||||
}
|
||||
|
||||
@@ -733,7 +820,7 @@ public sealed partial class ShellPage : Microsoft.UI.Xaml.Controls.Page,
|
||||
return;
|
||||
}
|
||||
|
||||
SearchBox.Focus(FocusState.Programmatic);
|
||||
SearchBox.FocusActiveControl();
|
||||
SearchBox.SelectSearch();
|
||||
}
|
||||
else
|
||||
@@ -865,47 +952,74 @@ public sealed partial class ShellPage : Microsoft.UI.Xaml.Controls.Page,
|
||||
case VirtualKey.F when modifiers.OnlyAlt: // Alt+F: toggle filter focus
|
||||
((ShellPage)sender).ToggleFilterFocus();
|
||||
e.Handled = true;
|
||||
break;
|
||||
case VirtualKey.Down when modifiers.None:
|
||||
case VirtualKey.Tab when modifiers.None:
|
||||
// In a collapsed compact palette, Down/Tab reveals the top-level items so the
|
||||
// user can browse and discover them. Only swallow the key when we actually
|
||||
// expand; otherwise let it fall through to normal list navigation / focus move.
|
||||
if (((ShellPage)sender).TryExpandCollapsedCompact())
|
||||
{
|
||||
e.Handled = true;
|
||||
}
|
||||
|
||||
break;
|
||||
default:
|
||||
{
|
||||
// The CommandBar is responsible for handling all the item keybindings,
|
||||
// since the bound context item may need to then show another
|
||||
// context menu
|
||||
TryCommandKeybindingMessage msg = new(modifiers.Ctrl, modifiers.Alt, modifiers.Shift, modifiers.Win, e.Key);
|
||||
WeakReferenceMessenger.Default.Send(msg);
|
||||
e.Handled = msg.Handled;
|
||||
// The CommandBar handles item keybindings; skip them while collapsed so a chord can't hit the hidden selection.
|
||||
if (((ShellPage)sender).ItemActionsAllowed)
|
||||
{
|
||||
TryCommandKeybindingMessage msg = new(modifiers.Ctrl, modifiers.Alt, modifiers.Shift, modifiers.Win, e.Key);
|
||||
WeakReferenceMessenger.Default.Send(msg);
|
||||
e.Handled = msg.Handled;
|
||||
}
|
||||
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static void ShellPage_OnKeyDown(object sender, KeyRoutedEventArgs e)
|
||||
private void ShellPage_OnKeyDown(object sender, KeyRoutedEventArgs e)
|
||||
{
|
||||
var mods = KeyModifiers.GetCurrent();
|
||||
if (mods.Ctrl && e.Key == VirtualKey.Enter)
|
||||
if (ItemActionsAllowed && TryHandleItemAction(e))
|
||||
{
|
||||
// ctrl+enter
|
||||
WeakReferenceMessenger.Default.Send<ActivateSecondaryCommandMessage>();
|
||||
e.Handled = true;
|
||||
return;
|
||||
}
|
||||
else if (e.Key == VirtualKey.Enter)
|
||||
{
|
||||
WeakReferenceMessenger.Default.Send<ActivateSelectedListItemMessage>();
|
||||
e.Handled = true;
|
||||
}
|
||||
else if (mods.Ctrl && e.Key == VirtualKey.K)
|
||||
{
|
||||
// ctrl+k
|
||||
WeakReferenceMessenger.Default.Send<OpenContextMenuMessage>(new OpenContextMenuMessage(null, null, null, ContextMenuFilterLocation.Bottom));
|
||||
e.Handled = true;
|
||||
}
|
||||
else if (e.Key == VirtualKey.Escape)
|
||||
|
||||
if (e.Key == VirtualKey.Escape)
|
||||
{
|
||||
WeakReferenceMessenger.Default.Send<NavigateBackMessage>(new());
|
||||
e.Handled = true;
|
||||
}
|
||||
}
|
||||
|
||||
private static bool TryHandleItemAction(KeyRoutedEventArgs e)
|
||||
{
|
||||
var mods = KeyModifiers.GetCurrent();
|
||||
switch (e.Key)
|
||||
{
|
||||
// Ctrl+Enter
|
||||
case VirtualKey.Enter when mods.OnlyCtrl:
|
||||
WeakReferenceMessenger.Default.Send<ActivateSecondaryCommandMessage>();
|
||||
break;
|
||||
|
||||
// Enter
|
||||
case VirtualKey.Enter when mods.None:
|
||||
WeakReferenceMessenger.Default.Send<ActivateSelectedListItemMessage>();
|
||||
break;
|
||||
|
||||
// Ctrl+K
|
||||
case VirtualKey.K when mods.OnlyCtrl:
|
||||
WeakReferenceMessenger.Default.Send<OpenContextMenuMessage>(new(null, null, null, ContextMenuFilterLocation.Bottom));
|
||||
break;
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
|
||||
e.Handled = true;
|
||||
return true;
|
||||
}
|
||||
|
||||
private void ShellPage_OnPointerPressed(object sender, PointerRoutedEventArgs e)
|
||||
{
|
||||
try
|
||||
@@ -936,6 +1050,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;
|
||||
@@ -953,6 +1085,23 @@ public sealed partial class ShellPage : Microsoft.UI.Xaml.Controls.Page,
|
||||
PropertyChanged?.Invoke(this, new(nameof(ExpandedMode)));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Expands a collapsed compact palette on demand (via Down/Tab) so the user can browse the
|
||||
/// top-level items. Returns <see langword="false"/> and does nothing unless compact mode is
|
||||
/// on and the palette is currently collapsed, letting the caller keep the key's normal
|
||||
/// meaning (list navigation / focus traversal) in every other case.
|
||||
/// </summary>
|
||||
private bool TryExpandCollapsedCompact()
|
||||
{
|
||||
if (!_compactMode || ExpandedMode)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
HandleExpandCompactOnUiThread(true);
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Forces the shell into its compact (collapsed) layout and flushes layout so the host can
|
||||
/// read the resulting card height. Only has an effect when compact mode is enabled.
|
||||
@@ -979,6 +1128,13 @@ public sealed partial class ShellPage : Microsoft.UI.Xaml.Controls.Page,
|
||||
|
||||
_isDisposed = true;
|
||||
WeakReferenceMessenger.Default.UnregisterAll(this);
|
||||
_settingsService.SettingsChanged -= OnSettingsChanged;
|
||||
|
||||
if (_hostWindow is not null)
|
||||
{
|
||||
_hostWindow.IsVisibleToUserChanged -= HostWindow_IsVisibleToUserChanged;
|
||||
_hostWindow = null;
|
||||
}
|
||||
|
||||
_focusAfterLoadedCts?.Cancel();
|
||||
_focusAfterLoadedCts?.Dispose();
|
||||
|
||||
@@ -9,6 +9,11 @@ namespace Microsoft.CmdPal.UI.Services;
|
||||
/// </summary>
|
||||
public interface IHostWindow
|
||||
{
|
||||
/// <summary>
|
||||
/// Raised when <see cref="IsVisibleToUser"/> changes.
|
||||
/// </summary>
|
||||
event EventHandler? IsVisibleToUserChanged;
|
||||
|
||||
/// <summary>
|
||||
/// Gets a value indicating whether the window is visible to the user, taking account not only window visibility but also cloaking.
|
||||
/// </summary>
|
||||
|
||||
@@ -13,8 +13,8 @@ namespace Microsoft.CmdPal.Ext.TimeDate.UnitTests;
|
||||
[TestClass]
|
||||
public class AvailableResultsListTests
|
||||
{
|
||||
private CultureInfo originalCulture;
|
||||
private CultureInfo originalUiCulture;
|
||||
private CultureInfo originalCulture = null!;
|
||||
private CultureInfo originalUiCulture = null!;
|
||||
|
||||
[TestInitialize]
|
||||
public void Setup()
|
||||
|
||||
@@ -12,8 +12,8 @@ namespace Microsoft.CmdPal.Ext.TimeDate.UnitTests;
|
||||
[TestClass]
|
||||
public class FallbackTimeDateItemTests
|
||||
{
|
||||
private CultureInfo originalCulture;
|
||||
private CultureInfo originalUiCulture;
|
||||
private CultureInfo originalCulture = null!;
|
||||
private CultureInfo originalUiCulture = null!;
|
||||
|
||||
[TestInitialize]
|
||||
public void Setup()
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
<PropertyGroup>
|
||||
<IsPackable>false</IsPackable>
|
||||
<IsTestProject>true</IsTestProject>
|
||||
<Nullable>enable</Nullable>
|
||||
<RootNamespace>Microsoft.CmdPal.Ext.TimeDate.UnitTests</RootNamespace>
|
||||
<OutputPath>$(SolutionDir)$(Platform)\$(Configuration)\WinUI3Apps\CmdPal\tests\</OutputPath>
|
||||
<AppendTargetFrameworkToOutputPath>false</AppendTargetFrameworkToOutputPath>
|
||||
|
||||
@@ -0,0 +1,160 @@
|
||||
// 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 Microsoft.CmdPal.Ext.TimeDate;
|
||||
using Microsoft.VisualStudio.TestTools.UnitTesting;
|
||||
|
||||
namespace Microsoft.CmdPal.Ext.TimeDate.UnitTests;
|
||||
|
||||
[TestClass]
|
||||
[System.Diagnostics.CodeAnalysis.SuppressMessage("IDisposable", "CA1001:Types that own disposable fields should be disposable", Justification = "Disposed in TestCleanup")]
|
||||
public class NowDockBandTests
|
||||
{
|
||||
private static readonly DateTime FixedTime = new DateTime(2025, 7, 1, 14, 5, 32);
|
||||
|
||||
private CultureInfo _originalCulture = null!;
|
||||
private CultureInfo _originalUiCulture = null!;
|
||||
private NowDockBand? _band;
|
||||
|
||||
[TestInitialize]
|
||||
public void Setup()
|
||||
{
|
||||
_originalCulture = CultureInfo.CurrentCulture;
|
||||
_originalUiCulture = CultureInfo.CurrentUICulture;
|
||||
CultureInfo.CurrentCulture = new CultureInfo("en-US", false);
|
||||
CultureInfo.CurrentUICulture = new CultureInfo("en-US", false);
|
||||
}
|
||||
|
||||
[TestCleanup]
|
||||
public void Cleanup()
|
||||
{
|
||||
_band?.Dispose();
|
||||
_band = null;
|
||||
CultureInfo.CurrentCulture = _originalCulture;
|
||||
CultureInfo.CurrentUICulture = _originalUiCulture;
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void Constructor_TitleIsSetImmediately()
|
||||
{
|
||||
_band = new NowDockBand(clock: () => FixedTime);
|
||||
|
||||
Assert.AreEqual("2:05 PM", _band.Title);
|
||||
Assert.IsFalse(string.IsNullOrEmpty(_band.Subtitle));
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void UpdateText_LongTimeFormat_TitleContainsSeconds()
|
||||
{
|
||||
_band = new NowDockBand(timeWithSeconds: true, clock: () => FixedTime);
|
||||
|
||||
_band.UpdateText();
|
||||
|
||||
Assert.AreEqual("2:05:32 PM", _band.Title);
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void UpdateText_ShortDateFormat_SubtitleIsShortDate()
|
||||
{
|
||||
_band = new NowDockBand(clock: () => FixedTime);
|
||||
|
||||
_band.UpdateText();
|
||||
|
||||
Assert.AreEqual("7/1/2025", _band.Subtitle);
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void UpdateText_FiresOnUpdatedCallback()
|
||||
{
|
||||
var callbackFired = false;
|
||||
_band = new NowDockBand(onUpdated: () => callbackFired = true, clock: () => FixedTime);
|
||||
|
||||
callbackFired = false; // reset — constructor already fired it once during synchronous UpdateText()
|
||||
|
||||
_band.UpdateText();
|
||||
|
||||
Assert.IsTrue(callbackFired);
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void UpdateText_CallbackFiredAfterAssignments()
|
||||
{
|
||||
var titleAtCallback = string.Empty;
|
||||
_band = new NowDockBand(
|
||||
onUpdated: () => titleAtCallback = _band?.Title ?? string.Empty,
|
||||
clock: () => FixedTime);
|
||||
|
||||
titleAtCallback = string.Empty; // reset after construction callback
|
||||
|
||||
_band.UpdateText();
|
||||
|
||||
Assert.IsFalse(string.IsNullOrEmpty(titleAtCallback), "Title should be assigned before callback fires");
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void UpdateText_CopyCommandsUpdated()
|
||||
{
|
||||
_band = new NowDockBand(clock: () => FixedTime);
|
||||
|
||||
_band.UpdateText();
|
||||
|
||||
Assert.AreEqual(_band.Title, _band.CopyTimeCommand.Text);
|
||||
Assert.AreEqual(_band.Subtitle, _band.CopyDateCommand.Text);
|
||||
}
|
||||
|
||||
[DataTestMethod]
|
||||
[DataRow("de-DE")]
|
||||
[DataRow("fr-FR")]
|
||||
[DataRow("ar-SA")]
|
||||
public void UpdateText_CultureSmoke_TitleNonEmpty(string cultureName)
|
||||
{
|
||||
// Culture MUST be set before construction — constructor calls UpdateText() synchronously
|
||||
CultureInfo.CurrentCulture = new CultureInfo(cultureName, false);
|
||||
CultureInfo.CurrentUICulture = new CultureInfo(cultureName, false);
|
||||
|
||||
_band = new NowDockBand(clock: () => FixedTime);
|
||||
|
||||
Assert.IsFalse(string.IsNullOrEmpty(_band.Title), $"Title should be non-empty for culture '{cultureName}'");
|
||||
Assert.IsFalse(string.IsNullOrEmpty(_band.Subtitle), $"Subtitle should be non-empty for culture '{cultureName}'");
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void UpdateSettings_EnablingSeconds_TitleIncludesSeconds()
|
||||
{
|
||||
_band = new NowDockBand(timeWithSeconds: false, clock: () => FixedTime);
|
||||
|
||||
Assert.AreEqual("2:05 PM", _band.Title, "Precondition: seconds hidden by default");
|
||||
|
||||
_band.UpdateSettings(timeWithSeconds: true);
|
||||
|
||||
Assert.AreEqual("2:05:32 PM", _band.Title, "Title should update live to include seconds");
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void UpdateSettings_DisablingSeconds_TitleDropsSeconds()
|
||||
{
|
||||
_band = new NowDockBand(timeWithSeconds: true, clock: () => FixedTime);
|
||||
|
||||
Assert.AreEqual("2:05:32 PM", _band.Title, "Precondition: seconds shown");
|
||||
|
||||
_band.UpdateSettings(timeWithSeconds: false);
|
||||
|
||||
Assert.AreEqual("2:05 PM", _band.Title, "Title should update live to drop seconds");
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void UpdateSettings_NoChange_FiresNoCallback()
|
||||
{
|
||||
var callbackCount = 0;
|
||||
_band = new NowDockBand(timeWithSeconds: false, onUpdated: () => callbackCount++, clock: () => FixedTime);
|
||||
|
||||
callbackCount = 0; // reset after construction callback
|
||||
|
||||
_band.UpdateSettings(timeWithSeconds: false);
|
||||
|
||||
Assert.AreEqual(0, callbackCount, "A no-op settings change should not refresh the band");
|
||||
}
|
||||
}
|
||||
@@ -15,8 +15,8 @@ namespace Microsoft.CmdPal.Ext.TimeDate.UnitTests;
|
||||
[TestClass]
|
||||
public class QueryTests : CommandPaletteUnitTestBase
|
||||
{
|
||||
private CultureInfo originalCulture;
|
||||
private CultureInfo originalUiCulture;
|
||||
private CultureInfo originalCulture = null!;
|
||||
private CultureInfo originalUiCulture = null!;
|
||||
|
||||
[TestInitialize]
|
||||
public void Setup()
|
||||
@@ -166,7 +166,7 @@ public class QueryTests : CommandPaletteUnitTestBase
|
||||
Assert.IsTrue(results.Length > 0, $"Query '{query}' should return at least one result");
|
||||
|
||||
var firstItem = results.FirstOrDefault();
|
||||
Assert.IsTrue(firstItem.Title.StartsWith("Error: Invalid input", StringComparison.CurrentCulture), $"Query '{query}' should return an error result for invalid input");
|
||||
Assert.IsTrue(firstItem!.Title.StartsWith("Error: Invalid input", StringComparison.CurrentCulture), $"Query '{query}' should return an error result for invalid input");
|
||||
}
|
||||
|
||||
[DataTestMethod]
|
||||
@@ -201,7 +201,7 @@ public class QueryTests : CommandPaletteUnitTestBase
|
||||
// Assert
|
||||
Assert.IsNotNull(results);
|
||||
var firstResult = results.FirstOrDefault();
|
||||
Assert.IsTrue(firstResult.Subtitle.StartsWith(expectedSubtitle, StringComparison.CurrentCulture), $"Could not find result with subtitle starting with '{expectedSubtitle}' for query '{query}'");
|
||||
Assert.IsTrue(firstResult!.Subtitle.StartsWith(expectedSubtitle, StringComparison.CurrentCulture), $"Could not find result with subtitle starting with '{expectedSubtitle}' for query '{query}'");
|
||||
}
|
||||
|
||||
[DataTestMethod]
|
||||
@@ -218,7 +218,7 @@ public class QueryTests : CommandPaletteUnitTestBase
|
||||
// Assert
|
||||
Assert.IsNotNull(resultsList);
|
||||
var firstResult = resultsList.FirstOrDefault();
|
||||
Assert.IsTrue(firstResult.Title.Contains(expectedResult, StringComparison.CurrentCulture), $"Delimiter query '{query}' result not match {expectedResult} current result {firstResult.Title}");
|
||||
Assert.IsTrue(firstResult!.Title.Contains(expectedResult, StringComparison.CurrentCulture), $"Delimiter query '{query}' result not match {expectedResult} current result {firstResult.Title}");
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
|
||||
@@ -12,8 +12,8 @@ namespace Microsoft.CmdPal.Ext.TimeDate.UnitTests;
|
||||
[TestClass]
|
||||
public class ResultHelperTests
|
||||
{
|
||||
private CultureInfo originalCulture;
|
||||
private CultureInfo originalUiCulture;
|
||||
private CultureInfo originalCulture = null!;
|
||||
private CultureInfo originalUiCulture = null!;
|
||||
|
||||
[TestInitialize]
|
||||
public void Setup()
|
||||
@@ -41,6 +41,8 @@ public class ResultHelperTests
|
||||
{
|
||||
Label = "Test Label",
|
||||
Value = "Test Value",
|
||||
AlternativeSearchTag = string.Empty,
|
||||
IconType = ResultIconType.Time,
|
||||
};
|
||||
|
||||
// Act
|
||||
@@ -55,10 +57,10 @@ public class ResultHelperTests
|
||||
[TestMethod]
|
||||
public void ResultHelper_CreateListItem_HandlesNullInput()
|
||||
{
|
||||
AvailableResult availableResult = null;
|
||||
AvailableResult? availableResult = null;
|
||||
|
||||
// Act & Assert
|
||||
Assert.ThrowsException<System.NullReferenceException>(() => availableResult.ToListItem());
|
||||
Assert.ThrowsException<System.NullReferenceException>(() => availableResult!.ToListItem());
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
@@ -69,6 +71,8 @@ public class ResultHelperTests
|
||||
{
|
||||
Label = string.Empty,
|
||||
Value = string.Empty,
|
||||
AlternativeSearchTag = string.Empty,
|
||||
IconType = ResultIconType.Time,
|
||||
};
|
||||
|
||||
// Act
|
||||
@@ -88,6 +92,7 @@ public class ResultHelperTests
|
||||
{
|
||||
Label = "Test Label",
|
||||
Value = "Test Value",
|
||||
AlternativeSearchTag = string.Empty,
|
||||
IconType = ResultIconType.Date,
|
||||
};
|
||||
|
||||
@@ -110,6 +115,8 @@ public class ResultHelperTests
|
||||
{
|
||||
Label = longText,
|
||||
Value = longText,
|
||||
AlternativeSearchTag = string.Empty,
|
||||
IconType = ResultIconType.Time,
|
||||
};
|
||||
|
||||
// Act
|
||||
@@ -130,6 +137,8 @@ public class ResultHelperTests
|
||||
{
|
||||
Label = specialText,
|
||||
Value = specialText,
|
||||
AlternativeSearchTag = string.Empty,
|
||||
IconType = ResultIconType.Time,
|
||||
};
|
||||
|
||||
// Act
|
||||
|
||||
@@ -13,6 +13,7 @@ public class Settings : ISettingsInterface
|
||||
private readonly int firstDayOfWeek;
|
||||
private readonly bool enableFallbackItems;
|
||||
private readonly bool timeWithSecond;
|
||||
private readonly bool dockClockWithSecond;
|
||||
private readonly bool dateWithWeekday;
|
||||
private readonly List<string> customFormats;
|
||||
|
||||
@@ -21,13 +22,15 @@ public class Settings : ISettingsInterface
|
||||
int firstDayOfWeek = -1,
|
||||
bool enableFallbackItems = true,
|
||||
bool timeWithSecond = false,
|
||||
bool dockClockWithSecond = false,
|
||||
bool dateWithWeekday = false,
|
||||
List<string> customFormats = null)
|
||||
List<string>? customFormats = null)
|
||||
{
|
||||
this.firstWeekOfYear = firstWeekOfYear;
|
||||
this.firstDayOfWeek = firstDayOfWeek;
|
||||
this.enableFallbackItems = enableFallbackItems;
|
||||
this.timeWithSecond = timeWithSecond;
|
||||
this.dockClockWithSecond = dockClockWithSecond;
|
||||
this.dateWithWeekday = dateWithWeekday;
|
||||
this.customFormats = customFormats ?? new List<string>();
|
||||
}
|
||||
@@ -40,6 +43,8 @@ public class Settings : ISettingsInterface
|
||||
|
||||
public bool TimeWithSecond => timeWithSecond;
|
||||
|
||||
public bool DockClockWithSecond => dockClockWithSecond;
|
||||
|
||||
public bool DateWithWeekday => dateWithWeekday;
|
||||
|
||||
public List<string> CustomFormats => customFormats;
|
||||
|
||||
@@ -12,8 +12,8 @@ namespace Microsoft.CmdPal.Ext.TimeDate.UnitTests;
|
||||
[TestClass]
|
||||
public class StringParserTests
|
||||
{
|
||||
private CultureInfo originalCulture;
|
||||
private CultureInfo originalUiCulture;
|
||||
private CultureInfo originalCulture = null!;
|
||||
private CultureInfo originalUiCulture = null!;
|
||||
|
||||
[TestInitialize]
|
||||
public void Setup()
|
||||
|
||||
@@ -12,8 +12,8 @@ namespace Microsoft.CmdPal.Ext.TimeDate.UnitTests;
|
||||
[TestClass]
|
||||
public class TimeAndDateHelperTests
|
||||
{
|
||||
private CultureInfo originalCulture;
|
||||
private CultureInfo originalUiCulture;
|
||||
private CultureInfo originalCulture = null!;
|
||||
private CultureInfo originalUiCulture = null!;
|
||||
|
||||
[TestInitialize]
|
||||
public void Setup()
|
||||
|
||||
@@ -15,8 +15,8 @@ namespace Microsoft.CmdPal.Ext.TimeDate.UnitTests;
|
||||
[TestClass]
|
||||
public class TimeDateCalculatorTests
|
||||
{
|
||||
private CultureInfo originalCulture;
|
||||
private CultureInfo originalUiCulture;
|
||||
private CultureInfo originalCulture = null!;
|
||||
private CultureInfo originalUiCulture = null!;
|
||||
|
||||
[TestInitialize]
|
||||
public void Setup()
|
||||
@@ -69,7 +69,7 @@ public class TimeDateCalculatorTests
|
||||
var settings = new SettingsManager();
|
||||
|
||||
// Act
|
||||
var results = TimeDateCalculator.ExecuteSearch(settings, null);
|
||||
var results = TimeDateCalculator.ExecuteSearch(settings, null!);
|
||||
|
||||
// Assert
|
||||
Assert.IsNotNull(results);
|
||||
|
||||
@@ -11,8 +11,8 @@ namespace Microsoft.CmdPal.Ext.TimeDate.UnitTests
|
||||
[TestClass]
|
||||
public class TimeDateCommandsProviderTests
|
||||
{
|
||||
private CultureInfo originalCulture;
|
||||
private CultureInfo originalUiCulture;
|
||||
private CultureInfo originalCulture = null!;
|
||||
private CultureInfo originalUiCulture = null!;
|
||||
|
||||
[TestInitialize]
|
||||
public void Setup()
|
||||
@@ -90,5 +90,27 @@ namespace Microsoft.CmdPal.Ext.TimeDate.UnitTests
|
||||
// Assert
|
||||
Assert.IsFalse(string.IsNullOrEmpty(displayName));
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void GetDockBands_ReturnsNonEmptyArray()
|
||||
{
|
||||
var provider = new TimeDateCommandsProvider();
|
||||
|
||||
var bands = provider.GetDockBands();
|
||||
|
||||
Assert.IsTrue(bands.Length > 0, "GetDockBands should return at least one item");
|
||||
Assert.IsNotNull(bands[0], "First dock band should not be null");
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void GetDockBands_NotificationCenterBandDoesNotSetDockIcon()
|
||||
{
|
||||
var provider = new TimeDateCommandsProvider();
|
||||
|
||||
var bands = provider.GetDockBands();
|
||||
|
||||
Assert.IsTrue(bands.Length > 1, "Expected notification center band to be present");
|
||||
Assert.IsNull(bands[1].Icon, "Notification center band should not set a dock icon");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,137 @@
|
||||
// Copyright (c) Microsoft Corporation
|
||||
// The Microsoft Corporation licenses this file to you under the MIT license.
|
||||
// See the LICENSE file in the project root for more information.
|
||||
|
||||
using System;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.CommandPalette.Extensions;
|
||||
using Microsoft.CommandPalette.Extensions.Toolkit;
|
||||
using Microsoft.VisualStudio.TestTools.UnitTesting;
|
||||
|
||||
namespace Microsoft.CmdPal.UI.ViewModels.UnitTests;
|
||||
|
||||
[TestClass]
|
||||
public partial class DetailsViewModelTests
|
||||
{
|
||||
private sealed class TestPageContext : IPageContext
|
||||
{
|
||||
public TaskScheduler Scheduler => TaskScheduler.Default;
|
||||
|
||||
public ICommandProviderContext ProviderContext => CommandProviderContext.Empty;
|
||||
|
||||
public void ShowException(Exception ex, string? extensionHint = null)
|
||||
{
|
||||
throw new AssertFailedException($"Unexpected exception from view model: {ex}");
|
||||
}
|
||||
}
|
||||
|
||||
private static WeakReference<IPageContext> CreatePageContext()
|
||||
{
|
||||
var ctx = new TestPageContext();
|
||||
return new WeakReference<IPageContext>(ctx);
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void InitializeProperties_SetsBodyAndTitle()
|
||||
{
|
||||
var details = new Details { Title = "Hello", Body = "World" };
|
||||
var vm = new DetailsViewModel(details, CreatePageContext());
|
||||
|
||||
vm.InitializeProperties();
|
||||
|
||||
Assert.AreEqual("Hello", vm.Title);
|
||||
Assert.AreEqual("World", vm.Body);
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void PropChanged_Body_UpdatesViewModelProperty()
|
||||
{
|
||||
var details = new Details { Title = "Initial", Body = "Initial body" };
|
||||
var vm = new DetailsViewModel(details, CreatePageContext());
|
||||
vm.InitializeProperties();
|
||||
|
||||
// Act — toolkit Details raises PropChanged synchronously on set
|
||||
details.Body = "Updated body";
|
||||
|
||||
// The property value is set synchronously in FetchProperty;
|
||||
// ApplyPendingUpdates flushes the PropertyChanged notification queue.
|
||||
vm.ApplyPendingUpdates();
|
||||
|
||||
Assert.AreEqual("Updated body", vm.Body);
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void PropChanged_Title_UpdatesViewModelProperty()
|
||||
{
|
||||
var details = new Details { Title = "Original", Body = "Text" };
|
||||
var vm = new DetailsViewModel(details, CreatePageContext());
|
||||
vm.InitializeProperties();
|
||||
|
||||
details.Title = "New Title";
|
||||
vm.ApplyPendingUpdates();
|
||||
|
||||
Assert.AreEqual("New Title", vm.Title);
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void PropChanged_Metadata_RebuildsList()
|
||||
{
|
||||
var details = new Details
|
||||
{
|
||||
Title = "T",
|
||||
Body = "B",
|
||||
Metadata = [],
|
||||
};
|
||||
var vm = new DetailsViewModel(details, CreatePageContext());
|
||||
vm.InitializeProperties();
|
||||
Assert.AreEqual(0, vm.Metadata.Count);
|
||||
|
||||
// Act — update metadata with a link element
|
||||
details.Metadata = [new DetailsElement { Key = "link", Data = new DetailsLink("http://example.com", "Example") }];
|
||||
vm.ApplyPendingUpdates();
|
||||
|
||||
Assert.AreEqual(1, vm.Metadata.Count);
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void Cleanup_UnsubscribesFromPropChanged()
|
||||
{
|
||||
var details = new Details { Title = "T", Body = "Original" };
|
||||
var vm = new DetailsViewModel(details, CreatePageContext());
|
||||
vm.InitializeProperties();
|
||||
|
||||
// Act — cleanup unsubscribes, then change should not propagate
|
||||
vm.SafeCleanup();
|
||||
details.Body = "After cleanup";
|
||||
|
||||
Assert.AreEqual("Original", vm.Body);
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void NonObservableDetails_DoesNotThrow()
|
||||
{
|
||||
// IDetails that does NOT implement INotifyPropChanged
|
||||
var details = new NonObservableDetails();
|
||||
var vm = new DetailsViewModel(details, CreatePageContext());
|
||||
|
||||
// Should not throw — just doesn't subscribe to anything
|
||||
vm.InitializeProperties();
|
||||
|
||||
Assert.AreEqual("Static Title", vm.Title);
|
||||
Assert.AreEqual("Static Body", vm.Body);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A minimal IDetails that does NOT implement INotifyPropChanged.
|
||||
/// </summary>
|
||||
private sealed partial class NonObservableDetails : IDetails
|
||||
{
|
||||
public IIconInfo HeroImage => new IconInfo(string.Empty);
|
||||
|
||||
public string Title => "Static Title";
|
||||
|
||||
public string Body => "Static Body";
|
||||
|
||||
public IDetailsElement[] Metadata => [];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
// 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.Immutable;
|
||||
using Microsoft.CmdPal.UI.ViewModels.Settings;
|
||||
using Microsoft.VisualStudio.TestTools.UnitTesting;
|
||||
|
||||
namespace Microsoft.CmdPal.UI.ViewModels.UnitTests;
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that <see cref="DockSettings"/> (and its nested <see cref="DockMonitorConfig"/>)
|
||||
/// compare by <em>content</em> rather than by list reference. Dock consumers guard their
|
||||
/// (expensive) reloads with <c>_settings == args.DockSettings</c>, so two settings that differ
|
||||
/// only by having freshly-rebuilt band lists — e.g. after loading from disk — must compare
|
||||
/// equal, otherwise the reload fires needlessly.
|
||||
/// </summary>
|
||||
[TestClass]
|
||||
public class DockSettingsEqualityTests
|
||||
{
|
||||
private static DockBandSettings Band(string providerId, string commandId, bool? showTitles = null) =>
|
||||
new() { ProviderId = providerId, CommandId = commandId, ShowTitles = showTitles };
|
||||
|
||||
[TestMethod]
|
||||
public void Equal_WhenBandListsHaveSameContentButDifferentInstances()
|
||||
{
|
||||
var a = new DockSettings
|
||||
{
|
||||
StartBands = ImmutableList.Create(Band("p", "home"), Band("w", "winget", showTitles: false)),
|
||||
};
|
||||
var b = new DockSettings
|
||||
{
|
||||
StartBands = ImmutableList.Create(Band("p", "home"), Band("w", "winget", showTitles: false)),
|
||||
};
|
||||
|
||||
// Distinct ImmutableList instances — would be reference-unequal without EquatableList.
|
||||
Assert.AreNotSame(a.StartBands, b.StartBands);
|
||||
Assert.AreEqual(a, b);
|
||||
Assert.IsTrue(a == b);
|
||||
Assert.AreEqual(a.GetHashCode(), b.GetHashCode());
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void NotEqual_WhenABandDiffers()
|
||||
{
|
||||
var a = new DockSettings { StartBands = ImmutableList.Create(Band("p", "home")) };
|
||||
var b = new DockSettings { StartBands = ImmutableList.Create(Band("p", "settings")) };
|
||||
|
||||
Assert.AreNotEqual(a, b);
|
||||
Assert.IsTrue(a != b);
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void NotEqual_WhenBandOrderDiffers()
|
||||
{
|
||||
var a = new DockSettings { StartBands = ImmutableList.Create(Band("p", "a"), Band("p", "b")) };
|
||||
var b = new DockSettings { StartBands = ImmutableList.Create(Band("p", "b"), Band("p", "a")) };
|
||||
|
||||
Assert.AreNotEqual(a, b);
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void Equal_WhenMonitorConfigsHaveSameContentIncludingPerMonitorBands()
|
||||
{
|
||||
var a = new DockSettings
|
||||
{
|
||||
MonitorConfigs = ImmutableList.Create(new DockMonitorConfig
|
||||
{
|
||||
MonitorDeviceId = @"\\.\DISPLAY1",
|
||||
IsCustomized = true,
|
||||
StartBands = ImmutableList.Create(Band("p", "home")),
|
||||
}),
|
||||
};
|
||||
var b = new DockSettings
|
||||
{
|
||||
MonitorConfigs = ImmutableList.Create(new DockMonitorConfig
|
||||
{
|
||||
MonitorDeviceId = @"\\.\DISPLAY1",
|
||||
IsCustomized = true,
|
||||
StartBands = ImmutableList.Create(Band("p", "home")),
|
||||
}),
|
||||
};
|
||||
|
||||
Assert.AreEqual(a, b);
|
||||
Assert.AreEqual(a.GetHashCode(), b.GetHashCode());
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void NotEqual_WhenPerMonitorBandsIsNullVersusEmpty()
|
||||
{
|
||||
// null means "inherit global bands"; an explicit empty list does not. The two must
|
||||
// stay distinguishable so a monitor can't silently switch between the two meanings.
|
||||
var inherit = new DockMonitorConfig { MonitorDeviceId = "m", StartBands = null };
|
||||
var explicitEmpty = new DockMonitorConfig { MonitorDeviceId = "m", StartBands = ImmutableList<DockBandSettings>.Empty };
|
||||
|
||||
Assert.AreNotEqual(inherit, explicitEmpty);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
// 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.Immutable;
|
||||
using Microsoft.CmdPal.UI.ViewModels.Settings;
|
||||
using Microsoft.VisualStudio.TestTools.UnitTesting;
|
||||
|
||||
namespace Microsoft.CmdPal.UI.ViewModels.UnitTests;
|
||||
|
||||
/// <summary>
|
||||
/// Tests for <see cref="EquatableList{T}"/> — the value-equality wrapper used as the backing
|
||||
/// field for record list properties so record equality compares list contents, not references.
|
||||
/// </summary>
|
||||
[TestClass]
|
||||
public class EquatableListTests
|
||||
{
|
||||
private static EquatableList<string> Of(params string[] items) =>
|
||||
new(ImmutableList.Create(items));
|
||||
|
||||
[TestMethod]
|
||||
public void Equal_WhenSameContentDifferentInstances()
|
||||
{
|
||||
var a = Of("x", "y", "z");
|
||||
var b = Of("x", "y", "z");
|
||||
|
||||
Assert.AreNotSame(a.List, b.List);
|
||||
Assert.IsTrue(a.Equals(b));
|
||||
Assert.AreEqual(a, b);
|
||||
Assert.AreEqual(a.GetHashCode(), b.GetHashCode());
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void Equal_WhenSameUnderlyingReference()
|
||||
{
|
||||
var shared = ImmutableList.Create("a", "b");
|
||||
var a = new EquatableList<string>(shared);
|
||||
var b = new EquatableList<string>(shared);
|
||||
|
||||
Assert.IsTrue(a.Equals(b));
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void NotEqual_WhenAnElementDiffers()
|
||||
{
|
||||
Assert.AreNotEqual(Of("a", "b"), Of("a", "c"));
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void NotEqual_WhenOrderDiffers()
|
||||
{
|
||||
Assert.AreNotEqual(Of("a", "b"), Of("b", "a"));
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void NotEqual_WhenCountsDiffer()
|
||||
{
|
||||
Assert.AreNotEqual(Of("a"), Of("a", "b"));
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void EmptyLists_AreEqual()
|
||||
{
|
||||
var fromEmpty = new EquatableList<string>(ImmutableList<string>.Empty);
|
||||
var fromNull = new EquatableList<string>(null);
|
||||
|
||||
Assert.IsTrue(fromEmpty.Equals(fromNull));
|
||||
Assert.AreEqual(fromEmpty.GetHashCode(), fromNull.GetHashCode());
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void Default_ExposesEmptyListAndEqualsEmpty()
|
||||
{
|
||||
EquatableList<string> defaulted = default;
|
||||
|
||||
// A default(struct) has a null inner list; List must still surface an empty list
|
||||
// (never null) and compare equal to an explicitly-empty instance.
|
||||
Assert.IsNotNull(defaulted.List);
|
||||
Assert.AreEqual(0, defaulted.List.Count);
|
||||
Assert.IsTrue(defaulted.Equals(new EquatableList<string>(ImmutableList<string>.Empty)));
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void List_IsNeverNull()
|
||||
{
|
||||
Assert.IsNotNull(new EquatableList<string>(null).List);
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void EqualsObject_ReturnsFalse_ForOtherTypes()
|
||||
{
|
||||
object other = "not an equatable list";
|
||||
|
||||
Assert.IsFalse(Of("a").Equals(other));
|
||||
Assert.IsFalse(Of("a").Equals(null!));
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void EqualsObject_ReturnsTrue_ForBoxedEqualValue()
|
||||
{
|
||||
object boxed = Of("a", "b");
|
||||
|
||||
Assert.IsTrue(Of("a", "b").Equals(boxed));
|
||||
}
|
||||
}
|
||||
@@ -19,6 +19,16 @@ public partial class PerformanceMonitorCommandsProvider : CommandProvider
|
||||
public const string ProviderLoadGuardBlockId = ProviderIdValue + ".ProviderLoad";
|
||||
public const string PageIdValue = "com.microsoft.cmdpal.performanceWidget";
|
||||
|
||||
private static readonly PerformanceMetricKind?[] BandMetrics =
|
||||
[
|
||||
null,
|
||||
PerformanceMetricKind.Cpu,
|
||||
PerformanceMetricKind.Memory,
|
||||
PerformanceMetricKind.Network,
|
||||
PerformanceMetricKind.Gpu,
|
||||
PerformanceMetricKind.Battery,
|
||||
];
|
||||
|
||||
internal static ProviderCrashSentinel CrashSentinel { get; } = new(ProviderIdValue);
|
||||
|
||||
private readonly Lock _stateLock = new();
|
||||
@@ -109,15 +119,26 @@ public partial class PerformanceMonitorCommandsProvider : CommandProvider
|
||||
DisposeActivePages();
|
||||
|
||||
var page = new PerformanceMonitorDisabledPage(this);
|
||||
var band = new PerformanceMonitorDisabledPage(this);
|
||||
_bands =
|
||||
[
|
||||
new CommandItem(band)
|
||||
|
||||
// Mirror the compact form of the real bands, and keep texts short
|
||||
var disabledValue = Resources.GetResource("Performance_Monitor_Disabled_Band_Title");
|
||||
var bands = new List<ICommandItem>(BandMetrics.Length);
|
||||
foreach (var metric in BandMetrics)
|
||||
{
|
||||
var icon = GetBandIcon(metric);
|
||||
var item = new ListItem(page)
|
||||
{
|
||||
Title = Resources.GetResource("Performance_Monitor_Disabled_Band_Title"),
|
||||
Subtitle = DisplayName,
|
||||
},
|
||||
];
|
||||
Title = disabledValue,
|
||||
Subtitle = GetBandSubtitle(metric),
|
||||
Icon = icon,
|
||||
};
|
||||
bands.Add(new WrappedDockItem([item], PerformanceWidgetsPage.GetBandId(metric), GetBandDisplayTitle(metric))
|
||||
{
|
||||
Icon = icon,
|
||||
});
|
||||
}
|
||||
|
||||
_bands = bands.ToArray();
|
||||
_commands =
|
||||
[
|
||||
new CommandItem(page)
|
||||
@@ -129,6 +150,45 @@ public partial class PerformanceMonitorCommandsProvider : CommandProvider
|
||||
_softDisabled = true;
|
||||
}
|
||||
|
||||
private string GetBandDisplayTitle(PerformanceMetricKind? metric)
|
||||
{
|
||||
return metric switch
|
||||
{
|
||||
PerformanceMetricKind.Cpu => Resources.GetResource("CPU_Usage_Title"),
|
||||
PerformanceMetricKind.Memory => Resources.GetResource("Memory_Usage_Title"),
|
||||
PerformanceMetricKind.Network => Resources.GetResource("Network_Usage_Title"),
|
||||
PerformanceMetricKind.Gpu => Resources.GetResource("GPU_Usage_Title"),
|
||||
PerformanceMetricKind.Battery => Resources.GetResource("Battery_Usage_Title"),
|
||||
_ => DisplayName,
|
||||
};
|
||||
}
|
||||
|
||||
private string GetBandSubtitle(PerformanceMetricKind? metric)
|
||||
{
|
||||
return metric switch
|
||||
{
|
||||
PerformanceMetricKind.Cpu => Resources.GetResource("CPU_Usage_Subtitle"),
|
||||
PerformanceMetricKind.Memory => Resources.GetResource("Memory_Usage_Subtitle"),
|
||||
PerformanceMetricKind.Network => Resources.GetResource("Network_Usage_Subtitle"),
|
||||
PerformanceMetricKind.Gpu => Resources.GetResource("GPU_Usage_Subtitle"),
|
||||
PerformanceMetricKind.Battery => Resources.GetResource("Battery_Usage_Subtitle"),
|
||||
_ => string.Empty,
|
||||
};
|
||||
}
|
||||
|
||||
private static IconInfo GetBandIcon(PerformanceMetricKind? metric)
|
||||
{
|
||||
return metric switch
|
||||
{
|
||||
PerformanceMetricKind.Cpu => Icons.CpuIcon,
|
||||
PerformanceMetricKind.Memory => Icons.MemoryIcon,
|
||||
PerformanceMetricKind.Network => Icons.NetworkIcon,
|
||||
PerformanceMetricKind.Gpu => Icons.GpuIcon,
|
||||
PerformanceMetricKind.Battery => Icons.BatteryIcon,
|
||||
_ => Icons.PerformanceMonitorIcon,
|
||||
};
|
||||
}
|
||||
|
||||
private void SetEnabledState()
|
||||
{
|
||||
DisposeActivePages();
|
||||
|
||||
@@ -12,9 +12,9 @@ internal sealed partial class PerformanceMonitorDisabledPage : ContentPage
|
||||
{
|
||||
private readonly MarkdownContent _content;
|
||||
|
||||
public PerformanceMonitorDisabledPage(PerformanceMonitorCommandsProvider provider)
|
||||
public PerformanceMonitorDisabledPage(PerformanceMonitorCommandsProvider provider, string? id = null)
|
||||
{
|
||||
Id = PerformanceMonitorCommandsProvider.PageIdValue;
|
||||
Id = id ?? PerformanceMonitorCommandsProvider.PageIdValue;
|
||||
Name = Resources.GetResource("Performance_Monitor_Disabled_Title");
|
||||
Title = Resources.GetResource("Performance_Monitor_Title");
|
||||
Icon = Icons.PerformanceMonitorIcon;
|
||||
|
||||
@@ -93,7 +93,7 @@ internal sealed partial class PerformanceWidgetsPage : OnLoadStaticListPage, IDi
|
||||
{
|
||||
_isBandPage = isBandPage;
|
||||
_singleMetric = singleMetric;
|
||||
_id = singleMetric is null ? BaseId : $"{BaseId}.{GetMetricSuffix(singleMetric.Value)}";
|
||||
_id = GetBandId(singleMetric);
|
||||
|
||||
if (IncludesMetric(PerformanceMetricKind.Cpu))
|
||||
{
|
||||
@@ -287,6 +287,11 @@ internal sealed partial class PerformanceWidgetsPage : OnLoadStaticListPage, IDi
|
||||
_batteryPage?.Dispose();
|
||||
}
|
||||
|
||||
internal static string GetBandId(PerformanceMetricKind? metric)
|
||||
{
|
||||
return metric is null ? BaseId : $"{BaseId}.{GetMetricSuffix(metric.Value)}";
|
||||
}
|
||||
|
||||
private bool IncludesMetric(PerformanceMetricKind metric)
|
||||
{
|
||||
return _singleMetric is null || _singleMetric == metric;
|
||||
|
||||
@@ -199,7 +199,7 @@
|
||||
<value>Temporarily disabled after repeated startup crashes</value>
|
||||
</data>
|
||||
<data name="Performance_Monitor_Disabled_Band_Title" xml:space="preserve">
|
||||
<value>Perf disabled</value>
|
||||
<value>Disabled</value>
|
||||
</data>
|
||||
<data name="Performance_Monitor_Disabled_Body" xml:space="preserve">
|
||||
<value>Performance monitor was temporarily disabled after repeated crashes while initializing or reading performance counters.
|
||||
|
||||
@@ -29,16 +29,16 @@ internal sealed partial class FallbackTimeDateItem : FallbackCommandItem
|
||||
|
||||
_validOptions = new(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
Resources.ResourceManager.GetString("Microsoft_plugin_timedate_SearchTagDate", CultureInfo.CurrentCulture),
|
||||
Resources.ResourceManager.GetString("Microsoft_plugin_timedate_SearchTagDateNow", CultureInfo.CurrentCulture),
|
||||
Resources.ResourceManager.GetString("Microsoft_plugin_timedate_SearchTagDate", CultureInfo.CurrentCulture)!,
|
||||
Resources.ResourceManager.GetString("Microsoft_plugin_timedate_SearchTagDateNow", CultureInfo.CurrentCulture)!,
|
||||
|
||||
Resources.ResourceManager.GetString("Microsoft_plugin_timedate_SearchTagTime", CultureInfo.CurrentCulture),
|
||||
Resources.ResourceManager.GetString("Microsoft_plugin_timedate_SearchTagTimeNow", CultureInfo.CurrentCulture),
|
||||
Resources.ResourceManager.GetString("Microsoft_plugin_timedate_SearchTagTime", CultureInfo.CurrentCulture)!,
|
||||
Resources.ResourceManager.GetString("Microsoft_plugin_timedate_SearchTagTimeNow", CultureInfo.CurrentCulture)!,
|
||||
|
||||
Resources.ResourceManager.GetString("Microsoft_plugin_timedate_SearchTagFormat", CultureInfo.CurrentCulture),
|
||||
Resources.ResourceManager.GetString("Microsoft_plugin_timedate_SearchTagFormatNow", CultureInfo.CurrentCulture),
|
||||
Resources.ResourceManager.GetString("Microsoft_plugin_timedate_SearchTagFormat", CultureInfo.CurrentCulture)!,
|
||||
Resources.ResourceManager.GetString("Microsoft_plugin_timedate_SearchTagFormatNow", CultureInfo.CurrentCulture)!,
|
||||
|
||||
Resources.ResourceManager.GetString("Microsoft_plugin_timedate_SearchTagWeek", CultureInfo.CurrentCulture),
|
||||
Resources.ResourceManager.GetString("Microsoft_plugin_timedate_SearchTagWeek", CultureInfo.CurrentCulture)!,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -53,7 +53,7 @@ internal sealed partial class FallbackTimeDateItem : FallbackCommandItem
|
||||
}
|
||||
|
||||
var availableResults = AvailableResultsList.GetList(false, _settingsManager, timestamp: _timestamp);
|
||||
ListItem result = null;
|
||||
ListItem? result = null;
|
||||
var maxScore = 0;
|
||||
|
||||
foreach (var f in availableResults)
|
||||
|
||||
@@ -10,22 +10,22 @@ internal sealed class AvailableResult
|
||||
/// <summary>
|
||||
/// Gets or sets the time/date value
|
||||
/// </summary>
|
||||
internal string Value { get; set; }
|
||||
internal required string Value { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the text used for the subtitle and as search term
|
||||
/// </summary>
|
||||
internal string Label { get; set; }
|
||||
internal required string Label { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets an alternative search tag that will be evaluated if label doesn't match. For example we like to show the era on searches for 'year' too.
|
||||
/// </summary>
|
||||
internal string AlternativeSearchTag { get; set; }
|
||||
internal required string AlternativeSearchTag { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets a value indicating the type of result
|
||||
/// </summary>
|
||||
internal ResultIconType IconType { get; set; }
|
||||
internal required ResultIconType IconType { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets a value to show additional error details
|
||||
@@ -37,7 +37,7 @@ internal sealed class AvailableResult
|
||||
/// </summary>
|
||||
/// <param name="theme">Theme</param>
|
||||
/// <returns>Path</returns>
|
||||
public IconInfo GetIconInfo()
|
||||
public IconInfo? GetIconInfo()
|
||||
{
|
||||
return IconType switch
|
||||
{
|
||||
|
||||
@@ -20,6 +20,8 @@ public interface ISettingsInterface
|
||||
|
||||
public bool TimeWithSecond { get; }
|
||||
|
||||
public bool DockClockWithSecond { get; }
|
||||
|
||||
public bool DateWithWeekday { get; }
|
||||
|
||||
public List<string> CustomFormats { get; }
|
||||
|
||||
@@ -17,7 +17,7 @@ internal static class ResultHelper
|
||||
/// <param name="stringId">Id of the string. (Example: `MyString` for `MyString` and `MyStringNow`)</param>
|
||||
/// <param name="stringIdNow">Optional string id for now case</param>
|
||||
/// <returns>The string from the resource file, or <see cref="string.Empty"/> otherwise.</returns>
|
||||
internal static string SelectStringFromResources(bool isSystemTimeDate, string stringId, string stringIdNow = default)
|
||||
internal static string SelectStringFromResources(bool isSystemTimeDate, string stringId, string? stringIdNow = default)
|
||||
{
|
||||
return !isSystemTimeDate
|
||||
? Resources.ResourceManager.GetString(stringId, CultureInfo.CurrentUICulture) ?? string.Empty
|
||||
|
||||
@@ -87,6 +87,12 @@ public class SettingsManager : JsonSettingsManager, ISettingsInterface
|
||||
Resources.Microsoft_plugin_timedate_SettingTimeWithSeconds_Description,
|
||||
false); // TODO -- double check default value
|
||||
|
||||
private readonly ToggleSetting _dockClockWithSeconds = new(
|
||||
Namespaced(nameof(DockClockWithSecond)),
|
||||
Resources.Microsoft_plugin_timedate_SettingDockClockWithSeconds,
|
||||
Resources.Microsoft_plugin_timedate_SettingDockClockWithSeconds_Description,
|
||||
false);
|
||||
|
||||
private readonly ToggleSetting _dateWithWeekday = new(
|
||||
Namespaced(nameof(DateWithWeekday)),
|
||||
Resources.Microsoft_plugin_timedate_SettingDateWithWeekday,
|
||||
@@ -143,9 +149,11 @@ public class SettingsManager : JsonSettingsManager, ISettingsInterface
|
||||
|
||||
public bool TimeWithSecond => _timeWithSeconds.Value;
|
||||
|
||||
public bool DockClockWithSecond => _dockClockWithSeconds.Value;
|
||||
|
||||
public bool DateWithWeekday => _dateWithWeekday.Value;
|
||||
|
||||
public List<string> CustomFormats => _customFormats.Value.Split(TEXTBOXNEWLINE).ToList();
|
||||
public List<string> CustomFormats => (_customFormats.Value ?? string.Empty).Split(TEXTBOXNEWLINE).ToList();
|
||||
|
||||
internal static string SettingsJsonPath()
|
||||
{
|
||||
@@ -161,6 +169,7 @@ public class SettingsManager : JsonSettingsManager, ISettingsInterface
|
||||
|
||||
Settings.Add(_enableFallbackItems);
|
||||
Settings.Add(_timeWithSeconds);
|
||||
Settings.Add(_dockClockWithSeconds);
|
||||
Settings.Add(_dateWithWeekday);
|
||||
Settings.Add(_firstWeekOfYear);
|
||||
Settings.Add(_firstDayOfWeek);
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
|
||||
<PropertyGroup>
|
||||
<RootNamespace>Microsoft.CmdPal.Ext.TimeDate</RootNamespace>
|
||||
<Nullable>enable</Nullable>
|
||||
<OutputPath>$(SolutionDir)$(Platform)\$(Configuration)\WinUI3Apps\CmdPal</OutputPath>
|
||||
<AppendTargetFrameworkToOutputPath>false</AppendTargetFrameworkToOutputPath>
|
||||
<AppendRuntimeIdentifierToOutputPath>false</AppendRuntimeIdentifierToOutputPath>
|
||||
|
||||
@@ -0,0 +1,134 @@
|
||||
// Copyright (c) Microsoft Corporation
|
||||
// The Microsoft Corporation licenses this file to you under the MIT license.
|
||||
// See the LICENSE file in the project root for more information.
|
||||
|
||||
using System;
|
||||
using System.Globalization;
|
||||
using Microsoft.CmdPal.Ext.TimeDate.Helpers;
|
||||
using Microsoft.CommandPalette.Extensions.Toolkit;
|
||||
|
||||
namespace Microsoft.CmdPal.Ext.TimeDate;
|
||||
|
||||
internal sealed partial class NowDockBand : ListItem, IDisposable
|
||||
{
|
||||
private static readonly TimeSpan PerSecondUpdateInterval = TimeSpan.FromSeconds(1);
|
||||
private static readonly TimeSpan PerMinuteUpdateInterval = TimeSpan.FromMinutes(1);
|
||||
|
||||
private readonly System.Timers.Timer _timer;
|
||||
private readonly Action? _onUpdated;
|
||||
private readonly Func<DateTime> _clock;
|
||||
private bool _timeWithSeconds;
|
||||
|
||||
private CopyTextCommand _copyTimeCommand;
|
||||
private CopyTextCommand _copyDateCommand;
|
||||
|
||||
internal CopyTextCommand CopyTimeCommand => _copyTimeCommand;
|
||||
|
||||
internal CopyTextCommand CopyDateCommand => _copyDateCommand;
|
||||
|
||||
internal NowDockBand(bool timeWithSeconds = false, Action? onUpdated = null, Func<DateTime>? clock = null)
|
||||
{
|
||||
_timeWithSeconds = timeWithSeconds;
|
||||
_onUpdated = onUpdated;
|
||||
_clock = clock ?? (() => DateTime.Now);
|
||||
|
||||
Command = new OpenUrlCommand("ms-actioncenter:")
|
||||
{
|
||||
Id = "com.microsoft.cmdpal.timedate.dockBand",
|
||||
Name = Resources.timedate_show_notification_center_command_name,
|
||||
Result = CommandResult.Dismiss(),
|
||||
};
|
||||
_copyTimeCommand = new CopyTextCommand(string.Empty) { Name = Resources.timedate_copy_time_command_name };
|
||||
_copyDateCommand = new CopyTextCommand(string.Empty) { Name = Resources.timedate_copy_date_command_name };
|
||||
MoreCommands =
|
||||
[
|
||||
new CommandContextItem(_copyTimeCommand),
|
||||
new CommandContextItem(_copyDateCommand),
|
||||
];
|
||||
|
||||
UpdateText();
|
||||
|
||||
_timer = new System.Timers.Timer() { AutoReset = true };
|
||||
ConfigureTimer();
|
||||
}
|
||||
|
||||
// Reads the current "show seconds" preference and, if it changed, reconfigures
|
||||
// the timer cadence and refreshes the displayed text. Safe to call at any time
|
||||
// (e.g. from a settings-changed handler) so the dock clock stays in sync without
|
||||
// requiring the app to restart.
|
||||
internal void UpdateSettings(bool timeWithSeconds)
|
||||
{
|
||||
if (_timeWithSeconds == timeWithSeconds)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_timeWithSeconds = timeWithSeconds;
|
||||
ConfigureTimer();
|
||||
UpdateText();
|
||||
}
|
||||
|
||||
private void ConfigureTimer()
|
||||
{
|
||||
_timer.Stop();
|
||||
_timer.Elapsed -= Timer_Elapsed;
|
||||
_timer.Elapsed -= Timer_ElapsedFirstMinuteTick;
|
||||
|
||||
if (_timeWithSeconds)
|
||||
{
|
||||
_timer.Interval = PerSecondUpdateInterval.TotalMilliseconds;
|
||||
_timer.Elapsed += Timer_Elapsed;
|
||||
}
|
||||
else
|
||||
{
|
||||
// Align the first tick to the next minute boundary so the clock flips
|
||||
// exactly when the system clock does, then fall back to a per-minute cadence.
|
||||
var now = _clock();
|
||||
_timer.Interval = PerMinuteUpdateInterval.TotalMilliseconds - ((now.Second * 1000) + now.Millisecond);
|
||||
_timer.Elapsed += Timer_ElapsedFirstMinuteTick;
|
||||
}
|
||||
|
||||
_timer.Start();
|
||||
}
|
||||
|
||||
private void Timer_ElapsedFirstMinuteTick(object? sender, System.Timers.ElapsedEventArgs e)
|
||||
{
|
||||
if (sender is System.Timers.Timer timer)
|
||||
{
|
||||
timer.Interval = PerMinuteUpdateInterval.TotalMilliseconds;
|
||||
timer.Elapsed -= Timer_ElapsedFirstMinuteTick;
|
||||
timer.Elapsed += Timer_Elapsed;
|
||||
}
|
||||
|
||||
Timer_Elapsed(sender, e);
|
||||
}
|
||||
|
||||
private void Timer_Elapsed(object? sender, System.Timers.ElapsedEventArgs e)
|
||||
{
|
||||
UpdateText();
|
||||
}
|
||||
|
||||
internal void UpdateText()
|
||||
{
|
||||
var now = _clock();
|
||||
var timeString = now.ToString(
|
||||
TimeAndDateHelper.GetStringFormat(FormatStringType.Time, _timeWithSeconds, false),
|
||||
CultureInfo.CurrentCulture);
|
||||
var dateString = now.ToString(
|
||||
TimeAndDateHelper.GetStringFormat(FormatStringType.Date, false, false),
|
||||
CultureInfo.CurrentCulture);
|
||||
|
||||
Title = timeString;
|
||||
Subtitle = dateString;
|
||||
_copyTimeCommand.Text = timeString;
|
||||
_copyDateCommand.Text = dateString;
|
||||
|
||||
_onUpdated?.Invoke(); // Must remain last — ViewModel reads Title/Subtitle via GetItems() on callback
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
_timer.Stop();
|
||||
_timer.Dispose();
|
||||
}
|
||||
}
|
||||
@@ -834,6 +834,24 @@ namespace Microsoft.CmdPal.Ext.TimeDate {
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Looks up a localized string similar to Show seconds on the dock clock.
|
||||
/// </summary>
|
||||
public static string Microsoft_plugin_timedate_SettingDockClockWithSeconds {
|
||||
get {
|
||||
return ResourceManager.GetString("Microsoft_plugin_timedate_SettingDockClockWithSeconds", resourceCulture);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Looks up a localized string similar to This setting applies to the clock shown on the Command Palette dock..
|
||||
/// </summary>
|
||||
public static string Microsoft_plugin_timedate_SettingDockClockWithSeconds_Description {
|
||||
get {
|
||||
return ResourceManager.GetString("Microsoft_plugin_timedate_SettingDockClockWithSeconds_Description", resourceCulture);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Looks up a localized string similar to Select for more details..
|
||||
/// </summary>
|
||||
|
||||
@@ -267,6 +267,12 @@
|
||||
<data name="Microsoft_plugin_timedate_SettingTimeWithSeconds_Description" xml:space="preserve">
|
||||
<value>This setting applies to the 'Time' and 'Now' result.</value>
|
||||
</data>
|
||||
<data name="Microsoft_plugin_timedate_SettingDockClockWithSeconds" xml:space="preserve">
|
||||
<value>Show seconds on the dock clock</value>
|
||||
</data>
|
||||
<data name="Microsoft_plugin_timedate_SettingDockClockWithSeconds_Description" xml:space="preserve">
|
||||
<value>This setting applies to the clock shown on the Command Palette dock.</value>
|
||||
</data>
|
||||
<data name="Microsoft_plugin_timedate_SubTitleNote" xml:space="preserve">
|
||||
<value>Select or press Ctrl+C to copy</value>
|
||||
<comment>'Ctrl+C' is a shortcut</comment>
|
||||
|
||||
@@ -20,8 +20,11 @@ public sealed partial class TimeDateCommandsProvider : CommandProvider
|
||||
private static readonly TimeDateExtensionPage _timeDateExtensionPage = new(_settingsManager);
|
||||
private readonly FallbackTimeDateItem _fallbackTimeDateItem = new(_settingsManager);
|
||||
|
||||
private readonly ListItem _bandItem;
|
||||
private readonly ListItem _notificationCenterBandItem;
|
||||
private readonly WrappedDockItem _bandItem;
|
||||
private readonly WrappedDockItem _notificationCenterBandItem;
|
||||
|
||||
// Keep a reference to the band so we can dispose it when the provider is disposed.
|
||||
private NowDockBand? _nowDockBand;
|
||||
|
||||
public TimeDateCommandsProvider()
|
||||
{
|
||||
@@ -37,8 +40,40 @@ public sealed partial class TimeDateCommandsProvider : CommandProvider
|
||||
Icon = _timeDateExtensionPage.Icon;
|
||||
Settings = _settingsManager.Settings;
|
||||
|
||||
_bandItem = new NowDockBand();
|
||||
_notificationCenterBandItem = new NotificationCenterDockBand();
|
||||
WrappedDockItem? wrappedBand = null;
|
||||
|
||||
// During NowDockBand construction, UpdateText() runs synchronously.
|
||||
// At that point wrappedBand is still null so the callback is a no-op.
|
||||
// On subsequent timer ticks, wrappedBand is non-null and SetItems fires
|
||||
// RaiseItemsChanged - the framework marshals to the UI thread in
|
||||
// DockBandViewModel.InitializeFromList via DoOnUiThread.
|
||||
_nowDockBand = new NowDockBand(_settingsManager.DockClockWithSecond, onUpdated: () =>
|
||||
{
|
||||
if (wrappedBand is not null)
|
||||
{
|
||||
wrappedBand.Items = [_nowDockBand!];
|
||||
}
|
||||
});
|
||||
|
||||
// Re-read the dock clock preference whenever settings change so the band updates
|
||||
// live (no app restart required). The band ignores no-op changes internally.
|
||||
_settingsManager.Settings.SettingsChanged += OnSettingsChanged;
|
||||
|
||||
wrappedBand = new WrappedDockItem(
|
||||
[_nowDockBand],
|
||||
"com.microsoft.cmdpal.timedate.dockBand",
|
||||
Resources.Microsoft_plugin_timedate_dock_band_title)
|
||||
{
|
||||
Icon = Icons.TimeDateExtIcon,
|
||||
};
|
||||
|
||||
_bandItem = wrappedBand;
|
||||
|
||||
var notificationCenterBand = new NotificationCenterDockBand();
|
||||
_notificationCenterBandItem = new WrappedDockItem(
|
||||
[notificationCenterBand],
|
||||
"com.microsoft.cmdpal.timedate.notificationCenterBand",
|
||||
Resources.timedate_notification_center_band_title);
|
||||
}
|
||||
|
||||
private string GetTranslatedPluginDescription()
|
||||
@@ -56,97 +91,26 @@ public sealed partial class TimeDateCommandsProvider : CommandProvider
|
||||
|
||||
public override ICommandItem[] GetDockBands()
|
||||
{
|
||||
var clockBand = new WrappedDockItem(
|
||||
[_bandItem],
|
||||
"com.microsoft.cmdpal.timedate.dockBand",
|
||||
Resources.Microsoft_plugin_timedate_dock_band_title)
|
||||
{
|
||||
Icon = Icons.TimeDateExtIcon,
|
||||
};
|
||||
return [_bandItem, _notificationCenterBandItem];
|
||||
}
|
||||
|
||||
var notificationBand = new WrappedDockItem(
|
||||
[_notificationCenterBandItem],
|
||||
"com.microsoft.cmdpal.timedate.notificationCenterBand",
|
||||
Resources.timedate_notification_center_band_title)
|
||||
{
|
||||
Icon = Icons.NotificationCenterIcon,
|
||||
};
|
||||
private void OnSettingsChanged(object sender, Settings args)
|
||||
{
|
||||
_nowDockBand?.UpdateSettings(_settingsManager.DockClockWithSecond);
|
||||
}
|
||||
|
||||
return new ICommandItem[] { clockBand, notificationBand };
|
||||
public override void Dispose()
|
||||
{
|
||||
_settingsManager.Settings.SettingsChanged -= OnSettingsChanged;
|
||||
_nowDockBand?.Dispose();
|
||||
_nowDockBand = null;
|
||||
GC.SuppressFinalize(this);
|
||||
base.Dispose();
|
||||
}
|
||||
}
|
||||
|
||||
#pragma warning disable SA1402 // File may only contain a single type
|
||||
|
||||
internal sealed partial class NowDockBand : ListItem
|
||||
{
|
||||
private static readonly TimeSpan UpdateInterval = TimeSpan.FromSeconds(60);
|
||||
private CopyTextCommand _copyTimeCommand;
|
||||
private CopyTextCommand _copyDateCommand;
|
||||
|
||||
public NowDockBand()
|
||||
{
|
||||
// Open Notification Center on click
|
||||
Command = new OpenUrlCommand("ms-actioncenter:") { Id = "com.microsoft.cmdpal.timedate.dockBand", Name = Resources.timedate_show_notification_center_command_name, Result = CommandResult.Dismiss(), Icon = null };
|
||||
_copyTimeCommand = new CopyTextCommand(string.Empty) { Name = Resources.timedate_copy_time_command_name };
|
||||
_copyDateCommand = new CopyTextCommand(string.Empty) { Name = Resources.timedate_copy_date_command_name };
|
||||
MoreCommands = [
|
||||
new CommandContextItem(_copyTimeCommand),
|
||||
new CommandContextItem(_copyDateCommand)
|
||||
];
|
||||
UpdateText();
|
||||
|
||||
// Create a timer to update the time every minute
|
||||
System.Timers.Timer timer = new(UpdateInterval.TotalMilliseconds);
|
||||
|
||||
// but we want it to tick on the minute, so calculate the initial delay
|
||||
var now = DateTime.Now;
|
||||
timer.Interval = UpdateInterval.TotalMilliseconds - ((now.Second * 1000) + now.Millisecond);
|
||||
|
||||
// then after the first tick, set it to 60 seconds
|
||||
timer.Elapsed += Timer_ElapsedFirst;
|
||||
timer.Start();
|
||||
}
|
||||
|
||||
private void Timer_ElapsedFirst(object sender, System.Timers.ElapsedEventArgs e)
|
||||
{
|
||||
// After the first tick, set the interval to 60 seconds
|
||||
if (sender is System.Timers.Timer timer)
|
||||
{
|
||||
timer.Interval = UpdateInterval.TotalMilliseconds;
|
||||
timer.Elapsed -= Timer_ElapsedFirst;
|
||||
timer.Elapsed += Timer_Elapsed;
|
||||
|
||||
// Still call the callback, so that we update the clock
|
||||
Timer_Elapsed(sender, e);
|
||||
}
|
||||
}
|
||||
|
||||
private void Timer_Elapsed(object sender, System.Timers.ElapsedEventArgs e)
|
||||
{
|
||||
UpdateText();
|
||||
}
|
||||
|
||||
private void UpdateText()
|
||||
{
|
||||
var timeExtended = false; // timeLongFormat ?? settings.TimeWithSecond;
|
||||
var dateExtended = false; // dateLongFormat ?? settings.DateWithWeekday;
|
||||
var dateTimeNow = DateTime.Now;
|
||||
|
||||
var timeString = dateTimeNow.ToString(
|
||||
TimeAndDateHelper.GetStringFormat(FormatStringType.Time, timeExtended, dateExtended),
|
||||
CultureInfo.CurrentCulture);
|
||||
var dateString = dateTimeNow.ToString(
|
||||
TimeAndDateHelper.GetStringFormat(FormatStringType.Date, timeExtended, dateExtended),
|
||||
CultureInfo.CurrentCulture);
|
||||
|
||||
Title = timeString;
|
||||
Subtitle = dateString;
|
||||
|
||||
_copyDateCommand.Text = dateString;
|
||||
_copyTimeCommand.Text = timeString;
|
||||
}
|
||||
}
|
||||
|
||||
internal sealed partial class NotificationCenterDockBand : ListItem
|
||||
{
|
||||
public NotificationCenterDockBand()
|
||||
|
||||
@@ -0,0 +1,103 @@
|
||||
// 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.Timers;
|
||||
using Microsoft.CommandPalette.Extensions;
|
||||
using Microsoft.CommandPalette.Extensions.Toolkit;
|
||||
|
||||
#nullable enable
|
||||
|
||||
namespace SamplePagesExtension;
|
||||
|
||||
internal sealed partial class SampleLiveDetailsPage : ListPage, IDisposable
|
||||
{
|
||||
private readonly Details _clockDetails = new()
|
||||
{
|
||||
Title = "Current Time",
|
||||
Body = "Loading...",
|
||||
};
|
||||
|
||||
private readonly Details _counterDetails = new()
|
||||
{
|
||||
Title = "Count: 0",
|
||||
Body = "Elapsed: 0 seconds",
|
||||
};
|
||||
|
||||
private readonly Details _staticDetails = new()
|
||||
{
|
||||
Title = "Static Details",
|
||||
Body = "This item does not update. Select the items above to see live updates in the details pane.",
|
||||
};
|
||||
|
||||
private readonly ListItem[] _items;
|
||||
private Timer? _timer;
|
||||
private int _counter;
|
||||
private bool _disposed;
|
||||
|
||||
public SampleLiveDetailsPage()
|
||||
{
|
||||
Icon = new IconInfo("\uE916"); // Refresh
|
||||
Name = Title = "Live Updating Details";
|
||||
ShowDetails = true;
|
||||
|
||||
_items = [
|
||||
new ListItem(new NoOpCommand())
|
||||
{
|
||||
Title = "Live Clock",
|
||||
Subtitle = "Details pane shows current time, updating every second",
|
||||
Details = _clockDetails,
|
||||
},
|
||||
new ListItem(new NoOpCommand())
|
||||
{
|
||||
Title = "Counter",
|
||||
Subtitle = "Details pane increments a counter every second",
|
||||
Details = _counterDetails,
|
||||
},
|
||||
new ListItem(new NoOpCommand())
|
||||
{
|
||||
Title = "Static Item",
|
||||
Subtitle = "This item's details do not change",
|
||||
Details = _staticDetails,
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
public override IListItem[] GetItems()
|
||||
{
|
||||
if (_timer is null)
|
||||
{
|
||||
_timer = new Timer(1000);
|
||||
_timer.Elapsed += Timer_Elapsed;
|
||||
_timer.AutoReset = true;
|
||||
_timer.Enabled = true;
|
||||
}
|
||||
|
||||
return _items;
|
||||
}
|
||||
|
||||
private void Timer_Elapsed(object? sender, ElapsedEventArgs e)
|
||||
{
|
||||
_counter++;
|
||||
|
||||
// Updating Details properties fires INotifyPropChanged automatically
|
||||
// (Details extends BaseObservable). DetailsViewModel picks up the change
|
||||
// live without requiring the user to reselect the item.
|
||||
_clockDetails.Body = DateTime.Now.ToString("HH:mm:ss", CultureInfo.CurrentCulture);
|
||||
|
||||
_counterDetails.Title = $"Count: {_counter}";
|
||||
_counterDetails.Body = $"Elapsed: {_counter} second{(_counter == 1 ? string.Empty : "s")}";
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
if (!_disposed)
|
||||
{
|
||||
_timer?.Dispose();
|
||||
_timer = null;
|
||||
_disposed = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -29,6 +29,11 @@ public partial class SamplesListPage : ListPage
|
||||
Title = "List Page With Details",
|
||||
Subtitle = "A list of items, each with additional details to display",
|
||||
},
|
||||
new ListItem(new SampleLiveDetailsPage())
|
||||
{
|
||||
Title = "Live Updating Details",
|
||||
Subtitle = "Details pane updates in real time without reselecting",
|
||||
},
|
||||
new ListItem(new SectionsIndexPage())
|
||||
{
|
||||
Title = "List Pages With Sections",
|
||||
|
||||
@@ -1,283 +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 Peek.Common.Helpers;
|
||||
|
||||
namespace Peek.Common.UnitTests
|
||||
{
|
||||
[TestClass]
|
||||
public class MathHelperTests
|
||||
{
|
||||
/// <summary>
|
||||
/// Product code: MathHelper.Modulo(int, int)
|
||||
/// What: Verifies standard modulo for positive numbers (7 mod 3 = 1)
|
||||
/// Why: Baseline correctness — ensures the positive path matches C# % operator
|
||||
/// </summary>
|
||||
[TestMethod]
|
||||
public void Modulo_PositiveNumbers_ShouldReturnStandardModulo()
|
||||
{
|
||||
Assert.AreEqual(1, MathHelper.Modulo(7, 3));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Product code: MathHelper.Modulo(int, int)
|
||||
/// What: Verifies that 0 mod n = 0
|
||||
/// Why: Zero dividend is a common boundary — must not throw or return non-zero
|
||||
/// </summary>
|
||||
[TestMethod]
|
||||
public void Modulo_ZeroDividend_ShouldReturnZero()
|
||||
{
|
||||
Assert.AreEqual(0, MathHelper.Modulo(0, 5));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Product code: MathHelper.Modulo(int, int)
|
||||
/// What: Verifies that exact division yields zero remainder
|
||||
/// Why: Guards against off-by-one in the modulo formula
|
||||
/// </summary>
|
||||
[TestMethod]
|
||||
public void Modulo_ExactDivision_ShouldReturnZero()
|
||||
{
|
||||
Assert.AreEqual(0, MathHelper.Modulo(6, 3));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Product code: MathHelper.Modulo(int, int) — negative dividend path
|
||||
/// What: Verifies that -1 mod 3 = 2 (mathematical modulo, not C# remainder)
|
||||
/// Why: C# % returns -1 for negative dividends; Modulo wraps to positive range
|
||||
/// </summary>
|
||||
[TestMethod]
|
||||
public void Modulo_NegativeDividend_ShouldReturnPositiveResult()
|
||||
{
|
||||
Assert.AreEqual(2, MathHelper.Modulo(-1, 3));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Product code: MathHelper.Modulo(int, int) — negative dividend with larger magnitude
|
||||
/// What: Verifies -7 mod 3 = 2 (wraps correctly for larger negative values)
|
||||
/// Why: Tests the full formula: ((-7 % 3) + 3) % 3 = (-1 + 3) % 3 = 2
|
||||
/// </summary>
|
||||
[TestMethod]
|
||||
public void Modulo_NegativeDividend_LargerMagnitude_ShouldWrapCorrectly()
|
||||
{
|
||||
Assert.AreEqual(2, MathHelper.Modulo(-7, 3));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Product code: MathHelper.Modulo(int, int)
|
||||
/// What: Verifies -6 mod 3 = 0 (exact negative multiple)
|
||||
/// Why: Edge case where negative dividend is an exact multiple of divisor
|
||||
/// </summary>
|
||||
[TestMethod]
|
||||
public void Modulo_NegativeDividend_ExactMultiple_ShouldReturnZero()
|
||||
{
|
||||
Assert.AreEqual(0, MathHelper.Modulo(-6, 3));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Product code: MathHelper.Modulo(int, int)
|
||||
/// What: Verifies n mod 1 = 0 for positive n
|
||||
/// Why: Divisor of 1 always yields 0 — guards against divide-by-zero edge case
|
||||
/// </summary>
|
||||
[TestMethod]
|
||||
public void Modulo_PositiveDividend_DivisorOne_ShouldReturnZero()
|
||||
{
|
||||
Assert.AreEqual(0, MathHelper.Modulo(5, 1));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Product code: MathHelper.Modulo(int, int)
|
||||
/// What: Verifies -n mod 1 = 0 for negative n
|
||||
/// Why: Tests the negative path with divisor=1 (simplest negative case)
|
||||
/// </summary>
|
||||
[TestMethod]
|
||||
public void Modulo_NegativeDividend_DivisorOne_ShouldReturnZero()
|
||||
{
|
||||
Assert.AreEqual(0, MathHelper.Modulo(-5, 1));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Product code: MathHelper.Modulo(int, int)
|
||||
/// What: Verifies modulo works with large numbers (1000001 mod 1000000 = 1)
|
||||
/// Why: Tests that no integer overflow occurs in the formula for large operands
|
||||
/// </summary>
|
||||
[TestMethod]
|
||||
public void Modulo_LargePositiveNumbers_ShouldWork()
|
||||
{
|
||||
Assert.AreEqual(1, MathHelper.Modulo(1000001, 1000000));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Product code: MathHelper.Modulo(int, int)
|
||||
/// What: Verifies that dividend less than divisor returns the dividend itself
|
||||
/// Why: 2 mod 5 = 2 — no division happens, just returns the dividend
|
||||
/// </summary>
|
||||
[TestMethod]
|
||||
public void Modulo_DividendLessThanDivisor_ShouldReturnDividend()
|
||||
{
|
||||
Assert.AreEqual(2, MathHelper.Modulo(2, 5));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Product code: MathHelper.Modulo(int, int) — negative dividend
|
||||
/// What: Verifies -2 mod 3 = 1 (mathematical modulo)
|
||||
/// Why: Tests another negative wrap case: ((-2 % 3) + 3) % 3 = (-2 + 3) % 3 = 1
|
||||
/// </summary>
|
||||
[TestMethod]
|
||||
public void Modulo_NegativeDividend_MinusTwo_ModThree_ShouldReturnOne()
|
||||
{
|
||||
Assert.AreEqual(1, MathHelper.Modulo(-2, 3));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Product code: MathHelper.Modulo(int, int) — zero divisor
|
||||
/// What: Documents that dividing by zero throws DivideByZeroException
|
||||
/// Why: Edge case — callers must handle zero divisor; the method does not guard against it
|
||||
/// </summary>
|
||||
[TestMethod]
|
||||
[ExpectedException(typeof(ArgumentOutOfRangeException))]
|
||||
public void Modulo_ZeroDivisor_ShouldThrow()
|
||||
{
|
||||
MathHelper.Modulo(5, 0);
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
[ExpectedException(typeof(ArgumentOutOfRangeException))]
|
||||
public void Modulo_NegativeDivisor_ShouldThrow()
|
||||
{
|
||||
MathHelper.Modulo(-1, -3);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Product code: MathHelper.NumberOfDigits(int)
|
||||
/// What: Verifies that 0 has 1 digit
|
||||
/// Why: Zero is a special case — Math.Abs(0).ToString() = "0" which has length 1
|
||||
/// </summary>
|
||||
[TestMethod]
|
||||
public void NumberOfDigits_Zero_ShouldReturnOne()
|
||||
{
|
||||
Assert.AreEqual(1, MathHelper.NumberOfDigits(0));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Product code: MathHelper.NumberOfDigits(int)
|
||||
/// What: Verifies single-digit number returns 1
|
||||
/// Why: Baseline case for the simplest positive input
|
||||
/// </summary>
|
||||
[TestMethod]
|
||||
public void NumberOfDigits_SingleDigit_ShouldReturnOne()
|
||||
{
|
||||
Assert.AreEqual(1, MathHelper.NumberOfDigits(5));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Product code: MathHelper.NumberOfDigits(int)
|
||||
/// What: Verifies two-digit number returns 2
|
||||
/// Why: Tests the transition from single to multi-digit
|
||||
/// </summary>
|
||||
[TestMethod]
|
||||
public void NumberOfDigits_TwoDigits_ShouldReturnTwo()
|
||||
{
|
||||
Assert.AreEqual(2, MathHelper.NumberOfDigits(42));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Product code: MathHelper.NumberOfDigits(int)
|
||||
/// What: Verifies three-digit number (100) returns 3
|
||||
/// Why: Tests exact power of 10 boundary (100 vs 99)
|
||||
/// </summary>
|
||||
[TestMethod]
|
||||
public void NumberOfDigits_ThreeDigits_ShouldReturnThree()
|
||||
{
|
||||
Assert.AreEqual(3, MathHelper.NumberOfDigits(100));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Product code: MathHelper.NumberOfDigits(int) — negative number
|
||||
/// What: Verifies that sign is ignored via Math.Abs
|
||||
/// Why: Negative sign is not a digit — only magnitude matters
|
||||
/// </summary>
|
||||
[TestMethod]
|
||||
public void NumberOfDigits_NegativeNumber_ShouldIgnoreSign()
|
||||
{
|
||||
Assert.AreEqual(3, MathHelper.NumberOfDigits(-123));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Product code: MathHelper.NumberOfDigits(int) — int.MaxValue
|
||||
/// What: Verifies int.MaxValue (2147483647) returns 10 digits
|
||||
/// Why: Upper boundary of int range — ensures no overflow in Math.Abs path
|
||||
/// </summary>
|
||||
[TestMethod]
|
||||
public void NumberOfDigits_MaxValue_ShouldReturnTenDigits()
|
||||
{
|
||||
Assert.AreEqual(10, MathHelper.NumberOfDigits(int.MaxValue));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Product code: MathHelper.NumberOfDigits(int) — near int.MinValue
|
||||
/// What: Verifies int.MinValue + 1 (-2147483647) returns 10 digits
|
||||
/// Why: int.MinValue itself overflows Math.Abs — this tests the safe boundary
|
||||
/// </summary>
|
||||
[TestMethod]
|
||||
public void NumberOfDigits_MinValue_ShouldHandleAbsoluteValue()
|
||||
{
|
||||
Assert.AreEqual(10, MathHelper.NumberOfDigits(int.MinValue + 1));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Product code: MathHelper.NumberOfDigits(int) — int.MinValue
|
||||
/// What: Documents that int.MinValue causes OverflowException due to Math.Abs
|
||||
/// Why: Edge case — Math.Abs(-2147483648) has no positive int representation
|
||||
/// </summary>
|
||||
[TestMethod]
|
||||
[ExpectedException(typeof(OverflowException))]
|
||||
public void NumberOfDigits_MinValue_ShouldThrowOverflow()
|
||||
{
|
||||
MathHelper.NumberOfDigits(int.MinValue);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Product code: MathHelper.NumberOfDigits(int) — powers of 10
|
||||
/// What: Verifies digit count at each power-of-10 boundary (1, 10, 100, 1000, 10000)
|
||||
/// Why: Powers of 10 are the exact transition points — off-by-one bugs surface here
|
||||
/// </summary>
|
||||
[TestMethod]
|
||||
public void NumberOfDigits_PowerOfTen_ShouldReturnCorrectCount()
|
||||
{
|
||||
Assert.AreEqual(1, MathHelper.NumberOfDigits(1));
|
||||
Assert.AreEqual(2, MathHelper.NumberOfDigits(10));
|
||||
Assert.AreEqual(3, MathHelper.NumberOfDigits(100));
|
||||
Assert.AreEqual(4, MathHelper.NumberOfDigits(1000));
|
||||
Assert.AreEqual(5, MathHelper.NumberOfDigits(10000));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Product code: MathHelper.NumberOfDigits(int) — 9→10 boundary
|
||||
/// What: Verifies that 9 returns 1 digit and 10 returns 2 digits
|
||||
/// Why: Tests the first single-to-double digit transition
|
||||
/// </summary>
|
||||
[TestMethod]
|
||||
public void NumberOfDigits_BoundaryValues_NineAndTen()
|
||||
{
|
||||
Assert.AreEqual(1, MathHelper.NumberOfDigits(9));
|
||||
Assert.AreEqual(2, MathHelper.NumberOfDigits(10));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Product code: MathHelper.NumberOfDigits(int) — 99→100 boundary
|
||||
/// What: Verifies that 99 returns 2 digits and 100 returns 3 digits
|
||||
/// Why: Tests the double-to-triple digit transition
|
||||
/// </summary>
|
||||
[TestMethod]
|
||||
public void NumberOfDigits_BoundaryValues_NinetyNineAndHundred()
|
||||
{
|
||||
Assert.AreEqual(2, MathHelper.NumberOfDigits(99));
|
||||
Assert.AreEqual(3, MathHelper.NumberOfDigits(100));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,156 +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 Peek.Common.Helpers;
|
||||
|
||||
namespace Peek.Common.UnitTests
|
||||
{
|
||||
[TestClass]
|
||||
public class PathHelperTests
|
||||
{
|
||||
/// <summary>
|
||||
/// Product code: PathHelper.IsUncPath(string)
|
||||
/// What: Verifies a standard UNC path (\\server\share) is recognized
|
||||
/// Why: Baseline positive case — the most common UNC format
|
||||
/// </summary>
|
||||
[TestMethod]
|
||||
public void IsUncPath_StandardUncPath_ShouldReturnTrue()
|
||||
{
|
||||
Assert.IsTrue(PathHelper.IsUncPath(@"\\server\share"));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Product code: PathHelper.IsUncPath(string)
|
||||
/// What: Verifies a UNC path with nested subfolders and file is recognized
|
||||
/// Why: Real-world UNC paths include subfolders — must not fail on deeper paths
|
||||
/// </summary>
|
||||
[TestMethod]
|
||||
public void IsUncPath_UncPathWithSubfolder_ShouldReturnTrue()
|
||||
{
|
||||
Assert.IsTrue(PathHelper.IsUncPath(@"\\server\share\folder\file.txt"));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Product code: PathHelper.IsUncPath(string)
|
||||
/// What: Verifies a UNC path with dotted server name (FQDN) is recognized
|
||||
/// Why: Enterprise environments use FQDN server names (e.g., server.corp.com)
|
||||
/// </summary>
|
||||
[TestMethod]
|
||||
public void IsUncPath_UncPathWithDottedServer_ShouldReturnTrue()
|
||||
{
|
||||
Assert.IsTrue(PathHelper.IsUncPath(@"\\server.domain.com\share"));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Product code: PathHelper.IsUncPath(string)
|
||||
/// What: Verifies a UNC path with IP address is recognized
|
||||
/// Why: Some network shares use IP addresses instead of hostnames
|
||||
/// </summary>
|
||||
[TestMethod]
|
||||
public void IsUncPath_UncPathWithIPAddress_ShouldReturnTrue()
|
||||
{
|
||||
Assert.IsTrue(PathHelper.IsUncPath(@"\\192.168.1.1\share"));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Product code: PathHelper.IsUncPath(string)
|
||||
/// What: Verifies a local drive path (C:\...) is not classified as UNC
|
||||
/// Why: Drive-letter paths are local — confusing them with UNC would break file access
|
||||
/// </summary>
|
||||
[TestMethod]
|
||||
public void IsUncPath_LocalDrivePath_ShouldReturnFalse()
|
||||
{
|
||||
Assert.IsFalse(PathHelper.IsUncPath(@"C:\Users\test\file.txt"));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Product code: PathHelper.IsUncPath(string)
|
||||
/// What: Verifies a bare drive root (D:\) is not classified as UNC
|
||||
/// Why: Drive roots are local paths — shortest possible local absolute path
|
||||
/// </summary>
|
||||
[TestMethod]
|
||||
public void IsUncPath_LocalRootPath_ShouldReturnFalse()
|
||||
{
|
||||
Assert.IsFalse(PathHelper.IsUncPath(@"D:\"));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Product code: PathHelper.IsUncPath(string)
|
||||
/// What: Verifies a relative path is not classified as UNC
|
||||
/// Why: Relative paths have no server component — Uri.TryCreate fails for them
|
||||
/// </summary>
|
||||
[TestMethod]
|
||||
public void IsUncPath_RelativePath_ShouldReturnFalse()
|
||||
{
|
||||
Assert.IsFalse(PathHelper.IsUncPath(@"folder\file.txt"));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Product code: PathHelper.IsUncPath(string)
|
||||
/// What: Verifies an empty string returns false without throwing
|
||||
/// Why: Empty input is a common edge case — must not crash
|
||||
/// </summary>
|
||||
[TestMethod]
|
||||
public void IsUncPath_EmptyString_ShouldReturnFalse()
|
||||
{
|
||||
Assert.IsFalse(PathHelper.IsUncPath(string.Empty));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Product code: PathHelper.IsUncPath(string)
|
||||
/// What: Verifies an HTTP URL is not classified as UNC
|
||||
/// Why: HTTP URLs are not file paths — Uri.IsUnc must return false for them
|
||||
/// </summary>
|
||||
[TestMethod]
|
||||
public void IsUncPath_HttpUrl_ShouldReturnFalse()
|
||||
{
|
||||
Assert.IsFalse(PathHelper.IsUncPath("http://example.com/path"));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Product code: PathHelper.IsUncPath(string)
|
||||
/// What: Verifies a file:/// URI (local file) is not classified as UNC
|
||||
/// Why: file:///C:/... is a local file URI, not a network UNC path
|
||||
/// </summary>
|
||||
[TestMethod]
|
||||
public void IsUncPath_FileUri_ShouldReturnFalse()
|
||||
{
|
||||
Assert.IsFalse(PathHelper.IsUncPath("file:///C:/Users/test/file.txt"));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Product code: PathHelper.IsUncPath(string)
|
||||
/// What: Verifies a standard UNC path with a file component (\\server\share\file.txt)
|
||||
/// Why: Tests UNC with a trailing file name — distinct from share-only paths
|
||||
/// </summary>
|
||||
[TestMethod]
|
||||
public void IsUncPath_StandardUncWithFile_ShouldReturnTrue()
|
||||
{
|
||||
Assert.IsTrue(PathHelper.IsUncPath(@"\\server\share\file.txt"));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Product code: PathHelper.IsUncPath(string)
|
||||
/// What: Verifies a single-backslash prefix is not classified as UNC
|
||||
/// Why: UNC requires exactly two leading backslashes — one backslash is not UNC
|
||||
/// </summary>
|
||||
[TestMethod]
|
||||
public void IsUncPath_SingleBackslash_ShouldReturnFalse()
|
||||
{
|
||||
Assert.IsFalse(PathHelper.IsUncPath(@"\server\share"));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Product code: PathHelper.IsUncPath(string)
|
||||
/// What: Verifies null input returns false without throwing
|
||||
/// Why: Null is a common edge case — Uri.TryCreate handles null gracefully
|
||||
/// </summary>
|
||||
[TestMethod]
|
||||
public void IsUncPath_NullInput_ShouldReturnFalse()
|
||||
{
|
||||
Assert.IsFalse(PathHelper.IsUncPath(null));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,28 +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" />
|
||||
|
||||
<PropertyGroup>
|
||||
<IsPackable>false</IsPackable>
|
||||
<AppendTargetFrameworkToOutputPath>false</AppendTargetFrameworkToOutputPath>
|
||||
<AppendRuntimeIdentifierToOutputPath>false</AppendRuntimeIdentifierToOutputPath>
|
||||
<OutputPath>$(SolutionDir)$(Platform)\$(Configuration)\tests\Peek.Common.Tests\</OutputPath>
|
||||
<RootNamespace>Peek.Common.UnitTests</RootNamespace>
|
||||
<AssemblyName>PowerToys.Peek.Common.UnitTests</AssemblyName>
|
||||
<OutputType>Exe</OutputType>
|
||||
<!-- Pull in the WindowsDesktop shared framework so that runtime DLLs (System.CodeDom,
|
||||
Microsoft.VisualBasic, WindowsBase, etc.) resolve to the same version as all other
|
||||
projects in the solution. Without this, transitive NuGet packages provide older
|
||||
versions that fail the verifyDepsJsonLibraryVersions CI check. -->
|
||||
<UseWindowsForms>true</UseWindowsForms>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="MSTest" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\Peek.Common\Peek.Common.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -11,11 +11,6 @@ namespace Peek.Common.Helpers
|
||||
{
|
||||
public static int Modulo(int a, int b)
|
||||
{
|
||||
if (b <= 0)
|
||||
{
|
||||
throw new ArgumentOutOfRangeException(nameof(b), b, "Divisor must be positive.");
|
||||
}
|
||||
|
||||
return a < 0 ? ((a % b) + b) % b : a % b;
|
||||
}
|
||||
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -45,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();
|
||||
@@ -68,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);
|
||||
});
|
||||
@@ -76,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);
|
||||
});
|
||||
@@ -84,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);
|
||||
});
|
||||
@@ -236,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;
|
||||
}
|
||||
|
||||
@@ -391,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;
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
<?xml version="1.0" encoding="utf-8" ?>
|
||||
<Application
|
||||
x:Class="PowerAccent.UI.App"
|
||||
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml">
|
||||
<Application.Resources>
|
||||
<ResourceDictionary>
|
||||
<ResourceDictionary.MergedDictionaries>
|
||||
<XamlControlsResources xmlns="using:Microsoft.UI.Xaml.Controls" />
|
||||
</ResourceDictionary.MergedDictionaries>
|
||||
</ResourceDictionary>
|
||||
</Application.Resources>
|
||||
</Application>
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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,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; }
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -432,12 +432,9 @@ function Test-CoreFiles {
|
||||
'PowerToys.MouseWithoutBordersHelper.dll',
|
||||
'PowerToys.MouseWithoutBordersHelper.exe',
|
||||
|
||||
# PowerAccent
|
||||
'PowerAccent.Core.dll',
|
||||
'PowerToys.PowerAccent.dll',
|
||||
'PowerToys.PowerAccent.exe',
|
||||
# PowerAccent - only the runner-loaded module interface ships in the install root.
|
||||
# The app, core, common and keyboard-service binaries moved to WinUI3Apps (see $winUI3SignedFiles).
|
||||
'PowerToys.PowerAccentModuleInterface.dll',
|
||||
'PowerToys.PowerAccentKeyboardService.dll',
|
||||
|
||||
# Workspaces
|
||||
'PowerToys.WorkspacesSnapshotTool.exe',
|
||||
@@ -500,7 +497,14 @@ function Test-CoreFiles {
|
||||
'PowerToys.RegistryPreviewExt.dll',
|
||||
'PowerToys.RegistryPreviewUILib.dll',
|
||||
'PowerToys.RegistryPreview.dll',
|
||||
'PowerToys.RegistryPreview.exe'
|
||||
'PowerToys.RegistryPreview.exe',
|
||||
|
||||
# PowerAccent (Quick Accent) - moved from the install root to WinUI3Apps
|
||||
'PowerAccent.Core.dll',
|
||||
'PowerAccent.Common.dll',
|
||||
'PowerToys.PowerAccent.dll',
|
||||
'PowerToys.PowerAccent.exe',
|
||||
'PowerToys.PowerAccentKeyboardService.dll'
|
||||
)
|
||||
|
||||
# Tools signed files (in Tools subdirectory)
|
||||
|
||||
Reference in New Issue
Block a user