Compare commits

..

2 Commits

Author SHA1 Message Date
copilot-swe-agent[bot]
bdcf3f805d Apply remaining changes 2026-06-27 19:07:31 +00:00
copilot-swe-agent[bot]
748f2192ee Initial plan 2026-06-27 18:58:19 +00:00
84 changed files with 398 additions and 6909 deletions

View File

@@ -135,7 +135,6 @@ BITMAPINFO
BITMAPINFOHEADER
BITSPERPEL
BITSPIXEL
Blackmagic
bla
BLENDFUNCTION
blittable
@@ -540,7 +539,6 @@ EXTRINSICPROPERTIES
eyetracker
FANCYZONESDRAWLAYOUTTEST
FANCYZONESEDITOR
Fairlight
FARPROC
fdw
fdx

View File

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

View File

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

View File

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

View File

@@ -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());

View File

@@ -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();

View File

@@ -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();

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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)
{
}
}

View File

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

View File

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

View File

@@ -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();

View File

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

View File

@@ -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="&#xE946;"
Visibility="{x:Bind ViewModel.PasteActionError.HasDetails, Mode=OneWay, Converter={StaticResource BoolToVisibilityConverter}}" />
<TextBlock
FontWeight="SemiBold"
Foreground="{ThemeResource SystemFillColorCriticalBrush}"
Style="{StaticResource CaptionTextBlockStyle}"
Text="{x:Bind ViewModel.PasteActionError.Text, Mode=OneWay}" />
</StackPanel>
<HyperlinkButton
x:Uid="SettingsBtn"
Grid.Column="1"
Margin="0,-1,0,0"
Padding="0"
HorizontalAlignment="Left"
VerticalAlignment="Top"
Command="{x:Bind ViewModel.OpenSettingsCommand}"
FontSize="12" />
<animations:Implicit.ShowAnimations>
<animations:OpacityAnimation To="1.0" Duration="0:0:0.6" />
</animations:Implicit.ShowAnimations>
<animations:Implicit.HideAnimations>
<animations:OpacityAnimation To="0.0" Duration="0:0:0.167" />
</animations:Implicit.HideAnimations>
</Grid>
<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>

View File

@@ -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);
}
}
}
}

View File

@@ -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="&#xE946;"
Visibility="{x:Bind ViewModel.PasteActionError.HasDetails, Mode=OneWay, Converter={StaticResource BoolToVisibilityConverter}}" />
<TextBlock
Grid.Column="1"
FontWeight="SemiBold"
Foreground="{ThemeResource SystemFillColorCriticalBrush}"
MaxLines="2"
Style="{StaticResource CaptionTextBlockStyle}"
Text="{x:Bind ViewModel.PasteActionError.Text, Mode=OneWay}"
TextTrimming="CharacterEllipsis"
TextWrapping="Wrap" />
<HyperlinkButton
x:Name="ShowErrorDetailsBtn"
x:Uid="ShowErrorDetailsBtn"
Grid.Column="2"
Margin="4,-1,0,0"
Padding="0"
HorizontalAlignment="Left"
VerticalAlignment="Top"
Click="ShowErrorDetailsBtn_Click"
FontSize="12"
Visibility="{x:Bind ViewModel.PasteActionError.HasDetails, Mode=OneWay, Converter={StaticResource BoolToVisibilityConverter}}" />
<HyperlinkButton
x:Uid="SettingsBtn"
Grid.Column="3"
Margin="0,-1,0,0"
Padding="0"
HorizontalAlignment="Left"
VerticalAlignment="Top"
Command="{x:Bind ViewModel.OpenSettingsCommand}"
FontSize="12" />
</Grid>
<ScrollViewer Grid.Row="3">
<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>

View File

@@ -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();
}
}
}

View File

@@ -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);
}
}

View File

@@ -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();
}

View File

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

View File

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

View File

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

View File

@@ -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);
}

View File

@@ -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);
}

View File

@@ -1,13 +0,0 @@
// Copyright (c) Microsoft Corporation
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
namespace AdvancedPaste.Services.PythonScripts;
/// <summary>
/// Represents a single Python package requirement declared via
/// <c># @advancedpaste:requires import_name=pip_package</c>.
/// </summary>
/// <param name="ImportName">The Python import name used in the script (e.g. "cv2").</param>
/// <param name="PipPackage">The pip install name (e.g. "opencv-python-headless"). Equals <see cref="ImportName"/> when not explicitly specified.</param>
public sealed record PythonRequirement(string ImportName, string PipPackage);

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

Binary file not shown.

Before

Width:  |  Height:  |  Size: 46 KiB

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.3 KiB

After

Width:  |  Height:  |  Size: 433 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 46 KiB

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.3 KiB

After

Width:  |  Height:  |  Size: 433 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.3 KiB

After

Width:  |  Height:  |  Size: 328 B

View File

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

View File

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

View File

@@ -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();
}
}

View File

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

View File

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

View File

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

View File

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

Binary file not shown.

Before

Width:  |  Height:  |  Size: 54 KiB

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.5 KiB

After

Width:  |  Height:  |  Size: 433 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 54 KiB

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.5 KiB

After

Width:  |  Height:  |  Size: 433 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.7 KiB

After

Width:  |  Height:  |  Size: 328 B

View File

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

View File

@@ -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");
}
}

View File

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

View File

@@ -122,7 +122,6 @@ public partial class MainViewModel
foreach (var monitor in Monitors)
{
monitor.RefreshCustomVcpNames();
monitor.RefreshMouseWheelIncrement();
}
}
catch (Exception ex)

View File

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

View File

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

Binary file not shown.

Before

Width:  |  Height:  |  Size: 40 KiB

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.6 KiB

After

Width:  |  Height:  |  Size: 433 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 40 KiB

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.6 KiB

After

Width:  |  Height:  |  Size: 433 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.0 KiB

After

Width:  |  Height:  |  Size: 328 B

View File

@@ -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);
}

View File

@@ -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,
};
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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);
}
}
}
}
}

View File

@@ -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);
}
}
}

View File

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

View File

@@ -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();

View File

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

View File

@@ -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=&#xE8E9;}">
<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="&#xE72E;" />
</InfoBar.IconSource>
</InfoBar>
<tkcontrols:SettingsCard x:Uid="AdvancedPaste_PythonScripts_RuntimeCard">
<tkcontrols:SettingsCard.HeaderIcon>
<FontIcon Glyph="&#xE943;" />
</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="&#xE8B7;" />
</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=&#xE8DA;,
FontSize=16}"
Style="{StaticResource SubtleButtonStyle}" />
<Button
x:Name="OpenScriptsFolderButton"
VerticalAlignment="Bottom"
Click="OpenScriptsFolder_Click"
Content="{ui:FontIcon Glyph=&#xE838;,
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="&#xE943;" />
</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=&#xE8DA;,
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="&#xEC7A;" />
</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=&#xE756;}"
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="&#xE8A7;"
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"

View File

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

View File

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

View File

@@ -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\&lt;user&gt;\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>

View File

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

View File

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