Compare commits

..

10 Commits

Author SHA1 Message Date
Muyuan Li (from Dev Box)
c73d51f804 Apply XAML styling fixes
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-07-01 17:29:18 +08:00
Muyuan Li (from Dev Box)
20ea79af28 Address PR review round 4: stale UI, debounce, import validation, trust dialog
- Fix early return in RefreshWslDistros to still update with default-only list
- Store all PythonScriptActions (not just IsShown=true) so hide logic works
- Pass cancellation token to Task.Delay in debounce handler
- Use FileNotFoundException(message, fileName) constructor properly
- Display script SHA-256 hash in trust dialog for user verification
- Validate Python import names before embedding in shell commands

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-07-01 16:32:03 +08:00
Muyuan Li (from Dev Box)
ea63d6cd1d Address PR review round 3: error handling, resource cleanup, process lifecycle
- Fix PythonScriptNotFound to format {0} placeholder and use PasteActionException
- Dispose old CancellationTokenSource before creating new one in debounce handler
- Remove .Wait() on UI-scheduled task (fire-and-forget is sufficient)
- Add WaitForExit after Kill() in RefreshWslDistros to prevent ReadToEnd blocking
- Localize '(System default)' WSL distro display name via resource string
- Kill child Python/WSL process on user cancellation to prevent orphaned processes

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-07-01 15:50:34 +08:00
Muyuan Li (from Dev Box)
dfb553dd20 Address PR review round 2: HTML format, presence-based tags, clipboard handling
- Fix TryParseTag to support presence-based @advancedpaste:disabled tag
- Change 'if not input_value' to 'if input_value is None' (empty string is valid)
- Wrap HTML output with HtmlFormatHelper.CreateHtmlFormat for CF_HTML compliance
- Add try-catch for FileNotFoundException during ComputeHash
- Change else-if to sequential ifs in DataPackageFromViewAsync (preserve all formats)
- Remove unused System.* PackageReferences from UITest project
- Fix '..' typo to '...' in search placeholder text

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-07-01 15:27:54 +08:00
Muyuan Li (from Dev Box)
86ddc6b5d1 Address PR review comments: security, UX, and correctness fixes
- Fix ScriptsFolder to fall back to default folder when setting is blank
- Align Settings tag parsing with runtime: use @advancedpaste:disabled
- Remove misleading 'written back to headers' description string
- Add modifier release/restore to SendInput Ctrl+C path (matches Ctrl+V)
- Sanitize pip package names to prevent shell metacharacter injection
- Fix RefreshWslDistros to WaitForExit before ReadToEnd (prevents hang)

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-07-01 15:01:18 +08:00
Muyuan Li (from Dev Box)
950ee6ae7d Remove unnecessary System.Text.Json PackageReference (NU1510 after rebase)
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-06-30 17:16:16 +08:00
Muyuan Li (from Dev Box)
c3b87795b3 Address review round 3: remove PythonScript kernel function, fix files format matching
- Remove KernelFunctionDescription from PythonScript to prevent broken AI kernel registration
- Normalize file/files format names in _runner.py so files-input scripts match correctly

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-06-30 16:28:41 +08:00
Muyuan Li (from Dev Box)
961ff9319c Address review round 2: serialization context, discovery alignment, remove unused file write
- Register Python script settings types in SettingsSerializationContext for AOT safety
- Align Settings discovery with runtime: scan entire file, reject multi-function scripts
- Remove unused ap_input.json file write on Windows execution path (stdin is used)

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-06-30 16:16:36 +08:00
Muyuan Li (from Dev Box)
541eb8a440 Address review round 1: fix reverted behaviors, null guard, localization, docs
- Restore FuzzTests to use UTF-8 decoding and GetAwaiter().GetResult()
- Restore defensive try/catch in JsonHelper.ToJsonFromXmlOrCsvAsync
- Add null guard for ReadMetadata in PasteFormatExecutor
- Restore is_enabled_by_default() override in dllmain.cpp
- Localize hard-coded strings in AdvancedPastePage.xaml via x:Uid
- Fix _runner.py docstring to include audio/video input types
- Fix typos in UITestAdvancedPaste.md
- Filter Settings script list to match runtime discovery behavior

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-06-30 16:01:02 +08:00
Muyuan Li (from Dev Box)
7a2b07a3c9 Advanced Paste: Add Python script extension support
Enable users to write custom clipboard transformation scripts in Python
that integrate directly into the Advanced Paste menu. Scripts are auto-
discovered from a configurable folder and can run on native Windows
Python or inside WSL.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-06-30 15:42:18 +08:00
185 changed files with 10532 additions and 6209 deletions

View File

@@ -1234,8 +1234,6 @@ NOTSRCCOPY
NOTSRCERASE
Notupdated
notwindows
NOTXORPEN
Nouveaut
nowarn
NOZORDER
NPH

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",
"WinUI3Apps\\PowerToys.WorkspacesEditor.exe",
"WinUI3Apps\\PowerToys.WorkspacesEditor.dll",
"PowerToys.WorkspacesEditor.exe",
"PowerToys.WorkspacesEditor.dll",
"PowerToys.WorkspacesLauncherUI.exe",
"PowerToys.WorkspacesLauncherUI.dll",
"PowerToys.WorkspacesModuleInterface.dll",

View File

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

View File

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

View File

@@ -0,0 +1,285 @@
# Advanced Paste Python Scripts
Advanced Paste supports user-defined Python scripts that transform clipboard content. Scripts are
discovered automatically from a configurable folder and appear as actions in the Advanced Paste UI.
## Quick start
1. Open the scripts folder — by default `%LOCALAPPDATA%\Microsoft\PowerToys\AdvancedPaste\Scripts`.
You can change this in **Settings → Advanced Paste → Python scripts → Scripts folder**.
2. Drop a `.py` file into the folder.
3. Define one `advanced_paste_from_<input>_to_<output>` function (see [Writing a script](#writing-a-script)).
4. Open the Advanced Paste UI (`Win+Shift+V`) — your script will appear in the action list.
> **Important:** Each `.py` file must define exactly one `advanced_paste_from_<input>_to_<output>`
> function. Scripts with zero or multiple such functions are ignored.
## Writing a script
You write a single Python function whose **name** declares both what clipboard input it accepts
and what output type it produces.
No imports from PowerToys are needed — zero setup, zero dependencies on our side.
### Function naming convention
The function name follows the pattern:
```
advanced_paste_from_<input>_to_<output>(<param>)
```
**Input types** (what the function receives):
| Input | Parameter | When it runs |
|-------|-----------|--------------|
| `text` | `str` — clipboard text | Clipboard has text |
| `html` | `str` — clipboard HTML | Clipboard has HTML |
| `image` | `str` — path to temp image file | Clipboard has an image |
| `audio` | `str` — path to audio file | Clipboard has an audio file |
| `video` | `str` — path to video file | Clipboard has a video file |
| `files` | `list[str]` — file paths | Clipboard has files |
**Output types** (what the function produces — declared via `_to_` suffix):
| Output | Effect |
|--------|--------|
| `text` | Sets clipboard to text |
| `html` | Sets clipboard to HTML |
| `image` | Sets clipboard to image |
| `audio` | Sets clipboard to audio file |
| `video` | Sets clipboard to video file |
| `file` | Sets clipboard to a file |
| `files` | Sets clipboard to multiple files |
### Return value
The return value is interpreted according to the declared output type:
| Output type | Expected return value |
|-------------|---------------------|
| `text` | `str` (or any value — will be converted via `str()`) |
| `html` | `str` containing HTML |
| `image` | `str` or `pathlib.Path` pointing to an image file |
| `file` | `str` or `pathlib.Path` pointing to a file |
| `files` | `list` of `str`/`pathlib.Path` file paths |
Returning `None` produces an empty result (no-op).
## Examples
### Minimal — uppercase text
```python
def advanced_paste_from_text_to_text(text):
return text.upper()
```
That's it. No headers required, no imports from PowerToys.
### With optional metadata
```python
# @advancedpaste:name Reverse Text
# @advancedpaste:desc Reverses clipboard text character by character
def advanced_paste_from_text_to_text(text):
return text[::-1]
```
### Text to HTML
```python
# @advancedpaste:name Markdown Table to HTML
# @advancedpaste:desc Convert a markdown table to an HTML table
def advanced_paste_from_text_to_html(text):
headers = text.splitlines()[0].split("|")
return f"<table><tr>{''.join(f'<th>{h.strip()}</th>' for h in headers if h.strip())}</tr></table>"
```
### Image to text (OCR)
```python
# @advancedpaste:requires pytesseract
def advanced_paste_from_image_to_text(image_path):
import pytesseract
return pytesseract.image_to_string(image_path).strip()
```
### Save text as file
```python
import os
from pathlib import Path
import tempfile
def advanced_paste_from_text_to_file(text):
# Use ADVANCED_PASTE_WORK_DIR for WSL compatibility; falls back to temp dir on Windows.
out_dir = os.environ.get("ADVANCED_PASTE_WORK_DIR") or tempfile.gettempdir()
out = Path(out_dir) / "clipboard.txt"
out.write_text(text, encoding="utf-8")
return out
```
### Image processing (image → image)
```python
import os
from PIL import Image
from pathlib import Path
import tempfile
def advanced_paste_from_image_to_image(image_path):
"""Convert image to grayscale."""
img = Image.open(image_path).convert("L")
out_dir = os.environ.get("ADVANCED_PASTE_WORK_DIR") or tempfile.gettempdir()
out = Path(out_dir) / "gray.png"
img.save(out)
return out
```
### File listing (files → text)
```python
import os
def advanced_paste_from_files_to_text(file_paths):
lines = []
for p in file_paths:
size = os.path.getsize(p)
lines.append(f"{os.path.basename(p)} ({size} bytes)")
return "\n".join(lines)
```
## Header tags
All header tags are **optional**. Tags are placed in comment lines at the top of the script.
| Tag | Description |
|-----|-------------|
| `name` | Display name in the Advanced Paste UI. If omitted, the filename is used. |
| `desc` | Short description / tooltip. |
| `disabled` | Presence of this tag disables the script (it won't appear in the UI). |
| `requires` | Declare Python package dependencies (see [Dependencies](#declaring-dependencies)). |
### Example header
```python
# @advancedpaste:name My Formatter
# @advancedpaste:desc Formats clipboard text as markdown table
```
To disable a script without deleting it, add:
```python
# @advancedpaste:disabled
```
Remove the line to re-enable.
## Declaring dependencies
Use `requires` to declare Python packages the script needs:
```python
# @advancedpaste:requires PIL=Pillow
# @advancedpaste:requires cv2=opencv-python-headless numpy requests
```
Each token is either:
- **`import_name`** — the pip package is assumed to have the same name (e.g. `requests`).
- **`import_name=pip_package`** — when the import name differs from the pip package
(e.g. `cv2=opencv-python-headless`, `PIL=Pillow`).
### Automatic import detection
Advanced Paste also scans the script body for `import` and `from ... import` statements
and cross-references them against the Python standard library. Any non-stdlib import
that is not already installed triggers a prompt to install it automatically.
## Security — script trust
The first time a script is executed (or after it has been modified), Advanced Paste
shows a confirmation dialog. Upon approval the SHA-256 hash of the script is stored.
Subsequent runs of the unchanged file skip the dialog.
## Error handling
When a script fails, Advanced Paste extracts the Python traceback from stderr and
displays a user-friendly summary in the UI:
- **ModuleNotFoundError** — identifies the missing module and suggests installing it.
- **SyntaxError** — shows the file and line number.
- **Timeout** — shows the configured timeout value (default 30 s; configurable in Settings).
- **Other errors** — shows the last line of the traceback as a summary, with the full
traceback available in the expandable *Details* section.
## Settings
The Python scripts feature uses a **mode selector** (dropdown) with three options:
| Mode | Description |
|------|-------------|
| **Disabled** | Python scripts are not active. |
| **Windows** | Scripts run using a native Windows Python interpreter. |
| **WSL** | Scripts run inside Windows Subsystem for Linux. |
Each mode maintains its own independent settings (scripts folder, interpreter path, etc.),
so switching between Windows and WSL does not lose your previous configuration.
### Windows mode settings
| Setting | Description | Default |
|---------|-------------|---------|
| Scripts folder | Folder to scan for `.py` scripts. | `%LOCALAPPDATA%\Microsoft\PowerToys\AdvancedPaste\Scripts` |
| Python interpreter | Path to the Python executable. Leave blank for auto-detection. | *(auto-detect)* |
### WSL mode settings
| Setting | Description | Default |
|---------|-------------|---------|
| Scripts folder | Folder to scan for `.py` scripts (Windows path — auto-translated to `/mnt/...`). | `%LOCALAPPDATA%\Microsoft\PowerToys\AdvancedPaste\Scripts` |
| WSL distribution | Which WSL distro to use (e.g. `Ubuntu`). Leave blank for the default distribution. | *(default)* |
### Scripts list
The Settings page shows a read-only list of discovered scripts. For each script you can see:
- **Name** — from `@advancedpaste:name` tag, or the filename if not set.
- **Description** — from `@advancedpaste:desc` tag.
- **Conversion** — the input → output types detected from the function name (e.g. "text → image").
The list is **not editable** from Settings. To change a script's name, description, enabled state,
or any other metadata, open the script file directly (click the "Open in editor" button) and edit
the `# @advancedpaste:...` header tags. After saving, click **Refresh** in Settings to reload.
### WSL mode details
When **WSL** mode is selected:
- Scripts are executed via `wsl.exe bash -l -c "python3 ..."` using the configured distribution.
- The scripts folder remains on the Windows filesystem; paths are automatically translated
to `/mnt/c/...` format for WSL access.
- Package installation uses `pip3 install` inside the WSL environment.
- Output files from scripts must be written under `/mnt/` (the Windows-mounted filesystem)
so they can be accessed from Windows. The runner sets the `ADVANCED_PASTE_WORK_DIR` environment
variable to a temp directory under `/mnt/c/...` — use it instead of `tempfile.gettempdir()`
when producing file output for cross-platform compatibility.
> **Tip:** If you have Python installed only in WSL (not on Windows), select WSL mode
> to use your existing WSL Python environment with all its packages.
## Tips
- Each `.py` file must contain exactly one `advanced_paste_from_<input>_to_<output>` function.
If you need to handle multiple input types, create separate script files for each.
- A `.py` file without any matching function is ignored — use this for helper modules
that other scripts can import.
- Scripts can be tested from the command line:
```
echo {"format":["text"],"text":"hello"} | python _runner.py my_script.py
```
- The script's directory is added to `sys.path` at runtime, so you can import sibling `.py`
files as helper modules.

View File

@@ -0,0 +1,5 @@
# [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,6 +10,7 @@ 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"WinUI3Apps\\PowerToys.WorkspacesEditor.exe",
L"PowerToys.WorkspacesEditor.exe",
L"PowerToys.WorkspacesWindowArranger.exe",
L"Microsoft.CmdPal.UI.exe",
L"Microsoft.CmdPal.Ext.PowerToys.exe",

View File

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

View File

@@ -5,8 +5,6 @@
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;
@@ -39,18 +37,6 @@ 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
{
@@ -66,9 +52,6 @@ public partial class TransparentWindow : WinUIEx.WindowEx
private readonly nint _hwnd;
private bool _inputHooked;
private bool _seenActivated;
public TransparentWindow()
{
AppWindow.Hide();
@@ -91,30 +74,8 @@ 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,
@@ -151,8 +112,6 @@ public partial class TransparentWindow : WinUIEx.WindowEx
DispatcherQueuePriority.Low,
() =>
{
_seenActivated = false;
EnsureInputHooks();
_ = ShowWindow(_hwnd, SwShowNa);
Showing?.Invoke(this, new ShowingEventArgs(transition));
});
@@ -175,41 +134,6 @@ 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", "WinUI3Apps"),
[PowerToysModule.Workspaces] = new ModuleInfo("PowerToys.WorkspacesEditor.exe", "Workspaces Editor"),
[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

@@ -47,8 +47,6 @@ internal sealed class IntegrationTestUserSettings : IUserSettings
public bool ShowCustomPreview => false;
public bool ShowAIPaste => true;
public bool CloseAfterLosingFocus => false;
public bool EnableClipboardPreview => true;
@@ -59,6 +57,22 @@ internal sealed class IntegrationTestUserSettings : IUserSettings
public PasteAIConfiguration PasteAIConfiguration => _configuration;
public IReadOnlyList<AdvancedPastePythonScriptAction> PythonScriptActions => Array.Empty<AdvancedPastePythonScriptAction>();
public string PythonScriptsFolder => string.Empty;
public string PythonExecutablePath => string.Empty;
public bool PythonUseWsl => false;
public string PythonWslDistribution => string.Empty;
public int PythonScriptTimeoutSeconds => 30;
public bool IsPythonScriptsEnabled => true;
public IReadOnlyDictionary<string, string> TrustedScriptHashes => new Dictionary<string, string>();
public event EventHandler Changed;
public Task SetActiveAIProviderAsync(string providerId)
@@ -67,4 +81,8 @@ internal sealed class IntegrationTestUserSettings : IUserSettings
Changed?.Invoke(this, EventArgs.Empty);
return Task.CompletedTask;
}
public void StoreTrustedScriptHash(string scriptPath, string hash)
{
}
}

View File

@@ -0,0 +1,560 @@
// 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.IO;
using System.Linq;
using AdvancedPaste.Helpers;
using AdvancedPaste.Services.PythonScripts;
using AdvancedPaste.UnitTests.Mocks;
using Microsoft.VisualStudio.TestTools.UnitTesting;
namespace AdvancedPaste.UnitTests.ServicesTests;
[TestClass]
public sealed class PythonScriptServiceTests
{
private PythonScriptService _service;
[TestInitialize]
public void Setup()
{
_service = new PythonScriptService(new IntegrationTestUserSettings());
}
[TestMethod]
public void MergeWithAutoDetectedImports_DetectsSimpleImports()
{
var lines = new[]
{
"# @advancedpaste:name test",
"import requests",
"import numpy",
"import os",
"import sys",
};
var result = PythonScriptService.MergeWithAutoDetectedImports(lines, []);
Assert.AreEqual(2, result.Count); // requests + numpy; os and sys are stdlib
Assert.IsTrue(result.Any(r => r.ImportName == "requests" && r.PipPackage == "requests"));
Assert.IsTrue(result.Any(r => r.ImportName == "numpy" && r.PipPackage == "numpy"));
}
[TestMethod]
public void MergeWithAutoDetectedImports_DetectsFromImports()
{
var lines = new[]
{
"from PIL import Image",
"from markitdown import MarkItDown",
};
var result = PythonScriptService.MergeWithAutoDetectedImports(lines, []);
Assert.AreEqual(2, result.Count);
Assert.IsTrue(result.Any(r => r.ImportName == "PIL" && r.PipPackage == "Pillow"));
Assert.IsTrue(result.Any(r => r.ImportName == "markitdown" && r.PipPackage == "markitdown"));
}
[TestMethod]
public void MergeWithAutoDetectedImports_WellKnownMappings()
{
var lines = new[]
{
"import cv2",
"import win32clipboard",
"import yaml",
};
var result = PythonScriptService.MergeWithAutoDetectedImports(lines, []);
Assert.AreEqual(3, result.Count);
Assert.IsTrue(result.Any(r => r.ImportName == "cv2" && r.PipPackage == "opencv-python"));
Assert.IsTrue(result.Any(r => r.ImportName == "win32clipboard" && r.PipPackage == "pywin32"));
Assert.IsTrue(result.Any(r => r.ImportName == "yaml" && r.PipPackage == "PyYAML"));
}
[TestMethod]
public void MergeWithAutoDetectedImports_ExplicitRequirementsTakePrecedence()
{
var lines = new[]
{
"import cv2",
"import requests",
};
var explicitReqs = new List<PythonRequirement>
{
new("cv2", "opencv-python-headless"),
};
var result = PythonScriptService.MergeWithAutoDetectedImports(lines, explicitReqs);
Assert.AreEqual(2, result.Count);
// cv2 should use the explicit pip package name, not the well-known mapping
var cv2Req = result.First(r => r.ImportName == "cv2");
Assert.AreEqual("opencv-python-headless", cv2Req.PipPackage);
// requests should be auto-detected
Assert.IsTrue(result.Any(r => r.ImportName == "requests"));
}
[TestMethod]
public void MergeWithAutoDetectedImports_SkipsStdlib()
{
var lines = new[]
{
"import os",
"import sys",
"import json",
"import io",
"import pathlib",
"import tempfile",
"import subprocess",
};
var result = PythonScriptService.MergeWithAutoDetectedImports(lines, []);
Assert.AreEqual(0, result.Count);
}
[TestMethod]
public void MergeWithAutoDetectedImports_SkipsComments()
{
var lines = new[]
{
"# import requests",
"# from PIL import Image",
"import json",
};
var result = PythonScriptService.MergeWithAutoDetectedImports(lines, []);
Assert.AreEqual(0, result.Count);
}
[TestMethod]
public void MergeWithAutoDetectedImports_HandlesMultipleImportsOnOneLine()
{
var lines = new[]
{
"import requests, numpy, pandas",
};
var result = PythonScriptService.MergeWithAutoDetectedImports(lines, []);
Assert.AreEqual(3, result.Count);
Assert.IsTrue(result.Any(r => r.ImportName == "requests"));
Assert.IsTrue(result.Any(r => r.ImportName == "numpy"));
Assert.IsTrue(result.Any(r => r.ImportName == "pandas"));
}
[TestMethod]
public void MergeWithAutoDetectedImports_HandlesSubmoduleImport()
{
var lines = new[]
{
"import win32com.client",
"from llama_cpp import Llama",
};
var result = PythonScriptService.MergeWithAutoDetectedImports(lines, []);
Assert.AreEqual(2, result.Count);
Assert.IsTrue(result.Any(r => r.ImportName == "win32com" && r.PipPackage == "pywin32"));
Assert.IsTrue(result.Any(r => r.ImportName == "llama_cpp" && r.PipPackage == "llama-cpp-python"));
}
[TestMethod]
public void ParsePythonError_ModuleNotFoundError()
{
var stderr = """
Traceback (most recent call last):
File "C:\scripts\reverse.py", line 4, in <module>
import win32clipboard
ModuleNotFoundError: No module named 'win32clipboard'
""";
var (summary, details) = PythonScriptService.ParsePythonError(stderr);
Assert.IsTrue(summary.Contains("reverse.py"), $"Summary should mention the script: {summary}");
Assert.IsTrue(summary.Contains("line 4"), $"Summary should mention the line: {summary}");
Assert.IsTrue(summary.Contains("win32clipboard"), $"Summary should mention the module: {summary}");
Assert.IsTrue(summary.Contains("pywin32"), $"Summary should suggest pip package: {summary}");
Assert.IsTrue(!string.IsNullOrEmpty(details));
}
[TestMethod]
public void ParsePythonError_SyntaxError()
{
var stderr = """
File "test.py", line 5
def foo(
^
SyntaxError: unexpected EOF while parsing
""";
var (summary, details) = PythonScriptService.ParsePythonError(stderr);
Assert.IsTrue(summary.Contains("test.py"), $"Summary should mention the script: {summary}");
Assert.IsTrue(summary.Contains("line 5"), $"Summary should mention the line: {summary}");
Assert.IsTrue(summary.Contains("Python syntax error:"), $"Summary: {summary}");
Assert.IsTrue(!string.IsNullOrEmpty(details));
}
[TestMethod]
public void ParsePythonError_SyntaxErrorWithColumn()
{
var stderr = " File \"script.py\", line 3\n x = (1 +\n ^\nSyntaxError: '(' was never closed\n";
var (summary, details) = PythonScriptService.ParsePythonError(stderr);
Assert.IsTrue(summary.Contains("script.py"), $"Summary should mention the script: {summary}");
Assert.IsTrue(summary.Contains("line 3"), $"Summary should mention the line: {summary}");
Assert.IsTrue(summary.Contains("col"), $"Summary should mention the column: {summary}");
Assert.IsTrue(summary.Contains("Python syntax error:"), $"Summary: {summary}");
Assert.IsTrue(!string.IsNullOrEmpty(details));
}
[TestMethod]
public void ParsePythonError_GenericError()
{
var stderr = """
Traceback (most recent call last):
File "test.py", line 10, in <module>
result = 1 / 0
ZeroDivisionError: division by zero
""";
var (summary, details) = PythonScriptService.ParsePythonError(stderr);
Assert.IsTrue(summary.Contains("test.py"), $"Summary should mention the script: {summary}");
Assert.IsTrue(summary.Contains("line 10"), $"Summary should mention the line: {summary}");
Assert.IsTrue(summary.Contains("ZeroDivisionError"), $"Summary: {summary}");
Assert.IsTrue(!string.IsNullOrEmpty(details));
}
[TestMethod]
public void ParsePythonError_NestedTraceback_ShowsLastFrame()
{
var stderr = """
Traceback (most recent call last):
File "main.py", line 5, in <module>
helper()
File "helper.py", line 12, in helper
do_work()
File "worker.py", line 8, in do_work
raise RuntimeError("bad state")
RuntimeError: bad state
""";
var (summary, details) = PythonScriptService.ParsePythonError(stderr);
Assert.IsTrue(summary.Contains("worker.py"), $"Summary should mention the last script in the chain: {summary}");
Assert.IsTrue(summary.Contains("line 8"), $"Summary should mention the line of the last frame: {summary}");
Assert.IsTrue(summary.Contains("bad state"), $"Summary should contain the error message: {summary}");
}
[TestMethod]
public void ParsePythonError_EmptyStderr()
{
var (summary, details) = PythonScriptService.ParsePythonError(string.Empty);
Assert.IsTrue(!string.IsNullOrEmpty(summary));
Assert.AreEqual(string.Empty, details);
}
[TestMethod]
public void ParsePythonError_NoTraceback_PlainStderr()
{
var stderr = "Something went wrong in the script\n";
var (summary, details) = PythonScriptService.ParsePythonError(stderr);
// No File "..." reference, so no location — just the message
Assert.IsTrue(summary.Contains("Something went wrong"), $"Summary: {summary}");
Assert.IsFalse(summary.Contains("line"), $"Summary should not contain 'line' without a traceback: {summary}");
}
[TestMethod]
public void ExtractLastTracebackLocation_BasicTraceback()
{
var lines = new[]
{
"Traceback (most recent call last):",
" File \"script.py\", line 10, in <module>",
" result = 1 / 0",
"ZeroDivisionError: division by zero",
};
var location = PythonScriptService.ExtractLastTracebackLocation(lines);
Assert.IsNotNull(location);
Assert.AreEqual("script.py", location.Value.FileName);
Assert.AreEqual(10, location.Value.Line);
Assert.IsNull(location.Value.Column);
}
[TestMethod]
public void ExtractLastTracebackLocation_WithCaret()
{
var lines = new[]
{
" File \"test.py\", line 5",
" def foo(",
" ^",
"SyntaxError: unexpected EOF while parsing",
};
var location = PythonScriptService.ExtractLastTracebackLocation(lines);
Assert.IsNotNull(location);
Assert.AreEqual("test.py", location.Value.FileName);
Assert.AreEqual(5, location.Value.Line);
Assert.IsNotNull(location.Value.Column);
}
[TestMethod]
public void ExtractLastTracebackLocation_FullPath_ReturnsBasename()
{
var lines = new[]
{
"Traceback (most recent call last):",
" File \"C:\\Users\\user\\scripts\\my_script.py\", line 42, in <module>",
" some_call()",
"ValueError: invalid value",
};
var location = PythonScriptService.ExtractLastTracebackLocation(lines);
Assert.IsNotNull(location);
Assert.AreEqual("my_script.py", location.Value.FileName);
Assert.AreEqual(42, location.Value.Line);
}
[TestMethod]
public void ExtractLastTracebackLocation_NoFileLine_ReturnsNull()
{
var lines = new[]
{
"Some random error output",
"No traceback here",
};
var location = PythonScriptService.ExtractLastTracebackLocation(lines);
Assert.IsNull(location);
}
[TestMethod]
public void ParsePipInstallError_ExtractsErrorLine()
{
var stderr = """
Collecting some-package
Downloading some-package-1.0.tar.gz (15 kB)
ERROR: Could not find a version that satisfies the requirement some-package (from versions: none)
ERROR: No matching distribution found for some-package
""";
var (summary, fullStderr) = PythonScriptService.ParsePipInstallError(stderr);
Assert.IsTrue(summary.Contains("No matching distribution"), $"Summary should contain the last ERROR line: {summary}");
Assert.IsTrue(!string.IsNullOrEmpty(fullStderr));
}
[TestMethod]
public void ParsePipInstallError_NoErrorPrefix_UsesLastLine()
{
var stderr = "permission denied: /usr/lib/python3/dist-packages\n";
var (summary, fullStderr) = PythonScriptService.ParsePipInstallError(stderr);
Assert.IsTrue(summary.Contains("permission denied"), $"Summary: {summary}");
Assert.IsTrue(!string.IsNullOrEmpty(fullStderr));
}
[TestMethod]
public void ParsePipInstallError_EmptyStderr()
{
var (summary, fullStderr) = PythonScriptService.ParsePipInstallError(string.Empty);
Assert.AreEqual("unknown error", summary);
Assert.AreEqual(string.Empty, fullStderr);
}
[TestMethod]
public void ReadMetadata_FunctionNameDeterminesFormat_Text()
{
// The new interface uses function names like advanced_paste_from_text_to_text(...)
// to determine supported formats, not parameter signatures.
var scriptPath = CreateTempScript("def advanced_paste_from_text_to_text(text):\n return text.upper()\n");
var metadata = _service.ReadMetadata(scriptPath);
Assert.IsNotNull(metadata);
Assert.AreEqual(Models.ClipboardFormat.Text, metadata.SupportedFormats);
Assert.AreEqual("text", metadata.OutputTypeHint);
File.Delete(scriptPath);
}
[TestMethod]
public void ReadMetadata_FunctionNameDeterminesFormat_Html()
{
var scriptPath = CreateTempScript("def advanced_paste_from_html_to_text(html: str) -> str:\n return html\n");
var metadata = _service.ReadMetadata(scriptPath);
Assert.IsNotNull(metadata);
Assert.AreEqual(Models.ClipboardFormat.Html, metadata.SupportedFormats);
Assert.AreEqual("text", metadata.OutputTypeHint);
File.Delete(scriptPath);
}
[TestMethod]
public void ReadMetadata_FunctionNameDeterminesFormat_Image()
{
var scriptPath = CreateTempScript("def advanced_paste_from_image_to_text(image_path):\n return 'desc'\n");
var metadata = _service.ReadMetadata(scriptPath);
Assert.IsNotNull(metadata);
Assert.AreEqual(Models.ClipboardFormat.Image, metadata.SupportedFormats);
Assert.AreEqual("text", metadata.OutputTypeHint);
File.Delete(scriptPath);
}
[TestMethod]
public void ReadMetadata_FunctionNameDeterminesFormat_Files()
{
var scriptPath = CreateTempScript("def advanced_paste_from_files_to_text(file_paths):\n return ''\n");
var metadata = _service.ReadMetadata(scriptPath);
Assert.IsNotNull(metadata);
Assert.AreEqual(Models.ClipboardFormat.File, metadata.SupportedFormats);
Assert.AreEqual("text", metadata.OutputTypeHint);
File.Delete(scriptPath);
}
[TestMethod]
public void ReadMetadata_OutputTypeHint_Image()
{
var scriptPath = CreateTempScript("def advanced_paste_from_text_to_image(text):\n return '/path/img.png'\n");
var metadata = _service.ReadMetadata(scriptPath);
Assert.IsNotNull(metadata);
Assert.AreEqual("image", metadata.OutputTypeHint);
File.Delete(scriptPath);
}
[TestMethod]
public void ReadMetadata_OutputTypeHint_File()
{
var scriptPath = CreateTempScript("def advanced_paste_from_text_to_file(text):\n return '/path/out.txt'\n");
var metadata = _service.ReadMetadata(scriptPath);
Assert.IsNotNull(metadata);
Assert.AreEqual("file", metadata.OutputTypeHint);
File.Delete(scriptPath);
}
[TestMethod]
public void ReadMetadata_OutputTypeHint_Files()
{
var scriptPath = CreateTempScript("def advanced_paste_from_files_to_files(file_paths):\n return file_paths\n");
var metadata = _service.ReadMetadata(scriptPath);
Assert.IsNotNull(metadata);
Assert.AreEqual("files", metadata.OutputTypeHint);
File.Delete(scriptPath);
}
[TestMethod]
public void ReadMetadata_RejectsMultipleFunctions()
{
var scriptPath = CreateTempScript(
"def advanced_paste_from_text_to_text(text):\n return text\n\n" +
"def advanced_paste_from_html_to_text(html):\n return html\n");
var metadata = _service.ReadMetadata(scriptPath);
Assert.IsNull(metadata);
File.Delete(scriptPath);
}
[TestMethod]
public void ReadMetadata_RejectsNoFunction()
{
var scriptPath = CreateTempScript("def some_other_function(text):\n return text\n");
var metadata = _service.ReadMetadata(scriptPath);
Assert.IsNull(metadata);
File.Delete(scriptPath);
}
[TestMethod]
public void ReadMetadata_RejectsOldFormatWithoutTo()
{
// Old format (advanced_paste_from_text without _to_) should be rejected.
var scriptPath = CreateTempScript("def advanced_paste_from_text(text):\n return text\n");
var metadata = _service.ReadMetadata(scriptPath);
Assert.IsNull(metadata);
File.Delete(scriptPath);
}
[TestMethod]
public void ReadMetadata_FunctionNameDeterminesFormat_Audio()
{
var scriptPath = CreateTempScript("def advanced_paste_from_audio_to_text(audio_path):\n return 'transcribed'\n");
var metadata = _service.ReadMetadata(scriptPath);
Assert.IsNotNull(metadata);
Assert.AreEqual(Models.ClipboardFormat.Audio, metadata.SupportedFormats);
Assert.AreEqual("text", metadata.OutputTypeHint);
File.Delete(scriptPath);
}
[TestMethod]
public void ReadMetadata_FunctionNameDeterminesFormat_Video()
{
var scriptPath = CreateTempScript("def advanced_paste_from_video_to_text(video_path):\n return 'description'\n");
var metadata = _service.ReadMetadata(scriptPath);
Assert.IsNotNull(metadata);
Assert.AreEqual(Models.ClipboardFormat.Video, metadata.SupportedFormats);
Assert.AreEqual("text", metadata.OutputTypeHint);
File.Delete(scriptPath);
}
[TestMethod]
public void ReadMetadata_OutputTypeHint_Audio()
{
var scriptPath = CreateTempScript("def advanced_paste_from_text_to_audio(text):\n return '/path/out.mp3'\n");
var metadata = _service.ReadMetadata(scriptPath);
Assert.IsNotNull(metadata);
Assert.AreEqual(Models.ClipboardFormat.Text, metadata.SupportedFormats);
Assert.AreEqual("audio", metadata.OutputTypeHint);
File.Delete(scriptPath);
}
[TestMethod]
public void ReadMetadata_OutputTypeHint_Video()
{
var scriptPath = CreateTempScript("def advanced_paste_from_text_to_video(text):\n return '/path/out.mp4'\n");
var metadata = _service.ReadMetadata(scriptPath);
Assert.IsNotNull(metadata);
Assert.AreEqual(Models.ClipboardFormat.Text, metadata.SupportedFormats);
Assert.AreEqual("video", metadata.OutputTypeHint);
File.Delete(scriptPath);
}
private static string CreateTempScript(string content)
{
var path = Path.Combine(Path.GetTempPath(), $"test_script_{Guid.NewGuid():N}.py");
File.WriteAllText(path, content);
return path;
}
}

View File

@@ -153,5 +153,9 @@
<Content Include="Assets\AdvancedPaste\SemanticKernel.svg">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</Content>
<Content Include="Services\PythonScripts\_runner.py">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</Content>
</ItemGroup>
</Project>

View File

@@ -14,6 +14,7 @@ using AdvancedPaste.Helpers;
using AdvancedPaste.Models;
using AdvancedPaste.Services;
using AdvancedPaste.Services.CustomActions;
using AdvancedPaste.Services.PythonScripts;
using AdvancedPaste.Settings;
using AdvancedPaste.ViewModels;
using ManagedCommon;
@@ -83,6 +84,8 @@ namespace AdvancedPaste
services.AddSingleton<IPasteAIProviderFactory, PasteAIProviderFactory>();
services.AddSingleton<ICustomActionTransformService, CustomActionTransformService>();
services.AddSingleton<IKernelService, AdvancedAIKernelService>();
services.AddSingleton<IPythonScriptService, PythonScriptService>();
services.AddSingleton<IPythonScriptTrustService, PythonScriptTrustService>();
services.AddSingleton<IPasteFormatExecutor, PasteFormatExecutor>();
services.AddSingleton<OptionsViewModel>();
}).Build();

View File

@@ -70,12 +70,12 @@
Spacing="2">
<TextBlock
Style="{StaticResource BodyTextBlockStyle}"
Text="{x:Bind Header, Mode=OneTime}"
Text="{x:Bind Header, Mode=OneWay}"
TextWrapping="NoWrap" />
<TextBlock
Foreground="{ThemeResource TextFillColorSecondaryBrush}"
Style="{StaticResource CaptionTextBlockStyle}"
Text="{x:Bind Timestamp, Converter={StaticResource DateTimeToFriendlyStringConverter}, Mode=OneTime}" />
Text="{x:Bind Timestamp, Converter={StaticResource DateTimeToFriendlyStringConverter}, Mode=OneWay}" />
</StackPanel>
</Grid>
</UserControl>

View File

@@ -755,63 +755,7 @@
<animations:OpacityAnimation To="0.0" Duration="0:0:0.167" />
</animations:Implicit.HideAnimations>
</TextBlock>
<Grid
x:Name="ErrorMessageGrid"
x:Uid="ErrorMessageGrid"
Grid.Row="1"
Margin="8,8,0,0"
ColumnSpacing="8"
Visibility="Collapsed">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="Auto" />
<ColumnDefinition Width="*" />
</Grid.ColumnDefinitions>
<StackPanel Grid.Column="0" Orientation="Horizontal">
<ToolTipService.ToolTip>
<ToolTip VerticalOffset="-105" Visibility="{x:Bind ViewModel.PasteActionError.HasDetails, Mode=OneWay, Converter={StaticResource BoolToVisibilityConverter}}">
<StackPanel
MinWidth="300"
HorizontalAlignment="Stretch"
Orientation="Vertical">
<TextBox
x:Name="AIErrorMessage"
x:Uid="AIErrorMessage"
FontSize="12"
IsReadOnly="True"
Text="{x:Bind ViewModel.PasteActionError.Details, Mode=OneWay}"
TextWrapping="Wrap" />
</StackPanel>
</ToolTip>
</ToolTipService.ToolTip>
<FontIcon
Margin="0,3,3,0"
VerticalAlignment="Top"
FontSize="12"
Foreground="{ThemeResource AccentTextFillColorPrimaryBrush}"
Glyph="&#xE946;"
Visibility="{x:Bind ViewModel.PasteActionError.HasDetails, Mode=OneWay, Converter={StaticResource BoolToVisibilityConverter}}" />
<TextBlock
FontWeight="SemiBold"
Foreground="{ThemeResource SystemFillColorCriticalBrush}"
Style="{StaticResource CaptionTextBlockStyle}"
Text="{x:Bind ViewModel.PasteActionError.Text, Mode=OneWay}" />
</StackPanel>
<HyperlinkButton
x:Uid="SettingsBtn"
Grid.Column="1"
Margin="0,-1,0,0"
Padding="0"
HorizontalAlignment="Left"
VerticalAlignment="Top"
Command="{x:Bind ViewModel.OpenSettingsCommand}"
FontSize="12" />
<animations:Implicit.ShowAnimations>
<animations:OpacityAnimation To="1.0" Duration="0:0:0.6" />
</animations:Implicit.ShowAnimations>
<animations:Implicit.HideAnimations>
<animations:OpacityAnimation To="0.0" Duration="0:0:0.167" />
</animations:Implicit.HideAnimations>
</Grid>
<!-- Error message grid moved to MainPage.xaml so it remains enabled when PromptBox is disabled -->
<VisualStateManager.VisualStateGroups>
<VisualStateGroup x:Name="CommonStates">
<VisualState x:Name="DefaultState" />
@@ -832,7 +776,6 @@
<VisualState.Setters>
<Setter Target="InputTxtBox.BorderBrush" Value="{ThemeResource SystemFillColorCriticalBrush}" />
<Setter Target="DisclaimerPresenter.Visibility" Value="Collapsed" />
<Setter Target="ErrorMessageGrid.Visibility" Value="Visible" />
</VisualState.Setters>
</VisualState>
</VisualStateGroup>

View File

@@ -43,7 +43,8 @@ namespace AdvancedPaste
double GetHeight(int maxCustomActionCount) =>
baseHeight +
new PasteFormatsToHeightConverter().GetHeight(coreActionCount + _userSettings.AdditionalActions.Count) +
new PasteFormatsToHeightConverter() { MaxItems = maxCustomActionCount }.GetHeight(_optionsViewModel.IsCustomAIServiceEnabled ? _userSettings.CustomActions.Count : 0);
new PasteFormatsToHeightConverter() { MaxItems = maxCustomActionCount }.GetHeight(_optionsViewModel.IsCustomAIServiceEnabled ? _userSettings.CustomActions.Count : 0) +
new PasteFormatsToHeightConverter() { MaxItems = maxCustomActionCount }.GetHeight(_optionsViewModel.PythonScriptPasteFormats.Count);
MinHeight = GetHeight(1);
Height = GetHeight(5);
@@ -59,6 +60,7 @@ namespace AdvancedPaste
UpdateHeight();
}
};
_optionsViewModel.PythonScriptPasteFormats.CollectionChanged += (_, _) => UpdateHeight();
AppWindow.SetIcon("Assets/AdvancedPaste/AdvancedPaste.ico");
this.ExtendsContentIntoTitleBar = true;
@@ -141,11 +143,7 @@ namespace AdvancedPaste
internal void FinishLoading(bool success)
{
MainPage.CustomFormatTextBox.IsLoading(false);
if (success)
{
VisualStateManager.GoToState(MainPage.CustomFormatTextBox, "DefaultState", true);
}
VisualStateManager.GoToState(MainPage.CustomFormatTextBox, "DefaultState", true);
}
}
}

View File

@@ -29,31 +29,31 @@
Padding="-9,0,0,0"
HorizontalAlignment="Stretch"
VerticalAlignment="Stretch"
AutomationProperties.AcceleratorKey="{x:Bind ShortcutText, Mode=OneTime}"
AutomationProperties.AcceleratorKey="{x:Bind ShortcutText, Mode=OneWay}"
AutomationProperties.AutomationControlType="ListItem"
AutomationProperties.FullDescription="{x:Bind ToolTip, Mode=OneTime}"
AutomationProperties.HelpText="{x:Bind Name, Mode=OneTime}"
AutomationProperties.Name="{x:Bind AccessibleName, Mode=OneTime}">
AutomationProperties.FullDescription="{x:Bind ToolTip, Mode=OneWay}"
AutomationProperties.HelpText="{x:Bind Name, Mode=OneWay}"
AutomationProperties.Name="{x:Bind AccessibleName, Mode=OneWay}">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="48" />
<ColumnDefinition Width="*" />
<ColumnDefinition Width="Auto" />
</Grid.ColumnDefinitions>
<ToolTipService.ToolTip>
<TextBlock Text="{x:Bind ToolTip, Mode=OneTime}" />
<TextBlock Text="{x:Bind ToolTip, Mode=OneWay}" />
</ToolTipService.ToolTip>
<FontIcon
Margin="0,0,0,0"
VerticalAlignment="Center"
AutomationProperties.AccessibilityView="Raw"
FontSize="16"
Glyph="{x:Bind IconGlyph, Mode=OneTime}" />
Glyph="{x:Bind IconGlyph, Mode=OneWay}" />
<TextBlock
Grid.Column="1"
VerticalAlignment="Center"
x:Phase="1"
Style="{StaticResource CaptionTextBlockStyle}"
Text="{x:Bind Name, Mode=OneTime}" />
Text="{x:Bind Name, Mode=OneWay}" />
<TextBlock
Grid.Column="2"
Margin="0,0,8,0"
@@ -61,7 +61,7 @@
VerticalAlignment="Center"
Foreground="{ThemeResource TextFillColorSecondaryBrush}"
Style="{StaticResource CaptionTextBlockStyle}"
Text="{x:Bind ShortcutText, Mode=OneTime}" />
Text="{x:Bind ShortcutText, Mode=OneWay}" />
</Grid>
</DataTemplate>
</controls:PasteFormatTemplateSelector.ItemTemplate>
@@ -83,13 +83,13 @@
Margin="0,0,0,0"
VerticalAlignment="Center"
FontSize="16"
Glyph="{x:Bind IconGlyph, Mode=OneTime}" />
Glyph="{x:Bind IconGlyph, Mode=OneWay}" />
<TextBlock
Grid.Column="1"
VerticalAlignment="Center"
x:Phase="1"
Style="{StaticResource CaptionTextBlockStyle}"
Text="{x:Bind Name, Mode=OneTime}" />
Text="{x:Bind Name, Mode=OneWay}" />
</Grid>
</DataTemplate>
</controls:PasteFormatTemplateSelector.ItemTemplateDisabled>
@@ -144,6 +144,7 @@
</Page.KeyboardAccelerators>
<Grid>
<Grid.RowDefinitions>
<RowDefinition Height="Auto" />
<RowDefinition Height="Auto" />
<RowDefinition Height="Auto" />
<RowDefinition Height="*" />
@@ -198,7 +199,7 @@
<ItemsView.ItemTemplate>
<DataTemplate x:DataType="local:ClipboardItem">
<ItemContainer
AutomationProperties.Name="{x:Bind Description, Mode=OneTime}"
AutomationProperties.Name="{x:Bind Description, Mode=OneWay}"
CornerRadius="16"
ToolTipService.ToolTip="{x:Bind Content}">
<Grid
@@ -250,11 +251,10 @@
Grid.Row="1"
Margin="20,0,20,0"
x:FieldModifier="public"
IsEnabled="{x:Bind ViewModel.IsCustomAIServiceEnabled, Mode=OneWay}"
TabIndex="0"
Visibility="{x:Bind ViewModel.ShowAIPasteSection, Converter={StaticResource BoolToVisibilityConverter}, Mode=OneWay}">
IsEnabled="True"
TabIndex="0">
<controls:PromptBox.Footer>
<StackPanel Orientation="Horizontal">
<StackPanel Orientation="Horizontal" Visibility="{x:Bind ViewModel.IsCustomAIServiceEnabled, Mode=OneWay, Converter={StaticResource BoolToVisibilityConverter}}">
<TextBlock
x:Uid="AIMistakeNote"
Margin="0,0,2,0"
@@ -300,19 +300,70 @@
</StackPanel>
</controls:PromptBox.Footer>
</controls:PromptBox>
<ScrollViewer Grid.Row="2">
<Grid
x:Name="ErrorMessageGrid"
Grid.Row="2"
Margin="20,4,20,0"
ColumnSpacing="8"
Visibility="{x:Bind ViewModel.PasteActionError.HasText, Mode=OneWay, Converter={StaticResource BoolToVisibilityConverter}}">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="Auto" />
<ColumnDefinition Width="*" />
<ColumnDefinition Width="Auto" />
<ColumnDefinition Width="Auto" />
</Grid.ColumnDefinitions>
<FontIcon
Grid.Column="0"
Margin="0,3,3,0"
VerticalAlignment="Top"
FontSize="12"
Foreground="{ThemeResource AccentTextFillColorPrimaryBrush}"
Glyph="&#xE946;"
Visibility="{x:Bind ViewModel.PasteActionError.HasDetails, Mode=OneWay, Converter={StaticResource BoolToVisibilityConverter}}" />
<TextBlock
Grid.Column="1"
FontWeight="SemiBold"
Foreground="{ThemeResource SystemFillColorCriticalBrush}"
MaxLines="2"
Style="{StaticResource CaptionTextBlockStyle}"
Text="{x:Bind ViewModel.PasteActionError.Text, Mode=OneWay}"
TextTrimming="CharacterEllipsis"
TextWrapping="Wrap" />
<HyperlinkButton
x:Name="ShowErrorDetailsBtn"
x:Uid="ShowErrorDetailsBtn"
Grid.Column="2"
Margin="4,-1,0,0"
Padding="0"
HorizontalAlignment="Left"
VerticalAlignment="Top"
Click="ShowErrorDetailsBtn_Click"
FontSize="12"
Visibility="{x:Bind ViewModel.PasteActionError.HasDetails, Mode=OneWay, Converter={StaticResource BoolToVisibilityConverter}}" />
<HyperlinkButton
x:Uid="SettingsBtn"
Grid.Column="3"
Margin="0,-1,0,0"
Padding="0"
HorizontalAlignment="Left"
VerticalAlignment="Top"
Command="{x:Bind ViewModel.OpenSettingsCommand}"
FontSize="12" />
</Grid>
<ScrollViewer Grid.Row="3">
<Grid RowSpacing="4">
<Grid.RowDefinitions>
<RowDefinition Height="{x:Bind ViewModel.StandardPasteFormats.Count, Mode=OneWay, Converter={StaticResource standardPasteFormatsToHeightConverter}}" />
<RowDefinition Height="Auto" />
<RowDefinition Height="*" MinHeight="{x:Bind ViewModel.CustomActionPasteFormats.Count, Mode=OneWay, Converter={StaticResource customActionsToMinHeightConverter}}" />
<RowDefinition Height="Auto" />
<RowDefinition Height="Auto" />
<RowDefinition Height="Auto" />
<RowDefinition Height="Auto" />
<RowDefinition Height="Auto" />
<RowDefinition Height="Auto" />
</Grid.RowDefinitions>
<ListView
x:Name="PasteOptionsListView"
Grid.Row="0"
VerticalAlignment="Bottom"
IsItemClickEnabled="True"
ItemClick="PasteFormat_ItemClick"
ItemContainerTransitions="{x:Null}"
@@ -342,6 +393,27 @@
ScrollViewer.VerticalScrollMode="Disabled"
SelectionMode="None"
TabIndex="2" />
<Rectangle
Grid.Row="3"
Height="1"
HorizontalAlignment="Stretch"
Fill="{ThemeResource DividerStrokeColorDefaultBrush}"
Visibility="{x:Bind ViewModel.PythonScriptPasteFormats.Count, Mode=OneWay, Converter={StaticResource countToVisibilityConverter}}" />
<ListView
x:Name="PythonScriptsListView"
Grid.Row="4"
VerticalAlignment="Top"
IsItemClickEnabled="True"
ItemClick="PasteFormat_ItemClick"
ItemContainerTransitions="{x:Null}"
ItemTemplateSelector="{StaticResource PasteFormatTemplateSelector}"
ItemsSource="{x:Bind ViewModel.PythonScriptPasteFormats, Mode=OneWay}"
ScrollViewer.VerticalScrollBarVisibility="Disabled"
ScrollViewer.VerticalScrollMode="Disabled"
SelectionMode="None"
TabIndex="3" />
</Grid>
</ScrollViewer>
</Grid>

View File

@@ -208,5 +208,43 @@ namespace AdvancedPaste.Pages
Clipboard.SetHistoryItemAsContent(item.Item);
}
}
private async void ShowErrorDetailsBtn_Click(object sender, RoutedEventArgs e)
{
var details = ViewModel.PasteActionError?.Details;
if (string.IsNullOrEmpty(details))
{
return;
}
var scrollViewer = new ScrollViewer
{
MaxHeight = 400,
MinWidth = 400,
HorizontalScrollBarVisibility = ScrollBarVisibility.Disabled,
VerticalScrollBarVisibility = ScrollBarVisibility.Auto,
};
var textBlock = new TextBlock
{
Text = details,
TextWrapping = TextWrapping.Wrap,
FontFamily = new Microsoft.UI.Xaml.Media.FontFamily("Consolas"),
FontSize = 12,
IsTextSelectionEnabled = true,
};
scrollViewer.Content = textBlock;
var dialog = new ContentDialog
{
Title = ResourceLoaderInstance.ResourceLoader.GetString("ErrorDetailsDialogTitle"),
CloseButtonText = ResourceLoaderInstance.ResourceLoader.GetString("ErrorDetailsDialogClose"),
Content = scrollViewer,
XamlRoot = this.XamlRoot,
};
await dialog.ShowAsync();
}
}
}

View File

@@ -17,8 +17,6 @@ namespace AdvancedPaste.Settings
public bool ShowCustomPreview { get; }
public bool ShowAIPaste { get; }
public bool CloseAfterLosingFocus { get; }
public bool EnableClipboardPreview { get; }
@@ -29,8 +27,26 @@ namespace AdvancedPaste.Settings
public PasteAIConfiguration PasteAIConfiguration { get; }
public IReadOnlyList<AdvancedPastePythonScriptAction> PythonScriptActions { get; }
public bool IsPythonScriptsEnabled { get; }
public string PythonScriptsFolder { get; }
public string PythonExecutablePath { get; }
public bool PythonUseWsl { get; }
public string PythonWslDistribution { get; }
public int PythonScriptTimeoutSeconds { get; }
public IReadOnlyDictionary<string, string> TrustedScriptHashes { get; }
public event EventHandler Changed;
Task SetActiveAIProviderAsync(string providerId);
void StoreTrustedScriptHash(string scriptPath, string hash);
}
}

View File

@@ -4,6 +4,7 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.IO.Abstractions;
using System.Linq;
using System.Threading;
@@ -25,6 +26,10 @@ namespace AdvancedPaste.Settings
private readonly Lock _loadingSettingsLock = new();
private readonly List<PasteFormats> _additionalActions;
private readonly List<AdvancedPasteCustomAction> _customActions;
private readonly List<AdvancedPastePythonScriptAction> _pythonScriptActions;
private FileSystemWatcher _scriptFolderWatcher;
private CancellationTokenSource _scriptFolderDebounce;
private string _watchedScriptsFolder = string.Empty;
private const string AdvancedPasteModuleName = "AdvancedPaste";
private const int MaxNumberOfRetry = 5;
@@ -38,8 +43,6 @@ namespace AdvancedPaste.Settings
public bool ShowCustomPreview { get; private set; }
public bool ShowAIPaste { get; private set; }
public bool CloseAfterLosingFocus { get; private set; }
public bool EnableClipboardPreview { get; private set; }
@@ -50,18 +53,39 @@ namespace AdvancedPaste.Settings
public PasteAIConfiguration PasteAIConfiguration { get; private set; }
public IReadOnlyList<AdvancedPastePythonScriptAction> PythonScriptActions => _pythonScriptActions;
public string PythonScriptsFolder { get; private set; }
public bool IsPythonScriptsEnabled { get; private set; }
public string PythonExecutablePath { get; private set; }
public bool PythonUseWsl { get; private set; }
public string PythonWslDistribution { get; private set; } = string.Empty;
public int PythonScriptTimeoutSeconds { get; private set; } = 30;
public IReadOnlyDictionary<string, string> TrustedScriptHashes { get; private set; } = new Dictionary<string, string>();
public UserSettings(IFileSystem fileSystem)
{
_settingsUtils = new SettingsUtils(fileSystem);
IsAIEnabled = false;
ShowCustomPreview = true;
ShowAIPaste = true;
CloseAfterLosingFocus = false;
EnableClipboardPreview = true;
PasteAIConfiguration = new PasteAIConfiguration();
PythonScriptsFolder = GetDefaultScriptsFolder();
PythonExecutablePath = string.Empty;
PythonUseWsl = false;
PythonWslDistribution = string.Empty;
PythonScriptTimeoutSeconds = 30;
_additionalActions = [];
_customActions = [];
_pythonScriptActions = [];
_taskScheduler = TaskScheduler.FromCurrentSynchronizationContext();
LoadSettingsFromJson();
@@ -69,6 +93,14 @@ namespace AdvancedPaste.Settings
_watcher = Helper.GetFileWatcher(AdvancedPasteModuleName, "settings.json", OnSettingsFileChanged, fileSystem);
}
private static string GetDefaultScriptsFolder() =>
System.IO.Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
"Microsoft",
"PowerToys",
"AdvancedPaste",
"Scripts");
private void OnSettingsFileChanged()
{
lock (_loadingSettingsLock)
@@ -112,7 +144,6 @@ namespace AdvancedPaste.Settings
IsAIEnabled = properties.IsAIEnabled;
ShowCustomPreview = properties.ShowCustomPreview;
ShowAIPaste = properties.ShowAIPaste;
CloseAfterLosingFocus = properties.CloseAfterLosingFocus;
EnableClipboardPreview = properties.EnableClipboardPreview;
PasteAIConfiguration = properties.PasteAIConfiguration ?? new PasteAIConfiguration();
@@ -135,6 +166,42 @@ namespace AdvancedPaste.Settings
_customActions.Clear();
_customActions.AddRange(properties.CustomActions.Value.Where(customAction => customAction.IsShown && customAction.IsValid));
var pythonScripts = properties.PythonScripts ?? new AdvancedPastePythonScriptSettings();
pythonScripts.MigrateLegacyIfNeeded();
var mode = pythonScripts.Mode ?? "disabled";
IsPythonScriptsEnabled = !string.Equals(mode, "disabled", StringComparison.OrdinalIgnoreCase);
PythonUseWsl = string.Equals(mode, "wsl", StringComparison.OrdinalIgnoreCase);
if (PythonUseWsl)
{
var wslSettings = pythonScripts.WslSettings ?? new PythonScriptWslSettings();
PythonScriptsFolder = string.IsNullOrWhiteSpace(wslSettings.ScriptsFolder)
? GetDefaultScriptsFolder()
: wslSettings.ScriptsFolder;
PythonExecutablePath = string.Empty;
PythonWslDistribution = wslSettings.Distribution ?? string.Empty;
}
else
{
var winSettings = pythonScripts.WindowsSettings ?? new PythonScriptWindowsSettings();
PythonScriptsFolder = string.IsNullOrWhiteSpace(winSettings.ScriptsFolder)
? GetDefaultScriptsFolder()
: winSettings.ScriptsFolder;
PythonExecutablePath = winSettings.PythonExecutablePath ?? string.Empty;
PythonWslDistribution = string.Empty;
}
PythonScriptTimeoutSeconds = pythonScripts.TimeoutSeconds > 0 ? pythonScripts.TimeoutSeconds : 30;
TrustedScriptHashes = new Dictionary<string, string>(
pythonScripts.TrustedScriptHashes ?? new Dictionary<string, string>(),
StringComparer.OrdinalIgnoreCase);
_pythonScriptActions.Clear();
_pythonScriptActions.AddRange(pythonScripts.Value);
UpdateScriptFolderWatcher(PythonScriptsFolder);
Changed?.Invoke(this, EventArgs.Empty);
}
@@ -299,6 +366,103 @@ namespace AdvancedPaste.Settings
return string.IsNullOrWhiteSpace(filtered) ? "default" : filtered.ToLowerInvariant();
}
private void UpdateScriptFolderWatcher(string folderPath)
{
if (string.Equals(_watchedScriptsFolder, folderPath, StringComparison.OrdinalIgnoreCase))
{
return;
}
_scriptFolderWatcher?.Dispose();
_scriptFolderWatcher = null;
_watchedScriptsFolder = folderPath;
if (string.IsNullOrWhiteSpace(folderPath))
{
return;
}
try
{
if (!System.IO.Directory.Exists(folderPath))
{
System.IO.Directory.CreateDirectory(folderPath);
}
_scriptFolderWatcher = new FileSystemWatcher(folderPath, "*.py")
{
NotifyFilter = NotifyFilters.FileName | NotifyFilters.LastWrite | NotifyFilters.CreationTime,
EnableRaisingEvents = true,
IncludeSubdirectories = false,
};
_scriptFolderWatcher.Changed += OnScriptFolderChanged;
_scriptFolderWatcher.Created += OnScriptFolderChanged;
_scriptFolderWatcher.Deleted += OnScriptFolderChanged;
_scriptFolderWatcher.Renamed += OnScriptFolderChanged;
}
catch (Exception ex)
{
Logger.LogError($"Failed to set up script folder watcher for {folderPath}", ex);
}
}
private void OnScriptFolderChanged(object sender, FileSystemEventArgs e)
{
lock (_loadingSettingsLock)
{
_scriptFolderDebounce?.Cancel();
_scriptFolderDebounce?.Dispose();
_scriptFolderDebounce = new CancellationTokenSource();
var token = _scriptFolderDebounce.Token;
Task.Delay(TimeSpan.FromMilliseconds(500), token)
.ContinueWith(
_ =>
{
Task.Factory
.StartNew(
() => Changed?.Invoke(this, EventArgs.Empty),
CancellationToken.None,
TaskCreationOptions.None,
_taskScheduler);
},
token,
TaskContinuationOptions.NotOnCanceled,
TaskScheduler.Default);
}
}
public void StoreTrustedScriptHash(string scriptPath, string hash)
{
lock (_loadingSettingsLock)
{
try
{
var settings = _settingsUtils.GetSettingsOrDefault<AdvancedPasteSettings>(AdvancedPasteModuleName);
if (settings?.Properties?.PythonScripts is null)
{
return;
}
settings.Properties.PythonScripts.TrustedScriptHashes ??= new Dictionary<string, string>();
settings.Properties.PythonScripts.TrustedScriptHashes[scriptPath] = hash;
settings.Save(_settingsUtils);
// Update in-memory cache.
var updated = new Dictionary<string, string>(TrustedScriptHashes, StringComparer.OrdinalIgnoreCase)
{
[scriptPath] = hash,
};
TrustedScriptHashes = updated;
}
catch (Exception ex)
{
Logger.LogError("Failed to store trusted script hash", ex);
}
}
}
public async Task SetActiveAIProviderAsync(string providerId)
{
if (string.IsNullOrWhiteSpace(providerId))
@@ -391,6 +555,8 @@ namespace AdvancedPaste.Settings
if (disposing)
{
_cancellationTokenSource?.Dispose();
_scriptFolderDebounce?.Dispose();
_scriptFolderWatcher?.Dispose();
_watcher?.Dispose();
}

View File

@@ -40,6 +40,14 @@ public sealed class PasteFormat
IsSavedQuery = isSavedQuery,
};
public static PasteFormat CreatePythonScriptFormat(string name, string scriptPath, ClipboardFormat availableFormats) =>
new(PasteFormats.PythonScript, availableFormats, isAIServiceEnabled: false)
{
Name = name,
Prompt = scriptPath,
IsSavedQuery = true,
};
public PasteFormatMetadataAttribute Metadata => MetadataDict[Format];
public string IconGlyph => Metadata.IconGlyph;

View File

@@ -122,4 +122,12 @@ public enum PasteFormats
KernelFunctionDescription = "Takes user instructions and applies them to the current clipboard content (text or image). Use this function for image analysis, description, or transformation tasks beyond simple OCR.",
RequiresPrompt = true)]
CustomTextTransformation,
[PasteFormatMetadata(
IsCoreAction = false,
IconGlyph = "\uE943",
RequiresAIService = false,
CanPreview = true,
SupportedClipboardFormats = ClipboardFormat.Text | ClipboardFormat.Html | ClipboardFormat.Image | ClipboardFormat.Audio | ClipboardFormat.Video | ClipboardFormat.File)]
PythonScript,
}

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,15 +9,23 @@ using System.Threading.Tasks;
using AdvancedPaste.Helpers;
using AdvancedPaste.Models;
using AdvancedPaste.Services.CustomActions;
using AdvancedPaste.Services.PythonScripts;
using ManagedCommon;
using Microsoft.PowerToys.Telemetry;
using Windows.ApplicationModel.DataTransfer;
namespace AdvancedPaste.Services;
public sealed class PasteFormatExecutor(IKernelService kernelService, ICustomActionTransformService customActionTransformService) : IPasteFormatExecutor
public sealed class PasteFormatExecutor(
IKernelService kernelService,
ICustomActionTransformService customActionTransformService,
IPythonScriptService pythonScriptService,
IPythonScriptTrustService pythonScriptTrustService) : IPasteFormatExecutor
{
private readonly IKernelService _kernelService = kernelService;
private readonly ICustomActionTransformService _customActionTransformService = customActionTransformService;
private readonly IPythonScriptService _pythonScriptService = pythonScriptService;
private readonly IPythonScriptTrustService _pythonScriptTrustService = pythonScriptTrustService;
public async Task<DataPackage> ExecutePasteFormatAsync(PasteFormat pasteFormat, PasteActionSource source, CancellationToken cancellationToken, IProgress<double> progress)
{
@@ -32,6 +40,15 @@ public sealed class PasteFormatExecutor(IKernelService kernelService, ICustomAct
var clipboardData = Clipboard.GetContent();
// PythonScript must NOT run inside Task.Run: the trust confirmation (ContentDialog)
// requires the UI (XAML) thread and will throw if called from a thread-pool thread.
// Python script execution is fully async (process.WaitForExitAsync), so it is safe
// to await it directly without wrapping in Task.Run.
if (format == PasteFormats.PythonScript)
{
return await ExecutePythonScriptAsync(pasteFormat.Prompt, clipboardData, cancellationToken, progress);
}
// Run on thread-pool; although we use Async routines consistently, some actions still occasionally take a long time without yielding.
return await Task.Run(async () =>
pasteFormat.Format switch
@@ -42,6 +59,111 @@ public sealed class PasteFormatExecutor(IKernelService kernelService, ICustomAct
});
}
private async Task<DataPackage> ExecutePythonScriptAsync(
string scriptPath,
DataPackageView clipboardData,
CancellationToken cancellationToken,
IProgress<double> progress)
{
// Security: ensure the script is trusted before executing.
if (!_pythonScriptTrustService.IsTrusted(scriptPath))
{
string hash;
try
{
hash = _pythonScriptTrustService.ComputeHash(scriptPath);
}
catch (System.IO.FileNotFoundException)
{
throw new PasteActionException(
string.Format(System.Globalization.CultureInfo.CurrentCulture, ResourceLoaderInstance.ResourceLoader.GetString("PythonScriptNotFound"), scriptPath),
new System.IO.FileNotFoundException(null, scriptPath));
}
var approved = await _pythonScriptTrustService.RequestTrustAsync(scriptPath, hash);
if (!approved)
{
throw new OperationCanceledException("User declined to trust the Python script.");
}
_pythonScriptTrustService.StoreTrust(scriptPath, hash);
}
var metadata = _pythonScriptService.ReadMetadata(scriptPath);
if (metadata is null)
{
throw new InvalidOperationException($"Script '{scriptPath}' does not define a valid advanced_paste_from_*_to_*() function.");
}
// Pre-flight: check for missing packages and offer to install them.
var missingPackages = await _pythonScriptService.GetMissingRequirementsAsync(metadata, cancellationToken);
if (missingPackages.Count > 0)
{
var approved = await _pythonScriptTrustService.RequestInstallAsync(metadata.Name, missingPackages);
if (!approved)
{
throw new OperationCanceledException("User declined to install missing Python packages.");
}
await _pythonScriptService.InstallRequirementsAsync(missingPackages, metadata.Platform, cancellationToken);
}
var detectedFormat = await clipboardData.GetAvailableFormatsAsync();
// V2 interface: script defines advanced_paste_from_*_to_*() — use unified runner.
if (metadata.IsV2)
{
return await _pythonScriptService.ExecuteScriptAsync(scriptPath, metadata.Platform, clipboardData, detectedFormat, cancellationToken, progress);
}
// Legacy paths for backward compatibility.
if (string.Equals(metadata.Platform, "linux", StringComparison.OrdinalIgnoreCase))
{
return await _pythonScriptService.ExecuteWslScriptAsync(scriptPath, clipboardData, detectedFormat, cancellationToken, progress);
}
else
{
// Windows mode: script modifies the clipboard in-process; we return the updated clipboard.
await _pythonScriptService.ExecuteWindowsScriptAsync(scriptPath, detectedFormat, cancellationToken, progress);
// Re-read clipboard after script has run.
return Clipboard.GetContent() is { } updatedView
? await DataPackageFromViewAsync(updatedView)
: new DataPackage();
}
}
private static async Task<DataPackage> DataPackageFromViewAsync(DataPackageView view)
{
var pkg = new DataPackage();
if (view.Contains(StandardDataFormats.Text))
{
pkg.SetText(await view.GetTextAsync());
}
if (view.Contains(StandardDataFormats.Html))
{
pkg.SetHtmlFormat(await view.GetHtmlFormatAsync());
}
if (view.Contains(StandardDataFormats.StorageItems))
{
var items = await view.GetStorageItemsAsync();
pkg.SetStorageItems(items);
}
if (view.Contains(StandardDataFormats.Bitmap))
{
var bitmap = await view.GetBitmapAsync();
pkg.SetBitmap(bitmap);
}
return pkg;
}
private static void WriteTelemetry(PasteFormats format, PasteActionSource source)
{
switch (source)

View File

@@ -0,0 +1,71 @@
// 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.Threading;
using System.Threading.Tasks;
using AdvancedPaste.Models;
using Windows.ApplicationModel.DataTransfer;
namespace AdvancedPaste.Services.PythonScripts;
public interface IPythonScriptService
{
/// <summary>
/// V2 unified execution: C# reads the clipboard, pipes data as JSON to the runner,
/// and receives a DataPackage from JSON stdout. Works on both Windows and WSL
/// depending on the specified platform.
/// </summary>
Task<DataPackage> ExecuteScriptAsync(string scriptPath, string platform, DataPackageView clipboardData, ClipboardFormat detectedFormat, CancellationToken cancellationToken, IProgress<double> progress);
/// <summary>
/// Legacy Windows mode: the script directly manipulates the clipboard. C# waits for the process to exit.
/// Kept for backward compatibility with scripts that use win32clipboard directly.
/// </summary>
Task ExecuteWindowsScriptAsync(string scriptPath, ClipboardFormat detectedFormat, CancellationToken cancellationToken, IProgress<double> progress);
/// <summary>
/// Legacy WSL mode: C# passes data via JSON stdin, receives a DataPackage from JSON stdout.
/// Kept for backward compatibility with scripts that use json.load(sys.stdin) directly.
/// </summary>
Task<DataPackage> ExecuteWslScriptAsync(string scriptPath, DataPackageView clipboardData, ClipboardFormat detectedFormat, CancellationToken cancellationToken, IProgress<double> progress);
/// <summary>
/// Parses the @advancedpaste: header comments from a Python script file.
/// </summary>
PythonScriptMetadata ReadMetadata(string scriptPath);
/// <summary>
/// Discovers all .py scripts in <paramref name="folderPath"/> and returns their metadata.
/// </summary>
IReadOnlyList<PythonScriptMetadata> DiscoverScripts(string folderPath);
/// <summary>
/// Finds the Python executable to use. Returns null if none is found.
/// </summary>
string TryFindPythonExecutable(string overridePath = null);
/// <summary>
/// Returns true if wsl.exe is available on this machine.
/// </summary>
bool IsWslAvailable();
/// <summary>
/// Checks which of the declared requirements are not yet importable.
/// Returns an empty list if all packages are installed.
/// </summary>
Task<IReadOnlyList<PythonRequirement>> GetMissingRequirementsAsync(
PythonScriptMetadata metadata,
CancellationToken cancellationToken);
/// <summary>
/// Installs the given packages via pip / pip3.
/// </summary>
Task InstallRequirementsAsync(
IReadOnlyList<PythonRequirement> requirements,
string platform,
CancellationToken cancellationToken);
}

View File

@@ -0,0 +1,37 @@
// 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.Threading.Tasks;
namespace AdvancedPaste.Services.PythonScripts;
public interface IPythonScriptTrustService
{
/// <summary>
/// Returns true if the script at <paramref name="scriptPath"/> is currently trusted (hash matches stored value).
/// </summary>
bool IsTrusted(string scriptPath);
/// <summary>
/// Shows a UI confirmation dialog for the script. Returns true if the user approved execution.
/// </summary>
Task<bool> RequestTrustAsync(string scriptPath, string hash);
/// <summary>
/// Persists the trust entry for <paramref name="scriptPath"/> with the given <paramref name="hash"/>.
/// </summary>
void StoreTrust(string scriptPath, string hash);
/// <summary>
/// Computes the SHA-256 hash of the script file and returns the hex string.
/// </summary>
string ComputeHash(string scriptPath);
/// <summary>
/// Shows a confirmation dialog listing the missing packages and asking the user
/// whether to install them. Returns true if the user approved installation.
/// </summary>
Task<bool> RequestInstallAsync(string scriptName, IReadOnlyList<PythonRequirement> missingPackages);
}

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 AdvancedPaste.Services.PythonScripts;
/// <summary>
/// Represents a single Python package requirement declared via
/// <c># @advancedpaste:requires import_name=pip_package</c>.
/// </summary>
/// <param name="ImportName">The Python import name used in the script (e.g. "cv2").</param>
/// <param name="PipPackage">The pip install name (e.g. "opencv-python-headless"). Equals <see cref="ImportName"/> when not explicitly specified.</param>
public sealed record PythonRequirement(string ImportName, string PipPackage);

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 System.Collections.Generic;
using AdvancedPaste.Models;
namespace AdvancedPaste.Services.PythonScripts;
public sealed record PythonScriptMetadata(
string ScriptPath,
string Name,
string Description,
ClipboardFormat SupportedFormats,
string Platform,
string Version,
bool IsEnabled,
IReadOnlyList<PythonRequirement> Requirements,
bool IsV2 = false,
string OutputTypeHint = null);

View File

@@ -0,0 +1,127 @@
// 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.IO;
using System.Linq;
using System.Security.Cryptography;
using System.Threading.Tasks;
using AdvancedPaste.Helpers;
using AdvancedPaste.Settings;
using ManagedCommon;
using Microsoft.UI.Xaml.Controls;
namespace AdvancedPaste.Services.PythonScripts;
public sealed class PythonScriptTrustService(IUserSettings userSettings) : IPythonScriptTrustService
{
private readonly IUserSettings _userSettings = userSettings;
public bool IsTrusted(string scriptPath)
{
var hashes = _userSettings.TrustedScriptHashes;
if (hashes is null || !hashes.TryGetValue(scriptPath, out var storedHash))
{
return false;
}
try
{
var currentHash = ComputeHash(scriptPath);
return string.Equals(currentHash, storedHash, StringComparison.OrdinalIgnoreCase);
}
catch (Exception ex)
{
Logger.LogError($"Failed to compute hash for {scriptPath}", ex);
return false;
}
}
public async Task<bool> RequestTrustAsync(string scriptPath, string hash)
{
try
{
var resourceLoader = ResourceLoaderInstance.ResourceLoader;
var dialog = new ContentDialog
{
Title = resourceLoader.GetString("PythonScriptTrustTitle"),
Content = string.Format(
System.Globalization.CultureInfo.CurrentCulture,
resourceLoader.GetString("PythonScriptTrustContent"),
scriptPath,
hash),
PrimaryButtonText = resourceLoader.GetString("PythonScriptTrustConfirm"),
CloseButtonText = resourceLoader.GetString("PythonScriptTrustCancel"),
};
// XamlRoot must be set for ContentDialog to function.
var mainWindow = (Microsoft.UI.Xaml.Application.Current as AdvancedPaste.App)?.GetMainWindow();
if (mainWindow?.Content?.XamlRoot is { } xamlRoot)
{
dialog.XamlRoot = xamlRoot;
}
var result = await dialog.ShowAsync();
return result == ContentDialogResult.Primary;
}
catch (Exception ex)
{
Logger.LogError("Failed to show trust dialog", ex);
return false;
}
}
public void StoreTrust(string scriptPath, string hash)
{
_userSettings.StoreTrustedScriptHash(scriptPath, hash);
}
public string ComputeHash(string scriptPath)
{
using var stream = File.OpenRead(scriptPath);
var hashBytes = SHA256.HashData(stream);
return Convert.ToHexStringLower(hashBytes);
}
public async Task<bool> RequestInstallAsync(string scriptName, IReadOnlyList<PythonRequirement> missingPackages)
{
try
{
var resourceLoader = ResourceLoaderInstance.ResourceLoader;
var packageList = string.Join("\n", missingPackages.Select(r =>
string.Equals(r.ImportName, r.PipPackage, StringComparison.Ordinal)
? $" • {r.PipPackage}"
: $" • {r.PipPackage} (import: {r.ImportName})"));
var dialog = new ContentDialog
{
Title = resourceLoader.GetString("PythonPackageInstallTitle"),
Content = string.Format(
System.Globalization.CultureInfo.CurrentCulture,
resourceLoader.GetString("PythonPackageInstallContent"),
scriptName,
packageList),
PrimaryButtonText = resourceLoader.GetString("PythonPackageInstallConfirm"),
CloseButtonText = resourceLoader.GetString("PythonPackageInstallCancel"),
};
var mainWindow = (Microsoft.UI.Xaml.Application.Current as AdvancedPaste.App)?.GetMainWindow();
if (mainWindow?.Content?.XamlRoot is { } xamlRoot)
{
dialog.XamlRoot = xamlRoot;
}
var result = await dialog.ShowAsync();
return result == ContentDialogResult.Primary;
}
catch (Exception ex)
{
Logger.LogError("Failed to show package install dialog", ex);
return false;
}
}
}

View File

@@ -0,0 +1,255 @@
# 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.
"""
Advanced Paste Script Runner (V3 Named Function Interface)
This runner is shipped with PowerToys and is NOT user-editable.
It loads a user script, discovers the single advanced_paste_from_<input>_to_<output>
function by name convention, calls it with the current clipboard data, and formats
the return value into JSON on stdout.
Each script must define exactly one function matching the pattern:
def advanced_paste_from_<input>_to_<output>(<param>)
Supported input types:
- text, html, image, audio, video, files
Required output types (declared via _to_ suffix):
- text, html, image, file, files
Examples:
- advanced_paste_from_text_to_text(text: str) → output is text
- advanced_paste_from_text_to_image(text: str) → output is image
- advanced_paste_from_image_to_text(image_path) → output is text
- advanced_paste_from_files_to_text(file_paths) → output is text
Protocol:
- Input: JSON on stdin (clipboard data from C#)
- Output: JSON on stdout (result for C# to set on clipboard)
- Errors: stderr (displayed to user on failure)
"""
import importlib.util
import json
import os
import re
import sys
from pathlib import Path
def _apply_output_hint(result, hint: str) -> dict:
"""
Force the output to the type specified by the _to_ suffix in the function name.
Converts the return value to match the hinted type.
"""
if result is None:
if hint == "text":
return {"result_type": "text", "text": ""}
elif hint == "html":
return {"result_type": "html", "html": ""}
elif hint == "image":
return {"result_type": "image", "image_path": ""}
elif hint == "audio":
return {"result_type": "audio", "audio_path": ""}
elif hint == "video":
return {"result_type": "video", "video_path": ""}
elif hint in ("file", "files"):
return {"result_type": hint, "file_paths": []}
if hint == "text":
return {"result_type": "text", "text": str(result) if not isinstance(result, str) else result}
elif hint == "html":
return {"result_type": "html", "html": str(result) if not isinstance(result, str) else result}
elif hint == "image":
path = str(result)
return {"result_type": "image", "image_path": path}
elif hint == "audio":
path = str(result)
return {"result_type": "audio", "audio_path": path}
elif hint == "video":
path = str(result)
return {"result_type": "video", "video_path": path}
elif hint == "file":
if isinstance(result, (list, tuple)):
paths = [str(p) for p in result]
else:
paths = [str(result)]
return {"result_type": "file", "file_paths": paths}
elif hint == "files":
if isinstance(result, (list, tuple)):
paths = [str(p) for p in result]
else:
paths = [str(result)]
return {"result_type": "files", "file_paths": paths}
# Fallback (shouldn't happen with valid hints)
return {"result_type": "text", "text": str(result)}
# Pattern matching advanced_paste_from_<input>_to_<output> function names.
_AP_FUNCTION_PATTERN = re.compile(
r"^advanced_paste_from_(text|html|image|audio|video|files)_to_(text|html|image|audio|video|file|files)$"
)
def _load_user_module(script_path: str):
"""Dynamically load the user script as a Python module."""
spec = importlib.util.spec_from_file_location("_user_script", script_path)
if spec is None or spec.loader is None:
raise ImportError(f"Cannot load script: {script_path}")
module = importlib.util.module_from_spec(spec)
# Add the script's directory to sys.path so relative imports/helpers work.
script_dir = os.path.dirname(os.path.abspath(script_path))
if script_dir not in sys.path:
sys.path.insert(0, script_dir)
spec.loader.exec_module(module)
return module
def _discover_ap_function(module) -> tuple:
"""
Discover the single advanced_paste_from_<input>_to_<output> function in the module.
Returns a tuple (input_type, output_type, function) or None if not found.
Exits with error if multiple functions are defined.
"""
matches = []
for name in dir(module):
match = _AP_FUNCTION_PATTERN.match(name)
if match:
fn = getattr(module, name)
if callable(fn):
input_type = match.group(1)
output_type = match.group(2)
matches.append((input_type, output_type, fn))
if len(matches) == 0:
return None
if len(matches) > 1:
names = [f"advanced_paste_from_{m[0]}_to_{m[1]}" for m in matches]
print(
f"Error: script defines multiple advanced_paste_from_*_to_* functions "
f"({', '.join(names)}). Only one is allowed per script.",
file=sys.stderr,
)
sys.exit(1)
return matches[0]
def _format_output(result, output_type: str) -> dict:
"""
Format the return value according to the declared output type from the function name.
The output_type comes from the _to_ suffix and is always provided.
"""
if result is None:
if output_type in ("file", "files"):
return {"result_type": output_type, "file_paths": []}
elif output_type == "image":
return {"result_type": "image", "image_path": ""}
elif output_type == "audio":
return {"result_type": "audio", "audio_path": ""}
elif output_type == "video":
return {"result_type": "video", "video_path": ""}
elif output_type == "html":
return {"result_type": "html", "html": ""}
return {"result_type": "text", "text": ""}
return _apply_output_hint(result, output_type)
# ---------------------------------------------------------------------------
# Main entry point
# ---------------------------------------------------------------------------
def main():
if len(sys.argv) < 2:
print("Usage: _runner.py <script_path>", file=sys.stderr)
sys.exit(1)
script_path = sys.argv[1]
if not os.path.isfile(script_path):
print(f"Error: script not found: {script_path}", file=sys.stderr)
sys.exit(1)
# Read input payload from stdin.
try:
data = json.load(sys.stdin)
except json.JSONDecodeError as e:
print(f"Error: invalid JSON input: {e}", file=sys.stderr)
sys.exit(1)
# Load the user script.
module = _load_user_module(script_path)
# Discover the single advanced_paste_from_* function.
ap_result = _discover_ap_function(module)
if ap_result is None:
print(
f"Error: script '{os.path.basename(script_path)}' does not define an "
f"advanced_paste_from_<input>_to_<output> function.\n"
f"Example: def advanced_paste_from_text_to_text(text): return text.upper()",
file=sys.stderr,
)
sys.exit(1)
input_type, output_type, fn = ap_result
# Determine the input data key for this function's input type.
input_map = {
"text": "text",
"html": "html",
"image": "image_path",
"audio": "audio_path",
"video": "video_path",
"files": "file_paths",
}
key = input_map.get(input_type, input_type)
input_value = data.get(key)
# Expose work_dir as environment variable so scripts can write output files
# to a location accessible from both WSL and Windows (under /mnt/c/...).
work_dir = data.get("work_dir", "")
if work_dir:
os.environ["ADVANCED_PASTE_WORK_DIR"] = work_dir
# Check if the clipboard has matching data for this script's input type.
formats = data.get("format", ["text"])
if isinstance(formats, str):
formats = [formats]
# Normalize: treat "file" and "files" as equivalent so that
# advanced_paste_from_files_to_* scripts match the C# ClipboardFormat.File flag.
normalized_formats = set(formats)
if "file" in normalized_formats:
normalized_formats.add("files")
if "files" in normalized_formats:
normalized_formats.add("file")
if input_type not in normalized_formats:
print(
f"Error: script expects '{input_type}' input but clipboard has [{', '.join(formats)}].",
file=sys.stderr,
)
sys.exit(1)
if input_value is None:
print(
f"Error: no data available for format '{input_type}' "
f"(expected '{key}' in input payload).",
file=sys.stderr,
)
sys.exit(1)
# Call the function.
result = fn(input_value)
output = _format_output(result, output_type)
# Output JSON result.
json.dump(output, sys.stdout, ensure_ascii=False)
if __name__ == "__main__":
main()

View File

@@ -212,10 +212,10 @@
<value>Delete</value>
</data>
<data name="CustomFormatTextBox.PlaceholderText" xml:space="preserve">
<value>Describe what format you want..</value>
<value>Search or describe what format you want...</value>
</data>
<data name="InputTxtBoxTooltip.Text" xml:space="preserve">
<value>Describe what format you want..</value>
<value>Search or describe what format you want...</value>
</data>
<data name="LearnMoreLink.Text" xml:space="preserve">
<value>Privacy</value>
@@ -372,4 +372,75 @@
<value>Unable to load Foundry Local model: {0}</value>
<comment>{0} is the model identifier. Do not translate {0}.</comment>
</data>
<data name="PythonNotFound" xml:space="preserve">
<value>Python was not found. Please install Python or configure the path in Settings.</value>
</data>
<data name="WslNotAvailable" xml:space="preserve">
<value>WSL is not installed or not available. Cannot run Linux scripts.</value>
</data>
<data name="PythonScriptFailed" xml:space="preserve">
<value>The Python script failed to execute.</value>
</data>
<data name="PythonScriptTimeout" xml:space="preserve">
<value>Script execution timed out ({0} seconds).</value>
<comment>{0} is the configured timeout in seconds. Do not translate {0}.</comment>
</data>
<data name="PythonScriptNotFound" xml:space="preserve">
<value>Script file not found: {0}</value>
<comment>{0} is the script file path. Do not translate {0}.</comment>
</data>
<data name="PythonScriptInvalidJson" xml:space="preserve">
<value>The script output is not valid JSON.</value>
</data>
<data name="PythonScriptTrustTitle" xml:space="preserve">
<value>Run Python Script?</value>
</data>
<data name="PythonScriptTrustContent" xml:space="preserve">
<value>This script has not been verified. Running untrusted scripts can be a security risk. Do you want to run the following script?
{0}
SHA256: {1}</value>
<comment>{0} is the script file path. {1} is the SHA-256 hash of the script. Do not translate {0} or {1}.</comment>
</data>
<data name="PythonScriptTrustConfirm" xml:space="preserve">
<value>Run</value>
</data>
<data name="PythonScriptTrustCancel" xml:space="preserve">
<value>Cancel</value>
</data>
<data name="PythonPackageInstallTitle" xml:space="preserve">
<value>Install Missing Packages?</value>
</data>
<data name="PythonPackageInstallContent" xml:space="preserve">
<value>The script "{0}" requires the following Python packages that are not installed:
{1}
Install them now?</value>
<comment>{0} = script display name, {1} = bullet list of package names. Do not translate package names.</comment>
</data>
<data name="PythonPackageInstallConfirm" xml:space="preserve">
<value>Install</value>
</data>
<data name="PythonPackageInstallCancel" xml:space="preserve">
<value>Skip</value>
</data>
<data name="PythonPackageInstallFailed" xml:space="preserve">
<value>Failed to install package(s) "{0}": {1}</value>
<comment>{0} = pip package names, {1} = error detail. Do not translate package names.</comment>
</data>
<data name="PythonPackageInstallTimeout" xml:space="preserve">
<value>Package installation for "{0}" timed out ({1} seconds).</value>
<comment>{0} = pip package names, {1} = timeout in seconds. Do not translate {0} or {1}.</comment>
</data>
<data name="ShowErrorDetailsBtn.Content" xml:space="preserve">
<value>Show details</value>
</data>
<data name="ErrorDetailsDialogTitle" xml:space="preserve">
<value>Error Details</value>
</data>
<data name="ErrorDetailsDialogClose" xml:space="preserve">
<value>Close</value>
</data>
</root>

View File

@@ -16,6 +16,7 @@ using System.Threading.Tasks;
using AdvancedPaste.Helpers;
using AdvancedPaste.Models;
using AdvancedPaste.Services;
using AdvancedPaste.Services.PythonScripts;
using AdvancedPaste.Settings;
using Common.UI;
using CommunityToolkit.Mvvm.ComponentModel;
@@ -41,6 +42,7 @@ namespace AdvancedPaste.ViewModels
private readonly IUserSettings _userSettings;
private readonly IPasteFormatExecutor _pasteFormatExecutor;
private readonly IAICredentialsProvider _credentialsProvider;
private readonly IPythonScriptService _pythonScriptService;
private CancellationTokenSource _pasteActionCancellationTokenSource;
@@ -100,6 +102,8 @@ namespace AdvancedPaste.ViewModels
public ObservableCollection<PasteFormat> CustomActionPasteFormats { get; } = [];
public ObservableCollection<PasteFormat> PythonScriptPasteFormats { get; } = [];
public bool IsCustomAIServiceEnabled
{
get
@@ -234,8 +238,6 @@ namespace AdvancedPaste.ViewModels
public bool ShowClipboardHistoryButton => ClipboardHistoryEnabled;
public bool ShowAIPasteSection => _userSettings.ShowAIPaste && IsAllowedByGPO;
public bool HasIndeterminateTransformProgress => double.IsNaN(TransformProgress);
private PasteFormats CustomAIFormat =>
@@ -260,11 +262,12 @@ namespace AdvancedPaste.ViewModels
public event EventHandler PreviewRequested;
public OptionsViewModel(IFileSystem fileSystem, IAICredentialsProvider credentialsProvider, IUserSettings userSettings, IPasteFormatExecutor pasteFormatExecutor)
public OptionsViewModel(IFileSystem fileSystem, IAICredentialsProvider credentialsProvider, IUserSettings userSettings, IPasteFormatExecutor pasteFormatExecutor, IPythonScriptService pythonScriptService)
{
_credentialsProvider = credentialsProvider;
_userSettings = userSettings;
_pasteFormatExecutor = pasteFormatExecutor;
_pythonScriptService = pythonScriptService;
GeneratedResponses = [];
GeneratedResponses.CollectionChanged += (s, e) =>
@@ -322,7 +325,6 @@ namespace AdvancedPaste.ViewModels
OnPropertyChanged(nameof(AIProviders));
OnPropertyChanged(nameof(AllowedAIProviders));
OnPropertyChanged(nameof(ShowClipboardPreview));
OnPropertyChanged(nameof(ShowAIPasteSection));
NotifyActiveProviderChanged();
@@ -416,12 +418,51 @@ namespace AdvancedPaste.ViewModels
}
UpdateFormats(StandardPasteFormats, Enum.GetValues<PasteFormats>()
.Where(format => PasteFormat.MetadataDict[format].IsCoreAction || _userSettings.AdditionalActions.Contains(format))
.Where(format => format != PasteFormats.PythonScript &&
(PasteFormat.MetadataDict[format].IsCoreAction || _userSettings.AdditionalActions.Contains(format)))
.Select(CreateStandardPasteFormat));
UpdateFormats(
CustomActionPasteFormats,
IsCustomAIServiceEnabled ? _userSettings.CustomActions.Select(customAction => CreateCustomAIPasteFormat(customAction.Name, customAction.Prompt, isSavedQuery: true)) : []);
UpdateFormats(
PythonScriptPasteFormats,
BuildPythonScriptFormats());
}
private IEnumerable<PasteFormat> BuildPythonScriptFormats()
{
if (!_userSettings.IsPythonScriptsEnabled)
{
yield break;
}
var folder = _userSettings.PythonScriptsFolder;
if (string.IsNullOrWhiteSpace(folder))
{
yield break;
}
var discoveredScripts = _pythonScriptService.DiscoverScripts(folder);
var scriptActions = _userSettings.PythonScriptActions;
// Use metadata from discovered scripts, but apply IsShown from saved settings.
var hiddenPaths = new System.Collections.Generic.HashSet<string>(
scriptActions.Where(a => !a.IsShown).Select(a => a.ScriptPath),
StringComparer.OrdinalIgnoreCase);
foreach (var meta in discoveredScripts)
{
if (hiddenPaths.Contains(meta.ScriptPath) || !meta.IsEnabled)
{
continue;
}
// Filter by intersection: only pass clipboard formats the script supports.
var filteredFormats = AvailableClipboardFormats & meta.SupportedFormats;
yield return PasteFormat.CreatePythonScriptFormat(meta.Name, meta.ScriptPath, filteredFormats);
}
}
public void Dispose()
@@ -695,7 +736,10 @@ namespace AdvancedPaste.ViewModels
_pasteActionCancellationTokenSource = new();
TransformProgress = double.NaN;
PasteActionError = PasteActionError.None;
Query = pasteFormat.Query;
// For Python scripts the Prompt field holds the file path, not a user-visible query.
// Setting Query to the path would show it in the AI prompt box, which is misleading.
Query = pasteFormat.Format == PasteFormats.PythonScript ? string.Empty : pasteFormat.Query;
try
{
@@ -735,7 +779,7 @@ namespace AdvancedPaste.ViewModels
internal async Task ExecutePasteFormatAsync(VirtualKey key)
{
var pasteFormat = StandardPasteFormats.Concat(CustomActionPasteFormats)
var pasteFormat = StandardPasteFormats.Concat(CustomActionPasteFormats).Concat(PythonScriptPasteFormats)
.Where(pasteFormat => pasteFormat.IsEnabled)
.ElementAtOrDefault(key - VirtualKey.Number1);

View File

@@ -496,119 +496,23 @@ private:
if (!GetGUIThreadInfo(0, &gui_info))
{
Logger::warn(L"Auto-copy: GetGUIThreadInfo failed (error={})", GetLastError());
return false;
}
HWND target = gui_info.hwndFocus ? gui_info.hwndFocus : gui_info.hwndActive;
if (!target)
{
Logger::warn(L"Auto-copy: no focused or active window found");
return false;
}
DWORD_PTR result = 0;
auto sendResult = SendMessageTimeout(target, WM_COPY, 0, 0, SMTO_ABORTIFHUNG | SMTO_BLOCK, 50, &result);
return sendResult != 0;
}
// Helper: poll clipboard sequence number for a change from initial_sequence.
// Returns true if the sequence number changed within the given number of polls.
bool poll_clipboard_sequence(DWORD initial_sequence, int poll_attempts, std::chrono::milliseconds poll_delay)
{
for (int poll = 0; poll < poll_attempts; ++poll)
{
if (GetClipboardSequenceNumber() != initial_sequence)
{
return true;
}
std::this_thread::sleep_for(poll_delay);
}
return false;
}
// Helper: send Ctrl+C via SendInput, releasing any held modifier keys first
// (the hotkey combination may still have modifiers physically pressed).
bool send_ctrl_c_input()
{
std::vector<INPUT> inputs;
// Release all modifier keys that are currently held down from the hotkey.
// Without this, the target app sees e.g. Win+Shift+Ctrl+C instead of just Ctrl+C.
try_inject_modifier_key_up(inputs, VK_LCONTROL);
try_inject_modifier_key_up(inputs, VK_RCONTROL);
try_inject_modifier_key_up(inputs, VK_LWIN);
try_inject_modifier_key_up(inputs, VK_RWIN);
try_inject_modifier_key_up(inputs, VK_LSHIFT);
try_inject_modifier_key_up(inputs, VK_RSHIFT);
try_inject_modifier_key_up(inputs, VK_LMENU);
try_inject_modifier_key_up(inputs, VK_RMENU);
// Ctrl down
{
INPUT input_event = {};
input_event.type = INPUT_KEYBOARD;
input_event.ki.wVk = VK_CONTROL;
input_event.ki.dwExtraInfo = CENTRALIZED_KEYBOARD_HOOK_DONT_TRIGGER_FLAG;
inputs.push_back(input_event);
}
// C down
{
INPUT input_event = {};
input_event.type = INPUT_KEYBOARD;
input_event.ki.wVk = 0x43; // C
input_event.ki.dwExtraInfo = CENTRALIZED_KEYBOARD_HOOK_DONT_TRIGGER_FLAG;
inputs.push_back(input_event);
}
// C up
{
INPUT input_event = {};
input_event.type = INPUT_KEYBOARD;
input_event.ki.wVk = 0x43; // C
input_event.ki.dwFlags = KEYEVENTF_KEYUP;
input_event.ki.dwExtraInfo = CENTRALIZED_KEYBOARD_HOOK_DONT_TRIGGER_FLAG;
inputs.push_back(input_event);
}
// Ctrl up
{
INPUT input_event = {};
input_event.type = INPUT_KEYBOARD;
input_event.ki.wVk = VK_CONTROL;
input_event.ki.dwFlags = KEYEVENTF_KEYUP;
input_event.ki.dwExtraInfo = CENTRALIZED_KEYBOARD_HOOK_DONT_TRIGGER_FLAG;
inputs.push_back(input_event);
}
// Restore modifiers that were held down
try_inject_modifier_key_restore(inputs, VK_LCONTROL);
try_inject_modifier_key_restore(inputs, VK_RCONTROL);
try_inject_modifier_key_restore(inputs, VK_LWIN);
try_inject_modifier_key_restore(inputs, VK_RWIN);
try_inject_modifier_key_restore(inputs, VK_LSHIFT);
try_inject_modifier_key_restore(inputs, VK_RSHIFT);
try_inject_modifier_key_restore(inputs, VK_LMENU);
try_inject_modifier_key_restore(inputs, VK_RMENU);
// Prevent Start Menu from activating after Win key release/restore
INPUT dummyEvent = {};
dummyEvent.type = INPUT_KEYBOARD;
dummyEvent.ki.wVk = 0xFF;
dummyEvent.ki.dwFlags = KEYEVENTF_KEYUP;
inputs.push_back(dummyEvent);
auto uSent = SendInput(static_cast<UINT>(inputs.size()), inputs.data(), sizeof(INPUT));
if (uSent != inputs.size())
{
DWORD errorCode = GetLastError();
auto errorMessage = get_last_error_message(errorCode);
Logger::error(L"SendInput failed for Ctrl+C. Expected to send {} inputs and sent only {}. {}", inputs.size(), uSent, errorMessage.has_value() ? errorMessage.value() : L"");
Trace::AdvancedPaste_Error(errorCode, errorMessage.has_value() ? errorMessage.value() : L"", L"input.SendInput");
return false;
}
return true;
return SendMessageTimeout(target,
WM_COPY,
0,
0,
SMTO_ABORTIFHUNG | SMTO_BLOCK,
50,
&result) != 0;
}
bool send_copy_selection()
@@ -622,30 +526,99 @@ private:
for (int attempt = 0; attempt < copy_attempts; ++attempt)
{
const auto initial_sequence = GetClipboardSequenceNumber();
copy_succeeded = try_send_copy_message();
// Strategy 1: Try WM_COPY message (works for standard Win32 controls)
bool wm_copy_sent = try_send_copy_message();
if (wm_copy_sent)
if (!copy_succeeded)
{
if (poll_clipboard_sequence(initial_sequence, clipboard_poll_attempts, clipboard_poll_delay))
std::vector<INPUT> inputs;
// Release any held modifiers (from the Advanced Paste hotkey) before sending Ctrl+C.
// Without this, apps may receive Win+Shift+Ctrl+C instead of Ctrl+C.
try_inject_modifier_key_up(inputs, VK_LCONTROL);
try_inject_modifier_key_up(inputs, VK_RCONTROL);
try_inject_modifier_key_up(inputs, VK_LWIN);
try_inject_modifier_key_up(inputs, VK_RWIN);
try_inject_modifier_key_up(inputs, VK_LSHIFT);
try_inject_modifier_key_up(inputs, VK_RSHIFT);
try_inject_modifier_key_up(inputs, VK_LMENU);
try_inject_modifier_key_up(inputs, VK_RMENU);
// send Ctrl+C (key downs and key ups)
{
INPUT input_event = {};
input_event.type = INPUT_KEYBOARD;
input_event.ki.wVk = VK_CONTROL;
input_event.ki.dwExtraInfo = CENTRALIZED_KEYBOARD_HOOK_DONT_TRIGGER_FLAG;
inputs.push_back(input_event);
}
{
INPUT input_event = {};
input_event.type = INPUT_KEYBOARD;
input_event.ki.wVk = 0x43; // C
// Avoid triggering detection by the centralized keyboard hook.
input_event.ki.dwExtraInfo = CENTRALIZED_KEYBOARD_HOOK_DONT_TRIGGER_FLAG;
inputs.push_back(input_event);
}
{
INPUT input_event = {};
input_event.type = INPUT_KEYBOARD;
input_event.ki.wVk = 0x43; // C
input_event.ki.dwFlags = KEYEVENTF_KEYUP;
// Avoid triggering detection by the centralized keyboard hook.
input_event.ki.dwExtraInfo = CENTRALIZED_KEYBOARD_HOOK_DONT_TRIGGER_FLAG;
inputs.push_back(input_event);
}
{
INPUT input_event = {};
input_event.type = INPUT_KEYBOARD;
input_event.ki.wVk = VK_CONTROL;
input_event.ki.dwFlags = KEYEVENTF_KEYUP;
input_event.ki.dwExtraInfo = CENTRALIZED_KEYBOARD_HOOK_DONT_TRIGGER_FLAG;
inputs.push_back(input_event);
}
// Restore modifiers that were released above.
try_inject_modifier_key_restore(inputs, VK_LCONTROL);
try_inject_modifier_key_restore(inputs, VK_RCONTROL);
try_inject_modifier_key_restore(inputs, VK_LWIN);
try_inject_modifier_key_restore(inputs, VK_RWIN);
try_inject_modifier_key_restore(inputs, VK_LSHIFT);
try_inject_modifier_key_restore(inputs, VK_RSHIFT);
try_inject_modifier_key_restore(inputs, VK_LMENU);
try_inject_modifier_key_restore(inputs, VK_RMENU);
auto uSent = SendInput(static_cast<UINT>(inputs.size()), inputs.data(), sizeof(INPUT));
if (uSent != inputs.size())
{
DWORD errorCode = GetLastError();
auto errorMessage = get_last_error_message(errorCode);
Logger::error(L"SendInput failed for Ctrl+C. Expected to send {} inputs and sent only {}. {}", inputs.size(), uSent, errorMessage.has_value() ? errorMessage.value() : L"");
Trace::AdvancedPaste_Error(errorCode, errorMessage.has_value() ? errorMessage.value() : L"", L"input.SendInput");
}
else
{
copy_succeeded = true;
}
}
// Strategy 2: If WM_COPY didn't work, try SendInput Ctrl+C (works for Electron, browsers, etc.)
if (!copy_succeeded)
if (copy_succeeded)
{
const auto sequence_before_ctrl_c = GetClipboardSequenceNumber();
if (send_ctrl_c_input())
bool sequence_changed = false;
for (int poll_attempt = 0; poll_attempt < clipboard_poll_attempts; ++poll_attempt)
{
if (poll_clipboard_sequence(sequence_before_ctrl_c, clipboard_poll_attempts, clipboard_poll_delay))
if (GetClipboardSequenceNumber() != initial_sequence)
{
copy_succeeded = true;
sequence_changed = true;
break;
}
std::this_thread::sleep_for(clipboard_poll_delay);
}
copy_succeeded = sequence_changed;
}
if (copy_succeeded)
@@ -659,11 +632,6 @@ private:
}
}
if (!copy_succeeded)
{
Logger::warn(L"Auto-copy: all {} copy attempts failed — the target application did not update the clipboard after WM_COPY and Ctrl+C", copy_attempts);
}
return copy_succeeded;
}
@@ -1036,7 +1004,6 @@ public:
{
if (!send_copy_selection())
{
Logger::warn(L"Auto-copy: failed to copy selection for custom action index {} — aborting action", custom_action_index);
return false;
}
}

View File

@@ -1 +1 @@
{"properties":{"IsAIEnabled":{"value":false},"ShowCustomPreview":{"value":true},"ShowAIPaste":{"value":true},"CloseAfterLosingFocus":{"value":false},"advanced-paste-ui-hotkey":{"win":true,"ctrl":false,"alt":false,"shift":true,"code":86,"key":""},"paste-as-plain-hotkey":{"win":true,"ctrl":true,"alt":true,"shift":false,"code":79,"key":""},"paste-as-markdown-hotkey":{"win":true,"ctrl":true,"alt":true,"shift":false,"code":77,"key":""},"paste-as-json-hotkey":{"win":true,"ctrl":true,"alt":true,"shift":false,"code":74,"key":""},"custom-actions":{"value":[]},"additional-actions":{"image-to-text":{"shortcut":{"win":false,"ctrl":false,"alt":false,"shift":false,"code":0,"key":""},"isShown":true},"paste-as-file":{"isShown":true,"paste-as-txt-file":{"shortcut":{"win":false,"ctrl":false,"alt":false,"shift":false,"code":0,"key":""},"isShown":true},"paste-as-png-file":{"shortcut":{"win":false,"ctrl":false,"alt":false,"shift":false,"code":0,"key":""},"isShown":true},"paste-as-html-file":{"shortcut":{"win":false,"ctrl":false,"alt":false,"shift":false,"code":0,"key":""},"isShown":true}},"transcode":{"isShown":true,"transcode-to-mp3":{"shortcut":{"win":false,"ctrl":false,"alt":false,"shift":false,"code":0,"key":""},"isShown":true},"transcode-to-mp4":{"shortcut":{"win":false,"ctrl":false,"alt":false,"shift":false,"code":0,"key":""},"isShown":true}}},"paste-ai-configuration":{"active-provider-id":"","providers":[],"use-shared-credentials":true}},"name":"AdvancedPaste","version":"1"}
{"properties":{"IsAIEnabled":{"value":false},"ShowCustomPreview":{"value":true},"CloseAfterLosingFocus":{"value":false},"advanced-paste-ui-hotkey":{"win":true,"ctrl":false,"alt":false,"shift":true,"code":86,"key":""},"paste-as-plain-hotkey":{"win":true,"ctrl":true,"alt":true,"shift":false,"code":79,"key":""},"paste-as-markdown-hotkey":{"win":true,"ctrl":true,"alt":true,"shift":false,"code":77,"key":""},"paste-as-json-hotkey":{"win":true,"ctrl":true,"alt":true,"shift":false,"code":74,"key":""},"custom-actions":{"value":[]},"additional-actions":{"image-to-text":{"shortcut":{"win":false,"ctrl":false,"alt":false,"shift":false,"code":0,"key":""},"isShown":true},"paste-as-file":{"isShown":true,"paste-as-txt-file":{"shortcut":{"win":false,"ctrl":false,"alt":false,"shift":false,"code":0,"key":""},"isShown":true},"paste-as-png-file":{"shortcut":{"win":false,"ctrl":false,"alt":false,"shift":false,"code":0,"key":""},"isShown":true},"paste-as-html-file":{"shortcut":{"win":false,"ctrl":false,"alt":false,"shift":false,"code":0,"key":""},"isShown":true}},"transcode":{"isShown":true,"transcode-to-mp3":{"shortcut":{"win":false,"ctrl":false,"alt":false,"shift":false,"code":0,"key":""},"isShown":true},"transcode-to-mp4":{"shortcut":{"win":false,"ctrl":false,"alt":false,"shift":false,"code":0,"key":""},"isShown":true}}},"paste-ai-configuration":{"active-provider-id":"","providers":[],"use-shared-credentials":true}},"name":"AdvancedPaste","version":"1"}

View File

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

View File

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

View File

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

View File

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

View File

@@ -3,6 +3,7 @@
// 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;
@@ -11,17 +12,25 @@ 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 : ObservableObject, IDisposable
public partial class BaseApplication : INotifyPropertyChanged, 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; }
@@ -30,9 +39,23 @@ namespace WorkspacesCsharpLibrary.Models
public string Aumid { get; set; }
[ObservableProperty]
[property: JsonIgnore]
private bool _isNotFound;
[JsonIgnore]
public bool IsNotFound
{
get
{
return _isNotFound;
}
set
{
if (_isNotFound != value)
{
_isNotFound = value;
OnPropertyChanged(new PropertyChangedEventArgs(nameof(IsNotFound)));
}
}
}
private Icon _icon;

View File

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

View File

@@ -1,273 +0,0 @@
// Copyright (c) Microsoft Corporation
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
using System.Collections.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

@@ -1,193 +0,0 @@
// Copyright (c) Microsoft Corporation
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
using System.Collections.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

@@ -1,118 +0,0 @@
// Copyright (c) Microsoft Corporation
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
using System.Collections.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

@@ -1,38 +0,0 @@
// Copyright (c) Microsoft Corporation
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
using Microsoft.VisualStudio.TestTools.UnitTesting;
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

@@ -1,143 +0,0 @@
// Copyright (c) Microsoft Corporation
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
using System.Collections.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

@@ -1,60 +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.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

@@ -1,51 +0,0 @@
// Copyright (c) Microsoft Corporation
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
using System.Collections.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

@@ -1,28 +0,0 @@
<Project Sdk="Microsoft.NET.Sdk">
<!-- Look at Directory.Build.props in root for common stuff as well -->
<Import Project="$(RepoRoot)src\Common.Dotnet.CsWinRT.props" />
<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,29 +0,0 @@
// Copyright (c) Microsoft Corporation
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
using System;
using Microsoft.UI.Xaml;
using Microsoft.UI.Xaml.Data;
namespace WorkspacesEditor.Converters
{
public partial class BooleanToVisibilityConverter : IValueConverter
{
public object Convert(object value, Type targetType, object parameter, string language)
{
if (value is bool boolValue && boolValue)
{
return Visibility.Visible;
}
return Visibility.Collapsed;
}
public object ConvertBack(object value, Type targetType, object parameter, string language)
{
throw new NotImplementedException();
}
}
}

View File

@@ -1,27 +0,0 @@
// Copyright (c) Microsoft Corporation
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
using Microsoft.UI.Xaml.Data;
using WorkspacesEditor.Helpers;
namespace WorkspacesEditor.Converters
{
/// <summary>
/// Converts a workspace name to a contextual button label like "Launch MyWorkspace".
/// </summary>
public sealed partial class LaunchButtonNameConverter : IValueConverter
{
public object Convert(object value, System.Type targetType, object parameter, string language)
{
string name = value as string ?? string.Empty;
string launchStr = ResourceLoaderInstance.ResourceLoader?.GetString("Launch") ?? "Launch";
return $"{launchStr} {name}";
}
public object ConvertBack(object value, System.Type targetType, object parameter, string language)
{
throw new System.NotImplementedException();
}
}
}

View File

@@ -1,27 +0,0 @@
// Copyright (c) Microsoft Corporation
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
using Microsoft.UI.Xaml.Data;
using WorkspacesEditor.Helpers;
namespace WorkspacesEditor.Converters
{
/// <summary>
/// Converts a workspace name to a contextual label like "More options for MyWorkspace".
/// </summary>
public sealed partial class MoreOptionsButtonNameConverter : IValueConverter
{
public object Convert(object value, System.Type targetType, object parameter, string language)
{
string name = value as string ?? string.Empty;
string moreOptionsStr = ResourceLoaderInstance.ResourceLoader?.GetString("MoreOptions") ?? "More options";
return $"{moreOptionsStr} {name}";
}
public object ConvertBack(object value, System.Type targetType, object parameter, string language)
{
throw new System.NotImplementedException();
}
}
}

View File

@@ -1,50 +0,0 @@
// Copyright (c) Microsoft Corporation
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
using System;
using System.ComponentModel;
using System.Drawing;
using System.Drawing.Imaging;
using System.IO;
using Microsoft.UI.Xaml.Media.Imaging;
namespace 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

@@ -1,35 +0,0 @@
// Copyright (c) Microsoft Corporation
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
using System;
using ManagedCommon;
using Microsoft.Windows.ApplicationModel.Resources;
namespace 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

@@ -1,25 +0,0 @@
// Copyright (c) Microsoft Corporation
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
using Microsoft.UI.Xaml;
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

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

View File

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

View File

@@ -1,13 +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.Messages
{
/// <summary>
/// Sent to request graceful application shutdown via the WinUI lifecycle.
/// </summary>
public sealed class CloseApplicationMessage
{
}
}

View File

@@ -1,13 +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.Messages
{
/// <summary>
/// Sent by ViewModel to request navigation back to the main page.
/// </summary>
public sealed class GoBackMessage
{
}
}

View File

@@ -1,13 +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.Messages
{
/// <summary>
/// Sent by ViewModel to request the main window be minimized.
/// </summary>
public sealed class MinimizeWindowMessage
{
}
}

View File

@@ -1,21 +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 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

@@ -1,13 +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.Messages
{
/// <summary>
/// Sent by ViewModel to request the main window be restored from minimized state.
/// </summary>
public sealed class RestoreWindowMessage
{
}
}

View File

@@ -1,13 +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.Messages
{
/// <summary>
/// Sent by ViewModel to request the View layer show the snapshot window.
/// </summary>
public sealed class ShowSnapshotWindowMessage
{
}
}

View File

@@ -1,13 +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.Messages
{
/// <summary>
/// Sent by SnapshotWindow when user cancels (closes without capturing).
/// </summary>
public sealed class SnapshotCancelledMessage
{
}
}

View File

@@ -1,13 +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.Messages
{
/// <summary>
/// Sent by SnapshotWindow when user clicks Capture.
/// </summary>
public sealed class SnapshotCapturedMessage
{
}
}

View File

@@ -1,26 +0,0 @@
// Copyright (c) Microsoft Corporation
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
using Microsoft.UI.Xaml;
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

@@ -1,337 +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.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

@@ -1,46 +0,0 @@
// Copyright (c) Microsoft Corporation
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
using System;
using System.Threading;
using ManagedCommon;
using Microsoft.UI.Dispatching;
namespace 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,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.
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,110 +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.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

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

View File

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

View File

@@ -1,87 +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 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

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

View File

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

View File

@@ -1,23 +0,0 @@
<?xml version="1.0" encoding="utf-8" ?>
<Window
x:Class="WorkspacesEditor.MainWindow"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
Title="Workspaces"
mc:Ignorable="d">
<Grid Margin="0,16,0,0">
<Frame x:Name="ContentFrame" />
<ProgressRing
x:Name="LoadingRing"
Width="48"
Height="48"
HorizontalAlignment="Center"
VerticalAlignment="Center"
AutomationProperties.Name="Loading"
AutomationProperties.LiveSetting="Polite"
IsActive="False"
Visibility="Collapsed" />
</Grid>
</Window>

View File

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

View File

@@ -1,209 +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.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

@@ -1,48 +0,0 @@
<?xml version="1.0" encoding="utf-8" ?>
<Window
x:Class="WorkspacesEditor.Views.SnapshotWindow"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
Title="Snapshot Creator"
mc:Ignorable="d">
<Grid Margin="4">
<Grid.RowDefinitions>
<RowDefinition Height="*" />
<RowDefinition Height="Auto" />
</Grid.RowDefinitions>
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*" />
<ColumnDefinition Width="*" />
</Grid.ColumnDefinitions>
<ContentControl
Grid.Row="0"
Grid.ColumnSpan="2"
Margin="8"
VerticalAlignment="Center"
IsTabStop="True"
AutomationProperties.Name="{Binding Text, ElementName=DescriptionText}">
<TextBlock
x:Name="DescriptionText"
AutomationProperties.AccessibilityView="Raw"
HorizontalTextAlignment="Center"
TextWrapping="Wrap" />
</ContentControl>
<Button
x:Name="SnapshotButton"
Grid.Row="1"
Margin="8,8,4,8"
HorizontalAlignment="Stretch"
Click="SnapshotButtonClicked"
Style="{ThemeResource AccentButtonStyle}" />
<Button
x:Name="CancelButton"
Grid.Row="1"
Grid.Column="1"
Margin="4,8,8,8"
HorizontalAlignment="Stretch"
Click="CancelButtonClicked" />
</Grid>
</Window>

View File

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

View File

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

View File

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

View File

@@ -1,17 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<assembly manifestVersion="1.0" xmlns="urn:schemas-microsoft-com:asm.v1">
<assemblyIdentity version="1.0.0.0" name="PowerToys.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

@@ -0,0 +1,33 @@
<?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

@@ -0,0 +1,57 @@
<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

@@ -0,0 +1,173 @@
// 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

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

View File

@@ -0,0 +1,31 @@
// Copyright (c) Microsoft Corporation
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
using System.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

@@ -0,0 +1,292 @@
<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

@@ -0,0 +1,76 @@
// 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

@@ -0,0 +1,21 @@
<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

@@ -0,0 +1,179 @@
// 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

@@ -0,0 +1,24 @@
// 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

@@ -3,15 +3,10 @@
// 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
{
@@ -22,7 +17,7 @@ namespace WorkspacesEditor.Models
Minimized = 2,
}
public partial class Application : BaseApplication, IDisposable
public class Application : BaseApplication, IDisposable
{
private bool _isInitialized;
@@ -95,7 +90,7 @@ namespace WorkspacesEditor.Models
public override readonly int GetHashCode()
{
return HashCode.Combine(X, Y, Width, Height);
return base.GetHashCode();
}
}
@@ -111,11 +106,18 @@ 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()
@@ -128,7 +130,7 @@ namespace WorkspacesEditor.Models
{
if (_isInitialized)
{
Parent?.InitializePreview();
Parent.Initialize(App.GetCurrentTheme());
}
}
@@ -145,37 +147,35 @@ namespace WorkspacesEditor.Models
{
Maximized = value == (int)WindowPositionKind.Maximized;
Minimized = value == (int)WindowPositionKind.Minimized;
OnPropertyChanged(nameof(EditPositionEnabled));
OnPropertyChanged(new PropertyChangedEventArgs(nameof(EditPositionEnabled)));
RedrawPreviewImage();
}
}
private string _appMainParams;
public string AppMainParams
{
get
{
string adminStr = ResourceLoaderInstance.ResourceLoader?.GetString("Admin") ?? "Admin";
string argsStr = ResourceLoaderInstance.ResourceLoader?.GetString("Args") ?? "Args";
string result = IsElevated ? adminStr : string.Empty;
_appMainParams = _isElevated ? Properties.Resources.Admin : string.Empty;
if (!string.IsNullOrWhiteSpace(CommandLineArguments))
{
result += (result == string.Empty ? string.Empty : " | ") + argsStr + ": " + CommandLineArguments;
_appMainParams += (_appMainParams == string.Empty ? string.Empty : " | ") + Properties.Resources.Args + ": " + CommandLineArguments;
}
return result;
OnPropertyChanged(new PropertyChangedEventArgs(nameof(IsAppMainParamVisible)));
return _appMainParams;
}
}
public bool IsAppMainParamVisible => !string.IsNullOrWhiteSpace(AppMainParams);
public bool IsAppMainParamVisible => !string.IsNullOrWhiteSpace(_appMainParams);
[JsonIgnore]
public bool IsHighlighted { get; set; }
[ObservableProperty]
[NotifyPropertyChangedFor(nameof(RepeatIndexString))]
[property: JsonIgnore]
private int _repeatIndex;
[JsonIgnore]
public int RepeatIndex { get; set; }
[JsonIgnore]
public string RepeatIndexString => RepeatIndex <= 1 ? string.Empty : RepeatIndex.ToString(CultureInfo.InvariantCulture);
@@ -231,64 +231,51 @@ 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 string DeleteButtonContent
public bool IsExpanded
{
get
get => _isExpanded;
set
{
string deleteStr = ResourceLoaderInstance.ResourceLoader?.GetString("Delete") ?? "Remove";
string addBackStr = ResourceLoaderInstance.ResourceLoader?.GetString("AddBack") ?? "Add back";
return IsIncluded ? deleteStr : addBackStr;
if (_isExpanded != value)
{
_isExpanded = value;
OnPropertyChanged(new PropertyChangedEventArgs(nameof(IsExpanded)));
}
}
}
public string DeleteButtonAccessibleName => $"{DeleteButtonContent} {AppName}";
public string DeleteButtonContent => _isIncluded ? Properties.Resources.Delete : Properties.Resources.AddBack;
[ObservableProperty]
[NotifyPropertyChangedFor(nameof(DeleteButtonContent))]
[NotifyPropertyChangedFor(nameof(DeleteButtonAccessibleName))]
private bool _isIncluded = true;
partial void OnIsIncludedChanged(bool value)
public bool IsIncluded
{
if (!value)
get => _isIncluded;
set
{
IsExpanded = false;
if (_isIncluded != value)
{
_isIncluded = value;
OnPropertyChanged(new PropertyChangedEventArgs(nameof(IsIncluded)));
OnPropertyChanged(new PropertyChangedEventArgs(nameof(DeleteButtonContent)));
if (!_isIncluded)
{
IsExpanded = false;
}
}
}
}
private BitmapImage _iconImage;
[JsonIgnore]
public BitmapImage IconImage => _iconImage;
internal void CommandLineTextChanged(string newCommandLineValue)
{
CommandLineArguments = newCommandLineValue;
OnPropertyChanged(nameof(AppMainParams));
OnPropertyChanged(nameof(IsAppMainParamVisible));
OnPropertyChanged(new PropertyChangedEventArgs(nameof(AppMainParams)));
}
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 Windows.Foundation;
using System.Windows;
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,13 +1,21 @@
// 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 Windows.Foundation;
using System.ComponentModel;
using System.Windows;
namespace WorkspacesEditor.Models
{
public partial class MonitorSetup : Monitor
public class MonitorSetup : Monitor, INotifyPropertyChanged
{
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}";

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