Compare commits

...

76 Commits

Author SHA1 Message Date
Niels Laute
a8f6027665 Merge branch 'workspaces-editor-winui-migration' of https://github.com/microsoft/PowerToys into workspaces-editor-winui-migration
# Conflicts:
#	src/modules/Workspaces/WorkspacesEditor.WinUI/Views/MainPage.xaml
#	src/modules/Workspaces/WorkspacesEditor.WinUI/Views/MainWindow.xaml
#	src/modules/Workspaces/WorkspacesEditor.WinUI/Views/SnapshotWindow.xaml
#	src/modules/Workspaces/WorkspacesEditor.WinUI/Views/WorkspacesEditorPage.xaml
#	src/modules/Workspaces/WorkspacesEditor.WinUI/WorkspacesEditor.WinUI.csproj
2026-07-02 19:51:34 +02:00
Niels Laute
3ae3fe52d3 Refactor and UI revamp 2026-07-02 19:42:14 +02:00
chatasweetie
d7d99290cc Fix UI test build: add project reference to WorkspacesEditor.WinUI 2026-07-02 10:34:27 -07:00
chatasweetie
4288d88e85 Replace custom bool converters with CommunityToolkit.WinUI.Converters 2026-07-02 09:27:02 -07:00
chatasweetie
166d2abb90 apply XamlStyler formatting 2026-07-02 09:27:01 -07:00
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
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
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
chatasweetie
bcf3c98c8a eliminate side effects and external INPC callers - IconImage+ 2026-06-29 11:44:49 -07: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
chatasweetie
606e03b085 replace System.Timers.Timer with DispatcherTimer 2026-06-26 15:40:37 -07:00
chatasweetie
d7b8fe006d decouple ViewModel from Views via messaging and revert to WorkspacesEditorPage to {Binding} 2026-06-25 16:01:17 -07: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
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
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
122 changed files with 5473 additions and 4876 deletions

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

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

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

@@ -5,6 +5,8 @@
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;
@@ -37,6 +39,18 @@ namespace Microsoft.PowerToys.Common.UI.Controls.Window;
/// <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
{
@@ -52,6 +66,9 @@ public partial class TransparentWindow : WinUIEx.WindowEx
private readonly nint _hwnd;
private bool _inputHooked;
private bool _seenActivated;
public TransparentWindow()
{
AppWindow.Hide();
@@ -74,8 +91,30 @@ public partial class TransparentWindow : WinUIEx.WindowEx
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,
@@ -112,6 +151,8 @@ public partial class TransparentWindow : WinUIEx.WindowEx
DispatcherQueuePriority.Low,
() =>
{
_seenActivated = false;
EnsureInputHooks();
_ = ShowWindow(_hwnd, SwShowNa);
Showing?.Invoke(this, new ShowingEventArgs(transition));
});
@@ -134,6 +175,41 @@ public partial class TransparentWindow : WinUIEx.WindowEx
});
}
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)

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

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,27 +1,29 @@
// 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.Data;
namespace WorkspacesEditor.Converters
{
public class BooleanToInvertedVisibilityConverter : IValueConverter
public partial class IndexToBooleanConverter : 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 int index &&
parameter is string param &&
int.TryParse(param, NumberStyles.Integer, CultureInfo.InvariantCulture, out int target))
{
return Visibility.Collapsed;
return index == target;
}
return Visibility.Visible;
return false;
}
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,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,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 oldValue, bool newValue)
{
get => _isIncluded;
set
if (!newValue)
{
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>
@@ -157,6 +51,12 @@
<data name="CreateShortcut" xml:space="preserve">
<value>Create desktop shortcut</value>
</data>
<data name="CreateShortcutCheckBox.Content" xml:space="preserve">
<value>Create desktop shortcut</value>
</data>
<data name="MoveExistingWindowsCheckBox.Content" xml:space="preserve">
<value>Move existing windows</value>
</data>
<data name="Custom" xml:space="preserve">
<value>Custom</value>
</data>
@@ -169,6 +69,12 @@
<data name="Delete" xml:space="preserve">
<value>Remove</value>
</data>
<data name="DeleteWorkspaceButton.[using:Microsoft.UI.Xaml.Automation]AutomationProperties.Name" xml:space="preserve">
<value>Delete workspace</value>
</data>
<data name="DeleteWorkspaceButtonToolTip.Text" xml:space="preserve">
<value>Delete workspace</value>
</data>
<data name="DeleteSelected" xml:space="preserve">
<value>Remove selected apps</value>
</data>
@@ -178,6 +84,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>
@@ -205,12 +114,17 @@
<data name="LaunchEdit" xml:space="preserve">
<value>Launch &amp; edit</value>
</data>
<data name="LaunchEditLabel.Text" xml:space="preserve">
<value>Launch &amp; edit</value>
</data>
<data name="LaunchEditButton.[using:Microsoft.UI.Xaml.Automation]AutomationProperties.Name" xml:space="preserve">
<value>Launch and edit</value>
</data>
<data name="Launch_args" xml:space="preserve">
<value>Launch args</value>
</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 +144,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>
@@ -281,15 +198,30 @@
<data name="Revert" xml:space="preserve">
<value>Revert</value>
</data>
<data name="RevertButton.[using:Microsoft.UI.Xaml.Automation]AutomationProperties.Name" xml:space="preserve">
<value>Revert</value>
</data>
<data name="RevertButtonToolTip.Text" xml:space="preserve">
<value>Revert</value>
</data>
<data name="Save_Workspace" xml:space="preserve">
<value>Save</value>
</data>
<data name="SaveLabel.Text" xml:space="preserve">
<value>Save</value>
</data>
<data name="Search" xml:space="preserve">
<value>Search</value>
</data>
<data name="SearchExplanation" xml:space="preserve">
<value>Search for Workspaces or apps</value>
</data>
<data name="SearchBox.PlaceholderText" xml:space="preserve">
<value>Search for Workspaces or apps</value>
</data>
<data name="SearchBox.AutomationProperties.Name" xml:space="preserve">
<value>Search workspaces</value>
</data>
<data name="SecondsAgo" xml:space="preserve">
<value>seconds ago</value>
</data>
@@ -302,21 +234,23 @@
<data name="SelectedAllInWorkspace" xml:space="preserve">
<value>Select all apps in Workspace</value>
</data>
<data name="SnapshotDescription" xml:space="preserve">
<data name="SnapshotDescription.Text" xml:space="preserve">
<value>Edit your layout and click "Capture" when finished.</value>
</data>
<data name="SnapshotButton.Content" xml:space="preserve">
<value>Capture</value>
</data>
<data name="SnapshotCancelButton.Content" xml:space="preserve">
<value>Cancel</value>
</data>
<data name="SnapshotWindowTitle" xml:space="preserve">
<value>Snapshot Creator</value>
</data>
<data name="SortBy" xml:space="preserve">
<value>Sort by</value>
</data>
<data name="Take_Snapshot" xml:space="preserve">
<value>Capture</value>
</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 +267,106 @@
<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="SortLastLaunchedItem.Text" xml:space="preserve">
<value>Last launched</value>
</data>
<data name="SortCreatedItem.Text" xml:space="preserve">
<value>Created</value>
</data>
<data name="SortNameItem.Text" 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.Content" xml:space="preserve">
<value>Launch as Admin</value>
</data>
<data name="PositionHeader.Header" xml:space="preserve">
<value>Position</value>
</data>
<data name="LaunchBtn.[using:Microsoft.UI.Xaml.Automation]AutomationProperties.Name" xml:space="preserve">
<value>Launch</value>
</data>
<data name="LaunchLabel.Text" 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="CreateWorkspaceLabel.Text" xml:space="preserve">
<value>Create Workspace</value>
</data>
<data name="SortByButton.[using:Microsoft.UI.Xaml.Automation]AutomationProperties.Name" xml:space="preserve">
<value>Sort by</value>
</data>
<data name="SortByButtonToolTip.Text" 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.Header" xml:space="preserve">
<value>CLI arguments</value>
</data>
<data name="WindowPositionComboBox.Header" 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,570 @@
// 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()
{
return Workspaces;
}
/// <summary>
/// Returns the workspaces matching the given query (by workspace name or contained app name),
/// used to populate the title bar search suggestions. Returns an empty list for an empty query.
/// </summary>
public IEnumerable<Project> SearchWorkspaces(string query)
{
if (Workspaces == null || string.IsNullOrWhiteSpace(query))
{
return Enumerable.Empty<Project>();
}
return Workspaces
.Where(x =>
{
if (x.Name != null && x.Name.Contains(query, StringComparison.InvariantCultureIgnoreCase))
{
return true;
}
if (x.Applications == null)
{
return false;
}
return x.Applications.Any(app => app.AppName.Contains(query, StringComparison.InvariantCultureIgnoreCase));
})
.OrderBy(x => x.Name)
.ToList();
}
[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());
_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,14 @@
<?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>
</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,165 @@
<?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"
xmlns:tkcontrols="using:CommunityToolkit.WinUI.Controls"
xmlns:toolkit="using:CommunityToolkit.WinUI.Converters"
mc:Ignorable="d">
<Page.Resources>
<toolkit:BoolToVisibilityConverter x:Key="BoolToVis" />
<toolkit:BoolToVisibilityConverter
x:Key="BooleanToInvertedVisibilityConverter"
FalseValue="Visible"
TrueValue="Collapsed" />
<converters:IndexToBooleanConverter x:Key="IndexToBool" />
</Page.Resources>
<Grid>
<Grid.RowDefinitions>
<RowDefinition Height="Auto" />
<RowDefinition Height="*" />
</Grid.RowDefinitions>
<Grid MaxWidth="1000" Margin="24">
<!-- create button -->
<Button
x:Name="NewProjectButton"
x:Uid="CreateWorkspaceBtn"
HorizontalAlignment="Left"
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:Uid="CreateWorkspaceLabel"
Margin="8,0,0,0"
Foreground="{ThemeResource TextOnAccentFillColorPrimaryBrush}" />
</StackPanel>
</Button>
<!-- sort -->
<Button
x:Uid="SortByButton"
HorizontalAlignment="Right"
VerticalAlignment="Center"
Style="{StaticResource SubtleButtonStyle}">
<ToolTipService.ToolTip>
<TextBlock x:Uid="SortByButtonToolTip" />
</ToolTipService.ToolTip>
<FontIcon FontSize="16" Glyph="&#xE8CB;" />
<Button.Flyout>
<MenuFlyout>
<RadioMenuFlyoutItem
x:Uid="SortLastLaunchedItem"
Click="SortByLastLaunched_Click"
GroupName="WorkspacesSortOrder"
IsChecked="{x:Bind ViewModel.OrderByIndex, Mode=OneWay, Converter={StaticResource IndexToBool}, ConverterParameter=0}" />
<RadioMenuFlyoutItem
x:Uid="SortCreatedItem"
Click="SortByCreated_Click"
GroupName="WorkspacesSortOrder"
IsChecked="{x:Bind ViewModel.OrderByIndex, Mode=OneWay, Converter={StaticResource IndexToBool}, ConverterParameter=1}" />
<RadioMenuFlyoutItem
x:Uid="SortNameItem"
Click="SortByName_Click"
GroupName="WorkspacesSortOrder"
IsChecked="{x:Bind ViewModel.OrderByIndex, Mode=OneWay, Converter={StaticResource IndexToBool}, ConverterParameter=2}" />
</MenuFlyout>
</Button.Flyout>
</Button>
</Grid>
<Grid Grid.Row="1">
<!-- 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 -->
<ScrollViewer
HorizontalAlignment="Stretch"
VerticalScrollBarVisibility="Auto"
Visibility="{x:Bind ViewModel.IsWorkspacesViewEmpty, Mode=OneWay, Converter={StaticResource BooleanToInvertedVisibilityConverter}}">
<Grid Margin="24,0,24,24">
<ItemsRepeater
x:Name="WorkspacesList"
MaxWidth="1000"
AutomationProperties.Name="Workspace list"
ItemsSource="{x:Bind ViewModel.WorkspacesView, Mode=OneWay}">
<ItemsRepeater.Layout>
<StackLayout Spacing="4" />
</ItemsRepeater.Layout>
<ItemsRepeater.ItemTemplate>
<DataTemplate x:DataType="models:Project">
<tkcontrols:SettingsCard
HorizontalAlignment="Stretch"
AutomationProperties.Name="{x:Bind Name, Mode=OneWay}"
Click="WorkspaceCardClicked"
DataContext="{x:Bind}"
IsClickEnabled="True">
<tkcontrols:SettingsCard.Header>
<TextBlock FontWeight="SemiBold" Text="{x:Bind Name, Mode=OneWay}" />
</tkcontrols:SettingsCard.Header>
<tkcontrols:SettingsCard.Description>
<Grid Margin="0,8,0,0" RowSpacing="8">
<Grid.RowDefinitions>
<RowDefinition Height="Auto" />
<RowDefinition Height="Auto" />
</Grid.RowDefinitions>
<StackPanel Orientation="Horizontal" Spacing="8">
<Image Height="16" Source="{x:Bind PreviewIcons, Mode=OneWay}" />
<TextBlock
VerticalAlignment="Center"
Foreground="{ThemeResource TextFillColorSecondaryBrush}"
Text="{x:Bind AppsCountString, Mode=OneWay}" />
</StackPanel>
<StackPanel
Grid.Row="1"
Orientation="Horizontal"
Spacing="8">
<TextBlock
VerticalAlignment="Center"
FontFamily="{ThemeResource SymbolThemeFontFamily}"
FontSize="12"
Foreground="{ThemeResource TextFillColorSecondaryBrush}"
Text="&#xE81C;" />
<TextBlock
VerticalAlignment="Center"
FontSize="12"
Foreground="{ThemeResource TextFillColorSecondaryBrush}"
Text="{x:Bind LastLaunched, Mode=OneWay}" />
</StackPanel>
</Grid>
</tkcontrols:SettingsCard.Description>
<Button x:Uid="LaunchBtn" Click="LaunchButton_Click">
<StackPanel Orientation="Horizontal" Spacing="8">
<FontIcon FontSize="16" Glyph="&#xE8A7;" />
<TextBlock x:Uid="LaunchLabel" VerticalAlignment="Center" />
</StackPanel>
</Button>
</tkcontrols:SettingsCard>
</DataTemplate>
</ItemsRepeater.ItemTemplate>
</ItemsRepeater>
</Grid>
</ScrollViewer>
</Grid>
</Grid>
</Page>

View File

@@ -0,0 +1,115 @@
// 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.Models;
using WorkspacesEditor.ViewModels;
namespace WorkspacesEditor.Views
{
public sealed partial class MainPage : Page
{
public MainViewModel ViewModel { get; private set; }
public MainPage()
{
this.InitializeComponent();
}
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 SortByLastLaunched_Click(object sender, RoutedEventArgs e)
{
ViewModel.OrderByIndex = 0;
}
private void SortByCreated_Click(object sender, RoutedEventArgs e)
{
ViewModel.OrderByIndex = 1;
}
private void SortByName_Click(object sender, RoutedEventArgs e)
{
ViewModel.OrderByIndex = 2;
}
private void WorkspaceCardClicked(object sender, RoutedEventArgs e)
{
Project project = GetProjectFromSender(sender);
if (project != null)
{
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 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,81 @@
<?xml version="1.0" encoding="utf-8" ?>
<winuiex:WindowEx
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"
xmlns:models="using:WorkspacesEditor.Models"
xmlns:winuiex="using:WinUIEx"
Title="Workspaces"
Width="1100"
Height="760"
MinWidth="750"
MinHeight="680"
mc:Ignorable="d">
<Window.SystemBackdrop>
<MicaBackdrop />
</Window.SystemBackdrop>
<Grid>
<Grid.RowDefinitions>
<RowDefinition Height="Auto" />
<RowDefinition Height="*" />
</Grid.RowDefinitions>
<TitleBar
x:Name="AppTitleBar"
BackRequested="AppTitleBar_BackRequested"
IsTabStop="False">
<TitleBar.IconSource>
<ImageIconSource ImageSource="/Assets/Workspaces/Workspaces.ico" />
</TitleBar.IconSource>
<TitleBar.Content>
<AutoSuggestBox
x:Name="SearchBox"
x:Uid="SearchBox"
MaxWidth="360"
VerticalAlignment="Center"
QueryIcon="Find"
QuerySubmitted="SearchBox_QuerySubmitted"
SuggestionChosen="SearchBox_SuggestionChosen"
TextChanged="SearchBox_TextChanged"
TextMemberPath="Name">
<AutoSuggestBox.ItemTemplate>
<DataTemplate x:DataType="models:Project">
<StackPanel
Padding="0,4"
Orientation="Vertical"
Spacing="2">
<TextBlock FontWeight="SemiBold" Text="{x:Bind Name, Mode=OneWay}" />
<TextBlock
FontSize="12"
Foreground="{ThemeResource TextFillColorSecondaryBrush}"
Text="{x:Bind AppsCountString, Mode=OneWay}" />
</StackPanel>
</DataTemplate>
</AutoSuggestBox.ItemTemplate>
</AutoSuggestBox>
</TitleBar.Content>
<TitleBar.Resources>
<!--
TitleBar.Content uses Center alignment by default, which
sizes content to its DesiredSize. Override to Stretch so
the search box fills the available area up to MaxWidth.
-->
<HorizontalAlignment x:Key="TitleBarContentHorizontalAlignment">Stretch</HorizontalAlignment>
</TitleBar.Resources>
</TitleBar>
<Grid Grid.Row="1">
<Frame x:Name="ContentFrame" />
<ProgressRing
x:Name="LoadingRing"
Width="48"
Height="48"
HorizontalAlignment="Center"
VerticalAlignment="Center"
AutomationProperties.LiveSetting="Polite"
AutomationProperties.Name="Loading"
IsActive="False"
Visibility="Collapsed" />
</Grid>
</Grid>
</winuiex:WindowEx>

View File

@@ -0,0 +1,231 @@
// 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.Linq;
using System.Runtime.InteropServices;
using System.Threading;
using CommunityToolkit.Mvvm.Messaging;
using ManagedCommon;
using Microsoft.UI.Windowing;
using Microsoft.UI.Xaml;
using Microsoft.UI.Xaml.Controls;
using WinRT.Interop;
using WinUIEx;
using WorkspacesEditor.Helpers;
using WorkspacesEditor.Messages;
using WorkspacesEditor.Models;
using WorkspacesEditor.Views;
namespace WorkspacesEditor
{
public sealed partial class MainWindow : WindowEx, IDisposable
{
private readonly CancellationTokenSource _cancellationToken = new();
public MainWindow()
{
this.InitializeComponent();
var hwnd = WindowNative.GetWindowHandle(this);
this.CenterOnScreen();
AppWindow.SetIcon("Assets/Workspaces/Workspaces.ico");
// Set title from resource or fallback
try
{
this.Title = ResourceLoaderInstance.ResourceLoader?.GetString("MainTitle") ?? "Workspaces";
}
catch
{
this.Title = "Workspaces";
}
ExtendsContentIntoTitleBar = true;
AppWindow.TitleBar.PreferredHeightOption = TitleBarHeightOption.Tall;
SetTitleBar(AppTitleBar);
AppTitleBar.Title = this.Title;
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));
SearchBox.Visibility = Visibility.Collapsed;
AppTitleBar.IsBackButtonVisible = true;
AppTitleBar.Title = m.Project.EditorWindowTitle;
});
StrongReferenceMessenger.Default.Register<GoBackMessage>(this, (r, m) =>
{
if (ContentFrame.CanGoBack)
{
ContentFrame.GoBack();
}
SearchBox.Text = string.Empty;
SearchBox.Visibility = Visibility.Visible;
AppTitleBar.IsBackButtonVisible = false;
AppTitleBar.Title = this.Title;
});
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 SearchBox_TextChanged(AutoSuggestBox sender, AutoSuggestBoxTextChangedEventArgs args)
{
if (args.Reason != AutoSuggestionBoxTextChangeReason.UserInput)
{
return;
}
sender.ItemsSource = App.MainViewModel.SearchWorkspaces(sender.Text).ToList();
}
private void SearchBox_SuggestionChosen(AutoSuggestBox sender, AutoSuggestBoxSuggestionChosenEventArgs args)
{
if (args.SelectedItem is Project project)
{
sender.Text = project.Name;
}
}
private void SearchBox_QuerySubmitted(AutoSuggestBox sender, AutoSuggestBoxQuerySubmittedEventArgs args)
{
var vm = App.MainViewModel;
var project = args.ChosenSuggestion as Project
?? vm.SearchWorkspaces(args.QueryText).FirstOrDefault();
if (project == null)
{
return;
}
sender.Text = string.Empty;
vm.CloseAllPopups();
vm.EditProject(project);
}
private void AppTitleBar_BackRequested(Microsoft.UI.Xaml.Controls.TitleBar sender, object args)
{
// Discard any in-progress edits (same behavior as the editor's Cancel), then return to the overview.
WorkspacesCsharpLibrary.Data.TempProjectData.DeleteTempFile();
App.MainViewModel.SwitchToMainView();
}
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 OnClosed(object sender, WindowEventArgs args)
{
_cancellationToken.Dispose();
(Microsoft.UI.Xaml.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;
}
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);
}
}

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,67 @@
<?xml version="1.0" encoding="utf-8" ?>
<winuiex:WindowEx
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"
xmlns:winuiex="using:WinUIEx"
Title="Snapshot Creator"
Width="386"
Height="220"
IsAlwaysOnTop="True"
IsMaximizable="False"
IsMinimizable="False"
IsResizable="False"
mc:Ignorable="d">
<winuiex:WindowEx.SystemBackdrop>
<MicaBackdrop />
</winuiex:WindowEx.SystemBackdrop>
<Grid>
<Grid.RowDefinitions>
<RowDefinition Height="Auto" />
<RowDefinition Height="*" />
</Grid.RowDefinitions>
<TitleBar x:Name="AppTitleBar" IsTabStop="False">
<TitleBar.IconSource>
<ImageIconSource ImageSource="/Assets/Workspaces/Workspaces.ico" />
</TitleBar.IconSource>
</TitleBar>
<Grid
Grid.Row="1"
Margin="16,8,16,16"
RowSpacing="12">
<Grid.RowDefinitions>
<RowDefinition Height="*" />
<RowDefinition Height="Auto" />
</Grid.RowDefinitions>
<TextBlock
x:Uid="SnapshotDescription"
Grid.Row="0"
HorizontalAlignment="Center"
VerticalAlignment="Center"
HorizontalTextAlignment="Center"
TextWrapping="Wrap" />
<Grid Grid.Row="1" ColumnSpacing="8">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*" />
<ColumnDefinition Width="*" />
</Grid.ColumnDefinitions>
<Button
x:Name="SnapshotButton"
x:Uid="SnapshotButton"
Grid.Column="0"
HorizontalAlignment="Stretch"
Click="SnapshotButtonClicked"
Style="{ThemeResource AccentButtonStyle}" />
<Button
x:Name="CancelButton"
x:Uid="SnapshotCancelButton"
Grid.Column="1"
HorizontalAlignment="Stretch"
Click="CancelButtonClicked" />
</Grid>
</Grid>
</Grid>
</winuiex:WindowEx>

View File

@@ -0,0 +1,84 @@
// 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.Windowing;
using Microsoft.UI.Xaml;
using WinRT.Interop;
using WinUIEx;
using WorkspacesEditor.Helpers;
using WorkspacesEditor.Messages;
namespace WorkspacesEditor.Views
{
public sealed partial class SnapshotWindow : WindowEx
{
private bool _captured;
public SnapshotWindow()
{
this.InitializeComponent();
this.Title = ResourceLoaderInstance.ResourceLoader?.GetString("SnapshotWindowTitle") ?? "Snapshot Creator";
AppWindow.SetIcon("Assets/Workspaces/Workspaces.ico");
// Custom title bar
ExtendsContentIntoTitleBar = true;
AppWindow.TitleBar.PreferredHeightOption = TitleBarHeightOption.Tall;
SetTitleBar(AppTitleBar);
AppTitleBar.Title = this.Title;
// Center the small dialog on screen
this.CenterOnScreen();
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,287 @@
<?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:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
xmlns:models="using:WorkspacesEditor.Models"
xmlns:tkcontrols="using:CommunityToolkit.WinUI.Controls"
xmlns:toolkit="using:CommunityToolkit.WinUI.Converters"
mc:Ignorable="d">
<Page.Resources>
<toolkit:BoolToVisibilityConverter 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="{x:Bind MonitorName, Mode=OneWay}" />
</Border>
</DataTemplate>
<DataTemplate x:Key="appTemplate" x:DataType="models:Application">
<Grid>
<tkcontrols:SettingsExpander
HorizontalAlignment="Stretch"
HorizontalContentAlignment="Stretch"
AutomationProperties.AutomationId="{x:Bind AppName, Mode=OneWay}"
AutomationProperties.Name="{x:Bind AppName, Mode=OneWay}"
IsEnabled="{x:Bind IsIncluded, Mode=OneWay}"
IsExpanded="{x:Bind IsExpanded, Mode=TwoWay}">
<tkcontrols:SettingsExpander.HeaderIcon>
<ImageIcon Source="{x:Bind IconImage, Mode=OneWay}" />
</tkcontrols:SettingsExpander.HeaderIcon>
<tkcontrols:SettingsExpander.Header>
<StackPanel Orientation="Horizontal">
<TextBlock Text="{x:Bind AppName, Mode=OneWay}" />
<TextBlock
Margin="8,0,0,0"
VerticalAlignment="Center"
Foreground="{ThemeResource TextFillColorSecondaryBrush}"
Text="{x:Bind RepeatIndexString, Mode=OneWay}" />
<TextBlock
Margin="8,0,0,0"
VerticalAlignment="Center"
FontFamily="{ThemeResource SymbolThemeFontFamily}"
Foreground="{ThemeResource SystemFillColorCautionBrush}"
Text="&#xE7BA;"
Visibility="{x:Bind IsNotFound, Converter={StaticResource BoolToVis}, Mode=OneWay}" />
</StackPanel>
</tkcontrols:SettingsExpander.Header>
<tkcontrols:SettingsExpander.Description>
<TextBlock
FontSize="12"
Foreground="{ThemeResource AccentTextFillColorPrimaryBrush}"
Text="{x:Bind AppMainParams, Mode=OneWay}"
Visibility="{x:Bind IsAppMainParamVisible, Converter={StaticResource BoolToVis}, Mode=OneWay}" />
</tkcontrols:SettingsExpander.Description>
<Button
Grid.Column="4"
Width="Auto"
Margin="12,4"
AutomationProperties.Name="{x:Bind DeleteButtonAccessibleName, Mode=OneWay}"
Click="DeleteButtonClicked"
Content="{x:Bind DeleteButtonContent, Mode=OneWay}"
IsEnabled="True" />
<tkcontrols:SettingsExpander.Items>
<tkcontrols:SettingsCard ContentAlignment="Left">
<CheckBox
x:Uid="LaunchAsAdminLabel"
MinWidth="12"
IsChecked="{x:Bind IsElevated, Mode=TwoWay}"
IsEnabled="{x:Bind CanLaunchElevated, Mode=OneWay}" />
</tkcontrols:SettingsCard>
<tkcontrols:SettingsCard x:Uid="CliArgsTextBox">
<TextBox
MinWidth="200"
Margin="12,0,0,0"
HorizontalAlignment="Stretch"
VerticalContentAlignment="Center"
Text="{x:Bind CommandLineArguments, Mode=TwoWay}"
TextChanged="CommandLineTextBox_TextChanged" />
</tkcontrols:SettingsCard>
<tkcontrols:SettingsCard x:Uid="WindowPositionComboBox">
<ComboBox VerticalAlignment="Center" SelectedIndex="{x:Bind PositionComboboxIndex, Mode=TwoWay}">
<ComboBoxItem x:Uid="CustomItem" />
<ComboBoxItem x:Uid="MaximizedItem" />
<ComboBoxItem x:Uid="MinimizedItem" />
</ComboBox>
</tkcontrols:SettingsCard>
<tkcontrols:SettingsCard x:Uid="PositionHeader" Visibility="{x:Bind EditPositionEnabled, Mode=OneWay, Converter={StaticResource BoolToVis}}">
<StackPanel Orientation="Horizontal" Spacing="8">
<TextBlock
x:Uid="LeftLabel"
VerticalAlignment="Center"
Style="{StaticResource CaptionTextBlockStyle}" />
<TextBox
x:Uid="LeftTextBox"
MinWidth="60"
Margin="0,0,8,0"
Text="{x:Bind Position.X, Mode=OneWay}"
TextChanged="LeftTextBox_TextChanged" />
<TextBlock
x:Uid="TopLabel"
VerticalAlignment="Center"
Style="{StaticResource CaptionTextBlockStyle}" />
<TextBox
x:Uid="TopTextBox"
MinWidth="60"
Margin="0,0,8,0"
Text="{x:Bind Position.Y, Mode=OneWay}"
TextChanged="TopTextBox_TextChanged" />
<TextBlock
x:Uid="WidthLabel"
VerticalAlignment="Center"
Style="{StaticResource CaptionTextBlockStyle}" />
<TextBox
x:Uid="WidthTextBox"
MinWidth="60"
Margin="0,0,8,0"
Text="{x:Bind Position.Width, Mode=OneWay}"
TextChanged="WidthTextBox_TextChanged" />
<TextBlock
x:Uid="HeightLabel"
VerticalAlignment="Center"
Style="{StaticResource CaptionTextBlockStyle}" />
<TextBox
x:Uid="HeightTextBox"
MinWidth="60"
Text="{x:Bind Position.Height, Mode=OneWay}"
TextChanged="HeightTextBox_TextChanged" />
</StackPanel>
</tkcontrols:SettingsCard>
</tkcontrols:SettingsExpander.Items>
</tkcontrols:SettingsExpander>
</Grid>
</DataTemplate>
<models:AppListDataTemplateSelector
x:Key="AppListDataTemplateSelector"
AppTemplate="{StaticResource appTemplate}"
HeaderTemplate="{StaticResource headerTemplate}" />
</Page.Resources>
<Grid>
<Grid.RowDefinitions>
<RowDefinition Height="Auto" />
<RowDefinition Height="*" />
</Grid.RowDefinitions>
<Grid MaxWidth="1000" Margin="24">
<StackPanel
HorizontalAlignment="Right"
Orientation="Horizontal"
Spacing="8">
<Button
x:Name="RevertButton"
x:Uid="RevertButton"
Click="RevertButtonClicked"
IsEnabled="{Binding IsRevertEnabled, Mode=OneWay}"
Style="{StaticResource SubtleButtonStyle}">
<ToolTipService.ToolTip>
<TextBlock x:Uid="RevertButtonToolTip" />
</ToolTipService.ToolTip>
<FontIcon FontSize="16" Glyph="&#xE7A7;" />
</Button>
<Button
x:Name="LaunchEditButton"
x:Uid="LaunchEditButton"
Click="LaunchEditButtonClicked">
<StackPanel Orientation="Horizontal" Spacing="8">
<FontIcon FontSize="16" Glyph="&#xE8A7;" />
<TextBlock x:Uid="LaunchEditLabel" VerticalAlignment="Center" />
</StackPanel>
</Button>
</StackPanel>
<StackPanel
HorizontalAlignment="Left"
Orientation="Horizontal"
Spacing="8">
<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:Uid="SaveLabel"
Margin="8,0,0,0"
VerticalAlignment="Center"
Foreground="{ThemeResource TextOnAccentFillColorPrimaryBrush}" />
</StackPanel>
</Button>
<Button
x:Name="DeleteWorkspaceButton"
x:Uid="DeleteWorkspaceButton"
Click="DeleteWorkspaceButtonClicked"
Style="{StaticResource SubtleButtonStyle}">
<ToolTipService.ToolTip>
<TextBlock x:Uid="DeleteWorkspaceButtonToolTip" />
</ToolTipService.ToolTip>
<FontIcon FontSize="16" Glyph="&#xE74D;" />
</Button>
</StackPanel>
</Grid>
<!-- app list -->
<ScrollViewer
Grid.Row="1"
HorizontalAlignment="Stretch"
VerticalScrollBarVisibility="Auto">
<Grid Margin="24">
<Grid MaxWidth="1000" RowSpacing="16">
<Grid.RowDefinitions>
<RowDefinition Height="Auto" />
<RowDefinition Height="*" />
</Grid.RowDefinitions>
<Grid
Padding="16"
HorizontalAlignment="Stretch"
Background="{ThemeResource CardBackgroundFillColorDefaultBrush}"
BorderBrush="{ThemeResource CardStrokeColorDefaultBrush}"
BorderThickness="1"
ColumnSpacing="24"
CornerRadius="{StaticResource ControlCornerRadius}">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="Auto" />
<ColumnDefinition Width="*" />
</Grid.ColumnDefinitions>
<Grid
Width="160"
Height="120"
HorizontalAlignment="Left"
VerticalAlignment="Top"
Background="{ThemeResource ControlAltFillColorTertiaryBrush}"
CornerRadius="8">
<!-- THIS IS JUST A PLACEHOLDER.. SNAPSHOT IMAGE GOES HERE -->
</Grid>
<StackPanel
Grid.Column="1"
Orientation="Vertical"
Spacing="8">
<TextBox
x:Name="EditNameTextBox"
x:Uid="EditNameTextBox"
Width="224"
HorizontalAlignment="Left"
GotFocus="EditNameTextBox_GotFocus"
KeyDown="EditNameTextBoxKeyDown"
Text="{Binding Name, Mode=TwoWay, UpdateSourceTrigger=PropertyChanged}"
TextChanged="EditNameTextBox_TextChanged" />
<CheckBox
x:Uid="CreateShortcutCheckBox"
Margin="0,4,0,0"
IsChecked="{Binding IsShortcutNeeded, Mode=TwoWay}" />
<CheckBox x:Uid="MoveExistingWindowsCheckBox" IsChecked="{Binding MoveExistingWindows, Mode=TwoWay}" />
</StackPanel>
</Grid>
<ItemsRepeater
x:Name="CapturedAppList"
x:Uid="CapturedAppListControl"
Grid.Row="1"
ItemTemplate="{StaticResource AppListDataTemplateSelector}"
ItemsSource="{Binding ApplicationsListed, Mode=OneWay}">
<ItemsRepeater.Layout>
<StackLayout Spacing="4" />
</ItemsRepeater.Layout>
</ItemsRepeater>
</Grid>
</Grid>
</ScrollViewer>
</Grid>
</Page>

View File

@@ -0,0 +1,229 @@
// 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.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();
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 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 DeleteButtonClicked(object sender, RoutedEventArgs e)
{
if (sender is FrameworkElement element && element.DataContext is Application app)
{
app.SwitchDeletion();
}
}
private async void DeleteWorkspaceButtonClicked(object sender, RoutedEventArgs e)
{
if (this.DataContext is not Project project)
{
return;
}
var dialog = new 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 = ContentDialogButton.Primary,
XamlRoot = this.XamlRoot,
};
var result = await dialog.ShowAsync();
if (result == ContentDialogResult.Primary)
{
// The edited project is a copy, so remove the matching workspace from the list by Id.
var existing = _mainViewModel.Workspaces.FirstOrDefault(x => x.Id == project.Id);
if (existing != null)
{
_mainViewModel.DeleteProject(existing);
}
TempProjectData.DeleteTempFile();
_mainViewModel.SwitchToMainView();
}
}
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,33 @@
<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="CommunityToolkit.WinUI.Controls.SettingsControls" />
<PackageReference Include="CommunityToolkit.WinUI.Converters" />
<PackageReference Include="Microsoft.WindowsAppSDK" />
<PackageReference Include="Microsoft.Windows.SDK.BuildTools" />
<PackageReference Include="System.Drawing.Common" />
<PackageReference Include="System.IO.Abstractions" />
<PackageReference Include="WinUIEx" />
</ItemGroup>
</Project>

View File

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

View File

@@ -1,173 +0,0 @@
// Copyright (c) Microsoft Corporation
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
using System;
using System.Globalization;
using System.Threading;
using System.Windows;
using ManagedCommon;
using Microsoft.PowerToys.Telemetry;
using Microsoft.Win32;
using WorkspacesEditor.Telemetry;
using WorkspacesEditor.Utils;
using WorkspacesEditor.ViewModels;
namespace WorkspacesEditor
{
/// <summary>
/// Interaction logic for App.xaml
/// </summary>
public partial class App : Application, IDisposable
{
private static Mutex _instanceMutex;
public static WorkspacesEditorIO WorkspacesEditorIO { get; private set; }
private MainWindow _mainWindow;
private MainViewModel _mainViewModel;
private bool _isDisposed;
private ETWTrace etwTrace = new ETWTrace();
public App()
{
PowerToysTelemetry.Log.WriteEvent(new WorkspacesEditorStartEvent() { TimeStamp = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds() });
WorkspacesEditorIO = new WorkspacesEditorIO();
}
private void OnStartup(object sender, StartupEventArgs e)
{
Logger.InitializeLogger("\\Workspaces\\Logs");
AppDomain.CurrentDomain.UnhandledException += OnUnhandledException;
var languageTag = LanguageHelper.LoadLanguage();
if (!string.IsNullOrEmpty(languageTag))
{
try
{
System.Threading.Thread.CurrentThread.CurrentUICulture = new CultureInfo(languageTag);
}
catch (CultureNotFoundException ex)
{
Logger.LogError("CultureNotFoundException: " + ex.Message);
}
}
const string appName = "Local\\PowerToys_Workspaces_Editor_InstanceMutex";
bool createdNew;
_instanceMutex = new Mutex(true, appName, out createdNew);
if (!createdNew)
{
Logger.LogWarning("Another instance of Workspaces Editor is already running. Exiting this instance.");
_instanceMutex = null;
Shutdown(0);
return;
}
if (PowerToys.GPOWrapperProjection.GPOWrapper.GetConfiguredWorkspacesEnabledValue() == PowerToys.GPOWrapperProjection.GpoRuleConfigured.Disabled)
{
Logger.LogWarning("Tried to start with a GPO policy setting the utility to always be disabled. Please contact your systems administrator.");
Shutdown(0);
return;
}
var args = e?.Args;
int powerToysRunnerPid;
if (args?.Length > 0)
{
_ = int.TryParse(args[0], out powerToysRunnerPid);
Logger.LogInfo($"WorkspacesEditor started from the PowerToys Runner. Runner pid={powerToysRunnerPid}");
RunnerHelper.WaitForPowerToysRunner(powerToysRunnerPid, () =>
{
Logger.LogInfo("PowerToys Runner exited. Exiting WorkspacesEditor");
Dispatcher.Invoke(Shutdown);
});
}
if (_mainViewModel == null)
{
_mainViewModel = new MainViewModel(WorkspacesEditorIO);
}
var parseResult = WorkspacesEditorIO.ParseWorkspaces(_mainViewModel);
// normal start of editor
if (_mainWindow == null)
{
_mainWindow = new MainWindow(_mainViewModel);
}
// reset main window owner to keep it on the top
_mainWindow.ShowActivated = true;
_mainWindow.Topmost = true;
_mainWindow.Show();
// we can reset topmost flag after it's opened
_mainWindow.Topmost = false;
}
public static Theme GetCurrentTheme()
{
if (SystemParameters.HighContrast)
{
return Theme.HighContrastOne;
}
try
{
var useLightTheme = Registry.GetValue(
@"HKEY_CURRENT_USER\Software\Microsoft\Windows\CurrentVersion\Themes\Personalize",
"AppsUseLightTheme",
1);
return (useLightTheme is int value && value == 0) ? Theme.Dark : Theme.Light;
}
catch
{
return Theme.Light;
}
}
private void OnExit(object sender, ExitEventArgs e)
{
if (_instanceMutex != null)
{
_instanceMutex.ReleaseMutex();
}
Dispose();
Environment.Exit(0);
}
private void OnUnhandledException(object sender, UnhandledExceptionEventArgs args)
{
Logger.LogError("Unhandled exception occurred", args.ExceptionObject as Exception);
}
protected virtual void Dispose(bool disposing)
{
if (!_isDisposed)
{
if (disposing)
{
_instanceMutex?.Dispose();
etwTrace?.Dispose();
}
_isDisposed = true;
}
}
public void Dispose()
{
// Do not change this code. Put cleanup code in 'Dispose(bool disposing)' method
Dispose(disposing: true);
GC.SuppressFinalize(this);
}
}
}

View File

@@ -1,22 +0,0 @@
// Copyright (c) Microsoft Corporation
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
using System.Windows;
using System.Windows.Controls;
namespace WorkspacesEditor.Controls
{
public class ResetIsEnabled : ContentControl
{
static ResetIsEnabled()
{
IsEnabledProperty.OverrideMetadata(
typeof(ResetIsEnabled),
new UIPropertyMetadata(
defaultValue: true,
propertyChangedCallback: (_, __) => { },
coerceValueCallback: (_, x) => x));
}
}
}

View File

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

View File

@@ -1,292 +0,0 @@
<Page
x:Class="WorkspacesEditor.MainPage"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:converters="clr-namespace:WorkspacesEditor.Converters"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:local="clr-namespace:WorkspacesEditor"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
xmlns:props="clr-namespace:WorkspacesEditor.Properties"
Title="MainPage"
d:DesignHeight="450"
d:DesignWidth="800"
mc:Ignorable="d">
<Page.Resources>
<BooleanToVisibilityConverter x:Key="BoolToVis" />
<converters:BooleanToInvertedVisibilityConverter x:Key="BooleanToInvertedVisibilityConverter" />
<Thickness x:Key="ContentDialogPadding">24,16,0,24</Thickness>
<Thickness x:Key="ContentDialogCommandSpaceMargin">0,24,24,0</Thickness>
</Page.Resources>
<Grid>
<Grid.RowDefinitions>
<RowDefinition Height="Auto" />
<RowDefinition Height="Auto" />
<RowDefinition Height="*" />
</Grid.RowDefinitions>
<!-- header + button -->
<local:HeadingTextBlock
x:Name="WorkspacesHeaderBlock"
Grid.Row="0"
Margin="24,0,48,16"
AutomationProperties.HeadingLevel="Level1"
FontSize="24"
FontWeight="SemiBold"
Foreground="{DynamicResource TextFillColorPrimaryBrush}"
Text="{x:Static props:Resources.Workspaces}" />
<Button
x:Name="NewProjectButton"
Margin="0,0,24,36"
HorizontalAlignment="Right"
VerticalAlignment="Bottom"
AutomationProperties.Name="{x:Static props:Resources.CreateWorkspace}"
Click="NewProjectButton_Click"
Style="{DynamicResource AccentButtonStyle}"
TabIndex="3">
<StackPanel Orientation="Horizontal">
<TextBlock
Margin="0,4,0,0"
AutomationProperties.Name="{x:Static props:Resources.CreateWorkspace}"
FontFamily="{DynamicResource SymbolThemeFontFamily}"
Foreground="{DynamicResource TextOnAccentFillColorPrimaryBrush}"
Text="&#xE710;" />
<TextBlock
Margin="8,0,0,0"
Foreground="{DynamicResource TextOnAccentFillColorPrimaryBrush}"
Text="{x:Static props:Resources.CreateWorkspace}" />
</StackPanel>
</Button>
<!-- search + sort -->
<StackPanel
Grid.Row="1"
Margin="24,0,0,0"
HorizontalAlignment="Left"
VerticalAlignment="Center"
Orientation="Horizontal">
<Grid>
<TextBox
x:Name="SearchTextBox"
Width="320"
Text="{Binding SearchTerm, Mode=TwoWay, UpdateSourceTrigger=PropertyChanged}"
ToolTip="{x:Static props:Resources.SearchExplanation}" />
<TextBlock
Margin="12,0,0,0"
VerticalAlignment="Center"
Foreground="{DynamicResource TextFillColorSecondaryBrush}"
IsHitTestVisible="False"
Text="{x:Static props:Resources.Search}">
<TextBlock.Style>
<Style TargetType="{x:Type TextBlock}">
<Setter Property="Visibility" Value="Collapsed" />
<Style.Triggers>
<DataTrigger Binding="{Binding Text, ElementName=SearchTextBox}" Value="">
<Setter Property="Visibility" Value="Visible" />
</DataTrigger>
</Style.Triggers>
</Style>
</TextBlock.Style>
</TextBlock>
</Grid>
<TextBlock
Margin="-48,0,34,0"
HorizontalAlignment="Left"
VerticalAlignment="Center"
AutomationProperties.Name="{x:Static props:Resources.Search}"
FontFamily="{DynamicResource SymbolThemeFontFamily}"
Foreground="{DynamicResource TextFillColorSecondaryBrush}"
IsHitTestVisible="False"
Text="&#xE71E;" />
</StackPanel>
<StackPanel
Grid.Row="1"
Margin="0,0,24,0"
HorizontalAlignment="Right"
Orientation="Horizontal">
<TextBlock
Margin="12,0,8,0"
VerticalAlignment="Center"
Foreground="{DynamicResource TextFillColorPrimaryBrush}"
Text="{x:Static props:Resources.SortBy}" />
<ComboBox MinWidth="140" SelectedIndex="{Binding OrderByIndex, Mode=TwoWay, UpdateSourceTrigger=PropertyChanged}">
<ComboBoxItem Content="{x:Static props:Resources.LastLaunched}" />
<ComboBoxItem Content="{x:Static props:Resources.Created}" />
<ComboBoxItem Content="{x:Static props:Resources.Name}" />
</ComboBox>
</StackPanel>
<!-- content -->
<TextBlock
Grid.Row="2"
HorizontalAlignment="Center"
VerticalAlignment="Center"
FontSize="16"
Foreground="{DynamicResource TextFillColorSecondaryBrush}"
Text="{Binding EmptyWorkspacesViewMessage, UpdateSourceTrigger=PropertyChanged}"
TextAlignment="Center"
Visibility="{Binding IsWorkspacesViewEmpty, Mode=OneWay, Converter={StaticResource BoolToVis}, UpdateSourceTrigger=PropertyChanged}" />
<ScrollViewer
Grid.Row="2"
Margin="0,24,0,0"
VerticalContentAlignment="Stretch"
VerticalScrollBarVisibility="Auto"
Visibility="{Binding IsWorkspacesViewEmpty, Mode=OneWay, Converter={StaticResource BooleanToInvertedVisibilityConverter}, UpdateSourceTrigger=PropertyChanged}">
<ItemsControl
x:Name="WorkspacesItemsControl"
Margin="24,0,24,24"
ItemsSource="{Binding WorkspacesView, Mode=OneWay, UpdateSourceTrigger=PropertyChanged}">
<ItemsControl.ItemsPanel>
<ItemsPanelTemplate>
<StackPanel
HorizontalAlignment="Stretch"
IsItemsHost="True"
Orientation="Vertical" />
</ItemsPanelTemplate>
</ItemsControl.ItemsPanel>
<ItemsControl.ItemTemplate>
<DataTemplate DataType="models:Project">
<Button
x:Name="EditButton"
Margin="0,4,0,0"
Padding="1"
HorizontalAlignment="Stretch"
HorizontalContentAlignment="Stretch"
AutomationProperties.Name="{x:Static props:Resources.Edit}"
Click="EditButtonClicked">
<Grid HorizontalAlignment="Stretch">
<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="{Binding Name, Mode=OneWay, UpdateSourceTrigger=PropertyChanged}" />
<StackPanel Margin="0,0,0,8" Orientation="Horizontal">
<Image Height="16" Source="{Binding PreviewIcons, Mode=OneWay, UpdateSourceTrigger=PropertyChanged}" />
<TextBlock
Margin="8,0,8,0"
VerticalAlignment="Center"
Foreground="{DynamicResource TextFillColorSecondaryBrush}"
Text="{Binding AppsCountString}" />
</StackPanel>
<StackPanel Orientation="Horizontal">
<TextBlock
Margin="0,0,8,0"
VerticalAlignment="Center"
FontFamily="{DynamicResource SymbolThemeFontFamily}"
FontSize="12"
Foreground="{DynamicResource TextFillColorSecondaryBrush}"
Text="&#xE81C;" />
<TextBlock
VerticalAlignment="Center"
FontSize="12"
Foreground="{DynamicResource TextFillColorSecondaryBrush}"
Text="{Binding LastLaunched, Mode=OneWay, UpdateSourceTrigger=PropertyChanged}" />
</StackPanel>
</StackPanel>
<StackPanel
Grid.Column="1"
Margin="12,12,12,12"
Orientation="Horizontal">
<Button
Margin="0,0,8,0"
AutomationProperties.Name="{x:Static props:Resources.Launch}"
Click="LaunchButton_Click"
Content="{x:Static props:Resources.Launch}" />
<StackPanel x:Name="WorkspaceActionGroup" Orientation="Horizontal">
<Button
x:Name="MoreButton"
Padding="8"
HorizontalAlignment="Right"
Click="MoreButton_Click"
Style="{DynamicResource SubtleButtonStyle}">
<TextBlock
FontFamily="{DynamicResource SymbolThemeFontFamily}"
Foreground="{DynamicResource TextFillColorPrimaryBrush}"
Text="&#xE712;" />
</Button>
<Popup
AllowsTransparency="True"
Closed="PopupClosed"
IsOpen="{Binding IsPopupVisible, Mode=TwoWay, UpdateSourceTrigger=PropertyChanged}"
Placement="Left"
PlacementTarget="{Binding ElementName=MoreButton}"
StaysOpen="False">
<Grid>
<Border
Background="{DynamicResource SolidBackgroundFillColorSecondaryBrush}"
BorderBrush="{DynamicResource SurfaceStrokeColorDefaultBrush}"
BorderThickness="1"
CornerRadius="8">
<StackPanel Orientation="Vertical">
<Button
Padding="8,8,24,8"
HorizontalAlignment="Stretch"
HorizontalContentAlignment="Left"
AutomationProperties.Name="{x:Static props:Resources.Edit}"
Click="EditButtonClicked"
Style="{DynamicResource SubtleButtonStyle}">
<StackPanel HorizontalAlignment="Left" Orientation="Horizontal">
<TextBlock
VerticalAlignment="Center"
AutomationProperties.Name="{x:Static props:Resources.Edit}"
FontFamily="{DynamicResource SymbolThemeFontFamily}"
Foreground="{DynamicResource TextFillColorPrimaryBrush}"
Text="&#xE70F;" />
<TextBlock
Margin="8,0,0,0"
VerticalAlignment="Center"
AutomationProperties.Name="{x:Static props:Resources.Edit}"
Foreground="{DynamicResource TextFillColorPrimaryBrush}"
Text="{x:Static props:Resources.Edit}" />
</StackPanel>
</Button>
<Rectangle
Height="1"
HorizontalAlignment="Stretch"
Fill="{DynamicResource DividerStrokeColorDefaultBrush}" />
<Button
Padding="8,8,24,8"
HorizontalAlignment="Stretch"
HorizontalContentAlignment="Left"
AutomationProperties.Name="{x:Static props:Resources.Delete}"
Click="DeleteButtonClicked"
Style="{DynamicResource SubtleButtonStyle}">
<StackPanel Orientation="Horizontal">
<TextBlock
VerticalAlignment="Center"
AutomationProperties.Name="{x:Static props:Resources.Delete}"
FontFamily="{DynamicResource SymbolThemeFontFamily}"
Foreground="{DynamicResource TextFillColorPrimaryBrush}"
Text="&#xE74D;" />
<TextBlock
Margin="8,0,0,0"
VerticalAlignment="Center"
AutomationProperties.Name="{x:Static props:Resources.Delete}"
Foreground="{DynamicResource TextFillColorPrimaryBrush}"
Text="{x:Static props:Resources.Delete}" />
</StackPanel>
</Button>
</StackPanel>
</Border>
</Grid>
</Popup>
</StackPanel>
</StackPanel>
</Grid>
</Button>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
</ScrollViewer>
</Grid>
</Page>

View File

@@ -1,76 +0,0 @@
// Copyright (c) Microsoft Corporation
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
using System.Windows;
using System.Windows.Controls;
using System.Windows.Controls.Primitives;
using ManagedCommon;
using WorkspacesEditor.Models;
using WorkspacesEditor.ViewModels;
namespace WorkspacesEditor
{
/// <summary>
/// Interaction logic for MainPage.xaml
/// </summary>
public partial class MainPage : Page
{
private MainViewModel _mainViewModel;
public MainPage(MainViewModel mainViewModel)
{
InitializeComponent();
_mainViewModel = mainViewModel;
this.DataContext = _mainViewModel;
}
private /*async*/ void NewProjectButton_Click(object sender, RoutedEventArgs e)
{
_mainViewModel.EnterSnapshotMode(false);
}
private void EditButtonClicked(object sender, RoutedEventArgs e)
{
_mainViewModel.CloseAllPopups();
Button button = sender as Button;
Project selectedProject = button.DataContext as Project;
_mainViewModel.EditProject(selectedProject);
}
private void DeleteButtonClicked(object sender, RoutedEventArgs e)
{
e.Handled = true;
Button button = sender as Button;
Project selectedProject = button.DataContext as Project;
selectedProject.IsPopupVisible = false;
_mainViewModel.DeleteProject(selectedProject);
}
private void MoreButton_Click(object sender, RoutedEventArgs e)
{
_mainViewModel.CloseAllPopups();
e.Handled = true;
Button button = sender as Button;
Project project = button.DataContext as Project;
project.IsPopupVisible = true;
}
private void PopupClosed(object sender, object e)
{
if (sender is Popup p && p.DataContext is Project proj)
{
proj.IsPopupVisible = false;
}
}
private void LaunchButton_Click(object sender, RoutedEventArgs e)
{
e.Handled = true;
Button button = sender as Button;
Project project = button.DataContext as Project;
_mainViewModel.LaunchProject(project);
}
}
}

View File

@@ -1,21 +0,0 @@
<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"
xmlns:props="clr-namespace:WorkspacesEditor.Properties"
x:Name="WorkspacesMainWindow"
Title="{x:Static props:Resources.MainTitle}"
MinWidth="750"
MinHeight="680"
AutomationProperties.Name="Workspaces Editor"
Closing="OnClosing"
ContentRendered="OnContentRendered"
ResizeMode="CanResize"
WindowStartupLocation="CenterOwner"
mc:Ignorable="d">
<Grid Margin="0,16,0,0">
<Frame x:Name="ContentFrame" NavigationUIVisibility="Hidden" />
</Grid>
</Window>

View File

@@ -1,179 +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.Drawing;
using System.Linq;
using System.Threading;
using System.Windows;
using System.Windows.Interop;
using ManagedCommon;
using Microsoft.PowerToys.Telemetry;
using WorkspacesEditor.Telemetry;
using WorkspacesEditor.Utils;
using WorkspacesEditor.ViewModels;
namespace WorkspacesEditor
{
/// <summary>
/// Interaction logic for MainWindow.xaml
/// </summary>
public partial class MainWindow : Window, IDisposable
{
public MainViewModel MainViewModel { get; set; }
private CancellationTokenSource cancellationToken = new CancellationTokenSource();
private static MainPage _mainPage;
public MainWindow(MainViewModel mainViewModel)
{
MainViewModel = mainViewModel;
mainViewModel.SetMainWindow(this);
if (Properties.Settings.Default.Height == -1 || !IsEditorInsideVisibleArea())
{
// This is the very first time the window is created or it would be placed outside the visible area (monitor rearrangement). Place it on the screen center
WindowInteropHelper windowInteropHelper = new WindowInteropHelper(this);
System.Windows.Forms.Screen screen = System.Windows.Forms.Screen.FromHandle(windowInteropHelper.Handle);
double dpi = MonitorHelper.GetScreenDpiFromScreen(screen);
this.Height = screen.WorkingArea.Height / dpi * 0.90;
this.Width = screen.WorkingArea.Width / dpi * 0.75;
this.Top = screen.WorkingArea.Top + (int)(screen.WorkingArea.Height / dpi * 0.05);
this.Left = screen.WorkingArea.Left + (int)(screen.WorkingArea.Width / dpi * 0.125);
SavePosition();
}
this.Top = Properties.Settings.Default.Top;
this.Left = Properties.Settings.Default.Left;
this.Height = Properties.Settings.Default.Height;
this.Width = Properties.Settings.Default.Width;
if (Properties.Settings.Default.Maximized)
{
WindowState = WindowState.Maximized;
}
InitializeComponent();
_mainPage = new MainPage(mainViewModel);
ContentFrame.Navigate(_mainPage);
MaxWidth = SystemParameters.PrimaryScreenWidth;
MaxHeight = SystemParameters.PrimaryScreenHeight;
Common.UI.NativeEventWaiter.WaitForEventLoop(
PowerToys.Interop.Constants.WorkspacesHotkeyEvent(),
() =>
{
if (ApplicationIsInFocus())
{
Environment.Exit(0);
}
else
{
if (WindowState == WindowState.Minimized)
{
WindowState = WindowState.Normal;
}
// Get the window handle of the Workspaces Editor window
IntPtr handle = new WindowInteropHelper(this).Handle;
WindowHelpers.BringToForeground(handle);
InvalidateVisual();
}
},
Application.Current.Dispatcher,
cancellationToken.Token);
PowerToysTelemetry.Log.WriteEvent(new WorkspacesEditorStartFinishEvent() { TimeStamp = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds() });
}
private bool IsEditorInsideVisibleArea()
{
System.Windows.Forms.Screen[] allScreens = MonitorHelper.GetDpiUnawareScreens();
Rectangle commonBounds = allScreens[0].Bounds;
for (int screenIndex = 1; screenIndex < allScreens.Length; screenIndex++)
{
Rectangle rectangle = allScreens[screenIndex].Bounds;
commonBounds = Rectangle.Union(rectangle, commonBounds);
}
Rectangle editorBounds = new Rectangle((int)Properties.Settings.Default.Left, (int)Properties.Settings.Default.Top, (int)Properties.Settings.Default.Width, (int)Properties.Settings.Default.Height);
return editorBounds.IntersectsWith(commonBounds);
}
private void SavePosition()
{
if (WindowState == WindowState.Maximized)
{
// Use the RestoreBounds as the current values will be 0, 0 and the size of the screen
Properties.Settings.Default.Top = RestoreBounds.Top;
Properties.Settings.Default.Left = RestoreBounds.Left;
Properties.Settings.Default.Height = RestoreBounds.Height;
Properties.Settings.Default.Width = RestoreBounds.Width;
Properties.Settings.Default.Maximized = true;
}
else
{
Properties.Settings.Default.Top = this.Top;
Properties.Settings.Default.Left = this.Left;
Properties.Settings.Default.Height = this.Height;
Properties.Settings.Default.Width = this.Width;
Properties.Settings.Default.Maximized = false;
}
Properties.Settings.Default.Save();
}
private void OnClosing(object sender, EventArgs e)
{
SavePosition();
cancellationToken.Dispose();
App.Current.Shutdown();
}
// This is required to fix a WPF rendering bug when using custom chrome
private void OnContentRendered(object sender, EventArgs e)
{
// Get the window handle of the Workspaces Editor window
IntPtr handle = new WindowInteropHelper(this).Handle;
WindowHelpers.BringToForeground(handle);
InvalidateVisual();
}
public void ShowPage(ProjectEditor editPage)
{
ContentFrame.Navigate(editPage);
}
public void SwitchToMainView()
{
ContentFrame.GoBack();
}
public static bool ApplicationIsInFocus()
{
var activatedHandle = NativeMethods.GetForegroundWindow();
if (activatedHandle == IntPtr.Zero)
{
return false; // No window is currently activated
}
var procId = Environment.ProcessId;
int activeProcId;
_ = NativeMethods.GetWindowThreadProcessId(activatedHandle, out activeProcId);
return activeProcId == procId;
}
public void Dispose()
{
GC.SuppressFinalize(this);
}
}
}

View File

@@ -1,24 +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.
namespace WorkspacesEditor.Models
{
public sealed class AppListDataTemplateSelector : System.Windows.Controls.DataTemplateSelector
{
public System.Windows.DataTemplate HeaderTemplate { get; set; }
public System.Windows.DataTemplate AppTemplate { get; set; }
public AppListDataTemplateSelector()
{
HeaderTemplate = new System.Windows.DataTemplate();
AppTemplate = new System.Windows.DataTemplate();
}
public override System.Windows.DataTemplate SelectTemplate(object item, System.Windows.DependencyObject container)
{
return item is MonitorHeaderRow ? HeaderTemplate : AppTemplate;
}
}
}

View File

@@ -1,411 +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.Collections.Generic;
using System.ComponentModel;
using System.Drawing;
using System.Globalization;
using System.Linq;
using System.Text.Json.Serialization;
using System.Threading.Tasks;
using System.Windows.Media.Imaging;
using ManagedCommon;
using WorkspacesCsharpLibrary.Data;
using WorkspacesEditor.Utils;
namespace WorkspacesEditor.Models
{
public class Project : INotifyPropertyChanged
{
[JsonIgnore]
public string EditorWindowTitle { get; set; }
public string Id { get; private set; }
private string _name;
public string Name
{
get => _name;
set
{
_name = value;
OnPropertyChanged(new PropertyChangedEventArgs(nameof(Name)));
OnPropertyChanged(new PropertyChangedEventArgs(nameof(CanBeSaved)));
}
}
public long CreationTime { get; } // in seconds
public long LastLaunchedTime { get; } // in seconds
public bool IsShortcutNeeded { get; set; }
public bool MoveExistingWindows { get; set; }
public string LastLaunched
{
get
{
string lastLaunched = WorkspacesEditor.Properties.Resources.LastLaunched + ": ";
if (LastLaunchedTime == 0)
{
return lastLaunched + WorkspacesEditor.Properties.Resources.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 + WorkspacesEditor.Properties.Resources.Recently;
}
if (delta < 2 * MINUTE)
{
return lastLaunched + WorkspacesEditor.Properties.Resources.OneMinuteAgo;
}
if (delta < 45 * MINUTE)
{
return lastLaunched + ts.Minutes + " " + WorkspacesEditor.Properties.Resources.MinutesAgo;
}
if (delta < 90 * MINUTE)
{
return lastLaunched + WorkspacesEditor.Properties.Resources.OneHourAgo;
}
if (delta < 24 * HOUR)
{
return lastLaunched + ts.Hours + " " + WorkspacesEditor.Properties.Resources.HoursAgo;
}
if (delta < 48 * HOUR)
{
return lastLaunched + WorkspacesEditor.Properties.Resources.Yesterday;
}
if (delta < 30 * DAY)
{
return lastLaunched + ts.Days + " " + WorkspacesEditor.Properties.Resources.DaysAgo;
}
if (delta < 12 * MONTH)
{
int months = Convert.ToInt32(Math.Floor((double)ts.Days / 30));
return lastLaunched + (months <= 1 ? WorkspacesEditor.Properties.Resources.OneMonthAgo : months + " " + WorkspacesEditor.Properties.Resources.MonthsAgo);
}
else
{
int years = Convert.ToInt32(Math.Floor((double)ts.Days / 365));
return lastLaunched + (years <= 1 ? WorkspacesEditor.Properties.Resources.OneYearAgo : years + " " + WorkspacesEditor.Properties.Resources.YearsAgo);
}
}
}
public bool CanBeSaved => Name.Length > 0 && Applications.Count > 0;
private bool _isRevertEnabled;
public bool IsRevertEnabled
{
get => _isRevertEnabled;
set
{
if (_isRevertEnabled != value)
{
_isRevertEnabled = value;
OnPropertyChanged(new PropertyChangedEventArgs(nameof(IsRevertEnabled)));
}
}
}
private bool _isPopupVisible;
[JsonIgnore]
public bool IsPopupVisible
{
get => _isPopupVisible;
set
{
_isPopupVisible = value;
OnPropertyChanged(new PropertyChangedEventArgs(nameof(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.Left).ThenBy(x => x.Key.MonitorDpiUnawareBounds.Top))
{
MonitorHeaderRow headerRow = new() { MonitorName = "Screen " + appItem.Key.MonitorNumber, SelectString = Properties.Resources.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 = Properties.Resources.Minimized_Apps, SelectString = Properties.Resources.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 ? Properties.Resources.App : Properties.Resources.Apps);
}
}
public List<MonitorSetup> Monitors { get; }
public bool IsPositionChangedManually { get; set; } // telemetry
private BitmapImage _previewIcons;
private BitmapImage _previewImage;
private double _previewImageWidth;
public Project(Project selectedProject)
{
Id = selectedProject.Id;
Name = selectedProject.Name;
PreviewIcons = selectedProject.PreviewIcons;
PreviewImage = selectedProject.PreviewImage;
IsShortcutNeeded = selectedProject.IsShortcutNeeded;
MoveExistingWindows = selectedProject.MoveExistingWindows;
int screenIndex = 1;
Monitors = [];
foreach (MonitorSetup item in selectedProject.Monitors.OrderBy(x => x.MonitorDpiAwareBounds.Left).ThenBy(x => x.MonitorDpiAwareBounds.Top))
{
Monitors.Add(item);
screenIndex++;
}
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)
{
Models.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 Models.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)
{
System.Windows.Rect dpiAware = new(monitor.MonitorRectDpiAware.Left, monitor.MonitorRectDpiAware.Top, monitor.MonitorRectDpiAware.Width, monitor.MonitorRectDpiAware.Height);
System.Windows.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 BitmapImage PreviewIcons
{
get => _previewIcons;
set
{
_previewIcons = value;
OnPropertyChanged(new PropertyChangedEventArgs(nameof(PreviewIcons)));
}
}
public BitmapImage PreviewImage
{
get => _previewImage;
set
{
_previewImage = value;
OnPropertyChanged(new PropertyChangedEventArgs(nameof(PreviewImage)));
}
}
public double PreviewImageWidth
{
get => _previewImageWidth;
set
{
_previewImageWidth = value;
OnPropertyChanged(new PropertyChangedEventArgs(nameof(PreviewImageWidth)));
}
}
public event PropertyChangedEventHandler PropertyChanged;
public void OnPropertyChanged(PropertyChangedEventArgs e)
{
PropertyChanged?.Invoke(this, e);
}
public async void Initialize(Theme currentTheme)
{
PreviewIcons = await Task.Run(() => DrawHelper.DrawPreviewIcons(this));
Rectangle commonBounds = GetCommonBounds();
PreviewImage = await Task.Run(() => DrawHelper.DrawPreview(this, commonBounds, currentTheme));
PreviewImageWidth = commonBounds.Width / (commonBounds.Height * 1.2 / 200);
}
private Rectangle GetCommonBounds()
{
double minX = Monitors.First().MonitorDpiAwareBounds.Left;
double minY = Monitors.First().MonitorDpiAwareBounds.Top;
double maxX = Monitors.First().MonitorDpiAwareBounds.Right;
double maxY = Monitors.First().MonitorDpiAwareBounds.Bottom;
for (int monitorIndex = 1; monitorIndex < Monitors.Count; monitorIndex++)
{
Monitor monitor = Monitors[monitorIndex];
minX = Math.Min(minX, monitor.MonitorDpiAwareBounds.Left);
minY = Math.Min(minY, monitor.MonitorDpiAwareBounds.Top);
maxX = Math.Max(maxX, monitor.MonitorDpiAwareBounds.Right);
maxY = Math.Max(maxY, monitor.MonitorDpiAwareBounds.Bottom);
}
return new Rectangle((int)minX, (int)minY, (int)(maxX - minX), (int)(maxY - minY));
}
public void UpdateAfterLaunchAndEdit(Project projectBeforeLaunch)
{
Id = projectBeforeLaunch.Id;
Name = projectBeforeLaunch.Name;
IsRevertEnabled = true;
MoveExistingWindows = projectBeforeLaunch.MoveExistingWindows;
foreach (Application app in Applications)
{
var sameAppBefore = projectBeforeLaunch.Applications.Where(x => x.Id.Equals(app.Id, StringComparison.OrdinalIgnoreCase));
if (sameAppBefore.Any())
{
var appBefore = sameAppBefore.FirstOrDefault();
app.CommandLineArguments = appBefore.CommandLineArguments;
app.IsElevated = appBefore.IsElevated;
}
}
}
internal void CloseExpanders()
{
foreach (Application app in Applications)
{
app.IsExpanded = false;
}
}
internal MonitorSetup GetMonitorForApp(Application app)
{
MonitorSetup monitorSetup = Monitors.Where(x => x.MonitorNumber == app.MonitorNumber).FirstOrDefault();
if (monitorSetup == null)
{
// monitors changed: try to determine monitor id based on middle point
int middleX = app.Position.X + (app.Position.Width / 2);
int middleY = app.Position.Y + (app.Position.Height / 2);
MonitorSetup monitorCandidate = Monitors.Where(x =>
(x.MonitorDpiUnawareBounds.Left < middleX) &&
(x.MonitorDpiUnawareBounds.Right > middleX) &&
(x.MonitorDpiUnawareBounds.Top < middleY) &&
(x.MonitorDpiUnawareBounds.Bottom > middleY)).FirstOrDefault();
if (monitorCandidate != null)
{
app.MonitorNumber = monitorCandidate.MonitorNumber;
return monitorCandidate;
}
else
{
// monitors and even the app's area unknown, set the main monitor (which is closer to (0,0)) as the app's monitor
monitorCandidate = Monitors.OrderBy(x => Math.Abs(x.MonitorDpiUnawareBounds.Left) + Math.Abs(x.MonitorDpiUnawareBounds.Top)).FirstOrDefault();
if (monitorCandidate != null)
{
app.MonitorNumber = monitorCandidate.MonitorNumber;
return monitorCandidate;
}
else
{
// no monitors defined at all.
Logger.LogError($"Wrong workspace setup. No monitors defined for the workspace: {Name}.");
return null;
}
}
}
return monitorSetup;
}
}
}

View File

@@ -1,18 +0,0 @@
<Window
x:Class="WorkspacesEditor.OverlayWindow"
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:local="clr-namespace:WorkspacesEditor"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
AllowsTransparency="True"
Background="Transparent"
ShowActivated="False"
ShowInTaskbar="False"
WindowStyle="None"
mc:Ignorable="d">
<Border
Background="Transparent"
BorderBrush="Red"
BorderThickness="4" />
</Window>

View File

@@ -1,53 +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.Windows;
using WorkspacesEditor.Utils;
namespace WorkspacesEditor
{
/// <summary>
/// Interaction logic for OverlayWindow.xaml
/// </summary>
public partial class OverlayWindow : Window
{
private int _targetX;
private int _targetY;
private int _targetWidth;
private int _targetHeight;
public OverlayWindow()
{
InitializeComponent();
SourceInitialized += OnWindowSourceInitialized;
}
/// <summary>
/// Sets the target bounds for the overlay window.
/// The window will be positioned using DPI-unaware context after initialization.
/// </summary>
public void SetTargetBounds(int x, int y, int width, int height)
{
_targetX = x;
_targetY = y;
_targetWidth = width;
_targetHeight = height;
// Set initial WPF properties (will be corrected after HWND creation)
Left = x;
Top = y;
Width = width;
Height = height;
}
private void OnWindowSourceInitialized(object sender, EventArgs e)
{
// Reposition window using DPI-unaware context to match the virtual coordinates.
// This fixes overlay positioning on mixed-DPI multi-monitor setups.
NativeMethods.SetWindowPositionDpiUnaware(this, _targetX, _targetY, _targetWidth, _targetHeight);
}
}
}

View File

@@ -1,702 +0,0 @@
//------------------------------------------------------------------------------
// <auto-generated>
// This code was generated by a tool.
// Runtime Version:4.0.30319.42000
//
// Changes to this file may cause incorrect behavior and will be lost if
// the code is regenerated.
// </auto-generated>
//------------------------------------------------------------------------------
namespace WorkspacesEditor.Properties {
using System;
/// <summary>
/// A strongly-typed resource class, for looking up localized strings, etc.
/// </summary>
// This class was auto-generated by the StronglyTypedResourceBuilder
// class via a tool like ResGen or Visual Studio.
// To add or remove a member, edit your .ResX file then rerun ResGen
// with the /str option, or rebuild your VS project.
[global::System.CodeDom.Compiler.GeneratedCodeAttribute("System.Resources.Tools.StronglyTypedResourceBuilder", "18.0.0.0")]
[global::System.Diagnostics.DebuggerNonUserCodeAttribute()]
[global::System.Runtime.CompilerServices.CompilerGeneratedAttribute()]
public class Resources {
private static global::System.Resources.ResourceManager resourceMan;
private static global::System.Globalization.CultureInfo resourceCulture;
[global::System.Diagnostics.CodeAnalysis.SuppressMessageAttribute("Microsoft.Performance", "CA1811:AvoidUncalledPrivateCode")]
internal Resources() {
}
/// <summary>
/// Returns the cached ResourceManager instance used by this class.
/// </summary>
[global::System.ComponentModel.EditorBrowsableAttribute(global::System.ComponentModel.EditorBrowsableState.Advanced)]
public static global::System.Resources.ResourceManager ResourceManager {
get {
if (object.ReferenceEquals(resourceMan, null)) {
global::System.Resources.ResourceManager temp = new global::System.Resources.ResourceManager("WorkspacesEditor.Properties.Resources", typeof(Resources).Assembly);
resourceMan = temp;
}
return resourceMan;
}
}
/// <summary>
/// Overrides the current thread's CurrentUICulture property for all
/// resource lookups using this strongly typed resource class.
/// </summary>
[global::System.ComponentModel.EditorBrowsableAttribute(global::System.ComponentModel.EditorBrowsableState.Advanced)]
public static global::System.Globalization.CultureInfo Culture {
get {
return resourceCulture;
}
set {
resourceCulture = value;
}
}
/// <summary>
/// Looks up a localized string similar to Add back.
/// </summary>
public static string AddBack {
get {
return ResourceManager.GetString("AddBack", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to Admin.
/// </summary>
public static string Admin {
get {
return ResourceManager.GetString("Admin", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to app.
/// </summary>
public static string App {
get {
return ResourceManager.GetString("App", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to App name.
/// </summary>
public static string App_name {
get {
return ResourceManager.GetString("App_name", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to apps.
/// </summary>
public static string Apps {
get {
return ResourceManager.GetString("Apps", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to Are you sure?.
/// </summary>
public static string Are_You_Sure {
get {
return ResourceManager.GetString("Are_You_Sure", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to Are you sure you want to delete this Workspace?.
/// </summary>
public static string Are_You_Sure_Description {
get {
return ResourceManager.GetString("Are_You_Sure_Description", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to Args.
/// </summary>
public static string Args {
get {
return ResourceManager.GetString("Args", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to Cancel.
/// </summary>
public static string Cancel {
get {
return ResourceManager.GetString("Cancel", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to CLI arguments.
/// </summary>
public static string CliArguments {
get {
return ResourceManager.GetString("CliArguments", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to Created.
/// </summary>
public static string Created {
get {
return ResourceManager.GetString("Created", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to Create desktop shortcut.
/// </summary>
public static string CreateShortcut {
get {
return ResourceManager.GetString("CreateShortcut", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to Create Workspace.
/// </summary>
public static string CreateWorkspace {
get {
return ResourceManager.GetString("CreateWorkspace", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to Custom.
/// </summary>
public static string Custom {
get {
return ResourceManager.GetString("Custom", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to days ago.
/// </summary>
public static string DaysAgo {
get {
return ResourceManager.GetString("DaysAgo", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to Workspace.
/// </summary>
public static string DefaultWorkspaceNamePrefix {
get {
return ResourceManager.GetString("DefaultWorkspaceNamePrefix", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to Remove.
/// </summary>
public static string Delete {
get {
return ResourceManager.GetString("Delete", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to Delete Workspace dialog..
/// </summary>
public static string Delete_Workspace_Dialog_Announce {
get {
return ResourceManager.GetString("Delete_Workspace_Dialog_Announce", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to Remove selected apps.
/// </summary>
public static string DeleteSelected {
get {
return ResourceManager.GetString("DeleteSelected", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to Edit.
/// </summary>
public static string Edit {
get {
return ResourceManager.GetString("Edit", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to opened.
/// </summary>
public static string Edit_Project_Open_Announce {
get {
return ResourceManager.GetString("Edit_Project_Open_Announce", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to Edit Workspace.
/// </summary>
public static string EditWorkspace {
get {
return ResourceManager.GetString("EditWorkspace", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to Error parsing Workspaces data..
/// </summary>
public static string Error_Parsing_Message {
get {
return ResourceManager.GetString("Error_Parsing_Message", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to Height.
/// </summary>
public static string Height {
get {
return ResourceManager.GetString("Height", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to hours ago.
/// </summary>
public static string HoursAgo {
get {
return ResourceManager.GetString("HoursAgo", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to Last launched.
/// </summary>
public static string LastLaunched {
get {
return ResourceManager.GetString("LastLaunched", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to Launch.
/// </summary>
public static string Launch {
get {
return ResourceManager.GetString("Launch", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to Launch args.
/// </summary>
public static string Launch_args {
get {
return ResourceManager.GetString("Launch_args", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to Launch as Admin.
/// </summary>
public static string LaunchAsAdmin {
get {
return ResourceManager.GetString("LaunchAsAdmin", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to Launch &amp; edit.
/// </summary>
public static string LaunchEdit {
get {
return ResourceManager.GetString("LaunchEdit", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to Left.
/// </summary>
public static string Left {
get {
return ResourceManager.GetString("Left", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to Workspaces Editor.
/// </summary>
public static string MainTitle {
get {
return ResourceManager.GetString("MainTitle", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to Maximized.
/// </summary>
public static string Maximized {
get {
return ResourceManager.GetString("Maximized", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to Minimized.
/// </summary>
public static string Minimized {
get {
return ResourceManager.GetString("Minimized", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to Minimized apps.
/// </summary>
public static string Minimized_Apps {
get {
return ResourceManager.GetString("Minimized_Apps", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to minutes ago.
/// </summary>
public static string MinutesAgo {
get {
return ResourceManager.GetString("MinutesAgo", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to months ago.
/// </summary>
public static string MonthsAgo {
get {
return ResourceManager.GetString("MonthsAgo", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to Move existing windows.
/// </summary>
public static string MoveIfExist {
get {
return ResourceManager.GetString("MoveIfExist", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to Name.
/// </summary>
public static string Name {
get {
return ResourceManager.GetString("Name", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to never.
/// </summary>
public static string Never {
get {
return ResourceManager.GetString("Never", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to New Workspace.
/// </summary>
public static string New_Workspace {
get {
return ResourceManager.GetString("New_Workspace", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to There are no saved Workspaces..
/// </summary>
public static string No_Workspaces_Message {
get {
return ResourceManager.GetString("No_Workspaces_Message", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to The application cannot be found.
/// </summary>
public static string NotFoundTooltip {
get {
return ResourceManager.GetString("NotFoundTooltip", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to No Workspaces match the current search..
/// </summary>
public static string NoWorkspacesMatch {
get {
return ResourceManager.GetString("NoWorkspacesMatch", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to an hour ago.
/// </summary>
public static string OneHourAgo {
get {
return ResourceManager.GetString("OneHourAgo", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to a minute ago.
/// </summary>
public static string OneMinuteAgo {
get {
return ResourceManager.GetString("OneMinuteAgo", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to one month ago.
/// </summary>
public static string OneMonthAgo {
get {
return ResourceManager.GetString("OneMonthAgo", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to one second ago.
/// </summary>
public static string OneSecondAgo {
get {
return ResourceManager.GetString("OneSecondAgo", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to one year ago.
/// </summary>
public static string OneYearAgo {
get {
return ResourceManager.GetString("OneYearAgo", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to Pin Workspaces to taskbar.
/// </summary>
public static string PinToTaskbar {
get {
return ResourceManager.GetString("PinToTaskbar", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to recently.
/// </summary>
public static string Recently {
get {
return ResourceManager.GetString("Recently", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to Revert.
/// </summary>
public static string Revert {
get {
return ResourceManager.GetString("Revert", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to Save.
/// </summary>
public static string Save_Workspace {
get {
return ResourceManager.GetString("Save_Workspace", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to Search.
/// </summary>
public static string Search {
get {
return ResourceManager.GetString("Search", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to Search for Workspaces or apps.
/// </summary>
public static string SearchExplanation {
get {
return ResourceManager.GetString("SearchExplanation", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to seconds ago.
/// </summary>
public static string SecondsAgo {
get {
return ResourceManager.GetString("SecondsAgo", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to Select all apps on.
/// </summary>
public static string SelectAllAppsOnMonitor {
get {
return ResourceManager.GetString("SelectAllAppsOnMonitor", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to Select all minimized apps.
/// </summary>
public static string SelectAllMinimizedApps {
get {
return ResourceManager.GetString("SelectAllMinimizedApps", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to Select all apps in Workspace.
/// </summary>
public static string SelectedAllInWorkspace {
get {
return ResourceManager.GetString("SelectedAllInWorkspace", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to Edit your layout and click &quot;Capture&quot; when finished..
/// </summary>
public static string SnapshotDescription {
get {
return ResourceManager.GetString("SnapshotDescription", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to Snapshot Creator.
/// </summary>
public static string SnapshotWindowTitle {
get {
return ResourceManager.GetString("SnapshotWindowTitle", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to Sort by.
/// </summary>
public static string SortBy {
get {
return ResourceManager.GetString("SortBy", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to Capture.
/// </summary>
public static string Take_Snapshot {
get {
return ResourceManager.GetString("Take_Snapshot", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to Top.
/// </summary>
public static string Top {
get {
return ResourceManager.GetString("Top", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to Width.
/// </summary>
public static string Width {
get {
return ResourceManager.GetString("Width", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to Window position.
/// </summary>
public static string WindowPosition {
get {
return ResourceManager.GetString("WindowPosition", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to Workspace name.
/// </summary>
public static string WorkspaceName {
get {
return ResourceManager.GetString("WorkspaceName", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to Workspaces.
/// </summary>
public static string Workspaces {
get {
return ResourceManager.GetString("Workspaces", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to Write arguments here.
/// </summary>
public static string WriteArgs {
get {
return ResourceManager.GetString("WriteArgs", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to years ago.
/// </summary>
public static string YearsAgo {
get {
return ResourceManager.GetString("YearsAgo", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to yesterday.
/// </summary>
public static string Yesterday {
get {
return ResourceManager.GetString("Yesterday", resourceCulture);
}
}
}
}

View File

@@ -1,86 +0,0 @@
//------------------------------------------------------------------------------
// <auto-generated>
// This code was generated by a tool.
// Runtime Version:4.0.30319.42000
//
// Changes to this file may cause incorrect behavior and will be lost if
// the code is regenerated.
// </auto-generated>
//------------------------------------------------------------------------------
namespace WorkspacesEditor.Properties {
[global::System.Runtime.CompilerServices.CompilerGeneratedAttribute()]
[global::System.CodeDom.Compiler.GeneratedCodeAttribute("Microsoft.VisualStudio.Editors.SettingsDesigner.SettingsSingleFileGenerator", "17.12.0.0")]
internal sealed partial class Settings : global::System.Configuration.ApplicationSettingsBase {
private static Settings defaultInstance = ((Settings)(global::System.Configuration.ApplicationSettingsBase.Synchronized(new Settings())));
public static Settings Default {
get {
return defaultInstance;
}
}
[global::System.Configuration.UserScopedSettingAttribute()]
[global::System.Diagnostics.DebuggerNonUserCodeAttribute()]
[global::System.Configuration.DefaultSettingValueAttribute("-1")]
public double Top {
get {
return ((double)(this["Top"]));
}
set {
this["Top"] = value;
}
}
[global::System.Configuration.UserScopedSettingAttribute()]
[global::System.Diagnostics.DebuggerNonUserCodeAttribute()]
[global::System.Configuration.DefaultSettingValueAttribute("-1")]
public double Left {
get {
return ((double)(this["Left"]));
}
set {
this["Left"] = value;
}
}
[global::System.Configuration.UserScopedSettingAttribute()]
[global::System.Diagnostics.DebuggerNonUserCodeAttribute()]
[global::System.Configuration.DefaultSettingValueAttribute("-1")]
public double Height {
get {
return ((double)(this["Height"]));
}
set {
this["Height"] = value;
}
}
[global::System.Configuration.UserScopedSettingAttribute()]
[global::System.Diagnostics.DebuggerNonUserCodeAttribute()]
[global::System.Configuration.DefaultSettingValueAttribute("-1")]
public double Width {
get {
return ((double)(this["Width"]));
}
set {
this["Width"] = value;
}
}
[global::System.Configuration.UserScopedSettingAttribute()]
[global::System.Diagnostics.DebuggerNonUserCodeAttribute()]
[global::System.Configuration.DefaultSettingValueAttribute("False")]
public bool Maximized {
get {
return ((bool)(this["Maximized"]));
}
set {
this["Maximized"] = value;
}
}
}
}

View File

@@ -1,21 +0,0 @@
<?xml version='1.0' encoding='utf-8'?>
<SettingsFile xmlns="http://schemas.microsoft.com/VisualStudio/2004/01/settings" CurrentProfile="(Default)" GeneratedClassNamespace="WorkspacesEditor.Properties" GeneratedClassName="Settings">
<Profiles />
<Settings>
<Setting Name="Top" Type="System.Double" Scope="User">
<Value Profile="(Default)">-1</Value>
</Setting>
<Setting Name="Left" Type="System.Double" Scope="User">
<Value Profile="(Default)">-1</Value>
</Setting>
<Setting Name="Height" Type="System.Double" Scope="User">
<Value Profile="(Default)">-1</Value>
</Setting>
<Setting Name="Width" Type="System.Double" Scope="User">
<Value Profile="(Default)">-1</Value>
</Setting>
<Setting Name="Maximized" Type="System.Boolean" Scope="User">
<Value Profile="(Default)">False</Value>
</Setting>
</Settings>
</SettingsFile>

View File

@@ -1,52 +0,0 @@
<Window
x:Class="WorkspacesEditor.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:local="clr-namespace:WorkspacesEditor"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
xmlns:props="clr-namespace:WorkspacesEditor.Properties"
Title="{x:Static props:Resources.SnapshotWindowTitle}"
Width="420"
Closing="Window_Closing"
ResizeMode="NoResize"
ShowInTaskbar="False"
SizeToContent="Height"
WindowStartupLocation="CenterScreen"
mc:Ignorable="d">
<Grid Margin="4">
<Grid.RowDefinitions>
<RowDefinition Height="1*" />
<RowDefinition Height="Auto" />
</Grid.RowDefinitions>
<Grid.ColumnDefinitions>
<ColumnDefinition Width="1*" />
<ColumnDefinition Width="1*" />
</Grid.ColumnDefinitions>
<TextBlock
Grid.ColumnSpan="2"
Margin="8,16,8,16"
Text="{x:Static props:Resources.SnapshotDescription}"
TextAlignment="Center"
TextWrapping="Wrap" />
<Button
x:Name="SnapshotButton"
Grid.Row="1"
Margin="8,8,4,8"
HorizontalAlignment="Stretch"
AutomationProperties.Name="{x:Static props:Resources.Take_Snapshot}"
Click="SnapshotButtonClicked"
Content="{x:Static props:Resources.Take_Snapshot}"
Style="{DynamicResource AccentButtonStyle}" />
<Button
x:Name="CancelButton"
Grid.Row="1"
Grid.Column="1"
Margin="4,8,8,8"
HorizontalAlignment="Stretch"
AutomationProperties.Name="{x:Static props:Resources.Cancel}"
Click="CancelButtonClicked"
Content="{x:Static props:Resources.Cancel}" />
</Grid>
</Window>

View File

@@ -1,41 +0,0 @@
// Copyright (c) Microsoft Corporation
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
using System.Windows;
using WorkspacesEditor.ViewModels;
namespace WorkspacesEditor
{
/// <summary>
/// Interaction logic for SnapshotWindow.xaml
/// </summary>
public partial class SnapshotWindow : Window
{
private MainViewModel _mainViewModel;
public SnapshotWindow(MainViewModel mainViewModel)
{
_mainViewModel = mainViewModel;
InitializeComponent();
}
private void CancelButtonClicked(object sender, RoutedEventArgs e)
{
Close();
_mainViewModel.CancelSnapshot();
}
private void SnapshotButtonClicked(object sender, RoutedEventArgs e)
{
Close();
_mainViewModel.SnapWorkspace();
}
private void Window_Closing(object sender, System.ComponentModel.CancelEventArgs e)
{
_mainViewModel.CancelSnapshot();
}
}
}

View File

@@ -1,50 +0,0 @@
// Copyright (c) Microsoft Corporation
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
using System;
using System.Runtime.InteropServices;
using System.Threading;
using System.Windows.Forms;
namespace WorkspacesEditor.Utils
{
public class MonitorHelper
{
private const int DpiAwarenessContextUnaware = -1;
private Screen[] screens;
[DllImport("user32.dll")]
private static extern IntPtr SetThreadDpiAwarenessContext(IntPtr dpiContext);
private void SaveDpiUnawareScreens()
{
SetThreadDpiAwarenessContext(DpiAwarenessContextUnaware);
screens = Screen.AllScreens;
}
private Screen[] GetDpiUnawareScreenBounds()
{
Thread dpiUnawareThread = new(new ThreadStart(SaveDpiUnawareScreens));
dpiUnawareThread.Start();
dpiUnawareThread.Join();
return screens;
}
public static Screen[] GetDpiUnawareScreens()
{
MonitorHelper monitorHelper = new();
return monitorHelper.GetDpiUnawareScreenBounds();
}
internal static double GetScreenDpiFromScreen(Screen screen)
{
System.Drawing.Point point = new(screen.Bounds.Left + 1, screen.Bounds.Top + 1);
nint monitor = NativeMethods.MonitorFromPoint(point, NativeMethods._MONITOR_DEFAULTTONEAREST);
_ = NativeMethods.GetDpiForMonitor(monitor, NativeMethods.DpiType.EFFECTIVE, out uint dpiX, out _);
return dpiX / 96.0;
}
}
}

View File

@@ -1,93 +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 System.Windows;
using System.Windows.Interop;
namespace WorkspacesEditor.Utils
{
internal sealed class NativeMethods
{
public const int SW_RESTORE = 9;
public const int SW_NORMAL = 1;
public const int SW_MINIMIZE = 6;
[DllImport("user32.dll", SetLastError = true)]
[return: MarshalAs(UnmanagedType.Bool)]
public static extern bool MoveWindow(IntPtr hWnd, int X, int Y, int nWidth, int nHeight, bool bRepaint);
[DllImport("user32.dll", SetLastError = true)]
private static extern bool SetWindowPos(IntPtr hWnd, IntPtr hWndInsertAfter, int X, int Y, int cx, int cy, uint uFlags);
[DllImport("user32.dll")]
private static extern IntPtr SetThreadDpiAwarenessContext(IntPtr dpiContext);
private const uint SWP_NOZORDER = 0x0004;
private const uint SWP_NOACTIVATE = 0x0010;
private static readonly IntPtr DPI_AWARENESS_CONTEXT_UNAWARE = new IntPtr(-1);
/// <summary>
/// Positions a WPF window using DPI-unaware context to match the virtual coordinates.
/// This fixes overlay positioning on mixed-DPI multi-monitor setups.
/// </summary>
public static void SetWindowPositionDpiUnaware(Window window, int x, int y, int width, int height)
{
var helper = new WindowInteropHelper(window).Handle;
if (helper != IntPtr.Zero)
{
// Temporarily switch to DPI-unaware context to position window.
IntPtr oldContext = SetThreadDpiAwarenessContext(DPI_AWARENESS_CONTEXT_UNAWARE);
try
{
SetWindowPos(helper, IntPtr.Zero, x, y, width, height, SWP_NOZORDER | SWP_NOACTIVATE);
}
finally
{
SetThreadDpiAwarenessContext(oldContext);
}
}
}
[DllImport("USER32.DLL")]
public static extern bool SetForegroundWindow(IntPtr hWnd);
[DllImport("user32.dll")]
public static extern bool ShowWindow(IntPtr hWnd, int nCmdShow);
[DllImport("user32.dll")]
public static extern void SwitchToThisWindow(IntPtr hWnd, bool fAltTab);
[DllImport("user32.dll")]
public static extern IntPtr GetForegroundWindow();
[DllImport("user32.dll")]
public static extern uint GetWindowThreadProcessId(IntPtr hWnd, out int processId);
[DllImport("kernel32.dll")]
public static extern uint GetCurrentThreadId();
[DllImport("user32.dll")]
public static extern bool AttachThreadInput(uint idAttach, uint idAttachTo, bool fAttach);
public enum DpiType
{
EFFECTIVE = 0,
ANGULAR = 1,
RAW = 2,
}
[DllImport("User32.dll")]
public static extern IntPtr MonitorFromPoint([In] System.Drawing.Point pt, [In] uint dwFlags);
[DllImport("Shcore.dll")]
public static extern IntPtr GetDpiForMonitor([In] IntPtr hmonitor, [In] DpiType dpiType, [Out] out uint dpiX, [Out] out uint dpiY);
public const int _S_OK = 0;
public const int _MONITOR_DEFAULTTONEAREST = 2;
public const int _E_INVALIDARG = -2147024809;
}
}

View File

@@ -1,15 +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.
namespace WorkspacesEditor.Utils
{
public readonly struct ParsingResult(bool result, string message = "", string data = "")
{
public bool Result { get; } = result;
public string Message { get; } = message;
public string MalformedData { get; } = data;
}
}

View File

@@ -1,19 +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.Linq;
namespace WorkspacesEditor.Utils
{
public static class StringUtils
{
public static string UpperCamelCaseToDashCase(this string str)
{
// If it's a single letter variable, leave it as it is
return str.Length == 1
? str
: string.Concat(str.Select((x, i) => i > 0 && char.IsUpper(x) ? "-" + x.ToString() : x.ToString())).ToLowerInvariant();
}
}
}

View File

@@ -1,154 +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.Drawing;
using System.Drawing.Drawing2D;
using System.Drawing.Imaging;
using System.IO;
using System.Linq;
using ManagedCommon;
namespace WorkspacesEditor.Utils
{
public class WorkspacesIcon : IDisposable
{
private const int IconSize = 128;
public static readonly Brush LightThemeIconBackground = new SolidBrush(Color.FromArgb(255, 239, 243, 251));
public static readonly Brush LightThemeIconForeground = new SolidBrush(Color.FromArgb(255, 47, 50, 56));
public static readonly Brush DarkThemeIconBackground = new SolidBrush(Color.FromArgb(255, 55, 55, 55));
public static readonly Brush DarkThemeIconForeground = new SolidBrush(Color.FromArgb(255, 228, 228, 228));
public 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(System.Globalization.CultureInfo.CurrentCulture).ToCharArray()[0];
}
}
return result;
}
public static Bitmap DrawIcon(string text, Theme currentTheme)
{
Brush background = currentTheme == Theme.Dark ? DarkThemeIconBackground : LightThemeIconBackground;
Brush foreground = currentTheme == Theme.Dark ? DarkThemeIconForeground : LightThemeIconForeground;
Bitmap bitmap = new Bitmap(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();
// Calculate scaling factors
float scaleX = (float)IconSize / textSize.Width;
float scaleY = (float)IconSize / textSize.Height;
float scale = Math.Min(scaleX, scaleY) * 0.8f; // Use the smaller scale factor to maintain aspect ratio
// Calculate the position to center the text
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 (Path.Exists(path))
{
File.Delete(path);
}
FileStream fileStream = new FileStream(path, FileMode.CreateNew);
using (var memoryStream = new MemoryStream())
{
WorkspacesCsharpLibrary.DrawHelper.SaveBitmap(icon, memoryStream);
BinaryWriter iconWriter = new BinaryWriter(fileStream);
if (fileStream != null && iconWriter != null)
{
// 0-1 reserved, 0
iconWriter.Write((byte)0);
iconWriter.Write((byte)0);
// 2-3 image type, 1 = icon, 2 = cursor
iconWriter.Write((short)1);
// 4-5 number of images
iconWriter.Write((short)1);
// image entry 1
// 0 image width
iconWriter.Write((byte)IconSize);
// 1 image height
iconWriter.Write((byte)IconSize);
// 2 number of colors
iconWriter.Write((byte)0);
// 3 reserved
iconWriter.Write((byte)0);
// 4-5 color planes
iconWriter.Write((short)0);
// 6-7 bits per pixel
iconWriter.Write((short)32);
// 8-11 size of image data
iconWriter.Write((int)memoryStream.Length);
// 12-15 offset of image data
iconWriter.Write((int)(6 + 16));
// write image data
// png data must contain the whole png data file
iconWriter.Write(memoryStream.ToArray());
iconWriter.Flush();
}
}
fileStream.Flush();
fileStream.Close();
}
public void Dispose()
{
GC.SuppressFinalize(this);
}
}
}

View File

@@ -1,612 +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.Collections.Generic;
using System.Collections.ObjectModel;
using System.ComponentModel;
using System.Diagnostics;
using System.Drawing;
using System.Globalization;
using System.IO;
using System.Linq;
using System.Threading.Tasks;
using System.Timers;
using System.Windows;
using ManagedCommon;
using Microsoft.PowerToys.Settings.UI.Library;
using Microsoft.PowerToys.Telemetry;
using WorkspacesCsharpLibrary;
using WorkspacesCsharpLibrary.Data;
using WorkspacesCsharpLibrary.Utils;
using WorkspacesEditor.Models;
using WorkspacesEditor.Telemetry;
using WorkspacesEditor.Utils;
using static WorkspacesCsharpLibrary.Data.WorkspacesData;
namespace WorkspacesEditor.ViewModels
{
public class MainViewModel : INotifyPropertyChanged, IDisposable
{
private WorkspacesEditorIO _workspacesEditorIO;
private ProjectEditor editPage;
private SnapshotWindow _snapshotWindow;
private List<OverlayWindow> _overlayWindows = new List<OverlayWindow>();
private Project editedProject;
private Project projectBeforeLaunch;
private string projectNameBeingEdited;
private MainWindow _mainWindow;
private Timer lastUpdatedTimer;
private WorkspacesSettings settings;
private PwaHelper _pwaHelper;
private bool _isExistingProjectLaunched;
public ObservableCollection<Project> Workspaces { get; set; } = new ObservableCollection<Project>();
public IEnumerable<Project> WorkspacesView
{
get
{
IEnumerable<Project> workspaces = GetFilteredWorkspaces();
IsWorkspacesViewEmpty = !(workspaces != null && workspaces.Any());
OnPropertyChanged(new PropertyChangedEventArgs(nameof(IsWorkspacesViewEmpty)));
if (IsWorkspacesViewEmpty)
{
if (Workspaces != null && Workspaces.Any())
{
EmptyWorkspacesViewMessage = Properties.Resources.NoWorkspacesMatch;
}
else
{
EmptyWorkspacesViewMessage = Properties.Resources.No_Workspaces_Message;
}
OnPropertyChanged(new PropertyChangedEventArgs(nameof(EmptyWorkspacesViewMessage)));
return Enumerable.Empty<Project>();
}
OrderBy orderBy = (OrderBy)_orderByIndex;
if (orderBy == OrderBy.LastViewed)
{
return workspaces.OrderByDescending(x => x.LastLaunchedTime);
}
else if (orderBy == OrderBy.Created)
{
return workspaces.OrderByDescending(x => x.CreationTime);
}
else
{
return workspaces.OrderBy(x => x.Name);
}
}
}
public bool IsWorkspacesViewEmpty { get; set; }
public string EmptyWorkspacesViewMessage { get; set; }
// return those workspaces where the project name or any of the selected apps' name contains the search term
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));
});
}
private string _searchTerm;
public string SearchTerm
{
get => _searchTerm;
set
{
_searchTerm = value;
OnPropertyChanged(new PropertyChangedEventArgs(nameof(WorkspacesView)));
}
}
private int _orderByIndex;
public int OrderByIndex
{
get => _orderByIndex;
set
{
_orderByIndex = value;
OnPropertyChanged(new PropertyChangedEventArgs(nameof(WorkspacesView)));
settings.Properties.SortBy = (WorkspacesProperties.SortByProperty)value;
settings.Save(SettingsUtils.Default);
}
}
public event PropertyChangedEventHandler PropertyChanged;
public void OnPropertyChanged(PropertyChangedEventArgs e)
{
PropertyChanged?.Invoke(this, e);
}
public MainViewModel(WorkspacesEditorIO workspacesEditorIO)
{
settings = Utils.Settings.ReadSettings();
_orderByIndex = (int)settings.Properties.SortBy;
_workspacesEditorIO = workspacesEditorIO;
_pwaHelper = new PwaHelper();
lastUpdatedTimer = new System.Timers.Timer();
lastUpdatedTimer.Interval = 1000;
lastUpdatedTimer.Elapsed += LastUpdatedTimerElapsed;
lastUpdatedTimer.Start();
}
public void Initialize()
{
foreach (Project project in Workspaces)
{
project.Initialize(App.GetCurrentTheme());
}
OnPropertyChanged(new PropertyChangedEventArgs(nameof(WorkspacesView)));
}
public void SetEditedProject(Project editedProject)
{
this.editedProject = editedProject;
}
public void SaveProject(Project projectToSave)
{
SendEditTelemetryEvent(projectToSave, editedProject);
if (editedProject.Name != projectToSave.Name)
{
RemoveShortcut(editedProject);
}
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.OnPropertyChanged(new System.ComponentModel.PropertyChangedEventArgs("AppsCountString"));
editedProject.Initialize(App.GetCurrentTheme());
_workspacesEditorIO.SerializeWorkspaces(Workspaces.ToList());
ApplyShortcut(editedProject);
}
private string GetDesktopShortcutAddress(Project project) => Path.Combine(FolderUtils.Desktop(), project.Name + ".lnk");
private string GetShortcutStoreAddress(Project project)
{
var dataFolder = FolderUtils.DataFolder();
Directory.CreateDirectory(dataFolder);
var shortcutStoreFolder = Path.Combine(dataFolder, "WorkspacesIcons");
Directory.CreateDirectory(shortcutStoreFolder);
return Path.Combine(shortcutStoreFolder, project.Id + ".ico");
}
private void ApplyShortcut(Project project)
{
if (!project.IsShortcutNeeded)
{
RemoveShortcut(project);
return;
}
var basePath = AppDomain.CurrentDomain.BaseDirectory;
var shortcutAddress = GetDesktopShortcutAddress(project);
var shortcutIconFilename = GetShortcutStoreAddress(project);
Bitmap icon = WorkspacesIcon.DrawIcon(WorkspacesIcon.IconTextFromProjectName(project.Name), App.GetCurrentTheme());
WorkspacesIcon.SaveIcon(icon, shortcutIconFilename);
try
{
// Workaround to be able to create a shortcut with unicode filename
File.WriteAllBytes(shortcutAddress, Array.Empty<byte>());
// Create a ShellLinkObject that references the .lnk file
Shell32.Shell shell = new Shell32.Shell();
Shell32.Folder dir = shell.NameSpace(FolderUtils.Desktop());
Shell32.FolderItem folderItem = dir.Items().Item($"{project.Name}.lnk");
Shell32.ShellLinkObject link = (Shell32.ShellLinkObject)folderItem.GetLink;
// Set the .lnk file properties
link.Description = $"Project Launcher {project.Id}";
link.Path = Path.Combine(basePath, "PowerToys.WorkspacesLauncher.exe");
link.Arguments = $"{project.Id.ToString()} {(int)InvokePoint.Shortcut}";
link.WorkingDirectory = basePath;
link.SetIconLocation(shortcutIconFilename, 0);
link.Save(shortcutAddress);
}
catch (Exception ex)
{
Logger.LogError($"Shortcut creation error: {ex.Message}");
}
}
public void SaveProjectName(Project project)
{
projectNameBeingEdited = project.Name;
}
public void CancelProjectName(Project project)
{
project.Name = projectNameBeingEdited;
}
public async void SnapWorkspace()
{
CancelSnapshot();
await Task.Run(() => RunSnapshotTool(_isExistingProjectLaunched));
Project project = _workspacesEditorIO.ParseTempProject();
if (project != null)
{
if (_isExistingProjectLaunched)
{
project.UpdateAfterLaunchAndEdit(projectBeforeLaunch);
project.EditorWindowTitle = Properties.Resources.EditWorkspace;
editPage.DataContext = project;
CheckShortcutPresence(project);
project.Initialize(App.GetCurrentTheme());
}
else
{
EditProject(project, true);
}
}
}
internal void RevertLaunch()
{
CheckShortcutPresence(projectBeforeLaunch);
editPage.DataContext = projectBeforeLaunch;
projectBeforeLaunch.Initialize(App.GetCurrentTheme());
}
public void EditProject(Project selectedProject, bool isNewlyCreated = false)
{
editPage = new ProjectEditor(this);
SetEditedProject(selectedProject);
if (!isNewlyCreated)
{
selectedProject = new Project(selectedProject);
}
if (isNewlyCreated)
{
// generate a default name for the new project
string defaultNamePrefix = Properties.Resources.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 (Exception)
{
}
}
}
selectedProject.Name = defaultNamePrefix + " " + (nextProjectIndex + 1).ToString(CultureInfo.CurrentCulture);
}
selectedProject.EditorWindowTitle = isNewlyCreated ? Properties.Resources.CreateWorkspace : Properties.Resources.EditWorkspace;
selectedProject.Initialize(App.GetCurrentTheme());
CheckShortcutPresence(selectedProject);
editPage.DataContext = selectedProject;
_mainWindow.ShowPage(editPage);
lastUpdatedTimer.Stop();
}
private void CheckShortcutPresence(Project project)
{
string basePath = AppDomain.CurrentDomain.BaseDirectory;
string shortcutAddress = Path.Combine(FolderUtils.Desktop(), project.Name + ".lnk");
project.IsShortcutNeeded = File.Exists(shortcutAddress);
}
public void AddNewProject(Project project)
{
project.Applications.RemoveAll(app => !app.IsIncluded);
project.Initialize(App.GetCurrentTheme());
Workspaces.Add(project);
_workspacesEditorIO.SerializeWorkspaces(Workspaces.ToList());
TempProjectData.DeleteTempFile();
OnPropertyChanged(new PropertyChangedEventArgs(nameof(WorkspacesView)));
ApplyShortcut(project);
SendCreateTelemetryEvent(project);
}
public void DeleteProject(Project selectedProject)
{
Workspaces.Remove(selectedProject);
_workspacesEditorIO.SerializeWorkspaces(Workspaces.ToList());
RemoveShortcut(selectedProject);
OnPropertyChanged(new PropertyChangedEventArgs(nameof(WorkspacesView)));
SendDeleteTelemetryEvent();
}
private void RemoveShortcut(Project selectedProject)
{
string shortcutAddress = GetDesktopShortcutAddress(selectedProject);
string shortcutIconFilename = GetShortcutStoreAddress(selectedProject);
if (File.Exists(shortcutIconFilename))
{
File.Delete(shortcutIconFilename);
}
if (File.Exists(shortcutAddress))
{
File.Delete(shortcutAddress);
}
}
public void SetMainWindow(MainWindow mainWindow)
{
_mainWindow = mainWindow;
}
public void SwitchToMainView()
{
_mainWindow.SwitchToMainView();
SearchTerm = string.Empty;
OnPropertyChanged(new PropertyChangedEventArgs(nameof(SearchTerm)));
lastUpdatedTimer.Start();
editedProject = null;
}
public void LaunchProject(string projectId)
{
if (!Workspaces.Where(x => x.Id == projectId).Any())
{
Logger.LogWarning($"Workspace to launch not found. Id: {projectId}");
return;
}
LaunchProject(Workspaces.Where(x => x.Id == projectId).First(), true);
}
public async void LaunchProject(Project project, bool exitAfterLaunch = false)
{
if (project == null)
{
return;
}
await Task.Run(() => RunLauncher(project.Id, InvokePoint.EditorButton));
if (_workspacesEditorIO.ParseWorkspaces(this).Result == true)
{
OnPropertyChanged(new PropertyChangedEventArgs(nameof(WorkspacesView)));
}
if (exitAfterLaunch)
{
Logger.LogInfo($"Launched the Workspace {project.Name}. Exiting.");
Environment.Exit(0);
}
}
private void LastUpdatedTimerElapsed(object sender, ElapsedEventArgs e)
{
if (Workspaces == null)
{
return;
}
foreach (Project project in Workspaces)
{
project.OnPropertyChanged(new PropertyChangedEventArgs("LastLaunched"));
}
}
private void RunSnapshotTool(bool isExistingProjectLaunched)
{
Process process = new Process();
var exeDir = Path.GetDirectoryName(Environment.ProcessPath);
var snapshotUtilsPath = Path.Combine(exeDir, "PowerToys.WorkspacesSnapshotTool.exe");
process.StartInfo = new ProcessStartInfo(snapshotUtilsPath);
process.StartInfo.CreateNoWindow = true;
process.StartInfo.Arguments = isExistingProjectLaunched ? $"{(int)InvokePoint.LaunchAndEdit}" : string.Empty;
try
{
process.Start();
process.WaitForExit();
}
catch (Exception ex)
{
MessageBox.Show($"An error occurred: {ex.Message}");
}
}
private void RunLauncher(string projectId, InvokePoint invokePoint)
{
Process process = new Process();
process.StartInfo = new ProcessStartInfo(@".\PowerToys.WorkspacesLauncher.exe", $"{projectId} {(int)invokePoint}");
process.StartInfo.CreateNoWindow = true;
try
{
process.Start();
process.WaitForExit();
}
catch (Exception ex)
{
MessageBox.Show($"An error occurred: {ex.Message}");
}
}
public void Dispose()
{
GC.SuppressFinalize(this);
}
internal void CloseAllPopups()
{
foreach (Project project in Workspaces)
{
project.IsPopupVisible = false;
}
}
internal void EnterSnapshotMode(bool isExistingProjectLaunched)
{
_isExistingProjectLaunched = isExistingProjectLaunched;
_mainWindow.WindowState = System.Windows.WindowState.Minimized;
_overlayWindows.Clear();
foreach (var screen in MonitorHelper.GetDpiUnawareScreens())
{
var bounds = screen.Bounds;
OverlayWindow overlayWindow = new OverlayWindow();
// Use DPI-unaware positioning to fix overlay on mixed-DPI multi-monitor setups
overlayWindow.SetTargetBounds(bounds.Left, bounds.Top, bounds.Width, bounds.Height);
overlayWindow.ShowActivated = true;
overlayWindow.Topmost = true;
overlayWindow.Show();
_overlayWindows.Add(overlayWindow);
}
_snapshotWindow = new SnapshotWindow(this);
_snapshotWindow.ShowActivated = true;
_snapshotWindow.Topmost = true;
_snapshotWindow.Show();
}
internal void CancelSnapshot()
{
foreach (OverlayWindow overlayWindow in _overlayWindows)
{
overlayWindow.Close();
}
_mainWindow.WindowState = System.Windows.WindowState.Normal;
}
internal async void LaunchAndEdit(Project project)
{
await Task.Run(() => RunLauncher(project.Id, InvokePoint.LaunchAndEdit));
projectBeforeLaunch = new Project(project);
EnterSnapshotMode(true);
}
private void SendCreateTelemetryEvent(Project project)
{
var telemetryEvent = new CreateEvent();
telemetryEvent.Successful = true;
telemetryEvent.NumScreens = project.Monitors.Count;
telemetryEvent.AppCount = project.Applications.Count;
telemetryEvent.CliCount = project.Applications.FindAll(app => app.CommandLineArguments.Length > 0).Count;
telemetryEvent.ShortcutCreated = project.IsShortcutNeeded;
telemetryEvent.AdminCount = project.Applications.FindAll(app => app.IsElevated).Count;
PowerToysTelemetry.Log.WriteEvent(telemetryEvent);
}
private void SendEditTelemetryEvent(Project updatedProject, Project prevProject)
{
int appsRemovedCount = updatedProject.Applications.FindAll(val => !val.IsIncluded).Count;
foreach (var app in prevProject.Applications)
{
var updatedApp = updatedProject.Applications.Find(val => app.AppName == val.AppName && app.Position == val.Position);
if (updatedApp == null)
{
appsRemovedCount++;
}
}
int appsAddedCount = 0;
int cliAdded = 0, cliRemoved = 0;
int adminAdded = 0, adminRemoved = 0;
foreach (var app in updatedProject.Applications)
{
var prevApp = prevProject.Applications.Find(val => app.AppName == val.AppName && app.Position == val.Position);
if (prevApp == null)
{
if (app.IsIncluded)
{
appsAddedCount++;
}
continue;
}
if (app.CommandLineArguments.Length > 0 && prevApp.CommandLineArguments.Length == 0)
{
cliAdded++;
}
if (prevApp.CommandLineArguments.Length > 0 && app.CommandLineArguments.Length == 0)
{
cliRemoved++;
}
if (app.IsElevated && !prevApp.IsElevated)
{
adminAdded++;
}
if (!app.IsElevated && prevApp.IsElevated)
{
adminRemoved++;
}
}
var telemetryEvent = new EditEvent();
telemetryEvent.Successful = true;
telemetryEvent.ScreenCountDelta = updatedProject.Monitors.Count - prevProject.Monitors.Count;
telemetryEvent.AppsAdded = appsAddedCount;
telemetryEvent.AppsRemoved = appsRemovedCount;
telemetryEvent.CliAdded = cliAdded;
telemetryEvent.CliRemoved = cliRemoved;
telemetryEvent.AdminAdded = adminAdded;
telemetryEvent.AdminRemoved = adminRemoved;
telemetryEvent.PixelAdjustmentsUsed = updatedProject.IsPositionChangedManually;
PowerToysTelemetry.Log.WriteEvent(telemetryEvent);
}
private void SendDeleteTelemetryEvent()
{
var telemetryEvent = new EditEvent();
telemetryEvent.Successful = true;
PowerToysTelemetry.Log.WriteEvent(telemetryEvent);
}
}
}

View File

@@ -1,379 +0,0 @@
<Page
x:Class="WorkspacesEditor.ProjectEditor"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:controls="clr-namespace:WorkspacesEditor.Controls"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:local="clr-namespace:WorkspacesEditor"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
xmlns:models="clr-namespace:WorkspacesEditor.Models"
xmlns:props="clr-namespace:WorkspacesEditor.Properties"
Title="Workspaces Editor"
mc:Ignorable="d">
<Page.Resources>
<BooleanToVisibilityConverter x:Key="BoolToVis" />
<Style x:Key="TextBlockEnabledStyle" TargetType="TextBlock">
<Setter Property="Foreground" Value="{DynamicResource TextFillColorPrimaryBrush}" />
<Style.Triggers>
<Trigger Property="IsEnabled" Value="False">
<Setter Property="Foreground" Value="{DynamicResource TextFillColorSecondaryBrush}" />
</Trigger>
</Style.Triggers>
</Style>
<DataTemplate x:Key="headerTemplate">
<Border HorizontalAlignment="Stretch">
<TextBlock
Margin="0,16,0,8"
VerticalAlignment="Center"
DockPanel.Dock="Left"
FontWeight="SemiBold"
Foreground="{DynamicResource TextFillColorPrimaryBrush}"
Text="{Binding MonitorName}" />
</Border>
</DataTemplate>
<DataTemplate x:Key="appTemplate">
<Border
Margin="0,4,0,0"
MouseEnter="AppBorder_MouseEnter"
MouseLeave="AppBorder_MouseLeave">
<Expander
AutomationProperties.AutomationId="{Binding AppName}"
AutomationProperties.Name="{Binding AppName}"
IsEnabled="{Binding IsIncluded, Mode=OneWay, UpdateSourceTrigger=PropertyChanged}"
IsExpanded="{Binding IsExpanded, Mode=TwoWay, UpdateSourceTrigger=PropertyChanged}">
<Expander.Header>
<Grid HorizontalAlignment="{Binding HorizontalAlignment, RelativeSource={RelativeSource AncestorType=ContentPresenter}, Mode=OneWayToSource}" FlowDirection="LeftToRight">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="12" />
<ColumnDefinition Width="20" />
<ColumnDefinition Width="20" />
<ColumnDefinition Width="*" />
<ColumnDefinition Width="Auto" />
</Grid.ColumnDefinitions>
<Image
Grid.Column="1"
Width="20"
Height="20"
HorizontalAlignment="Center"
VerticalAlignment="Center"
Source="{Binding IconBitmapImage, UpdateSourceTrigger=PropertyChanged}" />
<StackPanel Grid.Column="3" VerticalAlignment="Center">
<StackPanel Orientation="Horizontal">
<TextBlock Text="{Binding AppName}" />
<TextBlock
Grid.Column="2"
Margin="8,0,0,0"
VerticalAlignment="Center"
Foreground="{DynamicResource TextFillColorSecondaryBrush}"
Text="{Binding RepeatIndexString, Mode=OneWay, UpdateSourceTrigger=PropertyChanged}" />
<TextBlock
Margin="8,0,0,0"
VerticalAlignment="Center"
FontFamily="{DynamicResource SymbolThemeFontFamily}"
Foreground="{DynamicResource SystemFillColorCautionBrush}"
Text="&#xE7BA;"
Visibility="{Binding IsNotFound, Converter={StaticResource BoolToVis}, Mode=OneWay, UpdateSourceTrigger=PropertyChanged}">
<TextBlock.ToolTip>
<ToolTip>
<TextBlock
FontFamily="Segoe UI Variable,SegoeUI"
Text="{x:Static props:Resources.NotFoundTooltip}"
TextWrapping="Wrap" />
</ToolTip>
</TextBlock.ToolTip>
</TextBlock>
</StackPanel>
<TextBlock
FontSize="12"
Foreground="{DynamicResource AccentTextFillColorPrimaryBrush}"
Text="{Binding AppMainParams, Mode=OneWay, UpdateSourceTrigger=PropertyChanged}"
Visibility="{Binding IsAppMainParamVisible, Converter={StaticResource BoolToVis}, Mode=OneWay, UpdateSourceTrigger=PropertyChanged}" />
</StackPanel>
<controls:ResetIsEnabled Grid.Column="4">
<Button
Width="Auto"
Margin="12,4"
AutomationProperties.Name="{x:Static props:Resources.Delete}"
Click="DeleteButtonClicked"
Content="{Binding DeleteButtonContent, Mode=OneWay, UpdateSourceTrigger=PropertyChanged}"
IsEnabled="True" />
</controls:ResetIsEnabled>
</Grid>
</Expander.Header>
<Grid
Margin="52,8,48,8"
HorizontalAlignment="{Binding HorizontalAlignment, RelativeSource={RelativeSource AncestorType=ContentPresenter}, Mode=OneWayToSource}"
FlowDirection="LeftToRight">
<Grid.RowDefinitions>
<RowDefinition Height="*" />
<RowDefinition Height="*" />
<RowDefinition Height="*" />
</Grid.RowDefinitions>
<CheckBox
MinWidth="12"
Content="{x:Static props:Resources.LaunchAsAdmin}"
IsChecked="{Binding IsElevated, Mode=TwoWay}"
IsEnabled="{Binding CanLaunchElevated, Mode=OneWay}" />
<DockPanel Grid.Row="1" Margin="0,16,0,0">
<TextBlock
VerticalAlignment="Center"
Foreground="{DynamicResource TextFillColorPrimaryBrush}"
Text="{x:Static props:Resources.CliArguments}" />
<TextBox
x:Name="CommandLineTextBox"
Margin="12,0,0,0"
HorizontalAlignment="Stretch"
VerticalContentAlignment="Center"
Foreground="{DynamicResource TextFillColorPrimaryBrush}"
Text="{Binding CommandLineArguments, Mode=TwoWay}"
TextChanged="CommandLineTextBox_TextChanged" />
</DockPanel>
<StackPanel
Grid.Row="2"
Margin="0,16,0,0"
Orientation="Horizontal">
<TextBlock
VerticalAlignment="Center"
IsEnabled="{Binding EditPositionEnabled, Mode=OneWay, UpdateSourceTrigger=PropertyChanged}"
Style="{StaticResource TextBlockEnabledStyle}"
Text="{x:Static props:Resources.WindowPosition}" />
<ComboBox
Margin="12,0,0,0"
VerticalAlignment="Center"
SelectedIndex="{Binding PositionComboboxIndex, Mode=TwoWay, UpdateSourceTrigger=PropertyChanged}">
<ComboBoxItem Content="{x:Static props:Resources.Custom}" />
<ComboBoxItem Content="{x:Static props:Resources.Maximized}" />
<ComboBoxItem Content="{x:Static props:Resources.Minimized}" />
</ComboBox>
<TextBlock
Margin="24,0,0,0"
VerticalAlignment="Center"
IsEnabled="{Binding EditPositionEnabled, Mode=OneWay, UpdateSourceTrigger=PropertyChanged}"
Style="{StaticResource TextBlockEnabledStyle}"
Text="{x:Static props:Resources.Left}" />
<TextBox
x:Name="LeftTextBox"
Margin="8,0,0,0"
HorizontalAlignment="Stretch"
VerticalContentAlignment="Center"
IsEnabled="{Binding EditPositionEnabled, Mode=OneWay, UpdateSourceTrigger=PropertyChanged}"
Text="{Binding Position.X, Mode=OneWay, UpdateSourceTrigger=PropertyChanged}"
TextChanged="LeftTextBox_TextChanged" />
<TextBlock
Margin="24,0,0,0"
VerticalAlignment="Center"
IsEnabled="{Binding EditPositionEnabled, Mode=OneWay, UpdateSourceTrigger=PropertyChanged}"
Style="{StaticResource TextBlockEnabledStyle}"
Text="{x:Static props:Resources.Top}" />
<TextBox
x:Name="TopTextBox"
Margin="8,0,0,0"
HorizontalAlignment="Stretch"
VerticalContentAlignment="Center"
IsEnabled="{Binding EditPositionEnabled, Mode=OneWay, UpdateSourceTrigger=PropertyChanged}"
Text="{Binding Position.Y, Mode=OneWay, UpdateSourceTrigger=PropertyChanged}"
TextChanged="TopTextBox_TextChanged" />
<TextBlock
Margin="24,0,0,0"
VerticalAlignment="Center"
IsEnabled="{Binding EditPositionEnabled, Mode=OneWay, UpdateSourceTrigger=PropertyChanged}"
Style="{StaticResource TextBlockEnabledStyle}"
Text="{x:Static props:Resources.Width}" />
<TextBox
x:Name="WidthTextBox"
Margin="8,0,0,0"
HorizontalAlignment="Stretch"
VerticalContentAlignment="Center"
IsEnabled="{Binding EditPositionEnabled, Mode=OneWay, UpdateSourceTrigger=PropertyChanged}"
Text="{Binding Position.Width, Mode=OneWay, UpdateSourceTrigger=PropertyChanged}"
TextChanged="WidthTextBox_TextChanged" />
<TextBlock
Margin="24,0,0,0"
VerticalAlignment="Center"
IsEnabled="{Binding EditPositionEnabled, Mode=OneWay, UpdateSourceTrigger=PropertyChanged}"
Style="{StaticResource TextBlockEnabledStyle}"
Text="{x:Static props:Resources.Height}" />
<TextBox
x:Name="HeightTextBox"
Margin="8,0,0,0"
HorizontalAlignment="Stretch"
VerticalContentAlignment="Center"
IsEnabled="{Binding EditPositionEnabled, Mode=OneWay, UpdateSourceTrigger=PropertyChanged}"
Text="{Binding Position.Height, Mode=OneWay, UpdateSourceTrigger=PropertyChanged}"
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="*" />
<RowDefinition Height="Auto" />
</Grid.RowDefinitions>
<Grid Margin="24,0,24,24">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*" />
<ColumnDefinition Width="Auto" />
</Grid.ColumnDefinitions>
<!-- faux BreadcrumbBar -->
<StackPanel VerticalAlignment="Top" Orientation="Horizontal">
<Button
Padding="0"
VerticalAlignment="Center"
Click="CancelButtonClicked"
Content="{x:Static props:Resources.Workspaces}"
FontSize="24"
Style="{DynamicResource SubtleButtonStyle}" />
<TextBlock
Margin="8,0,0,0"
VerticalAlignment="Center"
FontFamily="{DynamicResource SymbolThemeFontFamily}"
Foreground="{DynamicResource TextFillColorPrimaryBrush}"
Text="&#xE76C;" />
<TextBlock
Margin="8,0,0,0"
VerticalAlignment="Center"
FontSize="24"
FontWeight="SemiBold"
Foreground="{DynamicResource TextFillColorPrimaryBrush}"
Text="{Binding EditorWindowTitle}" />
</StackPanel>
<StackPanel HorizontalAlignment="Right" Orientation="Horizontal">
<Button
x:Name="SaveButton"
AutomationProperties.Name="{x:Static props:Resources.Save_Workspace}"
Click="SaveButtonClicked"
IsEnabled="{Binding CanBeSaved, UpdateSourceTrigger=PropertyChanged}"
Style="{DynamicResource AccentButtonStyle}">
<StackPanel Orientation="Horizontal">
<TextBlock
VerticalAlignment="Center"
AutomationProperties.Name="{x:Static props:Resources.Save_Workspace}"
FontFamily="{DynamicResource SymbolThemeFontFamily}"
Foreground="{DynamicResource TextOnAccentFillColorPrimaryBrush}"
Text="&#xE74E;" />
<TextBlock
Margin="8,0,0,0"
VerticalAlignment="Center"
Foreground="{DynamicResource TextOnAccentFillColorPrimaryBrush}"
Text="{x:Static props:Resources.Save_Workspace}" />
</StackPanel>
</Button>
<Button
x:Name="CancelButton"
Margin="8,0,0,0"
AutomationProperties.Name="{x:Static props:Resources.Cancel}"
Click="CancelButtonClicked"
Content="{x:Static props:Resources.Cancel}" />
</StackPanel>
</Grid>
<!-- preview -->
<Border
Grid.Row="1"
Margin="24,0"
HorizontalAlignment="Stretch"
Background="{DynamicResource LayerFillColorDefaultBrush}"
BorderBrush="{DynamicResource {x:Static SystemColors.ControlBrushKey}}"
BorderThickness="1"
CornerRadius="8">
<DockPanel Margin="16">
<Image
Width="{Binding PreviewImageWidth, Mode=OneWay, UpdateSourceTrigger=PropertyChanged}"
Height="200"
Margin="2"
DockPanel.Dock="Top"
Source="{Binding PreviewImage, Mode=OneWay, UpdateSourceTrigger=PropertyChanged}"
Stretch="Fill" />
<Button
x:Name="RevertButton"
HorizontalAlignment="Right"
AutomationProperties.Name="{x:Static props:Resources.Revert}"
Click="RevertButtonClicked"
Content="{x:Static props:Resources.Revert}"
DockPanel.Dock="Right"
IsEnabled="{Binding IsRevertEnabled, Mode=OneWay, UpdateSourceTrigger=PropertyChanged}" />
<Button
x:Name="LaunchEditButton"
Margin="0,0,8,0"
HorizontalAlignment="Right"
AutomationProperties.Name="{x:Static props:Resources.LaunchEdit}"
Click="LaunchEditButtonClicked"
Content="{x:Static props:Resources.LaunchEdit}"
DockPanel.Dock="Right" />
</DockPanel>
</Border>
<!-- properties -->
<DockPanel
Grid.Row="2"
Margin="24,16,24,0"
HorizontalAlignment="Stretch">
<StackPanel Orientation="Vertical">
<TextBlock
Margin="0,0,0,8"
FontWeight="SemiBold"
Foreground="{DynamicResource TextFillColorPrimaryBrush}"
Text="{x:Static props:Resources.WorkspaceName}" />
<TextBox
x:Name="EditNameTextBox"
Width="300"
HorizontalAlignment="Left"
GotFocus="EditNameTextBox_GotFocus"
KeyDown="EditNameTextBoxKeyDown"
Text="{Binding Name, Mode=TwoWay}"
TextChanged="EditNameTextBox_TextChanged" />
</StackPanel>
<StackPanel
HorizontalAlignment="Right"
DockPanel.Dock="Right"
Orientation="Horizontal">
<CheckBox
Margin="24,0,0,0"
VerticalAlignment="Bottom"
Content="{x:Static props:Resources.CreateShortcut}"
IsChecked="{Binding IsShortcutNeeded, Mode=TwoWay, UpdateSourceTrigger=PropertyChanged}" />
<CheckBox
Margin="16,0,0,0"
VerticalAlignment="Bottom"
Content="{x:Static props:Resources.MoveIfExist}"
IsChecked="{Binding MoveExistingWindows, Mode=TwoWay}" />
</StackPanel>
</DockPanel>
<ScrollViewer
Grid.Row="3"
Margin="0,24,0,0"
PreviewMouseWheel="ScrollViewer_PreviewMouseWheel"
VerticalScrollBarVisibility="Auto">
<StackPanel Margin="24,0,24,24" Orientation="Vertical">
<Grid>
<Grid.RowDefinitions>
<RowDefinition Height="Auto" />
<RowDefinition Height="*" />
</Grid.RowDefinitions>
<ItemsControl
x:Name="CapturedAppList"
AutomationProperties.Name="Captured Application List"
ItemTemplateSelector="{StaticResource AppListDataTemplateSelector}"
ItemsSource="{Binding ApplicationsListed, Mode=OneWay, UpdateSourceTrigger=PropertyChanged}" />
</Grid>
</StackPanel>
</ScrollViewer>
</Grid>
</Page>

View File

@@ -1,208 +0,0 @@
// Copyright (c) Microsoft Corporation
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
using System;
using System.ComponentModel;
using System.Linq;
using System.Windows;
using System.Windows.Controls;
using System.Windows.Input;
using WorkspacesCsharpLibrary.Data;
using WorkspacesEditor.Models;
using WorkspacesEditor.ViewModels;
namespace WorkspacesEditor
{
/// <summary>
/// Interaction logic for ProjectEditor.xaml
/// </summary>
public partial class ProjectEditor : Page
{
private const double ScrollSpeed = 15;
private MainViewModel _mainViewModel;
public ProjectEditor(MainViewModel mainViewModel)
{
_mainViewModel = mainViewModel;
InitializeComponent();
}
private void SaveButtonClicked(object sender, RoutedEventArgs e)
{
Project projectToSave = this.DataContext as Project;
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)
{
// delete the temp file created by the snapshot tool
TempProjectData.DeleteTempFile();
_mainViewModel.SwitchToMainView();
}
private void DeleteButtonClicked(object sender, RoutedEventArgs e)
{
Button button = sender as Button;
Models.Application app = button.DataContext as Models.Application;
app.SwitchDeletion();
}
private void EditNameTextBoxKeyDown(object sender, KeyEventArgs e)
{
if (e.Key == Key.Enter)
{
e.Handled = true;
Project project = this.DataContext as Project;
TextBox textBox = sender as TextBox;
project.Name = textBox.Text;
}
else if (e.Key == Key.Escape)
{
e.Handled = true;
Project project = this.DataContext as Project;
_mainViewModel.CancelProjectName(project);
}
}
private void EditNameTextBox_GotFocus(object sender, RoutedEventArgs e)
{
_mainViewModel.SaveProjectName(DataContext as Project);
}
private void AppBorder_MouseEnter(object sender, MouseEventArgs e)
{
Border border = sender as Border;
Models.Application app = border.DataContext as Models.Application;
app.IsHighlighted = true;
Project project = app.Parent;
project.Initialize(App.GetCurrentTheme());
}
private void AppBorder_MouseLeave(object sender, MouseEventArgs e)
{
Border border = sender as Border;
Models.Application app = border.DataContext as Models.Application;
if (app == null)
{
return;
}
app.IsHighlighted = false;
Project project = app.Parent;
project.Initialize(App.GetCurrentTheme());
}
private void EditNameTextBox_TextChanged(object sender, TextChangedEventArgs e)
{
Project project = this.DataContext as Project;
TextBox textBox = sender as TextBox;
project.Name = textBox.Text;
project.OnPropertyChanged(new PropertyChangedEventArgs(nameof(Project.CanBeSaved)));
}
private void LeftTextBox_TextChanged(object sender, TextChangedEventArgs e)
{
TextBox textBox = sender as TextBox;
Models.Application application = textBox.DataContext as Models.Application;
int newPos;
if (!int.TryParse(textBox.Text, out newPos))
{
newPos = 0;
}
application.Position = new Models.Application.WindowPosition() { X = newPos, Y = application.Position.Y, Width = application.Position.Width, Height = application.Position.Height };
Project project = application.Parent;
project.IsPositionChangedManually = true;
project.Initialize(App.GetCurrentTheme());
}
private void TopTextBox_TextChanged(object sender, TextChangedEventArgs e)
{
TextBox textBox = sender as TextBox;
Models.Application application = textBox.DataContext as Models.Application;
int newPos;
if (!int.TryParse(textBox.Text, out newPos))
{
newPos = 0;
}
application.Position = new Models.Application.WindowPosition() { X = application.Position.X, Y = newPos, Width = application.Position.Width, Height = application.Position.Height };
Project project = application.Parent;
project.IsPositionChangedManually = true;
project.Initialize(App.GetCurrentTheme());
}
private void WidthTextBox_TextChanged(object sender, TextChangedEventArgs e)
{
TextBox textBox = sender as TextBox;
Models.Application application = textBox.DataContext as Models.Application;
int newPos;
if (!int.TryParse(textBox.Text, out newPos))
{
newPos = 0;
}
application.Position = new Models.Application.WindowPosition() { X = application.Position.X, Y = application.Position.Y, Width = newPos, Height = application.Position.Height };
Project project = application.Parent;
project.IsPositionChangedManually = true;
project.Initialize(App.GetCurrentTheme());
}
private void HeightTextBox_TextChanged(object sender, TextChangedEventArgs e)
{
TextBox textBox = sender as TextBox;
Models.Application application = textBox.DataContext as Models.Application;
int newPos;
if (!int.TryParse(textBox.Text, out newPos))
{
newPos = 0;
}
application.Position = new Models.Application.WindowPosition() { X = application.Position.X, Y = application.Position.Y, Width = application.Position.Width, Height = newPos };
Project project = application.Parent;
project.IsPositionChangedManually = true;
project.Initialize(App.GetCurrentTheme());
}
private void CommandLineTextBox_TextChanged(object sender, TextChangedEventArgs e)
{
TextBox textBox = sender as TextBox;
Models.Application application = textBox.DataContext as Models.Application;
application.CommandLineTextChanged(textBox.Text);
}
private void LaunchEditButtonClicked(object sender, RoutedEventArgs e)
{
Button button = sender as Button;
Project project = button.DataContext as Project;
_mainViewModel.LaunchAndEdit(project);
}
private void RevertButtonClicked(object sender, RoutedEventArgs e)
{
_mainViewModel.RevertLaunch();
}
private void ScrollViewer_PreviewMouseWheel(object sender, MouseWheelEventArgs e)
{
ScrollViewer scrollViewer = sender as ScrollViewer;
double scrollAmount = Math.Sign(e.Delta) * ScrollSpeed;
scrollViewer.ScrollToVerticalOffset(scrollViewer.VerticalOffset - scrollAmount);
e.Handled = true;
}
}
}

View File

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

View File

@@ -0,0 +1,152 @@
// Copyright (c) Microsoft Corporation
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
using Microsoft.PowerToys.UITest;
using Microsoft.VisualStudio.TestTools.UnitTesting;
namespace WorkspacesEditorUITest;
/// <summary>
/// Design validation tests for the Workspace Editing page.
/// This page appears when a user clicks "Edit" on a workspace
/// and shows the app list with positioning controls.
///
/// UI elements that must be preserved:
/// - Workspace name text box
/// - App list with per-app controls
/// - Save/Cancel buttons
/// - Position controls (X, Y, Width, Height or Maximized/Minimized dropdown)
/// </summary>
[TestClass]
public class EditingPageDesignTests : WorkspacesUiAutomationBase
{
public EditingPageDesignTests()
: base()
{
}
[TestInitialize]
public void Setup()
{
// Ensure at least one workspace exists
AttachWorkspacesEditor();
if (!Has<Element>(By.AccessibilityId("WorkspacesItemsControl")))
{
CreateTestWorkspace("EditDesignTest");
Task.Delay(2000).Wait();
}
}
[TestMethod("EditingPage.HasNameTextBox")]
[TestCategory("Design.EditingPage")]
public void EditingPage_HasWorkspaceNameInput()
{
NavigateToEditPage();
Assert.IsTrue(
Has<TextBox>(By.AccessibilityId("EditNameTextBox")) || Has<TextBox>(By.Name("Workspace name")),
"Editing page should have a workspace name text box");
CancelAndReturn();
}
[TestMethod("EditingPage.HasSaveButton")]
[TestCategory("Design.EditingPage")]
public void EditingPage_HasSaveButton()
{
NavigateToEditPage();
Assert.IsTrue(
Has<Button>("Save Workspace") || Has<Button>("Save"),
"Editing page should have a Save button");
CancelAndReturn();
}
[TestMethod("EditingPage.HasCancelButton")]
[TestCategory("Design.EditingPage")]
public void EditingPage_HasCancelButton()
{
NavigateToEditPage();
Assert.IsTrue(Has<Button>("Cancel"), "Editing page should have a Cancel button");
CancelAndReturn();
}
[TestMethod("EditingPage.HasLaunchAndEditButton")]
[TestCategory("Design.EditingPage")]
public void EditingPage_HasLaunchAndEditButton()
{
NavigateToEditPage();
Assert.IsTrue(
Has<Button>("Launch & Edit") || Has<Button>("Launch and Edit"),
"Editing page should have a 'Launch & Edit' button");
CancelAndReturn();
}
[TestMethod("EditingPage.HasAppList")]
[TestCategory("Design.EditingPage")]
public void EditingPage_HasApplicationsList()
{
NavigateToEditPage();
// Should have some app items visible
Assert.IsTrue(
Has<Element>(By.AccessibilityId("AppList")) || Has<Custom>("AppList"),
"Editing page should have an application list");
CancelAndReturn();
}
[TestMethod("EditingPage.Cancel_ReturnsToMainPage")]
[TestCategory("Design.EditingPage")]
public void EditingPage_Cancel_ReturnsToMainList()
{
NavigateToEditPage();
Find<Button>("Cancel").Click();
Task.Delay(1000).Wait();
Assert.IsTrue(Has<Button>("Create Workspace"), "After cancel, should return to main page");
}
private void NavigateToEditPage()
{
AttachWorkspacesEditor();
try
{
var root = Find<Element>(By.AccessibilityId("WorkspacesItemsControl"));
var moreButton = root.Find<Button>(By.AccessibilityId("MoreButton"));
moreButton.Click();
Task.Delay(500).Wait();
var editButton = Find<Button>(By.Name("Edit"));
editButton.Click();
Task.Delay(1000).Wait();
}
catch
{
// If edit via more menu doesn't work, try direct edit button
var editButton = Find<Button>(By.Name("Edit"));
editButton?.Click();
Task.Delay(1000).Wait();
}
}
private void CancelAndReturn()
{
try
{
Find<Button>("Cancel").Click();
Task.Delay(500).Wait();
}
catch
{
// Best effort cleanup
}
}
}

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 Microsoft.PowerToys.UITest;
using Microsoft.VisualStudio.TestTools.UnitTesting;
namespace WorkspacesEditorUITest;
/// <summary>
/// Design validation tests for the Workspaces Editor main window.
/// These verify that all expected UI elements are present and accessible,
/// serving as a contract that the WinUI migration must satisfy.
///
/// Window: MainWindow / WorkspacesEditorPage
/// Tests cover: header elements, action buttons, workspace list, search, sort.
/// </summary>
[TestClass]
public class EditorMainWindowDesignTests : WorkspacesUiAutomationBase
{
public EditorMainWindowDesignTests()
: base()
{
}
[TestMethod("MainWindow.Header.TitleTextPresent")]
[TestCategory("Design.MainWindow")]
public void MainWindow_HasWorkspacesTitleText()
{
Assert.IsTrue(Has<TextBlock>(By.Name("Workspaces")), "Should display 'Workspaces' title");
}
[TestMethod("MainWindow.Header.CreateWorkspaceButtonPresent")]
[TestCategory("Design.MainWindow")]
public void MainWindow_HasCreateWorkspaceButton()
{
Assert.IsTrue(Has<Button>("Create Workspace"), "Should have 'Create Workspace' button");
}
[TestMethod("MainWindow.Header.SearchBoxPresent")]
[TestCategory("Design.MainWindow")]
public void MainWindow_HasSearchBox()
{
Assert.IsTrue(
Has<TextBox>(By.AccessibilityId("SearchBox")) || Has<TextBox>(By.Name("Search")),
"Should have a search input");
}
[TestMethod("MainWindow.Header.SortByPresent")]
[TestCategory("Design.MainWindow")]
public void MainWindow_HasSortByDropdown()
{
Assert.IsTrue(
Has<ComboBox>(By.AccessibilityId("SortByComboBox")) || Has<ComboBox>(By.Name("SortBy")),
"Should have 'Sort by' dropdown");
}
[TestMethod("MainWindow.Content.WorkspacesListPresent")]
[TestCategory("Design.MainWindow")]
public void MainWindow_HasWorkspacesList()
{
// The workspaces list container should exist even when empty
Assert.IsTrue(
Has<Element>(By.AccessibilityId("WorkspacesItemsControl")) || Has<Custom>("WorkspacesList"),
"Should have workspace list container");
}
[TestMethod("MainWindow.Content.EmptyStateMessagePresent")]
[TestCategory("Design.MainWindow")]
public void MainWindow_EmptyState_ShowsMessage()
{
// When no workspaces exist, should show a message
var hasEmptyMessage = Has<TextBlock>(By.Name("There are no saved Workspaces"))
|| Has<TextBlock>(By.Name("No saved Workspaces"));
// This test is informational — may not have empty state if workspaces exist
if (!Has<Custom>("WorkspacesList") || !Has<Element>(By.ClassName("WorkspaceItem")))
{
Assert.IsTrue(hasEmptyMessage, "Empty state should show a message when no workspaces exist");
}
}
[TestMethod("MainWindow.Keyboard.TabNavigationWorks")]
[TestCategory("Design.MainWindow")]
public void MainWindow_TabNavigation_MovesForwardThroughControls()
{
// Press Tab and verify focus moves to an interactive element
SendKeys(Key.Tab);
Task.Delay(500).Wait();
// At least one focusable element should have focus
// This verifies keyboard navigation isn't broken
Assert.IsTrue(true, "Tab navigation executed without crash");
}
[TestMethod("MainWindow.Accessibility.CreateButtonHasAutomationName")]
[TestCategory("Design.MainWindow")]
public void MainWindow_CreateButton_HasAccessibleName()
{
var button = Find<Button>("Create Workspace");
Assert.IsNotNull(button, "Create Workspace button should be findable by its accessible name");
}
}

View File

@@ -0,0 +1,108 @@
// Copyright (c) Microsoft Corporation
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
using Microsoft.PowerToys.UITest;
using Microsoft.VisualStudio.TestTools.UnitTesting;
namespace WorkspacesEditorUITest;
/// <summary>
/// Design validation tests for the Snapshot/Capture window.
/// This window appears when creating a new workspace and shows
/// a "Capture" button overlay on the desktop.
///
/// These tests validate the capture flow UI elements exist
/// and are accessible for the WinUI migration.
/// </summary>
[TestClass]
public class SnapshotWindowDesignTests : WorkspacesUiAutomationBase
{
public SnapshotWindowDesignTests()
: base()
{
}
[TestMethod("SnapshotWindow.HasCaptureButton")]
[TestCategory("Design.SnapshotWindow")]
public void SnapshotWindow_HasCaptureButton()
{
AttachWorkspacesEditor();
var createButton = Find<Button>("Create Workspace");
createButton.Click();
Task.Delay(1000).Wait();
AttachSnapshotWindow();
Assert.IsTrue(Has<Button>("Capture"), "Snapshot window should have a Capture button");
// Cancel to clean up
var cancelButton = Find<Button>("Cancel");
cancelButton.Click();
Task.Delay(500).Wait();
}
[TestMethod("SnapshotWindow.HasCancelButton")]
[TestCategory("Design.SnapshotWindow")]
public void SnapshotWindow_HasCancelButton()
{
AttachWorkspacesEditor();
var createButton = Find<Button>("Create Workspace");
createButton.Click();
Task.Delay(1000).Wait();
AttachSnapshotWindow();
Assert.IsTrue(Has<Button>("Cancel"), "Snapshot window should have a Cancel button");
// Clean up
Find<Button>("Cancel").Click();
Task.Delay(500).Wait();
}
[TestMethod("SnapshotWindow.CancelReturnsToEditor")]
[TestCategory("Design.SnapshotWindow")]
public void SnapshotWindow_CancelButton_ReturnsToEditor()
{
AttachWorkspacesEditor();
var createButton = Find<Button>("Create Workspace");
createButton.Click();
Task.Delay(1000).Wait();
AttachSnapshotWindow();
Find<Button>("Cancel").Click();
Task.Delay(1000).Wait();
// Should be back in the editor
AttachWorkspacesEditor();
Assert.IsTrue(Has<Button>("Create Workspace"), "After cancel, should return to editor with Create button visible");
}
[TestMethod("SnapshotWindow.Accessibility.ButtonsHaveNames")]
[TestCategory("Design.SnapshotWindow")]
public void SnapshotWindow_Buttons_HaveAccessibleNames()
{
AttachWorkspacesEditor();
var createButton = Find<Button>("Create Workspace");
createButton.Click();
Task.Delay(1000).Wait();
AttachSnapshotWindow();
// Both buttons should be findable by name (meaning they have accessible names)
var capture = Find<Button>("Capture");
var cancel = Find<Button>("Cancel");
Assert.IsNotNull(capture, "Capture button should have an accessible name");
Assert.IsNotNull(cancel, "Cancel button should have an accessible name");
// Clean up
cancel.Click();
Task.Delay(500).Wait();
}
}

View File

@@ -0,0 +1,160 @@
// Copyright (c) Microsoft Corporation
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
using Microsoft.PowerToys.UITest;
using Microsoft.VisualStudio.TestTools.UnitTesting;
namespace WorkspacesEditorUITest;
/// <summary>
/// Design validation tests for workspace items in the list.
/// When workspaces exist, each item must have: name, app count, launch button,
/// edit button, more options button.
///
/// These define the per-item UI contract the migration must preserve.
/// </summary>
[TestClass]
public class WorkspaceItemDesignTests : WorkspacesUiAutomationBase
{
public WorkspaceItemDesignTests()
: base()
{
}
[TestInitialize]
public void Setup()
{
// Ensure at least one workspace exists for item-level tests
if (!HasWorkspaceItem())
{
CreateTestWorkspace("DesignTest");
Task.Delay(2000).Wait();
}
}
[TestMethod("WorkspaceItem.HasName")]
[TestCategory("Design.WorkspaceItem")]
public void WorkspaceItem_DisplaysName()
{
if (!HasWorkspaceItem())
{
Assert.Inconclusive("No workspace items available for testing");
return;
}
var item = GetFirstWorkspaceItem();
Assert.IsNotNull(item, "Should have at least one workspace item");
}
[TestMethod("WorkspaceItem.HasLaunchButton")]
[TestCategory("Design.WorkspaceItem")]
public void WorkspaceItem_HasLaunchButton()
{
if (!HasWorkspaceItem())
{
Assert.Inconclusive("No workspace items available for testing");
return;
}
var item = GetFirstWorkspaceItem();
var launchButton = item.Find<Button>(By.Name("Launch"));
Assert.IsNotNull(launchButton, "Workspace item should have a Launch button");
}
[TestMethod("WorkspaceItem.HasEditButton")]
[TestCategory("Design.WorkspaceItem")]
public void WorkspaceItem_HasEditButton()
{
if (!HasWorkspaceItem())
{
Assert.Inconclusive("No workspace items available for testing");
return;
}
var item = GetFirstWorkspaceItem();
var editButton = item.Find<Button>(By.Name("Edit"));
Assert.IsNotNull(editButton, "Workspace item should have an Edit button");
}
[TestMethod("WorkspaceItem.HasMoreOptionsButton")]
[TestCategory("Design.WorkspaceItem")]
public void WorkspaceItem_HasMoreOptionsButton()
{
if (!HasWorkspaceItem())
{
Assert.Inconclusive("No workspace items available for testing");
return;
}
var item = GetFirstWorkspaceItem();
var moreButton = item.Find<Button>(By.AccessibilityId("MoreButton"));
Assert.IsNotNull(moreButton, "Workspace item should have a More options button");
}
[TestMethod("WorkspaceItem.HasAppCountText")]
[TestCategory("Design.WorkspaceItem")]
public void WorkspaceItem_DisplaysAppCount()
{
if (!HasWorkspaceItem())
{
Assert.Inconclusive("No workspace items available for testing");
return;
}
var item = GetFirstWorkspaceItem();
// App count text should contain a number followed by "App" or "Apps"
var textBlocks = item.FindAll<TextBlock>(By.ClassName("TextBlock"));
bool hasAppCount = textBlocks.Any(t =>
{
var text = t.GetAttribute("Name") ?? string.Empty;
return text.Contains("App", System.StringComparison.OrdinalIgnoreCase);
});
Assert.IsTrue(hasAppCount, "Workspace item should display app count");
}
[TestMethod("WorkspaceItem.HasLastLaunchedText")]
[TestCategory("Design.WorkspaceItem")]
public void WorkspaceItem_DisplaysLastLaunchedTime()
{
if (!HasWorkspaceItem())
{
Assert.Inconclusive("No workspace items available for testing");
return;
}
var item = GetFirstWorkspaceItem();
// Should contain "Last launched" text
var textBlocks = item.FindAll<TextBlock>(By.ClassName("TextBlock"));
bool hasLastLaunched = textBlocks.Any(t =>
{
var text = t.GetAttribute("Name") ?? string.Empty;
return text.Contains("Last", System.StringComparison.OrdinalIgnoreCase);
});
Assert.IsTrue(hasLastLaunched, "Workspace item should display last launched time");
}
private bool HasWorkspaceItem()
{
try
{
var root = Find<Element>(By.AccessibilityId("WorkspacesItemsControl"));
return root != null;
}
catch
{
return false;
}
}
private Element GetFirstWorkspaceItem()
{
var root = Find<Element>(By.AccessibilityId("WorkspacesItemsControl"));
var items = root.FindAll<Element>(By.ClassName("WorkspaceItem"));
return items.Count > 0 ? items[0] : root;
}
}

View File

@@ -26,7 +26,7 @@
<ItemGroup>
<ProjectReference Include="..\..\..\common\UITestAutomation\UITestAutomation.csproj" />
<ProjectReference Include="..\WorkspacesEditor\WorkspacesEditor.csproj" />
<ProjectReference Include="..\WorkspacesEditor.WinUI\WorkspacesEditor.WinUI.csproj" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,236 @@
# Migrate Workspaces Editor from WPF to WinUI 3
## Background
The Workspaces Launcher UI has been successfully migrated from WPF to WinUI 3 ([PR #48700](https://github.com/microsoft/PowerToys/pull/48700)), establishing reusable patterns for the codebase. The Workspaces Editor is the remaining WPF-based UI surface in the Workspaces module and represents a larger, more complex migration effort.
The Editor is the primary user-facing window for creating, editing, and managing workspaces. It includes multiple pages, complex data templates, and COM interop for shortcut creation.
---
## Goal
Migrate the Workspaces Editor from WPF to WinUI 3 to:
- Complete the Workspaces module WinUI modernization
- Remove all WPF dependencies from the Workspaces module
- Maintain feature parity with existing Editor functionality
- Leverage patterns established in the Launcher UI migration
- Improve long-term maintainability and UI consistency
## Non-Goals
The following are explicitly out of scope:
- New user-facing features or UX redesigns
- Changes to workspace configuration format (`workspaces.json`)
- Changes to the C++ engine components (Launcher, WindowArranger, SnapshotTool)
- Changes to the Module Interface
- Telemetry changes
> The objective is functional parity, not feature expansion.
---
## Scope
### In Scope
- WorkspacesEditor WPF application (6 XAML files, 31 C# files)
- WorkspacesCsharpLibrary WPF imaging code (`BaseApplication.cs` icon handling)
- Resource dictionaries and styling
- ViewModels and data binding
- Accessibility and theme support
- Installer and signing updates
- WorkspacesEditorUITest updates
### Out of Scope
- Workspaces core C++ functionality
- Launcher UI (already migrated to WinUI 3)
- Named pipe IPC protocol
- Window placement algorithms
- Configuration file format changes
### Dependencies (Must Be Resolved First)
> **Blocker:** `WorkspacesCsharpLibrary` contains WPF imaging code (`System.Windows.Media.Imaging.BitmapImage`) used by both the Editor and `Workspaces.ModuleServices`. This library must be updated to remove WPF dependencies before the Editor migration can proceed.
---
## Key Challenges
This migration is significantly more complex than the Launcher UI:
| Challenge | Details | Approach |
|-----------|---------|----------|
| Multiple windows/pages | MainWindow, WorkspacesEditorPage, SnapshotWindow, OverlayWindow | Migrate each window independently, starting from leaf pages |
| Frame-based navigation | WPF `Frame` + `Page` pattern | WinUI `NavigationView` or direct content switching |
| WPF Triggers in multiple locations | `Style.Triggers`, `DataTriggers` on IsEnabled, IsMouseOver | Convert each to `VisualStateManager` states |
| Expander with complex DataTemplates | Workspace app list uses `Expander` with nested templates | WinUI `Expander` is a direct equivalent; port DataTemplate content |
| COM interop (shortcut creation) | `IWshRuntimeLibrary` for Windows shortcuts | COM interop works identically in WinUI; no migration needed |
| BitmapImage in shared library | `WorkspacesCsharpLibrary.BaseApplication` uses WPF imaging | Replace with `Microsoft.UI.Xaml.Media.Imaging.BitmapImage` or `Windows.Graphics.Imaging` |
| Icon extraction (GDI+ pipeline) | `System.Drawing.Icon``Bitmap``BitmapImage` chain | Replace with `Windows.Graphics.Imaging.SoftwareBitmap` pipeline |
---
## Risks to Investigate Before Writing Code
These areas require spikes before committing to implementation:
### 1. SnapshotWindow & OverlayWindow (HIGH RISK)
The capture experience relies on:
- Transparent windows
- Topmost behavior
- Screen coordinates and hit testing
- Desktop overlay rendering
WinUI has known gaps in windowing and overlay scenarios that often require `AppWindow`/HWND interop. This is where functional regressions are most likely to surface.
**Action:** Spike a minimal transparent topmost WinUI window with click-through behavior before estimating Milestone 4.
### 2. Resource Migration (MEDIUM RISK)
The `.resx``.resw` migration is straightforward per-file but touches nearly every XAML file. Before estimating effort, inventory:
- Total number of localized strings
- Converters that reference resource strings
- Bindings that depend on `{x:Static}` resource syntax
**Action:** Run a count of `x:Static props:Resources.` references across all Editor XAML files.
### 3. UITest Migration (MEDIUM RISK)
UI test migration effort is often underestimated:
> UI migration = 40% of effort, Test fixes = 60% of effort
`WorkspacesEditorUITest` may depend on:
- WPF-specific element identifiers
- Accessibility IDs that change with WinUI
- Automation patterns that differ between frameworks
**Action:** Inspect `WorkspacesEditorUITest` early to understand element identifiers, accessibility IDs, and automation patterns before assuming they port cleanly.
---
## PR Structure
**Single PR with 5 milestones** (same pattern as the Launcher UI migration):
### Milestone 1: Remove WPF Imaging Dependencies
**Goal:** Decouple `WorkspacesCsharpLibrary` from WPF-specific imaging APIs.
- [ ] Remove `System.Windows.Media.Imaging` dependency from `BaseApplication.cs`
- [ ] Replace WPF `BitmapImage` property with WinUI-compatible alternative
- [ ] Update icon extraction pipeline (GDI+ → SoftwareBitmap or platform-agnostic)
- [ ] Verify `Workspaces.ModuleServices` still builds and functions
- [ ] Run existing tests to confirm no regressions
**Success criteria:** No `System.Windows.*` imaging dependencies remain. Existing tests pass. Editor still builds.
**Why first?** This is the primary blocker — the Editor and ModuleServices both depend on this library.
---
### Milestone 2: WinUI Editor Foundation
**Goal:** Create the new WinUI editor project and bootstrapping infrastructure.
- [ ] Create new WinUI `.csproj` (`WorkspacesEditor.WinUI`)
- [ ] Custom entry point with `DISABLE_XAML_GENERATED_MAIN`
- [ ] GPO check and singleton mutex (match Launcher UI pattern)
- [ ] `DispatcherQueue` setup
- [ ] Create empty MainWindow shell
- [ ] Verify project builds and window displays
**Success criteria:** Empty editor launches successfully. Existing functionality untouched.
---
### Milestone 3: Main Editor Page Migration
**Goal:** Move the primary workspace management experience to WinUI.
This is likely the largest milestone.
- [ ] Port the main editor page layout (workspace list, search, sort, create button)
- [ ] Migrate DataTemplates (workspace items with app lists)
- [ ] Convert `Expander` controls (WPF Expander → WinUI Expander)
- [ ] Port `Style.Triggers``VisualStateManager`
- [ ] Wire ViewModel data binding (`ObservableCollection`, `INotifyPropertyChanged`)
- [ ] Migrate `.resx` strings → `.resw` with `x:Uid` pattern
**Success criteria:** Workspace list renders. Search works. Sort works. Workspace selection works.
---
### Milestone 4: Snapshot + Overlay Migration
**Goal:** Move the workspace capture experience to WinUI.
This is where most functional regressions will likely surface due to WinUI windowing limitations.
- [ ] Port SnapshotWindow (capture overlay)
- [ ] Port OverlayWindow (desktop overlay during capture)
- [ ] Wire navigation flow: Editor → Snapshot → return to Editor
- [ ] Handle transparent/topmost window behavior via `AppWindow`/HWND interop
**Success criteria:** New workspace creation works end-to-end. Capture flow works. Return-to-editor flow works.
---
### Milestone 5: Final Integration & WPF Removal
**Goal:** Complete migration and remove legacy implementation.
- [ ] Remove old WPF `WorkspacesEditor` project
- [ ] Update installer references (WiX, signing)
- [ ] Update solution file (`PowerToys.slnx`)
- [ ] Update verification script paths
- [ ] Update `WorkspacesEditorUITest` to reference new project
- [ ] Accessibility validation (keyboard nav, Narrator, High Contrast)
- [ ] Theme validation (Light/Dark/HC)
- [ ] Final test pass
**Success criteria:** All existing scenarios pass. WPF editor removed. WinUI editor becomes production implementation.
---
## Validation
### Functional Testing
- [ ] Create new workspace (capture, name, save)
- [ ] Edit workspace (rename, remove apps, modify positions)
- [ ] Launch workspace from Editor
- [ ] Delete workspace
- [ ] Search workspaces by name/app
- [ ] Sort workspaces (name, created, last launched)
- [ ] Create desktop shortcut for workspace
- [ ] Launch & Edit flow (re-capture)
### Accessibility Testing
- [ ] Keyboard-only navigation through all Editor controls
- [ ] Tab order logical and consistent
- [ ] Narrator announces all interactive elements
- [ ] Focus management after dialogs and page transitions
- [ ] High Contrast mode renders correctly
### Visual Testing
- [ ] 100%, 150%, 200% DPI scaling
- [ ] Light Theme
- [ ] Dark Theme
- [ ] Multiple monitor environments
- [ ] Window resizing behavior
---
## Expected Outcome
The Workspaces module will be completely free of WPF dependencies. Both the Editor and Launcher UI will run on WinUI 3, providing a consistent Fluent UI experience. The patterns established in the Launcher UI migration (project structure, IPC handling, resource management, accessibility approach) will be directly reusable, reducing the learning curve for this larger effort.
**Estimated effort:** 3040 hours, single PR with 5 milestones.

View File

@@ -22,7 +22,7 @@
const std::wstring workspacesLauncherPath = L"PowerToys.WorkspacesLauncher.exe";
const std::wstring workspacesWindowArrangerPath = L"PowerToys.WorkspacesWindowArranger.exe";
const std::wstring workspacesSnapshotToolPath = L"PowerToys.WorkspacesSnapshotTool.exe";
const std::wstring workspacesEditorPath = L"PowerToys.WorkspacesEditor.exe";
const std::wstring workspacesEditorPath = L"WinUI3Apps\\PowerToys.WorkspacesEditor.exe";
namespace
{
@@ -327,7 +327,7 @@ private:
SHELLEXECUTEINFOW sei{ sizeof(sei) };
sei.fMask = SEE_MASK_NOCLOSEPROCESS;
sei.lpFile = L"PowerToys.WorkspacesEditor.exe";
sei.lpFile = workspacesEditorPath.c_str();
sei.nShow = SW_SHOWNORMAL;
sei.lpParameters = executable_args.data();
if (ShellExecuteExW(&sei))

View File

@@ -171,6 +171,7 @@ FONT 8, "MS Shell Dlg", 400, 0, 0x1
BEGIN
CONTROL "",IDC_HOTKEY,"msctls_hotkey32",WS_BORDER | WS_TABSTOP,59,57,80,12
LTEXT "After toggling ZoomIt you can zoom in with the mouse wheel or up and down arrow keys. Exit zoom mode with Escape or by pressing the right mouse button.",IDC_STATIC,7,6,230,26
LTEXT "Copy a zoomed screen with Ctrl+C or save it by typing Ctrl+S. Crop the copy or save region by entering Ctrl+Shift instead of Ctrl.",IDC_STATIC,7,34,230,18
LTEXT "Zoom Toggle:",IDC_STATIC,7,59,51,8
CONTROL "",IDC_ZOOM_SLIDER,"msctls_trackbar32",TBS_AUTOTICKS | TBS_BOTH | TBS_NOTICKS | WS_TABSTOP,53,118,150,15,WS_EX_TRANSPARENT
LTEXT "Specify the initial level of magnification when zooming in:",IDC_STATIC,7,105,230,10
@@ -182,8 +183,6 @@ BEGIN
LTEXT "4.0",IDC_STATIC,190,136,12,8
CONTROL "Animate zoom in and zoom out:",IDC_ANIMATE_ZOOM,"Button",BS_AUTOCHECKBOX | BS_LEFTTEXT | WS_TABSTOP,7,74,116,10
CONTROL "Smooth zoomed image:",IDC_SMOOTH_IMAGE,"Button",BS_AUTOCHECKBOX | BS_LEFTTEXT | WS_TABSTOP,7,88,116,10
LTEXT "Copy a zoomed screen with Ctrl+C or save it by typing Ctrl+S. Crop the copy or save region by entering Ctrl+Shift instead of Ctrl.",IDC_STATIC,7,148,230,17
LTEXT "Copy a zoomed screen with Ctrl+C or save it by typing Ctrl+S. Crop the copy or save region by entering Ctrl+Shift instead of Ctrl.",IDC_STATIC,6,34,230,18
END
DRAW DIALOGEX 0, 0, 260, 228
@@ -315,26 +314,31 @@ BEGIN
PUSHBUTTON "Cancel",IDCANCEL,162,142,50,14
END
SNIP DIALOGEX 0, 0, 260, 80
SNIP DIALOGEX 0, 0, 272, 105
STYLE DS_SETFONT | DS_FIXEDSYS | DS_CONTROL | WS_CHILD | WS_CLIPSIBLINGS | WS_SYSMENU
FONT 8, "MS Shell Dlg", 400, 0, 0x1
BEGIN
LTEXT "Copy a region of the screen to the clipboard or enter the hotkey with the Shift key in the opposite mode to save it to a file.",IDC_STATIC,7,7,230,19
LTEXT "Snip Toggle:",IDC_STATIC,7,33,45,8
CONTROL "",IDC_SNIP_HOTKEY,"msctls_hotkey32",WS_BORDER | WS_TABSTOP,67,32,80,12
LTEXT "Copy text from the selected region to the clipboard:",IDC_STATIC,7,50,230,10
LTEXT "Text Toggle:",IDC_STATIC,7,65,55,8
CONTROL "",IDC_SNIP_OCR_HOTKEY,"msctls_hotkey32",WS_BORDER | WS_TABSTOP,67,63,80,12
LTEXT "Copy a region of the screen to the clipboard, or save it to a file using the save shortcut.",IDC_STATIC,7,7,230,18
RTEXT "Snip Toggle:",IDC_STATIC,22,33,45,8
CONTROL "",IDC_SNIP_HOTKEY,"msctls_hotkey32",WS_BORDER | WS_TABSTOP,71,32,80,12
RTEXT "Snip Save Toggle:",IDC_STATIC,7,49,60,8
CONTROL "",IDC_SNIP_SAVE_HOTKEY,"msctls_hotkey32",WS_BORDER | WS_TABSTOP,71,48,80,12
LTEXT "Copy text from the selected region to the clipboard:",IDC_STATIC,7,66,230,10
RTEXT "Text Toggle:",IDC_STATIC,12,82,55,8
CONTROL "",IDC_SNIP_OCR_HOTKEY,"msctls_hotkey32",WS_BORDER | WS_TABSTOP,71,81,80,12
END
PANORAMA DIALOGEX 0, 0, 260, 105
PANORAMA DIALOGEX 0, 0, 260, 140
STYLE DS_SETFONT | DS_FIXEDSYS | DS_CONTROL | WS_CHILD | WS_CLIPSIBLINGS | WS_SYSMENU
FONT 8, "MS Shell Dlg", 400, 0, 0x1
BEGIN
LTEXT "Capture a scrolling panorama of a selected screen region. Select the area, then scroll the content. Move slowly and consistently, and do not rewind to previously covered areas. Press the hotkey again or with Shift to save to a file.",IDC_STATIC,7,7,245,33
LTEXT "Panorama Toggle:",IDC_STATIC,7,74,63,8
CONTROL "",IDC_SNIP_PANORAMA_HOTKEY,"msctls_hotkey32",WS_BORDER | WS_TABSTOP,73,72,80,12
LTEXT "For the best results, scroll slowly and at a constant rate, do not include stationary content (like scrollbars) in the capture area, and avoid content that is changing (e.g., animations or videos). ",IDC_STATIC,7,41,245,30
LTEXT "Capture a scrolling panorama of a selected screen region. Select the area, then scroll the content. Move slowly and consistently, and do not rewind to previously covered areas.",IDC_STATIC,7,7,245,30
LTEXT "Press the panorama toggle again to copy to the clipboard, or use the save shortcut to save to a file.",IDC_STATIC,7,39,245,18
LTEXT "For the best results, scroll slowly and at a constant rate, do not include stationary content (like scrollbars) in the capture area, and avoid content that is changing (e.g., animations or videos). ",IDC_STATIC,7,62,245,30
LTEXT "Panorama Toggle:",IDC_STATIC,7,95,80,8
CONTROL "",IDC_SNIP_PANORAMA_HOTKEY,"msctls_hotkey32",WS_BORDER | WS_TABSTOP,90,93,80,12
LTEXT "Panorama Save Toggle:",IDC_STATIC,7,111,80,8
CONTROL "",IDC_SNIP_PANORAMA_SAVE_HOTKEY,"msctls_hotkey32",WS_BORDER | WS_TABSTOP,90,109,80,12
END
DEMOTYPE DIALOGEX 0, 0, 260, 249
@@ -456,7 +460,9 @@ BEGIN
"SNIP", DIALOG
BEGIN
LEFTMARGIN, 7
RIGHTMARGIN, 265
TOPMARGIN, 7
BOTTOMMARGIN, 98
END
"PANORAMA", DIALOG

View File

@@ -17,7 +17,9 @@ DWORD g_BreakToggleKey = ((HOTKEYF_CONTROL) << 8)| '3';
DWORD g_DemoTypeToggleKey = ((HOTKEYF_CONTROL) << 8) | '7';
DWORD g_RecordToggleKey = ((HOTKEYF_CONTROL) << 8) | '5';
DWORD g_SnipToggleKey = ((HOTKEYF_CONTROL) << 8) | '6';
DWORD g_SnipSaveToggleKey = ((HOTKEYF_CONTROL | HOTKEYF_SHIFT) << 8) | '6';
DWORD g_SnipPanoramaToggleKey = ((HOTKEYF_CONTROL) << 8) | '8';
DWORD g_SnipPanoramaSaveToggleKey = ((HOTKEYF_CONTROL | HOTKEYF_SHIFT) << 8) | '8';
DWORD g_SnipOcrToggleKey = ((HOTKEYF_CONTROL | HOTKEYF_ALT) << 8) | '6';
DWORD g_ShowExpiredTime = 1;
@@ -80,7 +82,9 @@ REG_SETTING RegSettings[] = {
{ L"DrawToggleKey", SETTING_TYPE_DWORD, 0, &g_DrawToggleKey, static_cast<DOUBLE>(g_DrawToggleKey) },
{ L"RecordToggleKey", SETTING_TYPE_DWORD, 0, &g_RecordToggleKey, static_cast<DOUBLE>(g_RecordToggleKey) },
{ L"SnipToggleKey", SETTING_TYPE_DWORD, 0, &g_SnipToggleKey, static_cast<DOUBLE>(g_SnipToggleKey) },
{ L"SnipSaveToggleKey", SETTING_TYPE_DWORD, 0, &g_SnipSaveToggleKey, static_cast<DOUBLE>(g_SnipSaveToggleKey) },
{ L"SnipPanoramaToggleKey", SETTING_TYPE_DWORD, 0, &g_SnipPanoramaToggleKey, static_cast<DOUBLE>(g_SnipPanoramaToggleKey) },
{ L"SnipPanoramaSaveToggleKey", SETTING_TYPE_DWORD, 0, &g_SnipPanoramaSaveToggleKey, static_cast<DOUBLE>(g_SnipPanoramaSaveToggleKey) },
{ L"SnipOcrToggleKey", SETTING_TYPE_DWORD, 0, &g_SnipOcrToggleKey, static_cast<DOUBLE>(g_SnipOcrToggleKey) },
{ L"PenColor", SETTING_TYPE_DWORD, 0, &g_PenColor, static_cast<DOUBLE>(g_PenColor) },
{ L"PenWidth", SETTING_TYPE_DWORD, 0, &g_RootPenWidth, static_cast<DOUBLE>(g_RootPenWidth) },

View File

@@ -174,6 +174,8 @@ DWORD g_RecordToggleMod;
DWORD g_SnipToggleMod;
DWORD g_SnipPanoramaToggleMod;
DWORD g_SnipOcrToggleMod;
DWORD g_SnipSaveToggleMod;
DWORD g_SnipPanoramaSaveToggleMod;
BOOLEAN g_ZoomOnLiveZoom = FALSE;
DWORD g_PenWidth = PEN_WIDTH;
@@ -212,7 +214,10 @@ BOOL g_RecordToggle = FALSE;
BOOL g_RecordCropping = FALSE;
SelectRectangle g_SelectRectangle;
WebcamPreviewWindow g_WebcamPreview;
// The full path of the last saved recording file.
std::wstring g_RecordingSaveLocation;
// The last user-chosen recording filename. Used to construct unique recording filenames.
std::wstring g_RecordingSaveBaseFilename;
std::wstring g_ScreenshotSaveLocation;
winrt::IDirect3DDevice g_RecordDevice{ nullptr };
std::shared_ptr<VideoRecordingSession> g_RecordingSession = nullptr;
@@ -3582,12 +3587,16 @@ void RegisterAllHotkeys(HWND hWnd)
}
if (g_SnipToggleKey) {
registerHotkey( SNIP_HOTKEY, g_SnipToggleMod, g_SnipToggleKey & 0xFF );
registerHotkey( SNIP_SAVE_HOTKEY, ( g_SnipToggleMod ^ MOD_SHIFT ), g_SnipToggleKey & 0xFF );
}
if( g_SnipPanoramaToggleKey &&
if (g_SnipSaveToggleKey) {
registerHotkey( SNIP_SAVE_HOTKEY, g_SnipSaveToggleMod, g_SnipSaveToggleKey & 0xFF);
}
if (g_SnipPanoramaToggleKey &&
(g_SnipPanoramaToggleKey != g_SnipToggleKey || g_SnipPanoramaToggleMod != g_SnipToggleMod) ) {
registerHotkey( SNIP_PANORAMA_HOTKEY, g_SnipPanoramaToggleMod | MOD_NOREPEAT, g_SnipPanoramaToggleKey & 0xFF );
registerHotkey( SNIP_PANORAMA_SAVE_HOTKEY, ( g_SnipPanoramaToggleMod ^ MOD_SHIFT ) | MOD_NOREPEAT, g_SnipPanoramaToggleKey & 0xFF );
}
if (g_SnipPanoramaSaveToggleKey) {
registerHotkey( SNIP_PANORAMA_SAVE_HOTKEY, g_SnipPanoramaSaveToggleMod | MOD_NOREPEAT, g_SnipPanoramaSaveToggleKey & 0xFF );
}
if (g_SnipOcrToggleKey) {
registerHotkey( SNIP_OCR_HOTKEY, g_SnipOcrToggleMod, g_SnipOcrToggleKey & 0xFF );
@@ -4816,6 +4825,8 @@ INT_PTR CALLBACK OptionsProc( HWND hDlg, UINT message,
TCHAR text[32];
DWORD newToggleKey, newTimeout, newToggleMod, newBreakToggleKey, newDemoTypeToggleKey, newRecordToggleKey, newSnipToggleKey, newSnipPanoramaToggleKey, newSnipOcrToggleKey;
DWORD newDrawToggleKey, newDrawToggleMod, newBreakToggleMod, newDemoTypeToggleMod, newRecordToggleMod, newSnipToggleMod, newSnipPanoramaToggleMod, newSnipOcrToggleMod;
DWORD newSnipSaveToggleKey, newSnipSaveToggleMod;
DWORD newSnipPanoramaSaveToggleKey, newSnipPanoramaSaveToggleMod;
DWORD newLiveZoomToggleKey, newLiveZoomToggleMod;
static std::vector<std::pair<std::wstring, std::wstring>> microphones;
@@ -5050,7 +5061,9 @@ INT_PTR CALLBACK OptionsProc( HWND hDlg, UINT message,
if( g_DemoTypeToggleKey ) SendMessage( GetDlgItem( g_OptionsTabs[DEMOTYPE_PAGE].hPage, IDC_DEMOTYPE_HOTKEY ), HKM_SETHOTKEY, g_DemoTypeToggleKey, 0 );
if( g_RecordToggleKey ) SendMessage( GetDlgItem( g_OptionsTabs[RECORD_PAGE].hPage, IDC_RECORD_HOTKEY), HKM_SETHOTKEY, g_RecordToggleKey, 0 );
if( g_SnipToggleKey) SendMessage( GetDlgItem( g_OptionsTabs[SNIP_PAGE].hPage, IDC_SNIP_HOTKEY), HKM_SETHOTKEY, g_SnipToggleKey, 0 );
if( g_SnipSaveToggleKey) SendMessage( GetDlgItem( g_OptionsTabs[SNIP_PAGE].hPage, IDC_SNIP_SAVE_HOTKEY), HKM_SETHOTKEY, g_SnipSaveToggleKey, 0 );
if( g_SnipPanoramaToggleKey) SendMessage( GetDlgItem( g_OptionsTabs[PANORAMA_PAGE].hPage, IDC_SNIP_PANORAMA_HOTKEY), HKM_SETHOTKEY, g_SnipPanoramaToggleKey, 0 );
if( g_SnipPanoramaSaveToggleKey) SendMessage( GetDlgItem( g_OptionsTabs[PANORAMA_PAGE].hPage, IDC_SNIP_PANORAMA_SAVE_HOTKEY), HKM_SETHOTKEY, g_SnipPanoramaSaveToggleKey, 0 );
if( g_SnipOcrToggleKey) SendMessage( GetDlgItem( g_OptionsTabs[SNIP_PAGE].hPage, IDC_SNIP_OCR_HOTKEY), HKM_SETHOTKEY, g_SnipOcrToggleKey, 0 );
CheckDlgButton( hDlg, IDC_SHOW_TRAY_ICON,
g_ShowTrayIcon ? BST_CHECKED: BST_UNCHECKED );
@@ -5512,7 +5525,9 @@ INT_PTR CALLBACK OptionsProc( HWND hDlg, UINT message,
newDemoTypeToggleKey = static_cast<DWORD>(SendMessage( GetDlgItem( g_OptionsTabs[DEMOTYPE_PAGE].hPage, IDC_DEMOTYPE_HOTKEY ), HKM_GETHOTKEY, 0, 0 ));
newRecordToggleKey = static_cast<DWORD>(SendMessage(GetDlgItem(g_OptionsTabs[RECORD_PAGE].hPage, IDC_RECORD_HOTKEY), HKM_GETHOTKEY, 0, 0));
newSnipToggleKey = static_cast<DWORD>(SendMessage( GetDlgItem( g_OptionsTabs[SNIP_PAGE].hPage, IDC_SNIP_HOTKEY), HKM_GETHOTKEY, 0, 0 ));
newSnipSaveToggleKey = static_cast<DWORD>(SendMessage( GetDlgItem( g_OptionsTabs[SNIP_PAGE].hPage, IDC_SNIP_SAVE_HOTKEY), HKM_GETHOTKEY, 0, 0 ));
newSnipPanoramaToggleKey = static_cast<DWORD>(SendMessage( GetDlgItem( g_OptionsTabs[PANORAMA_PAGE].hPage, IDC_SNIP_PANORAMA_HOTKEY), HKM_GETHOTKEY, 0, 0 ));
newSnipPanoramaSaveToggleKey = static_cast<DWORD>(SendMessage( GetDlgItem( g_OptionsTabs[PANORAMA_PAGE].hPage, IDC_SNIP_PANORAMA_SAVE_HOTKEY), HKM_GETHOTKEY, 0, 0 ));
newSnipOcrToggleKey = static_cast<DWORD>(SendMessage( GetDlgItem( g_OptionsTabs[SNIP_PAGE].hPage, IDC_SNIP_OCR_HOTKEY), HKM_GETHOTKEY, 0, 0 ));
newToggleMod = GetKeyMod( newToggleKey );
@@ -5522,7 +5537,9 @@ INT_PTR CALLBACK OptionsProc( HWND hDlg, UINT message,
newDemoTypeToggleMod = GetKeyMod( newDemoTypeToggleKey );
newRecordToggleMod = GetKeyMod(newRecordToggleKey);
newSnipToggleMod = GetKeyMod( newSnipToggleKey );
newSnipSaveToggleMod = GetKeyMod( newSnipSaveToggleKey );
newSnipPanoramaToggleMod = GetKeyMod( newSnipPanoramaToggleKey );
newSnipPanoramaSaveToggleMod = GetKeyMod( newSnipPanoramaSaveToggleKey );
newSnipOcrToggleMod = GetKeyMod( newSnipOcrToggleKey );
g_SliderZoomLevel = static_cast<int>(SendMessage( GetDlgItem(g_OptionsTabs[ZOOM_PAGE].hPage, IDC_ZOOM_SLIDER), TBM_GETPOS, 0, 0 ));
@@ -5591,25 +5608,41 @@ INT_PTR CALLBACK OptionsProc( HWND hDlg, UINT message,
}
else if (newSnipToggleKey &&
(!RegisterHotKey(GetParent(hDlg), SNIP_HOTKEY, newSnipToggleMod, newSnipToggleKey & 0xFF) ||
!RegisterHotKey(GetParent(hDlg), SNIP_SAVE_HOTKEY, (newSnipToggleMod ^ MOD_SHIFT), newSnipToggleKey & 0xFF))) {
!RegisterHotKey(GetParent(hDlg), SNIP_HOTKEY, newSnipToggleMod, newSnipToggleKey & 0xFF)) {
MessageBox(hDlg, L"The specified snip hotkey is already in use.\nSelect a different snip hotkey.",
APPNAME, MB_ICONERROR);
UnregisterAllHotkeys(GetParent(hDlg));
break;
}
else if (newSnipSaveToggleKey &&
!RegisterHotKey(GetParent(hDlg), SNIP_SAVE_HOTKEY, newSnipSaveToggleMod, newSnipSaveToggleKey & 0xFF)) {
MessageBox(hDlg, L"The specified snip save hotkey is already in use.\nSelect a different snip save hotkey.",
APPNAME, MB_ICONERROR);
UnregisterAllHotkeys(GetParent(hDlg));
break;
}
else if (newSnipPanoramaToggleKey &&
(newSnipPanoramaToggleKey != newSnipToggleKey || newSnipPanoramaToggleMod != newSnipToggleMod) &&
(!RegisterHotKey(GetParent(hDlg), SNIP_PANORAMA_HOTKEY, newSnipPanoramaToggleMod | MOD_NOREPEAT, newSnipPanoramaToggleKey & 0xFF) ||
!RegisterHotKey(GetParent(hDlg), SNIP_PANORAMA_SAVE_HOTKEY, ( newSnipPanoramaToggleMod ^ MOD_SHIFT ) | MOD_NOREPEAT, newSnipPanoramaToggleKey & 0xFF))) {
!RegisterHotKey(GetParent(hDlg), SNIP_PANORAMA_HOTKEY, newSnipPanoramaToggleMod | MOD_NOREPEAT, newSnipPanoramaToggleKey & 0xFF)) {
MessageBox(hDlg, L"The specified panorama snip hotkey is already in use.\nSelect a different panorama snip hotkey.",
APPNAME, MB_ICONERROR);
UnregisterAllHotkeys(GetParent(hDlg));
break;
}
else if (newSnipPanoramaSaveToggleKey &&
!RegisterHotKey(GetParent(hDlg), SNIP_PANORAMA_SAVE_HOTKEY, newSnipPanoramaSaveToggleMod | MOD_NOREPEAT, newSnipPanoramaSaveToggleKey & 0xFF)) {
MessageBox(hDlg, L"The specified panorama snip save hotkey is already in use.\nSelect a different panorama snip save hotkey.",
APPNAME, MB_ICONERROR);
UnregisterAllHotkeys(GetParent(hDlg));
break;
}
else if (newSnipOcrToggleKey &&
!RegisterHotKey(GetParent(hDlg), SNIP_OCR_HOTKEY, newSnipOcrToggleMod, newSnipOcrToggleKey & 0xFF)) {
@@ -5645,8 +5678,12 @@ INT_PTR CALLBACK OptionsProc( HWND hDlg, UINT message,
g_RecordToggleMod = newRecordToggleMod;
g_SnipToggleKey = newSnipToggleKey;
g_SnipToggleMod = newSnipToggleMod;
g_SnipSaveToggleKey = newSnipSaveToggleKey;
g_SnipSaveToggleMod = newSnipSaveToggleMod;
g_SnipPanoramaToggleKey = newSnipPanoramaToggleKey;
g_SnipPanoramaToggleMod = newSnipPanoramaToggleMod;
g_SnipPanoramaSaveToggleKey = newSnipPanoramaSaveToggleKey;
g_SnipPanoramaSaveToggleMod = newSnipPanoramaSaveToggleMod;
g_SnipOcrToggleKey = newSnipOcrToggleKey;
g_SnipOcrToggleMod = newSnipOcrToggleMod;
reg.WriteRegSettings( RegSettings );
@@ -6737,6 +6774,45 @@ void StopRecording()
}
}
//----------------------------------------------------------------------------
// GetTimestampSuffix
//
// Returns a timestamp string for disambiguating filenames.
// Format: " YYYY-MM-DD HHMMSS", e.g." 2025-11-02 143000".
//
// Used as a suffix for the default recording filename. Ensures
// chronological name sorting in Explorer.
//
//----------------------------------------------------------------------------
static std::wstring GetTimestampSuffix()
{
auto const now = std::chrono::system_clock::now();
auto const in_time_t = std::chrono::system_clock::to_time_t( now );
std::tm buf{};
localtime_s( &buf, &in_time_t );
std::wstringstream ss;
ss << L" " << std::put_time( &buf, L"%Y-%m-%d %H%M%S" );
return ss.str();
}
//----------------------------------------------------------------------------
// IsDefaultRecordingFilename
//
// Determines if the provided filename matches the default recording name.
// Case-insensitive comparison.
//
// Returns:
// true if filename is the default; otherwise false.
//
//----------------------------------------------------------------------------
static bool IsDefaultRecordingFilename(const std::wstring& filename)
{
return CompareStringOrdinal( DEFAULT_RECORDING_FILE, -1, filename.c_str(), -1, TRUE ) == CSTR_EQUAL
|| CompareStringOrdinal( DEFAULT_GIF_RECORDING_FILE, -1, filename.c_str(), -1, TRUE ) == CSTR_EQUAL;
}
//----------------------------------------------------------------------------
//
@@ -6791,19 +6867,70 @@ std::wstring GetUniqueFilename(const std::wstring& lastSavePath, const wchar_t*
//
// GetUniqueRecordingFilename
//
// Gets a unique file name for recording saves, using the " (N)" suffix
// approach so that the user can hit OK without worrying about overwriting
// if they are making multiple recordings in one session or don't want to
// always see an overwrite dialog or stop to clean up files.
// Generates a unique filename to be suggested in the "Save As" recording
// dialog, based on the user's last chosen filename and save location.
// This allows the user to quickly save a recording without worrying about
// manual renaming to prevent overwriting earlier recordings.
//
// There are two distinct behaviors based on the last used filename:
//
// 1. For the default filename ("Recording.mp4"):
// Generates a more descriptive name by appending a timestamp, e.g.
// "Recording 2025-11-03 143015.mp4". This ensures chronological sorting
// in Explorer when ordered by name and is consistent with other tools.
//
// 2. For custom filenames (e.g. "Presentation.mp4"):
// Appends a numeric suffix if the file already exists, e.g.
// "Presentation (1).mp4", "Presentation (2).mp4", etc.
//
// Returns:
// A unique filename (without folder path).
//
// Relies upon the global state of `g_RecordingSaveLocation` and
// `g_RecordingSaveBaseFilename`.
//
//----------------------------------------------------------------------------
auto GetUniqueRecordingFilename()
static auto GetUniqueRecordingFilename()
{
const wchar_t* defaultFile = (g_RecordingFormat == RecordingFormat::GIF)
? DEFAULT_GIF_RECORDING_FILE
: DEFAULT_RECORDING_FILE;
return GetUniqueFilename(g_RecordingSaveLocation, defaultFile, FOLDERID_Videos);
// Without a remembered filename, suggest the default name for the current format.
std::wstring baseFilename = g_RecordingSaveBaseFilename.empty()
? std::wstring( defaultFile )
: g_RecordingSaveBaseFilename;
std::filesystem::path basePath{ baseFilename };
// For the default filename, append a timestamp so successive default saves stay
// unique and sort chronologically in Explorer.
if ( IsDefaultRecordingFilename( basePath.filename().wstring() ) )
{
return basePath.stem().wstring() + GetTimestampSuffix() + basePath.extension().wstring();
}
// For custom filenames, append a numeric suffix to avoid collisions.
std::filesystem::path directory;
if ( !g_RecordingSaveLocation.empty() )
directory = std::filesystem::path( g_RecordingSaveLocation ).parent_path();
if ( directory.empty() )
{
wil::unique_cotaskmem_string folderPath;
if ( SUCCEEDED( SHGetKnownFolderPath( FOLDERID_Videos, KF_FLAG_DEFAULT, nullptr, folderPath.put() ) ) )
directory = folderPath.get();
}
std::wstring baseStem = basePath.stem().wstring();
std::wstring baseExtension = basePath.extension().wstring();
std::filesystem::path testPath = directory / ( baseStem + baseExtension );
for ( int index = 1; std::filesystem::exists( testPath ); index++ )
{
testPath = directory / ( baseStem + L" (" + std::to_wstring( index ) + L')' + baseExtension );
}
return testPath.filename().wstring();
}
//----------------------------------------------------------------------------
@@ -6835,7 +6962,7 @@ auto GetUniqueScreenshotFilename()
//
// StartRecordingAsync
//
// Starts the screen recording.
// Initiates screen recording and handles the save dialog workflow.
//
//----------------------------------------------------------------------------
winrt::fire_and_forget StartRecordingAsync( HWND hWnd, LPRECT rcCrop, HWND hWndRecord ) try
@@ -7080,8 +7207,30 @@ winrt::fire_and_forget StartRecordingAsync( HWND hWnd, LPRECT rcCrop, HWND hWndR
if (!finalPath.empty())
{
auto path = std::filesystem::path(finalPath);
// Remember the user's chosen filename and apply a timestamp to default
// names so successive saves stay unique and sort chronologically.
std::wstring filename = path.filename().wstring();
std::wstring finalFilename = filename;
if ( IsDefaultRecordingFilename( filename ) )
{
// The user accepted or re-typed the default filename. Remember it so the
// next suggestion also uses a timestamp, and append one to this save.
g_RecordingSaveBaseFilename = filename;
finalFilename = path.stem().wstring() + GetTimestampSuffix() + path.extension().wstring();
}
else if ( CompareStringOrdinal( suggestedName.c_str(), -1, filename.c_str(), -1, TRUE ) != CSTR_EQUAL )
{
// The user chose their own filename instead of the suggested one. Remember
// it so future suggestions use numeric suffixes based on this name.
g_RecordingSaveBaseFilename = filename;
}
// The path actually written to disk (with any timestamp applied).
std::wstring savedPath = ( path.parent_path() / finalFilename ).wstring();
winrt::StorageFolder folder{ co_await winrt::StorageFolder::GetFolderFromPathAsync(path.parent_path().c_str()) };
destFile = co_await folder.CreateFileAsync(path.filename().c_str(), winrt::CreationCollisionOption::ReplaceExisting);
destFile = co_await folder.CreateFileAsync(finalFilename.c_str(), winrt::CreationCollisionOption::ReplaceExisting);
// If user trimmed, use the trimmed file
winrt::StorageFile sourceFile = file;
@@ -7099,8 +7248,8 @@ winrt::fire_and_forget StartRecordingAsync( HWND hWnd, LPRECT rcCrop, HWND hWndR
try { co_await file.DeleteAsync(); } catch (...) {}
}
// Use finalPath directly - destFile.Path() may be stale after MoveAndReplaceAsync
g_RecordingSaveLocation = finalPath;
// Use savedPath directly - destFile.Path() may be stale after MoveAndReplaceAsync
g_RecordingSaveLocation = savedPath;
// Update the registry buffer and save to persist across app restarts
wcsncpy_s(g_RecordingSaveLocationBuffer, g_RecordingSaveLocation.c_str(), _TRUNCATE);
reg.WriteRegSettings(RegSettings);
@@ -7600,7 +7749,9 @@ LRESULT APIENTRY MainWndProc(
g_BreakToggleMod = GetKeyMod( g_BreakToggleKey );
g_DemoTypeToggleMod = GetKeyMod( g_DemoTypeToggleKey );
g_SnipToggleMod = GetKeyMod( g_SnipToggleKey );
g_SnipSaveToggleMod = GetKeyMod( g_SnipSaveToggleKey );
g_SnipPanoramaToggleMod = GetKeyMod( g_SnipPanoramaToggleKey );
g_SnipPanoramaSaveToggleMod = GetKeyMod( g_SnipPanoramaSaveToggleKey );
g_SnipOcrToggleMod = GetKeyMod( g_SnipOcrToggleKey );
g_RecordToggleMod = GetKeyMod( g_RecordToggleKey );
@@ -7651,23 +7802,37 @@ LRESULT APIENTRY MainWndProc(
}
else if (g_SnipToggleKey &&
(!RegisterHotKey(hWnd, SNIP_HOTKEY, g_SnipToggleMod, g_SnipToggleKey & 0xFF) ||
!RegisterHotKey(hWnd, SNIP_SAVE_HOTKEY, (g_SnipToggleMod ^ MOD_SHIFT), g_SnipToggleKey & 0xFF))) {
!RegisterHotKey(hWnd, SNIP_HOTKEY, g_SnipToggleMod, g_SnipToggleKey & 0xFF)) {
MessageBox(hWnd, L"The specified snip hotkey is already in use.\nSelect a different snip hotkey.",
APPNAME, MB_ICONERROR);
showOptions = TRUE;
}
else if (g_SnipSaveToggleKey &&
!RegisterHotKey(hWnd, SNIP_SAVE_HOTKEY, g_SnipSaveToggleMod, g_SnipSaveToggleKey & 0xFF)) {
MessageBox(hWnd, L"The specified snip save hotkey is already in use.\nSelect a different snip save hotkey.",
APPNAME, MB_ICONERROR);
showOptions = TRUE;
}
else if (g_SnipPanoramaToggleKey &&
(g_SnipPanoramaToggleKey != g_SnipToggleKey || g_SnipPanoramaToggleMod != g_SnipToggleMod) &&
(!RegisterHotKey(hWnd, SNIP_PANORAMA_HOTKEY, g_SnipPanoramaToggleMod | MOD_NOREPEAT, g_SnipPanoramaToggleKey & 0xFF) ||
!RegisterHotKey(hWnd, SNIP_PANORAMA_SAVE_HOTKEY, ( g_SnipPanoramaToggleMod ^ MOD_SHIFT ) | MOD_NOREPEAT, g_SnipPanoramaToggleKey & 0xFF))) {
!RegisterHotKey(hWnd, SNIP_PANORAMA_HOTKEY, g_SnipPanoramaToggleMod | MOD_NOREPEAT, g_SnipPanoramaToggleKey & 0xFF)) {
MessageBox(hWnd, L"The specified panorama snip hotkey is already in use.\nSelect a different panorama snip hotkey.",
APPNAME, MB_ICONERROR);
showOptions = TRUE;
}
else if (g_SnipPanoramaSaveToggleKey &&
!RegisterHotKey(hWnd, SNIP_PANORAMA_SAVE_HOTKEY, g_SnipPanoramaSaveToggleMod | MOD_NOREPEAT, g_SnipPanoramaSaveToggleKey & 0xFF)) {
MessageBox(hWnd, L"The specified panorama snip save hotkey is already in use.\nSelect a different panorama snip save hotkey.",
APPNAME, MB_ICONERROR);
showOptions = TRUE;
}
else if (g_SnipOcrToggleKey &&
!RegisterHotKey(hWnd, SNIP_OCR_HOTKEY, g_SnipOcrToggleMod, g_SnipOcrToggleKey & 0xFF)) {
@@ -10254,7 +10419,9 @@ LRESULT APIENTRY MainWndProc(
g_BreakToggleMod = GetKeyMod(g_BreakToggleKey);
g_DemoTypeToggleMod = GetKeyMod(g_DemoTypeToggleKey);
g_SnipToggleMod = GetKeyMod(g_SnipToggleKey);
g_SnipSaveToggleMod = GetKeyMod(g_SnipSaveToggleKey);
g_SnipPanoramaToggleMod = GetKeyMod(g_SnipPanoramaToggleKey);
g_SnipPanoramaSaveToggleMod = GetKeyMod(g_SnipPanoramaSaveToggleKey);
g_SnipOcrToggleMod = GetKeyMod(g_SnipOcrToggleKey);
g_RecordToggleMod = GetKeyMod(g_RecordToggleKey);
BOOL showOptions = FALSE;
@@ -10317,8 +10484,7 @@ LRESULT APIENTRY MainWndProc(
}
if (g_SnipToggleKey)
{
if (!RegisterHotKey(hWnd, SNIP_HOTKEY, g_SnipToggleMod, g_SnipToggleKey & 0xFF) ||
!RegisterHotKey(hWnd, SNIP_SAVE_HOTKEY, (g_SnipToggleMod ^ MOD_SHIFT), g_SnipToggleKey & 0xFF))
if (!RegisterHotKey(hWnd, SNIP_HOTKEY, g_SnipToggleMod, g_SnipToggleKey & 0xFF))
{
if(!g_StartedByPowerToys)
{
@@ -10327,11 +10493,21 @@ LRESULT APIENTRY MainWndProc(
showOptions = TRUE;
}
}
if (g_SnipSaveToggleKey)
{
if (!RegisterHotKey(hWnd, SNIP_SAVE_HOTKEY, g_SnipSaveToggleMod, g_SnipSaveToggleKey & 0xFF))
{
if(!g_StartedByPowerToys)
{
MessageBox(hWnd, L"The specified snip save hotkey is already in use.\nSelect a different snip save hotkey.", APPNAME, MB_ICONERROR);
}
showOptions = TRUE;
}
}
if (g_SnipPanoramaToggleKey &&
(g_SnipPanoramaToggleKey != g_SnipToggleKey || g_SnipPanoramaToggleMod != g_SnipToggleMod))
{
if (!RegisterHotKey(hWnd, SNIP_PANORAMA_HOTKEY, g_SnipPanoramaToggleMod | MOD_NOREPEAT, g_SnipPanoramaToggleKey & 0xFF) ||
!RegisterHotKey(hWnd, SNIP_PANORAMA_SAVE_HOTKEY, ( g_SnipPanoramaToggleMod ^ MOD_SHIFT ) | MOD_NOREPEAT, g_SnipPanoramaToggleKey & 0xFF))
if (!RegisterHotKey(hWnd, SNIP_PANORAMA_HOTKEY, g_SnipPanoramaToggleMod | MOD_NOREPEAT, g_SnipPanoramaToggleKey & 0xFF))
{
if(!g_StartedByPowerToys)
{
@@ -10340,6 +10516,17 @@ LRESULT APIENTRY MainWndProc(
showOptions = TRUE;
}
}
if (g_SnipPanoramaSaveToggleKey)
{
if (!RegisterHotKey(hWnd, SNIP_PANORAMA_SAVE_HOTKEY, g_SnipPanoramaSaveToggleMod | MOD_NOREPEAT, g_SnipPanoramaSaveToggleKey & 0xFF))
{
if(!g_StartedByPowerToys)
{
MessageBox(hWnd, L"The specified panorama snip save hotkey is already in use.\nSelect a different panorama snip save hotkey.", APPNAME, MB_ICONERROR);
}
showOptions = TRUE;
}
}
if (g_SnipOcrToggleKey)
{
if (!RegisterHotKey(hWnd, SNIP_OCR_HOTKEY, g_SnipOcrToggleMod, g_SnipOcrToggleKey & 0xFF))

View File

@@ -93,7 +93,6 @@
#include <algorithm>
#include <filesystem>
#include <future>
#include <regex>
#include <fstream>
#include <sstream>

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