mirror of
https://github.com/microsoft/PowerToys.git
synced 2026-07-02 16:39:14 +02:00
Compare commits
10 Commits
workspaces
...
user/muyua
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c73d51f804 | ||
|
|
20ea79af28 | ||
|
|
ea63d6cd1d | ||
|
|
dfb553dd20 | ||
|
|
86ddc6b5d1 | ||
|
|
950ee6ae7d | ||
|
|
c3b87795b3 | ||
|
|
961ff9319c | ||
|
|
541eb8a440 | ||
|
|
7a2b07a3c9 |
2
.github/actions/spell-check/expect.txt
vendored
2
.github/actions/spell-check/expect.txt
vendored
@@ -1234,8 +1234,6 @@ NOTSRCCOPY
|
||||
NOTSRCERASE
|
||||
Notupdated
|
||||
notwindows
|
||||
NOTXORPEN
|
||||
Nouveaut
|
||||
nowarn
|
||||
NOZORDER
|
||||
NPH
|
||||
|
||||
@@ -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
|
||||
---
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
285
doc/devdocs/modules/advancedpaste-python-scripts.md
Normal file
285
doc/devdocs/modules/advancedpaste-python-scripts.md
Normal 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.
|
||||
5
doc/devdocs/tools/clean-up-tool.md
Normal file
5
doc/devdocs/tools/clean-up-tool.md
Normal 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.
|
||||
@@ -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.
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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"),
|
||||
|
||||
@@ -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)
|
||||
{
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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=""
|
||||
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>
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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=""
|
||||
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>
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
@@ -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);
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
@@ -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>
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"}
|
||||
@@ -39,7 +39,6 @@
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<Natvis Include="$(MSBuildThisFileDirectory)..\..\natvis\wil.natvis" />
|
||||
<Natvis Include="$(MSBuildThisFileDirectory)..\..\natvis\wil.natstepfilter" />
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<None Include="packages.config" />
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -18,7 +18,6 @@
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="CommunityToolkit.Mvvm" />
|
||||
<PackageReference Include="System.IO.Abstractions" />
|
||||
</ItemGroup>
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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.");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
{
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
{
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
{
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
{
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
{
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
{
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
{
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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="" />
|
||||
<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="" />
|
||||
<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="" />
|
||||
<Button.Flyout>
|
||||
<MenuFlyout>
|
||||
<MenuFlyoutItem
|
||||
Click="EditButtonClicked"
|
||||
x:Uid="EditFlyoutItem">
|
||||
<MenuFlyoutItem.Icon>
|
||||
<FontIcon Glyph="" />
|
||||
</MenuFlyoutItem.Icon>
|
||||
</MenuFlyoutItem>
|
||||
<MenuFlyoutItem
|
||||
Click="DeleteButtonClicked"
|
||||
x:Uid="RemoveFlyoutItem">
|
||||
<MenuFlyoutItem.Icon>
|
||||
<FontIcon Glyph="" />
|
||||
</MenuFlyoutItem.Icon>
|
||||
</MenuFlyoutItem>
|
||||
</MenuFlyout>
|
||||
</Button.Flyout>
|
||||
</Button>
|
||||
</StackPanel>
|
||||
</Grid>
|
||||
</DataTemplate>
|
||||
</ListView.ItemTemplate>
|
||||
</ListView>
|
||||
</Grid>
|
||||
</Page>
|
||||
@@ -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}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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=""
|
||||
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="" />
|
||||
<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="" />
|
||||
<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 & 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>
|
||||
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
33
src/modules/Workspaces/WorkspacesEditor/App.config
Normal file
33
src/modules/Workspaces/WorkspacesEditor/App.config
Normal 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>
|
||||
57
src/modules/Workspaces/WorkspacesEditor/App.xaml
Normal file
57
src/modules/Workspaces/WorkspacesEditor/App.xaml
Normal 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>
|
||||
173
src/modules/Workspaces/WorkspacesEditor/App.xaml.cs
Normal file
173
src/modules/Workspaces/WorkspacesEditor/App.xaml.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
31
src/modules/Workspaces/WorkspacesEditor/HeadingTextBlock.cs
Normal file
31
src/modules/Workspaces/WorkspacesEditor/HeadingTextBlock.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
292
src/modules/Workspaces/WorkspacesEditor/MainPage.xaml
Normal file
292
src/modules/Workspaces/WorkspacesEditor/MainPage.xaml
Normal 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="" />
|
||||
<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="" />
|
||||
</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="" />
|
||||
<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="" />
|
||||
</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="" />
|
||||
<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="" />
|
||||
<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>
|
||||
76
src/modules/Workspaces/WorkspacesEditor/MainPage.xaml.cs
Normal file
76
src/modules/Workspaces/WorkspacesEditor/MainPage.xaml.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
21
src/modules/Workspaces/WorkspacesEditor/MainWindow.xaml
Normal file
21
src/modules/Workspaces/WorkspacesEditor/MainWindow.xaml
Normal 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>
|
||||
179
src/modules/Workspaces/WorkspacesEditor/MainWindow.xaml.cs
Normal file
179
src/modules/Workspaces/WorkspacesEditor/MainWindow.xaml.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
{
|
||||
@@ -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.
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user