Compare commits

...

99 Commits

Author SHA1 Message Date
Niels Laute
7b99309c33 Merge branch 'main' into workspaces-editor-winui-migration 2026-07-02 14:03:52 +02:00
Niels Laute
93669df118 TransparentWindow: opt-in Esc / focus-lost dismiss + multi-surface docs (#48950)
## Summary

Hardens the shared `TransparentWindow` (in
`src/common/Common.UI.Controls/`) with two **opt-in** dismissal
behaviors and documents multi-surface hosting. No consumer changes;
everything defaults off.

### 1. `DismissOnEscape` (default `false`)
Pressing <kbd>Esc</kbd> while the window content has keyboard focus
calls `Hide()`. Input is hooked lazily at show time because `Content` is
assigned by the consumer after construction.

### 2. `DismissOnFocusLost` (default `false`)
Light dismiss: hides when the window is deactivated. Guarded by a "seen
activated" flag (reset on each `Show()`) so the transient deactivation
that can occur during the show sequence doesn't dismiss prematurely.
This mirrors the guards that **PowerDisplay** (`_isShowingWindow`) and
**Quick Access** (`_hasSeenInteractiveActivation`) currently hand-roll —
they can drop their bespoke logic once they derive from
`TransparentWindow`.

> Both properties are no-ops unless the consumer activates the window
(it shows no-activate / `SW_SHOWNA`).

### 3. Multi-surface hosting (docs only — already works)
Added class `<remarks>` documenting that multiple `TransientSurface`s
can `SubscribeTo` one window: `HidingEventArgs` aggregates deferrals so
the window hides only after **all** surfaces finish animating out, and
plain `Show()` lets each surface play its own configured transition.

## Why no DWM "full-bleed" hardening here
The extra DWM hardening Shortcut Guide uses (NCRENDERING disabled,
`DwmExtendFrameIntoClientArea(-1)`, etc.) is only needed for
**full-monitor, edge-to-edge** overlays. Content-sized surfaces (Quick
Accent, CmdPal Toast) inset their acrylic card behind transparent
padding, so any phantom border sits in the transparent margin and is
invisible. Applying it universally would add compositing risk for zero
benefit, so it's deferred to the Shortcut Guide refactor (where it's
actually exercised) as an opt-in.

## Testing
- `Common.UI.Controls` builds clean (Debug/x64).
- Behavior is opt-in and off by default, so existing consumers are
unaffected.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-07-02 18:58:43 +08:00
Noraa Junker
56fabda79c [Chore] Remove outdated clean up tool (#48992)
<!-- Enter a brief description/summary of your PR here. What does it
fix/what does it change/how was it tested (even manually, if necessary)?
-->
## Summary of the Pull Request

Remove outdated clean up tool and script

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

- [x] Closes: #48991
<!-- - [ ] Closes: #yyy (add separate lines for additional resolved
issues) -->
- [ ] **Communication:** I've discussed this with core contributors
already. If the work hasn't been agreed, this work might be rejected
- [x] **Dev docs:** Added/updated
- [ ] **New binaries:** Added on the required places
- [ ] [JSON for
signing](https://github.com/microsoft/PowerToys/blob/main/.pipelines/ESRPSigning_core.json)
for new binaries
- [ ] [WXS for
installer](https://github.com/microsoft/PowerToys/blob/main/installer/PowerToysSetup/Product.wxs)
for new binaries and localization folder
- [ ] [YML for CI
pipeline](https://github.com/microsoft/PowerToys/blob/main/.pipelines/ci/templates/build-powertoys-steps.yml)
for new test projects
- [ ] [YML for signed
pipeline](https://github.com/microsoft/PowerToys/blob/main/.pipelines/release.yml)
- [ ] **Documentation updated:** If checked, please file a pull request
on [our docs
repo](https://github.com/MicrosoftDocs/windows-uwp/tree/docs/hub/powertoys)
and link it here: #xxx
2026-07-02 12:52:27 +02:00
Clint Rutkas
70555459ab [AlwaysOnTop] Guard m_frameDrawer in WindowBorder::UpdateBorderPosition (#48412)
## Summary

`WindowBorder::UpdateBorderPosition()` dereferences `m_frameDrawer`
without a null check when `GetFrameRect` fails on the tracked window:

```cpp
auto rectOpt = GetFrameRect(m_trackingWindow);
if (!rectOpt.has_value())
{
    m_frameDrawer->Hide();   // <-- AV if m_frameDrawer == nullptr
    return;
}
```

The sibling routine `WindowBorder::UpdateBorderProperties()` already
guards both `m_trackingWindow` and `m_frameDrawer` before doing any
work:

```cpp
if (!m_trackingWindow || !m_frameDrawer)
{
    return;
}
```

`UpdateBorderPosition` was the only call site that didn't match that
pattern. This PR brings it in line, and also adds a guard for `m_window`
for symmetry (the subsequent `SetWindowPos(m_window, ...)` would
otherwise fail silently with `ERROR_INVALID_WINDOW_HANDLE` but it's
cleaner to early-return).

## Why `m_frameDrawer` can be null when `UpdateBorderPosition` runs

`WindowBorder` registers itself as a `SettingsObserver` in its
base-class constructor — *before* `Init()` finishes. `Init()` is what
actually allocates `m_frameDrawer` (via `FrameDrawer::Create`). So
between those two steps there is a window where the object is alive and
observable but `m_frameDrawer == nullptr`.

The destructor has a similar shape:

```cpp
WindowBorder::~WindowBorder()
{
    if (m_frameDrawer)
    {
        m_frameDrawer->Hide();
        m_frameDrawer = nullptr;
    }

    if (m_window)
    {
        SetWindowLongPtrW(m_window, GWLP_USERDATA, 0);
        DestroyWindow(m_window);
    }
}
```

`m_frameDrawer` is nulled before `GWLP_USERDATA` is cleared and before
`DestroyWindow` is called. Any `WM_TIMER` that fires through `s_WndProc`
in that window dispatches to `WndProc → UpdateBorderPosition()` on an
instance whose `m_frameDrawer` has already been released — same null
deref, same access violation.

`UpdateBorderPosition` is also invoked from
`EVENT_OBJECT_LOCATIONCHANGE` / `EVENT_SYSTEM_MOVESIZEEND` in
`AlwaysOnTop.cpp` and from `WM_TIMER` in `WndProc`; both run on the
message-loop thread and either path can land here while the object is in
a transient half-constructed or half-destructed state.

## Change

Add `!m_frameDrawer` (and `!m_window`) to the early-return guard at the
top of `UpdateBorderPosition`. Three-line patch.

## How this was found

While reviewing the `WindowBorder` lifecycle for an unrelated
AlwaysOnTop tweak, the asymmetry between `UpdateBorderPosition` and
`UpdateBorderProperties` jumped out — the former dereferences
`m_frameDrawer` without the null check the latter performs.

## Tests

`src/modules/alwaysontop` has no test project today — `WindowBorder` is
tightly bound to Win32 (`HWND`, `DwmGetWindowAttribute`, `SetWindowPos`)
and `FrameDrawer` (Direct2D / D3D), with no abstraction layer to mock.
Adding meaningful native unit tests here would require a substantial
refactor that's well out of scope for this fix.

Manual validation:
- Build clean: `MSBuild
src\modules\alwaysontop\AlwaysOnTop\AlwaysOnTop.vcxproj
/p:Configuration=Release /p:Platform=x64` produces
`PowerToys.AlwaysOnTop.exe` with no warnings.
- Behavioural: with the guard added, the early-return path for "tracked
window has no valid extended frame bounds" simply skips the `Hide()`
call instead of crashing — same end-state for the user (border position
is left as-is until the next timer tick recovers it).

If we want a runtime regression net here, the right level is a UI /
lifecycle test that creates and pins/unpins windows under churn; that's
a separate piece of work and not gated on this fix.

## Risk

Three guarded conditions on a path that was already guarding one of
them. The behavioural delta only fires when the dereference *would* have
crashed — anywhere else the function is unchanged.

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

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-07-02 15:08:10 +08:00
moooyo
d6319516d0 [Skills] Fix wpf-to-winui3-migration SKILL.md failing to load (#49059)
## Summary of the Pull Request

The `wpf-to-winui3-migration` agent skill failed to load. The
`description` field in its `SKILL.md` YAML frontmatter was an
**unquoted** plain scalar containing `Keywords: ` (a colon followed by a
space). YAML interprets `: ` as a mapping key/value separator, so the
skill loader failed with:

> failed to parse YAML frontmatter: mapping values are not allowed in
this context at line 2 column 651

Because `.claude/skills` is a symlink to `.github/skills`, the CLI
enumerates the same file twice, so this single defect surfaced as
**two** skill load errors (`skill_error_count: 2`).

Fix: wrap the `description` value in single quotes so the colon is
treated literally. No wording changes; the description stays 926
characters (well under the 1024 limit).

## PR Checklist

- [ ] Closes: #xxx — N/A, trivial metadata fix, no tracking issue
- [x] **Communication:** Metadata-only fix; no design discussion needed
- [ ] **Tests:** N/A — no test harness for skill frontmatter; validated
by YAML parsing (see below)
- [x] **Localization:** N/A — not end-user-facing
- [ ] **Dev docs:** N/A
- [ ] **New binaries:** N/A

## Detailed Description of the Pull Request / Additional comments

`.github/skills/wpf-to-winui3-migration/SKILL.md` line 3 changed from:

```yaml
description: Guide for migrating ... after migration. Keywords: WPF, WinUI, ... SoftwareBitmap.
```

to:

```yaml
description: 'Guide for migrating ... after migration. Keywords: WPF, WinUI, ... SoftwareBitmap.'
```

The three other top-level skills already quote (or avoid `: ` in) their
descriptions, so only this one was affected. Single quotes are used
because the description contains no quote characters, so no escaping is
required.

## Validation Steps Performed

- Reproduced the loader error from the Copilot CLI logs: `mapping values
are not allowed in this context at line 2 column 651`.
- Parsed the frontmatter of all 9 `SKILL.md` files with a YAML parser:
before = 1 failure (this file), after = **0 failures**.
- Confirmed parsed `name` (`wpf-to-winui3-migration`), `description`
(926 chars, ≤ 1024), and `license` are intact and the literal `Keywords:
WPF...` text is preserved.

Co-authored-by: Yu Leng (from Dev Box) <yuleng@microsoft.com>
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-07-02 10:48:23 +08:00
Alex Mihaiuc
53737cbe31 ZoomIt add snip and panorama save hotkeys (#49075)
<!-- Enter a brief description/summary of your PR here. What does it
fix/what does it change/how was it tested (even manually, if necessary)?
-->
## Summary of the Pull Request

- Fixes #45808 — ZoomIt's "Snip Save" hotkey was auto-derived by XOR'ing
the Shift modifier from the Snip hotkey, causing `Ctrl+S` to be stolen
when Snip was set to `Ctrl+Shift+S`
- The Snip Save hotkey is now a separate, independently configurable
setting (default: `Ctrl+Shift+6`, complementing the default Snip hotkey
`Ctrl+6`)
- Updated both the PowerToys Settings UI and the standalone ZoomIt
options dialog
- The same is applied for the scrolling screenshot keys

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

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

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

ZoomIt registered two hotkeys for Snip — the primary (`SNIP_HOTKEY`) and
a "save to file" variant (`SNIP_SAVE_HOTKEY`) derived by toggling the
Shift modifier via XOR:

```cpp
RegisterHotKey(hWnd, SNIP_SAVE_HOTKEY, (g_SnipToggleMod ^ MOD_SHIFT), g_SnipToggleKey & 0xFF);
```

This worked fine for the default `Ctrl+6` (save became `Ctrl+Shift+6`),
but when a user configured `Ctrl+Shift+S` as their Snip hotkey, the XOR
removed Shift, making the save variant `Ctrl+S` — stealing a ubiquitous
shortcut from every other application.

## Changes

### C++ Backend (ZoomIt core)

- **`resource.h`** — Added `IDC_SNIP_SAVE_HOTKEY` control ID for the new
dialog control
- **`ZoomItSettings.h`** — Added `g_SnipSaveToggleKey` global variable
(default: `Ctrl+Shift+6`) and its `RegSettings[]` entry for registry
persistence
- **`ZoomIt.rc`** — Added a second hotkey control ("Snip Save Toggle")
to the standalone SNIP options dialog
- **`Zoomit.cpp`** — Added `g_SnipSaveToggleMod` global; replaced all 4
XOR-derived `RegisterHotKey` calls with the new explicit setting;
updated the options dialog init, read, validation, and save logic

### Settings Interop

- **`ZoomItSettings.cpp`** — Added `SnipSaveToggleKey` to the
`settings_with_special_semantics` map so it serializes as a hotkey JSON
object

### C# Settings UI

- **`ZoomItProperties.cs`** — Added `DefaultSnipSaveToggleKey` and
`SnipSaveToggleKey` property
- **`ZoomItViewModel.cs`** — Replaced the computed XOR-derived read-only
getter with a full get/set property backed by the new
`SnipSaveToggleKey` setting
- **`ZoomItPage.xaml`** — Replaced the read-only markdown description
with an editable `ShortcutControl` for the save hotkey
- **`Resources.resw`** — Added "Save snip to file" header string;
removed the old templated description


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

Built and tested locally --

 App shows additional shortcut for ZoomIt

<img width="983" height="188" alt="image"
src="https://github.com/user-attachments/assets/f73c1ecc-3aee-4dee-bfbb-b95764e7eb1c"
/>

 I am able to save files via `CTRL + S` as normal

 I am able to use Snip activation with `CTRL + Shift + S`

 I am able to use Save snip to file with the assigned shortcut of `CTRL
+ Shift + 6`

 Other ZoomIt commands run as expected (e.g. LiveZoom with my mapping
of `ALT + 4`

---------

Co-authored-by: Sean Killeen <SeanKilleen@gmail.com>
2026-07-02 01:49:21 +02:00
chatasweetie
87a5fac4bc remove deleted WPF project reference from UITest csproj 2026-07-01 14:41:11 -07:00
Dave Rayment
bf6ff579d3 [ZoomIt] Fix issue with recording filename suffixes (#43236)
<!-- Enter a brief description/summary of your PR here. What does it
fix/what does it change/how was it tested (even manually, if necessary)?
-->
## Summary of the Pull Request
Fixes an issue where ZoomIt would always remove a numeric suffix from a
suggested recording filename even when it was part of a user-chosen
name.

Also: appends a timestamp instead of a numeric suffix for the default
filename, improving consistency with other tools and allowing for
correct name ordering in Explorer views.

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

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

<!-- Provide a more detailed description of the PR, other things fixed,
or any additional comments/features here -->
## Detailed Description of the Pull Request / Additional comments
The `GetUniqueRecordingFilename()` function used regex to strip numeric
suffixes, and incorrectly assumed that all `(N)` patterns were
ZoomIt-generated. This broke user-provided filenames like "My
Presentation (2025).mp4".

### Root cause
```cpp
    // Chop off index if it's there
    auto base = std::regex_replace( path.stem().wstring(), std::wregex( L" [(][0-9]+[)]$" ), L"" );
    path.replace_filename( base + path.extension().wstring() );
```

This code strips off _any_ numeric suffix.

### Solution
The proposed solution tracks the user's chosen filename separately,
using it as the base for the file renaming strategy. The new string
`g_RecordingSaveBaseFilename` allows for additive suffix generation
without using regex stripping.

This change also allows us to remove **regex.h** as a dependency,
reducing the application's file size.

The code retains the addition of numeric suffixes for user-chosen
filenames, but timestamp suffixes are now added when the filename is the
default `Recording.mp4`; this is more consistent with tools like Windows
Snipping Tool, and allows for correct ordering of files in Windows
Explorer (previously, `Recording (11).mp4` would be sorted before
`Recording (2).mp4`, for example).

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

### Test Scenario 1: Default filename with timestamp suffix
1. Launch ZoomIt, start first recording with
<kbd>Ctrl</kbd>+<kbd>5</kbd>.
2. Stop the recording. The Save dialog shows "Recording.mp4".
3. Select **Save** to Accept this default. The file is saved as
"Recording 2025-11-03 180719.mp4" or similar.
4. Start and stop another recording.
5. The Save dialog shows "Recording 2025-11-03 180800.mp4" or similar,
i.e. with a distinct timestamp from the last save. Accept this
suggestion by selecting **Save**.
6. Verify both files exist on disk. By default, this will be in your
**Videos** folder.

### Test Scenario 2: Custom filename (ascending numeric suffix)
1. Launch ZoomIt, and start recording.
2. Stop the recording and change the suggested filename to "My
Presentation.mp4". Select **Save** to save the file.
3. Start and stop a second recording.
4. Confirm the dialog suggests "My Presentation (1).mp4" as the
filename. Select **Save** to accept this filename.
5. Start and stop a third recording.
6. Confirm the dialog suggests "My Presentation (2).mp4" as the
filename. Select **Save** to accept the suggestion.
7. Verify all files exist on disk with the correct names.

### Test Scenario 3: User filename with parenthetical number (bug repro)
1. Launch ZoomIt and start recording.
2. Stop the recording and change the suggested filename to "My
Presentation (2025).mp4". Select **Save** to save the recording to disk.
3. Start and stop a second recording.
4. Confirm the dialog suggests "My Presentation (2025) (1).mp4" as the
filename. (This was broken before - the prior version would suggest "My
Presentation.mp4".)
5. Accept the suggestion by selecting **Save**.
6. Start and stop a third recording.
7. Confirm the save dialog suggests "My Presentation (2025) (2).mp4".
8. Verify all files exist on disk with the correct names.

### Test Scenario 4: User modifies suggested filename
1. Save first recording as "Test.mp4".
2. Start and stop a second recording.
3. Confirm the second recording has a suggested filename of "Test
(1).mp4".
4. Change the suggested filename to "TestFinal.mp4" before saving.
5. Start and stop a third recording.
6. Confirm the save dialog suggests "TestFinal (1).mp4". (Verifies
correct updating of the base filename.)

### Test Scenario 5: Existing files at the save location
1. Manually create "Video.mp4" and "Video (1).mp4" in the Videos folder.
2. Start ZoomIt and save a recording to the same folder as "Video.mp4".
Confirm you are asked to overwrite the existing file.
3. Select "Yes" and save the file.
4. Start and stop another recording.
7. Confirm that the Save dialog's suggested filename is "Video (2).mp4",
skipping the manually-added file.

## Code updates

### Additional clean-up

- Clarified path construction in recording initialisation block
(replaced `/=` with explicit path building for a more readable
approach).
- Changed `DEFAULT_RECORDING_FILE` from a `#define` to a `constexpr`
instead. The new base filename variable is based upon it.

Co-authored-by: Niels Laute <niels.laute@live.nl>
2026-07-01 23:29:51 +02:00
chatasweetie
d48501be9f remove debug logging and unused usings 2026-07-01 14:26:14 -07:00
chatasweetie
0362e0d5fc improve navigation context and focus management 2026-07-01 13:48:23 -07:00
chatasweetie
0697cd8774 announce empty search results to Narrator 2026-07-01 13:22:45 -07:00
chatasweetie
9fb18f5bfb remove redundant indicator + add loading announcement 2026-07-01 13:10:22 -07:00
chatasweetie
03c97e0366 remove recording indicator from snapshot window 2026-07-01 12:53:54 -07:00
chatasweetie
77c53e6f9a add contextual names to Launch and Delete buttons 2026-07-01 11:08:29 -07:00
chatasweetie
0a2e4b5253 replace ItemsRepeater with ListView for list semantics 2026-07-01 10:47:12 -07:00
chatasweetie
af03fd610a add accessible names to search and name fields 2026-07-01 10:34:53 -07:00
Michael Jolley
0afe525f31 Fix memory leaks in Command Palette: unsubscribe event handlers and dispose resources (#48884)
## Summary

Fixes 6 memory leaks in Command Palette caused by event handlers not
being unsubscribed and disposable resources not being released.

## Changes

| File | Fix |
|------|-----|
| `MainListPage.cs` | Replace lambda on static
`AllAppsCommandProvider.Page.PropChanged` with named method; unsubscribe
in `Dispose()` |
| `WinRTExtensionService.cs` | Unsubscribe static `PackageCatalog`
events (`PackageInstalling`/`Uninstalling`/`Updating`) in `Dispose()` |
| `MainWindow.xaml.cs` | Unsubscribe all event handlers in `Dispose()`
(`SizeChanged`, `SettingsChanged`, `ActualThemeChanged`,
`XamlRoot.Changed`, `CardElement.SizeChanged`, timer `Tick`,
`ThemeChanged`, `KeyPressed`); replace lambda with named method |
| `ContentFormControl.xaml.cs` | Unsubscribe previous
`RenderedAdaptiveCard.Action` before subscribing to new card |
| `BlurImageControl.cs` | Track `LoadedImageSurface` and unsubscribe
`LoadCompleted` before loading a new image |
| `ShowFileInFolderCommand.cs` | Dispose `Process` object returned by
`Process.Start()` (handle leak) |

## Validation

- [x] Build clean (`tools\build\build.ps1 -Path src\modules\cmdpal
-Platform x64 -Configuration Debug`) — exit code 0
- [x] All 1809 CmdPal unit tests pass (2 pre-existing skips)

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-07-01 18:59:55 +02:00
chatasweetie
3f67465fc6 add red border overlay during snapshot capture 2026-07-01 09:24:16 -07:00
Michael Jolley
a43fb12d6f cmdpal: Support Enter key to submit FormContent (Adaptive Card) inputs (#48768)
## Summary

Adds Enter key support for submitting Adaptive Card forms in the Command
Palette. When a user presses Enter inside a single-line `Input.Text`
field, the form is automatically submitted using the first
`Action.Submit` or `Action.Execute` action on the card.

## Problem

When extensions use `FormContent` with Adaptive Cards, pressing Enter
inside an `Input.Text` field does not trigger submission. Users must
click the submit button with their mouse (or tab to it), breaking
keyboard-only workflows like login/unlock forms.

## Solution

Added a `KeyDown` event handler on the rendered Adaptive Card's
`FrameworkElement` in `ContentFormControl.xaml.cs`:

- Intercepts `VirtualKey.Enter` when the source is a `TextBox` that
doesn't accept returns (single-line)
- Finds the first `AdaptiveSubmitAction` or `AdaptiveExecuteAction` on
the card
- Calls `HandleSubmit` with that action and the current user inputs via
`RenderedAdaptiveCard.UserInputs.AsJson()`

Multiline text inputs (`AcceptsReturn = true`) are excluded so Enter
still inserts newlines.

## Validation

- Single file change in `ContentFormControl.xaml.cs`
- Uses existing `HandleSubmit` path — same behavior as clicking the
submit button
- No impact on cards without submit/execute actions
- No impact on multiline text inputs

Fixes #46003

---------

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-07-01 10:48:32 -05:00
Christian Gaarden Gaardmark
bc56443443 New++ updated attribution (#49047)
## Summary of the Pull Request
* Updated New+ attribution text and link after conversation with Niels
* Text="Based on Christian Gaardmark's New++ from the Productivity Plus
Pack"
*
Link="https://www.onegreatworld.com/products/productivity-plus-pack/?ref=settings_pt"

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

## Detailed Description of the Pull Request / Additional comments
* Text="Based on Christian Gaardmark's New++ from the Productivity Plus
Pack"
*
Link="https://www.onegreatworld.com/products/productivity-plus-pack/?ref=settings_pt"

## Validation Steps Performed
1. Manually confirmed visual layout
2. Manually confirmed link

<img width="470" height="65" alt="image"
src="https://github.com/user-attachments/assets/56d6e454-89cf-4a26-b570-b162df51f4a9"
/>

cc:
@niels9001
2026-07-01 14:18:05 +02:00
Clint Rutkas
3298625b67 Cancel stale Quick Accent toolbar render timer (#48944)
## Summary

Quick Accent's ShowToolbar queues a delayed render of the accent menu
through Task.Delay(...).ContinueWith(...). The continuation only checked
_visible before rendering, so a timer started by an earlier key press
could still fire for a **newer** summon — or one that had already been
hidden — popping the accent menu earlier than the configured delay
intended.

This was surfaced while reviewing the press-and-hold work in #48937, but
it is a pre-existing race independent of that feature, so it ships on
its own.

## Fix

- Tag each `ShowToolbar` summon with an incrementing `_showGeneration`
id and capture it in the local closure.
- The delayed continuation now renders only when it is still the most
recent summon (`generation == _showGeneration`) **and** `_visible`.
- Bump the generation when the toolbar hides, so any in-flight timer
queued before the hide is cancelled.

Everything runs on the UI thread (the dispatcher marshals
`ShowToolbar`/hide and the continuation uses
`FromCurrentSynchronizationContext`), so the counter needs no locking.

## Validation

- Built `PowerAccent.UI` (C++ hook + Core) Debug|x64 — 0 warnings, 0
errors.
- Manual: rapid repeated taps of an accent-capable letter no longer
flash the menu early from a leftover timer; normal hold-to-open and
navigation/commit are unchanged.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-07-01 15:36:50 +08:00
Clint Rutkas
ae9f241ef1 [FancyZones] Harden three shutdown races in WorkArea / ZonesOverlay / OnThreadExecutor (#48473)
## Summary

Four small shutdown-/teardown-race fixes in FancyZones that I spotted
while reading through the work-area and overlay teardown sequence for an
unrelated review. Each one is independently safe in the happy path, but
in combination they can crash the FancyZones host process during display
changes, monitor configuration changes, a settings toggle mid-drag, or
normal exit.

## Issues fixed

### 1. `~ZonesOverlay` joins a non-joinable thread when the constructor
early-returns
`ZonesOverlay::ZonesOverlay` can return early in two places — if
`GetClientRect` fails or if `CreateHwndRenderTarget` returns a failure
HRESULT (both reachable in the wild during a display-driver TDR or when
a monitor is disconnected mid-init). When that happens, `m_renderThread`
is never started and stays default-constructed. The destructor
unconditionally calls `m_renderThread.join()`, which on a non-joinable
thread is undefined behavior (MSVC throws `std::system_error`); thrown
from an implicit-noexcept destructor it calls `std::terminate()`.

Fix: guard the wake-up-and-join sequence with `if
(m_renderThread.joinable())`.

### 2. `~WorkArea` returns the HWND to the window pool before the
renderer is torn down
`WorkArea`'s explicit destructor body calls
`windowPool.FreeZonesOverlayWindow(m_window)` first, and only afterwards
does implicit member destruction run `~ZonesOverlay` (which joins the
render thread). Between those two steps the HWND is back in the pool and
immediately eligible for reuse by the next `NewZonesOverlayWindow` call,
while the still-alive render thread is using `m_renderTarget` to draw
into it. If the pool hands the same HWND to a freshly-built
`ZonesOverlay`, two render targets target the same window concurrently.

Fix: reset `m_zonesOverlay` (which joins the render thread) before
returning the window to the pool.

### 3. `~OnThreadExecutor` writes `_shutdown_request` outside the mutex
The destructor mutates the shutdown flag without holding `_task_mutex`,
then calls `_task_cv.notify_one()`. The worker checks the same flag
inside `_task_cv.wait(lock, predicate)`. The atomic does make the value
visible eventually, but if the notify lands in the narrow window where
the worker has just evaluated the predicate as false and is about to
atomically release the lock and sleep, the wakeup can be missed and
`_worker_thread.join()` hangs.

Fix: take `_task_mutex` around the `_shutdown_request = true` write so
it pairs correctly with the `cv.wait`.

### 4. `WindowMouseSnap` keeps a dangling `WorkArea*` across
`WorkAreaConfiguration::Clear()`
`FancyZones::UpdateWorkAreas()` rebuilds `m_workAreaConfiguration`
whenever monitor state changes mid-session, and the
`SpanZonesAcrossMonitors` settings toggle hits the same `Clear()`. If
the user is mid-drag at the moment one of these runs, the
`WindowMouseSnap` instance owned by `FancyZones` is still holding both a
`const` reference to the map being cleared (`m_activeWorkAreas`) and a
raw `WorkArea*` into one of the entries that's about to be destroyed
(`m_currentWorkArea`). The next `WM_MOUSEMOVE` -> `MoveSizeUpdate()`
then dereferences a freed pointer. `WindowMouseSnap`'s destructor only
resets window transparency, so relying on it doesn't help; the snapper
has to be torn down explicitly.

Fix: call `FancyZones::MoveSizeEnd()` (which already tears down the
snapper cleanly and is a no-op when the snapper is null) before each
`m_workAreaConfiguration.Clear()` call on these paths.

## Risk

Low. All four changes are localized to teardown / reconfiguration paths
and only tighten existing destruction sequences — the steady-state
behavior of `ZonesOverlay::Render`/`Show`/`Hide`, the work-area public
API, `OnThreadExecutor::submit`/`cancel`, and `WindowMouseSnap` drag
handling is unchanged. The `WorkArea` reordering is the most behavioral
change; it now guarantees the render thread has stopped using the HWND
before the pool can recycle it, which is what the existing
implicit-member-destruction order already implied but couldn't enforce
given the explicit destructor body.

## Validation

Spot-built locally; this repo's `dotnet restore` runtime-pack issue
(unrelated to this PR — same NU1102 pattern that's affecting other open
PRs) prevents a full `Build.cmd` here, but the C++ FancyZones modules
involved are unchanged in their public surface and are exercised by
existing unit tests in `FancyZonesTests` for the WorkArea code paths.

---

ADO: https://microsoft.visualstudio.com/OS/_workitems/edit/54653316/

---------

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-07-01 15:35:03 +08:00
Jiří Polášek
67a9fa2d13 CmdPal: Ensure that directory paths passed from Bookmarks is quoted (#48955)
This PR ensures that directory paths passed from Bookmarks through
`CommandLauncher` to Windows Explorer are properly quoted. In current
implementation the directory path that should be opened through Windows
Explorer is not quoted and if it contains a space then Explorer will
treat it as multiple arguments instead.

<!-- Enter a brief description/summary of your PR here. What does it
fix/what does it change/how was it tested (even manually, if necessary)?
-->
## Summary of the Pull Request

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

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

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

<!-- Describe how you validated the behavior. Add automated tests
wherever possible, but list manual validation steps taken as well -->
## Validation Steps Performed
2026-06-30 23:24:01 -05:00
Clint Rutkas
1cfc923bdb Fix mismatched WebView2 versions, upgrade WebView2 (#49051) 2026-06-30 18:18:06 -05:00
Clint Rutkas
2dd802f367 Fixing Windows.ImplementationLibrary mismatch between proj and package.config (#49050)
In the vcxproj files, it lists it correctly for
Microsoft.Windows.ImplementationLibrary.1.0.260126.7 but the package
files are incorrect
2026-06-30 18:17:27 -05:00
Clint Rutkas
a0d17406ba Updating MessagePack (#49029)
Version bump on Message Pack
2026-06-30 14:26:32 +02:00
Copilot
4a27c5d5f9 New+: Fix French translation guidance (Nouveau+ not Nouveauté+) (#47225)
## Summary of the Pull Request

French translation of "New+" was rendered as "Nouveauté+" ("Novelty+")
instead of "Nouveau+" ("New+"), inconsistent with how Windows itself
translates the "New" context menu item in French. This updates
translator guidance comments in the English resource files to explicitly
call out the correct French form.

## PR Checklist

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

## Detailed Description of the Pull Request / Additional comments

Translator-facing `<comment>` fields updated across three resource files
to explicitly state the correct French translation and flag the wrong
one:

- **`NewShellExtensionContextMenu/resources.resx`** and
**`NewShellExtensionContextMenu.win10/resources.resx`** —
`context_menu_item_new`:
> _"…e.g. Danish it would become Ny+, **French it would become Nouveau+
(not Nouveauté+)**"_

- **`Settings.UI/Strings/en-us/Resources.resw`** — five `NewPlus.*` /
`Oobe_NewPlus.*` strings:
> _"…Localize product name in accordance with Windows New. **e.g. French
would be Nouveau+ (not Nouveauté+)**"_

Actual `.lcl` translation files are managed by the CDPX localization
pipeline; these comment updates feed directly into the guidance the
localization team sees when updating those files.

## Validation Steps Performed

Comment-only changes to XML resource files; no runtime behavior
affected. Verified all targeted entries were updated and no existing
checked-in `Nouveauté` strings remain in the repo.

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: niels9001 <9866362+niels9001@users.noreply.github.com>
2026-06-30 17:06:25 +08:00
Dave Rayment
8bd5c1be6f [Quick Accent] Additions and reorg for IPA set. Additions to Special set (#49030)
<!-- Enter a brief description/summary of your PR here. What does it
fix/what does it change/how was it tested (even manually, if necessary)?
-->
## Summary of the Pull Request

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

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

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

The following additions and changes were made in response to the
feedback given in #48840 and also following an audit of the IPA set.

#### IPA

| Character | Action
|--|--|
| `ɱ` | Added to **M** |
| `ʍ` | Added to **W** | 
| `ɚ` | Added to **E** |
| ` ͡ ` and `  ͜  ` | Added to **PERIOD**
| `ɡ` | Added to **G**
| `ɫ` | Added to **I**
| `ʱ` | Added to **H**
| `◌̝`, `◌̥`, `◌̚`, `ˈ` and `ˌ` | added to **PERIOD**
| `ʎ` | Added to **Y**
| `ʔ` | Added to **COMMA** and **SLASH**
| `æ` | Added to **A** and **E**
| `œ` | Added to **O** and **E**
| `ʘ` | Added to **B** and **O**
| `β`, `ɓ` | Added to **B**
| `χ` | Added to **C** and **X**
| `ç`, `ǂ` | Added to **C**
| `ð`, `ɗ`, `ɖ`, `ǀ` | Added to **D**
| `ɠ`, `ʛ` | Added to **G**
| `ħ`, `ɥ`, `ɧ` | Added to **H**
| `ʄ` | Added to **J**
| `ɫ`, `ǁ` | Added to **L**
| `ø` | Added to **O**

I removed the caron vowel characters `ǎ`, `ǒ` and `ǔ`, as they should
not have been in the IPA set. These characters are available in the
Pinyin set.

A small number of keys had entries reordered where common mappings would
have been towards the end.

This IPA update includes:

- Click Consonants (`ʘ`, `ǀ`, `ǃ`, `ǂ`, `ǁ`), which are used in the
phonetic transcription of Southern and Eastern African languages, most
notably the Khoisan language groups and several Bantu languages (like
Zulu and Xhosa).
- Implosives and Ejectives (`ɓ`, `ɗ`, `ʼ`, etc.), which are essential
for transcribing languages across the globe, including Indigenous
languages of the Americas (e.g., Navajo and Mayan), the Caucasus (e.g.,
Georgian), Southeast Asia (e.g., Vietnamese), and widely across the
African continent.

#### SPECIAL set

| Character | Action | Notes
|--|--|--|
| `‽` and `⸘` | Added to **SLASH** |
| `⟨`, `⟩`, `⟪` and `⟫` | Replaced full-width CJK brackets with the
Western versions.
| `‰` and `‱` | Added to **P**

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

Manual testing, with specific tests to confirm that the new combining
marks display OK in the UI.
2026-06-30 10:06:02 +08:00
chatasweetie
027124f98a set AutomationProperties.Name on snapshot buttons 2026-06-29 14:46:16 -07:00
chatasweetie
df2d162275 Use OnPropertyChanged(string) overload, remove using System.ComponentModel 2026-06-29 14:38:09 -07:00
chatasweetie
123ae05e1b LaunchProjectAsync() to async void event 2026-06-29 14:30:24 -07:00
Clint Rutkas
7b19b4c219 Add build-time guard for Windows long path support (#49028)
PowerToys has deeply nested source paths that exceed the legacy
260-character MAX_PATH limit. Contributors who haven't enabled Windows
long path support hit cryptic 'path too long' / 'could not find file'
errors during their first build.

Add an EnsureLongPathsEnabled MSBuild target in Directory.Build.targets
that reads
HKLM\SYSTEM\CurrentControlSet\Control\FileSystem\LongPathsEnabled and
fails fast with an actionable error (PTLONGPATH) pointing at
tools\build\setup-dev-environment.ps1. Covers both Visual Studio and the
command-line build scripts, skips design-time builds, and can be
bypassed with /p:SkipLongPathsCheck=true.

**What happens if Long file path isn't enabled.**
<img width="824" height="916" alt="image"
src="https://github.com/user-attachments/assets/30731f65-4011-48c0-94b9-e521b4c7d266"
/>

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-06-29 21:29:14 +00:00
chatasweetie
2a4919fe41 catch specific exceptions in name-index parsing 2026-06-29 14:24:11 -07:00
chatasweetie
70e08ddd63 add timeout to Process.WaitForExit calls 2026-06-29 14:15:27 -07:00
chatasweetie
c17554482c consolidate theme detection + fix snapshot a11y 2026-06-29 13:43:30 -07:00
Jessica Dene Earley-Cha
b73fd670be [CmdPal] Fix excessive Narrator announcements on More button open (#48928)
<!-- Enter a brief description/summary of your PR here. What does it
fix/what does it change/how was it tested (even manually, if necessary)?
-->
## Summary of the Pull Request

Opening the More button caused Narrator to read a long cascade of
overlapping announcements — popup window, filter TextBox placeholder,
ListView item name, position ("1 of 4"), keyboard shortcut text — all
from a single keypress with no further input.

This PR replaces that cascade with a single, clean announcement:
**"Menu, {0} commands. {1}, {2} of {0}"**
(num of commands, first item in list, num of order in list, num of
commands)
**"Menu, 3 commands. Calculator, 1 of 3."**


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

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

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


| File | Change |
|------|--------|
| `ContextMenu.xaml` | Set `AccessibilityView="Raw"` on ListView and
TextBox; added `NarratorAnnouncer` TextBlock for raising UIA
notifications |
| `ContextMenu.xaml.cs` | Added `AnnounceOpened()`,
`AnnounceSelectedItem()`, and `_isOpening` guard to prevent programmatic
selection changes from triggering UIA events during the flyout
transition |
| `CommandBar.xaml.cs` | Call `AnnounceOpened()` on flyout open |
| `DockControl.xaml.cs` | Same for dock context menu |
| `Resources.resw` | Added `ScreenReader_Announcement_ContextMenuOpened`
localized format string |

## How it works

- ListView and TextBox are permanently `AccessibilityView="Raw"` —
invisible
  to Narrator, preventing all system-driven UIA announcements
- A zero-size `NarratorAnnouncer` TextBlock
(`AccessibilityView="Content"`,
  `LiveSetting="Assertive"`) serves as the sole notification source
- On open: a deferred `RaiseNotificationEvent` fires one consolidated
announcement
- On arrow navigation: `AnnounceSelectedItem()` fires item name and
position


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



https://github.com/user-attachments/assets/4660741f-6b91-4a32-9a56-83c64343b67a



- [x] Build succeeds (0 errors)
- [x] 124 ViewModel unit tests pass
- [x] Manual Narrator testing: open, arrow navigate, close, reopen
- [x] Keyboard filtering (type to filter) still works
- [x] Enter to invoke selected command still works
- [x] Escape to close still works
2026-06-29 21:39:12 +02:00
chatasweetie
bcf3c98c8a eliminate side effects and external INPC callers - IconImage+ 2026-06-29 11:44:49 -07:00
Niels Laute
a46a4437e5 Fix unreadable What's New titles/links when OS and PowerToys themes differ (#48910)
## Summary of the Pull Request

When the OS theme differs from the PowerToys theme (e.g. OS in Light,
PowerToys set to Dark), the **What's New / release-notes (Scoobe)** page
renders heading titles and hyperlinks with brushes pinned to the wrong
theme, making them unreadable. The system caption buttons
(min/max/close) are also not tinted to match the window theme.

The release-notes page uses the CommunityToolkit `MarkdownTextBlock`,
which captures its heading and link brushes from
`Application.Current.Resources` when its theme config is created. Those
resolve against the **OS (application) theme** rather than the window's
selected element theme, so headings/links break whenever the two themes
differ.

## PR Checklist

- [x] Closes: #43970
- [x] Closes: #48832
- [x] **Communication:** Local workaround for a known upstream control
bug
- [ ] **Tests:** Manually validated (UI/theming change)
- [x] **Localization:** No new end-user-facing strings
- [ ] **Dev docs:** N/A

## Detailed Description of the Pull Request / Additional comments

These are deliberately **local workarounds** until the upstream control
resolves brushes against the element theme via
[CommunityToolkit/Labs-Windows#785](https://github.com/CommunityToolkit/Labs-Windows/pull/785).
Comments + `TODO`s in the code point at that PR so the workaround can be
removed once it ships.

**`ScoobeReleaseNotesPage`**
- Pin the `MarkdownTextBlock.RequestedTheme` to the selected app theme
and reassign the `H1`–`H6` heading brushes and the link brush (resolved
for that theme) before the markdown is rendered. The themed brushes are
read from the control's own `Foreground` (`TextFillColorPrimaryBrush`)
and a hidden `LinkBrushProvider` carrier element
(`AccentTextFillColorPrimaryBrush`).
- Re-run the workaround and force a re-render on runtime theme changes
(subscribe to the page's `ActualThemeChanged`), so titles/links stay
readable when the user switches Light/Dark while the window is open.

**`TitleBarHelper` (new) + `ScoobeWindow`**
- Add a small shared
`TitleBarHelper.ApplySystemThemeToCaptionButtons(window, theme)` (port
of the WinUI Gallery helper + PowerToys conventions) and drive the
Scoobe window's caption-button colors from the content's actual theme,
updating on `ActualThemeChanged`. `ScoobeWindow` uses the built-in WinUI
`TitleBar`, which — unlike the custom PowerToys `TitleBar` control used
by the other Settings windows — does not tint the system caption buttons
to the app theme.

## Validation Steps Performed

- OS Light + PowerToys Dark: opened What's New → headings, hyperlinks,
body text and caption buttons all render readable/dark-themed
(previously black/unreadable titles). Confirmed working.
- Switched OS Light → Dark while the Scoobe window was open → markdown
content and caption buttons update live.
- OS Dark: content pane, tables and titles render with the dark theme
(addresses the "light pane in a dark window" report).

<img width="1673" height="929" alt="Screenshot 2026-06-26 130638"
src="https://github.com/user-attachments/assets/1db7fb37-f5ee-485b-863e-fc1ba0d13f6f"
/>

_OS is in Light Mode, while the app settings are set to Dark mode_

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-06-29 20:33:40 +02:00
chatasweetie
5b8eaef852 remove side effects from Application.cs getters 2026-06-29 11:14:57 -07:00
chatasweetie
d2ba3d9ae4 remove side effects from IconImage getter 2026-06-29 10:57:00 -07:00
chatasweetie
285db8d4a9 convert Application.cs properties to [ObservableProperty] 2026-06-29 10:43:55 -07:00
chatasweetie
740e18e081 extract RefreshWorkspacesView from getter 2026-06-29 10:31:23 -07:00
Clint Rutkas
3bf682048e Grab and Move: square overlay corners in remote sessions (#48999) 2026-06-29 07:05:19 -07:00
moooyo
28a9bbe8f0 [PowerDisplay] Add configurable mouse wheel increment for slider controls (#49002)
## Summary of the Pull Request

Adds a Settings option that controls how much the PowerDisplay flyout
sliders (brightness, contrast, volume) change per mouse-wheel notch. The
value is chosen from a **preset dropdown** (`1, 2, 5, 10, 15, 20, 25`)
and defaults to **5**, preserving today's behavior. Previously the
per-notch step was hardcoded as
`helpers:SliderExtensions.MouseWheelChange="5"` in four places in the
flyout.

<img width="1282" height="620" alt="image"
src="https://github.com/user-attachments/assets/3b299a47-eb7b-4b53-b3dc-0540fbb25bfc"
/>


## PR Checklist

- [x] Closes: #48805
- [x] **Communication:** I've discussed this with core contributors
already. If the work hasn't been agreed, this work might be rejected
- [x] **Tests:** Added/updated and all pass — _added
`MouseWheelIncrementSettingsTests` to the existing
`PowerDisplay.Lib.UnitTests`; passing via `vstest.console.exe`._
- [x] **Localization:** All end-user-facing strings can be localized —
_new `PowerDisplay_MouseWheelIncrement.Header`/`.Description` in
`en-us/Resources.resw`, surfaced via `x:Uid`._
- [ ] **Dev docs:** Added/updated — _N/A: small settings addition, no
behavioral/architecture docs affected._
- [ ] **New binaries:** Added on the required places — _N/A: no new
binaries or projects (the unit test was added to an existing test
project)._
- [ ] [JSON for
signing](https://github.com/microsoft/PowerToys/blob/main/.pipelines/ESRPSigning_core.json)
for new binaries
- [ ] [WXS for
installer](https://github.com/microsoft/PowerToys/blob/main/installer/PowerToysSetup/Product.wxs)
for new binaries and localization folder
- [ ] [YML for CI
pipeline](https://github.com/microsoft/PowerToys/blob/main/.pipelines/ci/templates/build-powertoys-steps.yml)
for new test projects
- [ ] [YML for signed
pipeline](https://github.com/microsoft/PowerToys/blob/main/.pipelines/release.yml)
- [ ] **Documentation updated:** _N/A._

## Detailed Description of the Pull Request / Additional comments

A single `int MouseWheelIncrement` is added to `PowerDisplayProperties`
and persisted to `settings.json` under `mouse_wheel_increment`; it is
edited in the Settings UI and read by the flyout app. The value applies
uniformly to all four flyout sliders.

**Data model** —
`src/settings-ui/Settings.UI.Library/PowerDisplayProperties.cs`
- `MouseWheelIncrement` (`int`,
`[JsonPropertyName("mouse_wheel_increment")]`, default `5` set in the
constructor, mirroring `MonitorRefreshDelay`). Old `settings.json`
without the key deserializes to `5` (no migration). The whole type is
already registered with the source-generated JSON contexts, so the new
property serializes on both the Settings and flyout sides with no
context change.

**Settings UI (write side)** — `src/settings-ui/Settings.UI/...`
- `PowerDisplayViewModel`: `MouseWheelIncrement` (get/set via
`SetSettingsProperty`, calls `SignalSettingsUpdated()` so an open flyout
updates live) and `MouseWheelIncrementOptions` = `{ 1, 2, 5, 10, 15, 20,
25 }`.
- `PowerDisplayPage.xaml`: a `ComboBox` `SettingsCard` in the
flyout-settings expander, immediately after "Monitor refresh delay",
matching that card's markup.
- `Strings/en-us/Resources.resw`: `Mouse wheel increment` + description.

**Flyout (read side)** — `src/modules/powerdisplay/PowerDisplay/...`
- `MainViewModel`: `[ObservableProperty] int MouseWheelIncrement`
(default 5), loaded from settings in `LoadUIDisplaySettings()` (runs at
startup and on the settings-updated IPC event, so the all-displays
slider updates live).
- `MonitorViewModel`: a read-only proxy `MouseWheelIncrement =>
_mainViewModel?.MouseWheelIncrement ?? 5` plus
`RefreshMouseWheelIncrement()`, called from `ApplySettingsFromUI`'s
per-monitor loop so the per-monitor sliders update live.
- `MainWindow.xaml`: the four sliders'
`SliderExtensions.MouseWheelChange` now bind to the setting — the
all-displays slider to `ViewModel.MouseWheelIncrement`, the three
per-monitor sliders (brightness/contrast/volume, inside the
`MonitorViewModel` `DataTemplate`) to `MouseWheelIncrement`.

## Validation Steps Performed

**Automated**
- Unit tests
(`PowerDisplay.Lib.UnitTests/MouseWheelIncrementSettingsTests.cs`), run
via `vstest.console.exe` — passing:
  - default value is `5`;
- legacy `settings.json` missing the key deserializes to `5` (no
migration);
  - round-trip preserves a non-default value;
  - serialization emits the `mouse_wheel_increment` snake_case key.
- `PowerToys.Settings` and the PowerDisplay flyout app both compile
clean (the XAML compiler validates the new `x:Bind` bindings).
- Confirmed the property flows through the source-generated JSON
contexts (whole-type `[JsonSerializable(typeof(PowerDisplaySettings))]`)
on both the Settings and flyout sides.

**Pending (manual, on-device — reason this PR is a draft)**
- [ ] Settings: the dropdown shows `5` on a fresh profile; changing it
writes the new `mouse_wheel_increment` value to `settings.json`.
- [ ] Flyout: scrolling each slider (all-displays brightness,
per-monitor brightness/contrast/volume) steps by the selected value,
including live update while the flyout is open.

---------

Co-authored-by: Yu Leng <yuleng@microsoft.com>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-29 07:56:44 +00:00
Niels Laute
536e768cac Extend MSIX context-menu icon sizing to the standard logo set (#48925)
## Summary

Addresses #48924. Several MSIX context-menu items did not ship the full
standard logo set (e.g. File Locksmith was missing the 44x44 logo), so
Windows fell back to a default icon in some surfaces.

This updates the MSIX assets for **File Locksmith**, **Image Resizer**
and **PowerRename** to provide the full logo set: 44x44 logo,
small/large/150x150 tiles, and store logo.

## PR Checklist
- [x] Closes #48924
- [ ] Tested manually

## Validation
Icon-asset only change; no code modified.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-06-28 09:33:50 +02:00
Copilot
70ff4013b9 Add Shortcut Guide V2 manifest spec link to copilot-instructions.md (#48967)
Adds a reference to the WinGet Manifest Keyboard Shortcuts schema spec
in `.github/copilot-instructions.md` so AI agents know where to find the
correct field definitions, file naming conventions, and the `+` prefix
rule when creating or editing Shortcut Guide V2 manifest files.

## Summary of the Pull Request

Adds a new `## Shortcut Guide V2 Manifests` section to
`.github/copilot-instructions.md` linking to [`doc/specs/WinGet Manifest
Keyboard Shortcuts
schema.md`](../doc/specs/WinGet%20Manifest%20Keyboard%20Shortcuts%20schema.md).
This ensures agents authoring new manifest files follow the correct
schema and naming scheme (e.g., `<PackageId>.<locale>.yml`, `+` prefix
for apps without a WinGet package).

## PR Checklist

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

## Detailed Description of the Pull Request / Additional comments

`.github/copilot-instructions.md` gains a dedicated section:

```markdown
## Shortcut Guide V2 Manifests

When creating or editing Shortcut Guide keyboard shortcut manifest files, follow the schema and naming conventions in the spec:

- [WinGet Manifest Keyboard Shortcuts schema](<../doc/specs/WinGet Manifest Keyboard Shortcuts schema.md>) – manifest file format, field definitions, file naming, and the `+` prefix convention for apps without a WinGet package
```

No production code changes.

## Validation Steps Performed

- Verified the relative link resolves to the correct spec file in the
repository.
- Confirmed the section is correctly placed before "Detailed
Documentation".

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
2026-06-27 21:07:48 +00:00
ABHIJEET KALE
7a04d4c270 [ShortcutGuide] Add DaVinci Resolve keyboard shortcut manifest (#48651) (#48652)
Adds a shortcut manifest for DaVinci Resolve (professional video editing
and color grading application by Blackmagic Design) so it appears in the
Shortcut Guide overlay when the app is focused.

The manifest contains 88 of the most commonly used DaVinci Resolve
keyboard shortcuts organized into 8 categories:

| Section | Shortcuts | Highlights |
|---------|-----------|------------|
| Popular shortcuts | 25 | Page navigation (F5-F8), Playback (JKL,
Space), Edit basics (Cut, Blade, Ripple Delete) |
| Timeline navigation | 13 | Frame/clip/track navigation, zoom, edit
point jumping |
| Edit | 14 | Cut/Copy/Paste/Duplicate, Render in Place, Compound Clip |
| Color | 17 | Node management (Alt+S/P/L), viewer modes (1-5),
Grade/Keyframe |
| Fairlight | 8 | Mute/Solo/Record/Automation, Bounce Mix |
| Fusion | 8 | View switching (1-4), Merge, Keyframe |
| Media | 8 | Import, Smart Bin, Reveal in Explorer, Rename |
| Deliver | 5 | Render Queue, Start Render, Output settings |

The manifest follows the same YAML schema as existing manifests. No code
changes needed -- manifests are auto-discovered at startup.
2026-06-27 19:48:39 +00:00
Michael Jolley
8c434cd6f4 CmdPal: Fix LayoutCycleException in gallery screenshot strip (#48090)
## Summary

Extension Gallery detail pages crash with `LayoutCycleException` /
`AG_E_LAYOUT_CYCLE` when scrolling the screenshot strip horizontally on
extensions that have many screenshots.

## Root Cause

The screenshot `ItemsView` uses a horizontal `StackLayout` and is placed
in a Grid row with `Height="Auto"`, inside a vertical `ScrollViewer`.
The `ItemsView` itself contains an internal `ScrollView` for horizontal
scrolling.

When screenshots load asynchronously (`BitmapImage` decode completes),
this triggers a layout feedback loop:

1. Image decodes → `ItemContainer` re-measures
2. Auto-height Grid row re-measures, offering `ItemsView` new available
height
3. `ItemsView`'s internal `ScrollView` recalculates extents
4. Parent Grid row invalidates → outer `ScrollViewer` re-layouts →
re-measures children → back to step 1

## Fix

Two XAML-only changes to `ExtensionGalleryItemPage.xaml`:

1. **Fixed Grid row height**: Changed the screenshot strip row from
`Height="Auto"` to `Height="232"` (200px image + 16px top/bottom
padding). The parent no longer queries `ItemsView` for desired height,
breaking the cycle.

2. **Fixed item width**: Added `Width="356"` to the screenshot `Border`
template (16:9 ratio: 200 × 16/9 ≈ 356). Provides a stable size before
async image decode, preventing re-measure triggers.

Fixes #47901

Co-authored-by: root <root@io.bbq>
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-06-27 12:32:13 +02:00
chatasweetie
606e03b085 replace System.Timers.Timer with DispatcherTimer 2026-06-26 15:40:37 -07:00
ScymicX
d983dbc285 Fix VS Code workspace UNC paths (#48922)
## Summary of the Pull Request

Fixes #47719

VS Code stores recently opened workspaces as URIs. A workspace on a
Windows network share can be stored as
`file://server/share/workspace.code-workspace`.

The VS Code Workspaces plugin interpreted the server part of that URI as
a VS Code remote authority. Because it was not a recognized remote
authority, valid UNC workspaces were discarded.

This change recognizes file URIs with a server authority as local UNC
paths and converts them to the Windows UNC format: `\\server\share\...`.

## PR Checklist

* [x] Closes #47719
* [x] **Communication:** This implementation follows the suggested
direction in the issue discussion.
* [ ] **Tests:** No automated regression test added.
* [x] **Localization:** No user-facing strings were changed.
* [x] **New binaries:** No binaries were added.
* [x] **Documentation updated:** No documentation changes are required.

## Validation Steps Performed

* Built the full PowerToys solution locally in `Release | x64` with 0
errors.
* Started the locally built PowerToys instance.
* Verified that the VS Code Workspaces plugin finds local workspaces.
* Verified that UNC workspaces using both a hostname and an IP address
appear in PowerToys Run.
* Opened a UNC workspace successfully from PowerToys Run.
2026-06-26 22:00:25 +00:00
Niels Laute
fb6843b0f1 Refactor transparent overlay into TransparentWindow + TransientSurface (#48915)
## Summary

Refactors the reusable transparent-overlay infrastructure in
`src/common/Common.UI.Controls/` into a clean separation between a pure
host window and a self-animating acrylic surface.

### What changed

- **`TransparentWindow`** is now animation-agnostic. It raises `Showing`
/ `Hiding` events; `Hiding` exposes a deferral so the HWND stays visible
until the surface's out-animation finishes.
- **`TransientSurface`** (renamed from `TransparentCard`) is a
self-animating "pseudo-window" content control. It owns all chrome —
`ThemeShadow`, always-active desktop acrylic, 1px border, rounded
corners — and its own show/hide slide animations.
- `SlideFrom` (`None`/`Left`/`Top`/`Right`/`Bottom`) selects the slide
edge. `None` is the default and plays **no animation at all** (instant
show/hide).
- `AcrylicKind` (new) is exposed and bound to the backdrop via
`TemplateBinding`, defaulting to **thin acrylic**. Consumers can
override to `Default`/`Base`.
- **`AlwaysActiveDesktopAcrylicBackdrop`** gains a matching `Kind`
dependency property.
- **CmdPal `ToastWindow`** is migrated to the new pattern as the proving
consumer (`Surface.SubscribeTo(this)`).

### Coordination model

A module declares a `<TransientSurface>` as the window's content and
calls `SubscribeTo(window)` once. The window raises `Showing`/`Hiding`;
the surface animates itself in/out and uses the `Hiding` deferral to
keep the window alive until the out-animation completes.

## Testing

- `Common.UI.Controls` builds clean (x64 Debug, exit 0).
- `Microsoft.CmdPal.UI` builds clean (x64 Debug, exit 0).
- ToastWindow keeps its slide-up animation (`SlideFrom="Bottom"`).


https://github.com/user-attachments/assets/a06b0f1a-740a-4fcd-bba8-6f7a64ed261b

---------

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-06-26 21:25:17 +00:00
Clint Rutkas
6dd1ce5dd1 Dev/crutkas/ripple v2.1 + spelling allow-list follow-up (#48232)
## Summary of the Pull Request

Adds a follow-up metadata fix to the existing Mouse Highlighter ripple
v2.1 work by allowing the term `xhair` in repo spell-check
configuration.

## PR Checklist

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

## Detailed Description of the Pull Request / Additional comments

- Added `xhair` to `.github/actions/spell-check/expect.txt`.
- This addresses spelling feedback on MouseHighlighter ripple/crosshair
code without changing runtime behavior.
- No functional changes to Mouse Highlighter logic were made in this
follow-up commit.

## Validation Steps Performed

- Verified the only content change is the new `xhair` entry in
spell-check expected words.
- Ran secret scanning on changed file
(`.github/actions/spell-check/expect.txt`) with no findings.
- Ran parallel validation:
  - Code Review: no issues.
  - CodeQL: skipped as trivial metadata-only change.

---------

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Co-authored-by: Niels Laute <niels.laute@live.nl>
Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
2026-06-26 21:35:27 +02:00
Michael Jolley
9ea30ec523 CmdPal: Fix fallback results showing when disabled in Command Palette (#48777)
Fallback results were showing in Command Palette search results even
when the user had disabled them in settings.

When a fallback command is disabled (e.g., VS Code for Command Palette
with `IsEnabled = false`), it is excluded from
`configuredGlobalFallbackIds` (which only contains enabled + global
fallbacks). However, during search filtering, all fallbacks NOT in
`configuredGlobalFallbackIds` were unconditionally added to
`commonFallbacks`, which gets scored and displayed in results. This
means disabled fallbacks still appeared.

To fix, I added an `IsEnabled` check when building the `commonFallbacks`
list in `MainListPage.cs`. Disabled fallback commands are now properly
excluded from search results.

Fixes #48504

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-06-26 12:40:53 -05:00
chatasweetie
d7b8fe006d decouple ViewModel from Views via messaging and revert to WorkspacesEditorPage to {Binding} 2026-06-25 16:01:17 -07:00
Niels Laute
c777fcc1e4 Fix CmdPal gallery crash when extension has no homepage (#48869)
## Summary

Selecting an extension in the CmdPal Extension Gallery crashed the app
when that extension had **no `homepage`** defined.

## Root cause

In `ExtensionGalleryItemPage.xaml`, the "View repository"
`HyperlinkButton` bound its `NavigateUri` (a `System.Uri`) directly to
the raw string property `ViewModel.Homepage`:

```xml
NavigateUri="{x:Bind ViewModel.Homepage, Mode=OneWay}"
```

`x:Bind` evaluates **all** bindings on an element regardless of
`Visibility`, and to assign a `string` to a `Uri` target it generates a
`new Uri(value)` conversion. When `homepage` is undefined, `Homepage` is
`null`, so the binding executes `new Uri(null)` →
`ArgumentNullException` → the page crashes on load. The
`Visibility="{... HasHomepage}"` collapse did not help because the
`NavigateUri` binding still runs.

Every other `NavigateUri` x:Bind in the codebase (`Link`, `LinkUri`) is
already bound to a `Uri?`, so the homepage hyperlink was the lone
offender.

## Fix

- **`ExtensionGalleryItemViewModel.cs`** — Added a validated `Uri?
HomepageUri` property backed by the existing `_homepageHttpUri` (already
`null` for missing or non-web homepages).
- **`ExtensionGalleryItemPage.xaml`** — Bound `NavigateUri` to
`ViewModel.HomepageUri` instead of the raw string. `null` is valid for
`NavigateUri`, so no conversion occurs. The tooltip still shows the
`Homepage` string.
- **Tests** — Added coverage asserting `HomepageUri` is set for a web
homepage and `null` when missing.

## Verification

- `Microsoft.CmdPal.UI.ViewModels`, `Microsoft.CmdPal.UI` (XAML
compile), and the unit test project all built cleanly (x64/Release, exit
code 0).
- All 24 `ExtensionGalleryItemViewModelTests` pass, including the two
new cases.
- Manually verified in VS that opening an extension without a homepage
no longer crashes.

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



https://github.com/user-attachments/assets/b268bafb-6bee-4862-9fbf-7a0e06675e36

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-06-25 17:14:37 -05:00
Michael Jolley
28e078897a [CmdPal] Fix memory leak in PerformanceWidgetsPage network band items (#48880)
## Summary

Fixes a memory leak in the Performance Monitor dock extension where
`GetItems()` created **new** `ListItem` instances for `_networkUpItem`
and `_networkDownItem` on every call.

## Problem

When the dock subscribes to `ItemsChanged` and calls `GetItems()` to
refresh, the band page path allocates 2 new `ListItem` objects each time
— the old ones are replaced in the fields but never collected (they
remain referenced by the `DockItemViewModel` wrappers until the next
refresh cycle). Under normal operation this leaks ~2 objects/second
indefinitely.

## Fix

Move `_networkUpItem`/`_networkDownItem` creation into the constructor
(matching the pattern used by CPU, Memory, GPU, and Battery items).
`GetItems()` now returns stable references. The `Updated` event handler
already updates their `.Title` properties, which propagates to the UI
via `PropChanged` → `CommandItemViewModel.Model_PropChanged`.

## Validation

- Build succeeds (`Microsoft.CmdPal.Ext.PerformanceMonitor.csproj`)
- Network up/down band items still receive title updates via the
existing `Updated` handler
- No `RaiseItemsChanged()` needed — `ListItem.Title` setter fires
`PropChanged`, which `DockItemViewModel` already observes

---------

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-06-25 21:06:13 +00:00
chatasweetie
35d04b6fd3 remove Action delegates from ViewModel 2026-06-25 13:36:32 -07:00
chatasweetie
8a8887eaf8 replace Environment.Exit with graceful shutdown 2026-06-25 12:49:22 -07:00
chatasweetie
27633f6f7d remove View type references from ViewModel 2026-06-25 12:42:00 -07:00
chatasweetie
7c194bd108 decouple SnapshotWindow from ViewModel 2026-06-25 12:30:25 -07:00
Michael Jolley
64f1243bdf Skip auto-labeling PRs that already have labels (#48877)
## Summary

The auto-labeler workflow now skips pull requests that already have
labels applied before running the AI classification. This avoids
overwriting or duplicating labels that were manually set by contributors
or maintainers.

## Changes

- Added a check in `labelIssue()` that returns early for PRs with
existing labels, logging which labels are already present.
- Issues continue to be labeled regardless (only PRs get the skip
logic).

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-06-25 19:22:58 +00:00
chatasweetie
1539e6e061 convert Bindings to x:Bind 2026-06-25 10:12:33 -07:00
chatasweetie
ecc737d0e5 migrate MainViewModel to ObservableObject 2026-06-25 09:50:18 -07:00
chatasweetie
b72ae3b8b5 migrate Project.cs to ObservableObject 2026-06-25 09:41:13 -07:00
chatasweetie
e055f303e1 migrate BaseApplication to ObservableObject 2026-06-25 09:33:39 -07:00
chatasweetie
8fcdc199a0 remove dead INotifyPropertyChanged from MonitorSetup 2026-06-25 09:08:17 -07:00
chatasweetie
b7bb10f5f8 add CommunityToolkit.Mvvm and mark classes partial 2026-06-25 09:04:46 -07:00
Mario Hewardt
e1074bc835 ZoomIt - Update notices (#48843)
<!-- Enter a brief description/summary of your PR here. What does it
fix/what does it change/how was it tested (even manually, if necessary)?
-->
## Summary of the Pull Request
Update notices for ZoomIt dependency

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

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

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

<!-- Describe how you validated the behavior. Add automated tests
wherever possible, but list manual validation steps taken as well -->
## Validation Steps Performed
2026-06-25 17:31:51 +02:00
Michael Jolley
2390aacbfc CmdPal: Prevent same-page settings navigation (#48703)
Fixes #48698 by preventing the Command Palette settings frame from
navigating to the same page again, which avoids adding a self-navigation
entry to the back stack.

Co-authored-by: root <root@io.bbq>
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-06-25 14:54:39 +02:00
chatasweetie
f85b25696f localize AutomationProperties.Names 2026-06-24 14:18:10 -07:00
chatasweetie
b620c40f75 Port telemetry events 2026-06-24 13:41:23 -07:00
chatasweetie
5eb0591c58 Port desktop shortcut creation 2026-06-24 13:22:33 -07:00
chatasweetie
3a0b5df3af moved CAPTURING to resources 2026-06-24 12:47:52 -07:00
chatasweetie
23c8bcc9be detect and use system theme for preview rendering 2026-06-24 11:58:42 -07:00
chatasweetie
5a236c2e4c Add error logging to DrawHelper catch blocks 2026-06-24 11:53:21 -07:00
chatasweetie
f061ed9ac6 remove dead code: unused P/Invokes, COM refs, empty handler 2026-06-24 11:50:17 -07:00
chatasweetie
a6ab4ebd3e removed comments reference to migration and overlaywindow that does not work for winui 2026-06-24 10:52:55 -07:00
chatasweetie
a361c32911 remove migration note and morebutton_click handle, not needed because there is a flyout 2026-06-24 10:43:28 -07:00
chatasweetie
54ac6f7c96 add logger for preview if issue 2026-06-24 10:41:26 -07:00
chatasweetie
519bd5398f Use AppWindow.Move + Resize separately instead of MoveAndResize for window size persistence across DPI-scaled monitors 2026-06-24 10:02:03 -07:00
chatasweetie
e845b000d2 Add AutomationProperties to workspace cards, More button, Save button, sort/position ComboBoxes, CLI TextBox, position fields, editor heading, and snapshot description for screen reader support 2026-06-24 08:52:33 -07:00
chatasweetie
60b78051fe fix keyboard accessibility: Escape key + capture focus 2026-06-23 14:47:18 -07:00
chatasweetie
c2fcf06391 update editor exe path to WinUI3Apps in infrastructure 2026-06-23 14:19:26 -07:00
chatasweetie
7b7a54a73f Delete dead HeadingTextBlock control 2026-06-23 14:09:40 -07:00
chatasweetie
c15e28bbca add pulsing red dot with CAPTURING text to replace red overlay borders shown during capture, due to WinUI limiation for multi-window support 2026-06-23 14:06:36 -07:00
chatasweetie
c3fb02567c mark hotkey thread as background for clean process exit 2026-06-23 13:23:08 -07:00
chatasweetie
c1e623cba9 localize hardcoded strings 2026-06-23 13:16:50 -07:00
chatasweetie
f81758d4e7 add a _captured flag, set it in SnapshotButtonClicked, skip CancelSnapshot in OnClosed if already captured 2026-06-23 13:02:11 -07:00
chatasweetie
c333ed96c0 add delete confirmation dialog 2026-06-23 12:57:20 -07:00
chatasweetie
fb46aaa913 add call base.Dispose in Disponse, haha 2026-06-23 12:40:55 -07:00
chatasweetie
de9b92e94a port DrawHelper to display icons on workspace cards on main 2026-06-23 12:34:40 -07:00
chatasweetie
2c46c8854c add min-size constrain and test 2026-06-23 11:50:08 -07:00
chatasweetie
3a30809c80 Update test project for WinUI Editor reference; all 51 tests pass 2026-06-23 11:26:14 -07:00
chatasweetie
a854e2cecc Add ProgressRing during workspace capture 2026-06-23 11:13:43 -07:00
chatasweetie
525097b54c Replace WorkspacesEditor in PowerToys.slnx with WorkspacesEditor.WinUI, update module interface to launch from WinUI3Apps subfolder, eelete old WPF WorkspacesEditor project 2026-06-23 11:05:28 -07:00
chatasweetie
7b4f89bfa6 Add SnapshotWindow dialog and OverlayWindow, wire Create Workspace capture flow: minimize → capture dialog → snapshot tool → edit page. 2026-06-23 10:43:18 -07:00
chatasweetie
31bf0aaf59 Port workspace list and editor pages: models, ViewModel, converters, data templates with Expanders, search/sort, app position editing, save/cancel navigation 2026-06-23 09:41:22 -07:00
chatasweetie
127eab3eab Empty WinUI 3 project for the Editor migration: custom entry point, GPO/singleton checks, window state persistence, hotkey toggle, IconHelper. 2026-06-22 14:39:00 -07:00
chatasweetie
76303d2f52 Add baseline tests and migration plan for Workspaces Editor WinUI migration 2026-06-18 15:50:39 -07:00
216 changed files with 9393 additions and 5452 deletions

View File

@@ -135,6 +135,7 @@ BITMAPINFO
BITMAPINFOHEADER
BITSPERPEL
BITSPIXEL
Blackmagic
bla
BLENDFUNCTION
blittable
@@ -539,6 +540,7 @@ EXTRINSICPROPERTIES
eyetracker
FANCYZONESDRAWLAYOUTTEST
FANCYZONESEDITOR
Fairlight
FARPROC
fdw
fdx
@@ -1232,6 +1234,8 @@ NOTSRCCOPY
NOTSRCERASE
Notupdated
notwindows
NOTXORPEN
Nouveaut
nowarn
NOZORDER
NPH
@@ -2178,6 +2182,7 @@ xclip
xcopy
xdf
xfd
xhair
xmp
Xoshiro
xsi

View File

@@ -30,6 +30,12 @@ These are auto-applied based on file location:
- [Runner & Settings UI](.github/instructions/runner-settings-ui.instructions.md)
- [Common Libraries](.github/instructions/common-libraries.instructions.md)
## Shortcut Guide V2 Manifests
When creating or editing Shortcut Guide keyboard shortcut manifest files, follow the schema and naming conventions in the spec:
- [WinGet Manifest Keyboard Shortcuts schema](<../doc/specs/WinGet Manifest Keyboard Shortcuts schema.md>) manifest file format, field definitions, file naming, and the `+` prefix convention for apps without a WinGet package
## Detailed Documentation
- [Architecture](../doc/devdocs/core/architecture.md)

View File

@@ -1,6 +1,6 @@
---
name: wpf-to-winui3-migration
description: Guide for migrating PowerToys modules from WPF to WinUI 3 (Windows App SDK). Use when asked to migrate WPF code, convert WPF XAML to WinUI, replace System.Windows namespaces with Microsoft.UI.Xaml, update Dispatcher to DispatcherQueue, replace DynamicResource with ThemeResource, migrate imaging APIs from System.Windows.Media.Imaging to Windows.Graphics.Imaging, convert WPF Window to WinUI Window, migrate .resx to .resw resources, migrate custom Observable/RelayCommand to CommunityToolkit.Mvvm source generators, handle WPF-UI (Lepo) to WinUI native control migration, or fix installer/build pipeline issues after migration. Keywords: WPF, WinUI, WinUI3, migration, porting, convert, namespace, XAML, Dispatcher, DispatcherQueue, imaging, BitmapImage, Window, ContentDialog, ThemeResource, DynamicResource, ResourceLoader, resw, resx, CommunityToolkit, ObservableProperty, WPF-UI, SizeToContent, AppWindow, SoftwareBitmap.
description: 'Guide for migrating PowerToys modules from WPF to WinUI 3 (Windows App SDK). Use when asked to migrate WPF code, convert WPF XAML to WinUI, replace System.Windows namespaces with Microsoft.UI.Xaml, update Dispatcher to DispatcherQueue, replace DynamicResource with ThemeResource, migrate imaging APIs from System.Windows.Media.Imaging to Windows.Graphics.Imaging, convert WPF Window to WinUI Window, migrate .resx to .resw resources, migrate custom Observable/RelayCommand to CommunityToolkit.Mvvm source generators, handle WPF-UI (Lepo) to WinUI native control migration, or fix installer/build pipeline issues after migration. Keywords: WPF, WinUI, WinUI3, migration, porting, convert, namespace, XAML, Dispatcher, DispatcherQueue, imaging, BitmapImage, Window, ContentDialog, ThemeResource, DynamicResource, ResourceLoader, resw, resx, CommunityToolkit, ObservableProperty, WPF-UI, SizeToContent, AppWindow, SoftwareBitmap.'
license: Complete terms in LICENSE.txt
---

View File

@@ -73,6 +73,13 @@ jobs:
const itemType = issue.pull_request ? 'Pull request' : 'Issue';
// Skip pull requests that already have labels applied.
if (issue.pull_request && issue.labels && issue.labels.length > 0) {
const existingLabels = issue.labels.map(l => l.name).join(', ');
console.log(`${itemType} #${issueNumber} already has labels (${existingLabels}); skipping.`);
return;
}
const title = issue.title ?? '';
const body = issue.body ?? '';

View File

@@ -232,8 +232,8 @@
"PowerToys.WorkspacesSnapshotTool.exe",
"PowerToys.WorkspacesLauncher.exe",
"PowerToys.WorkspacesWindowArranger.exe",
"PowerToys.WorkspacesEditor.exe",
"PowerToys.WorkspacesEditor.dll",
"WinUI3Apps\\PowerToys.WorkspacesEditor.exe",
"WinUI3Apps\\PowerToys.WorkspacesEditor.dll",
"PowerToys.WorkspacesLauncherUI.exe",
"PowerToys.WorkspacesLauncherUI.dll",
"PowerToys.WorkspacesModuleInterface.dll",

View File

@@ -4,6 +4,29 @@
<Import Project="$(MSBuildCachePackageRoot)\build\$(MSBuildCachePackageName).targets" Condition="'$(MSBuildCacheEnabled)' == 'true'" />
<Import Project="$(MSBuildCacheSharedCompilationPackageRoot)\build\Microsoft.MSBuildCache.SharedCompilation.targets" Condition="'$(MSBuildCacheEnabled)' == 'true'" />
<!--
Onboarding guard: PowerToys has deeply nested source paths that exceed the legacy
260-character MAX_PATH limit. Without Windows long path support enabled, the build
fails with cryptic "path too long" / "could not find file" errors that are hard for
new contributors to diagnose. Detect the missing registry setting up front and emit a
clear, actionable error before the confusing failures occur.
- Covers both Visual Studio (Ctrl+Shift+B) and the command-line build scripts.
- Runs only during real builds (skips design-time/IntelliSense passes).
- Bypass with /p:SkipLongPathsCheck=true if you know what you're doing.
See tools\build\setup-dev-environment.ps1 to enable everything automatically.
-->
<Target Name="EnsureLongPathsEnabled"
BeforeTargets="PrepareForBuild"
Condition="'$(DesignTimeBuild)' != 'true' and '$(SkipLongPathsCheck)' != 'true' and '$(OS)' == 'Windows_NT'">
<PropertyGroup>
<_LongPathsEnabled>$([MSBuild]::GetRegistryValueFromView('HKEY_LOCAL_MACHINE\SYSTEM\CurrentControlSet\Control\FileSystem', 'LongPathsEnabled', null, RegistryView.Registry64))</_LongPathsEnabled>
</PropertyGroup>
<Error Condition="'$(_LongPathsEnabled)' != '1'"
Code="PTLONGPATH"
Text="Windows long path support is not enabled. PowerToys source paths exceed the 260-character MAX_PATH limit, so the build will fail with cryptic 'path too long' errors. Fix it by running (from an elevated PowerShell): .\tools\build\setup-dev-environment.ps1 -- or set HKLM\SYSTEM\CurrentControlSet\Control\FileSystem\LongPathsEnabled = 1 (DWORD) and restart Windows. To bypass this check, build with /p:SkipLongPathsCheck=true." />
</Target>
<!-- Override ManifestTool to the x64 host tool under WindowsSdkDir for all projects once the SDK path is known. -->
<PropertyGroup Label="ManifestToolOverride">
<ManifestTool Condition="Exists('$(WindowsSdkDir)bin\x64\mt.exe')">$(WindowsSdkDir)bin\x64\mt.exe</ManifestTool>

View File

@@ -26,7 +26,7 @@
<PackageVersion Include="CommunityToolkit.WinUI.Controls.Sizers" Version="8.2.251219" />
<PackageVersion Include="CommunityToolkit.WinUI.Converters" Version="8.2.251219" />
<PackageVersion Include="CommunityToolkit.WinUI.Extensions" Version="8.2.251219" />
<PackageVersion Include="CommunityToolkit.WinUI.UI.Controls.DataGrid" Version="7.1.2" />
<PackageVersion Include="CommunityToolkit.WinUI.UI.Controls.DataGrid" Version="7.1.2" />
<PackageVersion Include="CommunityToolkit.Labs.WinUI.Controls.MarkdownTextBlock" Version="0.1.260116-build.2514" />
<PackageVersion Include="ControlzEx" Version="6.0.0" />
<PackageVersion Include="HelixToolkit" Version="2.24.0" />
@@ -38,7 +38,7 @@
<PackageVersion Include="Mages" Version="3.0.0" />
<PackageVersion Include="Markdig.Signed" Version="0.34.0" />
<!-- Including MessagePack to force version, since it's used by StreamJsonRpc but contains vulnerabilities. After StreamJsonRpc updates the version of MessagePack, we can upgrade StreamJsonRpc instead. -->
<PackageVersion Include="MessagePack" Version="3.1.3" />
<PackageVersion Include="MessagePack" Version="3.1.7" />
<PackageVersion Include="Microsoft.CodeAnalysis.NetAnalyzers" Version="10.0.102" />
<PackageVersion Include="Microsoft.CommandPalette.Extensions" Version="0.9.260303001" />
<PackageVersion Include="Microsoft.Data.Sqlite" Version="10.0.8" />
@@ -64,7 +64,7 @@
<PackageVersion Include="Microsoft.SemanticKernel.Connectors.MistralAI" Version="1.71.0-alpha" />
<PackageVersion Include="Microsoft.SemanticKernel.Connectors.Ollama" Version="1.71.0-alpha" />
<PackageVersion Include="Microsoft.Toolkit.Uwp.Notifications" Version="7.1.2" />
<PackageVersion Include="Microsoft.Web.WebView2" Version="1.0.3719.77" />
<PackageVersion Include="Microsoft.Web.WebView2" Version="1.0.4022.49" />
<!-- Package Microsoft.Win32.SystemEvents added as a hack for being able to exclude the runtime assets so they don't conflict with 8.0.1. This is a dependency of System.Drawing.Common but the 8.0.1 version wasn't published to nuget. -->
<PackageVersion Include="Microsoft.Win32.SystemEvents" Version="10.0.8" />
<PackageVersion Include="Microsoft.WindowsPackageManager.ComInterop" Version="1.10.340" />
@@ -76,7 +76,7 @@
This is present due to a bug in CsWinRT where WPF projects cause the analyzer to fail.
-->
<PackageVersion Include="Microsoft.Windows.CsWinRT" Version="2.2.0" />
<PackageVersion Include="Microsoft.Windows.ImplementationLibrary" Version="1.0.250325.1"/>
<PackageVersion Include="Microsoft.Windows.ImplementationLibrary" Version="1.0.250325.1" />
<PackageVersion Include="Microsoft.Windows.SDK.BuildTools" Version="10.0.26100.6901" />
<PackageVersion Include="Microsoft.WindowsAppSDK" Version="2.2.0" />
<PackageVersion Include="Microsoft.WindowsAppSDK.Foundation" Version="2.1.0" />
@@ -151,4 +151,4 @@
<PackageVersion Include="Microsoft.VariantAssignment.Client" Version="2.4.17140001" />
<PackageVersion Include="Microsoft.VariantAssignment.Contract" Version="3.0.16990001" />
</ItemGroup>
</Project>
</Project>

View File

@@ -12,6 +12,7 @@ This software incorporates material from third parties.
- Peek
- PowerDisplay
- Registry Preview
- ZoomIt
## Utility: Color Picker
@@ -1549,6 +1550,69 @@ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
## Utility: ZoomIt
### libwebp
ZoomIt uses libwebp to encode screenshots in the WebP image format.
**Source**: <https://github.com/webmproject/libwebp>
BSD-3-Clause License
Copyright (c) 2010, Google Inc. All rights reserved.
Redistribution and use in source and binary forms, with or without
modification, are permitted provided that the following conditions are
met:
* Redistributions of source code must retain the above copyright
notice, this list of conditions and the following disclaimer.
* Redistributions in binary form must reproduce the above copyright
notice, this list of conditions and the following disclaimer in
the documentation and/or other materials provided with the
distribution.
* Neither the name of Google nor the names of its contributors may
be used to endorse or promote products derived from this software
without specific prior written permission.
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
Additional IP Rights Grant (Patents)
"These implementations" means the copyrightable works that implement the WebM
codecs distributed by Google as part of the WebM Project.
Google hereby grants to you a perpetual, worldwide, non-exclusive, no-charge,
royalty-free, irrevocable (except as stated in this section) patent license to
make, have made, use, offer to sell, sell, import, transfer, and otherwise
run, modify and propagate the contents of these implementations of WebM, where
such license applies only to those patent claims, both currently owned by
Google and acquired in the future, licensable by Google that are necessarily
infringed by these implementations of WebM. This grant does not include claims
that would be infringed only as a consequence of further modification of these
implementations. If you or your agent or exclusive licensee institute or order
or agree to the institution of patent litigation or any other patent
enforcement activity against any entity (including a cross-claim or
counterclaim in a lawsuit) alleging that any of these implementations of WebM
or any code incorporated within any of these implementations of WebM
constitute direct or contributory patent infringement, or inducement of
patent infringement, then any patent rights granted to you under this License
for these implementations of WebM shall terminate as of the date such
litigation is filed.
## NuGet Packages used by PowerToys
- AdaptiveCards.ObjectModel.WinUI3

View File

@@ -1022,7 +1022,7 @@
<Platform Solution="*|ARM64" Project="ARM64" />
<Platform Solution="*|x64" Project="x64" />
</Project>
<Project Path="src/modules/Workspaces/WorkspacesEditor/WorkspacesEditor.csproj">
<Project Path="src/modules/Workspaces/WorkspacesEditor.WinUI/WorkspacesEditor.WinUI.csproj">
<Platform Solution="*|ARM64" Project="ARM64" />
<Platform Solution="*|x64" Project="x64" />
</Project>

View File

@@ -1,5 +0,0 @@
# [CleanUp_tool](/tools/CleanUp_tool/) and [CleanUp_tool_powershell_script](/tools/CleanUp_tool_powershell_script/CleanUp_tool.ps1)
This tool, respective this powershell script, is used to clean up the PowerToys installation. It cleans the `AppData` folder and the registry.
This tool is currently very outdated and just cleans up the registry keys of some few modules.

View File

@@ -10,7 +10,6 @@ Following tools are currently available:
* [BugReportTool](bug-report-tool.md) - A tool to collect logs and system information for bug reports.
* [Build tools](build-tools.md) - A set of scripts that help building PowerToys.
* [Clean up tool](clean-up-tool.md) - A tool to clean up the PowerToys installation.
* [Monitor info report](monitor-info-report.md) - A small diagnostic tool which helps identifying WinAPI bugs related to the physical monitor detection.
* [project template](/tools/project_template/README.md) - A Visual Studio project template for a new PowerToys project.
* [StylesReportTool](styles-report-tool.md) - A tool to collect information about an open window.

View File

@@ -1619,7 +1619,7 @@ UINT __stdcall TerminateProcessesCA(MSIHANDLE hInstall)
L"PowerToys.WorkspacesSnapshotTool.exe",
L"PowerToys.WorkspacesLauncher.exe",
L"PowerToys.WorkspacesLauncherUI.exe",
L"PowerToys.WorkspacesEditor.exe",
L"WinUI3Apps\\PowerToys.WorkspacesEditor.exe",
L"PowerToys.WorkspacesWindowArranger.exe",
L"Microsoft.CmdPal.UI.exe",
L"Microsoft.CmdPal.Ext.PowerToys.exe",

View File

@@ -1,4 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<packages>
<package id="Microsoft.Windows.ImplementationLibrary" version="1.0.250325.1" targetFramework="native" />
</packages>
<package id="Microsoft.Windows.ImplementationLibrary" version="1.0.260126.7" targetFramework="native" />
</packages>

View File

@@ -29,8 +29,30 @@ namespace Microsoft.PowerToys.Common.UI.Controls.Backdrops;
/// </remarks>
public sealed partial class AlwaysActiveDesktopAcrylicBackdrop : SystemBackdrop
{
/// <summary>
/// Identifies the <see cref="Kind"/> dependency property.
/// </summary>
public static readonly DependencyProperty KindProperty = DependencyProperty.Register(
nameof(Kind),
typeof(DesktopAcrylicKind),
typeof(AlwaysActiveDesktopAcrylicBackdrop),
new PropertyMetadata(DesktopAcrylicKind.Default, OnKindChanged));
private readonly Dictionary<ICompositionSupportsSystemBackdrop, BackdropTarget> _targets = new();
/// <summary>
/// Gets or sets the desktop acrylic material variant to render. Defaults to
/// <see cref="DesktopAcrylicKind.Default"/> (the standard, more opaque
/// acrylic); <see cref="DesktopAcrylicKind.Thin"/> renders a lighter, more
/// translucent material and <see cref="DesktopAcrylicKind.Base"/> the base
/// material. Changing this updates any live backdrop targets immediately.
/// </summary>
public DesktopAcrylicKind Kind
{
get => (DesktopAcrylicKind)GetValue(KindProperty);
set => SetValue(KindProperty, value);
}
protected override void OnTargetConnected(ICompositionSupportsSystemBackdrop connectedTarget, XamlRoot xamlRoot)
{
base.OnTargetConnected(connectedTarget, xamlRoot);
@@ -41,7 +63,10 @@ public sealed partial class AlwaysActiveDesktopAcrylicBackdrop : SystemBackdrop
Theme = ResolveTheme(xamlRoot),
};
var controller = new DesktopAcrylicController();
var controller = new DesktopAcrylicController
{
Kind = Kind,
};
controller.SetSystemBackdropConfiguration(configuration);
controller.AddSystemBackdropTarget(connectedTarget);
@@ -70,6 +95,17 @@ public sealed partial class AlwaysActiveDesktopAcrylicBackdrop : SystemBackdrop
}
}
private static void OnKindChanged(DependencyObject d, DependencyPropertyChangedEventArgs e)
{
var self = (AlwaysActiveDesktopAcrylicBackdrop)d;
var kind = (DesktopAcrylicKind)e.NewValue;
foreach (var target in self._targets.Values)
{
target.Controller.Kind = kind;
}
}
private static SystemBackdropTheme ResolveTheme(XamlRoot xamlRoot) =>
xamlRoot.Content is FrameworkElement rootElement
? rootElement.ActualTheme switch

View File

@@ -5,9 +5,9 @@
xmlns:backdrops="using:Microsoft.PowerToys.Common.UI.Controls.Backdrops"
xmlns:local="using:Microsoft.PowerToys.Common.UI.Controls">
<Style BasedOn="{StaticResource DefaultTransparentCardStyle}" TargetType="local:TransparentCard" />
<Style BasedOn="{StaticResource DefaultTransientSurfaceStyle}" TargetType="local:TransientSurface" />
<Style x:Key="DefaultTransparentCardStyle" TargetType="local:TransparentCard">
<Style x:Key="DefaultTransientSurfaceStyle" TargetType="local:TransientSurface">
<Setter Property="BorderBrush" Value="{ThemeResource SurfaceStrokeColorDefaultBrush}" />
<Setter Property="BorderThickness" Value="1" />
<Setter Property="CornerRadius" Value="8" />
@@ -16,7 +16,7 @@
<Setter Property="VerticalContentAlignment" Value="Stretch" />
<Setter Property="Template">
<Setter.Value>
<ControlTemplate TargetType="local:TransparentCard">
<ControlTemplate TargetType="local:TransientSurface">
<Grid
BorderBrush="{TemplateBinding BorderBrush}"
BorderThickness="{TemplateBinding BorderThickness}"
@@ -27,7 +27,7 @@
</Grid.Shadow>
<SystemBackdropElement CornerRadius="{TemplateBinding CornerRadius}">
<SystemBackdropElement.SystemBackdrop>
<backdrops:AlwaysActiveDesktopAcrylicBackdrop />
<backdrops:AlwaysActiveDesktopAcrylicBackdrop Kind="{TemplateBinding AcrylicKind}" />
</SystemBackdropElement.SystemBackdrop>
</SystemBackdropElement>
<ContentPresenter
@@ -41,5 +41,4 @@
</Setter.Value>
</Setter>
</Style>
</ResourceDictionary>

View File

@@ -0,0 +1,467 @@
// 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 CommunityToolkit.WinUI;
using CommunityToolkit.WinUI.Animations;
using Microsoft.PowerToys.Common.UI.Controls.Window;
using Microsoft.UI.Composition.SystemBackdrops;
using Microsoft.UI.Dispatching;
using Microsoft.UI.Xaml;
using Microsoft.UI.Xaml.Controls;
using Microsoft.UI.Xaml.Hosting;
namespace Microsoft.PowerToys.Common.UI.Controls;
/// <summary>
/// A floating, self-animating "pseudo window" surface for transient PowerToys
/// overlays (toasts, banners, indicators). It looks like a control but behaves
/// like a lightweight window: it provides the PowerToys-standard chrome — 1 px
/// border in <c>SurfaceStrokeColorDefaultBrush</c>, 8 px corner radius, a
/// <c>ThemeShadow</c>, and an always-active desktop acrylic backdrop — and owns
/// its own show/hide animations.
/// </summary>
/// <remarks>
/// <para>Designed to be declared as the root content of a
/// <see cref="TransparentWindow"/>, which stays animation-agnostic. Call
/// <see cref="SubscribeTo"/> once (e.g. from the hosting window's constructor)
/// to wire this surface to the window's <see cref="TransparentWindow.Showing"/> /
/// <see cref="TransparentWindow.Hiding"/> events. From then on the surface
/// animates itself in/out whenever the window is shown or hidden, and uses the
/// <see cref="TransparentWindow.Hiding"/> deferral to keep the window visible
/// until its out-animation finishes.</para>
/// <para>The show transition comes from the window's
/// <see cref="TransparentWindow.Show(Transition)"/> call (or from
/// <see cref="ShowTransition"/> when shown without one); the hide transition
/// always comes from <see cref="HideTransition"/>. Animations target the
/// surface itself, so the entire surface (border, acrylic, shadow, inner
/// content) animates as one. Apps that want a different look supply their own
/// <c>Style TargetType="TransientSurface"</c> in resources — the standard WinUI
/// restyle path.</para>
/// </remarks>
public sealed partial class TransientSurface : ContentControl
{
private const float ShadowDepth = 32f;
private const double SlideInOffset = 24;
private const double SlideOutOffset = 12;
// "Pop" transition: scale between 96% and 100% (a subtle 4% grow). Following
// Fluent motion guidance the scale uses a decelerate (EaseOut) curve; the
// fade is kept fast so the surface reads as an instant, light pop.
//
// The fade must run at least as long as the scale: if the scale outlasted the
// fade, the surface would reach full opacity while still visibly growing,
// which reads as a "resize" rather than a pop. Keeping the fade >= the scale
// hides the growth under the opacity ramp, so by the time it is fully opaque
// it is already at 100% size.
private const float PopScaleFrom = 0.96f;
private const double PopFadeShowMs = 180;
private const double PopScaleShowMs = 150;
private const double PopFadeHideMs = 120;
private const double PopScaleHideMs = 120;
public static readonly DependencyProperty ShowTransitionProperty = DependencyProperty.Register(
nameof(ShowTransition),
typeof(Transition),
typeof(TransientSurface),
new PropertyMetadata(Transition.None, OnTransitionChanged));
public static readonly DependencyProperty HideTransitionProperty = DependencyProperty.Register(
nameof(HideTransition),
typeof(Transition),
typeof(TransientSurface),
new PropertyMetadata(Transition.None, OnTransitionChanged));
public static readonly DependencyProperty AcrylicKindProperty = DependencyProperty.Register(
nameof(AcrylicKind),
typeof(DesktopAcrylicKind),
typeof(TransientSurface),
new PropertyMetadata(DesktopAcrylicKind.Thin));
private readonly DispatcherQueueTimer _hideCompletedTimer = DispatcherQueue.GetForCurrentThread().CreateTimer();
private readonly ImplicitAnimationSet _noAnimations = new();
private ImplicitAnimationSet _showAnimations = new();
private ImplicitAnimationSet _hideAnimations = new();
private bool _hasCustomShowAnimations;
private bool _hasCustomHideAnimations;
private Action? _abandonPendingHide;
public TransientSurface()
{
DefaultStyleKey = typeof(TransientSurface);
RebuildDefaultAnimations();
// Pin the scale center to the surface's center so the "Pop" transition
// grows/shrinks from the middle, not the top-left corner. An expression
// animation bound to the visual's own size keeps the center correct from
// the very first frame (a SizeChanged handler would race the show
// animation and let the first pop scale from 0,0).
PinScaleCenter();
// Start hidden so the first Show() animates in from the configured pose.
Visibility = Visibility.Collapsed;
}
/// <summary>
/// Raised after <see cref="Hide"/> once the longest animation in
/// <see cref="HideAnimations"/> (delay + duration) has completed.
/// </summary>
public event EventHandler? HideCompleted;
/// <summary>
/// Gets or sets the transition played when the surface is shown without an
/// explicit one (see <see cref="Show()"/>). Defaults to
/// <see cref="Transition.None"/>, which plays no animation at all (the
/// surface appears instantly); a directional value adds a fade plus a slide
/// in from that edge, and <see cref="Transition.Pop"/> a fade plus a subtle
/// scale-up. Changing this regenerates the default <see cref="ShowAnimations"/>
/// unless it has been set explicitly.
/// </summary>
public Transition ShowTransition
{
get => (Transition)GetValue(ShowTransitionProperty);
set => SetValue(ShowTransitionProperty, value);
}
/// <summary>
/// Gets or sets the transition played when the surface is hidden (see
/// <see cref="Hide"/>). Defaults to <see cref="Transition.None"/>, which
/// plays no animation at all (the surface disappears instantly); a
/// directional value adds a fade plus a slide out toward that edge, and
/// <see cref="Transition.Pop"/> a fade plus a subtle scale-down. Changing
/// this regenerates the default <see cref="HideAnimations"/> unless it has
/// been set explicitly.
/// </summary>
public Transition HideTransition
{
get => (Transition)GetValue(HideTransitionProperty);
set => SetValue(HideTransitionProperty, value);
}
/// <summary>
/// Gets or sets the desktop acrylic material variant painted behind the
/// surface. Defaults to <see cref="DesktopAcrylicKind.Thin"/> (a lighter,
/// more translucent material); set <see cref="DesktopAcrylicKind.Default"/>
/// for the standard, more opaque acrylic or <see cref="DesktopAcrylicKind.Base"/>
/// for the base material. Has no effect when a custom template without the
/// default acrylic backdrop is applied.
/// </summary>
public DesktopAcrylicKind AcrylicKind
{
get => (DesktopAcrylicKind)GetValue(AcrylicKindProperty);
set => SetValue(AcrylicKindProperty, value);
}
/// <summary>
/// Gets or sets the animations played when <see cref="Show()"/> flips the
/// surface to <see cref="Visibility.Visible"/>. Defaults to the animation
/// derived from <see cref="ShowTransition"/>. Assigning a value marks the set
/// as custom so <see cref="ShowTransition"/> no longer overwrites it.
/// </summary>
public ImplicitAnimationSet ShowAnimations
{
get => _showAnimations;
set
{
_showAnimations = value ?? new ImplicitAnimationSet();
_hasCustomShowAnimations = true;
}
}
/// <summary>
/// Gets or sets the animations played when <see cref="Hide"/> flips the
/// surface to <see cref="Visibility.Collapsed"/>. Defaults to the animation
/// derived from <see cref="HideTransition"/>. Assigning a value marks the set
/// as custom so <see cref="HideTransition"/> no longer overwrites it.
/// </summary>
public ImplicitAnimationSet HideAnimations
{
get => _hideAnimations;
set
{
_hideAnimations = value ?? new ImplicitAnimationSet();
_hasCustomHideAnimations = true;
}
}
/// <summary>
/// Wires this surface to a hosting <see cref="TransparentWindow"/> so it
/// animates itself in and out in response to the window's
/// <see cref="TransparentWindow.Showing"/> / <see cref="TransparentWindow.Hiding"/>
/// events. Call this once after the surface has been set as (or placed within)
/// the window's content.
/// </summary>
/// <param name="host">The window whose show/hide transitions drive this surface.</param>
public void SubscribeTo(TransparentWindow host)
{
ArgumentNullException.ThrowIfNull(host);
host.Showing += OnHostShowing;
host.Hiding += OnHostHiding;
}
/// <summary>
/// Resets the surface to its hidden pose and flips it to
/// <see cref="Visibility.Visible"/> so <see cref="ShowAnimations"/> plays,
/// using <paramref name="transition"/> as the show transition.
/// </summary>
/// <param name="transition">The transition to play when showing.</param>
public void Show(Transition transition)
{
ShowTransition = transition;
Show();
}
/// <summary>
/// Resets the surface to its hidden pose and flips it to
/// <see cref="Visibility.Visible"/> so <see cref="ShowAnimations"/> plays.
/// Repeated calls re-trigger the show animation cleanly and cancel any
/// pending <see cref="HideCompleted"/> notification.
/// </summary>
public void Show()
{
_hideCompletedTimer.Stop();
// If a hide from a previous cycle is still in flight, abandon it: drop its
// pending HideCompleted handler so the outstanding deferral is never
// completed. We are showing again, so the host must keep the window
// visible instead of later hiding it for this interrupted cycle.
_abandonPendingHide?.Invoke();
_abandonPendingHide = null;
// Attach the show animation and detach any hide animation: when Show() is
// called while the surface is still visible, the Collapsed -> Visible
// restart below would otherwise play the hide animation (a fade/scale out)
// immediately before the show, producing a visible flash. The real hide
// animation is re-attached just-in-time in Hide().
Implicit.SetShowAnimations(this, _showAnimations);
Implicit.SetHideAnimations(this, _noAnimations);
// Reset to the hidden pose so the show animation always animates from the
// configured starting frame.
Visibility = Visibility.Collapsed;
Visibility = Visibility.Visible;
}
/// <summary>
/// Flips the surface to <see cref="Visibility.Collapsed"/> so
/// <see cref="HideAnimations"/> plays, then raises <see cref="HideCompleted"/>
/// once the longest animation in <see cref="HideAnimations"/> (delay +
/// duration) has completed.
/// </summary>
public void Hide()
{
// Attach the hide animation just before collapsing (Show() detaches it to
// avoid a flash when re-showing an already-visible surface).
Implicit.SetHideAnimations(this, _hideAnimations);
Visibility = Visibility.Collapsed;
_hideCompletedTimer.Debounce(
() => HideCompleted?.Invoke(this, EventArgs.Empty),
interval: GetAnimationSetTotalDuration(_hideAnimations),
immediate: false);
}
private static void OnTransitionChanged(DependencyObject d, DependencyPropertyChangedEventArgs e)
{
((TransientSurface)d).RebuildDefaultAnimations();
}
private static TimeSpan GetAnimationSetTotalDuration(ImplicitAnimationSet set)
{
TimeSpan longest = TimeSpan.Zero;
foreach (var animation in set)
{
if (animation is Animation anim)
{
var total = (anim.Delay ?? TimeSpan.Zero) + (anim.Duration ?? TimeSpan.Zero);
if (total > longest)
{
longest = total;
}
}
}
return longest;
}
private static (string? ShowFrom, string? HideTo) GetSlideOffsets(Transition transition) => transition switch
{
Transition.Bottom => ($"0,{SlideInOffset},{ShadowDepth}", $"0,{SlideOutOffset},{ShadowDepth}"),
Transition.Top => ($"0,{-SlideInOffset},{ShadowDepth}", $"0,{-SlideOutOffset},{ShadowDepth}"),
Transition.Left => ($"{-SlideInOffset},0,{ShadowDepth}", $"{-SlideOutOffset},0,{ShadowDepth}"),
Transition.Right => ($"{SlideInOffset},0,{ShadowDepth}", $"{SlideOutOffset},0,{ShadowDepth}"),
_ => (null, null),
};
private void OnHostShowing(TransparentWindow sender, ShowingEventArgs e)
{
if (e.Transition is Transition transition)
{
Show(transition);
}
else
{
Show();
}
}
private void OnHostHiding(TransparentWindow sender, HidingEventArgs e)
{
// Take a deferral so the host keeps its window visible until our
// out-animation has finished, then complete it from HideCompleted.
var deferral = e.GetDeferral();
void OnHideCompleted(object? s, EventArgs args)
{
HideCompleted -= OnHideCompleted;
_abandonPendingHide = null;
deferral.Complete();
}
// Let a subsequent Show() cancel this hide cleanly: unsubscribe the
// handler so the deferral is never completed (the window stays visible)
// rather than firing AppWindow.Hide for an interrupted cycle.
_abandonPendingHide = () => HideCompleted -= OnHideCompleted;
HideCompleted += OnHideCompleted;
Hide();
}
private void RebuildDefaultAnimations()
{
if (!_hasCustomShowAnimations)
{
_showAnimations = BuildShowAnimations(ShowTransition);
}
if (!_hasCustomHideAnimations)
{
_hideAnimations = BuildHideAnimations(HideTransition);
}
}
private void PinScaleCenter()
{
var visual = ElementCompositionPreview.GetElementVisual(this);
var center = visual.Compositor.CreateExpressionAnimation("Vector3(this.Target.Size.X * 0.5, this.Target.Size.Y * 0.5, 0)");
visual.StartAnimation("CenterPoint", center);
}
private static ImplicitAnimationSet BuildShowAnimations(Transition transition)
{
var animations = new ImplicitAnimationSet();
if (transition == Transition.None)
{
// No animation at all.
return animations;
}
if (transition == Transition.Pop)
{
animations.Add(new OpacityAnimation
{
From = 0,
To = 1.0,
Duration = TimeSpan.FromMilliseconds(PopFadeShowMs),
EasingMode = Microsoft.UI.Xaml.Media.Animation.EasingMode.EaseOut,
EasingType = EasingType.Cubic,
});
animations.Add(new ScaleAnimation
{
From = $"{PopScaleFrom},{PopScaleFrom},1",
To = "1,1,1",
Duration = TimeSpan.FromMilliseconds(PopScaleShowMs),
EasingMode = Microsoft.UI.Xaml.Media.Animation.EasingMode.EaseOut,
EasingType = EasingType.Cubic,
});
return animations;
}
var (slideFrom, _) = GetSlideOffsets(transition);
animations.Add(new OpacityAnimation
{
From = 0,
To = 1.0,
Duration = TimeSpan.FromMilliseconds(200),
EasingMode = Microsoft.UI.Xaml.Media.Animation.EasingMode.EaseOut,
EasingType = EasingType.Cubic,
});
animations.Add(new TranslationAnimation
{
From = slideFrom,
To = $"0,0,{ShadowDepth}",
Duration = TimeSpan.FromMilliseconds(250),
EasingMode = Microsoft.UI.Xaml.Media.Animation.EasingMode.EaseOut,
EasingType = EasingType.Cubic,
});
return animations;
}
private static ImplicitAnimationSet BuildHideAnimations(Transition transition)
{
var animations = new ImplicitAnimationSet();
if (transition == Transition.None)
{
// No animation at all.
return animations;
}
if (transition == Transition.Pop)
{
animations.Add(new OpacityAnimation
{
From = 1.0,
To = 0,
Duration = TimeSpan.FromMilliseconds(PopFadeHideMs),
EasingMode = Microsoft.UI.Xaml.Media.Animation.EasingMode.EaseIn,
EasingType = EasingType.Cubic,
});
animations.Add(new ScaleAnimation
{
From = "1,1,1",
To = $"{PopScaleFrom},{PopScaleFrom},1",
Duration = TimeSpan.FromMilliseconds(PopScaleHideMs),
EasingMode = Microsoft.UI.Xaml.Media.Animation.EasingMode.EaseIn,
EasingType = EasingType.Cubic,
});
return animations;
}
var (_, slideTo) = GetSlideOffsets(transition);
animations.Add(new OpacityAnimation
{
From = 1.0,
To = 0,
Duration = TimeSpan.FromMilliseconds(180),
EasingMode = Microsoft.UI.Xaml.Media.Animation.EasingMode.EaseIn,
EasingType = EasingType.Cubic,
});
animations.Add(new TranslationAnimation
{
From = $"0,0,{ShadowDepth}",
To = slideTo,
Duration = TimeSpan.FromMilliseconds(180),
EasingMode = Microsoft.UI.Xaml.Media.Animation.EasingMode.EaseIn,
EasingType = EasingType.Cubic,
});
return animations;
}
}

View File

@@ -0,0 +1,35 @@
// Copyright (c) Microsoft Corporation
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
namespace Microsoft.PowerToys.Common.UI.Controls;
/// <summary>
/// A show or hide transition a surface (e.g. <see cref="TransientSurface"/>)
/// plays when it is shown or hidden. The directional values describe an edge —
/// interpreted as <em>in from</em> that edge on show and <em>out toward</em> it
/// on hide — while <see cref="None"/> and <see cref="Pop"/> are non-directional.
/// </summary>
public enum Transition
{
/// <summary>No animation; the surface appears and disappears instantly.</summary>
None,
/// <summary>Slide from the left edge (in from on show, out toward on hide).</summary>
Left,
/// <summary>Slide from the top edge (in from on show, out toward on hide).</summary>
Top,
/// <summary>Slide from the right edge (in from on show, out toward on hide).</summary>
Right,
/// <summary>Slide from the bottom edge (in from on show, out toward on hide).</summary>
Bottom,
/// <summary>
/// A subtle "pop": a quick fade combined with a small scale between 96% and
/// 100% from the surface's center. Stays in place — no slide.
/// </summary>
Pop,
}

View File

@@ -1,27 +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.UI.Xaml.Controls;
namespace Microsoft.PowerToys.Common.UI.Controls;
/// <summary>
/// A floating "card" surface for transient PowerToys overlays (toasts,
/// banners, indicators). Provides the PowerToys-standard chrome — 1 px
/// border in <c>SurfaceStrokeColorDefaultBrush</c>, 8 px corner radius,
/// a <c>ThemeShadow</c>, and an always-active desktop acrylic backdrop —
/// via a default <see cref="Microsoft.UI.Xaml.Controls.ControlTemplate"/>.
/// </summary>
/// <remarks>
/// Lives inside a <see cref="Window.TransparentWindow"/>. Apps that want a
/// different look supply their own <c>Style TargetType="TransparentCard"</c>
/// in resources — the standard WinUI restyle path.
/// </remarks>
public sealed partial class TransparentCard : ContentControl
{
public TransparentCard()
{
DefaultStyleKey = typeof(TransparentCard);
}
}

View File

@@ -4,6 +4,6 @@
<ResourceDictionary Source="ms-appx:///PowerToys.Common.UI.Controls/Controls/KeyVisual/KeyCharPresenter.xaml" />
<ResourceDictionary Source="ms-appx:///PowerToys.Common.UI.Controls/Controls/IsEnabledTextBlock/IsEnabledTextBlock.xaml" />
<ResourceDictionary Source="ms-appx:///PowerToys.Common.UI.Controls/Controls/ShortcutWithTextLabelControl/ShortcutWithTextLabelControl.xaml" />
<ResourceDictionary Source="ms-appx:///PowerToys.Common.UI.Controls/Controls/TransparentCard/TransparentCard.xaml" />
<ResourceDictionary Source="ms-appx:///PowerToys.Common.UI.Controls/Controls/TransientSurface/TransientSurface.xaml" />
</ResourceDictionary.MergedDictionaries>
</ResourceDictionary>

View File

@@ -9,7 +9,7 @@ using Microsoft.UI.Windowing;
using Windows.Graphics;
using WinUIEx;
namespace Microsoft.PowerToys.Common.UI.Controls.Flyout;
namespace Microsoft.PowerToys.Common.UI.Controls.Window;
/// <summary>
/// Shared helper for positioning and sizing flyout-style WinUI 3 windows
@@ -187,16 +187,13 @@ public static partial class FlyoutWindowHelper
}
/// <summary>
/// Two-step move that avoids WM_DPICHANGED double-scaling. First teleports a 1×1
/// window into the target display (which may trigger an auto-rescale, but on a 1×1
/// rect the effect is invisible). Then sets the real position+size while the window
/// is already on the target monitor — no DPI boundary crossing, so WinUI's auto
/// handler doesn't fire and overwrite our computed rect.
///
/// Skips the teleport when the window is already on the target display, since there
/// is no boundary to cross.
/// Move and resize <paramref name="window"/> to <paramref name="finalRect"/> (absolute
/// screen physical-pixel coordinates) on <paramref name="targetDisplay"/>. Performs a
/// two-step move that avoids WM_DPICHANGED double-scaling: first a 1×1 teleport into the
/// target display (invisible at that size), then the real position+size while the window
/// is already on that monitor. Skips the teleport when already on the target display.
/// </summary>
private static void MoveAndResizeOnDisplay(WindowEx window, DisplayArea targetDisplay, RectInt32 finalRect)
public static void MoveAndResizeOnDisplay(WindowEx window, DisplayArea targetDisplay, RectInt32 finalRect)
{
var currentDisplay = DisplayArea.GetFromWindowId(window.AppWindow.Id, DisplayAreaFallback.Nearest);
bool needsTeleport = currentDisplay is null || currentDisplay.DisplayId.Value != targetDisplay.DisplayId.Value;

View File

@@ -1,290 +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.Runtime.InteropServices;
using CommunityToolkit.WinUI;
using CommunityToolkit.WinUI.Animations;
using Microsoft.UI.Dispatching;
using Microsoft.UI.Windowing;
using Microsoft.UI.Xaml;
using Microsoft.UI.Xaml.Markup;
using WinUIEx;
namespace Microsoft.PowerToys.Common.UI.Controls.Window;
/// <summary>
/// Reusable transparent host window for transient overlays
/// (toasts, banners, indicators) that should not steal foreground.
/// </summary>
/// <remarks>
/// <para>The constructor applies all of the boilerplate that PowerToys overlays
/// currently hand-roll:</para>
/// <list type="bullet">
/// <item>Strip the native frame and caption (<c>WS_THICKFRAME</c> etc.).</item>
/// <item>Disable the Win11 1-pixel DWM border and corner rounding.</item>
/// <item>Mark the window as a tool window so it stays out of the taskbar and Alt-Tab.</item>
/// <item>Extend content into the title bar and collapse the title bar.</item>
/// </list>
/// <para>The visible chrome (acrylic + border + corner radius + shadow) lives
/// in a <see cref="TransparentCard"/> that the constructor assigns to
/// <see cref="Microsoft.UI.Xaml.Window.Content"/>. Consumers supply their own
/// UI via <see cref="InnerContent"/> — which is the XAML default-content slot
/// thanks to <see cref="ContentPropertyAttribute"/> — so a derived window can
/// be written as <c>&lt;common:TransparentWindow&gt;&lt;TextBlock/&gt;&lt;/common:TransparentWindow&gt;</c>.</para>
/// <para>Transparency is achieved with a <see cref="TransparentTintBackdrop"/>
/// system backdrop so the area outside the <see cref="TransparentCard"/> is
/// fully see-through. That buffer area is NOT click-through, so consumers
/// should keep it as small as possible (just enough to give the card's
/// shadow + slide animation room to breathe — roughly 24 px on each side).</para>
/// <para><see cref="Show"/> and <see cref="Hide"/> coordinate <c>SW_SHOWNA</c>
/// (no-activate), the <see cref="Microsoft.UI.Xaml.UIElement.Visibility"/>
/// toggle on the card, and a debounced
/// <see cref="Microsoft.UI.Windowing.AppWindow.Hide"/> sized from the longest
/// animation in <see cref="HideAnimations"/>. Animations target the card so
/// the entire surface (border, acrylic, shadow, inner content) slides as one.</para>
/// </remarks>
[ContentProperty(Name = nameof(InnerContent))]
public partial class TransparentWindow : WinUIEx.WindowEx
{
private const uint DwmwaColorNone = 0xFFFFFFFE;
private const int DwmwaWindowCornerPreference = 33;
private const int DwmwaBorderColor = 34;
private const int DwmwcpDoNotRound = 1;
private const int GwlExStyle = -20;
private const int WsExToolWindow = 0x00000080;
private const int SwShowNa = 8;
private readonly DispatcherQueueTimer _hideCloseTimer = DispatcherQueue.GetForCurrentThread().CreateTimer();
private readonly nint _hwnd;
private readonly TransparentCard _card;
private ImplicitAnimationSet _showAnimations;
private ImplicitAnimationSet _hideAnimations;
public TransparentWindow()
{
AppWindow.Hide();
ExtendsContentIntoTitleBar = true;
AppWindow.TitleBar.PreferredHeightOption = TitleBarHeightOption.Collapsed;
_hwnd = WinRT.Interop.WindowNative.GetWindowHandle(this);
HwndExtensions.ToggleWindowStyle(_hwnd, false, WindowStyle.TiledWindow);
unsafe
{
uint borderColor = DwmwaColorNone;
_ = DwmSetWindowAttribute(_hwnd, DwmwaBorderColor, &borderColor, sizeof(uint));
int cornerPref = DwmwcpDoNotRound;
_ = DwmSetWindowAttribute(_hwnd, DwmwaWindowCornerPreference, &cornerPref, sizeof(int));
}
ApplyExStyleBit(WsExToolWindow, true);
_showAnimations = BuildDefaultShowAnimations();
_hideAnimations = BuildDefaultHideAnimations();
_card = new TransparentCard();
Content = _card;
SystemBackdrop = new TransparentTintBackdrop();
}
/// <summary>
/// Gets the <see cref="TransparentCard"/> that provides the window's
/// visible chrome (acrylic + border + shadow). Consumers can configure
/// its layout (e.g. <c>HorizontalAlignment</c>, <c>VerticalAlignment</c>,
/// <c>MaxWidth</c>, <c>Margin</c>) to position the card inside the
/// window, or apply a custom <c>Style</c> to change its look.
/// </summary>
public TransparentCard Card => _card;
/// <summary>
/// Gets or sets the visual hosted inside the window's
/// <see cref="TransparentCard"/>. This is the XAML default-content slot:
/// child elements declared between the opening and closing
/// <c>TransparentWindow</c> tags in a derived .xaml are routed here.
/// </summary>
public object? InnerContent
{
get => _card.Content;
set => _card.Content = value;
}
/// <summary>
/// Gets or sets the animations played against
/// <see cref="Microsoft.UI.Xaml.Window.Content"/> when <see cref="Show"/>
/// flips it to <see cref="Visibility.Visible"/>. Defaults to a 200 ms
/// fade-in plus a 250 ms slide-up of 24 px.
/// </summary>
public ImplicitAnimationSet ShowAnimations
{
get => _showAnimations;
set => _showAnimations = value ?? new ImplicitAnimationSet();
}
/// <summary>
/// Gets or sets the animations played against
/// <see cref="Microsoft.UI.Xaml.Window.Content"/> when <see cref="Hide"/>
/// flips it to <see cref="Visibility.Collapsed"/>. Defaults to a 180 ms
/// fade-out plus a 180 ms slide-down of 12 px.
/// </summary>
public ImplicitAnimationSet HideAnimations
{
get => _hideAnimations;
set => _hideAnimations = value ?? new ImplicitAnimationSet();
}
/// <summary>
/// Shows the window without activation (<c>SW_SHOWNA</c>) and flips
/// <see cref="Microsoft.UI.Xaml.Window.Content"/> to
/// <see cref="Visibility.Visible"/> so <see cref="ShowAnimations"/> plays.
/// Repeated calls reset the content to its hidden pose first so the show
/// animation re-triggers cleanly. Any pending hide is cancelled.
/// </summary>
public void Show()
{
DispatcherQueue.TryEnqueue(
DispatcherQueuePriority.Low,
() =>
{
_hideCloseTimer.Stop();
if (Content is UIElement content)
{
// Re-apply each call so swapping animation collections at
// runtime takes effect on the next show/hide cycle.
Implicit.SetShowAnimations(content, _showAnimations);
Implicit.SetHideAnimations(content, _hideAnimations);
// Reset to the hidden pose so the show animation always
// animates from the configured starting frame.
content.Visibility = Visibility.Collapsed;
}
_ = ShowWindow(_hwnd, SwShowNa);
if (Content is UIElement c2)
{
c2.Visibility = Visibility.Visible;
}
});
}
/// <summary>
/// Flips <see cref="Microsoft.UI.Xaml.Window.Content"/> to
/// <see cref="Visibility.Collapsed"/> so <see cref="HideAnimations"/>
/// plays, then hides the underlying
/// <see cref="Microsoft.UI.Windowing.AppWindow"/> once the longest
/// animation in <see cref="HideAnimations"/> (delay + duration) has
/// completed.
/// </summary>
public void Hide()
{
DispatcherQueue.TryEnqueue(
DispatcherQueuePriority.Low,
() =>
{
if (Content is UIElement content)
{
content.Visibility = Visibility.Collapsed;
}
_hideCloseTimer.Debounce(
AppWindow.Hide,
interval: GetAnimationSetTotalDuration(_hideAnimations),
immediate: false);
});
}
private static TimeSpan GetAnimationSetTotalDuration(ImplicitAnimationSet set)
{
TimeSpan longest = TimeSpan.Zero;
foreach (var animation in set)
{
if (animation is Animation anim)
{
var total = (anim.Delay ?? TimeSpan.Zero) + (anim.Duration ?? TimeSpan.Zero);
if (total > longest)
{
longest = total;
}
}
}
return longest;
}
private void ApplyExStyleBit(int bit, bool set)
{
if (_hwnd == 0)
{
return;
}
nint exStyle = GetWindowLongPtr(_hwnd, GwlExStyle);
nint updated = set ? exStyle | bit : exStyle & ~(nint)bit;
if (updated != exStyle)
{
_ = SetWindowLongPtr(_hwnd, GwlExStyle, updated);
}
}
private static ImplicitAnimationSet BuildDefaultShowAnimations() => new()
{
new OpacityAnimation
{
From = 0,
To = 1.0,
Duration = TimeSpan.FromMilliseconds(200),
EasingMode = Microsoft.UI.Xaml.Media.Animation.EasingMode.EaseOut,
EasingType = EasingType.Cubic,
},
new TranslationAnimation
{
From = "0,24,32",
To = "0,0,32",
Duration = TimeSpan.FromMilliseconds(250),
EasingMode = Microsoft.UI.Xaml.Media.Animation.EasingMode.EaseOut,
EasingType = EasingType.Cubic,
},
};
private static ImplicitAnimationSet BuildDefaultHideAnimations() => new()
{
new OpacityAnimation
{
From = 1.0,
To = 0,
Duration = TimeSpan.FromMilliseconds(180),
EasingMode = Microsoft.UI.Xaml.Media.Animation.EasingMode.EaseIn,
EasingType = EasingType.Cubic,
},
new TranslationAnimation
{
From = "0,0,32",
To = "0,12,32",
Duration = TimeSpan.FromMilliseconds(180),
EasingMode = Microsoft.UI.Xaml.Media.Animation.EasingMode.EaseIn,
EasingType = EasingType.Cubic,
},
};
[LibraryImport("user32.dll", EntryPoint = "GetWindowLongPtrW")]
private static partial nint GetWindowLongPtr(nint hWnd, int nIndex);
[LibraryImport("user32.dll", EntryPoint = "SetWindowLongPtrW")]
private static partial nint SetWindowLongPtr(nint hWnd, int nIndex, nint dwNewLong);
[LibraryImport("user32.dll")]
[return: MarshalAs(UnmanagedType.Bool)]
private static partial bool ShowWindow(nint hWnd, int nCmdShow);
[LibraryImport("dwmapi.dll")]
private static unsafe partial int DwmSetWindowAttribute(nint hwnd, int dwAttribute, void* pvAttribute, int cbAttribute);
}

View File

@@ -0,0 +1,61 @@
// 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 Deferral = global::Windows.Foundation.Deferral;
namespace Microsoft.PowerToys.Common.UI.Controls.Window;
/// <summary>
/// Data for <see cref="TransparentWindow.Hiding"/>. Supports deferrals so an
/// animated surface can keep the window visible until its out-animation has
/// finished. If no handler takes a deferral, the window hides immediately.
/// </summary>
public sealed class HidingEventArgs : EventArgs
{
private int _outstanding;
private bool _raised;
private Action? _continuation;
/// <summary>
/// Requests that the window stay visible until the returned deferral is
/// completed. Call <see cref="Deferral.Complete"/> once the out-animation
/// has finished.
/// </summary>
/// <returns>A deferral that must be completed to allow the window to hide.</returns>
public Deferral GetDeferral()
{
Interlocked.Increment(ref _outstanding);
return new Deferral(OnDeferralCompleted);
}
/// <summary>
/// Called by the window after raising the event to register what should run
/// once every outstanding deferral has completed (or immediately if none
/// were taken).
/// </summary>
internal void RunWhenComplete(Action continuation)
{
_continuation = continuation;
_raised = true;
TryComplete();
}
private void OnDeferralCompleted()
{
Interlocked.Decrement(ref _outstanding);
TryComplete();
}
private void TryComplete()
{
if (_raised && Volatile.Read(ref _outstanding) == 0)
{
var continuation = _continuation;
_continuation = null;
continuation?.Invoke();
}
}
}

View File

@@ -0,0 +1,26 @@
// 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;
namespace Microsoft.PowerToys.Common.UI.Controls.Window;
/// <summary>
/// Data for <see cref="TransparentWindow.Showing"/>. Carries the transition the
/// content should play, or <see langword="null"/> to let the content use its own
/// configured show transition.
/// </summary>
public sealed class ShowingEventArgs : EventArgs
{
public ShowingEventArgs(Transition? transition)
{
Transition = transition;
}
/// <summary>
/// Gets the transition the content should play, or <see langword="null"/> to
/// use the content's own configured show transition.
/// </summary>
public Transition? Transition { get; }
}

View File

@@ -0,0 +1,240 @@
// 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.Runtime.InteropServices;
using Microsoft.UI.Dispatching;
using Microsoft.UI.Windowing;
using Microsoft.UI.Xaml;
using Microsoft.UI.Xaml.Input;
using Windows.Foundation;
using WinUIEx;
namespace Microsoft.PowerToys.Common.UI.Controls.Window;
/// <summary>
/// Reusable transparent host window for transient overlays
/// (toasts, banners, indicators) that should not steal foreground.
/// </summary>
/// <remarks>
/// <para>The constructor applies all of the boilerplate that PowerToys overlays
/// currently hand-roll:</para>
/// <list type="bullet">
/// <item>Strip the native frame and caption (<c>WS_THICKFRAME</c> etc.).</item>
/// <item>Disable the Win11 1-pixel DWM border and corner rounding.</item>
/// <item>Mark the window as a tool window so it stays out of the taskbar and Alt-Tab.</item>
/// <item>Extend content into the title bar and collapse the title bar.</item>
/// <item>Apply a <see cref="TransparentTintBackdrop"/> so the HWND is fully
/// see-through and the visible chrome can be drawn by the content.</item>
/// </list>
/// <para>This window is intentionally animation-agnostic: it does not own any
/// chrome or motion. Consumers supply their own content (typically a
/// <see cref="TransientSurface"/>) which draws the acrylic, border, corners and
/// shadow, and animates itself. <see cref="Show()"/> and <see cref="Hide"/>
/// coordinate <c>SW_SHOWNA</c> (no-activate) with the
/// <see cref="Showing"/> / <see cref="Hiding"/> events: a content surface
/// subscribes to those (e.g. via <see cref="TransientSurface.SubscribeTo"/>)
/// and plays its in/out animation. The <see cref="Hiding"/> event supports
/// deferrals, so the underlying
/// <see cref="Microsoft.UI.Windowing.AppWindow.Hide"/> is delayed until the
/// content has finished animating out. With no listener the window simply shows
/// or hides immediately.</para>
/// <para><b>Multiple surfaces.</b> More than one <see cref="TransientSurface"/>
/// may host on the same window by each calling
/// <see cref="TransientSurface.SubscribeTo"/>. The <see cref="Showing"/> and
/// <see cref="Hiding"/> events are simply raised for every subscriber, and
/// because <see cref="HidingEventArgs"/> aggregates deferrals the underlying
/// window is hidden only after <em>all</em> surfaces have finished animating
/// out. To let each surface play its own distinct transition, call the
/// parameterless <see cref="Show()"/> (so every surface uses its configured
/// <c>ShowTransition</c>/<c>HideTransition</c>); the <see cref="Show(Transition)"/>
/// overload instead broadcasts a single transition to all surfaces. Sizing the
/// window and positioning each surface within it remain the consumer's
/// responsibility (this window owns no layout).</para>
/// </remarks>
public partial class TransparentWindow : WinUIEx.WindowEx
{
private const uint DwmwaColorNone = 0xFFFFFFFE;
private const int DwmwaWindowCornerPreference = 33;
private const int DwmwaBorderColor = 34;
private const int DwmwcpDoNotRound = 1;
private const int GwlExStyle = -20;
private const int WsExToolWindow = 0x00000080;
private const int SwShowNa = 8;
private readonly nint _hwnd;
private bool _inputHooked;
private bool _seenActivated;
public TransparentWindow()
{
AppWindow.Hide();
ExtendsContentIntoTitleBar = true;
AppWindow.TitleBar.PreferredHeightOption = TitleBarHeightOption.Collapsed;
_hwnd = WinRT.Interop.WindowNative.GetWindowHandle(this);
HwndExtensions.ToggleWindowStyle(_hwnd, false, WindowStyle.TiledWindow);
unsafe
{
uint borderColor = DwmwaColorNone;
_ = DwmSetWindowAttribute(_hwnd, DwmwaBorderColor, &borderColor, sizeof(uint));
int cornerPref = DwmwcpDoNotRound;
_ = DwmSetWindowAttribute(_hwnd, DwmwaWindowCornerPreference, &cornerPref, sizeof(int));
}
ApplyExStyleBit(WsExToolWindow, true);
SystemBackdrop = new TransparentTintBackdrop();
Activated += OnActivatedForDismiss;
}
/// <summary>
/// Gets or sets a value indicating whether pressing <c>Esc</c> while the
/// window content has keyboard focus dismisses the window (<see cref="Hide"/>).
/// Defaults to <see langword="false"/>. The window is shown without
/// activation, so the consumer must activate it for its content to receive
/// keyboard input.
/// </summary>
public bool DismissOnEscape { get; set; }
/// <summary>
/// Gets or sets a value indicating whether the window dismisses itself
/// (<see cref="Hide"/>) when it loses focus (is deactivated), i.e. light
/// dismiss. Defaults to <see langword="false"/>. Only takes effect after the
/// window has been activated at least once since the last <see cref="Show()"/>,
/// so the transient deactivation that can occur during the show sequence does
/// not dismiss it prematurely. The window is shown without activation, so the
/// consumer must activate it for this to apply.
/// </summary>
public bool DismissOnFocusLost { get; set; }
/// <summary>
/// Raised (without activation) when <see cref="Show()"/> makes the window
/// visible. A content surface subscribes to this to play its in-animation,
/// using <see cref="ShowingEventArgs.Transition"/>.
/// </summary>
public event TypedEventHandler<TransparentWindow, ShowingEventArgs>? Showing;
/// <summary>
/// Raised when <see cref="Hide"/> begins dismissing the window. A content
/// surface subscribes to this to play its out-animation, taking a deferral
/// (<see cref="HidingEventArgs.GetDeferral"/>) so the underlying window stays
/// visible until the animation completes.
/// </summary>
public event TypedEventHandler<TransparentWindow, HidingEventArgs>? Hiding;
/// <summary>
/// Shows the window without activation (<c>SW_SHOWNA</c>) and raises
/// <see cref="Showing"/> without a transition, so subscribed content animates
/// in using its own configured show transition.
/// </summary>
public void Show() => RaiseShow(null);
/// <summary>
/// Shows the window without activation (<c>SW_SHOWNA</c>) and raises
/// <see cref="Showing"/> so subscribed content animates in using
/// <paramref name="transition"/>, overriding its configured show transition.
/// </summary>
/// <param name="transition">The transition the content should play.</param>
public void Show(Transition transition) => RaiseShow(transition);
private void RaiseShow(Transition? transition)
{
DispatcherQueue.TryEnqueue(
DispatcherQueuePriority.Low,
() =>
{
_seenActivated = false;
EnsureInputHooks();
_ = ShowWindow(_hwnd, SwShowNa);
Showing?.Invoke(this, new ShowingEventArgs(transition));
});
}
/// <summary>
/// Raises <see cref="Hiding"/> so subscribed content animates out, then hides
/// the underlying <see cref="Microsoft.UI.Windowing.AppWindow"/> once every
/// deferral taken by a handler has completed (immediately if none were taken).
/// </summary>
public void Hide()
{
DispatcherQueue.TryEnqueue(
DispatcherQueuePriority.Low,
() =>
{
var args = new HidingEventArgs();
Hiding?.Invoke(this, args);
args.RunWhenComplete(AppWindow.Hide);
});
}
private void OnActivatedForDismiss(object sender, WindowActivatedEventArgs args)
{
if (args.WindowActivationState == WindowActivationState.Deactivated)
{
if (DismissOnFocusLost && _seenActivated)
{
Hide();
}
return;
}
_seenActivated = true;
}
private void EnsureInputHooks()
{
if (_inputHooked || Content is not UIElement element)
{
return;
}
element.KeyDown += OnContentKeyDown;
_inputHooked = true;
}
private void OnContentKeyDown(object sender, KeyRoutedEventArgs e)
{
if (DismissOnEscape && e.Key == global::Windows.System.VirtualKey.Escape)
{
e.Handled = true;
Hide();
}
}
private void ApplyExStyleBit(int bit, bool set)
{
if (_hwnd == 0)
{
return;
}
nint exStyle = GetWindowLongPtr(_hwnd, GwlExStyle);
nint updated = set ? exStyle | bit : exStyle & ~(nint)bit;
if (updated != exStyle)
{
_ = SetWindowLongPtr(_hwnd, GwlExStyle, updated);
}
}
[LibraryImport("user32.dll", EntryPoint = "GetWindowLongPtrW")]
private static partial nint GetWindowLongPtr(nint hWnd, int nIndex);
[LibraryImport("user32.dll", EntryPoint = "SetWindowLongPtrW")]
private static partial nint SetWindowLongPtr(nint hWnd, int nIndex, nint dwNewLong);
[LibraryImport("user32.dll")]
[return: MarshalAs(UnmanagedType.Bool)]
private static partial bool ShowWindow(nint hWnd, int nCmdShow);
[LibraryImport("dwmapi.dll")]
private static unsafe partial int DwmSetWindowAttribute(nint hwnd, int dwAttribute, void* pvAttribute, int cbAttribute);
}

View File

@@ -103,7 +103,7 @@ namespace Microsoft.PowerToys.UITest
[PowerToysModule.FancyZone] = new ModuleInfo("PowerToys.FancyZonesEditor.exe", "FancyZones Layout"),
[PowerToysModule.Hosts] = new ModuleInfo("PowerToys.Hosts.exe", "Hosts File Editor", "WinUI3Apps"),
[PowerToysModule.Runner] = new ModuleInfo("PowerToys.exe", "PowerToys"),
[PowerToysModule.Workspaces] = new ModuleInfo("PowerToys.WorkspacesEditor.exe", "Workspaces Editor"),
[PowerToysModule.Workspaces] = new ModuleInfo("PowerToys.WorkspacesEditor.exe", "Workspaces Editor", "WinUI3Apps"),
[PowerToysModule.PowerRename] = new ModuleInfo("PowerToys.PowerRename.exe", "PowerRename", "WinUI3Apps"),
[PowerToysModule.CommandPalette] = new ModuleInfo("Microsoft.CmdPal.UI.exe", "PowerToys Command Palette", "WinUI3Apps\\CmdPal"),
[PowerToysModule.ScreenRuler] = new ModuleInfo("PowerToys.MeasureToolUI.exe", "PowerToys.ScreenRuler", "WinUI3Apps"),

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.4 KiB

After

Width:  |  Height:  |  Size: 46 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 433 B

After

Width:  |  Height:  |  Size: 5.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.4 KiB

After

Width:  |  Height:  |  Size: 46 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 433 B

After

Width:  |  Height:  |  Size: 5.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 328 B

After

Width:  |  Height:  |  Size: 2.3 KiB

View File

@@ -373,6 +373,13 @@ static int g_overlayRenderedH = 0;
// Always On Top (WindowCornerUtils::CornersRadius).
static int CornerRadiusForWindow(HWND hwnd)
{
// Remote sessions draw square windows even on Win11, yet still report DWMWCP_DEFAULT. Match the
// window: a remote session gets square (radius 0) so the overlay border doesn't round off the corner.
if (GetSystemMetrics(SM_REMOTESESSION))
{
return 0;
}
int pref = 0; // DWMWCP_DEFAULT
if (DwmGetWindowAttribute(hwnd, DWMWA_WINDOW_CORNER_PREFERENCE, &pref, sizeof(pref)) != S_OK)
{

View File

@@ -6,6 +6,8 @@
#include "trace.h"
#include <cmath>
#include <algorithm>
#include <memory>
#include <vector>
#ifdef COMPOSITION
namespace winrt
@@ -48,6 +50,18 @@ private:
void ClearDrawingPoint();
void ClearDrawing();
void BringToFront();
// Ripple mode: spawn the press/hold ring + glow at the click point and
// continue the animation into a fade-out on release. The held ring may
// optionally follow the cursor while held (gated by m_rippleShowDragTrail).
void SpawnRippleHoldDot(MouseButton button);
void FadeRippleHoldDot(MouseButton button);
// Ripple mode: emit a single self-contained ripple (grow + fade) for a quick
// click, independent of any held indicator.
void EmitSingleRipple(MouseButton button);
// Spotlight mode: pressed-state animation that shrinks the mask while
// a mouse button is held and restores it on release.
void SpotlightAnimatePress();
void SpotlightAnimateRelease();
HHOOK m_mouseHook = NULL;
static LRESULT CALLBACK MouseHookProc(int nCode, WPARAM wParam, LPARAM lParam) noexcept;
// Helpers for spotlight overlay
@@ -71,6 +85,16 @@ private:
winrt::CompositionSpriteShape m_leftPointer{ nullptr };
winrt::CompositionSpriteShape m_rightPointer{ nullptr };
winrt::CompositionSpriteShape m_alwaysPointer{ nullptr };
// Ellipse geometries kept alongside the pointer shapes so press-down /
// release animations can target the radius directly.
winrt::CompositionEllipseGeometry m_leftGeometry{ nullptr };
winrt::CompositionEllipseGeometry m_rightGeometry{ nullptr };
// Ripple-mode held glow (the soft halo behind the ring) — paired with
// m_left/rightPointer (which holds the ring shape) while a button is held.
winrt::CompositionSpriteShape m_leftRippleGlow{ nullptr };
winrt::CompositionSpriteShape m_rightRippleGlow{ nullptr };
winrt::CompositionEllipseGeometry m_leftGlowGeometry{ nullptr };
winrt::CompositionEllipseGeometry m_rightGlowGeometry{ nullptr };
// Spotlight overlay (mask with soft feathered edge)
winrt::SpriteVisual m_overlay{ nullptr };
winrt::CompositionMaskBrush m_spotlightMask{ nullptr };
@@ -84,9 +108,22 @@ private:
bool m_rightPointerEnabled = true;
bool m_alwaysPointerEnabled = true;
bool m_spotlightMode = false;
bool m_spotlightPressed = false;
bool m_rippleMode = true;
bool m_rippleShowDragTrail = MOUSE_HIGHLIGHTER_DEFAULT_RIPPLE_SHOW_DRAG_TRAIL;
bool m_rippleShowReleasePulse = MOUSE_HIGHLIGHTER_DEFAULT_RIPPLE_SHOW_RELEASE_PULSE;
float m_rippleSize = static_cast<float>(MOUSE_HIGHLIGHTER_DEFAULT_RIPPLE_SIZE);
float m_rippleIntensity = static_cast<float>(MOUSE_HIGHLIGHTER_DEFAULT_RIPPLE_INTENSITY);
int m_rippleDurationMs = MOUSE_HIGHLIGHTER_DEFAULT_RIPPLE_DURATION_MS;
bool m_leftButtonPressed = false;
bool m_rightButtonPressed = false;
// Pending hold-detection timers. A ripple "held indicator" is only spawned
// once the button has been held past a short threshold; a quick click that
// releases before then emits a single self-contained ripple instead. This
// prevents a single click from rendering two ripples (press + release).
UINT_PTR m_leftHoldTimer = 0;
UINT_PTR m_rightHoldTimer = 0;
UINT_PTR m_timer_id = 0;
bool m_visible = false;
@@ -102,6 +139,11 @@ private:
winrt::Windows::UI::Color m_alwaysColor = MOUSE_HIGHLIGHTER_DEFAULT_ALWAYS_COLOR;
};
static const uint32_t BRING_TO_FRONT_TIMER_ID = 123;
static const uint32_t HOLD_RIPPLE_TIMER_LEFT = 124;
static const uint32_t HOLD_RIPPLE_TIMER_RIGHT = 125;
// How long a ripple button must be held before the persistent "held indicator"
// is shown. Releasing before this is treated as a quick click (single ripple).
static const uint32_t HOLD_RIPPLE_THRESHOLD_MS = 180;
Highlighter* Highlighter::instance = nullptr;
bool Highlighter::CreateHighlighter()
@@ -194,11 +236,34 @@ void Highlighter::AddDrawingPoint(MouseButton button)
{
circleShape.FillBrush(m_compositor.CreateColorBrush(m_leftClickColor));
m_leftPointer = circleShape;
m_leftGeometry = circleGeometry;
// Niels-style press-down shrink: holding the button squeezes the
// circle to 70% over 180ms after a 150ms delay so quick clicks skip
// it. StartDrawingPointFading stops this animation on release.
const float pressedRadius = m_radius * 0.70f;
auto ease = m_compositor.CreateCubicBezierEasingFunction({ 0.2f, 0.0f }, { 0.4f, 1.0f });
auto anim = m_compositor.CreateVector2KeyFrameAnimation();
anim.InsertKeyFrame(0.0f, { m_radius, m_radius });
anim.InsertKeyFrame(1.0f, { pressedRadius, pressedRadius }, ease);
anim.Duration(std::chrono::milliseconds(180));
anim.DelayTime(std::chrono::milliseconds(150));
circleGeometry.StartAnimation(L"Radius", anim);
}
else if (button == MouseButton::Right)
{
circleShape.FillBrush(m_compositor.CreateColorBrush(m_rightClickColor));
m_rightPointer = circleShape;
m_rightGeometry = circleGeometry;
const float pressedRadius = m_radius * 0.70f;
auto ease = m_compositor.CreateCubicBezierEasingFunction({ 0.2f, 0.0f }, { 0.4f, 1.0f });
auto anim = m_compositor.CreateVector2KeyFrameAnimation();
anim.InsertKeyFrame(0.0f, { m_radius, m_radius });
anim.InsertKeyFrame(1.0f, { pressedRadius, pressedRadius }, ease);
anim.Duration(std::chrono::milliseconds(180));
anim.DelayTime(std::chrono::milliseconds(150));
circleGeometry.StartAnimation(L"Radius", anim);
}
else
{
@@ -238,17 +303,36 @@ void Highlighter::UpdateDrawingPointPosition(MouseButton button)
if (button == MouseButton::Left)
{
m_leftPointer.Offset({ static_cast<float>(pt.x), static_cast<float>(pt.y) });
if (m_leftRippleGlow)
{
m_leftRippleGlow.Offset({ static_cast<float>(pt.x), static_cast<float>(pt.y) });
}
}
else if (button == MouseButton::Right)
{
m_rightPointer.Offset({ static_cast<float>(pt.x), static_cast<float>(pt.y) });
if (m_rightRippleGlow)
{
m_rightRippleGlow.Offset({ static_cast<float>(pt.x), static_cast<float>(pt.y) });
}
}
else
{
// always / spotlight idle
if (m_spotlightMode)
{
UpdateSpotlightMask(static_cast<float>(pt.x), static_cast<float>(pt.y), m_radius, true);
if (m_spotlightPressed)
{
// Only update position while pressed — radius is being animated
if (m_spotlightMaskGradient)
{
m_spotlightMaskGradient.EllipseCenter({ static_cast<float>(pt.x), static_cast<float>(pt.y) });
}
}
else
{
UpdateSpotlightMask(static_cast<float>(pt.x), static_cast<float>(pt.y), m_radius, true);
}
}
else if (m_alwaysPointer)
{
@@ -259,14 +343,24 @@ void Highlighter::UpdateDrawingPointPosition(MouseButton button)
void Highlighter::StartDrawingPointFading(MouseButton button)
{
winrt::Windows::UI::Composition::CompositionSpriteShape circleShape{ nullptr };
winrt::Windows::UI::Composition::CompositionEllipseGeometry geom{ nullptr };
if (button == MouseButton::Left)
{
circleShape = m_leftPointer;
geom = m_leftGeometry;
}
else
{
// right
circleShape = m_rightPointer;
geom = m_rightGeometry;
}
// Stop any in-flight press-down shrink so the geometry doesn't keep
// animating while the fill is being faded out.
if (geom && m_compositor)
{
geom.StopAnimation(L"Radius");
}
auto brushColor = circleShape.FillBrush().as<winrt::Windows::UI::Composition::CompositionColorBrush>().Color();
@@ -329,6 +423,30 @@ LRESULT CALLBACK Highlighter::MouseHookProc(int nCode, WPARAM wParam, LPARAM lPa
switch (wParam)
{
case WM_LBUTTONDOWN:
if (instance->m_spotlightMode)
{
instance->SpotlightAnimatePress();
break;
}
if (instance->m_rippleMode)
{
if (instance->m_leftPointerEnabled)
{
// Defer the held indicator: only spawn it if the button is
// still down after the hold threshold. A quick click handled
// on button-up emits a single ripple instead.
instance->m_leftButtonPressed = true;
if (instance->m_leftHoldTimer == 0)
{
instance->m_leftHoldTimer = SetTimer(instance->m_hwnd, HOLD_RIPPLE_TIMER_LEFT, HOLD_RIPPLE_THRESHOLD_MS, nullptr);
}
if (instance->m_timer_id == 0)
{
instance->m_timer_id = SetTimer(instance->m_hwnd, BRING_TO_FRONT_TIMER_ID, 10, nullptr);
}
}
break;
}
if (instance->m_leftPointerEnabled)
{
if (instance->m_alwaysPointerEnabled && !instance->m_rightButtonPressed)
@@ -354,6 +472,28 @@ LRESULT CALLBACK Highlighter::MouseHookProc(int nCode, WPARAM wParam, LPARAM lPa
}
break;
case WM_RBUTTONDOWN:
if (instance->m_spotlightMode)
{
instance->SpotlightAnimatePress();
break;
}
if (instance->m_rippleMode)
{
if (instance->m_rightPointerEnabled)
{
// Defer the held indicator (see WM_LBUTTONDOWN).
instance->m_rightButtonPressed = true;
if (instance->m_rightHoldTimer == 0)
{
instance->m_rightHoldTimer = SetTimer(instance->m_hwnd, HOLD_RIPPLE_TIMER_RIGHT, HOLD_RIPPLE_THRESHOLD_MS, nullptr);
}
if (instance->m_timer_id == 0)
{
instance->m_timer_id = SetTimer(instance->m_hwnd, BRING_TO_FRONT_TIMER_ID, 10, nullptr);
}
}
break;
}
if (instance->m_rightPointerEnabled)
{
if (instance->m_alwaysPointerEnabled && !instance->m_leftButtonPressed)
@@ -376,6 +516,24 @@ LRESULT CALLBACK Highlighter::MouseHookProc(int nCode, WPARAM wParam, LPARAM lPa
}
break;
case WM_MOUSEMOVE:
if (instance->m_rippleMode)
{
// Held ripple ring follows the cursor while a button is down,
// gated by the "follow cursor while held" setting. When the
// setting is off, the ring stays anchored at the click point.
if (instance->m_rippleShowDragTrail)
{
if (instance->m_leftButtonPressed && instance->m_leftPointer)
{
instance->UpdateDrawingPointPosition(MouseButton::Left);
}
if (instance->m_rightButtonPressed && instance->m_rightPointer)
{
instance->UpdateDrawingPointPosition(MouseButton::Right);
}
}
break;
}
if (instance->m_leftButtonPressed)
{
instance->UpdateDrawingPointPosition(MouseButton::Left);
@@ -390,11 +548,33 @@ LRESULT CALLBACK Highlighter::MouseHookProc(int nCode, WPARAM wParam, LPARAM lPa
}
break;
case WM_LBUTTONUP:
if (instance->m_spotlightPressed)
{
instance->SpotlightAnimateRelease();
}
if (instance->m_leftButtonPressed)
{
instance->StartDrawingPointFading(MouseButton::Left);
if (instance->m_rippleMode)
{
if (instance->m_leftHoldTimer != 0)
{
// Released before the hold threshold => quick click.
KillTimer(instance->m_hwnd, instance->m_leftHoldTimer);
instance->m_leftHoldTimer = 0;
instance->EmitSingleRipple(MouseButton::Left);
}
else
{
// Held indicator was already shown; expand + fade it.
instance->FadeRippleHoldDot(MouseButton::Left);
}
}
else
{
instance->StartDrawingPointFading(MouseButton::Left);
}
instance->m_leftButtonPressed = false;
if (instance->m_alwaysPointerEnabled && !instance->m_rightButtonPressed)
if (!instance->m_rippleMode && instance->m_alwaysPointerEnabled && !instance->m_rightButtonPressed)
{
// Add AlwaysPointer only when it's enabled and RightPointer is not active
instance->AddDrawingPoint(MouseButton::None);
@@ -402,11 +582,32 @@ LRESULT CALLBACK Highlighter::MouseHookProc(int nCode, WPARAM wParam, LPARAM lPa
}
break;
case WM_RBUTTONUP:
if (instance->m_spotlightPressed)
{
instance->SpotlightAnimateRelease();
}
if (instance->m_rightButtonPressed)
{
instance->StartDrawingPointFading(MouseButton::Right);
if (instance->m_rippleMode)
{
if (instance->m_rightHoldTimer != 0)
{
// Released before the hold threshold => quick click.
KillTimer(instance->m_hwnd, instance->m_rightHoldTimer);
instance->m_rightHoldTimer = 0;
instance->EmitSingleRipple(MouseButton::Right);
}
else
{
instance->FadeRippleHoldDot(MouseButton::Right);
}
}
else
{
instance->StartDrawingPointFading(MouseButton::Right);
}
instance->m_rightButtonPressed = false;
if (instance->m_alwaysPointerEnabled && !instance->m_leftButtonPressed)
if (!instance->m_rippleMode && instance->m_alwaysPointerEnabled && !instance->m_leftButtonPressed)
{
// Add AlwaysPointer only when it's enabled and LeftPointer is not active
instance->AddDrawingPoint(MouseButton::None);
@@ -448,9 +649,16 @@ void Highlighter::StopDrawing()
m_visible = false;
m_leftButtonPressed = false;
m_rightButtonPressed = false;
m_spotlightPressed = false;
m_leftPointer = nullptr;
m_rightPointer = nullptr;
m_alwaysPointer = nullptr;
m_leftGeometry = nullptr;
m_rightGeometry = nullptr;
m_leftRippleGlow = nullptr;
m_rightRippleGlow = nullptr;
m_leftGlowGeometry = nullptr;
m_rightGlowGeometry = nullptr;
if (m_overlay)
{
m_overlay.IsVisible(false);
@@ -478,6 +686,16 @@ void Highlighter::ApplySettings(MouseHighlighterSettings settings)
m_rightPointerEnabled = settings.rightButtonColor.A != 0;
m_alwaysPointerEnabled = settings.alwaysColor.A != 0;
m_spotlightMode = settings.spotlightMode && settings.alwaysColor.A != 0;
m_rippleMode = settings.rippleMode && !m_spotlightMode;
m_rippleSize = (settings.rippleSize > 0) ? static_cast<float>(settings.rippleSize) : static_cast<float>(MOUSE_HIGHLIGHTER_DEFAULT_RIPPLE_SIZE);
m_rippleIntensity = (settings.rippleIntensity > 0.0) ? static_cast<float>(settings.rippleIntensity) : static_cast<float>(MOUSE_HIGHLIGHTER_DEFAULT_RIPPLE_INTENSITY);
m_rippleDurationMs = (settings.rippleDurationMs > 0) ? settings.rippleDurationMs : MOUSE_HIGHLIGHTER_DEFAULT_RIPPLE_DURATION_MS;
m_rippleShowDragTrail = settings.rippleShowDragTrail;
m_rippleShowReleasePulse = settings.rippleShowReleasePulse;
// Reset transient pressed-state flag so a settings change while a button
// happens to be down doesn't leave the spotlight stuck at a shrunken size.
m_spotlightPressed = false;
if (m_spotlightMode)
{
@@ -548,6 +766,7 @@ LRESULT CALLBACK Highlighter::WndProc(HWND hWnd, UINT message, WPARAM wParam, LP
// If we would use a timer with a 50 ms period, there would be a flickering on the UI, as in most of the cases
// the pinned window hides our window in a few milliseconds.
case BRING_TO_FRONT_TIMER_ID:
{
static int fireCount = 0;
if (fireCount++ >= 4)
{
@@ -558,6 +777,24 @@ LRESULT CALLBACK Highlighter::WndProc(HWND hWnd, UINT message, WPARAM wParam, LP
instance->BringToFront();
break;
}
case HOLD_RIPPLE_TIMER_LEFT:
// Button held past the threshold: show the persistent held indicator.
KillTimer(instance->m_hwnd, instance->m_leftHoldTimer);
instance->m_leftHoldTimer = 0;
if (instance->m_leftButtonPressed)
{
instance->SpawnRippleHoldDot(MouseButton::Left);
}
break;
case HOLD_RIPPLE_TIMER_RIGHT:
KillTimer(instance->m_hwnd, instance->m_rightHoldTimer);
instance->m_rightHoldTimer = 0;
if (instance->m_rightButtonPressed)
{
instance->SpawnRippleHoldDot(MouseButton::Right);
}
break;
}
break;
}
default:
@@ -643,6 +880,548 @@ void Highlighter::UpdateSpotlightMask(float cx, float cy, float radius, bool sho
}
}
// Spotlight press-down: shrink the mask radius briefly while a button is held.
void Highlighter::SpotlightAnimatePress()
{
if (!m_spotlightMode || !m_spotlightMaskGradient)
{
return;
}
m_spotlightPressed = true;
const float pressedRadius = m_radius * 0.85f;
auto ease = m_compositor.CreateCubicBezierEasingFunction({ 0.2f, 0.0f }, { 0.4f, 1.0f });
auto anim = m_compositor.CreateVector2KeyFrameAnimation();
anim.InsertKeyFrame(0.0f, { m_radius, m_radius });
anim.InsertKeyFrame(1.0f, { pressedRadius, pressedRadius }, ease);
anim.Duration(std::chrono::milliseconds(120));
m_spotlightMaskGradient.StartAnimation(L"EllipseRadius", anim);
}
// Spotlight release: animate the mask back to the configured radius.
void Highlighter::SpotlightAnimateRelease()
{
m_spotlightPressed = false;
if (!m_spotlightMode || !m_spotlightMaskGradient)
{
return;
}
auto ease = m_compositor.CreateCubicBezierEasingFunction({ 0.215f, 0.61f }, { 0.355f, 1.0f });
auto current = m_spotlightMaskGradient.EllipseRadius();
auto anim = m_compositor.CreateVector2KeyFrameAnimation();
anim.InsertKeyFrame(0.0f, current);
anim.InsertKeyFrame(1.0f, { m_radius, m_radius }, ease);
anim.Duration(std::chrono::milliseconds(200));
m_spotlightMaskGradient.StartAnimation(L"EllipseRadius", anim);
}
// Spawn the press/hold ring + glow at the click point. The shapes persist
// until FadeRippleHoldDot is called (button-up). While held they can be
// re-positioned to follow the cursor (UpdateDrawingPointPosition).
void Highlighter::SpawnRippleHoldDot(MouseButton button)
{
if (!m_compositor || !m_shape)
{
return;
}
winrt::Windows::UI::Color color = (button == MouseButton::Left) ? m_leftClickColor : m_rightClickColor;
if (color.A == 0)
{
return;
}
POINT pt{};
if (!GetCursorPos(&pt))
{
return;
}
ScreenToClient(m_hwnd, &pt);
const float fx = static_cast<float>(pt.x);
const float fy = static_cast<float>(pt.y);
// Resolve sizing/intensity from the ripple-specific settings so they're
// independent of the legacy "always-on dot" controls.
const float baseSize = (m_rippleSize > 1.0f) ? m_rippleSize : 1.0f;
float intensity = m_rippleIntensity;
if (intensity < 0.15f) intensity = 0.15f;
if (intensity > 1.35f) intensity = 1.35f;
const float ringHeld = baseSize * 0.55f;
const float glowHeld = baseSize * 0.65f;
const float lineWidth = (std::max)(2.25f, baseSize * (0.035f + intensity * 0.045f));
auto ease = m_compositor.CreateCubicBezierEasingFunction({ 0.215f, 0.61f }, { 0.355f, 1.0f });
// Held indicator: appears once the button has been held past the hold
// threshold and sits at the held radius until release. It must NOT expand
// outward on appearance — it only FADES IN at the held size. The single
// outward "ripple" expansion happens exclusively on release
// (FadeRippleHoldDot). If this grew outward, a slow single click (release
// shortly after the threshold) would show grow-to-held + release as two
// expansions — the double-ripple bug.
auto dur = std::chrono::milliseconds(120);
auto clampByte = [](float v) -> uint8_t {
if (v < 0.0f) v = 0.0f;
if (v > 255.0f) v = 255.0f;
return static_cast<uint8_t>(v);
};
// Glow color is the click color, lower alpha (×0.30), scaled by intensity.
const float glowAlpha = static_cast<float>(color.A) * 0.30f * intensity;
auto glowColor = winrt::Windows::UI::ColorHelper::FromArgb(clampByte(glowAlpha), color.R, color.G, color.B);
auto glowTransparent = winrt::Windows::UI::ColorHelper::FromArgb(0, color.R, color.G, color.B);
// Ring color uses full base alpha (alphaMul like the press recipe).
const float alphaMul = 0.18f + intensity * 0.78f;
auto ringColor = winrt::Windows::UI::ColorHelper::FromArgb(clampByte(static_cast<float>(color.A) * alphaMul), color.R, color.G, color.B);
auto ringTransparent = winrt::Windows::UI::ColorHelper::FromArgb(0, color.R, color.G, color.B);
// Clean up any stray "still held" shapes for this button — guards against
// stray button-down without matching button-up (e.g. focus loss).
winrt::CompositionSpriteShape& heldRing = (button == MouseButton::Left) ? m_leftPointer : m_rightPointer;
winrt::CompositionSpriteShape& heldGlow = (button == MouseButton::Left) ? m_leftRippleGlow : m_rightRippleGlow;
winrt::CompositionEllipseGeometry& heldGeom = (button == MouseButton::Left) ? m_leftGeometry : m_rightGeometry;
winrt::CompositionEllipseGeometry& heldGlowGeom = (button == MouseButton::Left) ? m_leftGlowGeometry : m_rightGlowGeometry;
if (m_shape && m_shape.Shapes())
{
auto shapes = m_shape.Shapes();
uint32_t idx = 0;
if (heldRing && shapes.IndexOf(heldRing, idx))
{
shapes.RemoveAt(idx);
}
if (heldGlow && shapes.IndexOf(heldGlow, idx))
{
shapes.RemoveAt(idx);
}
}
// Glow (filled) — added first so the ring renders on top. Sits at the held
// radius and fades its alpha in (no outward size growth).
auto glowGeom = m_compositor.CreateEllipseGeometry();
glowGeom.Radius({ glowHeld, glowHeld });
auto glowBrush = m_compositor.CreateColorBrush(glowTransparent);
auto glowShape = m_compositor.CreateSpriteShape(glowGeom);
glowShape.Offset({ fx, fy });
glowShape.FillBrush(glowBrush);
m_shape.Shapes().Append(glowShape);
auto glowFadeIn = m_compositor.CreateColorKeyFrameAnimation();
glowFadeIn.InsertKeyFrame(0.0f, glowTransparent);
glowFadeIn.InsertKeyFrame(1.0f, glowColor, ease);
glowFadeIn.Duration(dur);
glowBrush.StartAnimation(L"Color", glowFadeIn);
// Ring (stroked) — same: fixed at held radius, alpha fade-in only.
auto ringGeom = m_compositor.CreateEllipseGeometry();
ringGeom.Radius({ ringHeld, ringHeld });
auto ringBrush = m_compositor.CreateColorBrush(ringTransparent);
auto ringShape = m_compositor.CreateSpriteShape(ringGeom);
ringShape.Offset({ fx, fy });
ringShape.StrokeBrush(ringBrush);
ringShape.StrokeThickness(lineWidth);
ringShape.IsStrokeNonScaling(true);
m_shape.Shapes().Append(ringShape);
auto ringFadeIn = m_compositor.CreateColorKeyFrameAnimation();
ringFadeIn.InsertKeyFrame(0.0f, ringTransparent);
ringFadeIn.InsertKeyFrame(1.0f, ringColor, ease);
ringFadeIn.Duration(dur);
ringBrush.StartAnimation(L"Color", ringFadeIn);
heldRing = ringShape;
heldGlow = glowShape;
heldGeom = ringGeom;
heldGlowGeom = glowGeom;
}
// Continue the held-ring/glow animation outward and fade both to transparent.
// For right-click, optionally spawn the expanding crosshair lines.
void Highlighter::FadeRippleHoldDot(MouseButton button)
{
if (!m_compositor || !m_shape)
{
return;
}
winrt::CompositionSpriteShape& heldRing = (button == MouseButton::Left) ? m_leftPointer : m_rightPointer;
winrt::CompositionSpriteShape& heldGlow = (button == MouseButton::Left) ? m_leftRippleGlow : m_rightRippleGlow;
winrt::CompositionEllipseGeometry& heldGeom = (button == MouseButton::Left) ? m_leftGeometry : m_rightGeometry;
winrt::CompositionEllipseGeometry& heldGlowGeom = (button == MouseButton::Left) ? m_leftGlowGeometry : m_rightGlowGeometry;
if (!heldRing && !heldGlow)
{
return;
}
winrt::Windows::UI::Color color = (button == MouseButton::Left) ? m_leftClickColor : m_rightClickColor;
const float baseSize = (m_rippleSize > 1.0f) ? m_rippleSize : 1.0f;
float intensity = m_rippleIntensity;
if (intensity < 0.15f) intensity = 0.15f;
if (intensity > 1.35f) intensity = 1.35f;
int durationMs = m_rippleDurationMs;
if (durationMs < 60) durationMs = 60;
if (durationMs > 2000) durationMs = 2000;
auto dur = std::chrono::milliseconds(durationMs);
const float ringHeld = baseSize * 0.55f;
const float ringEnd = baseSize * 1.05f;
const float glowHeld = baseSize * 0.65f;
const float glowEnd = baseSize * 1.40f;
auto clampByte = [](float v) -> uint8_t {
if (v < 0.0f) v = 0.0f;
if (v > 255.0f) v = 255.0f;
return static_cast<uint8_t>(v);
};
auto ease = m_compositor.CreateCubicBezierEasingFunction({ 0.215f, 0.61f }, { 0.355f, 1.0f });
auto transparent = winrt::Windows::UI::ColorHelper::FromArgb(0, color.R, color.G, color.B);
// Track everything spawned by this fade (and the held shapes themselves)
// so the completion callback can remove them in one pass.
auto spawned = std::make_shared<std::vector<winrt::CompositionSpriteShape>>();
auto batch = m_compositor.CreateScopedBatch(winrt::CompositionBatchTypes::Animation);
if (heldGlow && heldGlowGeom)
{
// The held indicator has settled at the held radius; expand it outward
// from there and fade it to transparent.
heldGlowGeom.StopAnimation(L"Radius");
auto glowAnim = m_compositor.CreateVector2KeyFrameAnimation();
glowAnim.InsertKeyFrame(0.0f, { glowHeld, glowHeld });
glowAnim.InsertKeyFrame(1.0f, { glowEnd, glowEnd }, ease);
glowAnim.Duration(dur);
heldGlowGeom.StartAnimation(L"Radius", glowAnim);
auto brush = heldGlow.FillBrush().as<winrt::Windows::UI::Composition::CompositionColorBrush>();
auto startColor = brush.Color();
auto colorAnim = m_compositor.CreateColorKeyFrameAnimation();
colorAnim.InsertKeyFrame(0.0f, startColor);
colorAnim.InsertKeyFrame(1.0f, transparent, ease);
colorAnim.Duration(dur);
brush.StartAnimation(L"Color", colorAnim);
spawned->push_back(heldGlow);
}
if (heldRing && heldGeom)
{
heldGeom.StopAnimation(L"Radius");
auto ringAnim = m_compositor.CreateVector2KeyFrameAnimation();
ringAnim.InsertKeyFrame(0.0f, { ringHeld, ringHeld });
ringAnim.InsertKeyFrame(1.0f, { ringEnd, ringEnd }, ease);
ringAnim.Duration(dur);
heldGeom.StartAnimation(L"Radius", ringAnim);
auto brush = heldRing.StrokeBrush().as<winrt::Windows::UI::Composition::CompositionColorBrush>();
auto startColor = brush.Color();
auto colorAnim = m_compositor.CreateColorKeyFrameAnimation();
colorAnim.InsertKeyFrame(0.0f, startColor);
colorAnim.InsertKeyFrame(1.0f, transparent, ease);
colorAnim.Duration(dur);
brush.StartAnimation(L"Color", colorAnim);
spawned->push_back(heldRing);
}
// Right-click only: spawn expanding crosshair lines centered on the ring.
// Gated by the "show crosshairs on right-click release" toggle.
if (button == MouseButton::Right && m_rippleShowReleasePulse && heldRing)
{
const float xhairAlphaMul = 0.18f + intensity * 0.78f;
auto xhairColor = winrt::Windows::UI::ColorHelper::FromArgb(clampByte(static_cast<float>(color.A) * xhairAlphaMul), color.R, color.G, color.B);
const float xhairThickness = (std::max)(1.25f, baseSize * (0.025f + intensity * 0.03f));
auto center = heldRing.Offset();
const float startSpan = ringHeld * 0.85f;
const float endSpan = ringEnd * 0.85f;
auto makeLine = [&](float ax1, float ay1, float ax2, float ay2,
float bx1, float by1, float bx2, float by2) {
auto lineGeom = m_compositor.CreateLineGeometry();
lineGeom.Start({ ax1, ay1 });
lineGeom.End({ ax2, ay2 });
auto lineBrush = m_compositor.CreateColorBrush(xhairColor);
auto lineShape = m_compositor.CreateSpriteShape(lineGeom);
lineShape.StrokeBrush(lineBrush);
lineShape.StrokeThickness(xhairThickness);
lineShape.IsStrokeNonScaling(true);
m_shape.Shapes().Append(lineShape);
spawned->push_back(lineShape);
auto startAnim = m_compositor.CreateVector2KeyFrameAnimation();
startAnim.InsertKeyFrame(0.0f, { ax1, ay1 });
startAnim.InsertKeyFrame(1.0f, { bx1, by1 }, ease);
startAnim.Duration(dur);
lineGeom.StartAnimation(L"Start", startAnim);
auto endAnim = m_compositor.CreateVector2KeyFrameAnimation();
endAnim.InsertKeyFrame(0.0f, { ax2, ay2 });
endAnim.InsertKeyFrame(1.0f, { bx2, by2 }, ease);
endAnim.Duration(dur);
lineGeom.StartAnimation(L"End", endAnim);
auto colorAnim = m_compositor.CreateColorKeyFrameAnimation();
colorAnim.InsertKeyFrame(0.0f, xhairColor);
colorAnim.InsertKeyFrame(1.0f, transparent, ease);
colorAnim.Duration(dur);
lineBrush.StartAnimation(L"Color", colorAnim);
};
// Horizontal line (left half, right half).
makeLine(center.x - startSpan, center.y, center.x - startSpan * 0.30f, center.y,
center.x - endSpan, center.y, center.x - endSpan * 0.30f, center.y);
makeLine(center.x + startSpan * 0.30f, center.y, center.x + startSpan, center.y,
center.x + endSpan * 0.30f, center.y, center.x + endSpan, center.y);
// Vertical line (top half, bottom half).
makeLine(center.x, center.y - startSpan, center.x, center.y - startSpan * 0.30f,
center.x, center.y - endSpan, center.x, center.y - endSpan * 0.30f);
makeLine(center.x, center.y + startSpan * 0.30f, center.x, center.y + startSpan,
center.x, center.y + endSpan * 0.30f, center.x, center.y + endSpan);
}
// Detach our member handles BEFORE the batch completes so subsequent
// press events on this button create fresh shapes rather than racing.
heldRing = nullptr;
heldGlow = nullptr;
heldGeom = nullptr;
heldGlowGeom = nullptr;
batch.End();
if (spawned->empty())
{
return;
}
auto dispatcher = m_dispatcherQueueController.DispatcherQueue();
batch.Completed([dispatcher, spawned](auto&&, auto&&) {
dispatcher.TryEnqueue([spawned]() {
try
{
if (Highlighter::instance == nullptr || Highlighter::instance->m_shape == nullptr)
{
return;
}
auto shapes = Highlighter::instance->m_shape.Shapes();
for (auto const& s : *spawned)
{
uint32_t index = 0;
if (shapes.IndexOf(s, index))
{
shapes.RemoveAt(index);
}
}
}
catch (...)
{
// Highlighter may have torn down between batch completion and dispatch — ignore.
}
});
});
}
// Self-contained single ripple for a quick click (press + release before the
// hold threshold). Spawns a fresh ring + glow that grow from the click point
// outward and fade to transparent in one continuous animation — no held
// indicator, so a single click produces exactly one ripple. For right-click,
// optionally spawns the expanding crosshair lines too.
void Highlighter::EmitSingleRipple(MouseButton button)
{
if (!m_compositor || !m_shape)
{
return;
}
winrt::Windows::UI::Color color = (button == MouseButton::Left) ? m_leftClickColor : m_rightClickColor;
if (color.A == 0)
{
return;
}
POINT pt{};
if (!GetCursorPos(&pt))
{
return;
}
ScreenToClient(m_hwnd, &pt);
const float fx = static_cast<float>(pt.x);
const float fy = static_cast<float>(pt.y);
const float baseSize = (m_rippleSize > 1.0f) ? m_rippleSize : 1.0f;
float intensity = m_rippleIntensity;
if (intensity < 0.15f) intensity = 0.15f;
if (intensity > 1.35f) intensity = 1.35f;
int durationMs = m_rippleDurationMs;
if (durationMs < 60) durationMs = 60;
if (durationMs > 2000) durationMs = 2000;
auto dur = std::chrono::milliseconds(durationMs);
const float ringStart = baseSize * 0.20f;
const float ringEnd = baseSize * 1.05f;
const float glowStart = baseSize * 0.30f;
const float glowEnd = baseSize * 1.40f;
const float lineWidth = (std::max)(2.25f, baseSize * (0.035f + intensity * 0.045f));
auto clampByte = [](float v) -> uint8_t {
if (v < 0.0f) v = 0.0f;
if (v > 255.0f) v = 255.0f;
return static_cast<uint8_t>(v);
};
const float glowAlpha = static_cast<float>(color.A) * 0.30f * intensity;
auto glowColor = winrt::Windows::UI::ColorHelper::FromArgb(clampByte(glowAlpha), color.R, color.G, color.B);
const float alphaMul = 0.18f + intensity * 0.78f;
auto ringColor = winrt::Windows::UI::ColorHelper::FromArgb(clampByte(static_cast<float>(color.A) * alphaMul), color.R, color.G, color.B);
auto ease = m_compositor.CreateCubicBezierEasingFunction({ 0.215f, 0.61f }, { 0.355f, 1.0f });
auto transparent = winrt::Windows::UI::ColorHelper::FromArgb(0, color.R, color.G, color.B);
auto spawned = std::make_shared<std::vector<winrt::CompositionSpriteShape>>();
auto batch = m_compositor.CreateScopedBatch(winrt::CompositionBatchTypes::Animation);
// Glow (filled) — added first so the ring renders on top.
auto glowGeom = m_compositor.CreateEllipseGeometry();
glowGeom.Radius({ glowStart, glowStart });
auto glowBrush = m_compositor.CreateColorBrush(glowColor);
auto glowShape = m_compositor.CreateSpriteShape(glowGeom);
glowShape.Offset({ fx, fy });
glowShape.FillBrush(glowBrush);
m_shape.Shapes().Append(glowShape);
spawned->push_back(glowShape);
auto glowAnim = m_compositor.CreateVector2KeyFrameAnimation();
glowAnim.InsertKeyFrame(0.0f, { glowStart, glowStart });
glowAnim.InsertKeyFrame(1.0f, { glowEnd, glowEnd }, ease);
glowAnim.Duration(dur);
glowGeom.StartAnimation(L"Radius", glowAnim);
auto glowColorAnim = m_compositor.CreateColorKeyFrameAnimation();
glowColorAnim.InsertKeyFrame(0.0f, glowColor);
glowColorAnim.InsertKeyFrame(1.0f, transparent, ease);
glowColorAnim.Duration(dur);
glowBrush.StartAnimation(L"Color", glowColorAnim);
// Ring (stroked).
auto ringGeom = m_compositor.CreateEllipseGeometry();
ringGeom.Radius({ ringStart, ringStart });
auto ringBrush = m_compositor.CreateColorBrush(ringColor);
auto ringShape = m_compositor.CreateSpriteShape(ringGeom);
ringShape.Offset({ fx, fy });
ringShape.StrokeBrush(ringBrush);
ringShape.StrokeThickness(lineWidth);
ringShape.IsStrokeNonScaling(true);
m_shape.Shapes().Append(ringShape);
spawned->push_back(ringShape);
auto ringAnim = m_compositor.CreateVector2KeyFrameAnimation();
ringAnim.InsertKeyFrame(0.0f, { ringStart, ringStart });
ringAnim.InsertKeyFrame(1.0f, { ringEnd, ringEnd }, ease);
ringAnim.Duration(dur);
ringGeom.StartAnimation(L"Radius", ringAnim);
auto ringColorAnim = m_compositor.CreateColorKeyFrameAnimation();
ringColorAnim.InsertKeyFrame(0.0f, ringColor);
ringColorAnim.InsertKeyFrame(1.0f, transparent, ease);
ringColorAnim.Duration(dur);
ringBrush.StartAnimation(L"Color", ringColorAnim);
// Right-click only: spawn expanding crosshair lines centered on the click
// point. Gated by the "show crosshairs on right-click release" toggle.
if (button == MouseButton::Right && m_rippleShowReleasePulse)
{
auto xhairColor = ringColor;
const float xhairThickness = (std::max)(1.25f, baseSize * (0.025f + intensity * 0.03f));
const float startSpan = (baseSize * 0.55f) * 0.85f;
const float endSpan = ringEnd * 0.85f;
auto makeLine = [&](float ax1, float ay1, float ax2, float ay2,
float bx1, float by1, float bx2, float by2) {
auto lineGeom = m_compositor.CreateLineGeometry();
lineGeom.Start({ ax1, ay1 });
lineGeom.End({ ax2, ay2 });
auto lineBrush = m_compositor.CreateColorBrush(xhairColor);
auto lineShape = m_compositor.CreateSpriteShape(lineGeom);
lineShape.StrokeBrush(lineBrush);
lineShape.StrokeThickness(xhairThickness);
lineShape.IsStrokeNonScaling(true);
m_shape.Shapes().Append(lineShape);
spawned->push_back(lineShape);
auto startAnim = m_compositor.CreateVector2KeyFrameAnimation();
startAnim.InsertKeyFrame(0.0f, { ax1, ay1 });
startAnim.InsertKeyFrame(1.0f, { bx1, by1 }, ease);
startAnim.Duration(dur);
lineGeom.StartAnimation(L"Start", startAnim);
auto endAnim = m_compositor.CreateVector2KeyFrameAnimation();
endAnim.InsertKeyFrame(0.0f, { ax2, ay2 });
endAnim.InsertKeyFrame(1.0f, { bx2, by2 }, ease);
endAnim.Duration(dur);
lineGeom.StartAnimation(L"End", endAnim);
auto colorAnim = m_compositor.CreateColorKeyFrameAnimation();
colorAnim.InsertKeyFrame(0.0f, xhairColor);
colorAnim.InsertKeyFrame(1.0f, transparent, ease);
colorAnim.Duration(dur);
lineBrush.StartAnimation(L"Color", colorAnim);
};
// Horizontal line (left half, right half).
makeLine(fx - startSpan, fy, fx - startSpan * 0.30f, fy,
fx - endSpan, fy, fx - endSpan * 0.30f, fy);
makeLine(fx + startSpan * 0.30f, fy, fx + startSpan, fy,
fx + endSpan * 0.30f, fy, fx + endSpan, fy);
// Vertical line (top half, bottom half).
makeLine(fx, fy - startSpan, fx, fy - startSpan * 0.30f,
fx, fy - endSpan, fx, fy - endSpan * 0.30f);
makeLine(fx, fy + startSpan * 0.30f, fx, fy + startSpan,
fx, fy + endSpan * 0.30f, fx, fy + endSpan);
}
batch.End();
auto dispatcher = m_dispatcherQueueController.DispatcherQueue();
batch.Completed([dispatcher, spawned](auto&&, auto&&) {
dispatcher.TryEnqueue([spawned]() {
try
{
if (Highlighter::instance == nullptr || Highlighter::instance->m_shape == nullptr)
{
return;
}
auto shapes = Highlighter::instance->m_shape.Shapes();
for (auto const& s : *spawned)
{
uint32_t index = 0;
if (shapes.IndexOf(s, index))
{
shapes.RemoveAt(index);
}
}
}
catch (...)
{
// Highlighter may have torn down between batch completion and dispatch — ignore.
}
});
});
}
#pragma region MouseHighlighter_API
void MouseHighlighterApplySettings(MouseHighlighterSettings settings)

View File

@@ -4,10 +4,16 @@
const winrt::Windows::UI::Color MOUSE_HIGHLIGHTER_DEFAULT_LEFT_BUTTON_COLOR = winrt::Windows::UI::ColorHelper::FromArgb(166, 255, 255, 0);
const winrt::Windows::UI::Color MOUSE_HIGHLIGHTER_DEFAULT_RIGHT_BUTTON_COLOR = winrt::Windows::UI::ColorHelper::FromArgb(166, 0, 0, 255);
const winrt::Windows::UI::Color MOUSE_HIGHLIGHTER_DEFAULT_ALWAYS_COLOR = winrt::Windows::UI::ColorHelper::FromArgb(0, 255, 0, 0);
constexpr int MOUSE_HIGHLIGHTER_DEFAULT_RADIUS = 20;
constexpr int MOUSE_HIGHLIGHTER_DEFAULT_DELAY_MS = 500;
constexpr int MOUSE_HIGHLIGHTER_DEFAULT_DURATION_MS = 250;
constexpr int MOUSE_HIGHLIGHTER_DEFAULT_RADIUS = 30;
constexpr int MOUSE_HIGHLIGHTER_DEFAULT_DELAY_MS = 400;
constexpr int MOUSE_HIGHLIGHTER_DEFAULT_DURATION_MS = 400;
constexpr bool MOUSE_HIGHLIGHTER_DEFAULT_AUTO_ACTIVATE = false;
// Ripple-specific defaults (independent of the always-on circle settings above).
constexpr int MOUSE_HIGHLIGHTER_DEFAULT_RIPPLE_SIZE = 60;
constexpr double MOUSE_HIGHLIGHTER_DEFAULT_RIPPLE_INTENSITY = 0.7;
constexpr int MOUSE_HIGHLIGHTER_DEFAULT_RIPPLE_DURATION_MS = 480;
constexpr bool MOUSE_HIGHLIGHTER_DEFAULT_RIPPLE_SHOW_DRAG_TRAIL = true;
constexpr bool MOUSE_HIGHLIGHTER_DEFAULT_RIPPLE_SHOW_RELEASE_PULSE = true;
struct MouseHighlighterSettings
{
@@ -19,6 +25,12 @@ struct MouseHighlighterSettings
int fadeDurationMs = MOUSE_HIGHLIGHTER_DEFAULT_DURATION_MS;
bool autoActivate = MOUSE_HIGHLIGHTER_DEFAULT_AUTO_ACTIVATE;
bool spotlightMode = false;
bool rippleMode = true;
int rippleSize = MOUSE_HIGHLIGHTER_DEFAULT_RIPPLE_SIZE;
double rippleIntensity = MOUSE_HIGHLIGHTER_DEFAULT_RIPPLE_INTENSITY;
int rippleDurationMs = MOUSE_HIGHLIGHTER_DEFAULT_RIPPLE_DURATION_MS;
bool rippleShowDragTrail = MOUSE_HIGHLIGHTER_DEFAULT_RIPPLE_SHOW_DRAG_TRAIL;
bool rippleShowReleasePulse = MOUSE_HIGHLIGHTER_DEFAULT_RIPPLE_SHOW_RELEASE_PULSE;
};
int MouseHighlighterMain(HINSTANCE hinst, MouseHighlighterSettings settings);

View File

@@ -21,6 +21,12 @@ namespace
const wchar_t JSON_KEY_HIGHLIGHT_FADE_DURATION_MS[] = L"highlight_fade_duration_ms";
const wchar_t JSON_KEY_AUTO_ACTIVATE[] = L"auto_activate";
const wchar_t JSON_KEY_SPOTLIGHT_MODE[] = L"spotlight_mode";
const wchar_t JSON_KEY_RIPPLE_MODE[] = L"ripple_mode";
const wchar_t JSON_KEY_RIPPLE_SIZE[] = L"ripple_size";
const wchar_t JSON_KEY_RIPPLE_INTENSITY[] = L"ripple_intensity";
const wchar_t JSON_KEY_RIPPLE_DURATION_MS[] = L"ripple_duration_ms";
const wchar_t JSON_KEY_RIPPLE_SHOW_DRAG_TRAIL[] = L"ripple_show_drag_trail";
const wchar_t JSON_KEY_RIPPLE_SHOW_RELEASE_PULSE[] = L"ripple_show_release_pulse";
}
extern "C" IMAGE_DOS_HEADER __ImageBase;
@@ -392,6 +398,90 @@ public:
{
Logger::warn("Failed to initialize spotlight mode settings. Will use default value");
}
try
{
// Parse ripple mode
auto jsonPropertiesObject = settingsObject.GetNamedObject(JSON_KEY_PROPERTIES).GetNamedObject(JSON_KEY_RIPPLE_MODE);
highlightSettings.rippleMode = jsonPropertiesObject.GetNamedBoolean(JSON_KEY_VALUE);
}
catch (...)
{
Logger::warn("Failed to initialize ripple mode settings. Will use default value");
}
try
{
// Parse ripple size
auto jsonPropertiesObject = settingsObject.GetNamedObject(JSON_KEY_PROPERTIES).GetNamedObject(JSON_KEY_RIPPLE_SIZE);
int value = static_cast<int>(jsonPropertiesObject.GetNamedNumber(JSON_KEY_VALUE));
if (value > 0)
{
highlightSettings.rippleSize = value;
}
else
{
throw std::runtime_error("Invalid ripple size value");
}
}
catch (...)
{
Logger::warn("Failed to initialize ripple size from settings. Will use default value");
}
try
{
// Parse ripple intensity
auto jsonPropertiesObject = settingsObject.GetNamedObject(JSON_KEY_PROPERTIES).GetNamedObject(JSON_KEY_RIPPLE_INTENSITY);
double value = jsonPropertiesObject.GetNamedNumber(JSON_KEY_VALUE);
if (value > 0.0)
{
highlightSettings.rippleIntensity = value;
}
else
{
throw std::runtime_error("Invalid ripple intensity value");
}
}
catch (...)
{
Logger::warn("Failed to initialize ripple intensity from settings. Will use default value");
}
try
{
// Parse ripple duration
auto jsonPropertiesObject = settingsObject.GetNamedObject(JSON_KEY_PROPERTIES).GetNamedObject(JSON_KEY_RIPPLE_DURATION_MS);
int value = static_cast<int>(jsonPropertiesObject.GetNamedNumber(JSON_KEY_VALUE));
if (value > 0)
{
highlightSettings.rippleDurationMs = value;
}
else
{
throw std::runtime_error("Invalid ripple duration value");
}
}
catch (...)
{
Logger::warn("Failed to initialize ripple duration from settings. Will use default value");
}
try
{
// Parse ripple show drag trail
auto jsonPropertiesObject = settingsObject.GetNamedObject(JSON_KEY_PROPERTIES).GetNamedObject(JSON_KEY_RIPPLE_SHOW_DRAG_TRAIL);
highlightSettings.rippleShowDragTrail = jsonPropertiesObject.GetNamedBoolean(JSON_KEY_VALUE);
}
catch (...)
{
Logger::warn("Failed to initialize ripple show drag trail from settings. Will use default value");
}
try
{
// Parse ripple show release pulse
auto jsonPropertiesObject = settingsObject.GetNamedObject(JSON_KEY_PROPERTIES).GetNamedObject(JSON_KEY_RIPPLE_SHOW_RELEASE_PULSE);
highlightSettings.rippleShowReleasePulse = jsonPropertiesObject.GetNamedBoolean(JSON_KEY_VALUE);
}
catch (...)
{
Logger::warn("Failed to initialize ripple show release pulse from settings. Will use default value");
}
}
else
{

View File

@@ -39,6 +39,7 @@
</ItemGroup>
<ItemGroup>
<Natvis Include="$(MSBuildThisFileDirectory)..\..\natvis\wil.natvis" />
<Natvis Include="$(MSBuildThisFileDirectory)..\..\natvis\wil.natstepfilter" />
</ItemGroup>
<ItemGroup>
<None Include="packages.config" />

View File

@@ -1,4 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<packages>
<package id="Microsoft.Windows.ImplementationLibrary" version="1.0.250325.1" targetFramework="native" />
</packages>
<package id="Microsoft.Windows.ImplementationLibrary" version="1.0.260126.7" targetFramework="native" />
</packages>

View File

@@ -119,7 +119,7 @@
</resheader>
<data name="context_menu_item_new" xml:space="preserve">
<value>New+</value>
<comment>The main context menu item that users click on. This should be localized to match New in Windows. e.g. Danish it would become Ny+</comment>
<comment>The main context menu item that users click on. This should be localized to match New in Windows. e.g. Danish it would become Ny+, French it would become Nouveau+ (not Nouveauté+)</comment>
</data>
<data name="context_menu_item_open_templates" xml:space="preserve">
<value>Open templates</value>

View File

@@ -119,7 +119,7 @@
</resheader>
<data name="context_menu_item_new" xml:space="preserve">
<value>New+</value>
<comment>The main context menu item that users click on. This should be localized to match New in Windows. e.g. Danish it would become Ny+</comment>
<comment>The main context menu item that users click on. This should be localized to match New in Windows. e.g. Danish it would become Ny+, French it would become Nouveau+ (not Nouveauté+)</comment>
</data>
<data name="context_menu_item_open_templates" xml:space="preserve">
<value>Open templates</value>

View File

@@ -0,0 +1,821 @@
PackageName: BlackmagicDesign.DaVinciResolve
Name: DaVinci Resolve
WindowFilter: "Resolve.exe"
BackgroundProcess: false
Shortcuts:
- SectionName: Popular shortcuts
Properties:
- Name: Edit
Shortcut:
- Win: false
Ctrl: false
Shift: false
Alt: false
Keys:
- F5
- Name: Color
Shortcut:
- Win: false
Ctrl: false
Shift: false
Alt: false
Keys:
- F6
- Name: Fairlight
Shortcut:
- Win: false
Ctrl: false
Shift: false
Alt: false
Keys:
- F7
- Name: Deliver
Shortcut:
- Win: false
Ctrl: false
Shift: false
Alt: false
Keys:
- F8
- Name: Play / Pause
Shortcut:
- Win: false
Ctrl: false
Shift: false
Alt: false
Keys:
- Space
- Name: Play Reverse
Shortcut:
- Win: false
Ctrl: false
Shift: false
Alt: false
Keys:
- J
- Name: Stop
Shortcut:
- Win: false
Ctrl: false
Shift: false
Alt: false
Keys:
- K
- Name: Play Forward
Shortcut:
- Win: false
Ctrl: false
Shift: false
Alt: false
Keys:
- L
- Name: Import Media
Shortcut:
- Win: false
Ctrl: true
Shift: false
Alt: false
Keys:
- I
- Name: Export / Deliver
Shortcut:
- Win: false
Ctrl: true
Shift: true
Alt: false
Keys:
- E
- Name: Save Project
Shortcut:
- Win: false
Ctrl: true
Shift: false
Alt: false
Keys:
- S
- Name: Cut Clip
Shortcut:
- Win: false
Ctrl: true
Shift: false
Alt: false
Keys:
- B
- Name: Blade Edit
Shortcut:
- Win: false
Ctrl: true
Shift: false
Alt: false
Keys:
- Backslash
- Name: Ripple Delete
Shortcut:
- Win: false
Ctrl: false
Shift: true
Alt: false
Keys:
- Delete
- Name: Undo
Shortcut:
- Win: false
Ctrl: true
Shift: false
Alt: false
Keys:
- Z
- Name: Redo
Shortcut:
- Win: false
Ctrl: true
Shift: true
Alt: false
Keys:
- Z
- Name: Mark In
Shortcut:
- Win: false
Ctrl: false
Shift: false
Alt: false
Keys:
- I
- Name: Mark Out
Shortcut:
- Win: false
Ctrl: false
Shift: false
Alt: false
Keys:
- O
- Name: Marker
Shortcut:
- Win: false
Ctrl: false
Shift: false
Alt: false
Keys:
- M
- Name: Select All
Shortcut:
- Win: false
Ctrl: true
Shift: false
Alt: false
Keys:
- A
- Name: Go to Beginning
Shortcut:
- Win: false
Ctrl: false
Shift: false
Alt: false
Keys:
- Home
- Name: Go to End
Shortcut:
- Win: false
Ctrl: false
Shift: false
Alt: false
Keys:
- End
- Name: Snapping
Shortcut:
- Win: false
Ctrl: false
Shift: false
Alt: false
Keys:
- N
- Name: Selection Mode
Shortcut:
- Win: false
Ctrl: false
Shift: false
Alt: false
Keys:
- A
- Name: Trim Mode
Shortcut:
- Win: false
Ctrl: false
Shift: false
Alt: false
Keys:
- T
- Name: Change Clip Speed
Shortcut:
- Win: false
Ctrl: true
Shift: false
Alt: false
Keys:
- R
- SectionName: Timeline navigation
Properties:
- Name: Go to Next Frame
Shortcut:
- Win: false
Ctrl: false
Shift: false
Alt: false
Keys:
- Right
- Name: Go to Previous Frame
Shortcut:
- Win: false
Ctrl: false
Shift: false
Alt: false
Keys:
- Left
- Name: Jump Forward 5 Frames
Shortcut:
- Win: false
Ctrl: false
Shift: true
Alt: false
Keys:
- Right
- Name: Jump Back 5 Frames
Shortcut:
- Win: false
Ctrl: false
Shift: true
Alt: false
Keys:
- Left
- Name: Go to Next Clip
Shortcut:
- Win: false
Ctrl: false
Shift: true
Alt: false
Keys:
- Up
- Name: Go to Previous Clip
Shortcut:
- Win: false
Ctrl: false
Shift: true
Alt: false
Keys:
- Down
- Name: Go to Next Track
Shortcut:
- Win: false
Ctrl: false
Shift: false
Alt: false
Keys:
- Down
- Name: Go to Previous Track
Shortcut:
- Win: false
Ctrl: false
Shift: false
Alt: false
Keys:
- Up
- Name: Zoom In Timeline
Shortcut:
- Win: false
Ctrl: false
Shift: false
Alt: false
Keys:
- Equals
- Name: Zoom Out Timeline
Shortcut:
- Win: false
Ctrl: false
Shift: false
Alt: false
Keys:
- Minus
- Name: Full Screen Playback
Shortcut:
- Win: false
Ctrl: false
Shift: true
Alt: false
Keys:
- Space
- Name: Go to Previous Edit Point
Shortcut:
- Win: false
Ctrl: false
Shift: false
Alt: false
Keys:
- PageUp
- Name: Go to Next Edit Point
Shortcut:
- Win: false
Ctrl: false
Shift: false
Alt: false
Keys:
- PageDown
- SectionName: Edit
Properties:
- Name: Delete
Shortcut:
- Win: false
Ctrl: false
Shift: false
Alt: false
Keys:
- Delete
- 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: Cut
Shortcut:
- Win: false
Ctrl: true
Shift: false
Alt: false
Keys:
- X
- Name: Duplicate Clip
Shortcut:
- Win: false
Ctrl: true
Shift: false
Alt: false
Keys:
- D
- Name: Render in Place
Shortcut:
- Win: false
Ctrl: true
Shift: true
Alt: false
Keys:
- X
- Name: Add Edit
Shortcut:
- Win: false
Ctrl: true
Shift: false
Alt: false
Keys:
- Backslash
- Name: Append to End of Timeline
Shortcut:
- Win: false
Ctrl: false
Shift: false
Alt: false
Keys:
- End
- Name: Replace Clip
Shortcut:
- Win: false
Ctrl: false
Shift: false
Alt: false
Keys:
- R
- Name: Move Clip Up One Track
Shortcut:
- Win: false
Ctrl: true
Shift: true
Alt: false
Keys:
- Up
- Name: Move Clip Down One Track
Shortcut:
- Win: false
Ctrl: true
Shift: true
Alt: false
Keys:
- Down
- Name: Split Clip
Shortcut:
- Win: false
Ctrl: true
Shift: false
Alt: false
Keys:
- B
- Name: Link Clips
Shortcut:
- Win: false
Ctrl: true
Shift: false
Alt: false
Keys:
- L
- Name: Create Compound Clip
Shortcut:
- Win: false
Ctrl: true
Shift: false
Alt: false
Keys:
- G
- SectionName: Color
Properties:
- Name: Add Serial Node
Shortcut:
- Win: false
Ctrl: false
Shift: false
Alt: true
Keys:
- S
- Name: Add Parallel Node
Shortcut:
- Win: false
Ctrl: false
Shift: false
Alt: true
Keys:
- P
- Name: Add Layer Node
Shortcut:
- Win: false
Ctrl: false
Shift: false
Alt: true
Keys:
- L
- Name: Select Node 1
Shortcut:
- Win: false
Ctrl: false
Shift: false
Alt: false
Keys:
- "1"
- Name: Select Node 2
Shortcut:
- Win: false
Ctrl: false
Shift: false
Alt: false
Keys:
- "2"
- Name: Select Node 3
Shortcut:
- Win: false
Ctrl: false
Shift: false
Alt: false
Keys:
- "3"
- Name: Select Node 4
Shortcut:
- Win: false
Ctrl: false
Shift: false
Alt: false
Keys:
- "4"
- Name: Select Node 5
Shortcut:
- Win: false
Ctrl: false
Shift: false
Alt: false
Keys:
- "5"
- Name: Enable/Disable Current Grade
Shortcut:
- Win: false
Ctrl: false
Shift: false
Alt: false
Keys:
- D
- Name: Preview Mode
Shortcut:
- Win: false
Ctrl: false
Shift: true
Alt: false
Keys:
- W
- Name: Grade All Frames in Clip
Shortcut:
- Win: false
Ctrl: true
Shift: false
Alt: false
Keys:
- D
- Name: Keyframe Mode
Shortcut:
- Win: false
Ctrl: false
Shift: false
Alt: false
Keys:
- K
- Name: Select Color Wheels
Shortcut:
- Win: false
Ctrl: false
Shift: false
Alt: false
Keys:
- "1"
- Name: Select Curves
Shortcut:
- Win: false
Ctrl: false
Shift: false
Alt: false
Keys:
- "2"
- Name: Select Qualifier
Shortcut:
- Win: false
Ctrl: false
Shift: false
Alt: false
Keys:
- "3"
- Name: Select Power Window
Shortcut:
- Win: false
Ctrl: false
Shift: false
Alt: false
Keys:
- "4"
- Name: Select Tracking
Shortcut:
- Win: false
Ctrl: false
Shift: false
Alt: false
Keys:
- "5"
- Name: Reset Color Grade
Shortcut:
- Win: false
Ctrl: true
Shift: false
Alt: false
Keys:
- U
- SectionName: Fairlight
Properties:
- Name: Mute Track
Shortcut:
- Win: false
Ctrl: false
Shift: false
Alt: false
Keys:
- M
- Name: Solo Track
Shortcut:
- Win: false
Ctrl: false
Shift: false
Alt: false
Keys:
- S
- Name: Automation Mode
Shortcut:
- Win: false
Ctrl: false
Shift: false
Alt: false
Keys:
- F
- Name: Record Arm Selected Track
Shortcut:
- Win: false
Ctrl: false
Shift: false
Alt: false
Keys:
- R
- Name: Headphones Solo
Shortcut:
- Win: false
Ctrl: false
Shift: false
Alt: false
Keys:
- H
- Name: Add Marker
Shortcut:
- Win: false
Ctrl: false
Shift: false
Alt: false
Keys:
- Insert
- Name: Add Audio Track
Shortcut:
- Win: false
Ctrl: true
Shift: true
Alt: false
Keys:
- A
- Name: Bounce Mix
Shortcut:
- Win: false
Ctrl: true
Shift: true
Alt: false
Keys:
- X
- SectionName: Fusion
Properties:
- Name: Switch Between Spline and Keyframes
Shortcut:
- Win: false
Ctrl: false
Shift: false
Alt: false
Keys:
- K
- Name: Add Keyframe
Shortcut:
- Win: false
Ctrl: false
Shift: false
Alt: false
Keys:
- Shift
- Name: View Current Tool
Shortcut:
- Win: false
Ctrl: false
Shift: false
Alt: false
Keys:
- "1"
- Name: View Node Flow
Shortcut:
- Win: false
Ctrl: false
Shift: false
Alt: false
Keys:
- "2"
- Name: View Keyframes
Shortcut:
- Win: false
Ctrl: false
Shift: false
Alt: false
Keys:
- "3"
- Name: View Spline
Shortcut:
- Win: false
Ctrl: false
Shift: false
Alt: false
Keys:
- "4"
- Name: Merge Selected Tools
Shortcut:
- Win: false
Ctrl: true
Shift: false
Alt: false
Keys:
- N
- Name: Bypass Selected Tool
Shortcut:
- Win: false
Ctrl: false
Shift: false
Alt: false
Keys:
- "1"
- SectionName: Media
Properties:
- Name: Reveal in Explorer
Shortcut:
- Win: false
Ctrl: true
Shift: false
Alt: false
Keys:
- E
- Name: Smart Bin
Shortcut:
- Win: false
Ctrl: true
Shift: true
Alt: false
Keys:
- S
- Name: Rename Clip
Shortcut:
- Win: false
Ctrl: false
Shift: false
Alt: false
Keys:
- F2
- Name: Import XML / AAF
Shortcut:
- Win: false
Ctrl: true
Shift: true
Alt: false
Keys:
- I
- Name: Create New Bin
Shortcut:
- Win: false
Ctrl: true
Shift: false
Alt: false
Keys:
- N
- Name: Add Clip to Timeline
Shortcut:
- Win: false
Ctrl: false
Shift: false
Alt: false
Keys:
- Enter
- Name: Viewer Zoom In
Shortcut:
- Win: false
Ctrl: true
Shift: false
Alt: false
Keys:
- Equals
- Name: Viewer Zoom Out
Shortcut:
- Win: false
Ctrl: true
Shift: false
Alt: false
Keys:
- Minus
- SectionName: Deliver
Properties:
- Name: Add to Render Queue
Shortcut:
- Win: false
Ctrl: true
Shift: false
Alt: false
Keys:
- Enter
- Name: Start Render
Shortcut:
- Win: false
Ctrl: true
Shift: true
Alt: false
Keys:
- Enter
- Name: Select Preset
Shortcut:
- Win: false
Ctrl: false
Shift: false
Alt: false
Keys:
- F2
- Name: Render Settings
Shortcut:
- Win: false
Ctrl: true
Shift: false
Alt: false
Keys:
- E
- Name: Browse Output Location
Shortcut:
- Win: false
Ctrl: true
Shift: false
Alt: false
Keys:
- B

View File

@@ -3,7 +3,6 @@
// See the LICENSE file in the project root for more information.
using System;
using System.ComponentModel;
using System.Drawing;
using System.Drawing.Drawing2D;
using System.Drawing.Imaging;
@@ -12,25 +11,17 @@ using System.Linq;
using System.Text.Json.Serialization;
using System.Text.RegularExpressions;
using System.Windows.Media.Imaging;
using CommunityToolkit.Mvvm.ComponentModel;
using Windows.Management.Deployment;
namespace WorkspacesCsharpLibrary.Models
{
public partial class BaseApplication : INotifyPropertyChanged, IDisposable
public partial class BaseApplication : ObservableObject, IDisposable
{
public event PropertyChangedEventHandler PropertyChanged;
public void OnPropertyChanged(PropertyChangedEventArgs e)
{
PropertyChanged?.Invoke(this, e);
}
public string PwaAppId { get; set; }
public string AppPath { get; set; }
private bool _isNotFound;
public string PackagedId { get; set; }
public string PackagedName { get; set; }
@@ -39,23 +30,9 @@ namespace WorkspacesCsharpLibrary.Models
public string Aumid { get; set; }
[JsonIgnore]
public bool IsNotFound
{
get
{
return _isNotFound;
}
set
{
if (_isNotFound != value)
{
_isNotFound = value;
OnPropertyChanged(new PropertyChangedEventArgs(nameof(IsNotFound)));
}
}
}
[ObservableProperty]
[property: JsonIgnore]
private bool _isNotFound;
private Icon _icon;

View File

@@ -18,6 +18,7 @@
</PropertyGroup>
<ItemGroup>
<PackageReference Include="CommunityToolkit.Mvvm" />
<PackageReference Include="System.IO.Abstractions" />
</ItemGroup>

View File

@@ -0,0 +1,273 @@
// 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.Generic;
using Microsoft.VisualStudio.TestTools.UnitTesting;
using WorkspacesEditor.Models;
namespace WorkspacesEditor.UnitTests
{
/// <summary>
/// Tests for the Application model: state toggles, computed properties,
/// position management, and copy semantics.
/// </summary>
[TestClass]
public class ApplicationModelTests
{
[TestMethod]
[TestCategory("Model.Application")]
public void SwitchDeletion_InitiallyIncluded_TogglesOff()
{
var project = TestHelpers.CreateProject("Test", 0, 0, "Notepad");
var app = project.Applications[0];
app.IsIncluded = true;
app.IsIncluded = !app.IsIncluded;
Assert.IsFalse(app.IsIncluded);
}
[TestMethod]
[TestCategory("Model.Application")]
public void SwitchDeletion_InitiallyExcluded_TogglesOn()
{
var project = TestHelpers.CreateProject("Test", 0, 0, "Notepad");
var app = project.Applications[0];
app.IsIncluded = false;
app.IsIncluded = !app.IsIncluded;
Assert.IsTrue(app.IsIncluded);
}
[TestMethod]
[TestCategory("Model.Application")]
public void SwitchDeletion_DoubleToggle_ReturnsToOriginal()
{
var project = TestHelpers.CreateProject("Test", 0, 0, "Notepad");
var app = project.Applications[0];
app.IsIncluded = true;
app.IsIncluded = !app.IsIncluded;
app.IsIncluded = !app.IsIncluded;
Assert.IsTrue(app.IsIncluded);
}
[TestMethod]
[TestCategory("Model.Application")]
public void AppMainParams_NotElevatedNoArgs_ReturnsEmpty()
{
var project = TestHelpers.CreateProject("Test", 0, 0, "Notepad");
var app = project.Applications[0];
app.IsElevated = false;
app.CommandLineArguments = string.Empty;
Assert.AreEqual(string.Empty, app.AppMainParams);
}
[TestMethod]
[TestCategory("Model.Application")]
public void AppMainParams_ElevatedNoArgs_ContainsText()
{
var project = TestHelpers.CreateProject("Test", 0, 0, "Regedit");
var app = project.Applications[0];
app.IsElevated = true;
app.CommandLineArguments = string.Empty;
Assert.IsTrue(app.AppMainParams.Length > 0);
}
[TestMethod]
[TestCategory("Model.Application")]
public void AppMainParams_NotElevatedWithArgs_ContainsArgs()
{
var project = TestHelpers.CreateProject("Test", 0, 0, "Code");
var app = project.Applications[0];
app.IsElevated = false;
app.CommandLineArguments = "--new-window";
Assert.IsTrue(app.AppMainParams.Contains("--new-window", System.StringComparison.Ordinal));
}
[TestMethod]
[TestCategory("Model.Application")]
public void AppMainParams_ElevatedWithArgs_ContainsBoth()
{
var project = TestHelpers.CreateProject("Test", 0, 0, "Code");
var app = project.Applications[0];
app.IsElevated = true;
app.CommandLineArguments = "--reuse-window";
var result = app.AppMainParams;
Assert.IsTrue(result.Contains("--reuse-window", System.StringComparison.Ordinal));
Assert.IsTrue(result.Contains('|'), "Should have separator between admin and args");
}
[TestMethod]
[TestCategory("Model.Application")]
public void PositionComboboxIndex_Custom_ReturnsZero()
{
var project = TestHelpers.CreateProject("Test", 0, 0, "App");
var app = project.Applications[0];
app.Minimized = false;
app.Maximized = false;
Assert.AreEqual(0, app.PositionComboboxIndex);
}
[TestMethod]
[TestCategory("Model.Application")]
public void PositionComboboxIndex_Maximized_ReturnsOne()
{
var project = TestHelpers.CreateProject("Test", 0, 0, "App");
var app = project.Applications[0];
app.Minimized = false;
app.Maximized = true;
Assert.AreEqual(1, app.PositionComboboxIndex);
}
[TestMethod]
[TestCategory("Model.Application")]
public void PositionComboboxIndex_Minimized_ReturnsTwo()
{
var project = TestHelpers.CreateProject("Test", 0, 0, "App");
var app = project.Applications[0];
app.Minimized = true;
app.Maximized = false;
Assert.AreEqual(2, app.PositionComboboxIndex);
}
[TestMethod]
[TestCategory("Model.Application")]
public void EditPositionEnabled_CustomPosition_ReturnsTrue()
{
var project = TestHelpers.CreateProject("Test", 0, 0, "App");
var app = project.Applications[0];
app.Minimized = false;
app.Maximized = false;
Assert.IsTrue(app.EditPositionEnabled);
}
[TestMethod]
[TestCategory("Model.Application")]
public void EditPositionEnabled_Maximized_ReturnsFalse()
{
var project = TestHelpers.CreateProject("Test", 0, 0, "App");
var app = project.Applications[0];
app.Maximized = true;
Assert.IsFalse(app.EditPositionEnabled);
}
[TestMethod]
[TestCategory("Model.Application")]
public void EditPositionEnabled_Minimized_ReturnsFalse()
{
var project = TestHelpers.CreateProject("Test", 0, 0, "App");
var app = project.Applications[0];
app.Minimized = true;
Assert.IsFalse(app.EditPositionEnabled);
}
[TestMethod]
[TestCategory("Model.Application")]
public void RepeatIndexString_IndexZeroOrOne_ReturnsEmpty()
{
var project = TestHelpers.CreateProject("Test", 0, 0, "App");
var app = project.Applications[0];
app.RepeatIndex = 0;
Assert.AreEqual(string.Empty, app.RepeatIndexString);
app.RepeatIndex = 1;
Assert.AreEqual(string.Empty, app.RepeatIndexString);
}
[TestMethod]
[TestCategory("Model.Application")]
public void RepeatIndexString_IndexGreaterThanOne_ReturnsNumber()
{
var project = TestHelpers.CreateProject("Test", 0, 0, "App");
var app = project.Applications[0];
app.RepeatIndex = 2;
Assert.AreEqual("2", app.RepeatIndexString);
app.RepeatIndex = 5;
Assert.AreEqual("5", app.RepeatIndexString);
}
[TestMethod]
[TestCategory("Model.Application")]
public void WindowPosition_Equality_SameValues_ReturnsTrue()
{
var pos1 = new Application.WindowPosition { X = 100, Y = 200, Width = 800, Height = 600 };
var pos2 = new Application.WindowPosition { X = 100, Y = 200, Width = 800, Height = 600 };
Assert.IsTrue(pos1 == pos2);
}
[TestMethod]
[TestCategory("Model.Application")]
public void WindowPosition_Inequality_DifferentValues_ReturnsTrue()
{
var pos1 = new Application.WindowPosition { X = 0, Y = 0, Width = 1920, Height = 1080 };
var pos2 = new Application.WindowPosition { X = 960, Y = 0, Width = 960, Height = 1080 };
Assert.IsTrue(pos1 != pos2);
}
[TestMethod]
[TestCategory("Model.Application")]
public void CopyConstructor_CopiesAllFields()
{
var project = TestHelpers.CreateProject("Test", 0, 0, "VS Code");
var original = project.Applications[0];
original.CommandLineArguments = "--new-window";
original.IsElevated = true;
original.Maximized = true;
original.MonitorNumber = 2;
original.RepeatIndex = 3;
var copy = new Application(original);
Assert.AreEqual(original.AppName, copy.AppName);
Assert.AreEqual(original.CommandLineArguments, copy.CommandLineArguments);
Assert.AreEqual(original.IsElevated, copy.IsElevated);
Assert.AreEqual(original.Maximized, copy.Maximized);
Assert.AreEqual(original.MonitorNumber, copy.MonitorNumber);
Assert.AreEqual(original.RepeatIndex, copy.RepeatIndex);
}
[TestMethod]
[TestCategory("Model.Application")]
public void IsAppMainParamVisible_EmptyParams_ReturnsFalse()
{
var project = TestHelpers.CreateProject("Test", 0, 0, "App");
var app = project.Applications[0];
app.IsElevated = false;
app.CommandLineArguments = string.Empty;
_ = app.AppMainParams;
Assert.IsFalse(app.IsAppMainParamVisible);
}
[TestMethod]
[TestCategory("Model.Application")]
public void IsAppMainParamVisible_HasParams_ReturnsTrue()
{
var project = TestHelpers.CreateProject("Test", 0, 0, "App");
var app = project.Applications[0];
app.IsElevated = true;
_ = app.AppMainParams;
Assert.IsTrue(app.IsAppMainParamVisible);
}
}
}

View File

@@ -0,0 +1,193 @@
// Copyright (c) Microsoft Corporation
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
using System.Collections.ObjectModel;
using System.Linq;
using Microsoft.VisualStudio.TestTools.UnitTesting;
using WorkspacesEditor.Models;
using WorkspacesEditor.ViewModels;
namespace WorkspacesEditor.UnitTests
{
/// <summary>
/// Tests for MainViewModel search and filter logic.
/// The search filters workspaces by name and app name (case-insensitive, partial match).
/// This behavior must be preserved after the WinUI migration.
/// </summary>
[TestClass]
public class EditorViewModelSearchAndFilterTests
{
[TestMethod]
[TestCategory("ViewModel.Search")]
public void SearchTerm_Empty_ReturnsAllWorkspaces()
{
var vm = TestHelpers.CreateViewModel();
vm.Workspaces = new ObservableCollection<Project>
{
TestHelpers.CreateProject("DevSetup", 0, 0, "VS Code", "Terminal"),
TestHelpers.CreateProject("Browsing", 0, 0, "Edge", "Notepad"),
};
vm.SearchTerm = string.Empty;
Assert.AreEqual(2, vm.WorkspacesView.Count);
}
[TestMethod]
[TestCategory("ViewModel.Search")]
public void SearchTerm_Null_ReturnsAllWorkspaces()
{
var vm = TestHelpers.CreateViewModel();
vm.Workspaces = new ObservableCollection<Project>
{
TestHelpers.CreateProject("DevSetup", 0, 0, "VS Code"),
TestHelpers.CreateProject("Browsing", 0, 0, "Edge"),
};
vm.SearchTerm = null;
vm.RefreshWorkspacesView();
Assert.AreEqual(2, vm.WorkspacesView.Count);
}
[TestMethod]
[TestCategory("ViewModel.Search")]
public void SearchTerm_MatchesWorkspaceName_ReturnsMatching()
{
var vm = TestHelpers.CreateViewModel();
vm.Workspaces = new ObservableCollection<Project>
{
TestHelpers.CreateProject("DevSetup", 0, 0, "VS Code"),
TestHelpers.CreateProject("Browsing", 0, 0, "Edge"),
TestHelpers.CreateProject("DesignWork", 0, 0, "Figma"),
};
vm.SearchTerm = "Dev";
var results = vm.WorkspacesView.ToList();
Assert.AreEqual(1, results.Count);
Assert.AreEqual("DevSetup", results[0].Name);
}
[TestMethod]
[TestCategory("ViewModel.Search")]
public void SearchTerm_MatchesAppName_ReturnsWorkspaceContainingApp()
{
var vm = TestHelpers.CreateViewModel();
vm.Workspaces = new ObservableCollection<Project>
{
TestHelpers.CreateProject("DevSetup", 0, 0, "VS Code", "Terminal"),
TestHelpers.CreateProject("Browsing", 0, 0, "Edge", "Notepad"),
};
vm.SearchTerm = "Terminal";
var results = vm.WorkspacesView.ToList();
Assert.AreEqual(1, results.Count);
Assert.AreEqual("DevSetup", results[0].Name);
}
[TestMethod]
[TestCategory("ViewModel.Search")]
public void SearchTerm_CaseInsensitive_MatchesRegardlessOfCase()
{
var vm = TestHelpers.CreateViewModel();
vm.Workspaces = new ObservableCollection<Project>
{
TestHelpers.CreateProject("DevSetup", 0, 0, "VS Code"),
TestHelpers.CreateProject("Browsing", 0, 0, "Edge"),
};
vm.SearchTerm = "devsetup";
Assert.AreEqual(1, vm.WorkspacesView.Count);
}
[TestMethod]
[TestCategory("ViewModel.Search")]
public void SearchTerm_NoMatch_ReturnsEmpty()
{
var vm = TestHelpers.CreateViewModel();
vm.Workspaces = new ObservableCollection<Project>
{
TestHelpers.CreateProject("DevSetup", 0, 0, "VS Code"),
TestHelpers.CreateProject("Browsing", 0, 0, "Edge"),
};
vm.SearchTerm = "NonExistent";
Assert.AreEqual(0, vm.WorkspacesView.Count);
}
[TestMethod]
[TestCategory("ViewModel.Search")]
public void SearchTerm_PartialMatch_MatchesSubstring()
{
var vm = TestHelpers.CreateViewModel();
vm.Workspaces = new ObservableCollection<Project>
{
TestHelpers.CreateProject("MyDevelopmentSetup", 0, 0, "VS Code"),
TestHelpers.CreateProject("Browsing", 0, 0, "Edge"),
};
vm.SearchTerm = "Develop";
Assert.AreEqual(1, vm.WorkspacesView.Count);
}
[TestMethod]
[TestCategory("ViewModel.Search")]
public void SearchTerm_MatchesMultiple_ReturnsAll()
{
var vm = TestHelpers.CreateViewModel();
vm.Workspaces = new ObservableCollection<Project>
{
TestHelpers.CreateProject("DevSetup1", 0, 0, "VS Code"),
TestHelpers.CreateProject("DevSetup2", 0, 0, "Terminal"),
TestHelpers.CreateProject("Browsing", 0, 0, "Edge"),
};
vm.SearchTerm = "Dev";
Assert.AreEqual(2, vm.WorkspacesView.Count);
}
[TestMethod]
[TestCategory("ViewModel.Search")]
public void SearchTerm_Changed_RaisesPropertyChangedForWorkspacesView()
{
var vm = TestHelpers.CreateViewModel();
vm.Workspaces = new ObservableCollection<Project>
{
TestHelpers.CreateProject("Test", 0, 0, "App"),
};
var changedProps = new System.Collections.Generic.List<string>();
vm.PropertyChanged += (s, e) => changedProps.Add(e.PropertyName);
vm.SearchTerm = "Test";
Assert.IsTrue(changedProps.Contains("WorkspacesView"), $"Expected WorkspacesView in [{string.Join(", ", changedProps)}]");
}
[TestMethod]
[TestCategory("ViewModel.Search")]
public void SearchTerm_EmptyCollection_ReturnsEmptyAndSetsFlag()
{
var vm = TestHelpers.CreateViewModel();
vm.Workspaces = new ObservableCollection<Project>();
vm.SearchTerm = "anything";
Assert.AreEqual(0, vm.WorkspacesView.Count);
Assert.IsTrue(vm.IsWorkspacesViewEmpty);
}
[TestMethod]
[TestCategory("ViewModel.Search")]
public void SearchTerm_MatchesAppNameCaseInsensitive_ReturnsWorkspace()
{
var vm = TestHelpers.CreateViewModel();
vm.Workspaces = new ObservableCollection<Project>
{
TestHelpers.CreateProject("MySetup", 0, 0, "Visual Studio Code"),
};
vm.SearchTerm = "visual studio";
Assert.AreEqual(1, vm.WorkspacesView.Count);
}
}
}

View File

@@ -0,0 +1,118 @@
// Copyright (c) Microsoft Corporation
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
using System.Collections.ObjectModel;
using System.Linq;
using Microsoft.VisualStudio.TestTools.UnitTesting;
using WorkspacesEditor.Models;
using WorkspacesEditor.ViewModels;
namespace WorkspacesEditor.UnitTests
{
/// <summary>
/// Tests for MainViewModel sort logic.
/// Sorting affects the order of WorkspacesView: by name, creation time, or last-launched.
/// </summary>
[TestClass]
public class EditorViewModelSortTests
{
[TestMethod]
[TestCategory("ViewModel.Sort")]
public void Sort_ByName_ReturnsAlphabeticalOrder()
{
var vm = TestHelpers.CreateViewModel();
vm.Workspaces = new ObservableCollection<Project>
{
TestHelpers.CreateProject("Zebra", 0, 0, "App"),
TestHelpers.CreateProject("Alpha", 0, 0, "App"),
TestHelpers.CreateProject("Middle", 0, 0, "App"),
};
vm.OrderByIndex = 2; // Name
vm.RefreshWorkspacesView();
var results = vm.WorkspacesView.ToList();
Assert.AreEqual("Alpha", results[0].Name);
Assert.AreEqual("Middle", results[1].Name);
Assert.AreEqual("Zebra", results[2].Name);
}
[TestMethod]
[TestCategory("ViewModel.Sort")]
public void Sort_ByCreated_ReturnsNewestFirst()
{
var vm = TestHelpers.CreateViewModel();
vm.Workspaces = new ObservableCollection<Project>
{
TestHelpers.CreateProject("Oldest", 1000, 0, "App"),
TestHelpers.CreateProject("Newest", 3000, 0, "App"),
TestHelpers.CreateProject("Middle", 2000, 0, "App"),
};
vm.OrderByIndex = 1; // Created (descending)
vm.RefreshWorkspacesView();
var results = vm.WorkspacesView.ToList();
Assert.AreEqual("Newest", results[0].Name);
Assert.AreEqual("Middle", results[1].Name);
Assert.AreEqual("Oldest", results[2].Name);
}
[TestMethod]
[TestCategory("ViewModel.Sort")]
public void Sort_ByLastViewed_ReturnsMostRecentFirst()
{
var vm = TestHelpers.CreateViewModel();
vm.Workspaces = new ObservableCollection<Project>
{
TestHelpers.CreateProject("LeastRecent", 0, 1000, "App"),
TestHelpers.CreateProject("MostRecent", 0, 3000, "App"),
TestHelpers.CreateProject("Middle", 0, 2000, "App"),
};
vm.OrderByIndex = 0; // LastViewed (descending)
vm.RefreshWorkspacesView();
var results = vm.WorkspacesView.ToList();
Assert.AreEqual("MostRecent", results[0].Name);
Assert.AreEqual("Middle", results[1].Name);
Assert.AreEqual("LeastRecent", results[2].Name);
}
[TestMethod]
[TestCategory("ViewModel.Sort")]
public void Sort_OrderByIndex_RaisesPropertyChanged()
{
var vm = TestHelpers.CreateViewModel();
vm.Workspaces = new ObservableCollection<Project>();
var changedProps = new System.Collections.Generic.List<string>();
vm.PropertyChanged += (s, e) => changedProps.Add(e.PropertyName);
vm.OrderByIndex = 1;
Assert.IsTrue(changedProps.Contains("WorkspacesView"), $"Expected WorkspacesView in [{string.Join(", ", changedProps)}]");
}
[TestMethod]
[TestCategory("ViewModel.Sort")]
public void Sort_CombinedWithFilter_FilteredResultsAreSorted()
{
var vm = TestHelpers.CreateViewModel();
vm.Workspaces = new ObservableCollection<Project>
{
TestHelpers.CreateProject("Z Dev", 0, 0, "VS Code"),
TestHelpers.CreateProject("A Dev", 0, 0, "Terminal"),
TestHelpers.CreateProject("Browsing", 0, 0, "Edge"),
};
vm.OrderByIndex = 2; // Name
vm.SearchTerm = "Dev";
var results = vm.WorkspacesView.ToList();
Assert.AreEqual(2, results.Count);
Assert.AreEqual("A Dev", results[0].Name);
Assert.AreEqual("Z Dev", results[1].Name);
}
}
}

View File

@@ -0,0 +1,38 @@
// 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;
namespace WorkspacesEditor.UnitTests
{
/// <summary>
/// Tests for MainWindow configuration constants and constraints.
/// </summary>
[TestClass]
public class MainWindowConstraintTests
{
[TestMethod]
[TestCategory("Window.Constraints")]
public void MinWindowWidth_IsAtLeast750()
{
Assert.IsTrue(MainWindow.MinWindowWidth >= 750, "Min width must be at least 750 to fit all UI elements.");
}
[TestMethod]
[TestCategory("Window.Constraints")]
public void MinWindowHeight_IsAtLeast680()
{
Assert.IsTrue(MainWindow.MinWindowHeight >= 680, "Min height must be at least 680 to fit all UI elements.");
}
[TestMethod]
[TestCategory("Window.Constraints")]
public void MinWindowDimensions_AreReasonable()
{
// Ensure min size isn't accidentally set too large (e.g., exceeding common displays)
Assert.IsTrue(MainWindow.MinWindowWidth <= 1024, "Min width should not exceed 1024.");
Assert.IsTrue(MainWindow.MinWindowHeight <= 768, "Min height should not exceed 768.");
}
}
}

View File

@@ -0,0 +1,143 @@
// 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.Generic;
using System.Linq;
using Microsoft.VisualStudio.TestTools.UnitTesting;
using WorkspacesEditor.Models;
namespace WorkspacesEditor.UnitTests
{
/// <summary>
/// Tests for Project model validation, computed properties, and state management.
/// </summary>
[TestClass]
public class ProjectModelValidationTests
{
[TestMethod]
[TestCategory("Model.Project")]
public void CanBeSaved_NameAndAppsPresent_ReturnsTrue()
{
var project = TestHelpers.CreateProject("My Workspace", 0, 0, "Notepad");
Assert.IsTrue(project.CanBeSaved);
}
[TestMethod]
[TestCategory("Model.Project")]
public void CanBeSaved_EmptyName_ReturnsFalse()
{
var project = TestHelpers.CreateProject(string.Empty, 0, 0, "Notepad");
Assert.IsFalse(project.CanBeSaved);
}
[TestMethod]
[TestCategory("Model.Project")]
public void CanBeSaved_NoApps_ReturnsFalse()
{
var project = TestHelpers.CreateProject("Test Workspace");
Assert.IsFalse(project.CanBeSaved);
}
[TestMethod]
[TestCategory("Model.Project")]
public void Name_SetValue_RaisesPropertyChanged()
{
var project = TestHelpers.CreateProject("Initial", 0, 0, "App");
var changedProps = new List<string>();
project.PropertyChanged += (s, e) => changedProps.Add(e.PropertyName);
project.Name = "Changed";
Assert.IsTrue(changedProps.Contains("Name"));
Assert.IsTrue(changedProps.Contains("CanBeSaved"));
}
[TestMethod]
[TestCategory("Model.Project")]
public void AppsCountString_SingleApp_ContainsOne()
{
var project = TestHelpers.CreateProject("Test", 0, 0, "App1");
Assert.IsTrue(project.AppsCountString.StartsWith('1'));
}
[TestMethod]
[TestCategory("Model.Project")]
public void AppsCountString_MultipleApps_ContainsCount()
{
var project = TestHelpers.CreateProject("Test", 0, 0, "App1", "App2", "App3");
Assert.IsTrue(project.AppsCountString.StartsWith('3'));
}
[TestMethod]
[TestCategory("Model.Project")]
public void LastLaunched_NeverLaunched_ReturnsNonEmptyString()
{
var project = TestHelpers.CreateProject("Test", 0, 0, "App");
Assert.IsTrue(project.LastLaunched.Length > 0);
}
[TestMethod]
[TestCategory("Model.Project")]
public void IsRevertEnabled_SetTrue_RaisesPropertyChanged()
{
var project = TestHelpers.CreateProject("Test", 0, 0, "App");
string changedProp = null;
project.PropertyChanged += (s, e) => changedProp = e.PropertyName;
project.IsRevertEnabled = true;
Assert.AreEqual("IsRevertEnabled", changedProp);
}
[TestMethod]
[TestCategory("Model.Project")]
public void IsPopupVisible_SetTrue_RaisesPropertyChanged()
{
var project = TestHelpers.CreateProject("Test", 0, 0, "App");
string changedProp = null;
project.PropertyChanged += (s, e) => changedProp = e.PropertyName;
project.IsPopupVisible = true;
Assert.AreEqual("IsPopupVisible", changedProp);
}
[TestMethod]
[TestCategory("Model.Project")]
public void Name_Changed_UpdatesCanBeSaved()
{
var project = TestHelpers.CreateProject("Valid", 0, 0, "App");
Assert.IsTrue(project.CanBeSaved);
project.Name = string.Empty;
Assert.IsFalse(project.CanBeSaved);
project.Name = "Valid Again";
Assert.IsTrue(project.CanBeSaved);
}
[TestMethod]
[TestCategory("Model.Project")]
public void MoveExistingWindows_DefaultFalse_CanBeSet()
{
var project = TestHelpers.CreateProject("Test", 0, 0, "App");
Assert.IsFalse(project.MoveExistingWindows);
project.MoveExistingWindows = true;
Assert.IsTrue(project.MoveExistingWindows);
}
[TestMethod]
[TestCategory("Model.Project")]
public void IsShortcutNeeded_DefaultFalse_CanBeSet()
{
var project = TestHelpers.CreateProject("Test", 0, 0, "App");
Assert.IsFalse(project.IsShortcutNeeded);
project.IsShortcutNeeded = true;
Assert.IsTrue(project.IsShortcutNeeded);
}
}
}

View File

@@ -0,0 +1,60 @@
// Copyright (c) Microsoft Corporation
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
using System;
using System.Collections.Generic;
using System.Linq;
using WorkspacesCsharpLibrary.Data;
using WorkspacesEditor.Models;
using WorkspacesEditor.ViewModels;
namespace WorkspacesEditor.UnitTests
{
/// <summary>
/// Shared helpers for creating test fixtures.
/// Constructs Project and Application objects via the same constructors
/// used in production (ProjectWrapper deserialization path).
/// </summary>
internal static class TestHelpers
{
internal static MainViewModel CreateViewModel()
{
return new MainViewModel(new Utils.WorkspacesEditorIO());
}
internal static Project CreateProject(string name, long creationTime = 0, long lastLaunchedTime = 0, params string[] appNames)
{
var appWrappers = appNames.Select(n => new ApplicationWrapper
{
Application = n,
ApplicationPath = $@"C:\{n}.exe",
Title = string.Empty,
PackageFullName = string.Empty,
AppUserModelId = string.Empty,
PwaAppId = string.Empty,
CommandLineArguments = string.Empty,
IsElevated = false,
CanLaunchElevated = false,
Minimized = false,
Maximized = false,
Position = default,
Monitor = 0,
}).ToList();
var projectWrapper = new ProjectWrapper
{
Id = $"{{{Guid.NewGuid()}}}",
Name = name,
CreationTime = creationTime,
LastLaunchedTime = lastLaunchedTime,
IsShortcutNeeded = false,
MoveExistingWindows = false,
Applications = appWrappers,
MonitorConfiguration = new List<MonitorConfigurationWrapper>(),
};
return new Project(projectWrapper);
}
}
}

View File

@@ -0,0 +1,51 @@
// Copyright (c) Microsoft Corporation
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
using System.Collections.ObjectModel;
using System.Linq;
using Microsoft.VisualStudio.TestTools.UnitTesting;
using WorkspacesEditor.Models;
namespace WorkspacesEditor.UnitTests
{
/// <summary>
/// Smoke test to verify the test infrastructure compiles and Project/Application
/// objects can be created for testing.
/// </summary>
[TestClass]
public class TestInfrastructureTests
{
[TestMethod]
[TestCategory("Infrastructure")]
public void CreateProject_WithApps_ReturnsValidProject()
{
var project = TestHelpers.CreateProject("TestWorkspace", 0, 0, "Notepad", "VS Code");
Assert.IsNotNull(project);
Assert.AreEqual("TestWorkspace", project.Name);
Assert.AreEqual(2, project.Applications.Count);
}
[TestMethod]
[TestCategory("Infrastructure")]
public void CreateProject_ApplicationNames_AreCorrect()
{
var project = TestHelpers.CreateProject("Test", 0, 0, "App1", "App2", "App3");
Assert.AreEqual("App1", project.Applications[0].AppName);
Assert.AreEqual("App2", project.Applications[1].AppName);
Assert.AreEqual("App3", project.Applications[2].AppName);
}
[TestMethod]
[TestCategory("Infrastructure")]
public void CreateProject_NoApps_ReturnsEmptyApplicationsList()
{
var project = TestHelpers.CreateProject("EmptyWorkspace");
Assert.IsNotNull(project.Applications);
Assert.AreEqual(0, project.Applications.Count);
}
}
}

View File

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

View File

@@ -1,19 +1,19 @@
// 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.Globalization;
using System.Windows;
using System.Windows.Data;
using Microsoft.UI.Xaml;
using Microsoft.UI.Xaml.Data;
namespace WorkspacesEditor.Converters
{
public class BooleanToInvertedVisibilityConverter : IValueConverter
public partial class BooleanToInvertedVisibilityConverter : IValueConverter
{
public object Convert(object value, Type targetType, object parameter, CultureInfo culture)
public object Convert(object value, Type targetType, object parameter, string language)
{
if ((bool)value)
if (value is bool boolValue && boolValue)
{
return Visibility.Collapsed;
}
@@ -21,7 +21,7 @@ namespace WorkspacesEditor.Converters
return Visibility.Visible;
}
public object ConvertBack(object value, Type targetType, object parameter, CultureInfo culture)
public object ConvertBack(object value, Type targetType, object parameter, string language)
{
throw new NotImplementedException();
}

View File

@@ -0,0 +1,29 @@
// Copyright (c) Microsoft Corporation
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
using System;
using Microsoft.UI.Xaml;
using Microsoft.UI.Xaml.Data;
namespace WorkspacesEditor.Converters
{
public partial class BooleanToVisibilityConverter : IValueConverter
{
public object Convert(object value, Type targetType, object parameter, string language)
{
if (value is bool boolValue && boolValue)
{
return Visibility.Visible;
}
return Visibility.Collapsed;
}
public object ConvertBack(object value, Type targetType, object parameter, string language)
{
throw new NotImplementedException();
}
}
}

View File

@@ -0,0 +1,27 @@
// 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.UI.Xaml.Data;
using WorkspacesEditor.Helpers;
namespace WorkspacesEditor.Converters
{
/// <summary>
/// Converts a workspace name to a contextual button label like "Launch MyWorkspace".
/// </summary>
public sealed partial class LaunchButtonNameConverter : IValueConverter
{
public object Convert(object value, System.Type targetType, object parameter, string language)
{
string name = value as string ?? string.Empty;
string launchStr = ResourceLoaderInstance.ResourceLoader?.GetString("Launch") ?? "Launch";
return $"{launchStr} {name}";
}
public object ConvertBack(object value, System.Type targetType, object parameter, string language)
{
throw new System.NotImplementedException();
}
}
}

View File

@@ -0,0 +1,27 @@
// 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.UI.Xaml.Data;
using WorkspacesEditor.Helpers;
namespace WorkspacesEditor.Converters
{
/// <summary>
/// Converts a workspace name to a contextual label like "More options for MyWorkspace".
/// </summary>
public sealed partial class MoreOptionsButtonNameConverter : IValueConverter
{
public object Convert(object value, System.Type targetType, object parameter, string language)
{
string name = value as string ?? string.Empty;
string moreOptionsStr = ResourceLoaderInstance.ResourceLoader?.GetString("MoreOptions") ?? "More options";
return $"{moreOptionsStr} {name}";
}
public object ConvertBack(object value, System.Type targetType, object parameter, string language)
{
throw new System.NotImplementedException();
}
}
}

View File

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

View File

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

View File

@@ -0,0 +1,25 @@
// 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.UI.Xaml;
namespace WorkspacesEditor.Helpers
{
internal static class ThemeHelper
{
/// <summary>
/// Returns true if the current app theme is dark.
/// Uses WinUI Application.RequestedTheme which respects system settings.
/// </summary>
internal static bool IsDarkTheme()
{
if (Application.Current?.RequestedTheme == ApplicationTheme.Dark)
{
return true;
}
return false;
}
}
}

View File

@@ -0,0 +1,31 @@
// 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.Text.Json.Serialization;
namespace WorkspacesEditor.Helpers
{
public class WindowStateData
{
[JsonPropertyName("top")]
public double Top { get; set; }
[JsonPropertyName("left")]
public double Left { get; set; }
[JsonPropertyName("width")]
public double Width { get; set; }
[JsonPropertyName("height")]
public double Height { get; set; }
[JsonPropertyName("maximized")]
public bool Maximized { get; set; }
public bool IsValid()
{
return Width > 0 && Height > 0;
}
}
}

View File

@@ -0,0 +1,61 @@
// 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.IO;
using System.Text.Json;
using ManagedCommon;
namespace WorkspacesEditor.Helpers
{
internal static class WindowStateHelper
{
private static readonly string StateFilePath = Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
"Microsoft",
"PowerToys",
"Workspaces",
"editor-window-state.json");
private static readonly JsonSerializerOptions SerializerOptions = new() { WriteIndented = true };
public static WindowStateData Load()
{
try
{
if (File.Exists(StateFilePath))
{
string json = File.ReadAllText(StateFilePath);
return JsonSerializer.Deserialize<WindowStateData>(json);
}
}
catch (Exception ex)
{
Logger.LogError("Failed to load editor window state", ex);
}
return null;
}
public static void Save(WindowStateData state)
{
try
{
string directory = Path.GetDirectoryName(StateFilePath);
if (!Directory.Exists(directory))
{
Directory.CreateDirectory(directory);
}
string json = JsonSerializer.Serialize(state, SerializerOptions);
File.WriteAllText(StateFilePath, json);
}
catch (Exception ex)
{
Logger.LogError("Failed to save editor window state", ex);
}
}
}
}

View File

@@ -0,0 +1,13 @@
// Copyright (c) Microsoft Corporation
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
namespace WorkspacesEditor.Messages
{
/// <summary>
/// Sent to request graceful application shutdown via the WinUI lifecycle.
/// </summary>
public sealed class CloseApplicationMessage
{
}
}

View File

@@ -0,0 +1,13 @@
// Copyright (c) Microsoft Corporation
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
namespace WorkspacesEditor.Messages
{
/// <summary>
/// Sent by ViewModel to request navigation back to the main page.
/// </summary>
public sealed class GoBackMessage
{
}
}

View File

@@ -0,0 +1,13 @@
// Copyright (c) Microsoft Corporation
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
namespace WorkspacesEditor.Messages
{
/// <summary>
/// Sent by ViewModel to request the main window be minimized.
/// </summary>
public sealed class MinimizeWindowMessage
{
}
}

View File

@@ -0,0 +1,21 @@
// 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 WorkspacesEditor.Models;
namespace WorkspacesEditor.Messages
{
/// <summary>
/// Sent by ViewModel to request navigation to the editor page for a project.
/// </summary>
public sealed class NavigateToEditorMessage
{
public Project Project { get; }
public NavigateToEditorMessage(Project project)
{
Project = project;
}
}
}

View File

@@ -0,0 +1,13 @@
// Copyright (c) Microsoft Corporation
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
namespace WorkspacesEditor.Messages
{
/// <summary>
/// Sent by ViewModel to request the main window be restored from minimized state.
/// </summary>
public sealed class RestoreWindowMessage
{
}
}

View File

@@ -0,0 +1,13 @@
// Copyright (c) Microsoft Corporation
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
namespace WorkspacesEditor.Messages
{
/// <summary>
/// Sent by ViewModel to request the View layer show the snapshot window.
/// </summary>
public sealed class ShowSnapshotWindowMessage
{
}
}

View File

@@ -0,0 +1,13 @@
// Copyright (c) Microsoft Corporation
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
namespace WorkspacesEditor.Messages
{
/// <summary>
/// Sent by SnapshotWindow when user cancels (closes without capturing).
/// </summary>
public sealed class SnapshotCancelledMessage
{
}
}

View File

@@ -0,0 +1,13 @@
// Copyright (c) Microsoft Corporation
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
namespace WorkspacesEditor.Messages
{
/// <summary>
/// Sent by SnapshotWindow when user clicks Capture.
/// </summary>
public sealed class SnapshotCapturedMessage
{
}
}

View File

@@ -0,0 +1,26 @@
// 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.UI.Xaml;
using Microsoft.UI.Xaml.Controls;
namespace WorkspacesEditor.Models
{
public sealed partial class AppListDataTemplateSelector : DataTemplateSelector
{
public DataTemplate HeaderTemplate { get; set; }
public DataTemplate AppTemplate { get; set; }
protected override DataTemplate SelectTemplateCore(object item)
{
return item is MonitorHeaderRow ? HeaderTemplate : AppTemplate;
}
protected override DataTemplate SelectTemplateCore(object item, DependencyObject container)
{
return SelectTemplateCore(item);
}
}
}

View File

@@ -3,10 +3,15 @@
// See the LICENSE file in the project root for more information.
using System;
using System.ComponentModel;
using System.Globalization;
using System.Text.Json.Serialization;
using CommunityToolkit.Mvvm.ComponentModel;
using Microsoft.UI.Xaml.Media.Imaging;
using WorkspacesCsharpLibrary.Models;
using WorkspacesEditor.Helpers;
namespace WorkspacesEditor.Models
{
@@ -17,7 +22,7 @@ namespace WorkspacesEditor.Models
Minimized = 2,
}
public class Application : BaseApplication, IDisposable
public partial class Application : BaseApplication, IDisposable
{
private bool _isInitialized;
@@ -90,7 +95,7 @@ namespace WorkspacesEditor.Models
public override readonly int GetHashCode()
{
return base.GetHashCode();
return HashCode.Combine(X, Y, Width, Height);
}
}
@@ -106,18 +111,11 @@ namespace WorkspacesEditor.Models
public string CommandLineArguments { get; set; }
[ObservableProperty]
[NotifyPropertyChangedFor(nameof(AppMainParams))]
[NotifyPropertyChangedFor(nameof(IsAppMainParamVisible))]
private bool _isElevated;
public bool IsElevated
{
get => _isElevated;
set
{
_isElevated = value;
OnPropertyChanged(new PropertyChangedEventArgs(nameof(AppMainParams)));
}
}
public bool CanLaunchElevated { get; set; }
internal void SwitchDeletion()
@@ -130,7 +128,7 @@ namespace WorkspacesEditor.Models
{
if (_isInitialized)
{
Parent.Initialize(App.GetCurrentTheme());
Parent?.InitializePreview();
}
}
@@ -147,35 +145,37 @@ namespace WorkspacesEditor.Models
{
Maximized = value == (int)WindowPositionKind.Maximized;
Minimized = value == (int)WindowPositionKind.Minimized;
OnPropertyChanged(new PropertyChangedEventArgs(nameof(EditPositionEnabled)));
OnPropertyChanged(nameof(EditPositionEnabled));
RedrawPreviewImage();
}
}
private string _appMainParams;
public string AppMainParams
{
get
{
_appMainParams = _isElevated ? Properties.Resources.Admin : string.Empty;
string adminStr = ResourceLoaderInstance.ResourceLoader?.GetString("Admin") ?? "Admin";
string argsStr = ResourceLoaderInstance.ResourceLoader?.GetString("Args") ?? "Args";
string result = IsElevated ? adminStr : string.Empty;
if (!string.IsNullOrWhiteSpace(CommandLineArguments))
{
_appMainParams += (_appMainParams == string.Empty ? string.Empty : " | ") + Properties.Resources.Args + ": " + CommandLineArguments;
result += (result == string.Empty ? string.Empty : " | ") + argsStr + ": " + CommandLineArguments;
}
OnPropertyChanged(new PropertyChangedEventArgs(nameof(IsAppMainParamVisible)));
return _appMainParams;
return result;
}
}
public bool IsAppMainParamVisible => !string.IsNullOrWhiteSpace(_appMainParams);
public bool IsAppMainParamVisible => !string.IsNullOrWhiteSpace(AppMainParams);
[JsonIgnore]
public bool IsHighlighted { get; set; }
[JsonIgnore]
public int RepeatIndex { get; set; }
[ObservableProperty]
[NotifyPropertyChangedFor(nameof(RepeatIndexString))]
[property: JsonIgnore]
private int _repeatIndex;
[JsonIgnore]
public string RepeatIndexString => RepeatIndex <= 1 ? string.Empty : RepeatIndex.ToString(CultureInfo.InvariantCulture);
@@ -231,51 +231,64 @@ namespace WorkspacesEditor.Models
public void InitializationFinished()
{
_isInitialized = true;
LoadIcon();
}
private void LoadIcon()
{
_iconImage = IconHelper.TryGetExecutableIcon(AppPath);
if (_iconImage == null && !string.IsNullOrEmpty(AppPath))
{
IsNotFound = true;
}
}
[ObservableProperty]
private bool _isExpanded;
public bool IsExpanded
public string DeleteButtonContent
{
get => _isExpanded;
set
get
{
if (_isExpanded != value)
{
_isExpanded = value;
OnPropertyChanged(new PropertyChangedEventArgs(nameof(IsExpanded)));
}
string deleteStr = ResourceLoaderInstance.ResourceLoader?.GetString("Delete") ?? "Remove";
string addBackStr = ResourceLoaderInstance.ResourceLoader?.GetString("AddBack") ?? "Add back";
return IsIncluded ? deleteStr : addBackStr;
}
}
public string DeleteButtonContent => _isIncluded ? Properties.Resources.Delete : Properties.Resources.AddBack;
public string DeleteButtonAccessibleName => $"{DeleteButtonContent} {AppName}";
[ObservableProperty]
[NotifyPropertyChangedFor(nameof(DeleteButtonContent))]
[NotifyPropertyChangedFor(nameof(DeleteButtonAccessibleName))]
private bool _isIncluded = true;
public bool IsIncluded
partial void OnIsIncludedChanged(bool value)
{
get => _isIncluded;
set
if (!value)
{
if (_isIncluded != value)
{
_isIncluded = value;
OnPropertyChanged(new PropertyChangedEventArgs(nameof(IsIncluded)));
OnPropertyChanged(new PropertyChangedEventArgs(nameof(DeleteButtonContent)));
if (!_isIncluded)
{
IsExpanded = false;
}
}
IsExpanded = false;
}
}
private BitmapImage _iconImage;
[JsonIgnore]
public BitmapImage IconImage => _iconImage;
internal void CommandLineTextChanged(string newCommandLineValue)
{
CommandLineArguments = newCommandLineValue;
OnPropertyChanged(new PropertyChangedEventArgs(nameof(AppMainParams)));
OnPropertyChanged(nameof(AppMainParams));
OnPropertyChanged(nameof(IsAppMainParamVisible));
}
public string Version { get; set; }
public new void Dispose()
{
base.Dispose();
GC.SuppressFinalize(this);
}
}
}

View File

@@ -1,8 +1,8 @@
// 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.Windows;
using Windows.Foundation;
namespace WorkspacesEditor.Models
{

View File

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

View File

@@ -1,21 +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.ComponentModel;
using System.Windows;
using Windows.Foundation;
namespace WorkspacesEditor.Models
{
public class MonitorSetup : Monitor, INotifyPropertyChanged
public partial class MonitorSetup : Monitor
{
public event PropertyChangedEventHandler PropertyChanged;
public void OnPropertyChanged(PropertyChangedEventArgs e)
{
PropertyChanged?.Invoke(this, e);
}
public string MonitorInfo => MonitorName;
public string MonitorInfoWithResolution => $"{MonitorName} {MonitorDpiAwareBounds.Width}x{MonitorDpiAwareBounds.Height}";

View File

@@ -0,0 +1,337 @@
// Copyright (c) Microsoft Corporation
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
using System;
using System.Collections.Generic;
using System.ComponentModel;
using System.Globalization;
using System.Linq;
using System.Text.Json.Serialization;
using CommunityToolkit.Mvvm.ComponentModel;
using Microsoft.UI.Xaml.Media.Imaging;
using Windows.Foundation;
using WorkspacesCsharpLibrary.Data;
using WorkspacesEditor.Helpers;
namespace WorkspacesEditor.Models
{
public partial class Project : ObservableObject
{
[JsonIgnore]
public string EditorWindowTitle { get; set; }
public string Id { get; private set; }
[ObservableProperty]
[NotifyPropertyChangedFor(nameof(CanBeSaved))]
private string _name;
public long CreationTime { get; }
public long LastLaunchedTime { get; }
public bool IsShortcutNeeded { get; set; }
public bool MoveExistingWindows { get; set; }
public string LastLaunched
{
get
{
string lastLaunched = GetString("LastLaunched") + ": ";
if (LastLaunchedTime == 0)
{
return lastLaunched + GetString("Never");
}
const int Second = 1;
const int Minute = 60 * Second;
const int Hour = 60 * Minute;
const int Day = 24 * Hour;
const int Month = 30 * Day;
DateTime lastLaunchDateTime = new DateTime(1970, 1, 1, 0, 0, 0, 0, DateTimeKind.Utc).AddSeconds(LastLaunchedTime);
TimeSpan ts = DateTime.UtcNow - lastLaunchDateTime;
double delta = Math.Abs(ts.TotalSeconds);
if (delta < 1 * Minute)
{
return lastLaunched + GetString("Recently");
}
if (delta < 2 * Minute)
{
return lastLaunched + GetString("OneMinuteAgo");
}
if (delta < 45 * Minute)
{
return lastLaunched + ts.Minutes + " " + GetString("MinutesAgo");
}
if (delta < 90 * Minute)
{
return lastLaunched + GetString("OneHourAgo");
}
if (delta < 24 * Hour)
{
return lastLaunched + ts.Hours + " " + GetString("HoursAgo");
}
if (delta < 48 * Hour)
{
return lastLaunched + GetString("Yesterday");
}
if (delta < 30 * Day)
{
return lastLaunched + ts.Days + " " + GetString("DaysAgo");
}
if (delta < 12 * Month)
{
int months = Convert.ToInt32(Math.Floor((double)ts.Days / 30));
return lastLaunched + (months <= 1 ? GetString("OneMonthAgo") : months + " " + GetString("MonthsAgo"));
}
else
{
int years = Convert.ToInt32(Math.Floor((double)ts.Days / 365));
return lastLaunched + (years <= 1 ? GetString("OneYearAgo") : years + " " + GetString("YearsAgo"));
}
}
}
public bool CanBeSaved => !string.IsNullOrEmpty(Name) && Applications.Count > 0;
[ObservableProperty]
private bool _isRevertEnabled;
[ObservableProperty]
[property: JsonIgnore]
private bool _isPopupVisible;
public List<Application> Applications { get; set; }
public List<object> ApplicationsListed
{
get
{
List<object> applicationsListed = [];
ILookup<MonitorSetup, Application> apps = Applications.Where(x => !x.Minimized).ToLookup(x => x.MonitorSetup);
foreach (IGrouping<MonitorSetup, Application> appItem in apps.OrderBy(x => x.Key.MonitorDpiUnawareBounds.X).ThenBy(x => x.Key.MonitorDpiUnawareBounds.Y))
{
MonitorHeaderRow headerRow = new() { MonitorName = GetString("Screen") + " " + appItem.Key.MonitorNumber, SelectString = GetString("SelectAllAppsOnMonitor") + " " + appItem.Key.MonitorInfo };
applicationsListed.Add(headerRow);
foreach (Application app in appItem)
{
applicationsListed.Add(app);
}
}
IEnumerable<Application> minimizedApps = Applications.Where(x => x.Minimized);
if (minimizedApps.Any())
{
MonitorHeaderRow headerRow = new() { MonitorName = GetString("Minimized_Apps"), SelectString = GetString("SelectAllMinimizedApps") };
applicationsListed.Add(headerRow);
foreach (Application app in minimizedApps)
{
applicationsListed.Add(app);
}
}
return applicationsListed;
}
}
[JsonIgnore]
public string AppsCountString
{
get
{
int count = Applications.Count;
return count.ToString(CultureInfo.InvariantCulture) + " " + (count == 1 ? GetString("App") : GetString("Apps"));
}
}
/// <summary>
/// Call after modifying the Applications list to notify dependent computed properties.
/// </summary>
public void NotifyApplicationsChanged()
{
OnPropertyChanged(nameof(AppsCountString));
OnPropertyChanged(nameof(CanBeSaved));
OnPropertyChanged(nameof(ApplicationsListed));
}
/// <summary>
/// Call to refresh the relative time display for LastLaunched.
/// </summary>
public void NotifyLastLaunchedChanged()
{
OnPropertyChanged(nameof(LastLaunched));
}
public List<MonitorSetup> Monitors { get; }
public bool IsPositionChangedManually { get; set; }
[ObservableProperty]
[property: JsonIgnore]
private BitmapImage _previewIcons;
[ObservableProperty]
[property: JsonIgnore]
private BitmapImage _previewImage;
[ObservableProperty]
[property: JsonIgnore]
private double _previewImageWidth;
public Project()
{
Applications = [];
Monitors = [];
}
public Project(Project selectedProject)
{
Id = selectedProject.Id;
Name = selectedProject.Name;
PreviewIcons = selectedProject.PreviewIcons;
PreviewImage = selectedProject.PreviewImage;
IsShortcutNeeded = selectedProject.IsShortcutNeeded;
MoveExistingWindows = selectedProject.MoveExistingWindows;
Monitors = [];
foreach (MonitorSetup item in selectedProject.Monitors.OrderBy(x => x.MonitorDpiAwareBounds.X).ThenBy(x => x.MonitorDpiAwareBounds.Y))
{
Monitors.Add(item);
}
Applications = [];
foreach (Application item in selectedProject.Applications)
{
Application newApp = new(item);
newApp.Parent = this;
newApp.InitializationFinished();
Applications.Add(newApp);
}
}
public Project(ProjectWrapper project)
{
Id = project.Id;
Name = project.Name;
CreationTime = project.CreationTime;
LastLaunchedTime = project.LastLaunchedTime;
IsShortcutNeeded = project.IsShortcutNeeded;
MoveExistingWindows = project.MoveExistingWindows;
Monitors = [];
Applications = [];
foreach (ApplicationWrapper app in project.Applications)
{
Application newApp = new()
{
Id = string.IsNullOrEmpty(app.Id) ? $"{{{Guid.NewGuid()}}}" : app.Id,
AppName = app.Application,
AppPath = app.ApplicationPath,
AppTitle = app.Title,
PwaAppId = string.IsNullOrEmpty(app.PwaAppId) ? string.Empty : app.PwaAppId,
Version = string.IsNullOrEmpty(app.Version) ? string.Empty : app.Version,
PackageFullName = app.PackageFullName,
AppUserModelId = app.AppUserModelId,
Parent = this,
CommandLineArguments = app.CommandLineArguments,
IsElevated = app.IsElevated,
CanLaunchElevated = app.CanLaunchElevated,
Maximized = app.Maximized,
Minimized = app.Minimized,
IsNotFound = false,
Position = new Application.WindowPosition()
{
Height = app.Position.Height,
Width = app.Position.Width,
X = app.Position.X,
Y = app.Position.Y,
},
MonitorNumber = app.Monitor,
};
newApp.InitializationFinished();
Applications.Add(newApp);
}
foreach (MonitorConfigurationWrapper monitor in project.MonitorConfiguration)
{
Rect dpiAware = new(monitor.MonitorRectDpiAware.Left, monitor.MonitorRectDpiAware.Top, monitor.MonitorRectDpiAware.Width, monitor.MonitorRectDpiAware.Height);
Rect dpiUnaware = new(monitor.MonitorRectDpiUnaware.Left, monitor.MonitorRectDpiUnaware.Top, monitor.MonitorRectDpiUnaware.Width, monitor.MonitorRectDpiUnaware.Height);
Monitors.Add(new MonitorSetup(monitor.Id, monitor.InstanceId, monitor.MonitorNumber, monitor.Dpi, dpiAware, dpiUnaware));
}
}
public void InitializePreview()
{
try
{
if (Applications == null || Applications.Count == 0 || Monitors == null || Monitors.Count == 0)
{
return;
}
// Compute bounding rect across all monitors
double left = Monitors.Min(m => m.MonitorDpiAwareBounds.X);
double top = Monitors.Min(m => m.MonitorDpiAwareBounds.Y);
double right = Monitors.Max(m => m.MonitorDpiAwareBounds.X + m.MonitorDpiAwareBounds.Width);
double bottom = Monitors.Max(m => m.MonitorDpiAwareBounds.Y + m.MonitorDpiAwareBounds.Height);
var bounds = new System.Drawing.Rectangle((int)left, (int)top, (int)(right - left), (int)(bottom - top));
bool isDarkTheme = Helpers.ThemeHelper.IsDarkTheme();
PreviewImage = Utils.DrawHelper.DrawPreview(this, bounds, isDarkTheme);
PreviewImageWidth = bounds.Width * 0.1;
PreviewIcons = Utils.DrawHelper.DrawPreviewIcons(this);
}
catch (System.Exception ex)
{
ManagedCommon.Logger.LogError("Preview render failed", ex);
}
}
public MonitorSetup GetMonitorForApp(Application app)
{
if (Monitors == null || Monitors.Count == 0)
{
return new MonitorSetup("Unknown", string.Empty, app.MonitorNumber, 96, default, default);
}
return Monitors.FirstOrDefault(m => m.MonitorNumber == app.MonitorNumber)
?? Monitors[0];
}
public void CloseExpanders()
{
foreach (Application app in Applications)
{
app.IsExpanded = false;
}
}
public void UpdateAfterLaunchAndEdit(Project projectBefore)
{
Id = projectBefore.Id;
IsRevertEnabled = true;
}
private static string GetString(string key)
{
return ResourceLoaderInstance.ResourceLoader?.GetString(key) ?? key;
}
}
}

View File

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

View File

@@ -1,121 +1,16 @@
<?xml version="1.0" encoding="utf-8"?>
<root>
<!--
Microsoft ResX Schema
Version 2.0
The primary goals of this format is to allow a simple XML format
that is mostly human readable. The generation and parsing of the
various data types are done through the TypeConverter classes
associated with the data types.
Example:
... ado.net/XML headers & schema ...
<resheader name="resmimetype">text/microsoft-resx</resheader>
<resheader name="version">2.0</resheader>
<resheader name="reader">System.Resources.ResXResourceReader, System.Windows.Forms, ...</resheader>
<resheader name="writer">System.Resources.ResXResourceWriter, System.Windows.Forms, ...</resheader>
<data name="Name1"><value>this is my long string</value><comment>this is a comment</comment></data>
<data name="Color1" type="System.Drawing.Color, System.Drawing">Blue</data>
<data name="Bitmap1" mimetype="application/x-microsoft.net.object.binary.base64">
<value>[base64 mime encoded serialized .NET Framework object]</value>
</data>
<data name="Icon1" type="System.Drawing.Icon, System.Drawing" mimetype="application/x-microsoft.net.object.bytearray.base64">
<value>[base64 mime encoded string representing a byte array form of the .NET Framework object]</value>
<comment>This is a comment</comment>
</data>
There are any number of "resheader" rows that contain simple
name/value pairs.
Each data row contains a name, and value. The row also contains a
type or mimetype. Type corresponds to a .NET class that support
text/value conversion through the TypeConverter architecture.
Classes that don't support this are serialized and stored with the
mimetype set.
The mimetype is used for serialized objects, and tells the
ResXResourceReader how to depersist the object. This is currently not
extensible. For a given mimetype the value must be set accordingly:
Note - application/x-microsoft.net.object.binary.base64 is the format
that the ResXResourceWriter will generate, however the reader can
read any of the formats listed below.
mimetype: application/x-microsoft.net.object.binary.base64
value : The object must be serialized with
: System.Runtime.Serialization.Formatters.Binary.BinaryFormatter
: and then encoded with base64 encoding.
mimetype: application/x-microsoft.net.object.soap.base64
value : The object must be serialized with
: System.Runtime.Serialization.Formatters.Soap.SoapFormatter
: and then encoded with base64 encoding.
mimetype: application/x-microsoft.net.object.bytearray.base64
value : The object must be serialized into a byte array
: using a System.ComponentModel.TypeConverter
: and then encoded with base64 encoding.
-->
<xsd:schema id="root" xmlns="" xmlns:xsd="http://www.w3.org/2001/XMLSchema" xmlns:msdata="urn:schemas-microsoft-com:xml-msdata">
<xsd:import namespace="http://www.w3.org/XML/1998/namespace" />
<xsd:element name="root" msdata:IsDataSet="true">
<xsd:complexType>
<xsd:choice maxOccurs="unbounded">
<xsd:element name="metadata">
<xsd:complexType>
<xsd:sequence>
<xsd:element name="value" type="xsd:string" minOccurs="0" />
</xsd:sequence>
<xsd:attribute name="name" use="required" type="xsd:string" />
<xsd:attribute name="type" type="xsd:string" />
<xsd:attribute name="mimetype" type="xsd:string" />
<xsd:attribute ref="xml:space" />
</xsd:complexType>
</xsd:element>
<xsd:element name="assembly">
<xsd:complexType>
<xsd:attribute name="alias" type="xsd:string" />
<xsd:attribute name="name" type="xsd:string" />
</xsd:complexType>
</xsd:element>
<xsd:element name="data">
<xsd:complexType>
<xsd:sequence>
<xsd:element name="value" type="xsd:string" minOccurs="0" msdata:Ordinal="1" />
<xsd:element name="comment" type="xsd:string" minOccurs="0" msdata:Ordinal="2" />
</xsd:sequence>
<xsd:attribute name="name" type="xsd:string" use="required" msdata:Ordinal="1" />
<xsd:attribute name="type" type="xsd:string" msdata:Ordinal="3" />
<xsd:attribute name="mimetype" type="xsd:string" msdata:Ordinal="4" />
<xsd:attribute ref="xml:space" />
</xsd:complexType>
</xsd:element>
<xsd:element name="resheader">
<xsd:complexType>
<xsd:sequence>
<xsd:element name="value" type="xsd:string" minOccurs="0" msdata:Ordinal="1" />
</xsd:sequence>
<xsd:attribute name="name" type="xsd:string" use="required" />
</xsd:complexType>
</xsd:element>
</xsd:choice>
</xsd:complexType>
</xsd:element>
</xsd:schema>
<resheader name="resmimetype">
<value>text/microsoft-resx</value>
</resheader>
<resheader name="version">
<value>2.0</value>
<value>1.3</value>
</resheader>
<resheader name="reader">
<value>System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089</value>
<value>System.Resources.ResXResourceReader, System.Windows.Forms, Version=2.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089</value>
</resheader>
<resheader name="writer">
<value>System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089</value>
<value>System.Resources.ResXResourceWriter, System.Windows.Forms, Version=2.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089</value>
</resheader>
<data name="AddBack" xml:space="preserve">
<value>Add back</value>
@@ -140,7 +35,6 @@
</data>
<data name="Args" xml:space="preserve">
<value>Args</value>
<comment>Arguments</comment>
</data>
<data name="Cancel" xml:space="preserve">
<value>Cancel</value>
@@ -178,6 +72,9 @@
<data name="Edit" xml:space="preserve">
<value>Edit</value>
</data>
<data name="EditNameTextBox.AutomationProperties.Name" xml:space="preserve">
<value>Workspace name</value>
</data>
<data name="EditWorkspace" xml:space="preserve">
<value>Edit Workspace</value>
</data>
@@ -210,7 +107,6 @@
</data>
<data name="Left" xml:space="preserve">
<value>Left</value>
<comment>the left x coordinate</comment>
</data>
<data name="MainTitle" xml:space="preserve">
<value>Workspaces Editor</value>
@@ -230,6 +126,9 @@
<data name="MonthsAgo" xml:space="preserve">
<value>months ago</value>
</data>
<data name="MoreOptions" xml:space="preserve">
<value>More options for</value>
</data>
<data name="MoveIfExist" xml:space="preserve">
<value>Move existing windows</value>
</data>
@@ -290,6 +189,9 @@
<data name="SearchExplanation" xml:space="preserve">
<value>Search for Workspaces or apps</value>
</data>
<data name="SearchTextBox.AutomationProperties.Name" xml:space="preserve">
<value>Search workspaces</value>
</data>
<data name="SecondsAgo" xml:space="preserve">
<value>seconds ago</value>
</data>
@@ -316,7 +218,6 @@
</data>
<data name="Top" xml:space="preserve">
<value>Top</value>
<comment>the top y coordinate</comment>
</data>
<data name="Width" xml:space="preserve">
<value>Width</value>
@@ -333,4 +234,100 @@
<data name="Yesterday" xml:space="preserve">
<value>yesterday</value>
</data>
</root>
<data name="DismissButton.Content" xml:space="preserve">
<value>Dismiss</value>
</data>
<data name="LaunchButton.Content" xml:space="preserve">
<value>Launch</value>
</data>
<data name="LastLaunchedItem.Content" xml:space="preserve">
<value>Last launched</value>
</data>
<data name="CreatedItem.Content" xml:space="preserve">
<value>Created</value>
</data>
<data name="NameItem.Content" xml:space="preserve">
<value>Name</value>
</data>
<data name="CustomItem.Content" xml:space="preserve">
<value>Custom</value>
</data>
<data name="MaximizedItem.Content" xml:space="preserve">
<value>Maximized</value>
</data>
<data name="MinimizedItem.Content" xml:space="preserve">
<value>Minimized</value>
</data>
<data name="LaunchAsAdminLabel.Text" xml:space="preserve">
<value>Launch as Admin</value>
</data>
<data name="CliArgumentsLabel.Text" xml:space="preserve">
<value>CLI arguments</value>
</data>
<data name="WindowPositionLabel.Text" xml:space="preserve">
<value>Window position</value>
</data>
<data name="LaunchBtn.Content" xml:space="preserve">
<value>Launch</value>
</data>
<data name="EditFlyoutItem.Text" xml:space="preserve">
<value>Edit</value>
</data>
<data name="RemoveFlyoutItem.Text" xml:space="preserve">
<value>Remove</value>
</data>
<data name="LeftLabel.Text" xml:space="preserve">
<value>Left</value>
</data>
<data name="TopLabel.Text" xml:space="preserve">
<value>Top</value>
</data>
<data name="WidthLabel.Text" xml:space="preserve">
<value>Width</value>
</data>
<data name="HeightLabel.Text" xml:space="preserve">
<value>Height</value>
</data>
<data name="CapturingLabel.Text" xml:space="preserve">
<value>CAPTURING</value>
</data>
<data name="CapturedAppList" xml:space="preserve">
<value>Captured Application List</value>
</data>
<data name="Screen" xml:space="preserve">
<value>Screen</value>
</data>
<data name="CreateWorkspaceBtn.[using:Microsoft.UI.Xaml.Automation]AutomationProperties.Name" xml:space="preserve">
<value>Create Workspace</value>
</data>
<data name="SortByComboBox.[using:Microsoft.UI.Xaml.Automation]AutomationProperties.Name" xml:space="preserve">
<value>Sort by</value>
</data>
<data name="MoreOptionsBtn.[using:Microsoft.UI.Xaml.Automation]AutomationProperties.Name" xml:space="preserve">
<value>More options</value>
</data>
<data name="CliArgsTextBox.[using:Microsoft.UI.Xaml.Automation]AutomationProperties.Name" xml:space="preserve">
<value>CLI arguments</value>
</data>
<data name="WindowPositionComboBox.[using:Microsoft.UI.Xaml.Automation]AutomationProperties.Name" xml:space="preserve">
<value>Window position</value>
</data>
<data name="LeftTextBox.[using:Microsoft.UI.Xaml.Automation]AutomationProperties.Name" xml:space="preserve">
<value>Left</value>
</data>
<data name="TopTextBox.[using:Microsoft.UI.Xaml.Automation]AutomationProperties.Name" xml:space="preserve">
<value>Top</value>
</data>
<data name="WidthTextBox.[using:Microsoft.UI.Xaml.Automation]AutomationProperties.Name" xml:space="preserve">
<value>Width</value>
</data>
<data name="HeightTextBox.[using:Microsoft.UI.Xaml.Automation]AutomationProperties.Name" xml:space="preserve">
<value>Height</value>
</data>
<data name="SaveBtn.[using:Microsoft.UI.Xaml.Automation]AutomationProperties.Name" xml:space="preserve">
<value>Save</value>
</data>
<data name="CapturedAppListControl.[using:Microsoft.UI.Xaml.Automation]AutomationProperties.Name" xml:space="preserve">
<value>Captured Application List</value>
</data>
</root>

View File

@@ -1,4 +1,4 @@
// Copyright (c) Microsoft Corporation
// Copyright (c) Microsoft Corporation
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
@@ -18,22 +18,16 @@ namespace WorkspacesEditor.Telemetry
EventName = "Workspaces_CreateEvent";
}
// True if operation successfully completely. False if failed
public bool Successful { get; set; }
// Number of screens present in the project
public int NumScreens { get; set; }
// Total number of apps in the project
public int AppCount { get; set; }
// Number of apps with CLI args
public int CliCount { get; set; }
// Number of apps with "Launch as admin" set
public int AdminCount { get; set; }
// True if user checked "Create Shortcut". False if not.
public bool ShortcutCreated { get; set; }
public PartA_PrivTags PartA_PrivTags => PartA_PrivTags.ProductAndServiceUsage;

View File

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

View File

@@ -1,4 +1,4 @@
// Copyright (c) Microsoft Corporation
// Copyright (c) Microsoft Corporation
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
@@ -18,31 +18,22 @@ namespace WorkspacesEditor.Telemetry
EventName = "Workspaces_EditEvent";
}
// True if operation successfully completely. False if failed.
public bool Successful { get; set; }
// Change in number of screens in project
public int ScreenCountDelta { get; set; }
// Number of apps added to project through editing
public int AppsAdded { get; set; }
// Number of apps removed from project through editing
public int AppsRemoved { get; set; }
// Number of apps with CLI args added
public int CliAdded { get; set; }
// Number of apps with CLI args removed
public int CliRemoved { get; set; }
// Number of apps with admin added
public int AdminAdded { get; set; }
// Number of apps with admin removed
public int AdminRemoved { get; set; }
// True if used window pixel sizing boxes to adjust size
public bool PixelAdjustmentsUsed { get; set; }
public PartA_PrivTags PartA_PrivTags => PartA_PrivTags.ProductAndServiceUsage;

View File

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

View File

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

View File

@@ -1,4 +1,4 @@
// Copyright (c) Microsoft Corporation
// Copyright (c) Microsoft Corporation
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
@@ -7,25 +7,24 @@ using System.Collections.Generic;
using System.ComponentModel;
using System.Drawing;
using System.Drawing.Drawing2D;
using System.Drawing.Imaging;
using System.Globalization;
using System.IO;
using System.Linq;
using System.Windows.Media.Imaging;
using ManagedCommon;
using Microsoft.UI.Xaml.Media.Imaging;
using WorkspacesEditor.Models;
namespace WorkspacesEditor.Utils
{
public class DrawHelper
internal static class DrawHelper
{
private static readonly Font Font = new("Tahoma", 24);
private static readonly Font DrawFont = new("Tahoma", 24);
private static readonly double Scale = 0.1;
private static double gapWidth;
private static double gapHeight;
public static BitmapImage DrawPreview(Project project, Rectangle bounds, Theme currentTheme)
public static BitmapImage DrawPreview(Project project, Rectangle bounds, bool isDarkTheme)
{
List<double> horizontalGaps = [];
List<double> verticalGaps = [];
@@ -53,15 +52,13 @@ namespace WorkspacesEditor.Utils
{
if (app.Maximized)
{
Project project = app.Parent;
MonitorSetup monitor = project.GetMonitorForApp(app);
if (monitor == null)
{
// unrealistic case, there are no monitors at all in the workspace, use original rect
return new Rectangle(TransformX(app.ScaledPosition.X), TransformY(app.ScaledPosition.Y), Scaled(app.ScaledPosition.Width), Scaled(app.ScaledPosition.Height));
}
return new Rectangle(TransformX(monitor.MonitorDpiAwareBounds.Left), TransformY(monitor.MonitorDpiAwareBounds.Top), Scaled(monitor.MonitorDpiAwareBounds.Width), Scaled(monitor.MonitorDpiAwareBounds.Height));
return new Rectangle(TransformX(monitor.MonitorDpiAwareBounds.X), TransformY(monitor.MonitorDpiAwareBounds.Y), Scaled(monitor.MonitorDpiAwareBounds.Width), Scaled(monitor.MonitorDpiAwareBounds.Height));
}
else
{
@@ -93,24 +90,16 @@ namespace WorkspacesEditor.Utils
app.RepeatIndex = 0;
}
// now that all repeat index values are set, update the repeat index strings on UI
foreach (Application app in project.Applications)
{
app.OnPropertyChanged(new PropertyChangedEventArgs("RepeatIndexString"));
}
foreach (MonitorSetup monitor in project.Monitors)
{
// check for vertical gap
if (monitor.MonitorDpiAwareBounds.Left > bounds.Left && project.Monitors.Any(x => x.MonitorDpiAwareBounds.Right <= monitor.MonitorDpiAwareBounds.Left))
if (monitor.MonitorDpiAwareBounds.X > bounds.Left && project.Monitors.Any(x => (x.MonitorDpiAwareBounds.X + x.MonitorDpiAwareBounds.Width) <= monitor.MonitorDpiAwareBounds.X))
{
verticalGaps.Add(monitor.MonitorDpiAwareBounds.Left);
verticalGaps.Add(monitor.MonitorDpiAwareBounds.X);
}
// check for horizontal gap
if (monitor.MonitorDpiAwareBounds.Top > bounds.Top && project.Monitors.Any(x => x.MonitorDpiAwareBounds.Bottom <= monitor.MonitorDpiAwareBounds.Top))
if (monitor.MonitorDpiAwareBounds.Y > bounds.Top && project.Monitors.Any(x => (x.MonitorDpiAwareBounds.Y + x.MonitorDpiAwareBounds.Height) <= monitor.MonitorDpiAwareBounds.Y))
{
horizontalGaps.Add(monitor.MonitorDpiAwareBounds.Top);
horizontalGaps.Add(monitor.MonitorDpiAwareBounds.Y);
}
}
@@ -122,163 +111,34 @@ namespace WorkspacesEditor.Utils
g.InterpolationMode = InterpolationMode.HighQualityBicubic;
g.PixelOffsetMode = PixelOffsetMode.HighQuality;
Brush brush = new SolidBrush(currentTheme == Theme.Dark ? Color.FromArgb(10, 255, 255, 255) : Color.FromArgb(10, 0, 0, 0));
Brush brushForHighlight = new SolidBrush(currentTheme == Theme.Dark ? Color.FromArgb(192, 255, 255, 255) : Color.FromArgb(192, 0, 0, 0));
Brush brush = new SolidBrush(isDarkTheme ? Color.FromArgb(10, 255, 255, 255) : Color.FromArgb(10, 0, 0, 0));
Brush brushForHighlight = new SolidBrush(isDarkTheme ? Color.FromArgb(192, 255, 255, 255) : Color.FromArgb(192, 0, 0, 0));
// draw the monitors
foreach (MonitorSetup monitor in project.Monitors)
{
Brush monitorBrush = new SolidBrush(currentTheme == Theme.Dark ? Color.FromArgb(32, 7, 91, 155) : Color.FromArgb(32, 7, 91, 155));
g.FillRectangle(monitorBrush, new Rectangle(TransformX(monitor.MonitorDpiAwareBounds.Left), TransformY(monitor.MonitorDpiAwareBounds.Top), Scaled(monitor.MonitorDpiAwareBounds.Width), Scaled(monitor.MonitorDpiAwareBounds.Height)));
Brush monitorBrush = new SolidBrush(Color.FromArgb(32, 7, 91, 155));
g.FillRectangle(monitorBrush, new Rectangle(TransformX(monitor.MonitorDpiAwareBounds.X), TransformY(monitor.MonitorDpiAwareBounds.Y), Scaled(monitor.MonitorDpiAwareBounds.Width), Scaled(monitor.MonitorDpiAwareBounds.Height)));
}
IEnumerable<Application> appsToDraw = appsIncluded.Where(x => !x.Minimized);
// draw the highlighted app at the end to have its icon in the foreground for the case there are overlapping icons
foreach (Application app in appsToDraw.Where(x => !x.IsHighlighted))
{
Rectangle rect = GetAppRect(app);
DrawWindow(g, brush, rect, app, desiredIconSize, currentTheme);
DrawWindow(g, brush, rect, app, desiredIconSize, isDarkTheme);
}
foreach (Application app in appsToDraw.Where(x => x.IsHighlighted))
{
Rectangle rect = GetAppRect(app);
DrawWindow(g, brushForHighlight, rect, app, desiredIconSize, currentTheme);
DrawWindow(g, brushForHighlight, rect, app, desiredIconSize, isDarkTheme);
}
// draw the minimized windows
Rectangle rectMinimized = new(0, Scaled((bounds.Height * 1.02) + (horizontalGaps.Count * gapHeight)), Scaled(bounds.Width + (verticalGaps.Count * gapWidth)), Scaled(bounds.Height * 0.18));
DrawWindow(g, brush, brushForHighlight, rectMinimized, appsIncluded.Where(x => x.Minimized), currentTheme);
DrawWindow(g, brush, brushForHighlight, rectMinimized, appsIncluded.Where(x => x.Minimized), isDarkTheme);
}
using MemoryStream memory = new();
WorkspacesCsharpLibrary.DrawHelper.SaveBitmap(previewBitmap, memory);
memory.Position = 0;
BitmapImage bitmapImage = new();
bitmapImage.BeginInit();
bitmapImage.StreamSource = memory;
bitmapImage.CacheOption = BitmapCacheOption.OnLoad;
bitmapImage.EndInit();
bitmapImage.Freeze();
return bitmapImage;
}
public static void DrawWindow(Graphics graphics, Brush brush, Rectangle bounds, Application app, double desiredIconSize, Theme currentTheme)
{
if (graphics == null)
{
return;
}
if (brush == null)
{
return;
}
using (GraphicsPath path = RoundedRect(bounds))
{
if (app.IsHighlighted)
{
graphics.DrawPath(new Pen(currentTheme == Theme.Dark ? Color.White : Color.DarkGray, graphics.VisibleClipBounds.Height / 50), path);
}
else
{
graphics.DrawPath(new Pen(currentTheme == Theme.Dark ? Color.FromArgb(128, 82, 82, 82) : Color.FromArgb(128, 160, 160, 160), graphics.VisibleClipBounds.Height / 200), path);
}
graphics.FillPath(brush, path);
}
double iconSize = Math.Min(Math.Min(bounds.Width - 4, bounds.Height - 4), desiredIconSize);
Rectangle iconBounds = new((int)(bounds.Left + (bounds.Width / 2) - (iconSize / 2)), (int)(bounds.Top + (bounds.Height / 2) - (iconSize / 2)), (int)iconSize, (int)iconSize);
try
{
graphics.DrawIcon(app.Icon, iconBounds);
if (app.RepeatIndex > 1)
{
string indexString = app.RepeatIndex.ToString(CultureInfo.InvariantCulture);
int indexSize = (int)(iconBounds.Width * 0.5);
Rectangle indexBounds = new(iconBounds.Right - indexSize, iconBounds.Bottom - indexSize, indexSize, indexSize);
SizeF textSize = graphics.MeasureString(indexString, Font);
GraphicsState state = graphics.Save();
graphics.TranslateTransform(indexBounds.Left, indexBounds.Top);
graphics.ScaleTransform(indexBounds.Width / textSize.Width, indexBounds.Height / textSize.Height);
graphics.DrawString(indexString, Font, Brushes.Black, PointF.Empty);
graphics.Restore(state);
}
}
catch (Exception)
{
// sometimes drawing an icon throws an exception despite that the icon seems to be ok
}
}
public static void DrawWindow(Graphics graphics, Brush brush, Brush brushForHighlight, Rectangle bounds, IEnumerable<Application> apps, Theme currentTheme)
{
int appsCount = apps.Count();
if (appsCount == 0)
{
return;
}
if (graphics == null)
{
return;
}
if (brush == null)
{
return;
}
using (GraphicsPath path = RoundedRect(bounds))
{
if (apps.Where(x => x.IsHighlighted).Any())
{
graphics.DrawPath(new Pen(currentTheme == Theme.Dark ? Color.White : Color.DarkGray, graphics.VisibleClipBounds.Height / 50), path);
graphics.FillPath(brushForHighlight, path);
}
else
{
graphics.DrawPath(new Pen(currentTheme == Theme.Dark ? Color.FromArgb(128, 82, 82, 82) : Color.FromArgb(128, 160, 160, 160), graphics.VisibleClipBounds.Height / 200), path);
graphics.FillPath(brush, path);
}
}
double iconSize = Math.Min(bounds.Width, bounds.Height) * 0.5;
for (int iconCounter = 0; iconCounter < appsCount; iconCounter++)
{
Application app = apps.ElementAt(iconCounter);
Rectangle iconBounds = new((int)(bounds.Left + (bounds.Width / 2) - (iconSize * ((appsCount / 2) - iconCounter))), (int)(bounds.Top + (bounds.Height / 2) - (iconSize / 2)), (int)iconSize, (int)iconSize);
try
{
graphics.DrawIcon(app.Icon, iconBounds);
if (app.RepeatIndex > 0)
{
string indexString = app.RepeatIndex.ToString(CultureInfo.InvariantCulture);
int indexSize = (int)(iconBounds.Width * 0.5);
Rectangle indexBounds = new(iconBounds.Right - indexSize, iconBounds.Bottom - indexSize, indexSize, indexSize);
SizeF textSize = graphics.MeasureString(indexString, Font);
GraphicsState state = graphics.Save();
graphics.TranslateTransform(indexBounds.Left, indexBounds.Top);
graphics.ScaleTransform(indexBounds.Width / textSize.Width, indexBounds.Height / textSize.Height);
graphics.DrawString(indexString, Font, Brushes.Black, PointF.Empty);
graphics.Restore(state);
}
}
catch (Exception)
{
// sometimes drawing an icon throws an exception despite that the icon seems to be ok
}
}
return BitmapToWinUiImage(previewBitmap);
}
public static BitmapImage DrawPreviewIcons(Project project)
@@ -300,37 +160,134 @@ namespace WorkspacesEditor.Utils
{
try
{
graphics.DrawIcon(app.Icon, new Rectangle(32 * appIndex, 0, 24, 24));
graphics.DrawIcon(app.Icon, new Rectangle(appIndex * 32, 0, 24, 24));
}
catch (Exception e)
catch (Exception ex)
{
Logger.LogError($"Exception while drawing the icon for app {app.AppName}. Exception message: {e.Message}");
ManagedCommon.Logger.LogError($"Failed to draw preview icon for {app.AppName}", ex);
}
appIndex++;
}
}
return BitmapToWinUiImage(previewBitmap);
}
private static void DrawWindow(Graphics graphics, Brush brush, Rectangle bounds, Application app, double desiredIconSize, bool isDarkTheme)
{
if (graphics == null || brush == null)
{
return;
}
using (GraphicsPath path = RoundedRect(bounds))
{
if (app.IsHighlighted)
{
graphics.DrawPath(new Pen(isDarkTheme ? Color.White : Color.DarkGray, graphics.VisibleClipBounds.Height / 50), path);
}
else
{
graphics.DrawPath(new Pen(isDarkTheme ? Color.FromArgb(128, 82, 82, 82) : Color.FromArgb(128, 160, 160, 160), graphics.VisibleClipBounds.Height / 200), path);
}
graphics.FillPath(brush, path);
}
double iconSize = Math.Min(Math.Min(bounds.Width - 4, bounds.Height - 4), desiredIconSize);
Rectangle iconBounds = new((int)(bounds.Left + (bounds.Width / 2) - (iconSize / 2)), (int)(bounds.Top + (bounds.Height / 2) - (iconSize / 2)), (int)iconSize, (int)iconSize);
try
{
graphics.DrawIcon(app.Icon, iconBounds);
if (app.RepeatIndex > 1)
{
string indexString = app.RepeatIndex.ToString(CultureInfo.InvariantCulture);
int indexSize = (int)(iconBounds.Width * 0.5);
Rectangle indexBounds = new(iconBounds.Right - indexSize, iconBounds.Bottom - indexSize, indexSize, indexSize);
SizeF textSize = graphics.MeasureString(indexString, DrawFont);
GraphicsState state = graphics.Save();
graphics.TranslateTransform(indexBounds.Left, indexBounds.Top);
graphics.ScaleTransform(indexBounds.Width / textSize.Width, indexBounds.Height / textSize.Height);
graphics.DrawString(indexString, DrawFont, Brushes.Black, PointF.Empty);
graphics.Restore(state);
}
}
catch (Exception ex)
{
ManagedCommon.Logger.LogError($"Failed to draw window for {app.AppName}", ex);
}
}
private static void DrawWindow(Graphics graphics, Brush brush, Brush brushForHighlight, Rectangle bounds, IEnumerable<Application> apps, bool isDarkTheme)
{
int appsCount = apps.Count();
if (appsCount == 0 || graphics == null || brush == null)
{
return;
}
using (GraphicsPath path = RoundedRect(bounds))
{
if (apps.Any(x => x.IsHighlighted))
{
graphics.DrawPath(new Pen(isDarkTheme ? Color.White : Color.DarkGray, graphics.VisibleClipBounds.Height / 50), path);
graphics.FillPath(brushForHighlight, path);
}
else
{
graphics.DrawPath(new Pen(isDarkTheme ? Color.FromArgb(128, 82, 82, 82) : Color.FromArgb(128, 160, 160, 160), graphics.VisibleClipBounds.Height / 200), path);
graphics.FillPath(brush, path);
}
}
double iconSize = Math.Min(bounds.Width, bounds.Height) * 0.5;
for (int iconCounter = 0; iconCounter < appsCount; iconCounter++)
{
Application app = apps.ElementAt(iconCounter);
Rectangle iconBounds = new((int)(bounds.Left + (bounds.Width / 2) - (iconSize * ((appsCount / 2.0) - iconCounter))), (int)(bounds.Top + (bounds.Height / 2) - (iconSize / 2)), (int)iconSize, (int)iconSize);
try
{
graphics.DrawIcon(app.Icon, iconBounds);
if (app.RepeatIndex > 0)
{
string indexString = app.RepeatIndex.ToString(CultureInfo.InvariantCulture);
int indexSize = (int)(iconBounds.Width * 0.5);
Rectangle indexBounds = new(iconBounds.Right - indexSize, iconBounds.Bottom - indexSize, indexSize, indexSize);
SizeF textSize = graphics.MeasureString(indexString, DrawFont);
GraphicsState state = graphics.Save();
graphics.TranslateTransform(indexBounds.Left, indexBounds.Top);
graphics.ScaleTransform(indexBounds.Width / textSize.Width, indexBounds.Height / textSize.Height);
graphics.DrawString(indexString, DrawFont, Brushes.Black, PointF.Empty);
graphics.Restore(state);
}
}
catch (Exception ex)
{
ManagedCommon.Logger.LogError($"Failed to draw minimized app icon", ex);
}
}
}
private static BitmapImage BitmapToWinUiImage(Bitmap bitmap)
{
using MemoryStream memory = new();
WorkspacesCsharpLibrary.DrawHelper.SaveBitmap(previewBitmap, memory);
WorkspacesCsharpLibrary.DrawHelper.SaveBitmap(bitmap, memory);
memory.Position = 0;
BitmapImage bitmapImage = new();
bitmapImage.BeginInit();
bitmapImage.StreamSource = memory;
bitmapImage.CacheOption = BitmapCacheOption.OnLoad;
bitmapImage.EndInit();
bitmapImage.Freeze();
bitmapImage.SetSource(memory.AsRandomAccessStream());
return bitmapImage;
}
private static GraphicsPath RoundedRect(Rectangle bounds)
{
int minorSize = Math.Min(bounds.Width, bounds.Height);
int radius = (int)(minorSize / 8);
int radius = minorSize / 8;
int diameter = radius * 2;
Size size = new(diameter, diameter);
@@ -343,21 +300,13 @@ namespace WorkspacesEditor.Utils
return path;
}
// top left arc
path.AddArc(arc, 180, 90);
// top right arc
arc.X = bounds.Right - diameter;
path.AddArc(arc, 270, 90);
// bottom right arc
arc.Y = bounds.Bottom - diameter;
path.AddArc(arc, 0, 90);
// bottom left arc
arc.X = bounds.Left;
path.AddArc(arc, 90, 90);
path.CloseFigure();
return path;
}

View File

@@ -0,0 +1,19 @@
// Copyright (c) Microsoft Corporation
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
namespace WorkspacesEditor.Utils
{
public class ParsingResult
{
public bool Result { get; set; }
public string Message { get; set; }
public ParsingResult(bool result, string message = "")
{
Result = result;
Message = message;
}
}
}

View File

@@ -1,4 +1,4 @@
// Copyright (c) Microsoft Corporation
// Copyright (c) Microsoft Corporation
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
@@ -9,18 +9,18 @@ namespace WorkspacesEditor.Utils
public class Settings
{
private const string WorkspacesModuleName = "Workspaces";
private static readonly SettingsUtils _settingsUtils = SettingsUtils.Default;
private static readonly SettingsUtils SettingsUtilsInstance = SettingsUtils.Default;
public static WorkspacesSettings ReadSettings()
{
if (!_settingsUtils.SettingsExists(WorkspacesModuleName))
if (!SettingsUtilsInstance.SettingsExists(WorkspacesModuleName))
{
WorkspacesSettings defaultWorkspacesSettings = new();
defaultWorkspacesSettings.Save(_settingsUtils);
defaultWorkspacesSettings.Save(SettingsUtilsInstance);
return defaultWorkspacesSettings;
}
WorkspacesSettings settings = _settingsUtils.GetSettingsOrDefault<WorkspacesSettings>(WorkspacesModuleName);
WorkspacesSettings settings = SettingsUtilsInstance.GetSettingsOrDefault<WorkspacesSettings>(WorkspacesModuleName);
return settings;
}
}

View File

@@ -6,6 +6,7 @@ using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using ManagedCommon;
using WorkspacesCsharpLibrary.Data;
using WorkspacesCsharpLibrary.Utils;
@@ -16,10 +17,6 @@ namespace WorkspacesEditor.Utils
{
public class WorkspacesEditorIO
{
public WorkspacesEditorIO()
{
}
public ParsingResult ParseWorkspaces(MainViewModel mainViewModel)
{
try
@@ -39,8 +36,8 @@ namespace WorkspacesEditor.Utils
if (!SetWorkspaces(mainViewModel, workspaces))
{
Logger.LogWarning($"Workspaces storage file content could not be set. Reason: {Properties.Resources.Error_Parsing_Message}");
return new ParsingResult(false, WorkspacesEditor.Properties.Resources.Error_Parsing_Message);
Logger.LogWarning("Workspaces storage file content could not be set.");
return new ParsingResult(false, "Error parsing Workspaces data.");
}
return new ParsingResult(true);
@@ -76,8 +73,7 @@ namespace WorkspacesEditor.Utils
public void SerializeWorkspaces(List<Project> workspaces, bool useTempFile = false)
{
WorkspacesData serializer = new();
WorkspacesData.WorkspacesListWrapper workspacesWrapper = new() { };
workspacesWrapper.Workspaces = [];
WorkspacesData.WorkspacesListWrapper workspacesWrapper = new() { Workspaces = [] };
foreach (Project project in workspaces)
{
@@ -86,16 +82,16 @@ namespace WorkspacesEditor.Utils
Id = project.Id,
Name = project.Name,
CreationTime = project.CreationTime,
LastLaunchedTime = project.LastLaunchedTime,
IsShortcutNeeded = project.IsShortcutNeeded,
MoveExistingWindows = project.MoveExistingWindows,
LastLaunchedTime = project.LastLaunchedTime,
Applications = [],
MonitorConfiguration = [],
};
foreach (Application app in project.Applications.Where(x => x.IsIncluded))
foreach (Application app in project.Applications)
{
wrapper.Applications.Add(new ApplicationWrapper
ApplicationWrapper appWrapper = new()
{
Id = app.Id,
Application = app.AppName,
@@ -107,80 +103,79 @@ namespace WorkspacesEditor.Utils
CommandLineArguments = app.CommandLineArguments,
IsElevated = app.IsElevated,
CanLaunchElevated = app.CanLaunchElevated,
Version = app.Version,
Maximized = app.Maximized,
Minimized = app.Minimized,
Position = new ApplicationWrapper.WindowPositionWrapper
Position = new ApplicationWrapper.WindowPositionWrapper()
{
X = app.Position.X,
Y = app.Position.Y,
Height = app.Position.Height,
Width = app.Position.Width,
Height = app.Position.Height,
},
Monitor = app.MonitorNumber,
});
Version = app.Version,
};
wrapper.Applications.Add(appWrapper);
}
foreach (MonitorSetup monitor in project.Monitors)
{
wrapper.MonitorConfiguration.Add(new MonitorConfigurationWrapper
MonitorConfigurationWrapper monitorWrapper = new()
{
Id = monitor.MonitorName,
InstanceId = monitor.MonitorInstanceId,
MonitorNumber = monitor.MonitorNumber,
Dpi = monitor.Dpi,
MonitorRectDpiAware = new MonitorConfigurationWrapper.MonitorRectWrapper
MonitorRectDpiAware = new MonitorConfigurationWrapper.MonitorRectWrapper()
{
Left = (int)monitor.MonitorDpiAwareBounds.Left,
Top = (int)monitor.MonitorDpiAwareBounds.Top,
Left = (int)monitor.MonitorDpiAwareBounds.X,
Top = (int)monitor.MonitorDpiAwareBounds.Y,
Width = (int)monitor.MonitorDpiAwareBounds.Width,
Height = (int)monitor.MonitorDpiAwareBounds.Height,
},
MonitorRectDpiUnaware = new MonitorConfigurationWrapper.MonitorRectWrapper
MonitorRectDpiUnaware = new MonitorConfigurationWrapper.MonitorRectWrapper()
{
Left = (int)monitor.MonitorDpiUnawareBounds.Left,
Top = (int)monitor.MonitorDpiUnawareBounds.Top,
Left = (int)monitor.MonitorDpiUnawareBounds.X,
Top = (int)monitor.MonitorDpiUnawareBounds.Y,
Width = (int)monitor.MonitorDpiUnawareBounds.Width,
Height = (int)monitor.MonitorDpiUnawareBounds.Height,
},
});
};
wrapper.MonitorConfiguration.Add(monitorWrapper);
}
workspacesWrapper.Workspaces.Add(wrapper);
}
string file = useTempFile ? TempProjectData.File : serializer.File;
try
{
IOUtils ioUtils = new();
ioUtils.WriteFile(useTempFile ? TempProjectData.File : serializer.File, serializer.Serialize(workspacesWrapper));
WorkspacesCsharpLibrary.Utils.IOUtils ioUtils = new();
ioUtils.WriteFile(file, serializer.Serialize(workspacesWrapper));
}
catch (Exception e)
{
// TODO: show error
Logger.LogError($"Exception while writing storage file: {e.Message}");
}
}
private bool AddWorkspaces(MainViewModel mainViewModel, WorkspacesData.WorkspacesListWrapper workspaces)
private static bool SetWorkspaces(MainViewModel mainViewModel, WorkspacesData.WorkspacesListWrapper workspaces)
{
mainViewModel.Workspaces.Clear();
foreach (ProjectWrapper project in workspaces.Workspaces)
{
mainViewModel.Workspaces.Add(new Project(project));
try
{
Project newProject = new(project);
mainViewModel.Workspaces.Add(newProject);
}
catch (Exception e)
{
Logger.LogError($"Exception while adding workspace {project.Name}: {e.Message}");
}
}
mainViewModel.Initialize();
return true;
}
private bool SetWorkspaces(MainViewModel mainViewModel, WorkspacesData.WorkspacesListWrapper workspaces)
{
mainViewModel.Workspaces = [];
return AddWorkspaces(mainViewModel, workspaces);
}
internal void SerializeTempProject(Project project)
{
SerializeWorkspaces([project], true);
}
}
}

View File

@@ -0,0 +1,110 @@
// Copyright (c) Microsoft Corporation
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
using System;
using System.Drawing;
using System.Drawing.Drawing2D;
using System.Globalization;
using System.IO;
using System.Linq;
namespace WorkspacesEditor.Utils
{
internal static class WorkspacesIcon
{
private const int IconSize = 128;
private static readonly Brush LightThemeIconBackground = new SolidBrush(Color.FromArgb(255, 239, 243, 251));
private static readonly Brush LightThemeIconForeground = new SolidBrush(Color.FromArgb(255, 47, 50, 56));
private static readonly Brush DarkThemeIconBackground = new SolidBrush(Color.FromArgb(255, 55, 55, 55));
private static readonly Brush DarkThemeIconForeground = new SolidBrush(Color.FromArgb(255, 228, 228, 228));
private static readonly Font IconFont = new("Aptos", 24, FontStyle.Bold);
public static string IconTextFromProjectName(string projectName)
{
string result = string.Empty;
char[] delimiterChars = { ' ', ',', '.', ':', '-', '\t' };
string[] words = projectName.Split(delimiterChars);
foreach (string word in words)
{
if (string.IsNullOrEmpty(word))
{
continue;
}
if (word.All(char.IsDigit))
{
result += word;
}
else
{
result += word.ToUpper(CultureInfo.CurrentCulture)[0];
}
}
return result;
}
public static Bitmap DrawIcon(string text, bool isDarkTheme)
{
Brush background = isDarkTheme ? DarkThemeIconBackground : LightThemeIconBackground;
Brush foreground = isDarkTheme ? DarkThemeIconForeground : LightThemeIconForeground;
Bitmap bitmap = new(IconSize, IconSize);
using (Graphics graphics = Graphics.FromImage(bitmap))
{
graphics.SmoothingMode = SmoothingMode.AntiAlias;
graphics.InterpolationMode = InterpolationMode.HighQualityBicubic;
graphics.PixelOffsetMode = PixelOffsetMode.HighQuality;
graphics.FillEllipse(background, 0, 0, IconSize, IconSize);
var textSize = graphics.MeasureString(text, IconFont);
var state = graphics.Save();
float scaleX = IconSize / textSize.Width;
float scaleY = IconSize / textSize.Height;
float scale = Math.Min(scaleX, scaleY) * 0.8f;
float textX = (IconSize - (textSize.Width * scale)) / 2;
float textY = ((IconSize - (textSize.Height * scale)) / 2) + 6;
graphics.TranslateTransform(textX, textY);
graphics.ScaleTransform(scale, scale);
graphics.DrawString(text, IconFont, foreground, 0, 0);
graphics.Restore(state);
}
return bitmap;
}
public static void SaveIcon(Bitmap icon, string path)
{
if (File.Exists(path))
{
File.Delete(path);
}
using var fileStream = new FileStream(path, FileMode.CreateNew);
using var memoryStream = new MemoryStream();
WorkspacesCsharpLibrary.DrawHelper.SaveBitmap(icon, memoryStream);
using var iconWriter = new BinaryWriter(fileStream);
iconWriter.Write((byte)0);
iconWriter.Write((byte)0);
iconWriter.Write((short)1);
iconWriter.Write((short)1);
iconWriter.Write((byte)IconSize);
iconWriter.Write((byte)IconSize);
iconWriter.Write((byte)0);
iconWriter.Write((byte)0);
iconWriter.Write((short)0);
iconWriter.Write((short)32);
iconWriter.Write((int)memoryStream.Length);
iconWriter.Write(6 + 16);
iconWriter.Write(memoryStream.ToArray());
iconWriter.Flush();
}
}
}

View File

@@ -0,0 +1,568 @@
// Copyright (c) Microsoft Corporation
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
using System;
using System.Collections.Generic;
using System.Collections.ObjectModel;
using System.Diagnostics;
using System.Globalization;
using System.IO;
using System.Linq;
using System.Threading.Tasks;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using CommunityToolkit.Mvvm.Messaging;
using ManagedCommon;
using Microsoft.PowerToys.Settings.UI.Library;
using Microsoft.PowerToys.Telemetry;
using WorkspacesCsharpLibrary.Data;
using WorkspacesCsharpLibrary.Utils;
using WorkspacesEditor.Helpers;
using WorkspacesEditor.Messages;
using WorkspacesEditor.Models;
using WorkspacesEditor.Utils;
namespace WorkspacesEditor.ViewModels
{
public partial class MainViewModel : ObservableObject, IDisposable
{
private WorkspacesEditorIO _workspacesEditorIO;
private Project _editedProject;
private Project _projectBeforeLaunch;
private string _projectNameBeingEdited;
private Microsoft.UI.Xaml.DispatcherTimer _lastUpdatedTimer;
private WorkspacesSettings _settings;
private bool _isDisposed;
private bool _isExistingProjectLaunched;
public ObservableCollection<Project> Workspaces { get; set; } = new ObservableCollection<Project>();
private List<Project> _workspacesView = new();
public List<Project> WorkspacesView
{
get => _workspacesView;
private set => SetProperty(ref _workspacesView, value);
}
[ObservableProperty]
private bool _isWorkspacesViewEmpty;
[ObservableProperty]
private string _emptyWorkspacesViewMessage;
public void RefreshWorkspacesView()
{
IEnumerable<Project> workspaces = GetFilteredWorkspaces();
bool isEmpty = !(workspaces != null && workspaces.Any());
IsWorkspacesViewEmpty = isEmpty;
if (isEmpty)
{
if (Workspaces != null && Workspaces.Any())
{
EmptyWorkspacesViewMessage = GetString("NoWorkspacesMatch");
}
else
{
EmptyWorkspacesViewMessage = GetString("No_Workspaces_Message");
}
WorkspacesView = new List<Project>();
return;
}
WorkspacesData.OrderBy orderBy = (WorkspacesData.OrderBy)OrderByIndex;
if (orderBy == WorkspacesData.OrderBy.LastViewed)
{
WorkspacesView = workspaces.OrderByDescending(x => x.LastLaunchedTime).ToList();
}
else if (orderBy == WorkspacesData.OrderBy.Created)
{
WorkspacesView = workspaces.OrderByDescending(x => x.CreationTime).ToList();
}
else
{
WorkspacesView = workspaces.OrderBy(x => x.Name).ToList();
}
}
private IEnumerable<Project> GetFilteredWorkspaces()
{
if (string.IsNullOrEmpty(SearchTerm))
{
return Workspaces;
}
return Workspaces.Where(x =>
{
if (x.Name.Contains(SearchTerm, StringComparison.InvariantCultureIgnoreCase))
{
return true;
}
if (x.Applications == null)
{
return false;
}
return x.Applications.Any(app => app.AppName.Contains(SearchTerm, StringComparison.InvariantCultureIgnoreCase));
});
}
[ObservableProperty]
private string _searchTerm;
partial void OnSearchTermChanged(string value)
{
RefreshWorkspacesView();
}
[ObservableProperty]
private int _orderByIndex;
partial void OnOrderByIndexChanged(int value)
{
_settings.Properties.SortBy = (WorkspacesProperties.SortByProperty)value;
_settings.Save(SettingsUtils.Default);
RefreshWorkspacesView();
}
[ObservableProperty]
private bool _isLoading;
public MainViewModel(WorkspacesEditorIO workspacesEditorIO)
{
_settings = Utils.Settings.ReadSettings();
OrderByIndex = (int)_settings.Properties.SortBy;
_workspacesEditorIO = workspacesEditorIO;
StrongReferenceMessenger.Default.Register<SnapshotCapturedMessage>(this, (r, m) => ((MainViewModel)r).OnSnapshotCaptured());
StrongReferenceMessenger.Default.Register<SnapshotCancelledMessage>(this, (r, m) => ((MainViewModel)r).CancelSnapshot());
}
private void OnSnapshotCaptured()
{
_ = SnapWorkspaceAsync();
}
public void Initialize()
{
foreach (Project project in Workspaces)
{
project.InitializePreview();
}
// Create DispatcherTimer here (requires UI thread / DispatcherQueue to exist)
_lastUpdatedTimer = new Microsoft.UI.Xaml.DispatcherTimer();
_lastUpdatedTimer.Interval = TimeSpan.FromSeconds(1);
_lastUpdatedTimer.Tick += LastUpdatedTimerTick;
_lastUpdatedTimer.Start();
RefreshWorkspacesView();
}
public void SaveProject(Project projectToSave)
{
if (_editedProject == null)
{
return;
}
_editedProject.Name = projectToSave.Name;
_editedProject.IsShortcutNeeded = projectToSave.IsShortcutNeeded;
_editedProject.MoveExistingWindows = projectToSave.MoveExistingWindows;
_editedProject.PreviewIcons = projectToSave.PreviewIcons;
_editedProject.PreviewImage = projectToSave.PreviewImage;
_editedProject.Applications = projectToSave.Applications.Where(x => x.IsIncluded).ToList();
_editedProject.NotifyApplicationsChanged();
_editedProject.InitializePreview();
_workspacesEditorIO.SerializeWorkspaces(Workspaces.ToList());
ApplyShortcut(_editedProject);
PowerToysTelemetry.Log.WriteEvent(new Telemetry.EditEvent { Successful = true, PixelAdjustmentsUsed = projectToSave.IsPositionChangedManually });
}
public void EditProject(Project selectedProject, bool isNewlyCreated = false)
{
_editedProject = selectedProject;
if (!isNewlyCreated)
{
selectedProject = new Project(selectedProject);
}
if (isNewlyCreated)
{
string defaultNamePrefix = GetString("DefaultWorkspaceNamePrefix");
int nextProjectIndex = 0;
foreach (var proj in Workspaces)
{
if (proj.Name.StartsWith(defaultNamePrefix, StringComparison.CurrentCulture))
{
try
{
int index = int.Parse(proj.Name[(defaultNamePrefix.Length + 1)..], CultureInfo.CurrentCulture);
if (nextProjectIndex < index)
{
nextProjectIndex = index;
}
}
catch (FormatException)
{
}
catch (OverflowException)
{
}
}
}
selectedProject.Name = defaultNamePrefix + " " + (nextProjectIndex + 1).ToString(CultureInfo.CurrentCulture);
}
selectedProject.EditorWindowTitle = isNewlyCreated ? GetString("CreateWorkspace") : GetString("EditWorkspace");
selectedProject.InitializePreview();
_lastUpdatedTimer.Stop();
// Navigate to editor page, passing the project as parameter
StrongReferenceMessenger.Default.Send(new NavigateToEditorMessage(selectedProject));
}
public void AddNewProject(Project project)
{
project.Applications.RemoveAll(app => !app.IsIncluded);
project.InitializePreview();
Workspaces.Add(project);
_workspacesEditorIO.SerializeWorkspaces(Workspaces.ToList());
TempProjectData.DeleteTempFile();
RefreshWorkspacesView();
ApplyShortcut(project);
PowerToysTelemetry.Log.WriteEvent(new Telemetry.CreateEvent
{
Successful = true,
NumScreens = project.Monitors.Count,
AppCount = project.Applications.Count,
CliCount = project.Applications.FindAll(app => !string.IsNullOrEmpty(app.CommandLineArguments)).Count,
AdminCount = project.Applications.FindAll(app => app.IsElevated).Count,
ShortcutCreated = project.IsShortcutNeeded,
});
}
public void DeleteProject(Project selectedProject)
{
Workspaces.Remove(selectedProject);
_workspacesEditorIO.SerializeWorkspaces(Workspaces.ToList());
RemoveShortcut(selectedProject);
RefreshWorkspacesView();
PowerToysTelemetry.Log.WriteEvent(new Telemetry.DeleteEvent { Successful = true });
}
public void SwitchToMainView()
{
StrongReferenceMessenger.Default.Send(new GoBackMessage());
SearchTerm = string.Empty;
OnPropertyChanged(nameof(SearchTerm));
_lastUpdatedTimer.Start();
_editedProject = null;
}
[RelayCommand]
public async Task LaunchProjectAsync(Project project)
{
if (project == null)
{
return;
}
await Task.Run(() => RunLauncher(project.Id, InvokePoint.EditorButton));
if (_workspacesEditorIO.ParseWorkspaces(this).Result == true)
{
foreach (Project p in Workspaces)
{
p.InitializePreview();
}
RefreshWorkspacesView();
}
}
public async Task LaunchProjectAndExitAsync(Project project)
{
if (project == null)
{
return;
}
await Task.Run(() => RunLauncher(project.Id, InvokePoint.EditorButton));
if (_workspacesEditorIO.ParseWorkspaces(this).Result == true)
{
foreach (Project p in Workspaces)
{
p.InitializePreview();
}
RefreshWorkspacesView();
}
Logger.LogInfo($"Launched the Workspace {project.Name}. Exiting.");
StrongReferenceMessenger.Default.Send(new CloseApplicationMessage());
}
public void EnterSnapshotMode(bool isExistingProjectLaunched)
{
_isExistingProjectLaunched = isExistingProjectLaunched;
// Minimize the main window
StrongReferenceMessenger.Default.Send(new MinimizeWindowMessage());
// Request the View layer to show the snapshot window
StrongReferenceMessenger.Default.Send(new ShowSnapshotWindowMessage());
}
internal void CancelSnapshot()
{
StrongReferenceMessenger.Default.Send(new RestoreWindowMessage());
}
[RelayCommand]
internal async Task SnapWorkspaceAsync()
{
// Restore window immediately so user sees feedback
StrongReferenceMessenger.Default.Send(new RestoreWindowMessage());
IsLoading = true;
await Task.Run(() => RunSnapshotTool(_isExistingProjectLaunched));
IsLoading = false;
Project project = _workspacesEditorIO.ParseTempProject();
if (project != null)
{
if (_isExistingProjectLaunched)
{
project.UpdateAfterLaunchAndEdit(_projectBeforeLaunch);
project.EditorWindowTitle = GetString("EditWorkspace");
// Navigate to editor page with the updated project
StrongReferenceMessenger.Default.Send(new NavigateToEditorMessage(project));
}
else
{
EditProject(project, true);
}
}
}
[RelayCommand]
internal async Task LaunchAndEditAsync(Project project)
{
await Task.Run(() => RunLauncher(project.Id, InvokePoint.LaunchAndEdit));
_projectBeforeLaunch = new Project(project);
EnterSnapshotMode(true);
}
internal void RevertLaunch()
{
if (_projectBeforeLaunch != null)
{
_projectBeforeLaunch.InitializePreview();
StrongReferenceMessenger.Default.Send(new NavigateToEditorMessage(_projectBeforeLaunch));
}
}
public void SaveProjectName(Project project)
{
_projectNameBeingEdited = project.Name;
}
public void CancelProjectName(Project project)
{
project.Name = _projectNameBeingEdited;
}
internal void CloseAllPopups()
{
foreach (Project project in Workspaces)
{
project.IsPopupVisible = false;
}
}
private void LastUpdatedTimerTick(object sender, object e)
{
if (Workspaces == null)
{
return;
}
foreach (Project project in Workspaces)
{
project.NotifyLastLaunchedChanged();
}
}
private void RunLauncher(string projectId, InvokePoint invokePoint)
{
var exeDir = Path.GetDirectoryName(Environment.ProcessPath);
var parentDir = Path.GetDirectoryName(exeDir);
var launcherPath = Path.Combine(parentDir, "PowerToys.WorkspacesLauncher.exe");
if (!File.Exists(launcherPath))
{
launcherPath = Path.Combine(exeDir, "PowerToys.WorkspacesLauncher.exe");
}
Process process = new Process();
process.StartInfo = new ProcessStartInfo(launcherPath, $"{projectId} {(int)invokePoint}")
{
CreateNoWindow = true,
};
try
{
process.Start();
if (!process.WaitForExit(120_000))
{
Logger.LogWarning("Workspace launcher did not exit within 120 seconds.");
process.Kill();
}
}
catch (Exception ex)
{
Logger.LogError($"Failed to launch workspace: {ex.Message}");
}
}
private void RunSnapshotTool(bool isExistingProjectLaunched)
{
var exeDir = Path.GetDirectoryName(Environment.ProcessPath);
// Snapshot tool is in the parent directory
var parentDir = Path.GetDirectoryName(exeDir);
var snapshotUtilsPath = Path.Combine(parentDir, "PowerToys.WorkspacesSnapshotTool.exe");
if (!File.Exists(snapshotUtilsPath))
{
// Fallback: try same directory
snapshotUtilsPath = Path.Combine(exeDir, "PowerToys.WorkspacesSnapshotTool.exe");
}
Process process = new Process();
process.StartInfo = new ProcessStartInfo(snapshotUtilsPath)
{
CreateNoWindow = true,
Arguments = isExistingProjectLaunched ? $"{(int)InvokePoint.LaunchAndEdit}" : string.Empty,
};
try
{
process.Start();
if (!process.WaitForExit(120_000))
{
Logger.LogWarning("Snapshot tool did not exit within 120 seconds.");
process.Kill();
}
}
catch (Exception ex)
{
Logger.LogError($"Failed to run snapshot tool: {ex.Message}");
}
}
private static string GetString(string key)
{
return ResourceLoaderInstance.ResourceLoader?.GetString(key) ?? key;
}
private static string GetDesktopShortcutAddress(Project project) => Path.Combine(WorkspacesCsharpLibrary.Utils.FolderUtils.Desktop(), project.Name + ".lnk");
private static string GetShortcutStoreAddress(Project project)
{
var dataFolder = WorkspacesCsharpLibrary.Utils.FolderUtils.DataFolder();
Directory.CreateDirectory(dataFolder);
var shortcutStoreFolder = Path.Combine(dataFolder, "WorkspacesIcons");
Directory.CreateDirectory(shortcutStoreFolder);
return Path.Combine(shortcutStoreFolder, project.Id + ".ico");
}
private static void ApplyShortcut(Project project)
{
if (!project.IsShortcutNeeded)
{
RemoveShortcut(project);
return;
}
try
{
var basePath = Path.GetDirectoryName(Path.GetDirectoryName(Environment.ProcessPath));
var shortcutAddress = GetDesktopShortcutAddress(project);
var shortcutIconFilename = GetShortcutStoreAddress(project);
bool isDarkTheme = Helpers.ThemeHelper.IsDarkTheme();
var icon = Utils.WorkspacesIcon.DrawIcon(Utils.WorkspacesIcon.IconTextFromProjectName(project.Name), isDarkTheme);
Utils.WorkspacesIcon.SaveIcon(icon, shortcutIconFilename);
File.WriteAllBytes(shortcutAddress, Array.Empty<byte>());
Shell32.Shell shell = new Shell32.Shell();
Shell32.Folder dir = shell.NameSpace(WorkspacesCsharpLibrary.Utils.FolderUtils.Desktop());
Shell32.FolderItem folderItem = dir.Items().Item($"{project.Name}.lnk");
Shell32.ShellLinkObject link = (Shell32.ShellLinkObject)folderItem.GetLink;
link.Description = $"Project Launcher {project.Id}";
link.Path = Path.Combine(basePath, "PowerToys.WorkspacesLauncher.exe");
link.Arguments = $"{project.Id} {(int)InvokePoint.Shortcut}";
link.WorkingDirectory = basePath;
link.SetIconLocation(shortcutIconFilename, 0);
link.Save(shortcutAddress);
}
catch (Exception ex)
{
Logger.LogError($"Shortcut creation error: {ex.Message}");
}
}
private static void RemoveShortcut(Project project)
{
string shortcutAddress = GetDesktopShortcutAddress(project);
string shortcutIconFilename = GetShortcutStoreAddress(project);
if (File.Exists(shortcutIconFilename))
{
File.Delete(shortcutIconFilename);
}
if (File.Exists(shortcutAddress))
{
File.Delete(shortcutAddress);
}
}
private static void CheckShortcutPresence(Project project)
{
string shortcutAddress = Path.Combine(WorkspacesCsharpLibrary.Utils.FolderUtils.Desktop(), project.Name + ".lnk");
project.IsShortcutNeeded = File.Exists(shortcutAddress);
}
public void Dispose()
{
if (!_isDisposed)
{
_lastUpdatedTimer?.Stop();
StrongReferenceMessenger.Default.UnregisterAll(this);
_isDisposed = true;
}
GC.SuppressFinalize(this);
}
}
}

View File

@@ -0,0 +1,15 @@
<?xml version="1.0" encoding="utf-8" ?>
<Application
x:Class="WorkspacesEditor.App"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:local="using:WorkspacesEditor">
<Application.Resources>
<ResourceDictionary>
<ResourceDictionary.MergedDictionaries>
<XamlControlsResources xmlns="using:Microsoft.UI.Xaml.Controls" />
</ResourceDictionary.MergedDictionaries>
<FontFamily x:Key="SymbolThemeFontFamily">Segoe Fluent Icons, Segoe MDL2 Assets</FontFamily>
</ResourceDictionary>
</Application.Resources>
</Application>

View File

@@ -0,0 +1,87 @@
// 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 CommunityToolkit.Mvvm.Messaging;
using ManagedCommon;
using Microsoft.PowerToys.Telemetry;
using Microsoft.UI.Dispatching;
using Microsoft.UI.Xaml;
using WorkspacesEditor.Messages;
using WorkspacesEditor.Telemetry;
using WorkspacesEditor.Utils;
using WorkspacesEditor.ViewModels;
namespace WorkspacesEditor
{
public partial class App : Application, IDisposable
{
private MainWindow _mainWindow;
private bool _isDisposed;
public static DispatcherQueue DispatcherQueue { get; private set; }
public static WorkspacesEditorIO WorkspacesEditorIO { get; private set; }
public static MainViewModel MainViewModel { get; private set; }
public App()
{
PowerToysTelemetry.Log.WriteEvent(new WorkspacesEditorStartEvent() { TimeStamp = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds() });
string languageTag = LanguageHelper.LoadLanguage();
if (!string.IsNullOrEmpty(languageTag))
{
try
{
Microsoft.Windows.Globalization.ApplicationLanguages.PrimaryLanguageOverride = languageTag;
}
catch (Exception ex)
{
Logger.LogError("Failed to set language override: " + ex.Message);
}
}
this.InitializeComponent();
this.UnhandledException += OnUnhandledException;
}
protected override void OnLaunched(LaunchActivatedEventArgs args)
{
DispatcherQueue = DispatcherQueue.GetForCurrentThread();
WorkspacesEditorIO = new WorkspacesEditorIO();
MainViewModel = new MainViewModel(WorkspacesEditorIO);
WorkspacesEditorIO.ParseWorkspaces(MainViewModel);
MainViewModel.Initialize();
_mainWindow = new MainWindow();
_mainWindow.Activate();
StrongReferenceMessenger.Default.Register<CloseApplicationMessage>(this, (r, m) =>
{
Logger.LogInfo("CloseApplicationMessage received. Shutting down.");
((App)r).Exit();
});
}
private void OnUnhandledException(object sender, Microsoft.UI.Xaml.UnhandledExceptionEventArgs e)
{
Logger.LogError("Unhandled exception occurred", e.Exception);
}
public void Dispose()
{
if (!_isDisposed)
{
MainViewModel?.Dispose();
_isDisposed = true;
}
GC.SuppressFinalize(this);
}
}
}

View File

@@ -0,0 +1,207 @@
<?xml version="1.0" encoding="utf-8" ?>
<Page
x:Class="WorkspacesEditor.Views.MainPage"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:converters="using:WorkspacesEditor.Converters"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
xmlns:models="using:WorkspacesEditor.Models"
mc:Ignorable="d">
<Page.Resources>
<converters:BooleanToVisibilityConverter x:Key="BoolToVis" />
<converters:BooleanToInvertedVisibilityConverter x:Key="BooleanToInvertedVisibilityConverter" />
<converters:LaunchButtonNameConverter x:Key="LaunchButtonNameConverter" />
<converters:MoreOptionsButtonNameConverter x:Key="MoreOptionsButtonNameConverter" />
</Page.Resources>
<Grid>
<Grid.RowDefinitions>
<RowDefinition Height="Auto" />
<RowDefinition Height="Auto" />
<RowDefinition Height="*" />
</Grid.RowDefinitions>
<!-- header + create button -->
<TextBlock
x:Name="WorkspacesHeaderBlock"
Grid.Row="0"
Margin="24,0,48,16"
AutomationProperties.HeadingLevel="Level1"
FontSize="24"
FontWeight="SemiBold"
Foreground="{ThemeResource TextFillColorPrimaryBrush}" />
<Button
x:Name="NewProjectButton"
x:Uid="CreateWorkspaceBtn"
Margin="0,0,24,36"
HorizontalAlignment="Right"
VerticalAlignment="Bottom"
Click="NewProjectButton_Click"
Style="{ThemeResource AccentButtonStyle}"
TabIndex="3">
<StackPanel Orientation="Horizontal">
<TextBlock
Margin="0,4,0,0"
FontFamily="{ThemeResource SymbolThemeFontFamily}"
Foreground="{ThemeResource TextOnAccentFillColorPrimaryBrush}"
Text="&#xE710;" />
<TextBlock
x:Name="CreateWorkspaceText"
Margin="8,0,0,0"
Foreground="{ThemeResource TextOnAccentFillColorPrimaryBrush}" />
</StackPanel>
</Button>
<!-- search + sort -->
<StackPanel
Grid.Row="1"
Margin="24,0,0,0"
HorizontalAlignment="Left"
VerticalAlignment="Center"
Orientation="Horizontal">
<TextBox
x:Name="SearchTextBox"
x:Uid="SearchTextBox"
Width="320"
PlaceholderText="Search for Workspaces or apps"
Text="{x:Bind ViewModel.SearchTerm, Mode=TwoWay, UpdateSourceTrigger=PropertyChanged}" />
</StackPanel>
<StackPanel
Grid.Row="1"
Margin="0,0,24,0"
HorizontalAlignment="Right"
Orientation="Horizontal">
<TextBlock
x:Name="SortByLabel"
Margin="12,0,8,0"
VerticalAlignment="Center"
Foreground="{ThemeResource TextFillColorPrimaryBrush}" />
<ComboBox
x:Uid="SortByComboBox"
MinWidth="140"
SelectedIndex="{x:Bind ViewModel.OrderByIndex, Mode=TwoWay}">
<ComboBoxItem x:Uid="LastLaunchedItem" />
<ComboBoxItem x:Uid="CreatedItem" />
<ComboBoxItem x:Uid="NameItem" />
</ComboBox>
</StackPanel>
<!-- empty state -->
<TextBlock
x:Name="EmptyStateText"
Grid.Row="2"
HorizontalAlignment="Center"
VerticalAlignment="Center"
AutomationProperties.LiveSetting="Polite"
FontSize="16"
Foreground="{ThemeResource TextFillColorSecondaryBrush}"
Text="{x:Bind ViewModel.EmptyWorkspacesViewMessage, Mode=OneWay}"
TextAlignment="Center"
Visibility="{x:Bind ViewModel.IsWorkspacesViewEmpty, Mode=OneWay, Converter={StaticResource BoolToVis}}" />
<!-- workspace list -->
<ListView
x:Name="WorkspacesList"
Grid.Row="2"
Margin="24,24,24,0"
AutomationProperties.Name="Workspace list"
IsItemClickEnabled="True"
ItemClick="WorkspaceItemClicked"
ItemsSource="{x:Bind ViewModel.WorkspacesView, Mode=OneWay}"
SelectionMode="None"
Visibility="{x:Bind ViewModel.IsWorkspacesViewEmpty, Mode=OneWay, Converter={StaticResource BooleanToInvertedVisibilityConverter}}">
<ListView.ItemContainerStyle>
<Style TargetType="ListViewItem">
<Setter Property="HorizontalContentAlignment" Value="Stretch" />
<Setter Property="Padding" Value="0" />
<Setter Property="Margin" Value="0,4,0,0" />
</Style>
</ListView.ItemContainerStyle>
<ListView.ItemTemplate>
<DataTemplate x:DataType="models:Project">
<Grid
DataContext="{x:Bind}"
HorizontalAlignment="Stretch"
AutomationProperties.Name="{x:Bind Name}">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*" />
<ColumnDefinition Width="Auto" />
</Grid.ColumnDefinitions>
<StackPanel
Margin="12,8,8,8"
HorizontalAlignment="Left"
Orientation="Vertical">
<TextBlock
Margin="0,0,0,8"
FontSize="16"
FontWeight="SemiBold"
Text="{x:Bind Name, Mode=OneWay}" />
<StackPanel Margin="0,0,0,8" Orientation="Horizontal">
<Image
Height="16"
Source="{x:Bind PreviewIcons, Mode=OneWay}" />
<TextBlock
Margin="8,0,8,0"
VerticalAlignment="Center"
Foreground="{ThemeResource TextFillColorSecondaryBrush}"
Text="{x:Bind AppsCountString, Mode=OneWay}" />
</StackPanel>
<StackPanel Orientation="Horizontal">
<TextBlock
Margin="0,0,8,0"
FontFamily="{ThemeResource SymbolThemeFontFamily}"
FontSize="12"
Foreground="{ThemeResource TextFillColorSecondaryBrush}"
Text="&#xE81C;" />
<TextBlock
FontSize="12"
Foreground="{ThemeResource TextFillColorSecondaryBrush}"
Text="{x:Bind LastLaunched, Mode=OneWay}" />
</StackPanel>
</StackPanel>
<StackPanel
Grid.Column="1"
Margin="12"
Orientation="Horizontal">
<Button
Margin="0,0,8,0"
AutomationProperties.Name="{x:Bind Name, Mode=OneWay, Converter={StaticResource LaunchButtonNameConverter}}"
Click="LaunchButton_Click"
x:Uid="LaunchBtn" />
<Button
AutomationProperties.Name="{x:Bind Name, Mode=OneWay, Converter={StaticResource MoreOptionsButtonNameConverter}}"
x:Uid="MoreOptionsBtn"
Padding="8">
<TextBlock
FontFamily="{ThemeResource SymbolThemeFontFamily}"
Foreground="{ThemeResource TextFillColorPrimaryBrush}"
Text="&#xE712;" />
<Button.Flyout>
<MenuFlyout>
<MenuFlyoutItem
Click="EditButtonClicked"
x:Uid="EditFlyoutItem">
<MenuFlyoutItem.Icon>
<FontIcon Glyph="&#xE70F;" />
</MenuFlyoutItem.Icon>
</MenuFlyoutItem>
<MenuFlyoutItem
Click="DeleteButtonClicked"
x:Uid="RemoveFlyoutItem">
<MenuFlyoutItem.Icon>
<FontIcon Glyph="&#xE74D;" />
</MenuFlyoutItem.Icon>
</MenuFlyoutItem>
</MenuFlyout>
</Button.Flyout>
</Button>
</StackPanel>
</Grid>
</DataTemplate>
</ListView.ItemTemplate>
</ListView>
</Grid>
</Page>

View File

@@ -0,0 +1,142 @@
// Copyright (c) Microsoft Corporation
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
using System;
using Microsoft.UI.Xaml;
using Microsoft.UI.Xaml.Controls;
using Microsoft.UI.Xaml.Navigation;
using WorkspacesEditor.Helpers;
using WorkspacesEditor.Models;
using WorkspacesEditor.ViewModels;
namespace WorkspacesEditor.Views
{
public sealed partial class MainPage : Page
{
public MainViewModel ViewModel { get; private set; }
public MainPage()
{
this.InitializeComponent();
WorkspacesHeaderBlock.Text = ResourceLoaderInstance.ResourceLoader?.GetString("Workspaces") ?? "Workspaces";
CreateWorkspaceText.Text = ResourceLoaderInstance.ResourceLoader?.GetString("CreateWorkspace") ?? "Create Workspace";
SortByLabel.Text = ResourceLoaderInstance.ResourceLoader?.GetString("SortBy") ?? "Sort by";
SearchTextBox.PlaceholderText = ResourceLoaderInstance.ResourceLoader?.GetString("SearchExplanation") ?? "Search for Workspaces or apps";
}
protected override void OnNavigatedTo(NavigationEventArgs e)
{
base.OnNavigatedTo(e);
if (e.Parameter is MainViewModel vm)
{
ViewModel = vm;
this.DataContext = vm;
Bindings.Update();
vm.PropertyChanged += (s, args) =>
{
if (args.PropertyName == nameof(vm.IsWorkspacesViewEmpty) && vm.IsWorkspacesViewEmpty)
{
var peer = Microsoft.UI.Xaml.Automation.Peers.FrameworkElementAutomationPeer.CreatePeerForElement(EmptyStateText);
peer?.RaiseAutomationEvent(Microsoft.UI.Xaml.Automation.Peers.AutomationEvents.LiveRegionChanged);
}
};
}
}
private void NewProjectButton_Click(object sender, RoutedEventArgs e)
{
ViewModel.EnterSnapshotMode(false);
}
private void EditButtonClicked(object sender, RoutedEventArgs e)
{
ViewModel.CloseAllPopups();
Project selectedProject = GetProjectFromSender(sender);
if (selectedProject != null)
{
ViewModel.EditProject(selectedProject);
}
}
private void WorkspaceItemClicked(object sender, ItemClickEventArgs e)
{
if (e.ClickedItem is Project project)
{
ViewModel.CloseAllPopups();
ViewModel.EditProject(project);
}
}
private static Project GetProjectFromSender(object sender)
{
if (sender is FrameworkElement element)
{
// Direct DataContext (works for card button with DataContext="{x:Bind}")
if (element.DataContext is Project project)
{
return project;
}
// For MenuFlyoutItems inside a flyout, walk up the visual tree
var parent = element;
while (parent != null)
{
if (parent.DataContext is Project p)
{
return p;
}
parent = Microsoft.UI.Xaml.Media.VisualTreeHelper.GetParent(parent) as FrameworkElement;
}
}
return null;
}
private async void DeleteButtonClicked(object sender, RoutedEventArgs e)
{
Project selectedProject = GetProjectFromSender(sender);
if (selectedProject != null)
{
selectedProject.IsPopupVisible = false;
var dialog = new Microsoft.UI.Xaml.Controls.ContentDialog
{
Title = ResourceLoaderInstance.ResourceLoader?.GetString("Are_You_Sure") ?? "Are you sure?",
Content = ResourceLoaderInstance.ResourceLoader?.GetString("Are_You_Sure_Description") ?? "Are you sure you want to delete this Workspace?",
PrimaryButtonText = ResourceLoaderInstance.ResourceLoader?.GetString("Delete") ?? "Remove",
CloseButtonText = ResourceLoaderInstance.ResourceLoader?.GetString("Cancel") ?? "Cancel",
DefaultButton = Microsoft.UI.Xaml.Controls.ContentDialogButton.Close,
XamlRoot = this.XamlRoot,
};
var result = await dialog.ShowAsync();
if (result == Microsoft.UI.Xaml.Controls.ContentDialogResult.Primary)
{
ViewModel.DeleteProject(selectedProject);
}
}
}
private async void LaunchButton_Click(object sender, RoutedEventArgs e)
{
Project selectedProject = GetProjectFromSender(sender);
if (selectedProject != null)
{
try
{
await ViewModel.LaunchProjectAsync(selectedProject);
}
catch (System.Exception ex)
{
ManagedCommon.Logger.LogError($"LaunchProject failed: {ex.Message}");
}
}
}
}
}

View File

@@ -0,0 +1,23 @@
<?xml version="1.0" encoding="utf-8" ?>
<Window
x:Class="WorkspacesEditor.MainWindow"
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="Workspaces"
mc:Ignorable="d">
<Grid Margin="0,16,0,0">
<Frame x:Name="ContentFrame" />
<ProgressRing
x:Name="LoadingRing"
Width="48"
Height="48"
HorizontalAlignment="Center"
VerticalAlignment="Center"
AutomationProperties.Name="Loading"
AutomationProperties.LiveSetting="Polite"
IsActive="False"
Visibility="Collapsed" />
</Grid>
</Window>

View File

@@ -0,0 +1,313 @@
// 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.Runtime.InteropServices;
using System.Threading;
using CommunityToolkit.Mvvm.Messaging;
using ManagedCommon;
using Microsoft.UI;
using Microsoft.UI.Windowing;
using Microsoft.UI.Xaml;
using WinRT.Interop;
using WorkspacesEditor.Helpers;
using WorkspacesEditor.Messages;
using WorkspacesEditor.Views;
namespace WorkspacesEditor
{
public sealed partial class MainWindow : Window, IDisposable
{
public const int MinWindowWidth = 750;
public const int MinWindowHeight = 680;
private readonly CancellationTokenSource _cancellationToken = new();
private readonly AppWindow _appWindow;
public MainWindow()
{
this.InitializeComponent();
var hwnd = WindowNative.GetWindowHandle(this);
var windowId = Win32Interop.GetWindowIdFromWindow(hwnd);
_appWindow = AppWindow.GetFromWindowId(windowId);
SetMinSize(hwnd, MinWindowWidth, MinWindowHeight);
RestoreWindowState(hwnd);
// Set title from resource or fallback
try
{
this.Title = ResourceLoaderInstance.ResourceLoader?.GetString("MainTitle") ?? "Workspaces";
}
catch
{
this.Title = "Workspaces";
}
this.Closed += OnClosed;
// Listen for hotkey toggle event
StartHotkeyEventLoop(hwnd);
// Wire ViewModel navigation via messenger
// Use StrongReferenceMessenger for MainWindow since Window is not rooted
// in the visual tree and WeakReferenceMessenger may GC the registration.
var vm = App.MainViewModel;
StrongReferenceMessenger.Default.Register<NavigateToEditorMessage>(this, (r, m) =>
{
ContentFrame.Navigate(typeof(Views.WorkspacesEditorPage), (vm, m.Project));
});
StrongReferenceMessenger.Default.Register<GoBackMessage>(this, (r, m) =>
{
if (ContentFrame.CanGoBack)
{
ContentFrame.GoBack();
}
});
StrongReferenceMessenger.Default.Register<MinimizeWindowMessage>(this, (r, m) =>
{
ShowWindow(WindowNative.GetWindowHandle(this), 6); // SW_MINIMIZE
});
StrongReferenceMessenger.Default.Register<RestoreWindowMessage>(this, (r, m) =>
{
ShowWindow(WindowNative.GetWindowHandle(this), 9); // SW_RESTORE
});
// Listen for snapshot window requests from ViewModel
OverlayBorder overlayBorder = null;
StrongReferenceMessenger.Default.Register<ShowSnapshotWindowMessage>(this, (r, m) =>
{
// Show red border overlay around all displays
var displays = OverlayBorder.GetAllMonitorBounds();
overlayBorder = OverlayBorder.CreateForAllMonitors(displays);
var snapshotWindow = new Views.SnapshotWindow();
snapshotWindow.Closed += (s, args) =>
{
overlayBorder?.Dispose();
overlayBorder = null;
};
snapshotWindow.Activate();
});
// Bind loading ring to ViewModel.IsLoading
vm.PropertyChanged += (s, e) =>
{
if (e.PropertyName == nameof(vm.IsLoading))
{
LoadingRing.IsActive = vm.IsLoading;
LoadingRing.Visibility = vm.IsLoading
? Microsoft.UI.Xaml.Visibility.Visible
: Microsoft.UI.Xaml.Visibility.Collapsed;
}
};
// Navigate to main page
ContentFrame.Navigate(typeof(Views.MainPage), vm);
Microsoft.PowerToys.Telemetry.PowerToysTelemetry.Log.WriteEvent(new Telemetry.WorkspacesEditorStartFinishEvent() { TimeStamp = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds() });
}
private void RestoreWindowState(IntPtr hwnd)
{
var state = WindowStateHelper.Load();
if (state != null && state.IsValid())
{
// Use AppWindow for positioning — it handles DPI correctly for WinUI windows
_appWindow.Move(new Windows.Graphics.PointInt32((int)state.Left, (int)state.Top));
_appWindow.Resize(new Windows.Graphics.SizeInt32((int)state.Width, (int)state.Height));
if (state.Maximized)
{
ShowWindow(hwnd, 3); // SW_SHOWMAXIMIZED
}
}
else
{
// First launch: center on current display at 90% height, 75% width
var displayArea = DisplayArea.GetFromWindowId(
Win32Interop.GetWindowIdFromWindow(hwnd),
DisplayAreaFallback.Primary);
var workArea = displayArea.WorkArea;
int width = (int)(workArea.Width * 0.75);
int height = (int)(workArea.Height * 0.90);
int x = workArea.X + (int)(workArea.Width * 0.125);
int y = workArea.Y + (int)(workArea.Height * 0.05);
_appWindow.MoveAndResize(new Windows.Graphics.RectInt32(x, y, width, height));
}
}
private void StartHotkeyEventLoop(IntPtr hwnd)
{
var token = _cancellationToken.Token;
new Thread(() =>
{
var eventHandle = new EventWaitHandle(false, EventResetMode.AutoReset, PowerToys.Interop.Constants.WorkspacesHotkeyEvent());
while (true)
{
if (WaitHandle.WaitAny(new WaitHandle[] { token.WaitHandle, eventHandle }) == 1)
{
App.DispatcherQueue.TryEnqueue(() =>
{
if (ApplicationIsInFocus())
{
StrongReferenceMessenger.Default.Send(new CloseApplicationMessage());
}
else
{
WindowHelpers.BringToForeground(hwnd);
}
});
}
else
{
return;
}
}
}) { IsBackground = true }.Start();
}
private void SaveWindowState()
{
var hwnd = WindowNative.GetWindowHandle(this);
bool isMaximized = IsWindowMaximized(hwnd);
// Use AppWindow for both save and restore — same coordinate space, no DPI mismatch
var pos = _appWindow.Position;
var size = _appWindow.Size;
WindowStateHelper.Save(new WindowStateData
{
Top = pos.Y,
Left = pos.X,
Width = size.Width,
Height = size.Height,
Maximized = isMaximized,
});
}
private void OnClosed(object sender, WindowEventArgs args)
{
SaveWindowState();
_cancellationToken.Dispose();
(Application.Current as IDisposable)?.Dispose();
}
private static bool ApplicationIsInFocus()
{
var activatedHandle = GetForegroundWindow();
if (activatedHandle == IntPtr.Zero)
{
return false;
}
var procId = Environment.ProcessId;
_ = GetWindowThreadProcessId(activatedHandle, out int activeProcId);
return activeProcId == procId;
}
private static void SetMinSize(IntPtr hwnd, int minWidth, int minHeight)
{
var subclassId = (nuint)1;
SubclassProc callback = (hWnd, msg, wParam, lParam, id, data) =>
{
if (msg == WmGetminmaxinfo)
{
var mmi = Marshal.PtrToStructure<MINMAXINFO>(lParam);
mmi.PtMinTrackSize.X = minWidth;
mmi.PtMinTrackSize.Y = minHeight;
Marshal.StructureToPtr(mmi, lParam, false);
}
return DefSubclassProc(hWnd, msg, wParam, lParam);
};
// prevent GC of delegate
_subclassCallback = callback;
SetWindowSubclass(hwnd, callback, subclassId, 0);
}
private static SubclassProc _subclassCallback;
private const uint WmGetminmaxinfo = 0x0024;
private delegate IntPtr SubclassProc(IntPtr hWnd, uint msg, IntPtr wParam, IntPtr lParam, nuint id, nuint data);
[DllImport("comctl32.dll")]
[return: MarshalAs(UnmanagedType.Bool)]
private static extern bool SetWindowSubclass(IntPtr hWnd, SubclassProc pfnSubclass, nuint uIdSubclass, nuint dwRefData);
[DllImport("comctl32.dll")]
private static extern IntPtr DefSubclassProc(IntPtr hWnd, uint msg, IntPtr wParam, IntPtr lParam);
[StructLayout(LayoutKind.Sequential)]
private struct MINMAXINFO
{
public POINT PtReserved;
public POINT PtMaxSize;
public POINT PtMaxPosition;
public POINT PtMinTrackSize;
public POINT PtMaxTrackSize;
}
public void Dispose()
{
_cancellationToken?.Dispose();
GC.SuppressFinalize(this);
}
// Win32 interop
[DllImport("user32.dll")]
private static extern bool ShowWindow(IntPtr hWnd, int nCmdShow);
[DllImport("user32.dll")]
private static extern IntPtr GetForegroundWindow();
[DllImport("user32.dll")]
private static extern uint GetWindowThreadProcessId(IntPtr hWnd, out int processId);
[DllImport("user32.dll")]
[return: MarshalAs(UnmanagedType.Bool)]
private static extern bool GetWindowPlacement(IntPtr hWnd, out WINDOWPLACEMENT lpwndpl);
private static bool IsWindowMaximized(IntPtr hwnd)
{
GetWindowPlacement(hwnd, out WINDOWPLACEMENT placement);
return placement.ShowCmd == 3; // SW_SHOWMAXIMIZED
}
[StructLayout(LayoutKind.Sequential)]
private struct WINDOWPLACEMENT
{
public uint Length;
public uint Flags;
public uint ShowCmd;
public POINT PtMinPosition;
public POINT PtMaxPosition;
public RECT RcNormalPosition;
}
[StructLayout(LayoutKind.Sequential)]
private struct POINT
{
public int X;
public int Y;
}
[StructLayout(LayoutKind.Sequential)]
private struct RECT
{
public int Left;
public int Top;
public int Right;
public int Bottom;
}
}
}

View File

@@ -0,0 +1,209 @@
// Copyright (c) Microsoft Corporation
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
using System;
using System.Collections.Generic;
using System.Runtime.InteropServices;
using Microsoft.UI;
using Microsoft.UI.Windowing;
using Microsoft.UI.Xaml;
using Microsoft.UI.Xaml.Media;
using Windows.Graphics;
using WinRT.Interop;
namespace WorkspacesEditor.Views
{
/// <summary>
/// Creates 4 thin opaque red bar windows forming a border frame around a display area.
/// Click-through so the user can interact with their desktop beneath.
/// </summary>
internal sealed class OverlayBorder : IDisposable
{
private const int BorderThickness = 6;
private readonly List<Window> _windows = new();
/// <summary>
/// Gets the bounds of all monitors via Win32 EnumDisplayMonitors.
/// </summary>
public static List<RectInt32> GetAllMonitorBounds()
{
var monitors = new List<RectInt32>();
EnumDisplayMonitors(
IntPtr.Zero,
IntPtr.Zero,
(IntPtr hMonitor, IntPtr hdc, ref Rect lprcMonitor, IntPtr dwData) =>
{
monitors.Add(new RectInt32(
lprcMonitor.Left,
lprcMonitor.Top,
lprcMonitor.Right - lprcMonitor.Left,
lprcMonitor.Bottom - lprcMonitor.Top));
return true;
},
IntPtr.Zero);
return monitors;
}
/// <summary>
/// Creates overlay borders around all monitors.
/// </summary>
public static OverlayBorder CreateForAllMonitors(IEnumerable<RectInt32> monitorBounds)
{
var overlay = new OverlayBorder();
foreach (var bounds in monitorBounds)
{
overlay.CreateBorderForRect(bounds);
}
return overlay;
}
/// <summary>
/// Creates 4 strip windows (top, bottom, left, right) forming a red frame.
/// All bars extend to full length so corners connect cleanly.
/// </summary>
private void CreateBorderForRect(RectInt32 bounds)
{
// Top bar — full width
CreateStrip(bounds.X, bounds.Y, bounds.Width, BorderThickness);
// Bottom bar — full width
CreateStrip(bounds.X, bounds.Y + bounds.Height - BorderThickness, bounds.Width, BorderThickness);
// Left bar — full height (overlaps corners)
CreateStrip(bounds.X, bounds.Y, BorderThickness, bounds.Height);
// Right bar — full height (overlaps corners)
CreateStrip(bounds.X + bounds.Width - BorderThickness, bounds.Y, BorderThickness, bounds.Height);
}
private void CreateStrip(int x, int y, int width, int height)
{
var window = new Window();
window.Content = new Microsoft.UI.Xaml.Controls.Grid
{
Background = new SolidColorBrush(Microsoft.UI.Colors.Red),
};
// Get native handle and configure
var hwnd = WindowNative.GetWindowHandle(window);
var windowId = Win32Interop.GetWindowIdFromWindow(hwnd);
var appWindow = AppWindow.GetFromWindowId(windowId);
// Remove title bar and borders
if (appWindow.Presenter is OverlappedPresenter presenter)
{
presenter.IsAlwaysOnTop = true;
presenter.IsResizable = false;
presenter.IsMaximizable = false;
presenter.IsMinimizable = false;
presenter.SetBorderAndTitleBar(false, false);
}
// Disable DWM shadow/gradient and window chrome completely
int ncrpDisabled = 2; // DWMNCRP_DISABLED
_ = DwmSetWindowAttribute(hwnd, 2, ref ncrpDisabled, sizeof(int)); // DWMWA_NCRENDERING_POLICY
// Remove rounded corners (Windows 11)
int cornerPref = 1; // DWMWCP_DONOTROUND
_ = DwmSetWindowAttribute(hwnd, 33, ref cornerPref, sizeof(int)); // DWMWA_WINDOW_CORNER_PREFERENCE
// Remove window border color
int colorNone = unchecked((int)0xFFFFFFFE); // DWMWA_COLOR_NONE
_ = DwmSetWindowAttribute(hwnd, 34, ref colorNone, sizeof(int)); // DWMWA_BORDER_COLOR
// Disable shadow
var margins = new Margins { Left = 0, Right = 0, Top = 0, Bottom = 0 };
_ = DwmExtendFrameIntoClientArea(hwnd, ref margins);
// Remove WS_OVERLAPPEDWINDOW style, set WS_POPUP for minimal chrome
int style = GetWindowLong(hwnd, GwlStyle);
style &= ~WsOverlappedwindow;
style |= WsPopup;
_ = SetWindowLong(hwnd, GwlStyle, style);
// Make click-through + no taskbar entry
int exStyle = GetWindowLong(hwnd, GwlExstyle);
_ = SetWindowLong(hwnd, GwlExstyle, exStyle | WsExTransparent | WsExToolwindow | WsExTopmost);
// Position and size via SetWindowPos (bypasses AppWindow min-size constraints)
_ = SetWindowPos(hwnd, HwndTopmost, x, y, width, height, SwpNoactivate | SwpShowwindow);
// Show
window.Activate();
_windows.Add(window);
}
public void Dispose()
{
foreach (var window in _windows)
{
try
{
window.Close();
}
catch
{
}
}
_windows.Clear();
}
// Win32 interop
private const int GwlStyle = -16;
private const int GwlExstyle = -20;
private const int WsOverlappedwindow = 0x00CF0000;
private const int WsPopup = unchecked((int)0x80000000);
private const int WsExTransparent = 0x00000020;
private const int WsExToolwindow = 0x00000080;
private const int WsExTopmost = 0x00000008;
private const int SwpNoactivate = 0x0010;
private const int SwpShowwindow = 0x0040;
private static readonly IntPtr HwndTopmost = new IntPtr(-1);
[DllImport("user32.dll")]
private static extern int GetWindowLong(IntPtr hWnd, int nIndex);
[DllImport("user32.dll")]
private static extern int SetWindowLong(IntPtr hWnd, int nIndex, int dwNewLong);
[DllImport("user32.dll")]
[return: MarshalAs(UnmanagedType.Bool)]
private static extern bool SetWindowPos(IntPtr hWnd, IntPtr hWndInsertAfter, int x, int y, int cx, int cy, uint uFlags);
[DllImport("dwmapi.dll")]
private static extern int DwmSetWindowAttribute(IntPtr hwnd, int attr, ref int attrValue, int attrSize);
[DllImport("dwmapi.dll")]
private static extern int DwmExtendFrameIntoClientArea(IntPtr hwnd, ref Margins margins);
private delegate bool MonitorEnumProc(IntPtr hMonitor, IntPtr hdc, ref Rect lprcMonitor, IntPtr dwData);
[DllImport("user32.dll")]
[return: MarshalAs(UnmanagedType.Bool)]
private static extern bool EnumDisplayMonitors(IntPtr hdc, IntPtr lprcClip, MonitorEnumProc lpfnEnum, IntPtr dwData);
[StructLayout(LayoutKind.Sequential)]
private struct Rect
{
public int Left;
public int Top;
public int Right;
public int Bottom;
}
[StructLayout(LayoutKind.Sequential)]
private struct Margins
{
public int Left;
public int Right;
public int Top;
public int Bottom;
}
}
}

View File

@@ -0,0 +1,48 @@
<?xml version="1.0" encoding="utf-8" ?>
<Window
x:Class="WorkspacesEditor.Views.SnapshotWindow"
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="Snapshot Creator"
mc:Ignorable="d">
<Grid Margin="4">
<Grid.RowDefinitions>
<RowDefinition Height="*" />
<RowDefinition Height="Auto" />
</Grid.RowDefinitions>
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*" />
<ColumnDefinition Width="*" />
</Grid.ColumnDefinitions>
<ContentControl
Grid.Row="0"
Grid.ColumnSpan="2"
Margin="8"
VerticalAlignment="Center"
IsTabStop="True"
AutomationProperties.Name="{Binding Text, ElementName=DescriptionText}">
<TextBlock
x:Name="DescriptionText"
AutomationProperties.AccessibilityView="Raw"
HorizontalTextAlignment="Center"
TextWrapping="Wrap" />
</ContentControl>
<Button
x:Name="SnapshotButton"
Grid.Row="1"
Margin="8,8,4,8"
HorizontalAlignment="Stretch"
Click="SnapshotButtonClicked"
Style="{ThemeResource AccentButtonStyle}" />
<Button
x:Name="CancelButton"
Grid.Row="1"
Grid.Column="1"
Margin="4,8,8,8"
HorizontalAlignment="Stretch"
Click="CancelButtonClicked" />
</Grid>
</Window>

View File

@@ -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 CommunityToolkit.Mvvm.Messaging;
using Microsoft.UI;
using Microsoft.UI.Windowing;
using Microsoft.UI.Xaml;
using WinRT.Interop;
using WorkspacesEditor.Helpers;
using WorkspacesEditor.Messages;
namespace WorkspacesEditor.Views
{
public sealed partial class SnapshotWindow : Window
{
private bool _captured;
public SnapshotWindow()
{
this.InitializeComponent();
this.Title = ResourceLoaderInstance.ResourceLoader?.GetString("SnapshotWindowTitle") ?? "Snapshot Creator";
string description = ResourceLoaderInstance.ResourceLoader?.GetString("SnapshotDescription") ?? "Edit your layout and click \"Capture\" when finished.";
DescriptionText.Text = description;
string captureText = ResourceLoaderInstance.ResourceLoader?.GetString("Take_Snapshot") ?? "Capture";
SnapshotButton.Content = captureText;
Microsoft.UI.Xaml.Automation.AutomationProperties.SetName(SnapshotButton, captureText);
string cancelText = ResourceLoaderInstance.ResourceLoader?.GetString("Cancel") ?? "Cancel";
CancelButton.Content = cancelText;
Microsoft.UI.Xaml.Automation.AutomationProperties.SetName(CancelButton, cancelText);
// Configure window: small, centered, no resize, topmost
var hwnd = WindowNative.GetWindowHandle(this);
var windowId = Win32Interop.GetWindowIdFromWindow(hwnd);
var appWindow = AppWindow.GetFromWindowId(windowId);
appWindow.Resize(new Windows.Graphics.SizeInt32(420, 200));
if (appWindow.Presenter is OverlappedPresenter presenter)
{
presenter.IsResizable = false;
presenter.IsMaximizable = false;
presenter.IsAlwaysOnTop = true;
}
// Center on primary display
var displayArea = DisplayArea.Primary;
var workArea = displayArea.WorkArea;
int x = workArea.X + ((workArea.Width - 420) / 2);
int y = workArea.Y + ((workArea.Height - 200) / 2);
appWindow.Move(new Windows.Graphics.PointInt32(x, y));
this.Closed += OnClosed;
// Set focus to the Capture button when window loads
this.Activated += (s, e) =>
{
var snapshotHwnd = WindowNative.GetWindowHandle(this);
SetForegroundWindow(snapshotHwnd);
SnapshotButton.Focus(Microsoft.UI.Xaml.FocusState.Programmatic);
};
// Handle Escape key to cancel
this.Content.KeyDown += (s, e) =>
{
if (e.Key == Windows.System.VirtualKey.Escape)
{
this.Close();
}
};
}
private void SnapshotButtonClicked(object sender, RoutedEventArgs e)
{
_captured = true;
this.Close();
StrongReferenceMessenger.Default.Send(new SnapshotCapturedMessage());
}
private void CancelButtonClicked(object sender, RoutedEventArgs e)
{
this.Close();
}
private void OnClosed(object sender, WindowEventArgs args)
{
if (!_captured)
{
StrongReferenceMessenger.Default.Send(new SnapshotCancelledMessage());
}
}
[System.Runtime.InteropServices.DllImport("user32.dll")]
[return: System.Runtime.InteropServices.MarshalAs(System.Runtime.InteropServices.UnmanagedType.Bool)]
private static extern bool SetForegroundWindow(IntPtr hWnd);
}
}

View File

@@ -0,0 +1,310 @@
<?xml version="1.0" encoding="utf-8" ?>
<Page
x:Class="WorkspacesEditor.Views.WorkspacesEditorPage"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:converters="using:WorkspacesEditor.Converters"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
xmlns:models="using:WorkspacesEditor.Models"
mc:Ignorable="d">
<Page.Resources>
<converters:BooleanToVisibilityConverter x:Key="BoolToVis" />
<DataTemplate x:Key="headerTemplate" x:DataType="models:MonitorHeaderRow">
<Border HorizontalAlignment="Stretch">
<TextBlock
Margin="0,16,0,8"
VerticalAlignment="Center"
FontWeight="SemiBold"
Foreground="{ThemeResource TextFillColorPrimaryBrush}"
Text="{Binding MonitorName}" />
</Border>
</DataTemplate>
<DataTemplate x:Key="appTemplate" x:DataType="models:Application">
<Border Margin="0,4,0,0">
<Expander
HorizontalAlignment="Stretch"
HorizontalContentAlignment="Stretch"
AutomationProperties.AutomationId="{Binding AppName}"
AutomationProperties.Name="{Binding AppName}"
IsEnabled="{Binding IsIncluded, Mode=OneWay}"
IsExpanded="{Binding IsExpanded, Mode=TwoWay}">
<Expander.Header>
<Grid HorizontalAlignment="Stretch">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="12" />
<ColumnDefinition Width="20" />
<ColumnDefinition Width="12" />
<ColumnDefinition Width="*" />
<ColumnDefinition Width="Auto" />
</Grid.ColumnDefinitions>
<Image
Grid.Column="1"
Width="20"
Height="20"
HorizontalAlignment="Center"
VerticalAlignment="Center"
Source="{Binding IconImage, Mode=OneWay}" />
<StackPanel Grid.Column="3" VerticalAlignment="Center">
<StackPanel Orientation="Horizontal">
<TextBlock Text="{Binding AppName}" />
<TextBlock
Margin="8,0,0,0"
VerticalAlignment="Center"
Foreground="{ThemeResource TextFillColorSecondaryBrush}"
Text="{Binding RepeatIndexString, Mode=OneWay}" />
<TextBlock
Margin="8,0,0,0"
VerticalAlignment="Center"
FontFamily="{ThemeResource SymbolThemeFontFamily}"
Foreground="{ThemeResource SystemFillColorCautionBrush}"
Text="&#xE7BA;"
Visibility="{Binding IsNotFound, Converter={StaticResource BoolToVis}, Mode=OneWay}" />
</StackPanel>
<TextBlock
FontSize="12"
Foreground="{ThemeResource AccentTextFillColorPrimaryBrush}"
Text="{Binding AppMainParams, Mode=OneWay}"
Visibility="{Binding IsAppMainParamVisible, Converter={StaticResource BoolToVis}, Mode=OneWay}" />
</StackPanel>
<Button
Grid.Column="4"
Width="Auto"
Margin="12,4"
AutomationProperties.Name="{Binding DeleteButtonAccessibleName, Mode=OneWay}"
Click="DeleteButtonClicked"
Content="{Binding DeleteButtonContent, Mode=OneWay}"
IsEnabled="True" />
</Grid>
</Expander.Header>
<Grid Margin="52,8,48,8" HorizontalAlignment="Stretch">
<Grid.RowDefinitions>
<RowDefinition Height="Auto" />
<RowDefinition Height="Auto" />
<RowDefinition Height="Auto" />
</Grid.RowDefinitions>
<CheckBox
MinWidth="12"
IsChecked="{Binding IsElevated, Mode=TwoWay}"
IsEnabled="{Binding CanLaunchElevated, Mode=OneWay}">
<TextBlock x:Uid="LaunchAsAdminLabel" />
</CheckBox>
<StackPanel
Grid.Row="1"
Margin="0,16,0,0"
Orientation="Horizontal">
<TextBlock
VerticalAlignment="Center"
Foreground="{ThemeResource TextFillColorPrimaryBrush}"
x:Uid="CliArgumentsLabel" />
<TextBox
x:Uid="CliArgsTextBox"
Margin="12,0,0,0"
MinWidth="200"
HorizontalAlignment="Stretch"
VerticalContentAlignment="Center"
Text="{Binding CommandLineArguments, Mode=TwoWay}"
TextChanged="CommandLineTextBox_TextChanged" />
</StackPanel>
<StackPanel
Grid.Row="2"
Margin="0,16,0,0"
Orientation="Horizontal"
Spacing="8">
<TextBlock
VerticalAlignment="Center"
x:Uid="WindowPositionLabel" />
<ComboBox
x:Uid="WindowPositionComboBox"
VerticalAlignment="Center"
SelectedIndex="{Binding PositionComboboxIndex, Mode=TwoWay}">
<ComboBoxItem x:Uid="CustomItem" />
<ComboBoxItem x:Uid="MaximizedItem" />
<ComboBoxItem x:Uid="MinimizedItem" />
</ComboBox>
<TextBlock VerticalAlignment="Center" x:Uid="LeftLabel" />
<TextBox
x:Uid="LeftTextBox"
MinWidth="60"
IsEnabled="{Binding EditPositionEnabled, Mode=OneWay}"
Text="{Binding Position.X, Mode=OneWay}"
TextChanged="LeftTextBox_TextChanged" />
<TextBlock VerticalAlignment="Center" x:Uid="TopLabel" />
<TextBox
x:Uid="TopTextBox"
MinWidth="60"
IsEnabled="{Binding EditPositionEnabled, Mode=OneWay}"
Text="{Binding Position.Y, Mode=OneWay}"
TextChanged="TopTextBox_TextChanged" />
<TextBlock VerticalAlignment="Center" x:Uid="WidthLabel" />
<TextBox
x:Uid="WidthTextBox"
MinWidth="60"
IsEnabled="{Binding EditPositionEnabled, Mode=OneWay}"
Text="{Binding Position.Width, Mode=OneWay}"
TextChanged="WidthTextBox_TextChanged" />
<TextBlock VerticalAlignment="Center" x:Uid="HeightLabel" />
<TextBox
x:Uid="HeightTextBox"
MinWidth="60"
IsEnabled="{Binding EditPositionEnabled, Mode=OneWay}"
Text="{Binding Position.Height, Mode=OneWay}"
TextChanged="HeightTextBox_TextChanged" />
</StackPanel>
</Grid>
</Expander>
</Border>
</DataTemplate>
<models:AppListDataTemplateSelector
x:Key="AppListDataTemplateSelector"
AppTemplate="{StaticResource appTemplate}"
HeaderTemplate="{StaticResource headerTemplate}" />
</Page.Resources>
<Grid>
<Grid.RowDefinitions>
<RowDefinition Height="Auto" />
<RowDefinition Height="Auto" />
<RowDefinition Height="Auto" />
<RowDefinition Height="*" />
</Grid.RowDefinitions>
<!-- breadcrumb + Save/Cancel -->
<Grid Margin="24,0,24,24">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*" />
<ColumnDefinition Width="Auto" />
</Grid.ColumnDefinitions>
<StackPanel VerticalAlignment="Top" Orientation="Horizontal">
<Button
AutomationProperties.Name="Back to Workspaces"
Padding="0"
VerticalAlignment="Center"
Click="CancelButtonClicked"
FontSize="24">
<TextBlock x:Name="WorkspacesBackText" Text="Workspaces" />
</Button>
<TextBlock
Margin="8,0,0,0"
VerticalAlignment="Center"
FontFamily="{ThemeResource SymbolThemeFontFamily}"
Foreground="{ThemeResource TextFillColorPrimaryBrush}"
Text="&#xE76C;" />
<TextBlock
Margin="8,0,0,0"
VerticalAlignment="Center"
FontSize="24"
FontWeight="SemiBold"
Foreground="{ThemeResource TextFillColorPrimaryBrush}"
AutomationProperties.HeadingLevel="Level1"
Text="{Binding EditorWindowTitle, Mode=OneWay}" />
</StackPanel>
<StackPanel
Grid.Column="1"
HorizontalAlignment="Right"
Orientation="Horizontal">
<Button
x:Name="SaveButton"
x:Uid="SaveBtn"
Click="SaveButtonClicked"
IsEnabled="{Binding CanBeSaved, Mode=OneWay}"
Style="{ThemeResource AccentButtonStyle}">
<StackPanel Orientation="Horizontal">
<TextBlock
VerticalAlignment="Center"
FontFamily="{ThemeResource SymbolThemeFontFamily}"
Foreground="{ThemeResource TextOnAccentFillColorPrimaryBrush}"
Text="&#xE74E;" />
<TextBlock
x:Name="SaveText"
Margin="8,0,0,0"
VerticalAlignment="Center"
Foreground="{ThemeResource TextOnAccentFillColorPrimaryBrush}"
Text="Save" />
</StackPanel>
</Button>
<Button
x:Name="CancelButton"
Margin="8,0,0,0"
Click="CancelButtonClicked">
<TextBlock x:Name="CancelText" Text="Cancel" />
</Button>
</StackPanel>
</Grid>
<!-- properties -->
<StackPanel
Grid.Row="1"
Margin="24,0,24,0"
Orientation="Vertical">
<TextBlock
Margin="0,0,0,8"
FontWeight="SemiBold"
Foreground="{ThemeResource TextFillColorPrimaryBrush}">
<Run x:Name="WorkspaceNameLabel" Text="Workspace name" />
</TextBlock>
<StackPanel Orientation="Horizontal" Spacing="24">
<TextBox
x:Name="EditNameTextBox"
x:Uid="EditNameTextBox"
Width="300"
HorizontalAlignment="Left"
GotFocus="EditNameTextBox_GotFocus"
KeyDown="EditNameTextBoxKeyDown"
Text="{Binding Name, Mode=TwoWay, UpdateSourceTrigger=PropertyChanged}"
TextChanged="EditNameTextBox_TextChanged" />
<CheckBox
VerticalAlignment="Bottom"
IsChecked="{Binding IsShortcutNeeded, Mode=TwoWay}">
<TextBlock x:Name="CreateShortcutLabel" Text="Create desktop shortcut" />
</CheckBox>
<CheckBox
VerticalAlignment="Bottom"
IsChecked="{Binding MoveExistingWindows, Mode=TwoWay}">
<TextBlock x:Name="MoveIfExistLabel" Text="Move existing windows" />
</CheckBox>
</StackPanel>
</StackPanel>
<!-- Launch&Edit / Revert -->
<StackPanel
Grid.Row="2"
Margin="24,16,24,0"
HorizontalAlignment="Right"
Orientation="Horizontal"
Spacing="8">
<Button
x:Name="LaunchEditButton"
Click="LaunchEditButtonClicked">
<TextBlock x:Name="LaunchEditText" Text="Launch &amp; edit" />
</Button>
<Button
x:Name="RevertButton"
Click="RevertButtonClicked"
IsEnabled="{Binding IsRevertEnabled, Mode=OneWay}">
<TextBlock x:Name="RevertText" Text="Revert" />
</Button>
</StackPanel>
<!-- app list -->
<ScrollViewer
Grid.Row="3"
Margin="0,24,0,0"
VerticalScrollBarVisibility="Auto">
<StackPanel Margin="24,0,24,24" Orientation="Vertical">
<ItemsControl
x:Name="CapturedAppList"
x:Uid="CapturedAppListControl"
ItemTemplateSelector="{StaticResource AppListDataTemplateSelector}"
ItemsSource="{Binding ApplicationsListed, Mode=OneWay}" />
</StackPanel>
</ScrollViewer>
</Grid>
</Page>

View File

@@ -0,0 +1,215 @@
// 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.ComponentModel;
using System.Linq;
using Microsoft.UI.Xaml;
using Microsoft.UI.Xaml.Controls;
using Microsoft.UI.Xaml.Input;
using Microsoft.UI.Xaml.Navigation;
using Windows.System;
using WorkspacesCsharpLibrary.Data;
using WorkspacesEditor.Helpers;
using WorkspacesEditor.ViewModels;
using Application = WorkspacesEditor.Models.Application;
using Project = WorkspacesEditor.Models.Project;
namespace WorkspacesEditor.Views
{
public sealed partial class WorkspacesEditorPage : Page
{
private MainViewModel _mainViewModel;
public WorkspacesEditorPage()
{
this.InitializeComponent();
SetLocalizedStrings();
this.KeyDown += (s, e) =>
{
if (e.Key == Windows.System.VirtualKey.Escape)
{
TempProjectData.DeleteTempFile();
_mainViewModel?.SwitchToMainView();
e.Handled = true;
}
};
}
protected override void OnNavigatedTo(NavigationEventArgs e)
{
base.OnNavigatedTo(e);
if (e.Parameter is (MainViewModel vm, Project project))
{
_mainViewModel = vm;
this.DataContext = project;
// Set focus to the name field so Narrator announces the page context
this.Loaded += (s, args) => EditNameTextBox.Focus(Microsoft.UI.Xaml.FocusState.Programmatic);
}
}
private void SetLocalizedStrings()
{
WorkspacesBackText.Text = ResourceLoaderInstance.ResourceLoader?.GetString("Workspaces") ?? "Workspaces";
SaveText.Text = ResourceLoaderInstance.ResourceLoader?.GetString("Save_Workspace") ?? "Save";
CancelText.Text = ResourceLoaderInstance.ResourceLoader?.GetString("Cancel") ?? "Cancel";
WorkspaceNameLabel.Text = ResourceLoaderInstance.ResourceLoader?.GetString("WorkspaceName") ?? "Workspace name";
CreateShortcutLabel.Text = ResourceLoaderInstance.ResourceLoader?.GetString("CreateShortcut") ?? "Create desktop shortcut";
MoveIfExistLabel.Text = ResourceLoaderInstance.ResourceLoader?.GetString("MoveIfExist") ?? "Move existing windows";
LaunchEditText.Text = ResourceLoaderInstance.ResourceLoader?.GetString("LaunchEdit") ?? "Launch & edit";
RevertText.Text = ResourceLoaderInstance.ResourceLoader?.GetString("Revert") ?? "Revert";
}
private void SaveButtonClicked(object sender, RoutedEventArgs e)
{
if (this.DataContext is Project projectToSave)
{
projectToSave.CloseExpanders();
if (_mainViewModel.Workspaces.Any(x => x.Id == projectToSave.Id))
{
_mainViewModel.SaveProject(projectToSave);
}
else
{
_mainViewModel.AddNewProject(projectToSave);
}
_mainViewModel.SwitchToMainView();
}
}
private void CancelButtonClicked(object sender, RoutedEventArgs e)
{
TempProjectData.DeleteTempFile();
_mainViewModel.SwitchToMainView();
}
private void DeleteButtonClicked(object sender, RoutedEventArgs e)
{
if (sender is FrameworkElement element && element.DataContext is Application app)
{
app.SwitchDeletion();
}
}
private void EditNameTextBoxKeyDown(object sender, KeyRoutedEventArgs e)
{
if (e.Key == VirtualKey.Enter)
{
e.Handled = true;
if (this.DataContext is Project project && sender is TextBox textBox)
{
project.Name = textBox.Text;
}
}
else if (e.Key == VirtualKey.Escape)
{
e.Handled = true;
if (this.DataContext is Project project)
{
_mainViewModel.CancelProjectName(project);
}
}
}
private void EditNameTextBox_GotFocus(object sender, RoutedEventArgs e)
{
_mainViewModel.SaveProjectName(DataContext as Project);
}
private void EditNameTextBox_TextChanged(object sender, TextChangedEventArgs e)
{
if (this.DataContext is Project project && sender is TextBox textBox)
{
project.Name = textBox.Text;
}
}
private void LeftTextBox_TextChanged(object sender, TextChangedEventArgs e)
{
if (sender is TextBox textBox && textBox.DataContext is Application app)
{
if (!int.TryParse(textBox.Text, out int newPos))
{
newPos = 0;
}
app.Position = new Application.WindowPosition() { X = newPos, Y = app.Position.Y, Width = app.Position.Width, Height = app.Position.Height };
app.Parent.IsPositionChangedManually = true;
app.Parent.InitializePreview();
}
}
private void TopTextBox_TextChanged(object sender, TextChangedEventArgs e)
{
if (sender is TextBox textBox && textBox.DataContext is Application app)
{
if (!int.TryParse(textBox.Text, out int newPos))
{
newPos = 0;
}
app.Position = new Application.WindowPosition() { X = app.Position.X, Y = newPos, Width = app.Position.Width, Height = app.Position.Height };
app.Parent.IsPositionChangedManually = true;
app.Parent.InitializePreview();
}
}
private void WidthTextBox_TextChanged(object sender, TextChangedEventArgs e)
{
if (sender is TextBox textBox && textBox.DataContext is Application app)
{
if (!int.TryParse(textBox.Text, out int newPos))
{
newPos = 0;
}
app.Position = new Application.WindowPosition() { X = app.Position.X, Y = app.Position.Y, Width = newPos, Height = app.Position.Height };
app.Parent.IsPositionChangedManually = true;
app.Parent.InitializePreview();
}
}
private void HeightTextBox_TextChanged(object sender, TextChangedEventArgs e)
{
if (sender is TextBox textBox && textBox.DataContext is Application app)
{
if (!int.TryParse(textBox.Text, out int newPos))
{
newPos = 0;
}
app.Position = new Application.WindowPosition() { X = app.Position.X, Y = app.Position.Y, Width = app.Position.Width, Height = newPos };
app.Parent.IsPositionChangedManually = true;
app.Parent.InitializePreview();
}
}
private void CommandLineTextBox_TextChanged(object sender, TextChangedEventArgs e)
{
if (sender is TextBox textBox && textBox.DataContext is Application app)
{
app.CommandLineTextChanged(textBox.Text);
}
}
private void LaunchEditButtonClicked(object sender, RoutedEventArgs e)
{
if (this.DataContext is Project project)
{
_ = _mainViewModel.LaunchAndEditAsync(project);
}
}
private void RevertButtonClicked(object sender, RoutedEventArgs e)
{
_mainViewModel.RevertLaunch();
}
}
}

View File

@@ -4,31 +4,39 @@
<Import Project="$(RepoRoot)src\Common.SelfContained.props" />
<PropertyGroup>
<AssemblyTitle>PowerToys.WorkspacesEditor</AssemblyTitle>
<AssemblyDescription>PowerToys Workspaces Editor</AssemblyDescription>
<Description>PowerToys Workspaces Editor</Description>
<OutputType>WinExe</OutputType>
<UseWPF>true</UseWPF>
<UseWindowsForms>true</UseWindowsForms>
<RootNamespace>WorkspacesEditor</RootNamespace>
<ApplicationManifest>app.manifest</ApplicationManifest>
<UseWinUI>true</UseWinUI>
<Platforms>x64;ARM64</Platforms>
<EnableMsixTooling>true</EnableMsixTooling>
<EnablePreviewMsixTooling>true</EnablePreviewMsixTooling>
<WindowsPackageType>None</WindowsPackageType>
<AppendTargetFrameworkToOutputPath>false</AppendTargetFrameworkToOutputPath>
<AppendRuntimeIdentifierToOutputPath>false</AppendRuntimeIdentifierToOutputPath>
<GenerateSatelliteAssembliesForCore>true</GenerateSatelliteAssembliesForCore>
<OutputPath>$(RepoRoot)$(Platform)\$(Configuration)</OutputPath>
</PropertyGroup>
<PropertyGroup>
<ProjectGuid>{367D7543-7DBA-4381-99F1-BF6142A996C4}</ProjectGuid>
<ProjectTypeGuids>{60dc8134-eba5-43b8-bcc9-bb4bc16c2548};{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}</ProjectTypeGuids>
<AutoGenerateBindingRedirects>true</AutoGenerateBindingRedirects>
</PropertyGroup>
<PropertyGroup>
<ApplicationIcon>..\Assets\Workspaces\Workspaces.ico</ApplicationIcon>
</PropertyGroup>
<PropertyGroup>
<ApplicationManifest>app.manifest</ApplicationManifest>
<WindowsAppSDKSelfContained>true</WindowsAppSDKSelfContained>
<DefineConstants>DISABLE_XAML_GENERATED_MAIN,TRACE</DefineConstants>
<OutputPath>$(RepoRoot)$(Platform)\$(Configuration)\WinUI3Apps</OutputPath>
<AssemblyName>PowerToys.WorkspacesEditor</AssemblyName>
<ApplicationIcon>..\Assets\Workspaces\Workspaces.ico</ApplicationIcon>
<ProjectPriFileName>PowerToys.WorkspacesEditor.pri</ProjectPriFileName>
<GenerateSatelliteAssembliesForCore>true</GenerateSatelliteAssembliesForCore>
</PropertyGroup>
<ItemGroup>
<Page Remove="Views\App.xaml" />
</ItemGroup>
<ItemGroup>
<ApplicationDefinition Include="Views\App.xaml" />
</ItemGroup>
<!-- See https://learn.microsoft.com/windows/apps/develop/platform/csharp-winrt/net-projection-from-cppwinrt-component for more info -->
<PropertyGroup>
<CsWinRTIncludes>PowerToys.GPOWrapper</CsWinRTIncludes>
<CsWinRTGeneratedFilesDir>$(OutDir)</CsWinRTGeneratedFilesDir>
<ErrorOnDuplicatePublishOutputFiles>false</ErrorOnDuplicatePublishOutputFiles>
</PropertyGroup>
<ItemGroup>
<COMReference Include="IWshRuntimeLibrary">
<WrapperTool>tlbimp</WrapperTool>
@@ -49,59 +57,30 @@
<EmbedInteropTypes>true</EmbedInteropTypes>
</COMReference>
</ItemGroup>
<ItemGroup>
<Content Include="..\Assets\**\*.*">
<Link>Assets\Workspaces\%(Filename)%(Extension)</Link>
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</Content>
</ItemGroup>
<ItemGroup>
<EmbeddedResource Update="Properties\Resources.resx">
<Generator>PublicResXFileCodeGenerator</Generator>
<LastGenOutput>Resources.Designer.cs</LastGenOutput>
</EmbeddedResource>
<None Include="app.manifest" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="System.IO.Abstractions" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\..\common\Common.UI\Common.UI.csproj" />
<ProjectReference Include="..\..\..\common\GPOWrapperProjection\GPOWrapperProjection.csproj" />
<ProjectReference Include="..\..\..\common\interop\PowerToys.Interop.vcxproj" />
<ProjectReference Include="..\..\..\common\GPOWrapper\GPOWrapper.vcxproj" />
<ProjectReference Include="..\..\..\common\ManagedCommon\ManagedCommon.csproj" />
<ProjectReference Include="..\..\..\common\ManagedTelemetry\Telemetry\ManagedTelemetry.csproj" />
<ProjectReference Include="..\..\..\common\interop\PowerToys.Interop.vcxproj" />
<ProjectReference Include="..\..\..\settings-ui\Settings.UI.Library\Settings.UI.Library.csproj" />
<ProjectReference Include="..\WorkspacesCsharpLibrary\WorkspacesCsharpLibrary.csproj" />
</ItemGroup>
<ItemGroup>
<Compile Remove="Data\WorkspacesData.cs" />
<Compile Remove="Data\ProjectData.cs" />
<Compile Remove="Data\WorkspacesEditorData`1.cs" />
<Compile Remove="Utils\IOUtils.cs" />
<Compile Remove="Utils\FolderUtils.cs" />
<Compile Remove="Utils\DashCaseNamingPolicy.cs" />
</ItemGroup>
<ItemGroup>
<Compile Update="Properties\Resources.Designer.cs">
<DesignTime>True</DesignTime>
<AutoGen>True</AutoGen>
<DependentUpon>Resources.resx</DependentUpon>
</Compile>
<Compile Update="Properties\Settings.Designer.cs">
<DesignTimeSharedInput>True</DesignTimeSharedInput>
<AutoGen>True</AutoGen>
<DependentUpon>Settings.settings</DependentUpon>
</Compile>
</ItemGroup>
<ItemGroup>
<None Update="Properties\Settings.settings">
<Generator>SettingsSingleFileGenerator</Generator>
<LastGenOutput>Settings.Designer.cs</LastGenOutput>
</None>
<PackageReference Include="CommunityToolkit.Mvvm" />
<PackageReference Include="Microsoft.WindowsAppSDK" />
<PackageReference Include="Microsoft.Windows.SDK.BuildTools" />
<PackageReference Include="System.Drawing.Common" />
<PackageReference Include="System.IO.Abstractions" />
</ItemGroup>
</Project>

View File

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

View File

@@ -1,33 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<configuration>
<configSections>
<sectionGroup name="userSettings" type="System.Configuration.UserSettingsGroup, System, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089" >
<section name="WorkspacesEditor.Properties.Settings" type="System.Configuration.ClientSettingsSection, System, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089" allowExeDefinition="MachineToLocalUser" requirePermission="false" />
</sectionGroup>
</configSections>
<startup>
<supportedRuntime version="v4.0" sku=".NETFramework,Version=v4.7.2"/>
</startup>
<runtime>
<AppContextSwitchOverrides value="Switch.System.Windows.DoNotScaleForDpiChanges=false" />
</runtime>
<userSettings>
<WorkspacesEditor.Properties.Settings>
<setting name="Top" serializeAs="String">
<value>-1</value>
</setting>
<setting name="Left" serializeAs="String">
<value>-1</value>
</setting>
<setting name="Height" serializeAs="String">
<value>-1</value>
</setting>
<setting name="Width" serializeAs="String">
<value>-1</value>
</setting>
<setting name="Maximized" serializeAs="String">
<value>False</value>
</setting>
</WorkspacesEditor.Properties.Settings>
</userSettings>
</configuration>

View File

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

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