Compare commits
2 Commits
user/muyua
...
copilot/fi
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
bdcf3f805d | ||
|
|
748f2192ee |
2
.github/actions/spell-check/expect.txt
vendored
@@ -135,7 +135,6 @@ BITMAPINFO
|
||||
BITMAPINFOHEADER
|
||||
BITSPERPEL
|
||||
BITSPIXEL
|
||||
Blackmagic
|
||||
bla
|
||||
BLENDFUNCTION
|
||||
blittable
|
||||
@@ -540,7 +539,6 @@ EXTRINSICPROPERTIES
|
||||
eyetracker
|
||||
FANCYZONESDRAWLAYOUTTEST
|
||||
FANCYZONESEDITOR
|
||||
Fairlight
|
||||
FARPROC
|
||||
fdw
|
||||
fdx
|
||||
|
||||
6
.github/copilot-instructions.md
vendored
@@ -30,12 +30,6 @@ These are auto-applied based on file location:
|
||||
- [Runner & Settings UI](.github/instructions/runner-settings-ui.instructions.md)
|
||||
- [Common Libraries](.github/instructions/common-libraries.instructions.md)
|
||||
|
||||
## Shortcut Guide V2 Manifests
|
||||
|
||||
When creating or editing Shortcut Guide keyboard shortcut manifest files, follow the schema and naming conventions in the spec:
|
||||
|
||||
- [WinGet Manifest Keyboard Shortcuts schema](<../doc/specs/WinGet Manifest Keyboard Shortcuts schema.md>) – manifest file format, field definitions, file naming, and the `+` prefix convention for apps without a WinGet package
|
||||
|
||||
## Detailed Documentation
|
||||
|
||||
- [Architecture](../doc/devdocs/core/architecture.md)
|
||||
|
||||
@@ -4,29 +4,6 @@
|
||||
<Import Project="$(MSBuildCachePackageRoot)\build\$(MSBuildCachePackageName).targets" Condition="'$(MSBuildCacheEnabled)' == 'true'" />
|
||||
<Import Project="$(MSBuildCacheSharedCompilationPackageRoot)\build\Microsoft.MSBuildCache.SharedCompilation.targets" Condition="'$(MSBuildCacheEnabled)' == 'true'" />
|
||||
|
||||
<!--
|
||||
Onboarding guard: PowerToys has deeply nested source paths that exceed the legacy
|
||||
260-character MAX_PATH limit. Without Windows long path support enabled, the build
|
||||
fails with cryptic "path too long" / "could not find file" errors that are hard for
|
||||
new contributors to diagnose. Detect the missing registry setting up front and emit a
|
||||
clear, actionable error before the confusing failures occur.
|
||||
|
||||
- Covers both Visual Studio (Ctrl+Shift+B) and the command-line build scripts.
|
||||
- Runs only during real builds (skips design-time/IntelliSense passes).
|
||||
- Bypass with /p:SkipLongPathsCheck=true if you know what you're doing.
|
||||
See tools\build\setup-dev-environment.ps1 to enable everything automatically.
|
||||
-->
|
||||
<Target Name="EnsureLongPathsEnabled"
|
||||
BeforeTargets="PrepareForBuild"
|
||||
Condition="'$(DesignTimeBuild)' != 'true' and '$(SkipLongPathsCheck)' != 'true' and '$(OS)' == 'Windows_NT'">
|
||||
<PropertyGroup>
|
||||
<_LongPathsEnabled>$([MSBuild]::GetRegistryValueFromView('HKEY_LOCAL_MACHINE\SYSTEM\CurrentControlSet\Control\FileSystem', 'LongPathsEnabled', null, RegistryView.Registry64))</_LongPathsEnabled>
|
||||
</PropertyGroup>
|
||||
<Error Condition="'$(_LongPathsEnabled)' != '1'"
|
||||
Code="PTLONGPATH"
|
||||
Text="Windows long path support is not enabled. PowerToys source paths exceed the 260-character MAX_PATH limit, so the build will fail with cryptic 'path too long' errors. Fix it by running (from an elevated PowerShell): .\tools\build\setup-dev-environment.ps1 -- or set HKLM\SYSTEM\CurrentControlSet\Control\FileSystem\LongPathsEnabled = 1 (DWORD) and restart Windows. To bypass this check, build with /p:SkipLongPathsCheck=true." />
|
||||
</Target>
|
||||
|
||||
<!-- Override ManifestTool to the x64 host tool under WindowsSdkDir for all projects once the SDK path is known. -->
|
||||
<PropertyGroup Label="ManifestToolOverride">
|
||||
<ManifestTool Condition="Exists('$(WindowsSdkDir)bin\x64\mt.exe')">$(WindowsSdkDir)bin\x64\mt.exe</ManifestTool>
|
||||
|
||||
@@ -1,285 +0,0 @@
|
||||
# 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.
|
||||
@@ -232,10 +232,6 @@ namespace winrt::PowerToys::GPOWrapper::implementation
|
||||
{
|
||||
return static_cast<GpoRuleConfigured>(powertoys_gpo::getAllowedAdvancedPasteFoundryLocalValue());
|
||||
}
|
||||
GpoRuleConfigured GPOWrapper::GetAllowedAdvancedPastePythonScriptsValue()
|
||||
{
|
||||
return static_cast<GpoRuleConfigured>(powertoys_gpo::getAllowedAdvancedPastePythonScriptsValue());
|
||||
}
|
||||
GpoRuleConfigured GPOWrapper::GetConfiguredNewPlusEnabledValue()
|
||||
{
|
||||
return static_cast<GpoRuleConfigured>(powertoys_gpo::getConfiguredNewPlusEnabledValue());
|
||||
|
||||
@@ -64,7 +64,6 @@ namespace winrt::PowerToys::GPOWrapper::implementation
|
||||
static GpoRuleConfigured GetAllowedAdvancedPasteGoogleValue();
|
||||
static GpoRuleConfigured GetAllowedAdvancedPasteOllamaValue();
|
||||
static GpoRuleConfigured GetAllowedAdvancedPasteFoundryLocalValue();
|
||||
static GpoRuleConfigured GetAllowedAdvancedPastePythonScriptsValue();
|
||||
static GpoRuleConfigured GetConfiguredNewPlusEnabledValue();
|
||||
static GpoRuleConfigured GetConfiguredWorkspacesEnabledValue();
|
||||
static GpoRuleConfigured GetConfiguredMwbClipboardSharingEnabledValue();
|
||||
|
||||
@@ -68,7 +68,6 @@ namespace PowerToys
|
||||
static GpoRuleConfigured GetAllowedAdvancedPasteGoogleValue();
|
||||
static GpoRuleConfigured GetAllowedAdvancedPasteOllamaValue();
|
||||
static GpoRuleConfigured GetAllowedAdvancedPasteFoundryLocalValue();
|
||||
static GpoRuleConfigured GetAllowedAdvancedPastePythonScriptsValue();
|
||||
static GpoRuleConfigured GetConfiguredNewPlusEnabledValue();
|
||||
static GpoRuleConfigured GetConfiguredWorkspacesEnabledValue();
|
||||
static GpoRuleConfigured GetConfiguredMwbClipboardSharingEnabledValue();
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
#include "pch.h"
|
||||
#include "TestHelpers.h"
|
||||
#include <algorithm>
|
||||
#include <interop/excluded_app.h>
|
||||
#include <excluded_apps.h>
|
||||
|
||||
using namespace Microsoft::VisualStudio::CppUnitTestFramework;
|
||||
@@ -75,6 +77,45 @@ namespace UnitTestsCommonUtils
|
||||
Assert::IsTrue(find_app_name_in_path(path, apps));
|
||||
}
|
||||
|
||||
TEST_METHOD(ShortcutGuideBuiltInExcludedProcessNames_ContainsAtlantisExecutables)
|
||||
{
|
||||
const auto containsProcessName = [](std::wstring_view processName) {
|
||||
return std::find(
|
||||
shortcut_guide::interop::built_in_excluded_process_names.begin(),
|
||||
shortcut_guide::interop::built_in_excluded_process_names.end(),
|
||||
processName) != shortcut_guide::interop::built_in_excluded_process_names.end();
|
||||
};
|
||||
|
||||
Assert::IsTrue(containsProcessName(L"ATLANTIS.EXE"));
|
||||
Assert::IsTrue(containsProcessName(L"AWP.EXE"));
|
||||
}
|
||||
|
||||
TEST_METHOD(FindAppNameInPath_AtlantisExecutableName_ReturnsTrue)
|
||||
{
|
||||
std::wstring path = L"C:\\PROGRAM FILES\\ATLANTIS\\ATLANTIS.EXE";
|
||||
std::vector<std::wstring> apps = { L"ATLANTIS.EXE" };
|
||||
Assert::IsTrue(find_app_name_in_path(path, apps));
|
||||
}
|
||||
|
||||
TEST_METHOD(FindAppNameInPath_AtlantisAlternateExecutableName_ReturnsTrue)
|
||||
{
|
||||
std::wstring path = L"C:\\PROGRAM FILES\\ATLANTIS\\AWP.EXE";
|
||||
std::vector<std::wstring> apps = { L"AWP.EXE" };
|
||||
Assert::IsTrue(find_app_name_in_path(path, apps));
|
||||
}
|
||||
|
||||
TEST_METHOD(ShortcutGuideBuiltInExcludedWindowTitles_ContainsAtlantisWordProcessor)
|
||||
{
|
||||
const auto containsWindowTitle = [](std::wstring_view windowTitle) {
|
||||
return std::find(
|
||||
shortcut_guide::interop::built_in_excluded_window_titles.begin(),
|
||||
shortcut_guide::interop::built_in_excluded_window_titles.end(),
|
||||
windowTitle) != shortcut_guide::interop::built_in_excluded_window_titles.end();
|
||||
};
|
||||
|
||||
Assert::IsTrue(containsWindowTitle(L"ATLANTIS WORD PROCESSOR"));
|
||||
}
|
||||
|
||||
TEST_METHOD(FindAppNameInPath_UNCPath_Works)
|
||||
{
|
||||
std::wstring path = L"\\\\server\\share\\folder\\app.exe";
|
||||
|
||||
@@ -1,7 +1,48 @@
|
||||
#include "pch.h"
|
||||
#include "excluded_app.h"
|
||||
#include <common/utils/excluded_apps.h>
|
||||
#include <../utils/string_utils.h>
|
||||
|
||||
namespace
|
||||
{
|
||||
// This shared interop file exposes Shortcut Guide's exclusion check.
|
||||
// Atlantis Word Processor can hard-lock input when Shortcut Guide is invoked over it.
|
||||
constexpr int maxTitleLength = 255;
|
||||
|
||||
const std::vector<std::wstring> builtInExcludedProcessNames(
|
||||
shortcut_guide::interop::built_in_excluded_process_names.begin(),
|
||||
shortcut_guide::interop::built_in_excluded_process_names.end());
|
||||
|
||||
bool isShortcutGuideBuiltInProcessExcluded(const std::wstring& processPath)
|
||||
{
|
||||
return find_app_name_in_path(processPath, builtInExcludedProcessNames);
|
||||
}
|
||||
|
||||
bool isShortcutGuideBuiltInWindowTitleExcluded(HWND hwnd)
|
||||
{
|
||||
WCHAR title[maxTitleLength + 1]{};
|
||||
int len = GetWindowTextW(hwnd, title, maxTitleLength + 1);
|
||||
if (len <= 0)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
title[len] = L'\0';
|
||||
CharUpperBuffW(title, static_cast<DWORD>(len));
|
||||
std::wstring titleUpper(title, len);
|
||||
|
||||
for (const auto& excludedTitle : shortcut_guide::interop::built_in_excluded_window_titles)
|
||||
{
|
||||
if (titleUpper.contains(excludedTitle))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
extern "C"
|
||||
{
|
||||
__declspec(dllexport) bool IsCurrentWindowExcludedFromShortcutGuide()
|
||||
@@ -14,27 +55,32 @@ extern "C"
|
||||
std::wstring_view view(excludedUppercase);
|
||||
view = left_trim<wchar_t>(trim<wchar_t>(view));
|
||||
|
||||
m_excludedApps.clear();
|
||||
std::vector<std::wstring> excludedApps;
|
||||
|
||||
while (!view.empty())
|
||||
{
|
||||
auto pos = (std::min)(view.find_first_of(L"\r\n"), view.length());
|
||||
m_excludedApps.emplace_back(view.substr(0, pos));
|
||||
excludedApps.emplace_back(view.substr(0, pos));
|
||||
view.remove_prefix(pos);
|
||||
view = left_trim<wchar_t>(trim<wchar_t>(view));
|
||||
}
|
||||
|
||||
if (m_excludedApps.empty())
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (HWND foregroundApp{ GetForegroundWindow() })
|
||||
{
|
||||
auto processPath = get_process_path(foregroundApp);
|
||||
CharUpperBuffW(processPath.data(), static_cast<DWORD>(processPath.length()));
|
||||
|
||||
return check_excluded_app(foregroundApp, processPath, m_excludedApps);
|
||||
if (check_excluded_app(foregroundApp, processPath, excludedApps))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
if (isShortcutGuideBuiltInProcessExcluded(processPath))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
return isShortcutGuideBuiltInWindowTitleExcluded(foregroundApp);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -1,4 +1,20 @@
|
||||
#pragma once
|
||||
#include <array>
|
||||
#include <string>
|
||||
#include <string_view>
|
||||
#include <vector>
|
||||
|
||||
namespace shortcut_guide::interop
|
||||
{
|
||||
inline constexpr std::array<std::wstring_view, 2> built_in_excluded_process_names = {
|
||||
L"AWP.EXE",
|
||||
L"ATLANTIS.EXE",
|
||||
};
|
||||
|
||||
inline constexpr std::array<std::wstring_view, 1> built_in_excluded_window_titles = {
|
||||
L"ATLANTIS WORD PROCESSOR",
|
||||
};
|
||||
}
|
||||
|
||||
extern "C"
|
||||
{
|
||||
|
||||
@@ -93,7 +93,6 @@ namespace powertoys_gpo
|
||||
const std::wstring POLICY_ALLOW_ADVANCED_PASTE_GOOGLE = L"AllowAdvancedPasteGoogle";
|
||||
const std::wstring POLICY_ALLOW_ADVANCED_PASTE_OLLAMA = L"AllowAdvancedPasteOllama";
|
||||
const std::wstring POLICY_ALLOW_ADVANCED_PASTE_FOUNDRY_LOCAL = L"AllowAdvancedPasteFoundryLocal";
|
||||
const std::wstring POLICY_ALLOW_ADVANCED_PASTE_PYTHON_SCRIPTS = L"AllowAdvancedPastePythonScripts";
|
||||
const std::wstring POLICY_MWB_CLIPBOARD_SHARING_ENABLED = L"MwbClipboardSharingEnabled";
|
||||
const std::wstring POLICY_MWB_FILE_TRANSFER_ENABLED = L"MwbFileTransferEnabled";
|
||||
const std::wstring POLICY_MWB_USE_ORIGINAL_USER_INTERFACE = L"MwbUseOriginalUserInterface";
|
||||
@@ -638,11 +637,6 @@ namespace powertoys_gpo
|
||||
return getConfiguredValue(POLICY_ALLOW_ADVANCED_PASTE_FOUNDRY_LOCAL);
|
||||
}
|
||||
|
||||
inline gpo_rule_configured_t getAllowedAdvancedPastePythonScriptsValue()
|
||||
{
|
||||
return getConfiguredValue(POLICY_ALLOW_ADVANCED_PASTE_PYTHON_SCRIPTS);
|
||||
}
|
||||
|
||||
inline gpo_rule_configured_t getConfiguredMwbClipboardSharingEnabledValue()
|
||||
{
|
||||
return getConfiguredValue(POLICY_MWB_CLIPBOARD_SHARING_ENABLED);
|
||||
|
||||
@@ -718,16 +718,6 @@
|
||||
<decimal value="0" />
|
||||
</disabledValue>
|
||||
</policy>
|
||||
<policy name="AllowAdvancedPastePythonScripts" class="Both" displayName="$(string.AllowAdvancedPastePythonScripts)" explainText="$(string.AllowAdvancedPastePythonScriptsDescription)" key="Software\Policies\PowerToys" valueName="AllowAdvancedPastePythonScripts">
|
||||
<parentCategory ref="AdvancedPaste" />
|
||||
<supportedOn ref="SUPPORTED_POWERTOYS_0_99_0" />
|
||||
<enabledValue>
|
||||
<decimal value="1" />
|
||||
</enabledValue>
|
||||
<disabledValue>
|
||||
<decimal value="0" />
|
||||
</disabledValue>
|
||||
</policy>
|
||||
<policy name="MwbClipboardSharingEnabled" class="Both" displayName="$(string.MwbClipboardSharingEnabled)" explainText="$(string.MwbClipboardSharingEnabledDescription)" key="Software\Policies\PowerToys" valueName="MwbClipboardSharingEnabled">
|
||||
<parentCategory ref="MouseWithoutBorders" />
|
||||
<supportedOn ref="SUPPORTED_POWERTOYS_0_83_0" />
|
||||
|
||||
@@ -345,12 +345,6 @@ If you disable this policy, users will not be able to select or use Ollama endpo
|
||||
If you enable or don't configure this policy, users can configure and use Foundry Local as their AI provider.
|
||||
|
||||
If you disable this policy, users will not be able to select or use Foundry Local endpoint in Advanced Paste settings.</string>
|
||||
<string id="AllowAdvancedPastePythonScripts">Advanced Paste: Allow Python scripts</string>
|
||||
<string id="AllowAdvancedPastePythonScriptsDescription">This policy controls whether users can enable and execute Python scripts in Advanced Paste.
|
||||
|
||||
If you enable or don't configure this policy, users can enable the Python scripts feature (Disabled/Windows/WSL modes) and run custom Python scripts for clipboard transformations.
|
||||
|
||||
If you disable this policy, the Python scripts mode selector will be forced to Disabled and users will not be able to enable or run Python scripts through Advanced Paste.</string>
|
||||
<string id="MwbClipboardSharingEnabled">Clipboard sharing enabled</string>
|
||||
<string id="MwbFileTransferEnabled">File transfer enabled</string>
|
||||
<string id="MwbUseOriginalUserInterface">Original user interface is available</string>
|
||||
|
||||
@@ -47,6 +47,8 @@ internal sealed class IntegrationTestUserSettings : IUserSettings
|
||||
|
||||
public bool ShowCustomPreview => false;
|
||||
|
||||
public bool ShowAIPaste => true;
|
||||
|
||||
public bool CloseAfterLosingFocus => false;
|
||||
|
||||
public bool EnableClipboardPreview => true;
|
||||
@@ -57,22 +59,6 @@ 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)
|
||||
@@ -81,8 +67,4 @@ internal sealed class IntegrationTestUserSettings : IUserSettings
|
||||
Changed?.Invoke(this, EventArgs.Empty);
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public void StoreTrustedScriptHash(string scriptPath, string hash)
|
||||
{
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,560 +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.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,9 +153,5 @@
|
||||
<Content Include="Assets\AdvancedPaste\SemanticKernel.svg">
|
||||
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
|
||||
</Content>
|
||||
|
||||
<Content Include="Services\PythonScripts\_runner.py">
|
||||
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
|
||||
</Content>
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
|
||||
@@ -14,7 +14,6 @@ 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;
|
||||
@@ -84,8 +83,6 @@ 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=OneWay}"
|
||||
Text="{x:Bind Header, Mode=OneTime}"
|
||||
TextWrapping="NoWrap" />
|
||||
<TextBlock
|
||||
Foreground="{ThemeResource TextFillColorSecondaryBrush}"
|
||||
Style="{StaticResource CaptionTextBlockStyle}"
|
||||
Text="{x:Bind Timestamp, Converter={StaticResource DateTimeToFriendlyStringConverter}, Mode=OneWay}" />
|
||||
Text="{x:Bind Timestamp, Converter={StaticResource DateTimeToFriendlyStringConverter}, Mode=OneTime}" />
|
||||
</StackPanel>
|
||||
</Grid>
|
||||
</UserControl>
|
||||
|
||||
@@ -755,7 +755,63 @@
|
||||
<animations:OpacityAnimation To="0.0" Duration="0:0:0.167" />
|
||||
</animations:Implicit.HideAnimations>
|
||||
</TextBlock>
|
||||
<!-- Error message grid moved to MainPage.xaml so it remains enabled when PromptBox is disabled -->
|
||||
<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>
|
||||
<VisualStateManager.VisualStateGroups>
|
||||
<VisualStateGroup x:Name="CommonStates">
|
||||
<VisualState x:Name="DefaultState" />
|
||||
@@ -776,6 +832,7 @@
|
||||
<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,8 +43,7 @@ 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.PythonScriptPasteFormats.Count);
|
||||
new PasteFormatsToHeightConverter() { MaxItems = maxCustomActionCount }.GetHeight(_optionsViewModel.IsCustomAIServiceEnabled ? _userSettings.CustomActions.Count : 0);
|
||||
|
||||
MinHeight = GetHeight(1);
|
||||
Height = GetHeight(5);
|
||||
@@ -60,7 +59,6 @@ namespace AdvancedPaste
|
||||
UpdateHeight();
|
||||
}
|
||||
};
|
||||
_optionsViewModel.PythonScriptPasteFormats.CollectionChanged += (_, _) => UpdateHeight();
|
||||
|
||||
AppWindow.SetIcon("Assets/AdvancedPaste/AdvancedPaste.ico");
|
||||
this.ExtendsContentIntoTitleBar = true;
|
||||
@@ -143,7 +141,11 @@ namespace AdvancedPaste
|
||||
internal void FinishLoading(bool success)
|
||||
{
|
||||
MainPage.CustomFormatTextBox.IsLoading(false);
|
||||
VisualStateManager.GoToState(MainPage.CustomFormatTextBox, "DefaultState", true);
|
||||
|
||||
if (success)
|
||||
{
|
||||
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=OneWay}"
|
||||
AutomationProperties.AcceleratorKey="{x:Bind ShortcutText, Mode=OneTime}"
|
||||
AutomationProperties.AutomationControlType="ListItem"
|
||||
AutomationProperties.FullDescription="{x:Bind ToolTip, Mode=OneWay}"
|
||||
AutomationProperties.HelpText="{x:Bind Name, Mode=OneWay}"
|
||||
AutomationProperties.Name="{x:Bind AccessibleName, Mode=OneWay}">
|
||||
AutomationProperties.FullDescription="{x:Bind ToolTip, Mode=OneTime}"
|
||||
AutomationProperties.HelpText="{x:Bind Name, Mode=OneTime}"
|
||||
AutomationProperties.Name="{x:Bind AccessibleName, Mode=OneTime}">
|
||||
<Grid.ColumnDefinitions>
|
||||
<ColumnDefinition Width="48" />
|
||||
<ColumnDefinition Width="*" />
|
||||
<ColumnDefinition Width="Auto" />
|
||||
</Grid.ColumnDefinitions>
|
||||
<ToolTipService.ToolTip>
|
||||
<TextBlock Text="{x:Bind ToolTip, Mode=OneWay}" />
|
||||
<TextBlock Text="{x:Bind ToolTip, Mode=OneTime}" />
|
||||
</ToolTipService.ToolTip>
|
||||
<FontIcon
|
||||
Margin="0,0,0,0"
|
||||
VerticalAlignment="Center"
|
||||
AutomationProperties.AccessibilityView="Raw"
|
||||
FontSize="16"
|
||||
Glyph="{x:Bind IconGlyph, Mode=OneWay}" />
|
||||
Glyph="{x:Bind IconGlyph, Mode=OneTime}" />
|
||||
<TextBlock
|
||||
Grid.Column="1"
|
||||
VerticalAlignment="Center"
|
||||
x:Phase="1"
|
||||
Style="{StaticResource CaptionTextBlockStyle}"
|
||||
Text="{x:Bind Name, Mode=OneWay}" />
|
||||
Text="{x:Bind Name, Mode=OneTime}" />
|
||||
<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=OneWay}" />
|
||||
Text="{x:Bind ShortcutText, Mode=OneTime}" />
|
||||
</Grid>
|
||||
</DataTemplate>
|
||||
</controls:PasteFormatTemplateSelector.ItemTemplate>
|
||||
@@ -83,13 +83,13 @@
|
||||
Margin="0,0,0,0"
|
||||
VerticalAlignment="Center"
|
||||
FontSize="16"
|
||||
Glyph="{x:Bind IconGlyph, Mode=OneWay}" />
|
||||
Glyph="{x:Bind IconGlyph, Mode=OneTime}" />
|
||||
<TextBlock
|
||||
Grid.Column="1"
|
||||
VerticalAlignment="Center"
|
||||
x:Phase="1"
|
||||
Style="{StaticResource CaptionTextBlockStyle}"
|
||||
Text="{x:Bind Name, Mode=OneWay}" />
|
||||
Text="{x:Bind Name, Mode=OneTime}" />
|
||||
</Grid>
|
||||
</DataTemplate>
|
||||
</controls:PasteFormatTemplateSelector.ItemTemplateDisabled>
|
||||
@@ -144,7 +144,6 @@
|
||||
</Page.KeyboardAccelerators>
|
||||
<Grid>
|
||||
<Grid.RowDefinitions>
|
||||
<RowDefinition Height="Auto" />
|
||||
<RowDefinition Height="Auto" />
|
||||
<RowDefinition Height="Auto" />
|
||||
<RowDefinition Height="*" />
|
||||
@@ -199,7 +198,7 @@
|
||||
<ItemsView.ItemTemplate>
|
||||
<DataTemplate x:DataType="local:ClipboardItem">
|
||||
<ItemContainer
|
||||
AutomationProperties.Name="{x:Bind Description, Mode=OneWay}"
|
||||
AutomationProperties.Name="{x:Bind Description, Mode=OneTime}"
|
||||
CornerRadius="16"
|
||||
ToolTipService.ToolTip="{x:Bind Content}">
|
||||
<Grid
|
||||
@@ -251,10 +250,11 @@
|
||||
Grid.Row="1"
|
||||
Margin="20,0,20,0"
|
||||
x:FieldModifier="public"
|
||||
IsEnabled="True"
|
||||
TabIndex="0">
|
||||
IsEnabled="{x:Bind ViewModel.IsCustomAIServiceEnabled, Mode=OneWay}"
|
||||
TabIndex="0"
|
||||
Visibility="{x:Bind ViewModel.ShowAIPasteSection, Converter={StaticResource BoolToVisibilityConverter}, Mode=OneWay}">
|
||||
<controls:PromptBox.Footer>
|
||||
<StackPanel Orientation="Horizontal" Visibility="{x:Bind ViewModel.IsCustomAIServiceEnabled, Mode=OneWay, Converter={StaticResource BoolToVisibilityConverter}}">
|
||||
<StackPanel Orientation="Horizontal">
|
||||
<TextBlock
|
||||
x:Uid="AIMistakeNote"
|
||||
Margin="0,0,2,0"
|
||||
@@ -300,70 +300,19 @@
|
||||
</StackPanel>
|
||||
</controls:PromptBox.Footer>
|
||||
</controls:PromptBox>
|
||||
<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">
|
||||
<ScrollViewer Grid.Row="2">
|
||||
<Grid RowSpacing="4">
|
||||
<Grid.RowDefinitions>
|
||||
<RowDefinition Height="{x:Bind ViewModel.StandardPasteFormats.Count, Mode=OneWay, Converter={StaticResource standardPasteFormatsToHeightConverter}}" />
|
||||
<RowDefinition Height="Auto" />
|
||||
<RowDefinition Height="Auto" />
|
||||
<RowDefinition Height="Auto" />
|
||||
<RowDefinition Height="Auto" />
|
||||
<RowDefinition Height="Auto" />
|
||||
<RowDefinition Height="*" MinHeight="{x:Bind ViewModel.CustomActionPasteFormats.Count, Mode=OneWay, Converter={StaticResource customActionsToMinHeightConverter}}" />
|
||||
<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}"
|
||||
@@ -393,27 +342,6 @@
|
||||
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,43 +208,5 @@ 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,6 +17,8 @@ namespace AdvancedPaste.Settings
|
||||
|
||||
public bool ShowCustomPreview { get; }
|
||||
|
||||
public bool ShowAIPaste { get; }
|
||||
|
||||
public bool CloseAfterLosingFocus { get; }
|
||||
|
||||
public bool EnableClipboardPreview { get; }
|
||||
@@ -27,26 +29,8 @@ 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,7 +4,6 @@
|
||||
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.IO.Abstractions;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
@@ -26,10 +25,6 @@ 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;
|
||||
@@ -43,6 +38,8 @@ 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; }
|
||||
@@ -53,39 +50,18 @@ 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();
|
||||
@@ -93,14 +69,6 @@ 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)
|
||||
@@ -144,6 +112,7 @@ namespace AdvancedPaste.Settings
|
||||
|
||||
IsAIEnabled = properties.IsAIEnabled;
|
||||
ShowCustomPreview = properties.ShowCustomPreview;
|
||||
ShowAIPaste = properties.ShowAIPaste;
|
||||
CloseAfterLosingFocus = properties.CloseAfterLosingFocus;
|
||||
EnableClipboardPreview = properties.EnableClipboardPreview;
|
||||
PasteAIConfiguration = properties.PasteAIConfiguration ?? new PasteAIConfiguration();
|
||||
@@ -166,49 +135,6 @@ 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";
|
||||
|
||||
// Enforce GPO: if Python scripts are disallowed by policy, force disabled.
|
||||
if (PowerToys.GPOWrapper.GPOWrapper.GetAllowedAdvancedPastePythonScriptsValue() == PowerToys.GPOWrapper.GpoRuleConfigured.Disabled)
|
||||
{
|
||||
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);
|
||||
}
|
||||
|
||||
@@ -373,103 +299,6 @@ 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))
|
||||
@@ -562,8 +391,6 @@ namespace AdvancedPaste.Settings
|
||||
if (disposing)
|
||||
{
|
||||
_cancellationTokenSource?.Dispose();
|
||||
_scriptFolderDebounce?.Dispose();
|
||||
_scriptFolderWatcher?.Dispose();
|
||||
_watcher?.Dispose();
|
||||
}
|
||||
|
||||
|
||||
@@ -40,14 +40,6 @@ 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,12 +122,4 @@ 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,23 +9,15 @@ 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,
|
||||
IPythonScriptService pythonScriptService,
|
||||
IPythonScriptTrustService pythonScriptTrustService) : IPasteFormatExecutor
|
||||
public sealed class PasteFormatExecutor(IKernelService kernelService, ICustomActionTransformService customActionTransformService) : 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)
|
||||
{
|
||||
@@ -40,15 +32,6 @@ public sealed class PasteFormatExecutor(
|
||||
|
||||
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
|
||||
@@ -59,111 +42,6 @@ public sealed class PasteFormatExecutor(
|
||||
});
|
||||
}
|
||||
|
||||
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)
|
||||
|
||||
@@ -1,71 +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.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);
|
||||
}
|
||||
@@ -1,37 +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.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);
|
||||
}
|
||||
@@ -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 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);
|
||||
@@ -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 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);
|
||||
@@ -1,127 +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.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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,255 +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.
|
||||
|
||||
"""
|
||||
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>Search or describe what format you want...</value>
|
||||
<value>Describe what format you want..</value>
|
||||
</data>
|
||||
<data name="InputTxtBoxTooltip.Text" xml:space="preserve">
|
||||
<value>Search or describe what format you want...</value>
|
||||
<value>Describe what format you want..</value>
|
||||
</data>
|
||||
<data name="LearnMoreLink.Text" xml:space="preserve">
|
||||
<value>Privacy</value>
|
||||
@@ -372,75 +372,4 @@
|
||||
<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,7 +16,6 @@ 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;
|
||||
@@ -42,7 +41,6 @@ namespace AdvancedPaste.ViewModels
|
||||
private readonly IUserSettings _userSettings;
|
||||
private readonly IPasteFormatExecutor _pasteFormatExecutor;
|
||||
private readonly IAICredentialsProvider _credentialsProvider;
|
||||
private readonly IPythonScriptService _pythonScriptService;
|
||||
|
||||
private CancellationTokenSource _pasteActionCancellationTokenSource;
|
||||
|
||||
@@ -102,8 +100,6 @@ namespace AdvancedPaste.ViewModels
|
||||
|
||||
public ObservableCollection<PasteFormat> CustomActionPasteFormats { get; } = [];
|
||||
|
||||
public ObservableCollection<PasteFormat> PythonScriptPasteFormats { get; } = [];
|
||||
|
||||
public bool IsCustomAIServiceEnabled
|
||||
{
|
||||
get
|
||||
@@ -238,6 +234,8 @@ namespace AdvancedPaste.ViewModels
|
||||
|
||||
public bool ShowClipboardHistoryButton => ClipboardHistoryEnabled;
|
||||
|
||||
public bool ShowAIPasteSection => _userSettings.ShowAIPaste && IsAllowedByGPO;
|
||||
|
||||
public bool HasIndeterminateTransformProgress => double.IsNaN(TransformProgress);
|
||||
|
||||
private PasteFormats CustomAIFormat =>
|
||||
@@ -262,12 +260,11 @@ namespace AdvancedPaste.ViewModels
|
||||
|
||||
public event EventHandler PreviewRequested;
|
||||
|
||||
public OptionsViewModel(IFileSystem fileSystem, IAICredentialsProvider credentialsProvider, IUserSettings userSettings, IPasteFormatExecutor pasteFormatExecutor, IPythonScriptService pythonScriptService)
|
||||
public OptionsViewModel(IFileSystem fileSystem, IAICredentialsProvider credentialsProvider, IUserSettings userSettings, IPasteFormatExecutor pasteFormatExecutor)
|
||||
{
|
||||
_credentialsProvider = credentialsProvider;
|
||||
_userSettings = userSettings;
|
||||
_pasteFormatExecutor = pasteFormatExecutor;
|
||||
_pythonScriptService = pythonScriptService;
|
||||
|
||||
GeneratedResponses = [];
|
||||
GeneratedResponses.CollectionChanged += (s, e) =>
|
||||
@@ -325,6 +322,7 @@ namespace AdvancedPaste.ViewModels
|
||||
OnPropertyChanged(nameof(AIProviders));
|
||||
OnPropertyChanged(nameof(AllowedAIProviders));
|
||||
OnPropertyChanged(nameof(ShowClipboardPreview));
|
||||
OnPropertyChanged(nameof(ShowAIPasteSection));
|
||||
|
||||
NotifyActiveProviderChanged();
|
||||
|
||||
@@ -418,51 +416,12 @@ namespace AdvancedPaste.ViewModels
|
||||
}
|
||||
|
||||
UpdateFormats(StandardPasteFormats, Enum.GetValues<PasteFormats>()
|
||||
.Where(format => format != PasteFormats.PythonScript &&
|
||||
(PasteFormat.MetadataDict[format].IsCoreAction || _userSettings.AdditionalActions.Contains(format)))
|
||||
.Where(format => 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()
|
||||
@@ -736,10 +695,7 @@ namespace AdvancedPaste.ViewModels
|
||||
_pasteActionCancellationTokenSource = new();
|
||||
TransformProgress = double.NaN;
|
||||
PasteActionError = PasteActionError.None;
|
||||
|
||||
// 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;
|
||||
Query = pasteFormat.Query;
|
||||
|
||||
try
|
||||
{
|
||||
@@ -779,7 +735,7 @@ namespace AdvancedPaste.ViewModels
|
||||
|
||||
internal async Task ExecutePasteFormatAsync(VirtualKey key)
|
||||
{
|
||||
var pasteFormat = StandardPasteFormats.Concat(CustomActionPasteFormats).Concat(PythonScriptPasteFormats)
|
||||
var pasteFormat = StandardPasteFormats.Concat(CustomActionPasteFormats)
|
||||
.Where(pasteFormat => pasteFormat.IsEnabled)
|
||||
.ElementAtOrDefault(key - VirtualKey.Number1);
|
||||
|
||||
|
||||
@@ -496,23 +496,119 @@ 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;
|
||||
return SendMessageTimeout(target,
|
||||
WM_COPY,
|
||||
0,
|
||||
0,
|
||||
SMTO_ABORTIFHUNG | SMTO_BLOCK,
|
||||
50,
|
||||
&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;
|
||||
}
|
||||
|
||||
bool send_copy_selection()
|
||||
@@ -526,99 +622,30 @@ private:
|
||||
for (int attempt = 0; attempt < copy_attempts; ++attempt)
|
||||
{
|
||||
const auto initial_sequence = GetClipboardSequenceNumber();
|
||||
copy_succeeded = try_send_copy_message();
|
||||
|
||||
if (!copy_succeeded)
|
||||
// Strategy 1: Try WM_COPY message (works for standard Win32 controls)
|
||||
bool wm_copy_sent = try_send_copy_message();
|
||||
|
||||
if (wm_copy_sent)
|
||||
{
|
||||
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
|
||||
if (poll_clipboard_sequence(initial_sequence, clipboard_poll_attempts, clipboard_poll_delay))
|
||||
{
|
||||
copy_succeeded = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (copy_succeeded)
|
||||
// Strategy 2: If WM_COPY didn't work, try SendInput Ctrl+C (works for Electron, browsers, etc.)
|
||||
if (!copy_succeeded)
|
||||
{
|
||||
bool sequence_changed = false;
|
||||
for (int poll_attempt = 0; poll_attempt < clipboard_poll_attempts; ++poll_attempt)
|
||||
const auto sequence_before_ctrl_c = GetClipboardSequenceNumber();
|
||||
|
||||
if (send_ctrl_c_input())
|
||||
{
|
||||
if (GetClipboardSequenceNumber() != initial_sequence)
|
||||
if (poll_clipboard_sequence(sequence_before_ctrl_c, clipboard_poll_attempts, clipboard_poll_delay))
|
||||
{
|
||||
sequence_changed = true;
|
||||
break;
|
||||
copy_succeeded = true;
|
||||
}
|
||||
|
||||
std::this_thread::sleep_for(clipboard_poll_delay);
|
||||
}
|
||||
|
||||
copy_succeeded = sequence_changed;
|
||||
}
|
||||
|
||||
if (copy_succeeded)
|
||||
@@ -632,6 +659,11 @@ 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;
|
||||
}
|
||||
|
||||
@@ -1004,6 +1036,7 @@ 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},"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},"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"}
|
||||
|
Before Width: | Height: | Size: 46 KiB After Width: | Height: | Size: 1.4 KiB |
|
Before Width: | Height: | Size: 5.3 KiB After Width: | Height: | Size: 433 B |
|
Before Width: | Height: | Size: 46 KiB After Width: | Height: | Size: 1.4 KiB |
|
Before Width: | Height: | Size: 5.3 KiB After Width: | Height: | Size: 433 B |
|
Before Width: | Height: | Size: 2.3 KiB After Width: | Height: | Size: 328 B |
@@ -373,13 +373,6 @@ static int g_overlayRenderedH = 0;
|
||||
// Always On Top (WindowCornerUtils::CornersRadius).
|
||||
static int CornerRadiusForWindow(HWND hwnd)
|
||||
{
|
||||
// Remote sessions draw square windows even on Win11, yet still report DWMWCP_DEFAULT. Match the
|
||||
// window: a remote session gets square (radius 0) so the overlay border doesn't round off the corner.
|
||||
if (GetSystemMetrics(SM_REMOTESESSION))
|
||||
{
|
||||
return 0;
|
||||
}
|
||||
|
||||
int pref = 0; // DWMWCP_DEFAULT
|
||||
if (DwmGetWindowAttribute(hwnd, DWMWA_WINDOW_CORNER_PREFERENCE, &pref, sizeof(pref)) != S_OK)
|
||||
{
|
||||
|
||||
@@ -1,821 +0,0 @@
|
||||
PackageName: BlackmagicDesign.DaVinciResolve
|
||||
Name: DaVinci Resolve
|
||||
WindowFilter: "Resolve.exe"
|
||||
BackgroundProcess: false
|
||||
Shortcuts:
|
||||
- SectionName: Popular shortcuts
|
||||
Properties:
|
||||
- Name: Edit
|
||||
Shortcut:
|
||||
- Win: false
|
||||
Ctrl: false
|
||||
Shift: false
|
||||
Alt: false
|
||||
Keys:
|
||||
- F5
|
||||
- Name: Color
|
||||
Shortcut:
|
||||
- Win: false
|
||||
Ctrl: false
|
||||
Shift: false
|
||||
Alt: false
|
||||
Keys:
|
||||
- F6
|
||||
- Name: Fairlight
|
||||
Shortcut:
|
||||
- Win: false
|
||||
Ctrl: false
|
||||
Shift: false
|
||||
Alt: false
|
||||
Keys:
|
||||
- F7
|
||||
- Name: Deliver
|
||||
Shortcut:
|
||||
- Win: false
|
||||
Ctrl: false
|
||||
Shift: false
|
||||
Alt: false
|
||||
Keys:
|
||||
- F8
|
||||
- Name: Play / Pause
|
||||
Shortcut:
|
||||
- Win: false
|
||||
Ctrl: false
|
||||
Shift: false
|
||||
Alt: false
|
||||
Keys:
|
||||
- Space
|
||||
- Name: Play Reverse
|
||||
Shortcut:
|
||||
- Win: false
|
||||
Ctrl: false
|
||||
Shift: false
|
||||
Alt: false
|
||||
Keys:
|
||||
- J
|
||||
- Name: Stop
|
||||
Shortcut:
|
||||
- Win: false
|
||||
Ctrl: false
|
||||
Shift: false
|
||||
Alt: false
|
||||
Keys:
|
||||
- K
|
||||
- Name: Play Forward
|
||||
Shortcut:
|
||||
- Win: false
|
||||
Ctrl: false
|
||||
Shift: false
|
||||
Alt: false
|
||||
Keys:
|
||||
- L
|
||||
- Name: Import Media
|
||||
Shortcut:
|
||||
- Win: false
|
||||
Ctrl: true
|
||||
Shift: false
|
||||
Alt: false
|
||||
Keys:
|
||||
- I
|
||||
- Name: Export / Deliver
|
||||
Shortcut:
|
||||
- Win: false
|
||||
Ctrl: true
|
||||
Shift: true
|
||||
Alt: false
|
||||
Keys:
|
||||
- E
|
||||
- Name: Save Project
|
||||
Shortcut:
|
||||
- Win: false
|
||||
Ctrl: true
|
||||
Shift: false
|
||||
Alt: false
|
||||
Keys:
|
||||
- S
|
||||
- Name: Cut Clip
|
||||
Shortcut:
|
||||
- Win: false
|
||||
Ctrl: true
|
||||
Shift: false
|
||||
Alt: false
|
||||
Keys:
|
||||
- B
|
||||
- Name: Blade Edit
|
||||
Shortcut:
|
||||
- Win: false
|
||||
Ctrl: true
|
||||
Shift: false
|
||||
Alt: false
|
||||
Keys:
|
||||
- Backslash
|
||||
- Name: Ripple Delete
|
||||
Shortcut:
|
||||
- Win: false
|
||||
Ctrl: false
|
||||
Shift: true
|
||||
Alt: false
|
||||
Keys:
|
||||
- Delete
|
||||
- Name: Undo
|
||||
Shortcut:
|
||||
- Win: false
|
||||
Ctrl: true
|
||||
Shift: false
|
||||
Alt: false
|
||||
Keys:
|
||||
- Z
|
||||
- Name: Redo
|
||||
Shortcut:
|
||||
- Win: false
|
||||
Ctrl: true
|
||||
Shift: true
|
||||
Alt: false
|
||||
Keys:
|
||||
- Z
|
||||
- Name: Mark In
|
||||
Shortcut:
|
||||
- Win: false
|
||||
Ctrl: false
|
||||
Shift: false
|
||||
Alt: false
|
||||
Keys:
|
||||
- I
|
||||
- Name: Mark Out
|
||||
Shortcut:
|
||||
- Win: false
|
||||
Ctrl: false
|
||||
Shift: false
|
||||
Alt: false
|
||||
Keys:
|
||||
- O
|
||||
- Name: Marker
|
||||
Shortcut:
|
||||
- Win: false
|
||||
Ctrl: false
|
||||
Shift: false
|
||||
Alt: false
|
||||
Keys:
|
||||
- M
|
||||
- Name: Select All
|
||||
Shortcut:
|
||||
- Win: false
|
||||
Ctrl: true
|
||||
Shift: false
|
||||
Alt: false
|
||||
Keys:
|
||||
- A
|
||||
- Name: Go to Beginning
|
||||
Shortcut:
|
||||
- Win: false
|
||||
Ctrl: false
|
||||
Shift: false
|
||||
Alt: false
|
||||
Keys:
|
||||
- Home
|
||||
- Name: Go to End
|
||||
Shortcut:
|
||||
- Win: false
|
||||
Ctrl: false
|
||||
Shift: false
|
||||
Alt: false
|
||||
Keys:
|
||||
- End
|
||||
- Name: Snapping
|
||||
Shortcut:
|
||||
- Win: false
|
||||
Ctrl: false
|
||||
Shift: false
|
||||
Alt: false
|
||||
Keys:
|
||||
- N
|
||||
- Name: Selection Mode
|
||||
Shortcut:
|
||||
- Win: false
|
||||
Ctrl: false
|
||||
Shift: false
|
||||
Alt: false
|
||||
Keys:
|
||||
- A
|
||||
- Name: Trim Mode
|
||||
Shortcut:
|
||||
- Win: false
|
||||
Ctrl: false
|
||||
Shift: false
|
||||
Alt: false
|
||||
Keys:
|
||||
- T
|
||||
- Name: Change Clip Speed
|
||||
Shortcut:
|
||||
- Win: false
|
||||
Ctrl: true
|
||||
Shift: false
|
||||
Alt: false
|
||||
Keys:
|
||||
- R
|
||||
- SectionName: Timeline navigation
|
||||
Properties:
|
||||
- Name: Go to Next Frame
|
||||
Shortcut:
|
||||
- Win: false
|
||||
Ctrl: false
|
||||
Shift: false
|
||||
Alt: false
|
||||
Keys:
|
||||
- Right
|
||||
- Name: Go to Previous Frame
|
||||
Shortcut:
|
||||
- Win: false
|
||||
Ctrl: false
|
||||
Shift: false
|
||||
Alt: false
|
||||
Keys:
|
||||
- Left
|
||||
- Name: Jump Forward 5 Frames
|
||||
Shortcut:
|
||||
- Win: false
|
||||
Ctrl: false
|
||||
Shift: true
|
||||
Alt: false
|
||||
Keys:
|
||||
- Right
|
||||
- Name: Jump Back 5 Frames
|
||||
Shortcut:
|
||||
- Win: false
|
||||
Ctrl: false
|
||||
Shift: true
|
||||
Alt: false
|
||||
Keys:
|
||||
- Left
|
||||
- Name: Go to Next Clip
|
||||
Shortcut:
|
||||
- Win: false
|
||||
Ctrl: false
|
||||
Shift: true
|
||||
Alt: false
|
||||
Keys:
|
||||
- Up
|
||||
- Name: Go to Previous Clip
|
||||
Shortcut:
|
||||
- Win: false
|
||||
Ctrl: false
|
||||
Shift: true
|
||||
Alt: false
|
||||
Keys:
|
||||
- Down
|
||||
- Name: Go to Next Track
|
||||
Shortcut:
|
||||
- Win: false
|
||||
Ctrl: false
|
||||
Shift: false
|
||||
Alt: false
|
||||
Keys:
|
||||
- Down
|
||||
- Name: Go to Previous Track
|
||||
Shortcut:
|
||||
- Win: false
|
||||
Ctrl: false
|
||||
Shift: false
|
||||
Alt: false
|
||||
Keys:
|
||||
- Up
|
||||
- Name: Zoom In Timeline
|
||||
Shortcut:
|
||||
- Win: false
|
||||
Ctrl: false
|
||||
Shift: false
|
||||
Alt: false
|
||||
Keys:
|
||||
- Equals
|
||||
- Name: Zoom Out Timeline
|
||||
Shortcut:
|
||||
- Win: false
|
||||
Ctrl: false
|
||||
Shift: false
|
||||
Alt: false
|
||||
Keys:
|
||||
- Minus
|
||||
- Name: Full Screen Playback
|
||||
Shortcut:
|
||||
- Win: false
|
||||
Ctrl: false
|
||||
Shift: true
|
||||
Alt: false
|
||||
Keys:
|
||||
- Space
|
||||
- Name: Go to Previous Edit Point
|
||||
Shortcut:
|
||||
- Win: false
|
||||
Ctrl: false
|
||||
Shift: false
|
||||
Alt: false
|
||||
Keys:
|
||||
- PageUp
|
||||
- Name: Go to Next Edit Point
|
||||
Shortcut:
|
||||
- Win: false
|
||||
Ctrl: false
|
||||
Shift: false
|
||||
Alt: false
|
||||
Keys:
|
||||
- PageDown
|
||||
- SectionName: Edit
|
||||
Properties:
|
||||
- Name: Delete
|
||||
Shortcut:
|
||||
- Win: false
|
||||
Ctrl: false
|
||||
Shift: false
|
||||
Alt: false
|
||||
Keys:
|
||||
- Delete
|
||||
- Name: Copy
|
||||
Shortcut:
|
||||
- Win: false
|
||||
Ctrl: true
|
||||
Shift: false
|
||||
Alt: false
|
||||
Keys:
|
||||
- C
|
||||
- Name: Paste
|
||||
Shortcut:
|
||||
- Win: false
|
||||
Ctrl: true
|
||||
Shift: false
|
||||
Alt: false
|
||||
Keys:
|
||||
- V
|
||||
- Name: Cut
|
||||
Shortcut:
|
||||
- Win: false
|
||||
Ctrl: true
|
||||
Shift: false
|
||||
Alt: false
|
||||
Keys:
|
||||
- X
|
||||
- Name: Duplicate Clip
|
||||
Shortcut:
|
||||
- Win: false
|
||||
Ctrl: true
|
||||
Shift: false
|
||||
Alt: false
|
||||
Keys:
|
||||
- D
|
||||
- Name: Render in Place
|
||||
Shortcut:
|
||||
- Win: false
|
||||
Ctrl: true
|
||||
Shift: true
|
||||
Alt: false
|
||||
Keys:
|
||||
- X
|
||||
- Name: Add Edit
|
||||
Shortcut:
|
||||
- Win: false
|
||||
Ctrl: true
|
||||
Shift: false
|
||||
Alt: false
|
||||
Keys:
|
||||
- Backslash
|
||||
- Name: Append to End of Timeline
|
||||
Shortcut:
|
||||
- Win: false
|
||||
Ctrl: false
|
||||
Shift: false
|
||||
Alt: false
|
||||
Keys:
|
||||
- End
|
||||
- Name: Replace Clip
|
||||
Shortcut:
|
||||
- Win: false
|
||||
Ctrl: false
|
||||
Shift: false
|
||||
Alt: false
|
||||
Keys:
|
||||
- R
|
||||
- Name: Move Clip Up One Track
|
||||
Shortcut:
|
||||
- Win: false
|
||||
Ctrl: true
|
||||
Shift: true
|
||||
Alt: false
|
||||
Keys:
|
||||
- Up
|
||||
- Name: Move Clip Down One Track
|
||||
Shortcut:
|
||||
- Win: false
|
||||
Ctrl: true
|
||||
Shift: true
|
||||
Alt: false
|
||||
Keys:
|
||||
- Down
|
||||
- Name: Split Clip
|
||||
Shortcut:
|
||||
- Win: false
|
||||
Ctrl: true
|
||||
Shift: false
|
||||
Alt: false
|
||||
Keys:
|
||||
- B
|
||||
- Name: Link Clips
|
||||
Shortcut:
|
||||
- Win: false
|
||||
Ctrl: true
|
||||
Shift: false
|
||||
Alt: false
|
||||
Keys:
|
||||
- L
|
||||
- Name: Create Compound Clip
|
||||
Shortcut:
|
||||
- Win: false
|
||||
Ctrl: true
|
||||
Shift: false
|
||||
Alt: false
|
||||
Keys:
|
||||
- G
|
||||
- SectionName: Color
|
||||
Properties:
|
||||
- Name: Add Serial Node
|
||||
Shortcut:
|
||||
- Win: false
|
||||
Ctrl: false
|
||||
Shift: false
|
||||
Alt: true
|
||||
Keys:
|
||||
- S
|
||||
- Name: Add Parallel Node
|
||||
Shortcut:
|
||||
- Win: false
|
||||
Ctrl: false
|
||||
Shift: false
|
||||
Alt: true
|
||||
Keys:
|
||||
- P
|
||||
- Name: Add Layer Node
|
||||
Shortcut:
|
||||
- Win: false
|
||||
Ctrl: false
|
||||
Shift: false
|
||||
Alt: true
|
||||
Keys:
|
||||
- L
|
||||
- Name: Select Node 1
|
||||
Shortcut:
|
||||
- Win: false
|
||||
Ctrl: false
|
||||
Shift: false
|
||||
Alt: false
|
||||
Keys:
|
||||
- "1"
|
||||
- Name: Select Node 2
|
||||
Shortcut:
|
||||
- Win: false
|
||||
Ctrl: false
|
||||
Shift: false
|
||||
Alt: false
|
||||
Keys:
|
||||
- "2"
|
||||
- Name: Select Node 3
|
||||
Shortcut:
|
||||
- Win: false
|
||||
Ctrl: false
|
||||
Shift: false
|
||||
Alt: false
|
||||
Keys:
|
||||
- "3"
|
||||
- Name: Select Node 4
|
||||
Shortcut:
|
||||
- Win: false
|
||||
Ctrl: false
|
||||
Shift: false
|
||||
Alt: false
|
||||
Keys:
|
||||
- "4"
|
||||
- Name: Select Node 5
|
||||
Shortcut:
|
||||
- Win: false
|
||||
Ctrl: false
|
||||
Shift: false
|
||||
Alt: false
|
||||
Keys:
|
||||
- "5"
|
||||
- Name: Enable/Disable Current Grade
|
||||
Shortcut:
|
||||
- Win: false
|
||||
Ctrl: false
|
||||
Shift: false
|
||||
Alt: false
|
||||
Keys:
|
||||
- D
|
||||
- Name: Preview Mode
|
||||
Shortcut:
|
||||
- Win: false
|
||||
Ctrl: false
|
||||
Shift: true
|
||||
Alt: false
|
||||
Keys:
|
||||
- W
|
||||
- Name: Grade All Frames in Clip
|
||||
Shortcut:
|
||||
- Win: false
|
||||
Ctrl: true
|
||||
Shift: false
|
||||
Alt: false
|
||||
Keys:
|
||||
- D
|
||||
- Name: Keyframe Mode
|
||||
Shortcut:
|
||||
- Win: false
|
||||
Ctrl: false
|
||||
Shift: false
|
||||
Alt: false
|
||||
Keys:
|
||||
- K
|
||||
- Name: Select Color Wheels
|
||||
Shortcut:
|
||||
- Win: false
|
||||
Ctrl: false
|
||||
Shift: false
|
||||
Alt: false
|
||||
Keys:
|
||||
- "1"
|
||||
- Name: Select Curves
|
||||
Shortcut:
|
||||
- Win: false
|
||||
Ctrl: false
|
||||
Shift: false
|
||||
Alt: false
|
||||
Keys:
|
||||
- "2"
|
||||
- Name: Select Qualifier
|
||||
Shortcut:
|
||||
- Win: false
|
||||
Ctrl: false
|
||||
Shift: false
|
||||
Alt: false
|
||||
Keys:
|
||||
- "3"
|
||||
- Name: Select Power Window
|
||||
Shortcut:
|
||||
- Win: false
|
||||
Ctrl: false
|
||||
Shift: false
|
||||
Alt: false
|
||||
Keys:
|
||||
- "4"
|
||||
- Name: Select Tracking
|
||||
Shortcut:
|
||||
- Win: false
|
||||
Ctrl: false
|
||||
Shift: false
|
||||
Alt: false
|
||||
Keys:
|
||||
- "5"
|
||||
- Name: Reset Color Grade
|
||||
Shortcut:
|
||||
- Win: false
|
||||
Ctrl: true
|
||||
Shift: false
|
||||
Alt: false
|
||||
Keys:
|
||||
- U
|
||||
- SectionName: Fairlight
|
||||
Properties:
|
||||
- Name: Mute Track
|
||||
Shortcut:
|
||||
- Win: false
|
||||
Ctrl: false
|
||||
Shift: false
|
||||
Alt: false
|
||||
Keys:
|
||||
- M
|
||||
- Name: Solo Track
|
||||
Shortcut:
|
||||
- Win: false
|
||||
Ctrl: false
|
||||
Shift: false
|
||||
Alt: false
|
||||
Keys:
|
||||
- S
|
||||
- Name: Automation Mode
|
||||
Shortcut:
|
||||
- Win: false
|
||||
Ctrl: false
|
||||
Shift: false
|
||||
Alt: false
|
||||
Keys:
|
||||
- F
|
||||
- Name: Record Arm Selected Track
|
||||
Shortcut:
|
||||
- Win: false
|
||||
Ctrl: false
|
||||
Shift: false
|
||||
Alt: false
|
||||
Keys:
|
||||
- R
|
||||
- Name: Headphones Solo
|
||||
Shortcut:
|
||||
- Win: false
|
||||
Ctrl: false
|
||||
Shift: false
|
||||
Alt: false
|
||||
Keys:
|
||||
- H
|
||||
- Name: Add Marker
|
||||
Shortcut:
|
||||
- Win: false
|
||||
Ctrl: false
|
||||
Shift: false
|
||||
Alt: false
|
||||
Keys:
|
||||
- Insert
|
||||
- Name: Add Audio Track
|
||||
Shortcut:
|
||||
- Win: false
|
||||
Ctrl: true
|
||||
Shift: true
|
||||
Alt: false
|
||||
Keys:
|
||||
- A
|
||||
- Name: Bounce Mix
|
||||
Shortcut:
|
||||
- Win: false
|
||||
Ctrl: true
|
||||
Shift: true
|
||||
Alt: false
|
||||
Keys:
|
||||
- X
|
||||
- SectionName: Fusion
|
||||
Properties:
|
||||
- Name: Switch Between Spline and Keyframes
|
||||
Shortcut:
|
||||
- Win: false
|
||||
Ctrl: false
|
||||
Shift: false
|
||||
Alt: false
|
||||
Keys:
|
||||
- K
|
||||
- Name: Add Keyframe
|
||||
Shortcut:
|
||||
- Win: false
|
||||
Ctrl: false
|
||||
Shift: false
|
||||
Alt: false
|
||||
Keys:
|
||||
- Shift
|
||||
- Name: View Current Tool
|
||||
Shortcut:
|
||||
- Win: false
|
||||
Ctrl: false
|
||||
Shift: false
|
||||
Alt: false
|
||||
Keys:
|
||||
- "1"
|
||||
- Name: View Node Flow
|
||||
Shortcut:
|
||||
- Win: false
|
||||
Ctrl: false
|
||||
Shift: false
|
||||
Alt: false
|
||||
Keys:
|
||||
- "2"
|
||||
- Name: View Keyframes
|
||||
Shortcut:
|
||||
- Win: false
|
||||
Ctrl: false
|
||||
Shift: false
|
||||
Alt: false
|
||||
Keys:
|
||||
- "3"
|
||||
- Name: View Spline
|
||||
Shortcut:
|
||||
- Win: false
|
||||
Ctrl: false
|
||||
Shift: false
|
||||
Alt: false
|
||||
Keys:
|
||||
- "4"
|
||||
- Name: Merge Selected Tools
|
||||
Shortcut:
|
||||
- Win: false
|
||||
Ctrl: true
|
||||
Shift: false
|
||||
Alt: false
|
||||
Keys:
|
||||
- N
|
||||
- Name: Bypass Selected Tool
|
||||
Shortcut:
|
||||
- Win: false
|
||||
Ctrl: false
|
||||
Shift: false
|
||||
Alt: false
|
||||
Keys:
|
||||
- "1"
|
||||
- SectionName: Media
|
||||
Properties:
|
||||
- Name: Reveal in Explorer
|
||||
Shortcut:
|
||||
- Win: false
|
||||
Ctrl: true
|
||||
Shift: false
|
||||
Alt: false
|
||||
Keys:
|
||||
- E
|
||||
- Name: Smart Bin
|
||||
Shortcut:
|
||||
- Win: false
|
||||
Ctrl: true
|
||||
Shift: true
|
||||
Alt: false
|
||||
Keys:
|
||||
- S
|
||||
- Name: Rename Clip
|
||||
Shortcut:
|
||||
- Win: false
|
||||
Ctrl: false
|
||||
Shift: false
|
||||
Alt: false
|
||||
Keys:
|
||||
- F2
|
||||
- Name: Import XML / AAF
|
||||
Shortcut:
|
||||
- Win: false
|
||||
Ctrl: true
|
||||
Shift: true
|
||||
Alt: false
|
||||
Keys:
|
||||
- I
|
||||
- Name: Create New Bin
|
||||
Shortcut:
|
||||
- Win: false
|
||||
Ctrl: true
|
||||
Shift: false
|
||||
Alt: false
|
||||
Keys:
|
||||
- N
|
||||
- Name: Add Clip to Timeline
|
||||
Shortcut:
|
||||
- Win: false
|
||||
Ctrl: false
|
||||
Shift: false
|
||||
Alt: false
|
||||
Keys:
|
||||
- Enter
|
||||
- Name: Viewer Zoom In
|
||||
Shortcut:
|
||||
- Win: false
|
||||
Ctrl: true
|
||||
Shift: false
|
||||
Alt: false
|
||||
Keys:
|
||||
- Equals
|
||||
- Name: Viewer Zoom Out
|
||||
Shortcut:
|
||||
- Win: false
|
||||
Ctrl: true
|
||||
Shift: false
|
||||
Alt: false
|
||||
Keys:
|
||||
- Minus
|
||||
- SectionName: Deliver
|
||||
Properties:
|
||||
- Name: Add to Render Queue
|
||||
Shortcut:
|
||||
- Win: false
|
||||
Ctrl: true
|
||||
Shift: false
|
||||
Alt: false
|
||||
Keys:
|
||||
- Enter
|
||||
- Name: Start Render
|
||||
Shortcut:
|
||||
- Win: false
|
||||
Ctrl: true
|
||||
Shift: true
|
||||
Alt: false
|
||||
Keys:
|
||||
- Enter
|
||||
- Name: Select Preset
|
||||
Shortcut:
|
||||
- Win: false
|
||||
Ctrl: false
|
||||
Shift: false
|
||||
Alt: false
|
||||
Keys:
|
||||
- F2
|
||||
- Name: Render Settings
|
||||
Shortcut:
|
||||
- Win: false
|
||||
Ctrl: true
|
||||
Shift: false
|
||||
Alt: false
|
||||
Keys:
|
||||
- E
|
||||
- Name: Browse Output Location
|
||||
Shortcut:
|
||||
- Win: false
|
||||
Ctrl: true
|
||||
Shift: false
|
||||
Alt: false
|
||||
Keys:
|
||||
- B
|
||||
@@ -156,9 +156,8 @@ public sealed partial class CommandBar : UserControl,
|
||||
|
||||
private void ContextMenuFlyout_Opened(object sender, object e)
|
||||
{
|
||||
// Focus the filter box so the flyout captures keyboard input,
|
||||
// then fire a single consolidated Narrator announcement.
|
||||
// We need to wait until our flyout is opened to try and toss focus
|
||||
// at its search box. The control isn't in the UI tree before that
|
||||
ContextControl.FocusSearchBox();
|
||||
ContextControl.AnnounceOpened();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -139,23 +139,10 @@
|
||||
<RowDefinition />
|
||||
<RowDefinition />
|
||||
</Grid.RowDefinitions>
|
||||
|
||||
<!--
|
||||
Hidden element used solely for raising Narrator notifications.
|
||||
It must be Content-visible in UIA but has no visual presence.
|
||||
-->
|
||||
<TextBlock
|
||||
x:Name="NarratorAnnouncer"
|
||||
Width="0"
|
||||
Height="0"
|
||||
AutomationProperties.AccessibilityView="Content"
|
||||
AutomationProperties.LiveSetting="Assertive" />
|
||||
|
||||
<ListView
|
||||
x:Name="CommandsDropdown"
|
||||
MinWidth="248"
|
||||
Margin="0,4,0,2"
|
||||
AutomationProperties.AccessibilityView="Raw"
|
||||
IsItemClickEnabled="True"
|
||||
ItemClick="CommandsDropdown_ItemClick"
|
||||
ItemTemplateSelector="{StaticResource ContextItemTemplateSelector}"
|
||||
@@ -181,7 +168,6 @@
|
||||
x:Uid="ContextFilterBox"
|
||||
Margin="0"
|
||||
Padding="10,7,6,8"
|
||||
AutomationProperties.AccessibilityView="Raw"
|
||||
Background="{ThemeResource AcrylicBackgroundFillColorBaseBrush}"
|
||||
BorderThickness="0,0,0,2"
|
||||
CornerRadius="8, 8, 0, 0"
|
||||
|
||||
@@ -2,8 +2,6 @@
|
||||
// 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.Globalization;
|
||||
using System.Text;
|
||||
using CommunityToolkit.Mvvm.Messaging;
|
||||
using CommunityToolkit.WinUI;
|
||||
using Microsoft.CmdPal.Common.Text;
|
||||
@@ -13,7 +11,6 @@ using Microsoft.CmdPal.UI.ViewModels;
|
||||
using Microsoft.CmdPal.UI.ViewModels.Messages;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.UI.Xaml;
|
||||
using Microsoft.UI.Xaml.Automation.Peers;
|
||||
using Microsoft.UI.Xaml.Controls;
|
||||
using Microsoft.UI.Xaml.Input;
|
||||
using Windows.System;
|
||||
@@ -30,15 +27,6 @@ public sealed partial class ContextMenu : UserControl,
|
||||
public static readonly DependencyProperty SubscribeToCommandBarProperty =
|
||||
DependencyProperty.Register(nameof(SubscribeToCommandBar), typeof(bool), typeof(ContextMenu), new PropertyMetadata(true, OnSubscribeToCommandBarChanged));
|
||||
|
||||
private static readonly CompositeFormat _contextMenuOpenedFormat =
|
||||
CompositeFormat.Parse(ResourceLoaderInstance.GetString("ScreenReader_Announcement_ContextMenuOpened"));
|
||||
|
||||
/// <summary>
|
||||
/// True while the context menu is transitioning from PrepareForOpen to AnnounceOpened.
|
||||
/// Prevents ViewModel_PropertyChanged from triggering UIA-visible selection changes.
|
||||
/// </summary>
|
||||
private bool _isOpening;
|
||||
|
||||
public bool ShowFilterBox
|
||||
{
|
||||
get => (bool)GetValue(ShowFilterBoxProperty);
|
||||
@@ -115,47 +103,12 @@ public sealed partial class ContextMenu : UserControl,
|
||||
|
||||
internal void PrepareForOpen(ContextMenuFilterLocation filterLocation)
|
||||
{
|
||||
_isOpening = true;
|
||||
|
||||
ViewModel.FilterOnTop = filterLocation == ContextMenuFilterLocation.Top;
|
||||
ViewModel.ResetContextMenu();
|
||||
|
||||
UpdateUiForStackChange();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Fires a single consolidated Narrator announcement.
|
||||
/// Call this after the flyout is opened and focus has been set.
|
||||
/// </summary>
|
||||
internal void AnnounceOpened()
|
||||
{
|
||||
// Defer the announcement to the next dispatcher cycle. This ensures
|
||||
// any pending FilteredItems updates have completed and the flyout
|
||||
// content is fully materialized in the UIA tree.
|
||||
DispatcherQueue.TryEnqueue(() =>
|
||||
{
|
||||
_isOpening = false;
|
||||
|
||||
var commandItems = ViewModel.FilteredItems.OfType<CommandContextItemViewModel>().ToList();
|
||||
var itemCount = commandItems.Count;
|
||||
var selectedItem = CommandsDropdown.SelectedItem as CommandContextItemViewModel;
|
||||
var selectedName = selectedItem?.Title ?? string.Empty;
|
||||
var selectedIndex = selectedItem is not null ? commandItems.IndexOf(selectedItem) + 1 : 0;
|
||||
|
||||
var announcement = string.Format(
|
||||
CultureInfo.CurrentCulture,
|
||||
_contextMenuOpenedFormat,
|
||||
itemCount,
|
||||
selectedName,
|
||||
selectedIndex);
|
||||
|
||||
RaiseNarratorNotification(
|
||||
AutomationNotificationKind.ActionCompleted,
|
||||
announcement,
|
||||
"ContextMenuOpened");
|
||||
});
|
||||
}
|
||||
|
||||
public void Receive(UpdateCommandBarMessage message)
|
||||
{
|
||||
UpdateUiForStackChange();
|
||||
@@ -244,7 +197,7 @@ public sealed partial class ContextMenu : UserControl,
|
||||
{
|
||||
var prop = e.PropertyName;
|
||||
|
||||
if (prop == nameof(ContextMenuViewModel.FilteredItems) && !_isOpening)
|
||||
if (prop == nameof(ContextMenuViewModel.FilteredItems))
|
||||
{
|
||||
UpdateUiForStackChange();
|
||||
}
|
||||
@@ -302,14 +255,12 @@ public sealed partial class ContextMenu : UserControl,
|
||||
if (e.Key == VirtualKey.Up)
|
||||
{
|
||||
NavigateUp();
|
||||
AnnounceSelectedItem();
|
||||
|
||||
e.Handled = true;
|
||||
}
|
||||
else if (e.Key == VirtualKey.Down)
|
||||
{
|
||||
NavigateDown();
|
||||
AnnounceSelectedItem();
|
||||
|
||||
e.Handled = true;
|
||||
}
|
||||
@@ -396,46 +347,6 @@ public sealed partial class ContextMenu : UserControl,
|
||||
return item is SeparatorViewModel;
|
||||
}
|
||||
|
||||
private void AnnounceSelectedItem()
|
||||
{
|
||||
if (CommandsDropdown.SelectedItem is not CommandContextItemViewModel selected)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var commandItems = ViewModel.FilteredItems.OfType<CommandContextItemViewModel>().ToList();
|
||||
var position = commandItems.IndexOf(selected) + 1;
|
||||
var total = commandItems.Count;
|
||||
var announcement = $"{selected.Title}, {position} of {total}";
|
||||
|
||||
RaiseNarratorNotification(
|
||||
AutomationNotificationKind.ItemAdded,
|
||||
announcement,
|
||||
"ContextMenuSelectionChanged");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Raises a UIA notification via the dedicated NarratorAnnouncer element.
|
||||
/// Ensures the element has a peer (forcing layout if needed on first use).
|
||||
/// </summary>
|
||||
private void RaiseNarratorNotification(AutomationNotificationKind kind, string announcement, string activityId)
|
||||
{
|
||||
// On first flyout open the announcer may not have a peer yet.
|
||||
// UpdateLayout ensures the element is materialized in the UIA tree.
|
||||
var peer = FrameworkElementAutomationPeer.FromElement(NarratorAnnouncer);
|
||||
if (peer is null)
|
||||
{
|
||||
NarratorAnnouncer.UpdateLayout();
|
||||
peer = FrameworkElementAutomationPeer.CreatePeerForElement(NarratorAnnouncer);
|
||||
}
|
||||
|
||||
peer?.RaiseNotificationEvent(
|
||||
kind,
|
||||
AutomationNotificationProcessing.ImportantMostRecent,
|
||||
announcement,
|
||||
activityId);
|
||||
}
|
||||
|
||||
private void UpdateUiForStackChange()
|
||||
{
|
||||
ContextFilterBox.Text = string.Empty;
|
||||
|
||||
@@ -500,10 +500,9 @@ public sealed partial class DockControl : UserControl, IRecipient<CloseContextMe
|
||||
|
||||
private void ContextMenuFlyout_Opened(object sender, object e)
|
||||
{
|
||||
// Focus the filter box so the flyout captures keyboard input,
|
||||
// then fire a single consolidated Narrator announcement.
|
||||
// We need to wait until our flyout is opened to try and toss focus
|
||||
// at its search box. The control isn't in the UI tree before that
|
||||
ContextControl.FocusSearchBox();
|
||||
ContextControl.AnnounceOpened();
|
||||
}
|
||||
|
||||
public void Receive(CloseContextMenuMessage message)
|
||||
|
||||
@@ -559,9 +559,6 @@ Right-click to remove the key combination, thereby deactivating the shortcut.</v
|
||||
<data name="ScreenReader_Announcement_NavigatedToPage0" xml:space="preserve">
|
||||
<value>Navigated to {0} page</value>
|
||||
</data>
|
||||
<data name="ScreenReader_Announcement_ContextMenuOpened" xml:space="preserve">
|
||||
<value>Menu, {0} commands. {1}, {2} of {0}.</value>
|
||||
</data>
|
||||
<data name="SettingsButton.[using:Microsoft.UI.Xaml.Controls]ToolTipService.ToolTip" xml:space="preserve">
|
||||
<value>Settings (Ctrl+,)</value>
|
||||
</data>
|
||||
|
||||
|
Before Width: | Height: | Size: 54 KiB After Width: | Height: | Size: 1.4 KiB |
|
Before Width: | Height: | Size: 6.5 KiB After Width: | Height: | Size: 433 B |
|
Before Width: | Height: | Size: 54 KiB After Width: | Height: | Size: 1.4 KiB |
|
Before Width: | Height: | Size: 6.5 KiB After Width: | Height: | Size: 433 B |
|
Before Width: | Height: | Size: 2.7 KiB After Width: | Height: | Size: 328 B |
@@ -62,7 +62,7 @@ public static class CharacterMappings
|
||||
[LetterKey.VK_M] = ["ṁ", "ᵐ", "ₘ"],
|
||||
[LetterKey.VK_N] = ["ņ", "ṅ", "ⁿ", "ℕ", "№", "ₙ"],
|
||||
[LetterKey.VK_O] = ["ȯ", "∅", "⌀", "ᵒ", "ₒ"],
|
||||
[LetterKey.VK_P] = ["ṗ", "℗", "∏", "¶", "ᵖ", "ₚ", "‰", "‱"],
|
||||
[LetterKey.VK_P] = ["ṗ", "℗", "∏", "¶", "ᵖ", "ₚ"],
|
||||
[LetterKey.VK_Q] = ["ℚ", "𐞥"],
|
||||
[LetterKey.VK_R] = ["ṙ", "®", "ℝ", "ʳ", "ᵣ"],
|
||||
[LetterKey.VK_S] = ["ṡ", "§", "∑", "∫", "ˢ", "ₛ"],
|
||||
@@ -73,10 +73,10 @@ public static class CharacterMappings
|
||||
[LetterKey.VK_X] = ["ẋ", "×", "ˣ", "ₓ"],
|
||||
[LetterKey.VK_Y] = ["ẏ", "ꝡ", "ʸ"],
|
||||
[LetterKey.VK_Z] = ["ʒ", "ǯ", "ℤ", "ᶻ"],
|
||||
[LetterKey.VK_COMMA] = ["∙", "₋", "⁻", "–", "√", "‟", "⟪", "⟫", "‛", "⟨", "⟩", "″", "‴", "⁗"],
|
||||
[LetterKey.VK_COMMA] = ["∙", "₋", "⁻", "–", "√", "‟", "《", "》", "‛", "〈", "〉", "″", "‴", "⁗"], // – is in VK_MINUS for other languages, but not VK_COMMA, so we add it here.
|
||||
[LetterKey.VK_PERIOD] = ["…", "⁝", "\u0300", "\u0301", "\u0302", "\u0303", "\u0304", "\u0308", "\u030B", "\u030C"],
|
||||
[LetterKey.VK_MINUS] = ["~", "‐", "‑", "‒", "–", "—", "―", "⁓", "−", "⸺", "⸻", "∓", "₋", "⁻"],
|
||||
[LetterKey.VK_SLASH_] = ["÷", "√", "‽", "⸘"],
|
||||
[LetterKey.VK_SLASH_] = ["÷", "√"],
|
||||
[LetterKey.VK_DIVIDE_] = ["÷", "√"],
|
||||
[LetterKey.VK_MULTIPLY_] = ["×", "⋅", "ˣ", "ₓ"],
|
||||
[LetterKey.VK_PLUS] = ["≤", "≥", "≠", "≈", "≙", "⊕", "⊗", "±", "≅", "≡", "₊", "⁺", "₌", "⁼"],
|
||||
@@ -368,32 +368,25 @@ public static class CharacterMappings
|
||||
// a spoken language, but rather a set of symbols used across languages.
|
||||
new(Language.IPA, "IPA", LanguageGroup.Special, new Dictionary<LetterKey, string[]>
|
||||
{
|
||||
[LetterKey.VK_A] = ["ɑ", "æ", "ɒ", "ɐ"],
|
||||
[LetterKey.VK_B] = ["β", "ʙ", "ɓ", "ʘ"],
|
||||
[LetterKey.VK_C] = ["ç", "χ", "ǂ"],
|
||||
[LetterKey.VK_D] = ["ð", "ɗ", "ɖ", "ǀ"],
|
||||
[LetterKey.VK_E] = ["ə", "ɛ", "ɚ", "ɘ", "ɜ", "ɵ", "ɞ", "æ", "œ"],
|
||||
[LetterKey.VK_F] = ["ɸ"],
|
||||
[LetterKey.VK_G] = ["ɡ", "ɣ", "ɢ", "ɠ", "ʛ"],
|
||||
[LetterKey.VK_H] = ["ɦ", "ħ", "ɥ", "ʜ", "ɧ", "ʱ"],
|
||||
[LetterKey.VK_I] = ["ɪ", "ɨ"],
|
||||
[LetterKey.VK_J] = ["ɟ", "ʝ", "ʄ"],
|
||||
[LetterKey.VK_L] = ["ɫ", "ʎ", "ɬ", "ɮ", "ɭ", "ʟ", "ɺ", "ꞎ", "ǁ"],
|
||||
[LetterKey.VK_M] = ["ɱ"],
|
||||
[LetterKey.VK_N] = ["ŋ", "ɲ", "ɳ", "ɴ"],
|
||||
[LetterKey.VK_O] = ["ɔ", "ø", "œ", "ɤ", "ɶ", "ʘ"],
|
||||
[LetterKey.VK_R] = ["ɹ", "ɾ", "ʁ", "ʀ", "ɻ", "ɽ"],
|
||||
[LetterKey.VK_S] = ["ʃ", "ɕ", "ʂ"],
|
||||
[LetterKey.VK_T] = ["θ", "ʈ", "ǃ"],
|
||||
[LetterKey.VK_U] = ["ʊ", "ʉ"],
|
||||
[LetterKey.VK_V] = ["ʌ", "ʋ", "ⱱ"],
|
||||
[LetterKey.VK_W] = ["ʍ", "ɯ", "ɰ"],
|
||||
[LetterKey.VK_X] = ["χ"],
|
||||
[LetterKey.VK_Y] = ["ʎ", "ʏ"],
|
||||
[LetterKey.VK_A] = ["ɐ", "ɑ", "ɒ", "ǎ"],
|
||||
[LetterKey.VK_B] = ["ʙ"],
|
||||
[LetterKey.VK_E] = ["ɘ", "ɵ", "ə", "ɛ", "ɜ", "ɞ"],
|
||||
[LetterKey.VK_F] = ["ɟ", "ɸ"],
|
||||
[LetterKey.VK_G] = ["ɢ", "ɣ"],
|
||||
[LetterKey.VK_H] = ["ɦ", "ʜ"],
|
||||
[LetterKey.VK_I] = ["ɨ", "ɪ"],
|
||||
[LetterKey.VK_J] = ["ʝ"],
|
||||
[LetterKey.VK_L] = ["ɬ", "ɮ", "ꞎ", "ɭ", "ʎ", "ʟ", "ɺ"],
|
||||
[LetterKey.VK_N] = ["ɳ", "ɲ", "ŋ", "ɴ"],
|
||||
[LetterKey.VK_O] = ["ɤ", "ɔ", "ɶ", "ǒ"],
|
||||
[LetterKey.VK_R] = ["ʁ", "ɹ", "ɻ", "ɾ", "ɽ", "ʀ"],
|
||||
[LetterKey.VK_S] = ["ʃ", "ʂ", "ɕ"],
|
||||
[LetterKey.VK_U] = ["ʉ", "ʊ", "ǔ"],
|
||||
[LetterKey.VK_V] = ["ʋ", "ⱱ", "ʌ"],
|
||||
[LetterKey.VK_W] = ["ɰ", "ɯ"],
|
||||
[LetterKey.VK_Y] = ["ʏ"],
|
||||
[LetterKey.VK_Z] = ["ʒ", "ʐ", "ʑ"],
|
||||
[LetterKey.VK_COMMA] = ["ʔ", "ʕ", "ʡ", "ʢ"],
|
||||
[LetterKey.VK_PERIOD] = ["ˈ", "ˌ", "ː", "ʼ", "\u031D", "\u0325", "\u031A", "\u0361", "\u035C"],
|
||||
[LetterKey.VK_SLASH_] = ["ʔ"],
|
||||
[LetterKey.VK_COMMA] = ["ʡ", "ʔ", "ʕ", "ʢ"],
|
||||
}),
|
||||
|
||||
new(Language.IT, "Italian", LanguageGroup.Language, new Dictionary<LetterKey, string[]>
|
||||
|
||||
@@ -1,72 +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;
|
||||
using Microsoft.PowerToys.Settings.UI.Library;
|
||||
using Microsoft.VisualStudio.TestTools.UnitTesting;
|
||||
|
||||
namespace PowerDisplay.UnitTests;
|
||||
|
||||
/// <summary>
|
||||
/// Covers the persisted shape of the mouse-wheel-increment setting on
|
||||
/// <see cref="PowerDisplayProperties"/>: its default of 5 (the historical hardcoded step),
|
||||
/// its snake_case JSON key, round-trip fidelity, and the forward-compatibility promise that
|
||||
/// settings.json written before the feature existed deserializes to the default of 5 with no
|
||||
/// migration.
|
||||
/// </summary>
|
||||
[TestClass]
|
||||
public class MouseWheelIncrementSettingsTests
|
||||
{
|
||||
[TestMethod]
|
||||
public void Default_IsFive()
|
||||
{
|
||||
var properties = new PowerDisplayProperties();
|
||||
|
||||
Assert.AreEqual(5, properties.MouseWheelIncrement, "Default must preserve the historical hardcoded step of 5.");
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void Deserialize_LegacyJsonMissingField_DefaultsToFive()
|
||||
{
|
||||
// A settings.json captured before this feature shipped has no mouse_wheel_increment key.
|
||||
// Deserializing must fall back to the constructor default of 5, not 0. System.Text.Json
|
||||
// calls the parameterless constructor (which sets MouseWheelIncrement = 5) and then fills
|
||||
// only the fields present in JSON. If PowerDisplayProperties ever gains a
|
||||
// [JsonConstructor]-annotated constructor, re-verify this "defaults to 5" behavior.
|
||||
const string legacyJson = """
|
||||
{
|
||||
"monitor_refresh_delay": 5,
|
||||
"restore_settings_on_startup": false,
|
||||
"show_system_tray_icon": true
|
||||
}
|
||||
""";
|
||||
|
||||
var properties = JsonSerializer.Deserialize<PowerDisplayProperties>(legacyJson);
|
||||
|
||||
Assert.IsNotNull(properties);
|
||||
Assert.AreEqual(5, properties.MouseWheelIncrement);
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void RoundTrip_PreservesValue()
|
||||
{
|
||||
var original = new PowerDisplayProperties { MouseWheelIncrement = 15 };
|
||||
|
||||
var json = JsonSerializer.Serialize(original);
|
||||
var restored = JsonSerializer.Deserialize<PowerDisplayProperties>(json);
|
||||
|
||||
Assert.IsNotNull(restored);
|
||||
Assert.AreEqual(15, restored.MouseWheelIncrement);
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void Serialize_UsesSnakeCaseJsonKey()
|
||||
{
|
||||
var properties = new PowerDisplayProperties { MouseWheelIncrement = 10 };
|
||||
|
||||
var json = JsonSerializer.Serialize(properties);
|
||||
|
||||
StringAssert.Contains(json, "\"mouse_wheel_increment\":10");
|
||||
}
|
||||
}
|
||||
@@ -235,7 +235,7 @@
|
||||
Grid.Column="1"
|
||||
VerticalAlignment="Center"
|
||||
helpers:SliderExtensions.IsMouseWheelEnabled="True"
|
||||
helpers:SliderExtensions.MouseWheelChange="{x:Bind ViewModel.MouseWheelIncrement, Mode=OneWay}"
|
||||
helpers:SliderExtensions.MouseWheelChange="5"
|
||||
IsEnabled="{x:Bind ViewModel.IsLinkedBrightnessSliderEnabled, Mode=OneWay}"
|
||||
IsTabStop="True"
|
||||
Maximum="100"
|
||||
@@ -525,7 +525,7 @@
|
||||
Grid.Column="1"
|
||||
VerticalAlignment="Center"
|
||||
helpers:SliderExtensions.IsMouseWheelEnabled="True"
|
||||
helpers:SliderExtensions.MouseWheelChange="{x:Bind MouseWheelIncrement, Mode=OneWay}"
|
||||
helpers:SliderExtensions.MouseWheelChange="5"
|
||||
IsEnabled="{x:Bind IsBrightnessSliderEnabled, Mode=OneWay}"
|
||||
IsTabStop="True"
|
||||
Maximum="100"
|
||||
@@ -556,7 +556,7 @@
|
||||
Grid.Column="1"
|
||||
VerticalAlignment="Center"
|
||||
helpers:SliderExtensions.IsMouseWheelEnabled="True"
|
||||
helpers:SliderExtensions.MouseWheelChange="{x:Bind MouseWheelIncrement, Mode=OneWay}"
|
||||
helpers:SliderExtensions.MouseWheelChange="5"
|
||||
IsEnabled="{x:Bind IsInteractionEnabled, Mode=OneWay}"
|
||||
IsTabStop="True"
|
||||
Maximum="100"
|
||||
@@ -586,7 +586,7 @@
|
||||
Grid.Column="1"
|
||||
VerticalAlignment="Center"
|
||||
helpers:SliderExtensions.IsMouseWheelEnabled="True"
|
||||
helpers:SliderExtensions.MouseWheelChange="{x:Bind MouseWheelIncrement, Mode=OneWay}"
|
||||
helpers:SliderExtensions.MouseWheelChange="5"
|
||||
IsEnabled="{x:Bind IsInteractionEnabled, Mode=OneWay}"
|
||||
IsTabStop="True"
|
||||
Maximum="100"
|
||||
|
||||
@@ -122,7 +122,6 @@ public partial class MainViewModel
|
||||
foreach (var monitor in Monitors)
|
||||
{
|
||||
monitor.RefreshCustomVcpNames();
|
||||
monitor.RefreshMouseWheelIncrement();
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
|
||||
@@ -93,7 +93,6 @@ public partial class MainViewModel : ObservableObject, IDisposable
|
||||
IsScanning = true;
|
||||
ShowProfileSwitcher = true;
|
||||
ShowIdentifyMonitorsButton = true;
|
||||
MouseWheelIncrement = 5;
|
||||
|
||||
// Initialize settings utils
|
||||
_settingsUtils = SettingsUtils.Default;
|
||||
@@ -130,13 +129,6 @@ public partial class MainViewModel : ObservableObject, IDisposable
|
||||
[ObservableProperty]
|
||||
public partial bool ShowIdentifyMonitorsButton { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the per-mouse-wheel-notch step applied to every flyout slider. Loaded from
|
||||
/// PowerDisplaySettings; defaults to 5 (the historical hardcoded step).
|
||||
/// </summary>
|
||||
[ObservableProperty]
|
||||
public partial int MouseWheelIncrement { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets a value indicating whether brightness slider changes are broadcast to all
|
||||
/// non-excluded monitors as one linked level. Persisted in <c>PowerDisplaySettings</c> so
|
||||
@@ -487,7 +479,6 @@ public partial class MainViewModel : ObservableObject, IDisposable
|
||||
var settings = _settingsUtils.GetSettingsOrDefault<PowerDisplaySettings>(PowerDisplaySettings.ModuleName);
|
||||
ShowProfileSwitcher = settings.Properties.ShowProfileSwitcher;
|
||||
ShowIdentifyMonitorsButton = settings.Properties.ShowIdentifyMonitorsButton;
|
||||
MouseWheelIncrement = settings.Properties.MouseWheelIncrement;
|
||||
|
||||
// Load the linked-brightness exclusion set before applying LinkedLevelsActive. If this
|
||||
// method runs after monitors are already discovered, the toggle hook can seed the master
|
||||
|
||||
@@ -210,12 +210,6 @@ public partial class MonitorViewModel : ObservableObject, IDisposable
|
||||
// Property to access IsInteractionEnabled from parent ViewModel
|
||||
public bool IsInteractionEnabled => _mainViewModel?.IsInteractionEnabled ?? true;
|
||||
|
||||
/// <summary>
|
||||
/// Gets the shared per-mouse-wheel-notch step for this monitor's sliders, proxied from the
|
||||
/// owning <see cref="MainViewModel"/>. Falls back to 5 if the owner is unavailable.
|
||||
/// </summary>
|
||||
public int MouseWheelIncrement => _mainViewModel?.MouseWheelIncrement ?? 5;
|
||||
|
||||
public MonitorViewModel(Monitor monitor, MonitorManager monitorManager, MainViewModel mainViewModel)
|
||||
{
|
||||
_monitor = monitor;
|
||||
@@ -675,16 +669,6 @@ public partial class MonitorViewModel : ObservableObject, IDisposable
|
||||
OnPropertyChanged(nameof(AvailableInputSources));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Raise <see cref="PropertyChanged"/> for <see cref="MouseWheelIncrement"/> so per-monitor
|
||||
/// sliders pick up a new value after the user changes it in Settings. Called from
|
||||
/// <c>MainViewModel.ApplySettingsFromUI</c>.
|
||||
/// </summary>
|
||||
public void RefreshMouseWheelIncrement()
|
||||
{
|
||||
OnPropertyChanged(nameof(MouseWheelIncrement));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Set input source for this monitor
|
||||
/// </summary>
|
||||
|
||||
|
Before Width: | Height: | Size: 40 KiB After Width: | Height: | Size: 1.4 KiB |
|
Before Width: | Height: | Size: 4.6 KiB After Width: | Height: | Size: 433 B |
|
Before Width: | Height: | Size: 40 KiB After Width: | Height: | Size: 1.4 KiB |
|
Before Width: | Height: | Size: 4.6 KiB After Width: | Height: | Size: 433 B |
|
Before Width: | Height: | Size: 2.0 KiB After Width: | Height: | Size: 328 B |
@@ -26,6 +26,7 @@ namespace Microsoft.PowerToys.Settings.UI.Library
|
||||
AdditionalActions = new();
|
||||
IsAIEnabled = false;
|
||||
ShowCustomPreview = true;
|
||||
ShowAIPaste = true;
|
||||
CloseAfterLosingFocus = false;
|
||||
EnableClipboardPreview = true;
|
||||
AutoCopySelectionForCustomActionHotkey = false;
|
||||
@@ -74,6 +75,9 @@ namespace Microsoft.PowerToys.Settings.UI.Library
|
||||
[JsonConverter(typeof(BoolPropertyJsonConverter))]
|
||||
public bool ShowCustomPreview { get; set; }
|
||||
|
||||
[JsonConverter(typeof(BoolPropertyJsonConverter))]
|
||||
public bool ShowAIPaste { get; set; }
|
||||
|
||||
[JsonConverter(typeof(BoolPropertyJsonConverter))]
|
||||
public bool CloseAfterLosingFocus { get; set; }
|
||||
|
||||
@@ -107,10 +111,6 @@ namespace Microsoft.PowerToys.Settings.UI.Library
|
||||
[CmdConfigureIgnoreAttribute]
|
||||
public PasteAIConfiguration PasteAIConfiguration { get; set; }
|
||||
|
||||
[JsonPropertyName("python-scripts")]
|
||||
[CmdConfigureIgnoreAttribute]
|
||||
public AdvancedPastePythonScriptSettings PythonScripts { get; set; } = new();
|
||||
|
||||
public override string ToString()
|
||||
=> JsonSerializer.Serialize(this, SettingsSerializationContext.Default.AdvancedPasteProperties);
|
||||
}
|
||||
|
||||
@@ -1,298 +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 System.Text.Json.Serialization;
|
||||
using Microsoft.PowerToys.Settings.UI.Library.Helpers;
|
||||
|
||||
namespace Microsoft.PowerToys.Settings.UI.Library;
|
||||
|
||||
public sealed class AdvancedPastePythonScriptAction : Observable, IAdvancedPasteAction, ICloneable
|
||||
{
|
||||
private string _scriptPath = string.Empty;
|
||||
private string _name = string.Empty;
|
||||
private string _description = string.Empty;
|
||||
private bool _isShown = true;
|
||||
private bool _isEnabled = true;
|
||||
private string _platform = "windows";
|
||||
private string _formats = "any";
|
||||
private string _requires = string.Empty;
|
||||
private bool _requiresAutoDetect = true;
|
||||
private HotkeySettings _shortcut = new();
|
||||
private string _inputType = string.Empty;
|
||||
private string _outputType = string.Empty;
|
||||
|
||||
[JsonPropertyName("scriptPath")]
|
||||
public string ScriptPath
|
||||
{
|
||||
get => _scriptPath;
|
||||
set => Set(ref _scriptPath, value ?? string.Empty);
|
||||
}
|
||||
|
||||
[JsonPropertyName("name")]
|
||||
public string Name
|
||||
{
|
||||
get => _name;
|
||||
set
|
||||
{
|
||||
if (Set(ref _name, value ?? string.Empty))
|
||||
{
|
||||
OnPropertyChanged(nameof(DisplayName));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns Name if non-empty, otherwise falls back to the script filename without extension.
|
||||
/// </summary>
|
||||
[JsonIgnore]
|
||||
public string DisplayName =>
|
||||
!string.IsNullOrWhiteSpace(_name)
|
||||
? _name
|
||||
: System.IO.Path.GetFileNameWithoutExtension(_scriptPath);
|
||||
|
||||
[JsonPropertyName("description")]
|
||||
public string Description
|
||||
{
|
||||
get => _description;
|
||||
set => Set(ref _description, value ?? string.Empty);
|
||||
}
|
||||
|
||||
[JsonPropertyName("isShown")]
|
||||
public bool IsShown
|
||||
{
|
||||
get => _isShown;
|
||||
set => Set(ref _isShown, value);
|
||||
}
|
||||
|
||||
[JsonPropertyName("isEnabled")]
|
||||
public bool IsEnabled
|
||||
{
|
||||
get => _isEnabled;
|
||||
set => Set(ref _isEnabled, value);
|
||||
}
|
||||
|
||||
[JsonPropertyName("platform")]
|
||||
public string Platform
|
||||
{
|
||||
get => _platform;
|
||||
set => Set(ref _platform, value ?? "windows");
|
||||
}
|
||||
|
||||
[JsonPropertyName("formats")]
|
||||
public string Formats
|
||||
{
|
||||
get => _formats;
|
||||
set => Set(ref _formats, value ?? "any");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Space-separated requires entries, e.g. "cv2=opencv-python-headless numpy requests".
|
||||
/// Only written to header when RequiresAutoDetect is false (manual mode).
|
||||
/// </summary>
|
||||
[JsonPropertyName("requires")]
|
||||
public string Requires
|
||||
{
|
||||
get => _requires;
|
||||
set => Set(ref _requires, value ?? string.Empty);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// When true, dependencies are auto-detected from import statements.
|
||||
/// When false, the manual <see cref="Requires"/> value is used.
|
||||
/// </summary>
|
||||
[JsonPropertyName("requiresAutoDetect")]
|
||||
public bool RequiresAutoDetect
|
||||
{
|
||||
get => _requiresAutoDetect;
|
||||
set => Set(ref _requiresAutoDetect, value);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Inverted view of RequiresAutoDetect for UI binding.
|
||||
/// Uses a separate field to avoid circular property change notifications.
|
||||
/// </summary>
|
||||
[JsonIgnore]
|
||||
public bool IsRequiresManual
|
||||
{
|
||||
get => !_requiresAutoDetect;
|
||||
set
|
||||
{
|
||||
var newAuto = !value;
|
||||
if (_requiresAutoDetect != newAuto)
|
||||
{
|
||||
_requiresAutoDetect = newAuto;
|
||||
OnPropertyChanged(nameof(RequiresAutoDetect));
|
||||
OnPropertyChanged(nameof(IsRequiresManual));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
[JsonPropertyName("shortcut")]
|
||||
public HotkeySettings Shortcut
|
||||
{
|
||||
get => _shortcut;
|
||||
set
|
||||
{
|
||||
if (_shortcut != value)
|
||||
{
|
||||
_shortcut = value ?? new();
|
||||
OnPropertyChanged();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The input type declared in the function name (e.g. "text", "image", "audio").
|
||||
/// Read-only display property populated during script discovery.
|
||||
/// </summary>
|
||||
[JsonIgnore]
|
||||
public string InputType
|
||||
{
|
||||
get => _inputType;
|
||||
set => Set(ref _inputType, value ?? string.Empty);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The output type declared in the function name (e.g. "text", "image", "file").
|
||||
/// Read-only display property populated during script discovery.
|
||||
/// </summary>
|
||||
[JsonIgnore]
|
||||
public string OutputType
|
||||
{
|
||||
get => _outputType;
|
||||
set => Set(ref _outputType, value ?? string.Empty);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Human-readable conversion summary, e.g. "text → image".
|
||||
/// </summary>
|
||||
[JsonIgnore]
|
||||
public string ConversionSummary =>
|
||||
!string.IsNullOrEmpty(_inputType) && !string.IsNullOrEmpty(_outputType)
|
||||
? $"{_inputType} → {_outputType}"
|
||||
: string.Empty;
|
||||
|
||||
[JsonIgnore]
|
||||
public IEnumerable<IAdvancedPasteAction> SubActions => [];
|
||||
|
||||
// Convenience properties for format checkboxes
|
||||
[JsonIgnore]
|
||||
public bool SupportsText
|
||||
{
|
||||
get => FormatContains("text");
|
||||
set => ToggleFormat("text", value);
|
||||
}
|
||||
|
||||
[JsonIgnore]
|
||||
public bool SupportsHtml
|
||||
{
|
||||
get => FormatContains("html");
|
||||
set => ToggleFormat("html", value);
|
||||
}
|
||||
|
||||
[JsonIgnore]
|
||||
public bool SupportsImage
|
||||
{
|
||||
get => FormatContains("image");
|
||||
set => ToggleFormat("image", value);
|
||||
}
|
||||
|
||||
[JsonIgnore]
|
||||
public bool SupportsAudio
|
||||
{
|
||||
get => FormatContains("audio");
|
||||
set => ToggleFormat("audio", value);
|
||||
}
|
||||
|
||||
[JsonIgnore]
|
||||
public bool SupportsVideo
|
||||
{
|
||||
get => FormatContains("video");
|
||||
set => ToggleFormat("video", value);
|
||||
}
|
||||
|
||||
[JsonIgnore]
|
||||
public bool SupportsFiles
|
||||
{
|
||||
get => FormatContains("files") || FormatContains("file");
|
||||
set => ToggleFormat("files", value);
|
||||
}
|
||||
|
||||
private bool FormatContains(string format)
|
||||
{
|
||||
if (string.Equals(Formats, "any", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
return Formats.Split(',', StringSplitOptions.TrimEntries)
|
||||
.Any(f => string.Equals(f, format, StringComparison.OrdinalIgnoreCase));
|
||||
}
|
||||
|
||||
private bool _isTogglingFormat;
|
||||
|
||||
private void ToggleFormat(string format, bool include)
|
||||
{
|
||||
if (_isTogglingFormat)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_isTogglingFormat = true;
|
||||
try
|
||||
{
|
||||
var currentFormats = string.Equals(Formats, "any", StringComparison.OrdinalIgnoreCase)
|
||||
? new HashSet<string>(["text", "html", "image", "audio", "video", "files"], StringComparer.OrdinalIgnoreCase)
|
||||
: new HashSet<string>(Formats.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries), StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
// Normalize file/files
|
||||
currentFormats.Remove("file");
|
||||
|
||||
if (include)
|
||||
{
|
||||
currentFormats.Add(format);
|
||||
}
|
||||
else
|
||||
{
|
||||
currentFormats.Remove(format);
|
||||
}
|
||||
|
||||
var allFormats = new HashSet<string>(["text", "html", "image", "audio", "video", "files"], StringComparer.OrdinalIgnoreCase);
|
||||
Formats = currentFormats.SetEquals(allFormats) ? "any" : string.Join(", ", currentFormats);
|
||||
|
||||
OnPropertyChanged(nameof(SupportsText));
|
||||
OnPropertyChanged(nameof(SupportsHtml));
|
||||
OnPropertyChanged(nameof(SupportsImage));
|
||||
OnPropertyChanged(nameof(SupportsAudio));
|
||||
OnPropertyChanged(nameof(SupportsVideo));
|
||||
OnPropertyChanged(nameof(SupportsFiles));
|
||||
}
|
||||
finally
|
||||
{
|
||||
_isTogglingFormat = false;
|
||||
}
|
||||
}
|
||||
|
||||
public object Clone()
|
||||
{
|
||||
return new AdvancedPastePythonScriptAction
|
||||
{
|
||||
ScriptPath = ScriptPath,
|
||||
Name = Name,
|
||||
Description = Description,
|
||||
IsShown = IsShown,
|
||||
IsEnabled = IsEnabled,
|
||||
Platform = Platform,
|
||||
Formats = Formats,
|
||||
Requires = Requires,
|
||||
RequiresAutoDetect = RequiresAutoDetect,
|
||||
InputType = InputType,
|
||||
OutputType = OutputType,
|
||||
Shortcut = Shortcut != null ? new HotkeySettings(Shortcut.Win, Shortcut.Ctrl, Shortcut.Alt, Shortcut.Shift, Shortcut.Code) : null,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -1,106 +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.Text.Json.Serialization;
|
||||
|
||||
namespace Microsoft.PowerToys.Settings.UI.Library;
|
||||
|
||||
public sealed class AdvancedPastePythonScriptSettings
|
||||
{
|
||||
/// <summary>
|
||||
/// Execution mode: "disabled", "windows", or "wsl".
|
||||
/// </summary>
|
||||
[JsonPropertyName("mode")]
|
||||
public string Mode { get; set; } = "disabled";
|
||||
|
||||
/// <summary>
|
||||
/// Settings specific to Windows-native Python execution.
|
||||
/// </summary>
|
||||
[JsonPropertyName("windowsSettings")]
|
||||
public PythonScriptWindowsSettings WindowsSettings { get; set; } = new();
|
||||
|
||||
/// <summary>
|
||||
/// Settings specific to WSL Python execution.
|
||||
/// </summary>
|
||||
[JsonPropertyName("wslSettings")]
|
||||
public PythonScriptWslSettings WslSettings { get; set; } = new();
|
||||
|
||||
[JsonPropertyName("timeoutSeconds")]
|
||||
public int TimeoutSeconds { get; set; } = 30;
|
||||
|
||||
[JsonPropertyName("value")]
|
||||
public List<AdvancedPastePythonScriptAction> Value { get; set; } = [];
|
||||
|
||||
[JsonPropertyName("trustedScriptHashes")]
|
||||
public Dictionary<string, string> TrustedScriptHashes { get; set; } = [];
|
||||
|
||||
// Legacy properties — read for migration, never written back
|
||||
[JsonPropertyName("isEnabled")]
|
||||
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
|
||||
public bool? IsEnabled { get; set; }
|
||||
|
||||
[JsonPropertyName("useWsl")]
|
||||
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
|
||||
public bool? UseWsl { get; set; }
|
||||
|
||||
[JsonPropertyName("scriptsFolder")]
|
||||
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
|
||||
public string ScriptsFolder { get; set; }
|
||||
|
||||
[JsonPropertyName("pythonExecutablePath")]
|
||||
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
|
||||
public string PythonExecutablePath { get; set; }
|
||||
|
||||
[JsonPropertyName("wslDistribution")]
|
||||
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
|
||||
public string WslDistribution { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Migrates legacy settings (isEnabled/useWsl) to new mode format on first load.
|
||||
/// </summary>
|
||||
public void MigrateLegacyIfNeeded()
|
||||
{
|
||||
// Only migrate if Mode hasn't been set by the new UI yet
|
||||
// (i.e., still at default "disabled") AND legacy fields are present.
|
||||
if (IsEnabled.HasValue && string.Equals(Mode, "disabled", System.StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
// Migrate from old format
|
||||
if (!IsEnabled.Value)
|
||||
{
|
||||
Mode = "disabled";
|
||||
}
|
||||
else if (UseWsl == true)
|
||||
{
|
||||
Mode = "wsl";
|
||||
}
|
||||
else
|
||||
{
|
||||
Mode = "windows";
|
||||
}
|
||||
|
||||
if (!string.IsNullOrEmpty(ScriptsFolder))
|
||||
{
|
||||
WindowsSettings.ScriptsFolder = ScriptsFolder;
|
||||
}
|
||||
|
||||
if (!string.IsNullOrEmpty(PythonExecutablePath))
|
||||
{
|
||||
WindowsSettings.PythonExecutablePath = PythonExecutablePath;
|
||||
}
|
||||
|
||||
if (!string.IsNullOrEmpty(WslDistribution))
|
||||
{
|
||||
WslSettings.Distribution = WslDistribution;
|
||||
}
|
||||
}
|
||||
|
||||
// Always clear legacy fields so they don't persist
|
||||
IsEnabled = null;
|
||||
UseWsl = null;
|
||||
ScriptsFolder = null;
|
||||
PythonExecutablePath = null;
|
||||
WslDistribution = null;
|
||||
}
|
||||
}
|
||||
@@ -18,7 +18,6 @@ namespace Microsoft.PowerToys.Settings.UI.Library
|
||||
{
|
||||
ActivationShortcut = DefaultActivationShortcut;
|
||||
MonitorRefreshDelay = 5;
|
||||
MouseWheelIncrement = 5;
|
||||
Monitors = new List<MonitorInfo>();
|
||||
RestoreSettingsOnStartup = false;
|
||||
ShowSystemTrayIcon = true;
|
||||
@@ -49,13 +48,6 @@ namespace Microsoft.PowerToys.Settings.UI.Library
|
||||
[JsonPropertyName("monitor_refresh_delay")]
|
||||
public int MonitorRefreshDelay { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the amount each PowerDisplay flyout slider (brightness, contrast, volume)
|
||||
/// changes per mouse-wheel notch. Defaults to 5, the historical hardcoded step.
|
||||
/// </summary>
|
||||
[JsonPropertyName("mouse_wheel_increment")]
|
||||
public int MouseWheelIncrement { get; set; }
|
||||
|
||||
[JsonPropertyName("monitors")]
|
||||
public List<MonitorInfo> Monitors { get; set; }
|
||||
|
||||
|
||||
@@ -1,16 +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 Microsoft.PowerToys.Settings.UI.Library;
|
||||
|
||||
public sealed class PythonScriptWindowsSettings
|
||||
{
|
||||
[JsonPropertyName("scriptsFolder")]
|
||||
public string ScriptsFolder { get; set; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("pythonExecutablePath")]
|
||||
public string PythonExecutablePath { get; set; } = string.Empty;
|
||||
}
|
||||
@@ -1,16 +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 Microsoft.PowerToys.Settings.UI.Library;
|
||||
|
||||
public sealed class PythonScriptWslSettings
|
||||
{
|
||||
[JsonPropertyName("scriptsFolder")]
|
||||
public string ScriptsFolder { get; set; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("distribution")]
|
||||
public string Distribution { get; set; } = string.Empty;
|
||||
}
|
||||
@@ -140,10 +140,6 @@ namespace Microsoft.PowerToys.Settings.UI.Library
|
||||
[JsonSerializable(typeof(AdvancedPasteAdditionalAction))]
|
||||
[JsonSerializable(typeof(AdvancedPastePasteAsFileAction))]
|
||||
[JsonSerializable(typeof(AdvancedPasteTranscodeAction))]
|
||||
[JsonSerializable(typeof(AdvancedPastePythonScriptSettings))]
|
||||
[JsonSerializable(typeof(AdvancedPastePythonScriptAction))]
|
||||
[JsonSerializable(typeof(PythonScriptWindowsSettings))]
|
||||
[JsonSerializable(typeof(PythonScriptWslSettings))]
|
||||
[JsonSerializable(typeof(ImageResizerSizes))]
|
||||
[JsonSerializable(typeof(ImageResizerCustomSizeProperty))]
|
||||
[JsonSerializable(typeof(KeyboardKeysProperty))]
|
||||
|
||||
@@ -1,59 +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.Linq;
|
||||
using System.Reflection;
|
||||
|
||||
using Microsoft.PowerToys.Settings.UI.Library;
|
||||
using Microsoft.PowerToys.Settings.UI.ViewModels;
|
||||
using Microsoft.VisualStudio.TestTools.UnitTesting;
|
||||
|
||||
namespace ViewModelTests
|
||||
{
|
||||
[TestClass]
|
||||
public class AdvancedPaste
|
||||
{
|
||||
[TestMethod]
|
||||
public void CreateActionFromScript_ExtractsConversionTypes()
|
||||
{
|
||||
var scriptPath = Path.Combine(Path.GetTempPath(), $"AdvancedPaste-{Guid.NewGuid():N}.py");
|
||||
|
||||
try
|
||||
{
|
||||
File.WriteAllLines(
|
||||
scriptPath,
|
||||
[
|
||||
"# @advancedpaste:name transcribe audio",
|
||||
"# @advancedpaste:desc Transcribes audio to text",
|
||||
string.Empty,
|
||||
"def advanced_paste_from_audio_to_text(audio_path):",
|
||||
" return 'transcribed'",
|
||||
]);
|
||||
|
||||
var createActionFromScript = typeof(AdvancedPasteViewModel)
|
||||
.GetMethod("CreateActionFromScript", BindingFlags.NonPublic | BindingFlags.Static);
|
||||
|
||||
Assert.IsNotNull(createActionFromScript);
|
||||
|
||||
var action = (AdvancedPastePythonScriptAction)createActionFromScript.Invoke(
|
||||
null, [scriptPath, new System.Collections.Generic.List<AdvancedPastePythonScriptAction>()]);
|
||||
|
||||
Assert.AreEqual("transcribe audio", action.Name);
|
||||
Assert.AreEqual("Transcribes audio to text", action.Description);
|
||||
Assert.AreEqual("audio", action.InputType);
|
||||
Assert.AreEqual("text", action.OutputType);
|
||||
Assert.AreEqual("audio → text", action.ConversionSummary);
|
||||
}
|
||||
finally
|
||||
{
|
||||
if (File.Exists(scriptPath))
|
||||
{
|
||||
File.Delete(scriptPath);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,44 +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;
|
||||
using Microsoft.UI.Xaml;
|
||||
using Windows.UI;
|
||||
|
||||
namespace Microsoft.PowerToys.Settings.UI.Helpers
|
||||
{
|
||||
/// <summary>
|
||||
/// Helpers for theming the system caption buttons (minimize/maximize/close) of a window.
|
||||
/// </summary>
|
||||
public static class TitleBarHelper
|
||||
{
|
||||
/// <summary>
|
||||
/// Applies the given element theme to a window's system caption buttons.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Workaround for the AppWindow TitleBar not updating caption button colors to match the
|
||||
/// app theme when the OS theme differs from the app theme or the theme changes at runtime.
|
||||
/// Mirrors the helper used by the WinUI Gallery (https://github.com/microsoft/WinUI-Gallery).
|
||||
/// </remarks>
|
||||
public static void ApplySystemThemeToCaptionButtons(Window window, ElementTheme theme)
|
||||
{
|
||||
if (window?.AppWindow is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var titleBar = window.AppWindow.TitleBar;
|
||||
var foregroundColor = theme == ElementTheme.Dark ? Colors.White : Colors.Black;
|
||||
|
||||
titleBar.ButtonBackgroundColor = Colors.Transparent;
|
||||
titleBar.ButtonInactiveBackgroundColor = Colors.Transparent;
|
||||
titleBar.ButtonForegroundColor = foregroundColor;
|
||||
titleBar.ButtonHoverForegroundColor = foregroundColor;
|
||||
titleBar.ButtonInactiveForegroundColor = Colors.DarkGray;
|
||||
titleBar.ButtonHoverBackgroundColor = theme == ElementTheme.Dark
|
||||
? Color.FromArgb(24, 255, 255, 255)
|
||||
: Color.FromArgb(24, 0, 0, 0);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -52,18 +52,11 @@
|
||||
<tkcontrols:MarkdownTextBlock
|
||||
x:Name="ReleaseNotesMarkdown"
|
||||
Config="{StaticResource ReleaseNotesMarkdownConfig}"
|
||||
Foreground="{ThemeResource TextFillColorPrimaryBrush}"
|
||||
UseAutoLinks="True"
|
||||
UseEmphasisExtras="True"
|
||||
UseListExtras="True"
|
||||
UsePipeTables="True"
|
||||
UseTaskLists="True" />
|
||||
<!-- Hidden helper used to resolve the accent brush for the active element theme (see ApplyMarkdownThemeWorkaround). -->
|
||||
<TextBlock
|
||||
x:Name="LinkBrushProvider"
|
||||
Foreground="{ThemeResource AccentTextFillColorPrimaryBrush}"
|
||||
IsHitTestVisible="False"
|
||||
Visibility="Collapsed" />
|
||||
</Grid>
|
||||
</Grid>
|
||||
</Grid>
|
||||
|
||||
@@ -7,12 +7,10 @@ using System.Collections.Generic;
|
||||
using System.Globalization;
|
||||
using System.Text;
|
||||
using System.Text.RegularExpressions;
|
||||
using CommunityToolkit.WinUI.Controls;
|
||||
using ManagedCommon;
|
||||
using Microsoft.PowerToys.Settings.UI.Helpers;
|
||||
using Microsoft.UI.Xaml;
|
||||
using Microsoft.UI.Xaml.Controls;
|
||||
using Microsoft.UI.Xaml.Media;
|
||||
using Microsoft.UI.Xaml.Media.Imaging;
|
||||
using Microsoft.UI.Xaml.Navigation;
|
||||
|
||||
@@ -21,7 +19,6 @@ namespace Microsoft.PowerToys.Settings.UI.OOBE.Views
|
||||
public sealed partial class ScoobeReleaseNotesPage : Page
|
||||
{
|
||||
private IList<PowerToysReleaseInfo> _currentReleases;
|
||||
private string _releaseNotesMarkdownText;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="ScoobeReleaseNotesPage"/> class.
|
||||
@@ -29,37 +26,6 @@ namespace Microsoft.PowerToys.Settings.UI.OOBE.Views
|
||||
public ScoobeReleaseNotesPage()
|
||||
{
|
||||
this.InitializeComponent();
|
||||
|
||||
// Re-apply the markdown theme workaround when the theme changes at runtime so the
|
||||
// headings/links stay readable after the user switches between light and dark.
|
||||
this.ActualThemeChanged += OnActualThemeChanged;
|
||||
this.Unloaded += OnUnloaded;
|
||||
}
|
||||
|
||||
private void OnUnloaded(object sender, RoutedEventArgs e)
|
||||
{
|
||||
this.ActualThemeChanged -= OnActualThemeChanged;
|
||||
this.Unloaded -= OnUnloaded;
|
||||
}
|
||||
|
||||
private void OnActualThemeChanged(FrameworkElement sender, object args)
|
||||
{
|
||||
RefreshMarkdownTheme();
|
||||
}
|
||||
|
||||
private void RefreshMarkdownTheme()
|
||||
{
|
||||
if (string.IsNullOrEmpty(_releaseNotesMarkdownText))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
ApplyMarkdownThemeWorkaround();
|
||||
|
||||
// The MarkdownTextBlock captures heading/link brushes when it renders, so re-set the
|
||||
// text to force it to rebuild with the brushes for the now-active theme.
|
||||
ReleaseNotesMarkdown.Text = string.Empty;
|
||||
ReleaseNotesMarkdown.Text = _releaseNotesMarkdownText;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -162,18 +128,7 @@ namespace Microsoft.PowerToys.Settings.UI.OOBE.Views
|
||||
{
|
||||
LoadingProgressRing.Visibility = Visibility.Collapsed;
|
||||
|
||||
// Workaround: the MarkdownTextBlock control captures its heading foreground
|
||||
// brushes from Application.Current.Resources when its theme config is created,
|
||||
// which resolves against the OS (application) theme rather than the app's
|
||||
// selected theme. When the OS is Light but PowerToys is Dark (or vice versa),
|
||||
// headings render with an unreadable color. Force the control's theme and
|
||||
// reapply correctly-themed heading brushes before the markdown is rendered.
|
||||
// TODO: Remove once the upstream control resolves brushes against the element theme.
|
||||
// Upstream fix: https://github.com/CommunityToolkit/Labs-Windows/pull/785
|
||||
ApplyMarkdownThemeWorkaround();
|
||||
|
||||
var (releaseNotesMarkdown, heroImageUrl) = ProcessReleaseNotesMarkdown(_currentReleases);
|
||||
_releaseNotesMarkdownText = releaseNotesMarkdown;
|
||||
|
||||
// Set the Hero image if found
|
||||
if (!string.IsNullOrEmpty(heroImageUrl))
|
||||
@@ -195,46 +150,6 @@ namespace Microsoft.PowerToys.Settings.UI.OOBE.Views
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Works around the <see cref="MarkdownTextBlock"/> control pinning its heading and link
|
||||
/// brushes to the OS (application) theme instead of the element's selected theme, which makes
|
||||
/// titles/links unreadable when the OS and PowerToys themes differ. Pins the control's theme and
|
||||
/// reassigns the heading/link brushes resolved for the selected theme before the markdown renders.
|
||||
/// TODO: Remove once the upstream control resolves brushes against the element theme.
|
||||
/// Upstream fix: https://github.com/CommunityToolkit/Labs-Windows/pull/785
|
||||
/// </summary>
|
||||
private void ApplyMarkdownThemeWorkaround()
|
||||
{
|
||||
var elementTheme = App.IsDarkTheme() ? ElementTheme.Dark : ElementTheme.Light;
|
||||
ReleaseNotesMarkdown.RequestedTheme = elementTheme;
|
||||
LinkBrushProvider.RequestedTheme = elementTheme;
|
||||
|
||||
if (Resources["ReleaseNotesMarkdownConfig"] is MarkdownConfig config
|
||||
&& config.Themes is MarkdownThemes themes)
|
||||
{
|
||||
// The control's Foreground is bound to TextFillColorPrimaryBrush via ThemeResource,
|
||||
// so after setting RequestedTheme it resolves to the brush for the selected theme.
|
||||
// Reuse it for the heading brushes, which the control would otherwise pin to the OS theme.
|
||||
if (ReleaseNotesMarkdown.Foreground is Brush headingForeground)
|
||||
{
|
||||
themes.H1Foreground = headingForeground;
|
||||
themes.H2Foreground = headingForeground;
|
||||
themes.H3Foreground = headingForeground;
|
||||
themes.H4Foreground = headingForeground;
|
||||
themes.H5Foreground = headingForeground;
|
||||
themes.H6Foreground = headingForeground;
|
||||
}
|
||||
|
||||
// The link brush is likewise pinned to the OS theme's accent color, which can be
|
||||
// unreadable when the app theme differs from the OS theme. Reapply the accent brush
|
||||
// resolved for the selected theme using the hidden helper element.
|
||||
if (LinkBrushProvider.Foreground is Brush linkForeground)
|
||||
{
|
||||
themes.LinkForeground = linkForeground;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void Page_Loaded(object sender, RoutedEventArgs e)
|
||||
{
|
||||
DisplayReleaseNotes();
|
||||
|
||||
@@ -57,16 +57,6 @@ namespace Microsoft.PowerToys.Settings.UI
|
||||
this.ExtendsContentIntoTitleBar = true;
|
||||
this.SetTitleBar(AppTitleBar);
|
||||
Title = ResourceLoaderInstance.ResourceLoader.GetString("ScoobeWindow_Title");
|
||||
|
||||
// The built-in WinUI TitleBar does not tint the system caption buttons (min/max/close)
|
||||
// to match the app's selected theme, so they can be unreadable when the OS theme differs
|
||||
// from the PowerToys theme. Drive their colors from the window content's actual theme.
|
||||
if (this.Content is FrameworkElement rootElement)
|
||||
{
|
||||
TitleBarHelper.ApplySystemThemeToCaptionButtons(this, rootElement.ActualTheme);
|
||||
rootElement.ActualThemeChanged += (s, e) =>
|
||||
TitleBarHelper.ApplySystemThemeToCaptionButtons(this, s.ActualTheme);
|
||||
}
|
||||
}
|
||||
|
||||
private void Window_Activated(object sender, WindowActivatedEventArgs args)
|
||||
|
||||
@@ -164,10 +164,10 @@
|
||||
</InfoBar.IconSource>
|
||||
</InfoBar>
|
||||
</tkcontrols:SettingsExpander.ItemsHeader>
|
||||
<controls:ShortcutControl MinWidth="{StaticResource SettingActionControlMinWidth}" HotkeySettings="{x:Bind Path=ViewModel.AdvancedPasteUIShortcut, Mode=TwoWay}" />
|
||||
<controls:ShortcutControl HotkeySettings="{x:Bind Path=ViewModel.AdvancedPasteUIShortcut, Mode=TwoWay}" />
|
||||
<tkcontrols:SettingsExpander.Items>
|
||||
<tkcontrols:SettingsCard Name="AdvancedPasteEnableClipboardPreview" ContentAlignment="Left">
|
||||
<ptcontrols:CheckBoxWithDescriptionControl x:Uid="AdvancedPaste_EnableClipboardPreview" IsChecked="{x:Bind ViewModel.EnableClipboardPreview, Mode=TwoWay}" />
|
||||
<CheckBox x:Uid="AdvancedPaste_EnableClipboardPreview" IsChecked="{x:Bind ViewModel.EnableClipboardPreview, Mode=TwoWay}" />
|
||||
</tkcontrols:SettingsCard>
|
||||
<tkcontrols:SettingsCard Name="AdvancedPasteAutoCopySelectionCustomAction" ContentAlignment="Left">
|
||||
<ptcontrols:CheckBoxWithDescriptionControl x:Uid="AdvancedPaste_AutoCopySelectionForCustomActionHotkey" IsChecked="{x:Bind ViewModel.AutoCopySelectionForCustomActionHotkey, Mode=TwoWay}" />
|
||||
@@ -178,13 +178,15 @@
|
||||
IsEnabled="{x:Bind ViewModel.ClipboardHistoryEnabled, Mode=OneWay}">
|
||||
<ptcontrols:CheckBoxWithDescriptionControl x:Uid="AdvancedPaste_Clipboard_History_Enabled_SettingsCard" IsChecked="{x:Bind ViewModel.ClipboardHistoryEnabled, Mode=TwoWay}" />
|
||||
</tkcontrols:SettingsCard>
|
||||
|
||||
<tkcontrols:SettingsCard Name="AdvancedPasteCloseAfterLosingFocus" ContentAlignment="Left">
|
||||
<CheckBox x:Uid="AdvancedPaste_CloseAfterLosingFocus" IsChecked="{x:Bind ViewModel.CloseAfterLosingFocus, Mode=TwoWay}" />
|
||||
</tkcontrols:SettingsCard>
|
||||
<tkcontrols:SettingsCard Name="AdvancedPasteShowCustomPreviewSettingsCard" ContentAlignment="Left">
|
||||
<ptcontrols:CheckBoxWithDescriptionControl x:Uid="AdvancedPaste_ShowCustomPreviewSettingsCard" IsChecked="{x:Bind ViewModel.ShowCustomPreview, Mode=TwoWay}" />
|
||||
</tkcontrols:SettingsCard>
|
||||
<tkcontrols:SettingsCard Name="AdvancedPasteShowAIPasteSettingsCard" ContentAlignment="Left">
|
||||
<CheckBox x:Uid="AdvancedPaste_ShowAIPasteSettingsCard" IsChecked="{x:Bind ViewModel.ShowAIPaste, Mode=TwoWay}" />
|
||||
</tkcontrols:SettingsCard>
|
||||
</tkcontrols:SettingsExpander.Items>
|
||||
</tkcontrols:SettingsExpander>
|
||||
</controls:SettingsGroup>
|
||||
@@ -195,7 +197,7 @@
|
||||
Name="PasteAsPlainTextShortcut"
|
||||
x:Uid="PasteAsPlainText_Shortcut"
|
||||
HeaderIcon="{ui:FontIcon Glyph=}">
|
||||
<controls:ShortcutControl MinWidth="{StaticResource SettingActionControlMinWidth}" HotkeySettings="{x:Bind Path=ViewModel.PasteAsPlainTextShortcut, Mode=TwoWay}" />
|
||||
<controls:ShortcutControl HotkeySettings="{x:Bind Path=ViewModel.PasteAsPlainTextShortcut, Mode=TwoWay}" />
|
||||
</tkcontrols:SettingsCard>
|
||||
<tkcontrols:SettingsCard
|
||||
Name="PasteAsMarkdownShortcut"
|
||||
@@ -390,138 +392,6 @@
|
||||
IsTabStop="{x:Bind ViewModel.IsConflictingCopyShortcut, Mode=OneWay}"
|
||||
Severity="Warning" />
|
||||
</controls:SettingsGroup>
|
||||
|
||||
<controls:SettingsGroup x:Uid="AdvancedPaste_PythonScripts_GroupSettings" IsEnabled="{x:Bind ViewModel.IsEnabled, Mode=OneWay}">
|
||||
<InfoBar
|
||||
x:Name="PythonScriptsGpoInfoBar"
|
||||
x:Uid="GPO_SettingIsManaged"
|
||||
IsClosable="False"
|
||||
IsOpen="{x:Bind ViewModel.ShowPythonScriptsGpoConfiguredInfoBar, Mode=OneWay}"
|
||||
IsTabStop="{x:Bind ViewModel.ShowPythonScriptsGpoConfiguredInfoBar, Mode=OneWay}"
|
||||
Severity="Informational">
|
||||
<InfoBar.IconSource>
|
||||
<FontIconSource FontFamily="{StaticResource SymbolThemeFontFamily}" Glyph="" />
|
||||
</InfoBar.IconSource>
|
||||
</InfoBar>
|
||||
<tkcontrols:SettingsCard x:Uid="AdvancedPaste_PythonScripts_RuntimeCard">
|
||||
<tkcontrols:SettingsCard.HeaderIcon>
|
||||
<FontIcon Glyph="" />
|
||||
</tkcontrols:SettingsCard.HeaderIcon>
|
||||
<ComboBox
|
||||
MinWidth="{StaticResource SettingActionControlMinWidth}"
|
||||
IsEnabled="{x:Bind ViewModel.IsPythonScriptsDisallowedByGPO, Mode=OneWay, Converter={StaticResource BoolNegationConverter}}"
|
||||
SelectedIndex="{x:Bind ViewModel.PythonScriptsModeIndex, Mode=TwoWay}">
|
||||
<ComboBoxItem x:Uid="AdvancedPaste_PythonScripts_ModeOff" />
|
||||
<ComboBoxItem x:Uid="AdvancedPaste_PythonScripts_ModeWindows" />
|
||||
<ComboBoxItem x:Uid="AdvancedPaste_PythonScripts_ModeWsl" />
|
||||
</ComboBox>
|
||||
</tkcontrols:SettingsCard>
|
||||
<tkcontrols:SettingsCard x:Uid="AdvancedPaste_ScriptsFolder_SettingsCard" IsEnabled="{x:Bind ViewModel.IsPythonScriptsEnabled, Mode=OneWay}">
|
||||
<tkcontrols:SettingsCard.HeaderIcon>
|
||||
<FontIcon Glyph="" />
|
||||
</tkcontrols:SettingsCard.HeaderIcon>
|
||||
<StackPanel Orientation="Horizontal" Spacing="8">
|
||||
<TextBox
|
||||
x:Name="ScriptsFolderTextBox"
|
||||
x:Uid="AdvancedPaste_ScriptsFolder_TextBox"
|
||||
MinWidth="300"
|
||||
HorizontalAlignment="Stretch"
|
||||
Text="{x:Bind ViewModel.ScriptsFolder, Mode=TwoWay, UpdateSourceTrigger=LostFocus}" />
|
||||
<Button
|
||||
VerticalAlignment="Bottom"
|
||||
Click="BrowseScriptsFolder_Click"
|
||||
Content="{ui:FontIcon Glyph=,
|
||||
FontSize=16}"
|
||||
Style="{StaticResource SubtleButtonStyle}" />
|
||||
<Button
|
||||
x:Name="OpenScriptsFolderButton"
|
||||
VerticalAlignment="Bottom"
|
||||
Click="OpenScriptsFolder_Click"
|
||||
Content="{ui:FontIcon Glyph=,
|
||||
FontSize=16}"
|
||||
Style="{StaticResource SubtleButtonStyle}" />
|
||||
</StackPanel>
|
||||
</tkcontrols:SettingsCard>
|
||||
|
||||
<!-- Windows-mode settings -->
|
||||
<tkcontrols:SettingsCard x:Uid="AdvancedPaste_PythonExecutablePath_SettingsCard" Visibility="{x:Bind ViewModel.IsWindowsMode, Mode=OneWay}">
|
||||
<tkcontrols:SettingsCard.HeaderIcon>
|
||||
<FontIcon Glyph="" />
|
||||
</tkcontrols:SettingsCard.HeaderIcon>
|
||||
<StackPanel Orientation="Horizontal" Spacing="8">
|
||||
<TextBox
|
||||
x:Name="PythonExecutablePathTextBox"
|
||||
x:Uid="AdvancedPaste_PythonExecutablePath_TextBox"
|
||||
MinWidth="300"
|
||||
HorizontalAlignment="Stretch"
|
||||
Text="{x:Bind ViewModel.PythonExecutablePath, Mode=TwoWay, UpdateSourceTrigger=LostFocus}" />
|
||||
<Button
|
||||
VerticalAlignment="Bottom"
|
||||
Click="BrowsePythonExecutablePath_Click"
|
||||
Content="{ui:FontIcon Glyph=,
|
||||
FontSize=16}"
|
||||
Style="{StaticResource SubtleButtonStyle}" />
|
||||
</StackPanel>
|
||||
</tkcontrols:SettingsCard>
|
||||
|
||||
<!-- WSL-mode settings -->
|
||||
<tkcontrols:SettingsCard x:Uid="AdvancedPaste_WslDistribution_SettingsCard" Visibility="{x:Bind ViewModel.IsWslMode, Mode=OneWay}">
|
||||
<tkcontrols:SettingsCard.HeaderIcon>
|
||||
<FontIcon Glyph="" />
|
||||
</tkcontrols:SettingsCard.HeaderIcon>
|
||||
<ComboBox
|
||||
MinWidth="200"
|
||||
ItemsSource="{x:Bind ViewModel.WslDistroDisplayNames, Mode=OneWay}"
|
||||
SelectedIndex="{x:Bind ViewModel.WslDistributionIndex, Mode=TwoWay}" />
|
||||
</tkcontrols:SettingsCard>
|
||||
|
||||
<!-- Discovered Python Scripts (read-only) -->
|
||||
<tkcontrols:SettingsExpander
|
||||
x:Name="PythonScriptListExpander"
|
||||
x:Uid="AdvancedPaste_PythonScriptList"
|
||||
HeaderIcon="{ui:FontIcon Glyph=}"
|
||||
IsEnabled="{x:Bind ViewModel.IsPythonScriptsEnabled, Mode=OneWay}"
|
||||
IsExpanded="True"
|
||||
ItemsSource="{x:Bind ViewModel.PythonScriptActions, Mode=OneWay}">
|
||||
<StackPanel Orientation="Horizontal" Spacing="8">
|
||||
<Button
|
||||
x:Name="RefreshScriptsButton"
|
||||
x:Uid="AdvancedPaste_PythonScripts_LoadScripts"
|
||||
Click="RefreshPythonScripts_Click"
|
||||
Style="{ThemeResource AccentButtonStyle}" />
|
||||
</StackPanel>
|
||||
<tkcontrols:SettingsExpander.ItemTemplate>
|
||||
<DataTemplate x:DataType="models:AdvancedPastePythonScriptAction">
|
||||
<tkcontrols:SettingsCard
|
||||
Margin="0,0,0,2"
|
||||
Description="{x:Bind Description, Mode=OneWay}"
|
||||
Header="{x:Bind DisplayName, Mode=OneWay}"
|
||||
IsActionIconVisible="False">
|
||||
<StackPanel
|
||||
VerticalAlignment="Center"
|
||||
Orientation="Horizontal"
|
||||
Spacing="12">
|
||||
<TextBlock
|
||||
VerticalAlignment="Center"
|
||||
Foreground="{ThemeResource TextFillColorSecondaryBrush}"
|
||||
Style="{StaticResource CaptionTextBlockStyle}"
|
||||
Text="{x:Bind ConversionSummary, Mode=OneWay}" />
|
||||
<Button
|
||||
Click="OpenPythonScript_Click"
|
||||
Content=""
|
||||
FontFamily="{ThemeResource SymbolThemeFontFamily}"
|
||||
Style="{StaticResource SubtleButtonStyle}"
|
||||
Tag="{x:Bind}">
|
||||
<ToolTipService.ToolTip>
|
||||
<TextBlock x:Uid="AdvancedPaste_PythonScripts_OpenScriptInEditor" />
|
||||
</ToolTipService.ToolTip>
|
||||
</Button>
|
||||
</StackPanel>
|
||||
</tkcontrols:SettingsCard>
|
||||
</DataTemplate>
|
||||
</tkcontrols:SettingsExpander.ItemTemplate>
|
||||
</tkcontrols:SettingsExpander>
|
||||
</controls:SettingsGroup>
|
||||
</StackPanel>
|
||||
</controls:SettingsPageControl.ModuleContent>
|
||||
<controls:SettingsPageControl.PrimaryLinks>
|
||||
@@ -560,6 +430,7 @@
|
||||
</StackPanel>
|
||||
</ContentDialog>
|
||||
|
||||
<!-- Paste AI provider dialog -->
|
||||
<ContentDialog
|
||||
x:Name="PasteAIProviderConfigurationDialog"
|
||||
x:Uid="AdvancedPaste_EndpointDialog"
|
||||
|
||||
@@ -65,16 +65,6 @@ namespace Microsoft.PowerToys.Settings.UI.Views
|
||||
ViewModel.OnPageLoaded();
|
||||
UpdatePasteAIUIVisibility();
|
||||
await UpdateFoundryLocalUIAsync();
|
||||
|
||||
if (ViewModel.IsWslMode)
|
||||
{
|
||||
ViewModel.RefreshWslDistros();
|
||||
}
|
||||
|
||||
if (OpenScriptsFolderButton is not null)
|
||||
{
|
||||
ToolTipService.SetToolTip(OpenScriptsFolderButton, ResourceLoaderInstance.ResourceLoader.GetString("AdvancedPaste_PythonScripts_OpenFolder"));
|
||||
}
|
||||
};
|
||||
|
||||
Unloaded += (_, _) =>
|
||||
@@ -274,75 +264,6 @@ namespace Microsoft.PowerToys.Settings.UI.Views
|
||||
}
|
||||
}
|
||||
|
||||
private void BrowsePythonExecutablePath_Click(object sender, RoutedEventArgs e)
|
||||
{
|
||||
string selectedFile = PickFileDialog(
|
||||
"Python Executable\0python.exe;python3.exe\0All Executables\0*.exe\0",
|
||||
"Select Python Executable");
|
||||
|
||||
if (!string.IsNullOrEmpty(selectedFile))
|
||||
{
|
||||
PythonExecutablePathTextBox.Text = selectedFile;
|
||||
if (ViewModel is not null)
|
||||
{
|
||||
ViewModel.PythonExecutablePath = selectedFile;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void BrowseScriptsFolder_Click(object sender, RoutedEventArgs e)
|
||||
{
|
||||
IntPtr windowHandle = WinRT.Interop.WindowNative.GetWindowHandle(App.GetSettingsWindow());
|
||||
string selectedFolder = ShellGetFolder.GetFolderDialogWithFlags(
|
||||
windowHandle,
|
||||
ShellGetFolder.FolderDialogFlags._BIF_NEWDIALOGSTYLE);
|
||||
|
||||
if (!string.IsNullOrEmpty(selectedFolder))
|
||||
{
|
||||
ScriptsFolderTextBox.Text = selectedFolder;
|
||||
if (ViewModel is not null)
|
||||
{
|
||||
ViewModel.ScriptsFolder = selectedFolder;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void OpenScriptsFolder_Click(object sender, RoutedEventArgs e)
|
||||
{
|
||||
var folder = ViewModel?.ScriptsFolder;
|
||||
if (!string.IsNullOrEmpty(folder) && System.IO.Directory.Exists(folder))
|
||||
{
|
||||
System.Diagnostics.Process.Start(new System.Diagnostics.ProcessStartInfo
|
||||
{
|
||||
FileName = folder,
|
||||
UseShellExecute = true,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private void RefreshPythonScripts_Click(object sender, RoutedEventArgs e)
|
||||
{
|
||||
ViewModel?.RefreshPythonScripts();
|
||||
RefreshScriptsButton.Content = ResourceLoaderInstance.ResourceLoader.GetString("AdvancedPaste_PythonScript_RefreshScripts/Content");
|
||||
}
|
||||
|
||||
private void OpenPythonScript_Click(object sender, RoutedEventArgs e)
|
||||
{
|
||||
if (sender is not FrameworkElement { Tag: AdvancedPastePythonScriptAction action })
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (System.IO.File.Exists(action.ScriptPath))
|
||||
{
|
||||
var startInfo = new System.Diagnostics.ProcessStartInfo(action.ScriptPath)
|
||||
{
|
||||
UseShellExecute = true,
|
||||
};
|
||||
System.Diagnostics.Process.Start(startInfo);
|
||||
}
|
||||
}
|
||||
|
||||
private static string PickFileDialog(string filter, string title, string initialDir = null, int initialFilter = 0)
|
||||
{
|
||||
// Use Win32 OpenFileName dialog as FileOpenPicker doesn't work with elevated permissions
|
||||
|
||||
@@ -84,12 +84,6 @@
|
||||
ItemsSource="{x:Bind ViewModel.MonitorRefreshDelayOptions}"
|
||||
SelectedItem="{x:Bind ViewModel.MonitorRefreshDelay, Mode=TwoWay}" />
|
||||
</tkcontrols:SettingsCard>
|
||||
<tkcontrols:SettingsCard x:Uid="PowerDisplay_MouseWheelIncrement">
|
||||
<ComboBox
|
||||
MinWidth="{StaticResource PowerDisplayCompactActionControlMinWidth}"
|
||||
ItemsSource="{x:Bind ViewModel.MouseWheelIncrementOptions}"
|
||||
SelectedItem="{x:Bind ViewModel.MouseWheelIncrement, Mode=TwoWay}" />
|
||||
</tkcontrols:SettingsCard>
|
||||
<tkcontrols:SettingsCard ContentAlignment="Left">
|
||||
<CheckBox x:Uid="PowerDisplay_RestoreSettingsOnStartup" IsChecked="{x:Bind ViewModel.RestoreSettingsOnStartup, Mode=TwoWay}" />
|
||||
</tkcontrols:SettingsCard>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<root>
|
||||
<!--
|
||||
Microsoft ResX Schema
|
||||
@@ -5561,12 +5561,6 @@ The break timer font matches the text font.</value>
|
||||
<data name="PowerDisplay_MonitorRefreshDelay.Description" xml:space="preserve">
|
||||
<value>Number of seconds to wait after display changes before refreshing monitors. Increase if monitors are not detected after hot-plug.</value>
|
||||
</data>
|
||||
<data name="PowerDisplay_MouseWheelIncrement.Header" xml:space="preserve">
|
||||
<value>Mouse wheel increment</value>
|
||||
</data>
|
||||
<data name="PowerDisplay_MouseWheelIncrement.Description" xml:space="preserve">
|
||||
<value>How much brightness, contrast, and volume sliders change per mouse wheel notch.</value>
|
||||
</data>
|
||||
<data name="PowerDisplay_AdvancedSettings.Header" xml:space="preserve">
|
||||
<value>Advanced</value>
|
||||
</data>
|
||||
@@ -6230,77 +6224,4 @@ Text uses the current drawing color.</value>
|
||||
<value>Example: outlook</value>
|
||||
<comment>{Locked="outlook"}</comment>
|
||||
</data>
|
||||
<data name="AdvancedPaste_PythonScripts_GroupSettings.Header" xml:space="preserve">
|
||||
<value>Python scripts</value>
|
||||
</data>
|
||||
<data name="AdvancedPaste_PythonScripts_RuntimeCard.Header" xml:space="preserve">
|
||||
<value>Python runtime</value>
|
||||
</data>
|
||||
<data name="AdvancedPaste_PythonScripts_RuntimeCard.Description" xml:space="preserve">
|
||||
<value>Choose how to run custom Python scripts from the Advanced Paste menu</value>
|
||||
</data>
|
||||
<data name="AdvancedPaste_ScriptsFolder_SettingsCard.Header" xml:space="preserve">
|
||||
<value>Scripts folder</value>
|
||||
</data>
|
||||
<data name="AdvancedPaste_ScriptsFolder_SettingsCard.Description" xml:space="preserve">
|
||||
<value>Folder to scan for Python scripts (.py files). Leave blank to use the default location.</value>
|
||||
</data>
|
||||
<data name="AdvancedPaste_ScriptsFolder_TextBox.PlaceholderText" xml:space="preserve">
|
||||
<value>Default (%LOCALAPPDATA%\Microsoft\PowerToys\AdvancedPaste\Scripts)</value>
|
||||
</data>
|
||||
<data name="AdvancedPaste_PythonExecutablePath_SettingsCard.Header" xml:space="preserve">
|
||||
<value>Python interpreter</value>
|
||||
</data>
|
||||
<data name="AdvancedPaste_PythonExecutablePath_SettingsCard.Description" xml:space="preserve">
|
||||
<value>Path to the Python executable used to run scripts. Leave blank to detect automatically (supports Anaconda, Miniconda, system Python).</value>
|
||||
</data>
|
||||
<data name="AdvancedPaste_PythonExecutablePath_TextBox.PlaceholderText" xml:space="preserve">
|
||||
<value>Auto-detect (e.g. C:\Users\<user>\anaconda3\python.exe)</value>
|
||||
</data>
|
||||
<data name="AdvancedPaste_UseWsl_SettingsCard.Header" xml:space="preserve">
|
||||
<value>Use WSL</value>
|
||||
</data>
|
||||
<data name="AdvancedPaste_UseWsl_SettingsCard.Description" xml:space="preserve">
|
||||
<value>Run Python scripts in Windows Subsystem for Linux instead of native Windows Python.</value>
|
||||
</data>
|
||||
<data name="AdvancedPaste_WslDistribution_SettingsCard.Header" xml:space="preserve">
|
||||
<value>WSL distribution</value>
|
||||
</data>
|
||||
<data name="AdvancedPaste_WslDistribution_SettingsCard.Description" xml:space="preserve">
|
||||
<value>Select which WSL distribution to use for running Python scripts.</value>
|
||||
</data>
|
||||
<data name="AdvancedPaste_PythonScriptList.Header" xml:space="preserve">
|
||||
<value>Discovered scripts</value>
|
||||
</data>
|
||||
<data name="AdvancedPaste_PythonScriptList.Description" xml:space="preserve">
|
||||
<value>Python scripts found in the scripts folder.</value>
|
||||
</data>
|
||||
<data name="AdvancedPaste_PythonScript_RefreshScripts.Content" xml:space="preserve">
|
||||
<value>Refresh scripts</value>
|
||||
</data>
|
||||
<data name="AdvancedPaste_PythonScript_ApplyChanges.Content" xml:space="preserve">
|
||||
<value>Apply changes</value>
|
||||
</data>
|
||||
<data name="AdvancedPaste_PythonScripts_ModeOff.Content" xml:space="preserve">
|
||||
<value>Off</value>
|
||||
</data>
|
||||
<data name="AdvancedPaste_PythonScripts_ModeWindows.Content" xml:space="preserve">
|
||||
<value>Windows Python</value>
|
||||
</data>
|
||||
<data name="AdvancedPaste_PythonScripts_ModeWsl.Content" xml:space="preserve">
|
||||
<value>WSL Python</value>
|
||||
</data>
|
||||
<data name="AdvancedPaste_PythonScripts_LoadScripts.Content" xml:space="preserve">
|
||||
<value>Load scripts</value>
|
||||
</data>
|
||||
<data name="AdvancedPaste_PythonScripts_OpenFolder" xml:space="preserve">
|
||||
<value>Open folder</value>
|
||||
</data>
|
||||
<data name="AdvancedPaste_PythonScripts_OpenScriptInEditor.Text" xml:space="preserve">
|
||||
<value>Open script in editor</value>
|
||||
</data>
|
||||
<data name="AdvancedPaste_PythonScripts_WslDefaultDistro" xml:space="preserve">
|
||||
<value>(System default)</value>
|
||||
</data>
|
||||
|
||||
</root>
|
||||
@@ -51,7 +51,6 @@ namespace Microsoft.PowerToys.Settings.UI.ViewModels
|
||||
private bool _enabledStateIsGPOConfigured;
|
||||
private GpoRuleConfigured _onlineAIModelsGpoRuleConfiguration;
|
||||
private bool _onlineAIModelsDisallowedByGPO;
|
||||
private bool _pythonScriptsDisallowedByGPO;
|
||||
private bool _isEnabled;
|
||||
|
||||
private Func<string, int> SendConfigMSG { get; }
|
||||
@@ -177,18 +176,6 @@ namespace Microsoft.PowerToys.Settings.UI.ViewModels
|
||||
// disable AI if it was enabled
|
||||
DisableAI();
|
||||
}
|
||||
|
||||
_pythonScriptsDisallowedByGPO = GPOWrapper.GetAllowedAdvancedPastePythonScriptsValue() == GpoRuleConfigured.Disabled;
|
||||
|
||||
if (_pythonScriptsDisallowedByGPO)
|
||||
{
|
||||
// Force Python scripts to disabled mode when blocked by GPO
|
||||
var scripts = _advancedPasteSettings.Properties.PythonScripts;
|
||||
if (scripts != null && !string.Equals(scripts.Mode, "disabled", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
scripts.Mode = "disabled";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void MigrateLegacyAIEnablement()
|
||||
@@ -306,445 +293,6 @@ namespace Microsoft.PowerToys.Settings.UI.ViewModels
|
||||
|
||||
public AdvancedPasteAdditionalActions AdditionalActions => _additionalActions;
|
||||
|
||||
public bool IsPythonScriptsEnabled
|
||||
{
|
||||
get
|
||||
{
|
||||
var scripts = _advancedPasteSettings.Properties.PythonScripts;
|
||||
return scripts != null && !string.Equals(scripts.Mode, "disabled", StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// ComboBox index: 0 = Disabled, 1 = Windows, 2 = WSL.
|
||||
/// </summary>
|
||||
public int PythonScriptsModeIndex
|
||||
{
|
||||
get
|
||||
{
|
||||
var scripts = _advancedPasteSettings.Properties.PythonScripts;
|
||||
var mode = scripts?.Mode ?? "disabled";
|
||||
return mode switch
|
||||
{
|
||||
"windows" => 1,
|
||||
"wsl" => 2,
|
||||
_ => 0,
|
||||
};
|
||||
}
|
||||
|
||||
set
|
||||
{
|
||||
if (_pythonScriptsDisallowedByGPO)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var scripts = _advancedPasteSettings.Properties.PythonScripts ??= new AdvancedPastePythonScriptSettings();
|
||||
var newMode = value switch
|
||||
{
|
||||
1 => "windows",
|
||||
2 => "wsl",
|
||||
_ => "disabled",
|
||||
};
|
||||
|
||||
if (!string.Equals(scripts.Mode, newMode, StringComparison.Ordinal))
|
||||
{
|
||||
scripts.Mode = newMode;
|
||||
OnPropertyChanged(nameof(PythonScriptsModeIndex));
|
||||
OnPropertyChanged(nameof(IsPythonScriptsEnabled));
|
||||
OnPropertyChanged(nameof(IsWindowsMode));
|
||||
OnPropertyChanged(nameof(IsWslMode));
|
||||
OnPropertyChanged(nameof(ScriptsFolder));
|
||||
OnPropertyChanged(nameof(PythonExecutablePath));
|
||||
OnPropertyChanged(nameof(WslDistribution));
|
||||
SaveAndNotifySettings();
|
||||
|
||||
if (newMode == "wsl")
|
||||
{
|
||||
RefreshWslDistros();
|
||||
}
|
||||
|
||||
if (_scriptsDiscovered)
|
||||
{
|
||||
RefreshPythonScripts();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public bool IsWindowsMode => PythonScriptsModeIndex == 1;
|
||||
|
||||
public bool IsWslMode => PythonScriptsModeIndex == 2;
|
||||
|
||||
public bool ScriptsDiscovered => _scriptsDiscovered;
|
||||
|
||||
public string PythonExecutablePath
|
||||
{
|
||||
get => _advancedPasteSettings.Properties.PythonScripts?.WindowsSettings?.PythonExecutablePath ?? string.Empty;
|
||||
set
|
||||
{
|
||||
var scripts = _advancedPasteSettings.Properties.PythonScripts ??= new AdvancedPastePythonScriptSettings();
|
||||
scripts.WindowsSettings ??= new PythonScriptWindowsSettings();
|
||||
if (!string.Equals(scripts.WindowsSettings.PythonExecutablePath, value, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
scripts.WindowsSettings.PythonExecutablePath = value ?? string.Empty;
|
||||
OnPropertyChanged(nameof(PythonExecutablePath));
|
||||
SaveAndNotifySettings();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public string WslDistribution
|
||||
{
|
||||
get => _advancedPasteSettings.Properties.PythonScripts?.WslSettings?.Distribution ?? string.Empty;
|
||||
set
|
||||
{
|
||||
var scripts = _advancedPasteSettings.Properties.PythonScripts ??= new AdvancedPastePythonScriptSettings();
|
||||
scripts.WslSettings ??= new PythonScriptWslSettings();
|
||||
if (!string.Equals(scripts.WslSettings.Distribution, value, StringComparison.Ordinal))
|
||||
{
|
||||
scripts.WslSettings.Distribution = value ?? string.Empty;
|
||||
OnPropertyChanged(nameof(WslDistribution));
|
||||
OnPropertyChanged(nameof(WslDistributionIndex));
|
||||
SaveAndNotifySettings();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private List<string> _availableWslDistros = [string.Empty];
|
||||
|
||||
/// <summary>
|
||||
/// Available WSL distributions. First item is "" (system default).
|
||||
/// </summary>
|
||||
public List<string> AvailableWslDistros
|
||||
{
|
||||
get => _availableWslDistros;
|
||||
private set
|
||||
{
|
||||
_availableWslDistros = value;
|
||||
OnPropertyChanged(nameof(AvailableWslDistros));
|
||||
OnPropertyChanged(nameof(WslDistroDisplayNames));
|
||||
OnPropertyChanged(nameof(WslDistributionIndex));
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Display names for the ComboBox (maps empty string to "(System default)").
|
||||
/// </summary>
|
||||
public List<string> WslDistroDisplayNames =>
|
||||
_availableWslDistros.Select(d => string.IsNullOrEmpty(d) ? ResourceLoaderInstance.ResourceLoader.GetString("AdvancedPaste_PythonScripts_WslDefaultDistro") : d).ToList();
|
||||
|
||||
/// <summary>
|
||||
/// Selected index into AvailableWslDistros for ComboBox binding.
|
||||
/// </summary>
|
||||
public int WslDistributionIndex
|
||||
{
|
||||
get
|
||||
{
|
||||
var current = WslDistribution;
|
||||
var idx = _availableWslDistros.IndexOf(current);
|
||||
return idx >= 0 ? idx : 0;
|
||||
}
|
||||
|
||||
set
|
||||
{
|
||||
if (value >= 0 && value < _availableWslDistros.Count)
|
||||
{
|
||||
WslDistribution = _availableWslDistros[value];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Queries installed WSL distributions and populates AvailableWslDistros.
|
||||
/// </summary>
|
||||
public void RefreshWslDistros()
|
||||
{
|
||||
var distros = new List<string> { string.Empty }; // first = system default
|
||||
|
||||
try
|
||||
{
|
||||
var psi = new System.Diagnostics.ProcessStartInfo("wsl.exe", "-l -q")
|
||||
{
|
||||
UseShellExecute = false,
|
||||
CreateNoWindow = true,
|
||||
RedirectStandardOutput = true,
|
||||
StandardOutputEncoding = System.Text.Encoding.Unicode,
|
||||
};
|
||||
|
||||
using var process = System.Diagnostics.Process.Start(psi);
|
||||
if (process != null)
|
||||
{
|
||||
// WaitForExit first with timeout to avoid blocking indefinitely on ReadToEnd().
|
||||
// If the process doesn't finish in time, kill it and wait for exit before reading.
|
||||
if (!process.WaitForExit(5000))
|
||||
{
|
||||
try
|
||||
{
|
||||
process.Kill();
|
||||
}
|
||||
catch
|
||||
{
|
||||
}
|
||||
|
||||
// After Kill, wait briefly for the process to actually terminate.
|
||||
// If it still hasn't exited, skip reading but still update with default-only list.
|
||||
if (!process.WaitForExit(2000))
|
||||
{
|
||||
AvailableWslDistros = distros;
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
var output = process.StandardOutput.ReadToEnd();
|
||||
|
||||
var names = output
|
||||
.Split('\n', StringSplitOptions.RemoveEmptyEntries)
|
||||
.Select(line => line.Trim().Trim('\0', '\r'))
|
||||
.Where(name => !string.IsNullOrWhiteSpace(name));
|
||||
|
||||
foreach (var name in names)
|
||||
{
|
||||
distros.Add(name);
|
||||
}
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
// WSL not available — just show the default option
|
||||
}
|
||||
|
||||
AvailableWslDistros = distros;
|
||||
}
|
||||
|
||||
public string ScriptsFolder
|
||||
{
|
||||
get
|
||||
{
|
||||
var scripts = _advancedPasteSettings.Properties.PythonScripts;
|
||||
if (scripts == null)
|
||||
{
|
||||
return DefaultScriptsFolder;
|
||||
}
|
||||
|
||||
var folder = string.Equals(scripts.Mode, "wsl", StringComparison.OrdinalIgnoreCase)
|
||||
? scripts.WslSettings?.ScriptsFolder
|
||||
: scripts.WindowsSettings?.ScriptsFolder;
|
||||
|
||||
return string.IsNullOrWhiteSpace(folder) ? DefaultScriptsFolder : folder;
|
||||
}
|
||||
|
||||
set
|
||||
{
|
||||
var scripts = _advancedPasteSettings.Properties.PythonScripts ??= new AdvancedPastePythonScriptSettings();
|
||||
if (string.Equals(scripts.Mode, "wsl", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
scripts.WslSettings ??= new PythonScriptWslSettings();
|
||||
if (!string.Equals(scripts.WslSettings.ScriptsFolder, value, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
scripts.WslSettings.ScriptsFolder = value ?? string.Empty;
|
||||
OnPropertyChanged(nameof(ScriptsFolder));
|
||||
SaveAndNotifySettings();
|
||||
|
||||
if (_scriptsDiscovered)
|
||||
{
|
||||
RefreshPythonScripts();
|
||||
}
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
scripts.WindowsSettings ??= new PythonScriptWindowsSettings();
|
||||
if (!string.Equals(scripts.WindowsSettings.ScriptsFolder, value, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
scripts.WindowsSettings.ScriptsFolder = value ?? string.Empty;
|
||||
OnPropertyChanged(nameof(ScriptsFolder));
|
||||
SaveAndNotifySettings();
|
||||
|
||||
if (_scriptsDiscovered)
|
||||
{
|
||||
RefreshPythonScripts();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static string DefaultScriptsFolder { get; } = System.IO.Path.Combine(
|
||||
Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
|
||||
"Microsoft",
|
||||
"PowerToys",
|
||||
"AdvancedPaste",
|
||||
"Scripts");
|
||||
|
||||
private ObservableCollection<AdvancedPastePythonScriptAction> _pythonScriptActions = [];
|
||||
private bool _scriptsDiscovered;
|
||||
|
||||
public ObservableCollection<AdvancedPastePythonScriptAction> PythonScriptActions
|
||||
{
|
||||
get => _pythonScriptActions;
|
||||
set
|
||||
{
|
||||
_pythonScriptActions = value;
|
||||
OnPropertyChanged(nameof(PythonScriptActions));
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Scans the scripts folder for .py files and populates PythonScriptActions
|
||||
/// with metadata from their headers. Existing settings (hotkeys) are preserved.
|
||||
/// </summary>
|
||||
public void RefreshPythonScripts()
|
||||
{
|
||||
_scriptsDiscovered = true;
|
||||
OnPropertyChanged(nameof(ScriptsDiscovered));
|
||||
|
||||
var folder = ScriptsFolder;
|
||||
if (string.IsNullOrWhiteSpace(folder) || !System.IO.Directory.Exists(folder))
|
||||
{
|
||||
PythonScriptActions = [];
|
||||
return;
|
||||
}
|
||||
|
||||
var scripts = new ObservableCollection<AdvancedPastePythonScriptAction>();
|
||||
var savedActions = _advancedPasteSettings.Properties.PythonScripts?.Value ?? [];
|
||||
|
||||
foreach (var file in System.IO.Directory.EnumerateFiles(folder, "*.py", System.IO.SearchOption.TopDirectoryOnly))
|
||||
{
|
||||
try
|
||||
{
|
||||
var action = CreateActionFromScript(file, savedActions);
|
||||
|
||||
// Only list scripts that define exactly one advanced_paste_from_*_to_*() function,
|
||||
// matching the runtime's discovery behavior in PythonScriptService.
|
||||
if (string.IsNullOrEmpty(action.InputType) || string.IsNullOrEmpty(action.OutputType))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
scripts.Add(action);
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Skip scripts that can't be read
|
||||
}
|
||||
}
|
||||
|
||||
PythonScriptActions = scripts;
|
||||
}
|
||||
|
||||
private static AdvancedPastePythonScriptAction CreateActionFromScript(
|
||||
string filePath,
|
||||
List<AdvancedPastePythonScriptAction> savedActions)
|
||||
{
|
||||
// Read header metadata from the .py file
|
||||
var name = System.IO.Path.GetFileNameWithoutExtension(filePath);
|
||||
var description = string.Empty;
|
||||
var platform = "windows";
|
||||
var formats = "any";
|
||||
var enabled = true;
|
||||
var requires = string.Empty;
|
||||
var hasExplicitRequires = false;
|
||||
var inputType = string.Empty;
|
||||
var outputType = string.Empty;
|
||||
int matchingFunctionCount = 0;
|
||||
|
||||
using var reader = new System.IO.StreamReader(filePath, System.Text.Encoding.UTF8);
|
||||
string line;
|
||||
while ((line = reader.ReadLine()) is not null)
|
||||
{
|
||||
var trimmed = line.Trim();
|
||||
|
||||
// Detect the function definition to extract input/output types
|
||||
if (trimmed.StartsWith("def advanced_paste_from_", StringComparison.Ordinal))
|
||||
{
|
||||
var match = System.Text.RegularExpressions.Regex.Match(
|
||||
trimmed,
|
||||
@"^def advanced_paste_from_(text|html|image|audio|video|files)_to_(text|html|image|audio|video|file|files)\s*\(");
|
||||
if (match.Success)
|
||||
{
|
||||
matchingFunctionCount++;
|
||||
inputType = match.Groups[1].Value;
|
||||
outputType = match.Groups[2].Value;
|
||||
}
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!trimmed.StartsWith('#'))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (TryParseTag(trimmed, "@advancedpaste:name", out var val))
|
||||
{
|
||||
name = val;
|
||||
}
|
||||
else if (TryParseTag(trimmed, "@advancedpaste:desc", out val))
|
||||
{
|
||||
description = val;
|
||||
}
|
||||
else if (TryParseTag(trimmed, "@advancedpaste:platform", out val))
|
||||
{
|
||||
platform = val.ToLowerInvariant();
|
||||
}
|
||||
else if (TryParseTag(trimmed, "@advancedpaste:formats", out val))
|
||||
{
|
||||
formats = val;
|
||||
}
|
||||
else if (TryParseTag(trimmed, "@advancedpaste:disabled", out _, presenceBased: true))
|
||||
{
|
||||
enabled = false;
|
||||
}
|
||||
else if (TryParseTag(trimmed, "@advancedpaste:requires", out val))
|
||||
{
|
||||
// Accumulate multiple requires tags
|
||||
requires = string.IsNullOrEmpty(requires) ? val : $"{requires} {val}";
|
||||
hasExplicitRequires = true;
|
||||
}
|
||||
}
|
||||
|
||||
// Runtime rejects scripts with more than one matching function.
|
||||
if (matchingFunctionCount != 1)
|
||||
{
|
||||
inputType = string.Empty;
|
||||
outputType = string.Empty;
|
||||
}
|
||||
|
||||
// Preserve existing saved settings (hotkeys, IsShown)
|
||||
var saved = savedActions?.FirstOrDefault(a =>
|
||||
string.Equals(a.ScriptPath, filePath, StringComparison.OrdinalIgnoreCase));
|
||||
|
||||
return new AdvancedPastePythonScriptAction
|
||||
{
|
||||
ScriptPath = filePath,
|
||||
Name = name,
|
||||
Description = description,
|
||||
Platform = platform,
|
||||
Formats = formats,
|
||||
IsEnabled = enabled,
|
||||
Requires = requires,
|
||||
RequiresAutoDetect = !hasExplicitRequires,
|
||||
InputType = inputType,
|
||||
OutputType = outputType,
|
||||
IsShown = saved?.IsShown ?? true,
|
||||
Shortcut = saved?.Shortcut ?? new HotkeySettings(),
|
||||
};
|
||||
}
|
||||
|
||||
private static bool TryParseTag(string line, string tag, out string value, bool presenceBased = false)
|
||||
{
|
||||
var idx = line.IndexOf(tag, StringComparison.OrdinalIgnoreCase);
|
||||
if (idx < 0)
|
||||
{
|
||||
value = null;
|
||||
return false;
|
||||
}
|
||||
|
||||
value = line[(idx + tag.Length)..].Trim();
|
||||
return presenceBased || value.Length > 0;
|
||||
}
|
||||
|
||||
public static IEnumerable<AIServiceTypeMetadata> AvailableProviders => AIServiceTypeRegistry.GetAvailableServiceTypes();
|
||||
|
||||
/// <summary>
|
||||
@@ -798,16 +346,6 @@ namespace Microsoft.PowerToys.Settings.UI.ViewModels
|
||||
get => _onlineAIModelsDisallowedByGPO && _isEnabled;
|
||||
}
|
||||
|
||||
public bool IsPythonScriptsDisallowedByGPO
|
||||
{
|
||||
get => _pythonScriptsDisallowedByGPO || _enabledGpoRuleConfiguration == GpoRuleConfigured.Disabled;
|
||||
}
|
||||
|
||||
public bool ShowPythonScriptsGpoConfiguredInfoBar
|
||||
{
|
||||
get => _pythonScriptsDisallowedByGPO && _isEnabled;
|
||||
}
|
||||
|
||||
private bool IsClipboardHistoryEnabled()
|
||||
{
|
||||
string registryKey = @"HKEY_CURRENT_USER\Software\Microsoft\Clipboard\";
|
||||
@@ -1005,6 +543,19 @@ namespace Microsoft.PowerToys.Settings.UI.ViewModels
|
||||
}
|
||||
}
|
||||
|
||||
public bool ShowAIPaste
|
||||
{
|
||||
get => _advancedPasteSettings.Properties.ShowAIPaste;
|
||||
set
|
||||
{
|
||||
if (value != _advancedPasteSettings.Properties.ShowAIPaste)
|
||||
{
|
||||
_advancedPasteSettings.Properties.ShowAIPaste = value;
|
||||
NotifySettingsChanged();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public bool CloseAfterLosingFocus
|
||||
{
|
||||
get => _advancedPasteSettings.Properties.CloseAfterLosingFocus;
|
||||
@@ -1696,6 +1247,12 @@ namespace Microsoft.PowerToys.Settings.UI.ViewModels
|
||||
OnPropertyChanged(nameof(ShowCustomPreview));
|
||||
}
|
||||
|
||||
if (target.ShowAIPaste != source.ShowAIPaste)
|
||||
{
|
||||
target.ShowAIPaste = source.ShowAIPaste;
|
||||
OnPropertyChanged(nameof(ShowAIPaste));
|
||||
}
|
||||
|
||||
if (target.CloseAfterLosingFocus != source.CloseAfterLosingFocus)
|
||||
{
|
||||
target.CloseAfterLosingFocus = source.CloseAfterLosingFocus;
|
||||
|
||||
@@ -380,26 +380,6 @@ namespace Microsoft.PowerToys.Settings.UI.ViewModels
|
||||
|
||||
public List<int> MonitorRefreshDelayOptions => _monitorRefreshDelayOptions;
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the per-mouse-wheel-notch step shared by all PowerDisplay flyout sliders.
|
||||
/// </summary>
|
||||
public int MouseWheelIncrement
|
||||
{
|
||||
get => _settings.Properties.MouseWheelIncrement;
|
||||
set
|
||||
{
|
||||
if (SetSettingsProperty(_settings.Properties.MouseWheelIncrement, value, v => _settings.Properties.MouseWheelIncrement = v))
|
||||
{
|
||||
// Push to the (possibly open) flyout so the new step takes effect immediately.
|
||||
SignalSettingsUpdated();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private readonly List<int> _mouseWheelIncrementOptions = new List<int> { 1, 2, 5, 10, 15, 20, 25 };
|
||||
|
||||
public List<int> MouseWheelIncrementOptions => _mouseWheelIncrementOptions;
|
||||
|
||||
public ObservableCollection<MonitorInfo> Monitors
|
||||
{
|
||||
get => _monitors;
|
||||
|
||||