[AdvancedPaste]Additional actions - Image to text, Paste as file (txt, png, html) (#35167)

* [AdvancedPaste] Additional actions, including Image to text

* Spellcheck issue

* [AdvancedPaste] Paste as file and many other improvements

* Fixed typo

* Fixed typo

* [AdvancedPaste] Improved paste window menu layout

* [AdvancedPaste] Improved settings window layout

* [AdvancedPaste] Removed AudioToText for the moment

* Code cleanup

* Minor fixes

* Changed log-line with potentially sensitive info

* Extra telemetry for AdvancedPaste

* Added 'Hotkey' suffix to AdvancedPaste_Settings telemetry event
This commit is contained in:
Ani 2024-10-18 15:34:09 +02:00 committed by GitHub
parent 14139affc5
commit dd5cd2a570
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
41 changed files with 1435 additions and 624 deletions

View File

@ -63,6 +63,10 @@ namespace winrt::PowerToys::Interop::implementation
{
return CommonSharedConstants::ADVANCED_PASTE_JSON_MESSAGE;
}
hstring Constants::AdvancedPasteAdditionalActionMessage()
{
return CommonSharedConstants::ADVANCED_PASTE_ADDITIONAL_ACTION_MESSAGE;
}
hstring Constants::AdvancedPasteCustomActionMessage()
{
return CommonSharedConstants::ADVANCED_PASTE_CUSTOM_ACTION_MESSAGE;

View File

@ -19,6 +19,7 @@ namespace winrt::PowerToys::Interop::implementation
static hstring AdvancedPasteShowUIMessage();
static hstring AdvancedPasteMarkdownMessage();
static hstring AdvancedPasteJsonMessage();
static hstring AdvancedPasteAdditionalActionMessage();
static hstring AdvancedPasteCustomActionMessage();
static hstring ShowPowerOCRSharedEvent();
static hstring MouseJumpShowPreviewEvent();

View File

@ -16,6 +16,7 @@ namespace PowerToys
static String AdvancedPasteShowUIMessage();
static String AdvancedPasteMarkdownMessage();
static String AdvancedPasteJsonMessage();
static String AdvancedPasteAdditionalActionMessage();
static String AdvancedPasteCustomActionMessage();
static String ShowPowerOCRSharedEvent();
static String MouseJumpShowPreviewEvent();

View File

@ -32,6 +32,8 @@ namespace CommonSharedConstants
const wchar_t ADVANCED_PASTE_JSON_MESSAGE[] = L"PasteJson";
const wchar_t ADVANCED_PASTE_ADDITIONAL_ACTION_MESSAGE[] = L"AdditionalAction";
const wchar_t ADVANCED_PASTE_CUSTOM_ACTION_MESSAGE[] = L"CustomAction";
// Path to the event used to show Color Picker

View File

@ -3,12 +3,16 @@
// See the LICENSE file in the project root for more information.
using System;
using System.Collections.Generic;
using System.Globalization;
using System.Linq;
using System.Reflection;
using System.Threading;
using System.Threading.Tasks;
using AdvancedPaste.Helpers;
using AdvancedPaste.Models;
using AdvancedPaste.Services;
using AdvancedPaste.Settings;
using AdvancedPaste.ViewModels;
using ManagedCommon;
@ -34,6 +38,13 @@ namespace AdvancedPaste
{
public IHost Host { get; private set; }
private static readonly Dictionary<string, PasteFormats> AdditionalActionIPCKeys =
typeof(PasteFormats).GetFields()
.Where(field => field.IsLiteral)
.Select(field => (Format: (PasteFormats)field.GetRawConstantValue(), field.GetCustomAttribute<PasteFormatMetadataAttribute>().IPCKey))
.Where(field => field.IPCKey != null)
.ToDictionary(field => field.IPCKey, field => field.Format);
private readonly DispatcherQueue _dispatcherQueue = DispatcherQueue.GetForCurrentThread();
private readonly OptionsViewModel viewModel;
@ -60,8 +71,10 @@ namespace AdvancedPaste
Host = Microsoft.Extensions.Hosting.Host.CreateDefaultBuilder().UseContentRoot(AppContext.BaseDirectory).ConfigureServices((context, services) =>
{
services.AddSingleton<OptionsViewModel>();
services.AddSingleton<IUserSettings, UserSettings>();
services.AddSingleton<AICompletionsHelper>();
services.AddSingleton<OptionsViewModel>();
services.AddSingleton<IPasteFormatExecutor, PasteFormatExecutor>();
}).Build();
viewModel = GetService<OptionsViewModel>();
@ -111,35 +124,35 @@ namespace AdvancedPaste
private void ProcessNamedPipe(string pipeName)
{
void OnMessage(string message) => _dispatcherQueue.TryEnqueue(() => OnNamedPipeMessage(message));
void OnMessage(string message) => _dispatcherQueue.TryEnqueue(async () => await OnNamedPipeMessage(message));
Task.Run(async () =>
{
var connectTimeout = TimeSpan.FromSeconds(10);
await NamedPipeProcessor.ProcessNamedPipeAsync(pipeName, connectTimeout, OnMessage, CancellationToken.None);
});
Task.Run(async () => await NamedPipeProcessor.ProcessNamedPipeAsync(pipeName, connectTimeout: TimeSpan.FromSeconds(10), OnMessage, CancellationToken.None));
}
private void OnNamedPipeMessage(string message)
private async Task OnNamedPipeMessage(string message)
{
var messageParts = message.Split();
var messageType = messageParts.First();
if (messageType == PowerToys.Interop.Constants.AdvancedPasteShowUIMessage())
{
OnAdvancedPasteHotkey();
await ShowWindow();
}
else if (messageType == PowerToys.Interop.Constants.AdvancedPasteMarkdownMessage())
{
OnAdvancedPasteMarkdownHotkey();
await viewModel.ExecutePasteFormatAsync(PasteFormats.Markdown, PasteActionSource.GlobalKeyboardShortcut);
}
else if (messageType == PowerToys.Interop.Constants.AdvancedPasteJsonMessage())
{
OnAdvancedPasteJsonHotkey();
await viewModel.ExecutePasteFormatAsync(PasteFormats.Json, PasteActionSource.GlobalKeyboardShortcut);
}
else if (messageType == PowerToys.Interop.Constants.AdvancedPasteAdditionalActionMessage())
{
await OnAdvancedPasteAdditionalActionHotkey(messageParts);
}
else if (messageType == PowerToys.Interop.Constants.AdvancedPasteCustomActionMessage())
{
OnAdvancedPasteCustomActionHotkey(messageParts);
await OnAdvancedPasteCustomActionHotkey(messageParts);
}
}
@ -148,24 +161,27 @@ namespace AdvancedPaste
Logger.LogError("Unhandled exception", e.Exception);
}
private void OnAdvancedPasteJsonHotkey()
private async Task OnAdvancedPasteAdditionalActionHotkey(string[] messageParts)
{
viewModel.ReadClipboard();
viewModel.ToJsonFunction(true);
if (messageParts.Length != 2)
{
Logger.LogWarning("Unexpected additional action message");
}
else
{
if (!AdditionalActionIPCKeys.TryGetValue(messageParts[1], out PasteFormats pasteFormat))
{
Logger.LogWarning($"Unexpected additional action type {messageParts[1]}");
}
else
{
await ShowWindow();
await viewModel.ExecutePasteFormatAsync(pasteFormat, PasteActionSource.GlobalKeyboardShortcut);
}
}
}
private void OnAdvancedPasteMarkdownHotkey()
{
viewModel.ReadClipboard();
viewModel.ToMarkdownFunction(true);
}
private void OnAdvancedPasteHotkey()
{
ShowWindow();
}
private void OnAdvancedPasteCustomActionHotkey(string[] messageParts)
private async Task OnAdvancedPasteCustomActionHotkey(string[] messageParts)
{
if (messageParts.Length != 2)
{
@ -179,16 +195,15 @@ namespace AdvancedPaste
}
else
{
ShowWindow();
viewModel.ReadClipboard();
viewModel.ExecuteCustomActionWithPaste(customActionId);
await ShowWindow();
await viewModel.ExecuteCustomActionAsync(customActionId, PasteActionSource.GlobalKeyboardShortcut);
}
}
}
private void ShowWindow()
private async Task ShowWindow()
{
viewModel.OnShow();
await viewModel.OnShowAsync();
if (window is null)
{

View File

@ -346,7 +346,7 @@
x:Name="InputTxtBox"
HorizontalAlignment="Stretch"
x:FieldModifier="public"
IsEnabled="{x:Bind ViewModel.IsClipboardDataText, Mode=OneWay}"
IsEnabled="{x:Bind ViewModel.ClipboardHasData, Mode=OneWay}"
KeyDown="InputTxtBox_KeyDown"
PlaceholderText="{x:Bind ViewModel.InputTxtBoxPlaceholderText, Mode=OneWay}"
Style="{StaticResource CustomTextBoxStyle}"
@ -589,7 +589,7 @@
Background="Transparent"
Visibility="{x:Bind ViewModel.IsCustomAIEnabled, Mode=OneWay, Converter={StaticResource BoolToInvertedVisibilityConverter}}">
<ToolTipService.ToolTip>
<ToolTip Content="{x:Bind ViewModel.GeneralErrorText}" />
<ToolTip Content="{x:Bind ViewModel.AIDisabledErrorText}" />
</ToolTipService.ToolTip>
</Grid>
</Grid>
@ -638,7 +638,7 @@
FontWeight="SemiBold"
Foreground="{ThemeResource SystemFillColorCriticalBrush}"
Style="{StaticResource CaptionTextBlockStyle}"
Text="{x:Bind ViewModel.ApiErrorText, Mode=OneWay}" />
Text="{x:Bind ViewModel.PasteOperationErrorText, Mode=OneWay}" />
<HyperlinkButton
x:Uid="SettingsBtn"
Grid.Column="1"

View File

@ -2,17 +2,15 @@
// 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.Net;
using System.ComponentModel;
using System.Threading.Tasks;
using AdvancedPaste.Helpers;
using AdvancedPaste.Settings;
using AdvancedPaste.Models;
using AdvancedPaste.ViewModels;
using CommunityToolkit.Mvvm.Input;
using ManagedCommon;
using Microsoft.PowerToys.Telemetry;
using Microsoft.UI.Dispatching;
using Microsoft.UI.Xaml;
using Microsoft.UI.Xaml.Controls;
@ -20,12 +18,6 @@ namespace AdvancedPaste.Controls
{
public sealed partial class PromptBox : Microsoft.UI.Xaml.Controls.UserControl
{
// Minimum time to show spinner when generating custom format using forcePasteCustom
private static readonly TimeSpan MinTaskTime = TimeSpan.FromSeconds(2);
private readonly DispatcherQueue _dispatcherQueue = DispatcherQueue.GetForCurrentThread();
private readonly IUserSettings _userSettings;
public OptionsViewModel ViewModel { get; private set; }
public static readonly DependencyProperty PlaceholderTextProperty = DependencyProperty.Register(
@ -54,12 +46,31 @@ namespace AdvancedPaste.Controls
public PromptBox()
{
this.InitializeComponent();
_userSettings = App.GetService<IUserSettings>();
InitializeComponent();
ViewModel = App.GetService<OptionsViewModel>();
ViewModel.CustomActionActivated += (_, e) => GenerateCustom(e.ForcePasteCustom);
ViewModel.PropertyChanged += ViewModel_PropertyChanged;
ViewModel.CustomActionActivated += ViewModel_CustomActionActivated;
}
private void ViewModel_PropertyChanged(object sender, PropertyChangedEventArgs e)
{
if (e.PropertyName == nameof(ViewModel.Busy) || e.PropertyName == nameof(ViewModel.PasteOperationErrorText))
{
var state = ViewModel.Busy ? "LoadingState" : string.IsNullOrEmpty(ViewModel.PasteOperationErrorText) ? "DefaultState" : "ErrorState";
VisualStateManager.GoToState(this, state, true);
}
}
private void ViewModel_CustomActionActivated(object sender, CustomActionActivatedEventArgs e)
{
Logger.LogTrace();
if (!e.PasteResult)
{
PreviewGrid.Width = InputTxtBox.ActualWidth;
PreviewFlyout.ShowAt(InputTxtBox);
}
}
private void Grid_Loaded(object sender, RoutedEventArgs e)
@ -68,48 +79,7 @@ namespace AdvancedPaste.Controls
}
[RelayCommand]
private void GenerateCustom() => GenerateCustom(false);
private void GenerateCustom(bool forcePasteCustom)
{
Logger.LogTrace();
VisualStateManager.GoToState(this, "LoadingState", true);
string inputInstructions = ViewModel.Query;
ViewModel.SaveQuery(inputInstructions);
var customFormatTask = ViewModel.GenerateCustomFunction(inputInstructions);
var delayTask = forcePasteCustom ? Task.Delay(MinTaskTime) : Task.CompletedTask;
Task.WhenAll(customFormatTask, delayTask)
.ContinueWith(
_ =>
{
_dispatcherQueue.TryEnqueue(() =>
{
ViewModel.CustomFormatResult = customFormatTask.Result;
if (ViewModel.ApiRequestStatus == (int)HttpStatusCode.OK)
{
VisualStateManager.GoToState(this, "DefaultState", true);
if (_userSettings.ShowCustomPreview && !forcePasteCustom)
{
PreviewGrid.Width = InputTxtBox.ActualWidth;
PreviewFlyout.ShowAt(InputTxtBox);
}
else
{
ViewModel.PasteCustom();
InputTxtBox.Text = string.Empty;
}
}
else
{
VisualStateManager.GoToState(this, "ErrorState", true);
}
});
},
TaskScheduler.Default);
}
private async Task GenerateCustomAsync() => await ViewModel.GenerateCustomFunctionAsync(PasteActionSource.PromptBox);
[RelayCommand]
private void Recall()
@ -127,29 +97,24 @@ namespace AdvancedPaste.Controls
ClipboardHelper.SetClipboardTextContent(lastQuery.ClipboardData);
}
private void InputTxtBox_KeyDown(object sender, Microsoft.UI.Xaml.Input.KeyRoutedEventArgs e)
private async void InputTxtBox_KeyDown(object sender, Microsoft.UI.Xaml.Input.KeyRoutedEventArgs e)
{
if (e.Key == Windows.System.VirtualKey.Enter && InputTxtBox.Text.Length > 0 && ViewModel.IsCustomAIEnabled)
{
GenerateCustom();
await GenerateCustomAsync();
}
}
private void PreviewPasteBtn_Click(object sender, RoutedEventArgs e)
{
ViewModel.PasteCustom();
InputTxtBox.Text = string.Empty;
}
private void ThumbUpDown_Click(object sender, RoutedEventArgs e)
{
if (sender is Button btn)
if (sender is Button btn && bool.TryParse(btn.CommandParameter as string, out bool result))
{
bool result;
if (bool.TryParse(btn.CommandParameter as string, out result))
{
PowerToysTelemetry.Log.WriteEvent(new Telemetry.AdvancedPasteCustomFormatOutputThumbUpDownEvent(result));
}
PowerToysTelemetry.Log.WriteEvent(new Telemetry.AdvancedPasteCustomFormatOutputThumbUpDownEvent(result));
}
}

View File

@ -0,0 +1,24 @@
// Copyright (c) Microsoft Corporation
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
using System;
using System.Collections;
using Microsoft.UI.Xaml;
using Microsoft.UI.Xaml.Data;
namespace AdvancedPaste.Converters;
public sealed partial class PasteFormatsToHeightConverter : IValueConverter
{
private const int ItemHeight = 40;
public int MaxItems { get; set; } = 5;
public object Convert(object value, Type targetType, object parameter, string language) =>
new GridLength(GetHeight((value is ICollection collection) ? collection.Count : (value is int intValue) ? intValue : 0));
public int GetHeight(int itemCount) => Math.Min(MaxItems, itemCount) * ItemHeight;
public object ConvertBack(object value, Type targetType, object parameter, string language) => throw new NotImplementedException();
}

View File

@ -8,9 +8,9 @@
xmlns:pages="using:AdvancedPaste.Pages"
xmlns:winuiex="using:WinUIEx"
Width="420"
Height="308"
Height="188"
MinWidth="420"
MinHeight="308"
MinHeight="188"
Closed="WindowEx_Closed"
IsAlwaysOnTop="True"
IsMaximizable="False"

View File

@ -4,9 +4,13 @@
using System;
using System.Linq;
using AdvancedPaste.Converters;
using AdvancedPaste.Helpers;
using AdvancedPaste.Models;
using AdvancedPaste.Settings;
using AdvancedPaste.ViewModels;
using ManagedCommon;
using Microsoft.UI.Windowing;
using Microsoft.UI.Xaml;
@ -24,25 +28,32 @@ namespace AdvancedPaste
public MainWindow()
{
this.InitializeComponent();
InitializeComponent();
_userSettings = App.GetService<IUserSettings>();
var optionsViewModel = App.GetService<OptionsViewModel>();
var baseHeight = MinHeight;
var coreActionCount = PasteFormat.MetadataDict.Values.Count(metadata => metadata.IsCoreAction);
void UpdateHeight()
{
var trimmedCustomActionCount = optionsViewModel.IsPasteWithAIEnabled ? Math.Min(_userSettings.CustomActions.Count, 5) : 0;
Height = MinHeight = baseHeight + (trimmedCustomActionCount * 40);
double GetHeight(int maxCustomActionCount) =>
baseHeight +
new PasteFormatsToHeightConverter().GetHeight(coreActionCount + _userSettings.AdditionalActions.Count) +
new PasteFormatsToHeightConverter() { MaxItems = maxCustomActionCount }.GetHeight(optionsViewModel.IsAIServiceEnabled ? _userSettings.CustomActions.Count : 0);
MinHeight = GetHeight(1);
Height = GetHeight(5);
}
UpdateHeight();
_userSettings.CustomActions.CollectionChanged += (_, _) => UpdateHeight();
_userSettings.Changed += (_, _) => UpdateHeight();
optionsViewModel.PropertyChanged += (_, e) =>
{
if (e.PropertyName == nameof(optionsViewModel.IsPasteWithAIEnabled))
if (e.PropertyName == nameof(optionsViewModel.IsAIServiceEnabled))
{
UpdateHeight();
}

View File

@ -16,8 +16,9 @@
<Page.Resources>
<tkconverters:BoolToVisibilityConverter x:Name="BoolToVisibilityConverter" />
<converters:CountToVisibilityConverter x:Name="countToVisibilityConverter" />
<converters:PasteFormatsToHeightConverter x:Name="standardPasteFormatsToHeightConverter" />
<converters:CountToDoubleConverter
x:Name="customActionsCountToMinHeightConverter"
x:Name="customActionsToMinHeightConverter"
ValueIfNonZero="40"
ValueIfZero="0" />
<Style
@ -28,37 +29,56 @@
<Setter Property="Padding" Value="0" />
</Style.Setters>
</Style>
<Style x:Key="PasteFormatListViewItemStyle" TargetType="ListViewItem">
<Setter Property="Padding" Value="0" />
<Setter Property="Margin" Value="0" />
<Setter Property="HorizontalContentAlignment" Value="Stretch" />
<Setter Property="VerticalContentAlignment" Value="Stretch" />
</Style>
<DataTemplate x:Key="PasteFormatTemplate" x:DataType="local:PasteFormat">
<Grid>
<ToolTipService.ToolTip>
<TextBlock Text="{x:Bind ToolTip}" />
</ToolTipService.ToolTip>
<Grid.ColumnDefinitions>
<ColumnDefinition Width="26" />
<ColumnDefinition Width="*" />
<ColumnDefinition Width="Auto" />
</Grid.ColumnDefinitions>
<FontIcon
Margin="0,0,0,0"
VerticalAlignment="Center"
AutomationProperties.AccessibilityView="Raw"
FontSize="16"
Glyph="{x:Bind IconGlyph}" />
<TextBlock
Grid.Column="1"
VerticalAlignment="Center"
x:Phase="1"
Text="{x:Bind Name}" />
<TextBlock
Grid.Column="2"
Margin="0,0,8,0"
HorizontalAlignment="Right"
VerticalAlignment="Center"
Foreground="{ThemeResource TextFillColorSecondaryBrush}"
Style="{StaticResource CaptionTextBlockStyle}"
Text="{x:Bind ShortcutText, Mode=OneWay}"
Visibility="{x:Bind ShortcutText.Length, Mode=OneWay, Converter={StaticResource countToVisibilityConverter}}" />
</Grid>
<Button
Margin="0"
Padding="5,0,5,0"
HorizontalAlignment="Stretch"
VerticalAlignment="Stretch"
HorizontalContentAlignment="Stretch"
VerticalContentAlignment="Stretch"
AllowFocusOnInteraction="False"
BorderThickness="0"
Click="ListView_Button_Click"
IsEnabled="{x:Bind IsEnabled, Mode=OneWay}">
<Grid Opacity="{x:Bind Opacity, Mode=OneWay}">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="26" />
<ColumnDefinition Width="*" />
<ColumnDefinition Width="Auto" />
</Grid.ColumnDefinitions>
<ToolTipService.ToolTip>
<TextBlock Text="{x:Bind ToolTip, Mode=OneWay}" />
</ToolTipService.ToolTip>
<FontIcon
Margin="0,0,0,0"
VerticalAlignment="Center"
AutomationProperties.AccessibilityView="Raw"
FontSize="16"
Glyph="{x:Bind IconGlyph, Mode=OneWay}" />
<TextBlock
Grid.Column="1"
VerticalAlignment="Center"
x:Phase="1"
Text="{x:Bind Name, Mode=OneWay}" />
<TextBlock
Grid.Column="2"
Margin="0,0,8,0"
HorizontalAlignment="Right"
VerticalAlignment="Center"
Foreground="{ThemeResource TextFillColorSecondaryBrush}"
Style="{StaticResource CaptionTextBlockStyle}"
Text="{x:Bind ShortcutText, Mode=OneWay}"
Visibility="{x:Bind ShortcutText.Length, Mode=OneWay, Converter={StaticResource countToVisibilityConverter}}" />
</Grid>
</Button>
</DataTemplate>
</Page.Resources>
<Page.KeyboardAccelerators>
@ -166,9 +186,9 @@
BorderThickness="0,1,0,0"
RowSpacing="4">
<Grid.RowDefinitions>
<RowDefinition Height="{x:Bind ViewModel.StandardPasteFormats.Count, Mode=OneWay, Converter={StaticResource standardPasteFormatsToHeightConverter}}" />
<RowDefinition Height="Auto" />
<RowDefinition Height="Auto" />
<RowDefinition Height="*" MinHeight="{x:Bind ViewModel.CustomActionPasteFormats.Count, Mode=OneWay, Converter={StaticResource customActionsCountToMinHeightConverter}}" />
<RowDefinition Height="*" MinHeight="{x:Bind ViewModel.CustomActionPasteFormats.Count, Mode=OneWay, Converter={StaticResource customActionsToMinHeightConverter}}" />
<RowDefinition Height="Auto" />
<RowDefinition Height="Auto" />
</Grid.RowDefinitions>
@ -176,12 +196,13 @@
x:Name="PasteOptionsListView"
Grid.Row="0"
VerticalAlignment="Bottom"
IsEnabled="{x:Bind ViewModel.IsClipboardDataText, Mode=OneWay}"
IsItemClickEnabled="True"
ItemClick="ListView_Click"
IsItemClickEnabled="False"
ItemContainerStyle="{StaticResource PasteFormatListViewItemStyle}"
ItemContainerTransitions="{x:Null}"
ItemTemplate="{StaticResource PasteFormatTemplate}"
ItemsSource="{x:Bind ViewModel.StandardPasteFormats, Mode=OneWay}"
ScrollViewer.VerticalScrollBarVisibility="Visible"
ScrollViewer.VerticalScrollMode="Auto"
SelectionMode="None"
TabIndex="1" />
@ -196,9 +217,8 @@
x:Name="CustomActionsListView"
Grid.Row="2"
VerticalAlignment="Top"
IsEnabled="{x:Bind ViewModel.IsCustomAIEnabled, Mode=OneWay}"
IsItemClickEnabled="True"
ItemClick="ListView_Click"
IsItemClickEnabled="False"
ItemContainerStyle="{StaticResource PasteFormatListViewItemStyle}"
ItemContainerTransitions="{x:Null}"
ItemTemplate="{StaticResource PasteFormatTemplate}"
ItemsSource="{x:Bind ViewModel.CustomActionPasteFormats, Mode=OneWay}"

View File

@ -130,15 +130,15 @@ namespace AdvancedPaste.Pages
}
}
private void ListView_Click(object sender, ItemClickEventArgs e)
private async void ListView_Button_Click(object sender, RoutedEventArgs e)
{
if (e.ClickedItem is PasteFormat format)
if (sender is Button { DataContext: PasteFormat format })
{
ViewModel.ExecutePasteFormat(format);
await ViewModel.ExecutePasteFormatAsync(format, PasteActionSource.ContextMenu);
}
}
private void KeyboardAccelerator_Invoked(Microsoft.UI.Xaml.Input.KeyboardAccelerator sender, Microsoft.UI.Xaml.Input.KeyboardAcceleratorInvokedEventArgs args)
private async void KeyboardAccelerator_Invoked(Microsoft.UI.Xaml.Input.KeyboardAccelerator sender, Microsoft.UI.Xaml.Input.KeyboardAcceleratorInvokedEventArgs args)
{
if (GetMainWindow()?.Visible is false)
{
@ -171,7 +171,7 @@ namespace AdvancedPaste.Pages
case VirtualKey.Number7:
case VirtualKey.Number8:
case VirtualKey.Number9:
ViewModel.ExecutePasteFormat(sender.Key);
await ViewModel.ExecutePasteFormatAsync(sender.Key);
break;
default:
@ -199,8 +199,7 @@ namespace AdvancedPaste.Pages
}
else if (item.Image is not null)
{
RandomAccessStreamReference image = null;
image = await item.Item.Content.GetBitmapAsync();
RandomAccessStreamReference image = await item.Item.Content.GetBitmapAsync();
ClipboardHelper.SetClipboardImageContent(image);
}
}

View File

@ -3,12 +3,16 @@
// See the LICENSE file in the project root for more information.
using System;
using System.Threading;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using AdvancedPaste.Models;
using ManagedCommon;
using Microsoft.UI.Xaml.Media.Imaging;
using Windows.ApplicationModel.DataTransfer;
using Windows.Data.Html;
using Windows.Graphics.Imaging;
using Windows.Storage;
using Windows.Storage.Streams;
using Windows.System;
@ -16,6 +20,34 @@ namespace AdvancedPaste.Helpers
{
internal static class ClipboardHelper
{
private static readonly HashSet<string> ImageFileTypes = new(StringComparer.InvariantCultureIgnoreCase) { ".png", ".jpg", ".jpeg", ".gif", ".bmp", ".tiff", ".ico", ".svg" };
private static readonly (string DataFormat, ClipboardFormat ClipboardFormat)[] DataFormats =
[
(StandardDataFormats.Text, ClipboardFormat.Text),
(StandardDataFormats.Html, ClipboardFormat.Html),
(StandardDataFormats.Bitmap, ClipboardFormat.Image),
];
internal static async Task<ClipboardFormat> GetAvailableClipboardFormatsAsync(DataPackageView clipboardData)
{
var availableClipboardFormats = DataFormats.Aggregate(
ClipboardFormat.None,
(result, formatPair) => clipboardData.Contains(formatPair.DataFormat) ? (result | formatPair.ClipboardFormat) : result);
if (clipboardData.Contains(StandardDataFormats.StorageItems))
{
var storageItems = await clipboardData.GetStorageItemsAsync();
if (storageItems.Count == 1 && storageItems.Single() is StorageFile file && ImageFileTypes.Contains(file.FileType))
{
availableClipboardFormats |= ClipboardFormat.ImageFile;
}
}
return availableClipboardFormats;
}
internal static void SetClipboardTextContent(string text)
{
Logger.LogTrace();
@ -26,31 +58,45 @@ namespace AdvancedPaste.Helpers
output.SetText(text);
Clipboard.SetContentWithOptions(output, null);
// TODO(stefan): For some reason Flush() fails from time to time when directly activated via hotkey.
// Calling inside a loop makes it work.
bool flushed = false;
for (int i = 0; i < 5; i++)
Flush();
}
}
private static bool Flush()
{
// TODO(stefan): For some reason Flush() fails from time to time when directly activated via hotkey.
// Calling inside a loop makes it work.
const int maxAttempts = 5;
for (int i = 1; i <= maxAttempts; i++)
{
try
{
if (flushed)
Task.Run(Clipboard.Flush).Wait();
return true;
}
catch (Exception ex)
{
if (i == maxAttempts)
{
break;
}
try
{
Task.Run(() =>
{
Clipboard.Flush();
}).Wait();
flushed = true;
}
catch (Exception ex)
{
Logger.LogError("Clipboard.Flush() failed", ex);
Logger.LogError($"{nameof(Clipboard)}.{nameof(Flush)}() failed", ex);
}
}
}
return false;
}
private static async Task<bool> FlushAsync() => await Task.Run(Flush);
internal static async Task SetClipboardFileContentAsync(string fileName)
{
var storageFile = await StorageFile.GetFileFromPathAsync(fileName);
DataPackage output = new();
output.SetStorageItems([storageFile]);
Clipboard.SetContent(output);
await FlushAsync();
}
internal static void SetClipboardImageContent(RandomAccessStreamReference image)
@ -63,30 +109,7 @@ namespace AdvancedPaste.Helpers
output.SetBitmap(image);
Clipboard.SetContentWithOptions(output, null);
// TODO(stefan): For some reason Flush() fails from time to time when directly activated via hotkey.
// Calling inside a loop makes it work.
bool flushed = false;
for (int i = 0; i < 5; i++)
{
if (flushed)
{
break;
}
try
{
Task.Run(() =>
{
Clipboard.Flush();
}).Wait();
flushed = true;
}
catch (Exception ex)
{
Logger.LogError("Clipboard.Flush() failed", ex);
}
}
Flush();
}
}
@ -136,5 +159,58 @@ namespace AdvancedPaste.Helpers
Logger.LogInfo("Paste sent");
}
internal static async Task<string> GetClipboardTextOrHtmlTextAsync(DataPackageView clipboardData)
{
if (clipboardData.Contains(StandardDataFormats.Text))
{
return await clipboardData.GetTextAsync();
}
else if (clipboardData.Contains(StandardDataFormats.Html))
{
var html = await clipboardData.GetHtmlFormatAsync();
return HtmlUtilities.ConvertToText(html);
}
else
{
return string.Empty;
}
}
internal static async Task<string> GetClipboardHtmlContentAsync(DataPackageView clipboardData) =>
clipboardData.Contains(StandardDataFormats.Html) ? await clipboardData.GetHtmlFormatAsync() : string.Empty;
internal static async Task<SoftwareBitmap> GetClipboardImageContentAsync(DataPackageView clipboardData)
{
using var stream = await GetClipboardImageStreamAsync(clipboardData);
if (stream != null)
{
var decoder = await BitmapDecoder.CreateAsync(stream);
return await decoder.GetSoftwareBitmapAsync();
}
return null;
}
private static async Task<IRandomAccessStream> GetClipboardImageStreamAsync(DataPackageView clipboardData)
{
if (clipboardData.Contains(StandardDataFormats.StorageItems))
{
var storageItems = await clipboardData.GetStorageItemsAsync();
var file = storageItems.Count == 1 ? storageItems[0] as StorageFile : null;
if (file != null)
{
return await file.OpenReadAsync();
}
}
if (clipboardData.Contains(StandardDataFormats.Bitmap))
{
var bitmap = await clipboardData.GetBitmapAsync();
return await bitmap.OpenReadAsync();
}
return null;
}
}
}

View File

@ -2,8 +2,10 @@
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
using System.Collections.ObjectModel;
using System;
using System.Collections.Generic;
using AdvancedPaste.Models;
using Microsoft.PowerToys.Settings.UI.Library;
namespace AdvancedPaste.Settings
@ -16,6 +18,10 @@ namespace AdvancedPaste.Settings
public bool CloseAfterLosingFocus { get; }
public ObservableCollection<AdvancedPasteCustomAction> CustomActions { get; }
public IReadOnlyList<AdvancedPasteCustomAction> CustomActions { get; }
public IReadOnlyList<PasteFormats> AdditionalActions { get; }
public event EventHandler Changed;
}
}

View File

@ -0,0 +1,39 @@
// Copyright (c) Microsoft Corporation
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
using System;
using System.Linq;
using System.Threading.Tasks;
using Windows.Globalization;
using Windows.Graphics.Imaging;
using Windows.Media.Ocr;
using Windows.System.UserProfile;
namespace AdvancedPaste.Helpers;
public static class OcrHelpers
{
public static async Task<string> ExtractTextAsync(SoftwareBitmap bitmap)
{
var ocrLanguage = GetOCRLanguage() ?? throw new InvalidOperationException("Unable to determine OCR language");
var ocrEngine = OcrEngine.TryCreateFromLanguage(ocrLanguage) ?? throw new InvalidOperationException("Unable to create OCR engine");
var ocrResult = await ocrEngine.RecognizeAsync(bitmap);
return ocrResult.Text;
}
private static Language GetOCRLanguage()
{
var userLanguageTags = GlobalizationPreferences.Languages.ToList();
var languages = from language in OcrEngine.AvailableRecognizerLanguages
let tag = language.LanguageTag
where userLanguageTags.Contains(tag)
orderby userLanguageTags.IndexOf(tag)
select language;
return languages.FirstOrDefault();
}
}

View File

@ -3,11 +3,13 @@
// See the LICENSE file in the project root for more information.
using System;
using System.Collections.ObjectModel;
using System.Collections.Generic;
using System.IO.Abstractions;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using AdvancedPaste.Models;
using ManagedCommon;
using Microsoft.PowerToys.Settings.UI.Library;
using Microsoft.PowerToys.Settings.UI.Library.Utilities;
@ -20,6 +22,8 @@ namespace AdvancedPaste.Settings
private readonly TaskScheduler _taskScheduler;
private readonly IFileSystemWatcher _watcher;
private readonly object _loadingSettingsLock = new();
private readonly List<PasteFormats> _additionalActions;
private readonly List<AdvancedPasteCustomAction> _customActions;
private const string AdvancedPasteModuleName = "AdvancedPaste";
private const int MaxNumberOfRetry = 5;
@ -27,13 +31,17 @@ namespace AdvancedPaste.Settings
private bool _disposedValue;
private CancellationTokenSource _cancellationTokenSource;
public event EventHandler Changed;
public bool ShowCustomPreview { get; private set; }
public bool SendPasteKeyCombination { get; private set; }
public bool CloseAfterLosingFocus { get; private set; }
public ObservableCollection<AdvancedPasteCustomAction> CustomActions { get; private set; }
public IReadOnlyList<PasteFormats> AdditionalActions => _additionalActions;
public IReadOnlyList<AdvancedPasteCustomAction> CustomActions => _customActions;
public UserSettings()
{
@ -42,8 +50,8 @@ namespace AdvancedPaste.Settings
ShowCustomPreview = true;
SendPasteKeyCombination = true;
CloseAfterLosingFocus = false;
CustomActions = [];
_additionalActions = [];
_customActions = [];
_taskScheduler = TaskScheduler.FromCurrentSynchronizationContext();
LoadSettingsFromJson();
@ -88,18 +96,29 @@ namespace AdvancedPaste.Settings
{
void UpdateSettings()
{
ShowCustomPreview = settings.Properties.ShowCustomPreview;
SendPasteKeyCombination = settings.Properties.SendPasteKeyCombination;
CloseAfterLosingFocus = settings.Properties.CloseAfterLosingFocus;
var properties = settings.Properties;
CustomActions.Clear();
foreach (var customAction in settings.Properties.CustomActions.Value)
{
if (customAction.IsShown && customAction.IsValid)
{
CustomActions.Add(customAction);
}
}
ShowCustomPreview = properties.ShowCustomPreview;
SendPasteKeyCombination = properties.SendPasteKeyCombination;
CloseAfterLosingFocus = properties.CloseAfterLosingFocus;
var sourceAdditionalActions = properties.AdditionalActions;
(PasteFormats Format, IAdvancedPasteAction[] Actions)[] additionalActionFormats =
[
(PasteFormats.ImageToText, [sourceAdditionalActions.ImageToText]),
(PasteFormats.PasteAsTxtFile, [sourceAdditionalActions.PasteAsFile, sourceAdditionalActions.PasteAsFile.PasteAsTxtFile]),
(PasteFormats.PasteAsPngFile, [sourceAdditionalActions.PasteAsFile, sourceAdditionalActions.PasteAsFile.PasteAsPngFile]),
(PasteFormats.PasteAsHtmlFile, [sourceAdditionalActions.PasteAsFile, sourceAdditionalActions.PasteAsFile.PasteAsHtmlFile])
];
_additionalActions.Clear();
_additionalActions.AddRange(additionalActionFormats.Where(tuple => tuple.Actions.All(action => action.IsShown))
.Select(tuple => tuple.Format));
_customActions.Clear();
_customActions.AddRange(properties.CustomActions.Value.Where(customAction => customAction.IsShown && customAction.IsValid));
Changed?.Invoke(this, EventArgs.Empty);
}
Task.Factory

View File

@ -0,0 +1,18 @@
// 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;
namespace AdvancedPaste.Models;
[Flags]
public enum ClipboardFormat
{
None,
Text = 1 << 0,
Html = 1 << 1,
Audio = 1 << 2,
Image = 1 << 3,
ImageFile = 1 << 4,
}

View File

@ -5,14 +5,13 @@
using Microsoft.UI.Xaml.Media.Imaging;
using Windows.ApplicationModel.DataTransfer;
namespace AdvancedPaste.Models
namespace AdvancedPaste.Models;
public class ClipboardItem
{
public class ClipboardItem
{
public string Content { get; set; }
public string Content { get; set; }
public ClipboardHistoryItem Item { get; set; }
public ClipboardHistoryItem Item { get; set; }
public BitmapImage Image { get; set; }
}
public BitmapImage Image { get; set; }
}

View File

@ -6,9 +6,9 @@ using System;
namespace AdvancedPaste.Models;
public sealed class CustomActionActivatedEventArgs(string text, bool forcePasteCustom) : EventArgs
public sealed class CustomActionActivatedEventArgs(string text, bool pasteResult) : EventArgs
{
public string Text { get; private set; } = text;
public string Text { get; private init; } = text;
public bool ForcePasteCustom { get; private set; } = forcePasteCustom;
public bool PasteResult { get; private init; } = pasteResult;
}

View File

@ -0,0 +1,11 @@
// 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;
namespace AdvancedPaste.Models;
public sealed class PasteActionException(string message) : Exception(message)
{
}

View File

@ -0,0 +1,13 @@
// Copyright (c) Microsoft Corporation
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
namespace AdvancedPaste.Models;
public enum PasteActionSource
{
ContextMenu,
InAppKeyboardShortcut,
GlobalKeyboardShortcut,
PromptBox,
}

View File

@ -2,38 +2,62 @@
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
using CommunityToolkit.Mvvm.ComponentModel;
using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.Linq;
using System.Reflection;
using Microsoft.PowerToys.Settings.UI.Library;
namespace AdvancedPaste.Models;
public partial class PasteFormat : ObservableObject
[DebuggerDisplay("{Name} IsEnabled={IsEnabled} ShortcutText={ShortcutText}")]
public sealed class PasteFormat
{
[ObservableProperty]
private string _shortcutText = string.Empty;
public static readonly IReadOnlyDictionary<PasteFormats, PasteFormatMetadataAttribute> MetadataDict =
typeof(PasteFormats).GetFields()
.Where(field => field.IsLiteral)
.ToDictionary(field => (PasteFormats)field.GetRawConstantValue(), field => field.GetCustomAttribute<PasteFormatMetadataAttribute>());
[ObservableProperty]
private string _toolTip = string.Empty;
public PasteFormat()
private PasteFormat(PasteFormats format, ClipboardFormat clipboardFormats, bool isAIServiceEnabled)
{
Format = format;
IsEnabled = SupportsClipboardFormats(clipboardFormats) && (isAIServiceEnabled || !Metadata.RequiresAIService);
}
public PasteFormat(AdvancedPasteCustomAction customAction, string shortcutText)
public PasteFormat(PasteFormats format, ClipboardFormat clipboardFormats, bool isAIServiceEnabled, Func<string, string> resourceLoader)
: this(format, clipboardFormats, isAIServiceEnabled)
{
Name = Metadata.ResourceId == null ? string.Empty : resourceLoader(Metadata.ResourceId);
Prompt = string.Empty;
}
public PasteFormat(AdvancedPasteCustomAction customAction, ClipboardFormat clipboardFormats, bool isAIServiceEnabled)
: this(PasteFormats.Custom, clipboardFormats, isAIServiceEnabled)
{
IconGlyph = "\uE945";
Name = customAction.Name;
Prompt = customAction.Prompt;
Format = PasteFormats.Custom;
ShortcutText = shortcutText;
ToolTip = customAction.Prompt;
}
public string IconGlyph { get; init; }
public PasteFormatMetadataAttribute Metadata => MetadataDict[Format];
public string Name { get; init; }
public string IconGlyph => Metadata.IconGlyph;
public PasteFormats Format { get; init; }
public string Name { get; private init; }
public string Prompt { get; init; } = string.Empty;
public PasteFormats Format { get; private init; }
public string Prompt { get; private init; }
public bool IsEnabled { get; private init; }
public double Opacity => IsEnabled ? 1 : 0.5;
public string ToolTip => string.IsNullOrEmpty(Prompt) ? $"{Name} ({ShortcutText})" : Prompt;
public string Query => string.IsNullOrEmpty(Prompt) ? Name : Prompt;
public string ShortcutText { get; set; } = string.Empty;
public bool SupportsClipboardFormats(ClipboardFormat clipboardFormats) => (clipboardFormats & Metadata.SupportedClipboardFormats) != ClipboardFormat.None;
}

View File

@ -0,0 +1,23 @@
// 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;
namespace AdvancedPaste.Models;
[AttributeUsage(AttributeTargets.Field)]
public sealed class PasteFormatMetadataAttribute : Attribute
{
public bool IsCoreAction { get; init; }
public string ResourceId { get; init; }
public string IconGlyph { get; init; }
public bool RequiresAIService { get; init; }
public ClipboardFormat SupportedClipboardFormats { get; init; }
public string IPCKey { get; init; }
}

View File

@ -2,13 +2,33 @@
// 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.Models
using Microsoft.PowerToys.Settings.UI.Library;
namespace AdvancedPaste.Models;
public enum PasteFormats
{
public enum PasteFormats
{
PlainText,
Markdown,
Json,
Custom,
}
[PasteFormatMetadata(IsCoreAction = true, ResourceId = "PasteAsPlainText", IconGlyph = "\uE8E9", RequiresAIService = false, SupportedClipboardFormats = ClipboardFormat.Text)]
PlainText,
[PasteFormatMetadata(IsCoreAction = true, ResourceId = "PasteAsMarkdown", IconGlyph = "\ue8a5", RequiresAIService = false, SupportedClipboardFormats = ClipboardFormat.Text)]
Markdown,
[PasteFormatMetadata(IsCoreAction = true, ResourceId = "PasteAsJson", IconGlyph = "\uE943", RequiresAIService = false, SupportedClipboardFormats = ClipboardFormat.Text)]
Json,
[PasteFormatMetadata(IsCoreAction = false, ResourceId = "ImageToText", IconGlyph = "\uE91B", RequiresAIService = false, SupportedClipboardFormats = ClipboardFormat.Image | ClipboardFormat.ImageFile, IPCKey = AdvancedPasteAdditionalActions.PropertyNames.ImageToText)]
ImageToText,
[PasteFormatMetadata(IsCoreAction = false, ResourceId = "PasteAsTxtFile", IconGlyph = "\uE8D2", RequiresAIService = false, SupportedClipboardFormats = ClipboardFormat.Text | ClipboardFormat.Html, IPCKey = AdvancedPastePasteAsFileAction.PropertyNames.PasteAsTxtFile)]
PasteAsTxtFile,
[PasteFormatMetadata(IsCoreAction = false, ResourceId = "PasteAsPngFile", IconGlyph = "\uE8B9", RequiresAIService = false, SupportedClipboardFormats = ClipboardFormat.Image | ClipboardFormat.ImageFile, IPCKey = AdvancedPastePasteAsFileAction.PropertyNames.PasteAsPngFile)]
PasteAsPngFile,
[PasteFormatMetadata(IsCoreAction = false, ResourceId = "PasteAsHtmlFile", IconGlyph = "\uF6FA", RequiresAIService = false, SupportedClipboardFormats = ClipboardFormat.Html, IPCKey = AdvancedPastePasteAsFileAction.PropertyNames.PasteAsHtmlFile)]
PasteAsHtmlFile,
[PasteFormatMetadata(IsCoreAction = false, IconGlyph = "\uE945", RequiresAIService = true, SupportedClipboardFormats = ClipboardFormat.Text)]
Custom,
}

View File

@ -0,0 +1,13 @@
// Copyright (c) Microsoft Corporation
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
using System.Threading.Tasks;
using AdvancedPaste.Models;
namespace AdvancedPaste.Services;
public interface IPasteFormatExecutor
{
Task<string> ExecutePasteFormatAsync(PasteFormat pasteFormat, PasteActionSource source);
}

View File

@ -0,0 +1,253 @@
// Copyright (c) Microsoft Corporation
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
using System;
using System.Globalization;
using System.IO;
using System.Net;
using System.Threading.Tasks;
using AdvancedPaste.Helpers;
using AdvancedPaste.Models;
using ManagedCommon;
using Microsoft.PowerToys.Telemetry;
using Windows.ApplicationModel.DataTransfer;
using Windows.Graphics.Imaging;
using Windows.Storage.Streams;
namespace AdvancedPaste.Services;
public sealed class PasteFormatExecutor(AICompletionsHelper aiHelper) : IPasteFormatExecutor
{
private readonly AICompletionsHelper _aiHelper = aiHelper;
public async Task<string> ExecutePasteFormatAsync(PasteFormat pasteFormat, PasteActionSource source)
{
if (!pasteFormat.IsEnabled)
{
return null;
}
WriteTelemetry(pasteFormat.Format, source);
return await ExecutePasteFormatCoreAsync(pasteFormat, Clipboard.GetContent());
}
private async Task<string> ExecutePasteFormatCoreAsync(PasteFormat pasteFormat, DataPackageView clipboardData)
{
switch (pasteFormat.Format)
{
case PasteFormats.PlainText:
ToPlainText(clipboardData);
return null;
case PasteFormats.Markdown:
ToMarkdown(clipboardData);
return null;
case PasteFormats.Json:
ToJson(clipboardData);
return null;
case PasteFormats.ImageToText:
await ImageToTextAsync(clipboardData);
return null;
case PasteFormats.PasteAsTxtFile:
await ToTxtFileAsync(clipboardData);
return null;
case PasteFormats.PasteAsPngFile:
await ToPngFileAsync(clipboardData);
return null;
case PasteFormats.PasteAsHtmlFile:
await ToHtmlFileAsync(clipboardData);
return null;
case PasteFormats.Custom:
return await ToCustomAsync(pasteFormat.Prompt, clipboardData);
default:
throw new ArgumentException($"Unknown paste format {pasteFormat.Format}", nameof(pasteFormat));
}
}
private static void WriteTelemetry(PasteFormats format, PasteActionSource source)
{
switch (source)
{
case PasteActionSource.ContextMenu:
PowerToysTelemetry.Log.WriteEvent(new Telemetry.AdvancedPasteFormatClickedEvent(format));
break;
case PasteActionSource.InAppKeyboardShortcut:
PowerToysTelemetry.Log.WriteEvent(new Telemetry.AdvancedPasteInAppKeyboardShortcutEvent(format));
break;
case PasteActionSource.GlobalKeyboardShortcut:
case PasteActionSource.PromptBox:
break; // no telemetry yet for these sources
default:
throw new ArgumentOutOfRangeException(nameof(format));
}
}
private void ToPlainText(DataPackageView clipboardData)
{
Logger.LogTrace();
SetClipboardTextContent(MarkdownHelper.PasteAsPlainTextFromClipboard(clipboardData));
}
private void ToMarkdown(DataPackageView clipboardData)
{
Logger.LogTrace();
SetClipboardTextContent(MarkdownHelper.ToMarkdown(clipboardData));
}
private void ToJson(DataPackageView clipboardData)
{
Logger.LogTrace();
SetClipboardTextContent(JsonHelper.ToJsonFromXmlOrCsv(clipboardData));
}
private async Task ImageToTextAsync(DataPackageView clipboardData)
{
Logger.LogTrace();
var bitmap = await ClipboardHelper.GetClipboardImageContentAsync(clipboardData);
var text = await OcrHelpers.ExtractTextAsync(bitmap);
SetClipboardTextContent(text);
}
private async Task ToPngFileAsync(DataPackageView clipboardData)
{
Logger.LogTrace();
var clipboardBitmap = await ClipboardHelper.GetClipboardImageContentAsync(clipboardData);
using var pngStream = new InMemoryRandomAccessStream();
var encoder = await BitmapEncoder.CreateAsync(BitmapEncoder.PngEncoderId, pngStream);
encoder.SetSoftwareBitmap(clipboardBitmap);
await encoder.FlushAsync();
await SetClipboardFileContentAsync(pngStream.AsStreamForRead(), "png");
}
private async Task ToTxtFileAsync(DataPackageView clipboardData)
{
Logger.LogTrace();
var text = await ClipboardHelper.GetClipboardTextOrHtmlTextAsync(clipboardData);
await SetClipboardFileContentAsync(text, "txt");
}
private async Task ToHtmlFileAsync(DataPackageView clipboardData)
{
Logger.LogTrace();
var cfHtml = await ClipboardHelper.GetClipboardHtmlContentAsync(clipboardData);
var html = RemoveHtmlMetadata(cfHtml);
await SetClipboardFileContentAsync(html, "html");
}
/// <summary>
/// Removes leading CF_HTML metadata from HTML clipboard data.
/// See: https://learn.microsoft.com/en-us/windows/win32/dataxchg/html-clipboard-format
/// </summary>
private static string RemoveHtmlMetadata(string cfHtml)
{
int? GetIntTagValue(string tagName)
{
var tagNameWithColon = tagName + ":";
int tagStartPos = cfHtml.IndexOf(tagNameWithColon, StringComparison.InvariantCulture);
const int tagValueLength = 10;
return tagStartPos != -1 && int.TryParse(cfHtml.AsSpan(tagStartPos + tagNameWithColon.Length, tagValueLength), CultureInfo.InvariantCulture, out int result) ? result : null;
}
var startFragmentIndex = GetIntTagValue("StartFragment");
var endFragmentIndex = GetIntTagValue("EndFragment");
return (startFragmentIndex == null || endFragmentIndex == null) ? cfHtml : cfHtml[startFragmentIndex.Value..endFragmentIndex.Value];
}
private static async Task SetClipboardFileContentAsync(string data, string fileExtension)
{
if (string.IsNullOrEmpty(data))
{
throw new ArgumentException($"Empty value in {nameof(SetClipboardFileContentAsync)}", nameof(data));
}
var path = GetPasteAsFileTempFilePath(fileExtension);
await File.WriteAllTextAsync(path, data);
await ClipboardHelper.SetClipboardFileContentAsync(path);
}
private static async Task SetClipboardFileContentAsync(Stream stream, string fileExtension)
{
var path = GetPasteAsFileTempFilePath(fileExtension);
using var fileStream = File.Create(path);
await stream.CopyToAsync(fileStream);
await ClipboardHelper.SetClipboardFileContentAsync(path);
}
private static string GetPasteAsFileTempFilePath(string fileExtension)
{
var prefix = ResourceLoaderInstance.ResourceLoader.GetString("PasteAsFile_FilePrefix");
var timestamp = DateTime.Now.ToString("yyyyMMddHHmmss", CultureInfo.InvariantCulture);
return Path.Combine(Path.GetTempPath(), $"{prefix}{timestamp}.{fileExtension}");
}
private async Task<string> ToCustomAsync(string prompt, DataPackageView clipboardData)
{
Logger.LogTrace();
if (string.IsNullOrWhiteSpace(prompt))
{
return string.Empty;
}
if (!clipboardData.Contains(StandardDataFormats.Text))
{
Logger.LogWarning("Clipboard does not contain text data");
return string.Empty;
}
var currentClipboardText = await clipboardData.GetTextAsync();
if (string.IsNullOrWhiteSpace(currentClipboardText))
{
Logger.LogWarning("Clipboard has no usable text data");
return string.Empty;
}
var aiResponse = await Task.Run(() => _aiHelper.AIFormatString(prompt, currentClipboardText));
return aiResponse.ApiRequestStatus == (int)HttpStatusCode.OK
? aiResponse.Response
: throw new PasteActionException(TranslateErrorText(aiResponse.ApiRequestStatus));
}
private void SetClipboardTextContent(string content)
{
if (!string.IsNullOrEmpty(content))
{
ClipboardHelper.SetClipboardTextContent(content);
}
}
private static string TranslateErrorText(int apiRequestStatus) => (HttpStatusCode)apiRequestStatus switch
{
HttpStatusCode.TooManyRequests => ResourceLoaderInstance.ResourceLoader.GetString("OpenAIApiKeyTooManyRequests"),
HttpStatusCode.Unauthorized => ResourceLoaderInstance.ResourceLoader.GetString("OpenAIApiKeyUnauthorized"),
HttpStatusCode.OK => string.Empty,
_ => ResourceLoaderInstance.ResourceLoader.GetString("OpenAIApiKeyError") + apiRequestStatus.ToString(CultureInfo.InvariantCulture),
};
}

View File

@ -120,7 +120,10 @@
<data name="AIMistakeNote.Text" xml:space="preserve">
<value>AI can make mistakes.</value>
</data>
<data name="ClipboardDataTypeMismatchWarning" xml:space="preserve">
<data name="ClipboardEmptyWarning" xml:space="preserve">
<value>Clipboard does not contain any usable formats</value>
</data>
<data name="ClipboardDataNotTextWarning" xml:space="preserve">
<value>Clipboard data is not text</value>
</data>
<data name="OpenAINotConfigured" xml:space="preserve">
@ -135,6 +138,9 @@
<data name="OpenAIApiKeyError" xml:space="preserve">
<value>OpenAI request failed with status code: </value>
</data>
<data name="PasteError" xml:space="preserve">
<value>An error occurred during the paste operation</value>
</data>
<data name="ClipboardHistoryButton.Text" xml:space="preserve">
<value>Clipboard history</value>
</data>
@ -151,7 +157,7 @@
<value>Privacy</value>
</data>
<data name="LoadingText.Text" xml:space="preserve">
<value>Connecting to AI services and generating output..</value>
<value>Generating output...</value>
</data>
<data name="PasteAsJson" xml:space="preserve">
<value>Paste as JSON</value>
@ -162,6 +168,18 @@
<data name="PasteAsPlainText" xml:space="preserve">
<value>Paste as plain text</value>
</data>
<data name="ImageToText" xml:space="preserve">
<value>Image to text</value>
</data>
<data name="PasteAsTxtFile" xml:space="preserve">
<value>Paste as .txt file</value>
</data>
<data name="PasteAsPngFile" xml:space="preserve">
<value>Paste as .png file</value>
</data>
<data name="PasteAsHtmlFile" xml:space="preserve">
<value>Paste as .html file</value>
</data>
<data name="PasteButtonAutomation.[using:Microsoft.UI.Xaml.Automation]AutomationProperties.Name" xml:space="preserve">
<value>Paste</value>
</data>
@ -228,4 +246,7 @@
<data name="CtrlKey" xml:space="preserve">
<value>Ctrl</value>
</data>
<data name="PasteAsFile_FilePrefix" xml:space="preserve">
<value>PowerToys_Paste_</value>
</data>
</root>

View File

@ -3,21 +3,20 @@
// See the LICENSE file in the project root for more information.
using System;
using System.Collections.Generic;
using System.Collections.ObjectModel;
using System.Globalization;
using System.Linq;
using System.Net;
using System.Threading.Tasks;
using AdvancedPaste.Helpers;
using AdvancedPaste.Models;
using AdvancedPaste.Services;
using AdvancedPaste.Settings;
using Common.UI;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using ManagedCommon;
using Microsoft.PowerToys.Settings.UI.Library;
using Microsoft.PowerToys.Telemetry;
using Microsoft.UI.Xaml;
using Microsoft.Win32;
using Windows.ApplicationModel.DataTransfer;
@ -28,67 +27,66 @@ using DispatcherQueue = Microsoft.UI.Dispatching.DispatcherQueue;
namespace AdvancedPaste.ViewModels
{
public partial class OptionsViewModel : ObservableObject, IDisposable
public sealed partial class OptionsViewModel : ObservableObject, IDisposable
{
private readonly DispatcherQueue _dispatcherQueue = DispatcherQueue.GetForCurrentThread();
private readonly DispatcherTimer _clipboardTimer;
private readonly IUserSettings _userSettings;
private readonly AICompletionsHelper aiHelper;
private readonly App app = App.Current as App;
private readonly PasteFormat[] _allStandardPasteFormats;
private readonly IPasteFormatExecutor _pasteFormatExecutor;
private readonly AICompletionsHelper _aiHelper;
public DataPackageView ClipboardData { get; set; }
[ObservableProperty]
[NotifyPropertyChangedFor(nameof(InputTxtBoxPlaceholderText))]
[NotifyPropertyChangedFor(nameof(GeneralErrorText))]
[NotifyPropertyChangedFor(nameof(IsCustomAIEnabled))]
private bool _isClipboardDataText;
[NotifyPropertyChangedFor(nameof(ClipboardHasData))]
[NotifyPropertyChangedFor(nameof(InputTxtBoxPlaceholderText))]
[NotifyPropertyChangedFor(nameof(AIDisabledErrorText))]
private ClipboardFormat _availableClipboardFormats;
[ObservableProperty]
private bool _clipboardHistoryEnabled;
[ObservableProperty]
[NotifyPropertyChangedFor(nameof(InputTxtBoxPlaceholderText))]
[NotifyPropertyChangedFor(nameof(GeneralErrorText))]
[NotifyPropertyChangedFor(nameof(IsPasteWithAIEnabled))]
[NotifyPropertyChangedFor(nameof(AIDisabledErrorText))]
[NotifyPropertyChangedFor(nameof(IsAIServiceEnabled))]
[NotifyPropertyChangedFor(nameof(IsCustomAIEnabled))]
private bool _isAllowedByGPO;
[ObservableProperty]
[NotifyPropertyChangedFor(nameof(ApiErrorText))]
private int _apiRequestStatus;
private string _pasteOperationErrorText;
[ObservableProperty]
private string _query = string.Empty;
private bool _pasteFormatsDirty;
[ObservableProperty]
private bool _busy;
public ObservableCollection<PasteFormat> StandardPasteFormats { get; } = [];
public ObservableCollection<PasteFormat> CustomActionPasteFormats { get; } = [];
public bool IsPasteWithAIEnabled => IsAllowedByGPO && aiHelper.IsAIEnabled;
public bool IsAIServiceEnabled => IsAllowedByGPO && _aiHelper.IsAIEnabled;
public bool IsCustomAIEnabled => IsPasteWithAIEnabled && IsClipboardDataText;
public bool IsCustomAIEnabled => IsAIServiceEnabled && ClipboardHasText;
public bool ClipboardHasData => AvailableClipboardFormats != ClipboardFormat.None;
private bool ClipboardHasText => AvailableClipboardFormats.HasFlag(ClipboardFormat.Text);
private bool Visible => GetMainWindow()?.Visible is true;
public event EventHandler<CustomActionActivatedEventArgs> CustomActionActivated;
public OptionsViewModel(IUserSettings userSettings)
public OptionsViewModel(AICompletionsHelper aiHelper, IUserSettings userSettings, IPasteFormatExecutor pasteFormatExecutor)
{
aiHelper = new AICompletionsHelper();
_aiHelper = aiHelper;
_userSettings = userSettings;
_pasteFormatExecutor = pasteFormatExecutor;
ApiRequestStatus = (int)HttpStatusCode.OK;
_allStandardPasteFormats =
[
new PasteFormat { IconGlyph = "\uE8E9", Name = ResourceLoaderInstance.ResourceLoader.GetString("PasteAsPlainText"), Format = PasteFormats.PlainText },
new PasteFormat { IconGlyph = "\ue8a5", Name = ResourceLoaderInstance.ResourceLoader.GetString("PasteAsMarkdown"), Format = PasteFormats.Markdown },
new PasteFormat { IconGlyph = "\uE943", Name = ResourceLoaderInstance.ResourceLoader.GetString("PasteAsJson"), Format = PasteFormats.Json },
];
GeneratedResponses = new ObservableCollection<string>();
GeneratedResponses = [];
GeneratedResponses.CollectionChanged += (s, e) =>
{
OnPropertyChanged(nameof(HasMultipleResponses));
@ -96,28 +94,31 @@ namespace AdvancedPaste.ViewModels
};
ClipboardHistoryEnabled = IsClipboardHistoryEnabled();
ReadClipboard();
UpdateOpenAIKey();
_clipboardTimer = new() { Interval = TimeSpan.FromSeconds(1) };
_clipboardTimer.Tick += ClipboardTimer_Tick;
_clipboardTimer.Start();
RefreshPasteFormats();
_userSettings.CustomActions.CollectionChanged += (_, _) => EnqueueRefreshPasteFormats();
_userSettings.Changed += (_, _) => EnqueueRefreshPasteFormats();
PropertyChanged += (_, e) =>
{
if (e.PropertyName == nameof(Query) || e.PropertyName == nameof(IsPasteWithAIEnabled))
string[] dirtyingProperties = [nameof(Query), nameof(IsAIServiceEnabled), nameof(IsCustomAIEnabled), nameof(AvailableClipboardFormats)];
if (dirtyingProperties.Contains(e.PropertyName))
{
EnqueueRefreshPasteFormats();
}
};
}
private void ClipboardTimer_Tick(object sender, object e)
private static MainWindow GetMainWindow() => (App.Current as App)?.GetMainWindow();
private async void ClipboardTimer_Tick(object sender, object e)
{
if (app.GetMainWindow()?.Visible is true)
if (Visible)
{
ReadClipboard();
await ReadClipboardAsync();
UpdateAllowedByGPO();
}
}
@ -137,10 +138,12 @@ namespace AdvancedPaste.ViewModels
});
}
private PasteFormat CreatePasteFormat(PasteFormats format) => new(format, AvailableClipboardFormats, IsAIServiceEnabled, ResourceLoaderInstance.ResourceLoader.GetString);
private PasteFormat CreatePasteFormat(AdvancedPasteCustomAction customAction) => new(customAction, AvailableClipboardFormats, IsAIServiceEnabled);
private void RefreshPasteFormats()
{
bool Filter(string text) => text.Contains(Query, StringComparison.CurrentCultureIgnoreCase);
var ctrlString = ResourceLoaderInstance.ResourceLoader.GetString("CtrlKey");
int shortcutNum = 0;
@ -150,28 +153,33 @@ namespace AdvancedPaste.ViewModels
return shortcutNum <= 9 ? $"{ctrlString}+{shortcutNum}" : string.Empty;
}
StandardPasteFormats.Clear();
foreach (var format in _allStandardPasteFormats)
IEnumerable<PasteFormat> FilterAndSort(IEnumerable<PasteFormat> pasteFormats) =>
from pasteFormat in pasteFormats
let comparison = StringComparison.CurrentCultureIgnoreCase
where pasteFormat.Name.Contains(Query, comparison) || pasteFormat.Prompt.Contains(Query, comparison)
orderby pasteFormat.IsEnabled descending
select pasteFormat;
void UpdateFormats(ObservableCollection<PasteFormat> collection, IEnumerable<PasteFormat> pasteFormats)
{
if (Filter(format.Name))
collection.Clear();
foreach (var format in FilterAndSort(pasteFormats))
{
format.ShortcutText = GetNextShortcutText();
format.ToolTip = $"{format.Name} ({format.ShortcutText})";
StandardPasteFormats.Add(format);
if (format.IsEnabled)
{
format.ShortcutText = GetNextShortcutText();
}
collection.Add(format);
}
}
CustomActionPasteFormats.Clear();
if (IsPasteWithAIEnabled)
{
foreach (var customAction in _userSettings.CustomActions)
{
if (Filter(customAction.Name) || Filter(customAction.Prompt))
{
CustomActionPasteFormats.Add(new PasteFormat(customAction, GetNextShortcutText()));
}
}
}
UpdateFormats(StandardPasteFormats, Enum.GetValues<PasteFormats>()
.Where(format => PasteFormat.MetadataDict[format].IsCoreAction || _userSettings.AdditionalActions.Contains(format))
.Select(CreatePasteFormat));
UpdateFormats(CustomActionPasteFormats, IsAIServiceEnabled ? _userSettings.CustomActions.Select(CreatePasteFormat) : []);
}
public void Dispose()
@ -180,26 +188,34 @@ namespace AdvancedPaste.ViewModels
GC.SuppressFinalize(this);
}
public void ReadClipboard()
public async Task ReadClipboardAsync()
{
if (Busy)
{
return;
}
ClipboardData = Clipboard.GetContent();
IsClipboardDataText = ClipboardData.Contains(StandardDataFormats.Text);
AvailableClipboardFormats = await ClipboardHelper.GetAvailableClipboardFormatsAsync(ClipboardData);
}
public void OnShow()
public async Task OnShowAsync()
{
ReadClipboard();
PasteOperationErrorText = string.Empty;
Query = string.Empty;
await ReadClipboardAsync();
if (UpdateOpenAIKey())
{
app.GetMainWindow()?.StartLoading();
GetMainWindow()?.StartLoading();
_dispatcherQueue.TryEnqueue(() =>
{
app.GetMainWindow()?.FinishLoading(aiHelper.IsAIEnabled);
GetMainWindow()?.FinishLoading(_aiHelper.IsAIEnabled);
OnPropertyChanged(nameof(InputTxtBoxPlaceholderText));
OnPropertyChanged(nameof(GeneralErrorText));
OnPropertyChanged(nameof(IsPasteWithAIEnabled));
OnPropertyChanged(nameof(AIDisabledErrorText));
OnPropertyChanged(nameof(IsAIServiceEnabled));
OnPropertyChanged(nameof(IsCustomAIEnabled));
});
}
@ -209,7 +225,7 @@ namespace AdvancedPaste.ViewModels
}
// List to store generated responses
public ObservableCollection<string> GeneratedResponses { get; set; } = new ObservableCollection<string>();
public ObservableCollection<string> GeneratedResponses { get; set; } = [];
// Index to keep track of the current response
private int _currentResponseIndex;
@ -228,30 +244,20 @@ namespace AdvancedPaste.ViewModels
}
}
public bool HasMultipleResponses
{
get => GeneratedResponses.Count > 1;
}
public bool HasMultipleResponses => GeneratedResponses.Count > 1;
public string CurrentIndexDisplay => $"{CurrentResponseIndex + 1}/{GeneratedResponses.Count}";
public string InputTxtBoxPlaceholderText
=> ResourceLoaderInstance.ResourceLoader.GetString(ClipboardHasData ? "CustomFormatTextBox/PlaceholderText" : "ClipboardEmptyWarning");
public string AIDisabledErrorText
{
get
{
app.GetMainWindow().ClearInputText();
return IsClipboardDataText ? ResourceLoaderInstance.ResourceLoader.GetString("CustomFormatTextBox/PlaceholderText") : GeneralErrorText;
}
}
public string GeneralErrorText
{
get
{
if (!IsClipboardDataText)
if (!ClipboardHasText)
{
return ResourceLoaderInstance.ResourceLoader.GetString("ClipboardDataTypeMismatchWarning");
return ResourceLoaderInstance.ResourceLoader.GetString("ClipboardDataNotTextWarning");
}
if (!IsAllowedByGPO)
@ -259,7 +265,7 @@ namespace AdvancedPaste.ViewModels
return ResourceLoaderInstance.ResourceLoader.GetString("OpenAIGpoDisabled");
}
if (!aiHelper.IsAIEnabled)
if (!_aiHelper.IsAIEnabled)
{
return ResourceLoaderInstance.ResourceLoader.GetString("OpenAINotConfigured");
}
@ -270,17 +276,6 @@ namespace AdvancedPaste.ViewModels
}
}
public string ApiErrorText
{
get => (HttpStatusCode)ApiRequestStatus switch
{
HttpStatusCode.TooManyRequests => ResourceLoaderInstance.ResourceLoader.GetString("OpenAIApiKeyTooManyRequests"),
HttpStatusCode.Unauthorized => ResourceLoaderInstance.ResourceLoader.GetString("OpenAIApiKeyUnauthorized"),
HttpStatusCode.OK => string.Empty,
_ => ResourceLoaderInstance.ResourceLoader.GetString("OpenAIApiKeyError") + ApiRequestStatus.ToString(CultureInfo.InvariantCulture),
};
}
[ObservableProperty]
private string _customFormatResult;
@ -289,9 +284,17 @@ namespace AdvancedPaste.ViewModels
{
var text = GeneratedResponses.ElementAtOrDefault(CurrentResponseIndex);
if (text != null)
if (!string.IsNullOrEmpty(text))
{
PasteCustomFunction(text);
ClipboardHelper.SetClipboardTextContent(text);
HideWindow();
if (_userSettings.SendPasteKeyCombination)
{
ClipboardHelper.SendPasteKeyCombination();
}
Query = string.Empty;
}
}
@ -320,190 +323,120 @@ namespace AdvancedPaste.ViewModels
public void OpenSettings()
{
SettingsDeepLink.OpenSettings(SettingsDeepLink.SettingsWindow.AdvancedPaste, true);
(App.Current as App).GetMainWindow().Close();
GetMainWindow()?.Close();
}
private void SetClipboardContentAndHideWindow(string content)
internal async Task ExecutePasteFormatAsync(PasteFormats format, PasteActionSource source)
{
if (!string.IsNullOrEmpty(content))
{
ClipboardHelper.SetClipboardTextContent(content);
}
if (app.GetMainWindow() != null)
{
Windows.Win32.Foundation.HWND hwnd = (Windows.Win32.Foundation.HWND)app.GetMainWindow().GetWindowHandle();
Windows.Win32.PInvoke.ShowWindow(hwnd, Windows.Win32.UI.WindowsAndMessaging.SHOW_WINDOW_CMD.SW_HIDE);
}
await ReadClipboardAsync();
await ExecutePasteFormatAsync(CreatePasteFormat(format), source);
}
internal void ToPlainTextFunction()
internal async Task ExecutePasteFormatAsync(PasteFormat pasteFormat, PasteActionSource source)
{
try
{
Logger.LogTrace();
string outputString = MarkdownHelper.PasteAsPlainTextFromClipboard(ClipboardData);
SetClipboardContentAndHideWindow(outputString);
if (_userSettings.SendPasteKeyCombination)
{
ClipboardHelper.SendPasteKeyCombination();
}
}
catch
{
}
}
internal void ToMarkdownFunction(bool pasteAlways = false)
{
try
{
Logger.LogTrace();
string outputString = MarkdownHelper.ToMarkdown(ClipboardData);
SetClipboardContentAndHideWindow(outputString);
if (pasteAlways || _userSettings.SendPasteKeyCombination)
{
ClipboardHelper.SendPasteKeyCombination();
}
}
catch
{
}
}
internal void ToJsonFunction(bool pasteAlways = false)
{
try
{
Logger.LogTrace();
string jsonText = JsonHelper.ToJsonFromXmlOrCsv(ClipboardData);
SetClipboardContentAndHideWindow(jsonText);
if (pasteAlways || _userSettings.SendPasteKeyCombination)
{
ClipboardHelper.SendPasteKeyCombination();
}
}
catch
{
}
}
internal void ExecutePasteFormat(VirtualKey key)
{
var index = key - VirtualKey.Number1;
var pasteFormat = StandardPasteFormats.ElementAtOrDefault(index) ?? CustomActionPasteFormats.ElementAtOrDefault(index - StandardPasteFormats.Count);
if (pasteFormat != null)
{
ExecutePasteFormat(pasteFormat);
PowerToysTelemetry.Log.WriteEvent(new Telemetry.AdvancedPasteInAppKeyboardShortcutEvent(pasteFormat.Format));
}
}
internal void ExecutePasteFormat(PasteFormat pasteFormat)
{
if (!IsClipboardDataText || (pasteFormat.Format == PasteFormats.Custom && !IsCustomAIEnabled))
if (Busy)
{
Logger.LogWarning($"Execution of {pasteFormat.Format} from {source} suppressed as busy");
return;
}
switch (pasteFormat.Format)
if (!pasteFormat.IsEnabled)
{
case PasteFormats.PlainText:
ToPlainTextFunction();
break;
var resourceId = pasteFormat.SupportsClipboardFormats(AvailableClipboardFormats) ? "PasteError" : "ClipboardEmptyWarning";
PasteOperationErrorText = ResourceLoaderInstance.ResourceLoader.GetString(resourceId);
return;
}
case PasteFormats.Markdown:
ToMarkdownFunction();
break;
Busy = true;
PasteOperationErrorText = string.Empty;
Query = pasteFormat.Query;
case PasteFormats.Json:
ToJsonFunction();
break;
if (pasteFormat.Format == PasteFormats.Custom)
{
SaveQuery(Query);
}
case PasteFormats.Custom:
Query = pasteFormat.Prompt;
CustomActionActivated?.Invoke(this, new CustomActionActivatedEventArgs(pasteFormat.Prompt, false));
break;
try
{
// Minimum time to show busy spinner for AI actions when triggered by global keyboard shortcut.
var aiActionMinTaskTime = TimeSpan.FromSeconds(2);
var delayTask = (Visible && source == PasteActionSource.GlobalKeyboardShortcut) ? Task.Delay(aiActionMinTaskTime) : Task.CompletedTask;
var aiOutput = await _pasteFormatExecutor.ExecutePasteFormatAsync(pasteFormat, source);
await delayTask;
if (pasteFormat.Format != PasteFormats.Custom)
{
HideWindow();
if (source == PasteActionSource.GlobalKeyboardShortcut || _userSettings.SendPasteKeyCombination)
{
ClipboardHelper.SendPasteKeyCombination();
}
}
else
{
var pasteResult = source == PasteActionSource.GlobalKeyboardShortcut || !_userSettings.ShowCustomPreview;
GeneratedResponses.Add(aiOutput);
CurrentResponseIndex = GeneratedResponses.Count - 1;
CustomActionActivated?.Invoke(this, new CustomActionActivatedEventArgs(pasteFormat.Prompt, pasteResult));
if (pasteResult)
{
PasteCustom();
}
}
}
catch (Exception ex)
{
Logger.LogError("Error executing paste format", ex);
PasteOperationErrorText = ex is PasteActionException ? ex.Message : ResourceLoaderInstance.ResourceLoader.GetString("PasteError");
}
Busy = false;
}
internal async Task ExecutePasteFormatAsync(VirtualKey key)
{
var pasteFormat = StandardPasteFormats.Concat(CustomActionPasteFormats)
.Where(pasteFormat => pasteFormat.IsEnabled)
.ElementAtOrDefault(key - VirtualKey.Number1);
if (pasteFormat != null)
{
await ExecutePasteFormatAsync(pasteFormat, PasteActionSource.InAppKeyboardShortcut);
}
}
internal void ExecuteCustomActionWithPaste(int customActionId)
internal async Task ExecuteCustomActionAsync(int customActionId, PasteActionSource source)
{
Logger.LogTrace();
await ReadClipboardAsync();
var customAction = _userSettings.CustomActions.FirstOrDefault(customAction => customAction.Id == customActionId);
if (customAction != null)
{
Query = customAction.Prompt;
CustomActionActivated?.Invoke(this, new CustomActionActivatedEventArgs(customAction.Prompt, true));
await ExecutePasteFormatAsync(CreatePasteFormat(customAction), source);
}
}
internal async Task<string> GenerateCustomFunction(string inputInstructions)
internal async Task GenerateCustomFunctionAsync(PasteActionSource triggerSource)
{
Logger.LogTrace();
if (string.IsNullOrWhiteSpace(inputInstructions) || !IsCustomAIEnabled)
{
return string.Empty;
}
if (!IsClipboardDataText)
{
Logger.LogWarning("Clipboard does not contain text data");
return string.Empty;
}
string currentClipboardText = await Task.Run(async () =>
{
try
{
string text = await ClipboardData.GetTextAsync() as string;
return text;
}
catch (Exception)
{
// Couldn't get text from the clipboard. Resume with empty text.
return string.Empty;
}
});
if (string.IsNullOrWhiteSpace(currentClipboardText))
{
Logger.LogWarning("Clipboard has no usable text data");
return string.Empty;
}
var aiResponse = await Task.Run(() => aiHelper.AIFormatString(inputInstructions, currentClipboardText));
string aiOutput = aiResponse.Response;
ApiRequestStatus = aiResponse.ApiRequestStatus;
GeneratedResponses.Add(aiOutput);
CurrentResponseIndex = GeneratedResponses.Count - 1;
return aiOutput;
AdvancedPasteCustomAction customAction = new() { Name = "Default", Prompt = Query };
await ExecutePasteFormatAsync(CreatePasteFormat(customAction), triggerSource);
}
internal void PasteCustomFunction(string text)
private void HideWindow()
{
Logger.LogTrace();
var mainWindow = GetMainWindow();
SetClipboardContentAndHideWindow(text);
if (_userSettings.SendPasteKeyCombination)
if (mainWindow != null)
{
ClipboardHelper.SendPasteKeyCombination();
Windows.Win32.Foundation.HWND hwnd = (Windows.Win32.Foundation.HWND)mainWindow.GetWindowHandle();
Windows.Win32.PInvoke.ShowWindow(hwnd, Windows.Win32.UI.WindowsAndMessaging.SHOW_WINDOW_CMD.SW_HIDE);
}
}
@ -524,11 +457,7 @@ namespace AdvancedPaste.ViewModels
return;
}
string currentClipboardText = Task.Run(async () =>
{
string text = await clipboardData.GetTextAsync() as string;
return text;
}).Result;
var currentClipboardText = Task.Run(async () => await clipboardData.GetTextAsync()).Result;
var queryData = new CustomQuery
{
@ -536,13 +465,13 @@ namespace AdvancedPaste.ViewModels
ClipboardData = currentClipboardText,
};
SettingsUtils utils = new SettingsUtils();
SettingsUtils utils = new();
utils.SaveSettings(queryData.ToString(), Constants.AdvancedPasteModuleName, Constants.LastQueryJsonFileName);
}
internal CustomQuery LoadPreviousQuery()
{
SettingsUtils utils = new SettingsUtils();
SettingsUtils utils = new();
var query = utils.GetSettings<CustomQuery>(Constants.AdvancedPasteModuleName, Constants.LastQueryJsonFileName);
return query;
}
@ -572,9 +501,9 @@ namespace AdvancedPaste.ViewModels
if (IsAllowedByGPO)
{
var oldKey = aiHelper.GetKey();
var oldKey = _aiHelper.GetKey();
var newKey = AICompletionsHelper.LoadOpenAIKey();
aiHelper.SetOpenAIKey(newKey);
_aiHelper.SetOpenAIKey(newKey);
return newKey != oldKey;
}

View File

@ -42,6 +42,7 @@ namespace
{
const wchar_t JSON_KEY_PROPERTIES[] = L"properties";
const wchar_t JSON_KEY_CUSTOM_ACTIONS[] = L"custom-actions";
const wchar_t JSON_KEY_ADDITIONAL_ACTIONS[] = L"additional-actions";
const wchar_t JSON_KEY_SHORTCUT[] = L"shortcut";
const wchar_t JSON_KEY_IS_SHOWN[] = L"isShown";
const wchar_t JSON_KEY_ID[] = L"id";
@ -73,7 +74,6 @@ private:
HANDLE m_hProcess;
std::thread create_pipe_thread;
std::unique_ptr<CAtlFile> m_write_pipe;
// Time to wait for process to close after sending WM_CLOSE signal
@ -86,8 +86,18 @@ private:
Hotkey m_paste_as_markdown_hotkey{};
Hotkey m_paste_as_json_hotkey{};
std::vector<Hotkey> m_custom_action_hotkeys;
std::vector<int> m_custom_action_ids;
template<class Id>
struct ActionData
{
Id id;
Hotkey hotkey;
};
using AdditionalAction = ActionData<std::wstring>;
std::vector<AdditionalAction> m_additional_actions;
using CustomAction = ActionData<int>;
std::vector<CustomAction> m_custom_actions;
bool m_preview_custom_format_output = true;
@ -166,6 +176,34 @@ private:
open_ai_key_exists();
}
static std::wstring kebab_to_pascal_case(const std::wstring& kebab_str)
{
std::wstring result;
bool capitalize_next = true;
for (const auto ch : kebab_str)
{
if (ch == L'-')
{
capitalize_next = true;
}
else
{
if (capitalize_next)
{
result += std::towupper(ch);
capitalize_next = false;
}
else
{
result += ch;
}
}
}
return result;
}
bool migrate_data_and_remove_data_file(Hotkey& old_paste_as_plain_hotkey)
{
const wchar_t OLD_JSON_KEY_ACTIVATION_SHORTCUT[] = L"ActivationShortcut";
@ -197,6 +235,39 @@ private:
return false;
}
void process_additional_action(const winrt::hstring& actionName, const winrt::Windows::Data::Json::IJsonValue& actionValue)
{
if (actionValue.ValueType() != winrt::Windows::Data::Json::JsonValueType::Object)
{
return;
}
const auto action = actionValue.GetObjectW();
if (!action.GetNamedBoolean(JSON_KEY_IS_SHOWN, false))
{
return;
}
if (action.HasKey(JSON_KEY_SHORTCUT))
{
const AdditionalAction additionalAction
{
actionName.c_str(),
parse_single_hotkey(action.GetNamedObject(JSON_KEY_SHORTCUT))
};
m_additional_actions.push_back(additionalAction);
}
else
{
for (const auto& [subActionName, subAction] : action)
{
process_additional_action(subActionName, subAction);
}
}
}
void parse_hotkeys(PowerToysSettings::PowerToyValues& settings)
{
auto settingsObject = settings.get_raw_json();
@ -239,13 +310,23 @@ private:
*hotkey = parse_single_hotkey(keyName, settingsObject);
}
m_custom_action_hotkeys.clear();
m_custom_action_ids.clear();
m_additional_actions.clear();
m_custom_actions.clear();
if (settingsObject.HasKey(JSON_KEY_PROPERTIES))
{
const auto propertiesObject = settingsObject.GetNamedObject(JSON_KEY_PROPERTIES);
if (propertiesObject.HasKey(JSON_KEY_ADDITIONAL_ACTIONS))
{
const auto additionalActions = propertiesObject.GetNamedObject(JSON_KEY_ADDITIONAL_ACTIONS);
for (const auto& [actionName, additionalAction] : additionalActions)
{
process_additional_action(actionName, additionalAction);
}
}
if (propertiesObject.HasKey(JSON_KEY_CUSTOM_ACTIONS))
{
const auto customActions = propertiesObject.GetNamedObject(JSON_KEY_CUSTOM_ACTIONS).GetNamedArray(JSON_KEY_VALUE);
@ -257,8 +338,13 @@ private:
if (object.GetNamedBoolean(JSON_KEY_IS_SHOWN, false))
{
m_custom_action_hotkeys.push_back(parse_single_hotkey(object.GetNamedObject(JSON_KEY_SHORTCUT)));
m_custom_action_ids.push_back(static_cast<int>(object.GetNamedNumber(JSON_KEY_ID)));
const CustomAction customActionData
{
static_cast<int>(object.GetNamedNumber(JSON_KEY_ID)),
parse_single_hotkey(object.GetNamedObject(JSON_KEY_SHORTCUT))
};
m_custom_actions.push_back(customActionData);
}
}
}
@ -331,7 +417,7 @@ private:
return;
}
create_pipe_thread = std::thread([&] { start_named_pipe_server(pipe_name.value()); });
std::thread create_pipe_thread ([&]{ start_named_pipe_server(pipe_name.value()); });
launch_process(pipe_name.value());
create_pipe_thread.join();
}
@ -730,12 +816,19 @@ public:
m_preview_custom_format_output = settingsObject.GetNamedObject(JSON_KEY_PROPERTIES).GetNamedObject(JSON_KEY_SHOW_CUSTOM_PREVIEW).GetNamedBoolean(JSON_KEY_VALUE);
}
std::unordered_map<std::wstring, Hotkey> additionalActionMap;
for (const auto& action : m_additional_actions)
{
additionalActionMap[kebab_to_pascal_case(action.id)] = action.hotkey;
}
// order of args matter
Trace::AdvancedPaste_SettingsTelemetry(m_paste_as_plain_hotkey,
m_advanced_paste_ui_hotkey,
m_paste_as_markdown_hotkey,
m_paste_as_json_hotkey,
m_preview_custom_format_output);
m_preview_custom_format_output,
additionalActionMap);
// If you don't need to do any custom processing of the settings, proceed
// to persists the values calling:
@ -825,11 +918,24 @@ public:
return true;
}
const auto custom_action_index = hotkeyId - NUM_DEFAULT_HOTKEYS;
if (custom_action_index < m_custom_action_ids.size())
const auto additional_action_index = hotkeyId - NUM_DEFAULT_HOTKEYS;
if (additional_action_index < m_additional_actions.size())
{
const auto id = m_custom_action_ids.at(custom_action_index);
const auto& id = m_additional_actions.at(additional_action_index).id;
Logger::trace(L"Starting additional action id={}", id);
Trace::AdvancedPaste_Invoked(std::format(L"{}Direct", kebab_to_pascal_case(id)));
send_named_pipe_message(CommonSharedConstants::ADVANCED_PASTE_ADDITIONAL_ACTION_MESSAGE, id);
return true;
}
const auto custom_action_index = additional_action_index - m_additional_actions.size();
if (custom_action_index < m_custom_actions.size())
{
const auto id = m_custom_actions.at(custom_action_index).id;
Logger::trace(L"Starting custom action id={}", id);
@ -844,7 +950,7 @@ public:
virtual size_t get_hotkeys(Hotkey* hotkeys, size_t buffer_size) override
{
const size_t num_hotkeys = NUM_DEFAULT_HOTKEYS + m_custom_action_hotkeys.size();
const size_t num_hotkeys = NUM_DEFAULT_HOTKEYS + m_additional_actions.size() + m_custom_actions.size();
if (hotkeys && buffer_size >= num_hotkeys)
{
@ -852,9 +958,11 @@ public:
m_advanced_paste_ui_hotkey,
m_paste_as_markdown_hotkey,
m_paste_as_json_hotkey };
std::copy(default_hotkeys.begin(), default_hotkeys.end(), hotkeys);
std::copy(m_custom_action_hotkeys.begin(), m_custom_action_hotkeys.end(), hotkeys + NUM_DEFAULT_HOTKEYS);
const auto get_action_hotkey = [](const auto& action) { return action.hotkey; };
std::transform(m_additional_actions.begin(), m_additional_actions.end(), hotkeys + NUM_DEFAULT_HOTKEYS, get_action_hotkey);
std::transform(m_custom_actions.begin(), m_custom_actions.end(), hotkeys + NUM_DEFAULT_HOTKEYS + m_additional_actions.size(), get_action_hotkey);
}
return num_hotkeys;

View File

@ -58,45 +58,44 @@ void Trace::AdvancedPaste_SettingsTelemetry(const PowertoyModuleIface::Hotkey& p
const PowertoyModuleIface::Hotkey& advancedPasteUIHotkey,
const PowertoyModuleIface::Hotkey& pasteMarkdownHotkey,
const PowertoyModuleIface::Hotkey& pasteJsonHotkey,
const bool preview_custom_format_output) noexcept
const bool preview_custom_format_output,
const std::unordered_map<std::wstring, PowertoyModuleIface::Hotkey>& additionalActionsHotkeys) noexcept
{
std::wstring pastePlainHotkeyStr =
std::wstring(pastePlainHotkey.win ? L"Win + " : L"") +
std::wstring(pastePlainHotkey.ctrl ? L"Ctrl + " : L"") +
std::wstring(pastePlainHotkey.shift ? L"Shift + " : L"") +
std::wstring(pastePlainHotkey.alt ? L"Alt + " : L"") +
std::wstring(L"VK ") + std::to_wstring(pastePlainHotkey.key);
const auto getHotKeyStr = [](const PowertoyModuleIface::Hotkey& hotKey)
{
return std::wstring(hotKey.win ? L"Win + " : L"") +
std::wstring(hotKey.ctrl ? L"Ctrl + " : L"") +
std::wstring(hotKey.shift ? L"Shift + " : L"") +
std::wstring(hotKey.alt ? L"Alt + " : L"") +
std::wstring(L"VK ") + std::to_wstring(hotKey.key);
};
std::wstring advancedPasteUIHotkeyStr =
std::wstring(advancedPasteUIHotkey.win ? L"Win + " : L"") +
std::wstring(advancedPasteUIHotkey.ctrl ? L"Ctrl + " : L"") +
std::wstring(advancedPasteUIHotkey.shift ? L"Shift + " : L"") +
std::wstring(advancedPasteUIHotkey.alt ? L"Alt + " : L"") +
std::wstring(L"VK ") + std::to_wstring(advancedPasteUIHotkey.key);
std::vector<std::wstring> hotkeyStrs;
const auto getHotkeyCStr = [&](const PowertoyModuleIface::Hotkey& hotkey)
{
hotkeyStrs.push_back(getHotKeyStr(hotkey)); // Probably unnecessary, but offers protection against the macro TraceLoggingWideString expanding to something that would invalidate the pointer
return hotkeyStrs.back().c_str();
};
std::wstring pasteMarkdownHotkeyStr =
std::wstring(pasteMarkdownHotkey.win ? L"Win + " : L"") +
std::wstring(pasteMarkdownHotkey.ctrl ? L"Ctrl + " : L"") +
std::wstring(pasteMarkdownHotkey.shift ? L"Shift + " : L"") +
std::wstring(pasteMarkdownHotkey.alt ? L"Alt + " : L"") +
std::wstring(L"VK ") + std::to_wstring(pasteMarkdownHotkey.key);
std::wstring pasteJsonHotkeyStr =
std::wstring(pasteJsonHotkey.win ? L"Win + " : L"") +
std::wstring(pasteJsonHotkey.ctrl ? L"Ctrl + " : L"") +
std::wstring(pasteJsonHotkey.shift ? L"Shift + " : L"") +
std::wstring(pasteJsonHotkey.alt ? L"Alt + " : L"") +
std::wstring(L"VK ") + std::to_wstring(pasteJsonHotkey.key);
const auto getAdditionalActionHotkeyCStr = [&](const std::wstring& name)
{
const auto it = additionalActionsHotkeys.find(name);
return it != additionalActionsHotkeys.end() ? getHotkeyCStr(it->second) : L"";
};
TraceLoggingWrite(
g_hProvider,
"AdvancedPaste_Settings",
ProjectTelemetryPrivacyDataTag(ProjectTelemetryTag_ProductAndServicePerformance),
TraceLoggingKeyword(PROJECT_KEYWORD_MEASURE),
TraceLoggingWideString(pastePlainHotkeyStr.c_str(), "PastePlainHotkey"),
TraceLoggingWideString(advancedPasteUIHotkeyStr.c_str(), "AdvancedPasteUIHotkey"),
TraceLoggingWideString(pasteMarkdownHotkeyStr.c_str(), "PasteMarkdownHotkey"),
TraceLoggingWideString(pasteJsonHotkeyStr.c_str(), "PasteJsonHotkey"),
TraceLoggingBoolean(preview_custom_format_output, "ShowCustomPreview")
TraceLoggingWideString(getHotkeyCStr(pastePlainHotkey), "PastePlainHotkey"),
TraceLoggingWideString(getHotkeyCStr(advancedPasteUIHotkey), "AdvancedPasteUIHotkey"),
TraceLoggingWideString(getHotkeyCStr(pasteMarkdownHotkey), "PasteMarkdownHotkey"),
TraceLoggingWideString(getHotkeyCStr(pasteJsonHotkey), "PasteJsonHotkey"),
TraceLoggingBoolean(preview_custom_format_output, "ShowCustomPreview"),
TraceLoggingWideString(getAdditionalActionHotkeyCStr(L"ImageToText"), "ImageToTextHotkey"),
TraceLoggingWideString(getAdditionalActionHotkeyCStr(L"PasteAsTxtFile"), "PasteAsTxtFileHotkey"),
TraceLoggingWideString(getAdditionalActionHotkeyCStr(L"PasteAsPngFile"), "PasteAsPngFileHotkey"),
TraceLoggingWideString(getAdditionalActionHotkeyCStr(L"PasteAsHtmlFile"), "PasteAsHtmlFileHotkey")
);
}

View File

@ -1,5 +1,6 @@
#pragma once
#include <interface/powertoy_module_interface.h>
#include <unordered_map>
class Trace
{
@ -21,5 +22,6 @@ public:
const PowertoyModuleIface::Hotkey& advancedPasteUIHotkey,
const PowertoyModuleIface::Hotkey& pasteMarkdownHotkey,
const PowertoyModuleIface::Hotkey& pasteJsonHotkey,
const bool preview_custom_format_output) noexcept;
const bool preview_custom_format_output,
const std::unordered_map<std::wstring, PowertoyModuleIface::Hotkey>& additionalActionsHotkeys) noexcept;
};

View File

@ -0,0 +1,39 @@
// 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;
using Microsoft.PowerToys.Settings.UI.Library.Helpers;
namespace Microsoft.PowerToys.Settings.UI.Library;
public sealed partial class AdvancedPasteAdditionalAction : Observable, IAdvancedPasteAction
{
private HotkeySettings _shortcut = new();
private bool _isShown = true;
[JsonPropertyName("shortcut")]
public HotkeySettings Shortcut
{
get => _shortcut;
set
{
if (_shortcut != value)
{
// We null-coalesce here rather than outside this branch as we want to raise PropertyChanged when the setter is called
// with null; the ShortcutControl depends on this.
_shortcut = value ?? new();
OnPropertyChanged();
}
}
}
[JsonPropertyName("isShown")]
public bool IsShown
{
get => _isShown;
set => Set(ref _isShown, value);
}
}

View File

@ -0,0 +1,27 @@
// Copyright (c) Microsoft Corporation
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
using System.Collections.Generic;
using System.Linq;
using System.Text.Json.Serialization;
namespace Microsoft.PowerToys.Settings.UI.Library;
public sealed class AdvancedPasteAdditionalActions
{
public static class PropertyNames
{
public const string ImageToText = "image-to-text";
public const string PasteAsFile = "paste-as-file";
}
[JsonPropertyName(PropertyNames.ImageToText)]
public AdvancedPasteAdditionalAction ImageToText { get; init; } = new();
[JsonPropertyName(PropertyNames.PasteAsFile)]
public AdvancedPastePasteAsFileAction PasteAsFile { get; init; } = new();
[JsonIgnore]
public IEnumerable<IAdvancedPasteAction> AllActions => new IAdvancedPasteAction[] { ImageToText, PasteAsFile }.Concat(PasteAsFile.SubActions);
}

View File

@ -3,14 +3,13 @@
// See the LICENSE file in the project root for more information.
using System;
using System.ComponentModel;
using System.Runtime.CompilerServices;
using System.Text.Json;
using System.Text.Json.Serialization;
using Microsoft.PowerToys.Settings.UI.Library.Helpers;
namespace Microsoft.PowerToys.Settings.UI.Library;
public sealed class AdvancedPasteCustomAction : INotifyPropertyChanged, ICloneable
public sealed class AdvancedPasteCustomAction : Observable, IAdvancedPasteAction, ICloneable
{
private int _id;
private string _name = string.Empty;
@ -25,14 +24,7 @@ public sealed class AdvancedPasteCustomAction : INotifyPropertyChanged, ICloneab
public int Id
{
get => _id;
set
{
if (_id != value)
{
_id = value;
OnPropertyChanged();
}
}
set => Set(ref _id, value);
}
[JsonPropertyName("name")]
@ -41,10 +33,8 @@ public sealed class AdvancedPasteCustomAction : INotifyPropertyChanged, ICloneab
get => _name;
set
{
if (_name != value)
if (Set(ref _name, value))
{
_name = value;
OnPropertyChanged();
UpdateIsValid();
}
}
@ -56,10 +46,8 @@ public sealed class AdvancedPasteCustomAction : INotifyPropertyChanged, ICloneab
get => _prompt;
set
{
if (_prompt != value)
if (Set(ref _prompt, value))
{
_prompt = value;
OnPropertyChanged();
UpdateIsValid();
}
}
@ -86,62 +74,30 @@ public sealed class AdvancedPasteCustomAction : INotifyPropertyChanged, ICloneab
public bool IsShown
{
get => _isShown;
set
{
if (_isShown != value)
{
_isShown = value;
OnPropertyChanged();
}
}
set => Set(ref _isShown, value);
}
[JsonIgnore]
public bool CanMoveUp
{
get => _canMoveUp;
set
{
if (_canMoveUp != value)
{
_canMoveUp = value;
OnPropertyChanged();
}
}
set => Set(ref _canMoveUp, value);
}
[JsonIgnore]
public bool CanMoveDown
{
get => _canMoveDown;
set
{
if (_canMoveDown != value)
{
_canMoveDown = value;
OnPropertyChanged();
}
}
set => Set(ref _canMoveDown, value);
}
[JsonIgnore]
public bool IsValid
{
get => _isValid;
private set
{
if (_isValid != value)
{
_isValid = value;
OnPropertyChanged();
}
}
private set => Set(ref _isValid, value);
}
public event PropertyChangedEventHandler PropertyChanged;
public string ToJsonString() => JsonSerializer.Serialize(this);
public object Clone()
{
AdvancedPasteCustomAction clone = new();
@ -160,11 +116,6 @@ public sealed class AdvancedPasteCustomAction : INotifyPropertyChanged, ICloneab
CanMoveDown = other.CanMoveDown;
}
private void OnPropertyChanged([CallerMemberName] string propertyName = null)
{
PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName));
}
private HotkeySettings GetShortcutClone()
{
object shortcut = null;

View File

@ -0,0 +1,56 @@
// 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;
using Microsoft.PowerToys.Settings.UI.Library.Helpers;
namespace Microsoft.PowerToys.Settings.UI.Library;
public sealed class AdvancedPastePasteAsFileAction : Observable, IAdvancedPasteAction
{
public static class PropertyNames
{
public const string PasteAsTxtFile = "paste-as-txt-file";
public const string PasteAsPngFile = "paste-as-png-file";
public const string PasteAsHtmlFile = "paste-as-html-file";
}
private AdvancedPasteAdditionalAction _pasteAsTxtFile = new();
private AdvancedPasteAdditionalAction _pasteAsPngFile = new();
private AdvancedPasteAdditionalAction _pasteAsHtmlFile = new();
private bool _isShown = true;
[JsonPropertyName("isShown")]
public bool IsShown
{
get => _isShown;
set => Set(ref _isShown, value);
}
[JsonPropertyName(PropertyNames.PasteAsTxtFile)]
public AdvancedPasteAdditionalAction PasteAsTxtFile
{
get => _pasteAsTxtFile;
init => Set(ref _pasteAsTxtFile, value);
}
[JsonPropertyName(PropertyNames.PasteAsPngFile)]
public AdvancedPasteAdditionalAction PasteAsPngFile
{
get => _pasteAsPngFile;
init => Set(ref _pasteAsPngFile, value);
}
[JsonPropertyName(PropertyNames.PasteAsHtmlFile)]
public AdvancedPasteAdditionalAction PasteAsHtmlFile
{
get => _pasteAsHtmlFile;
init => Set(ref _pasteAsHtmlFile, value);
}
[JsonIgnore]
public IEnumerable<AdvancedPasteAdditionalAction> SubActions => [PasteAsTxtFile, PasteAsPngFile, PasteAsHtmlFile];
}

View File

@ -22,6 +22,7 @@ namespace Microsoft.PowerToys.Settings.UI.Library
PasteAsMarkdownShortcut = new();
PasteAsJsonShortcut = new();
CustomActions = new();
AdditionalActions = new();
ShowCustomPreview = true;
SendPasteKeyCombination = true;
CloseAfterLosingFocus = false;
@ -51,7 +52,11 @@ namespace Microsoft.PowerToys.Settings.UI.Library
[JsonPropertyName("custom-actions")]
[CmdConfigureIgnoreAttribute]
public AdvancedPasteCustomActions CustomActions { get; set; }
public AdvancedPasteCustomActions CustomActions { get; init; }
[JsonPropertyName("additional-actions")]
[CmdConfigureIgnoreAttribute]
public AdvancedPasteAdditionalActions AdditionalActions { get; init; }
public override string ToString()
=> JsonSerializer.Serialize(this);

View File

@ -11,17 +11,19 @@ namespace Microsoft.PowerToys.Settings.UI.Library.Helpers
{
public event PropertyChangedEventHandler PropertyChanged;
protected void Set<T>(ref T storage, T value, [CallerMemberName] string propertyName = null)
protected bool Set<T>(ref T storage, T value, [CallerMemberName] string propertyName = null)
{
if (Equals(storage, value))
{
return;
return false;
}
storage = value;
OnPropertyChanged(propertyName);
return true;
}
protected void OnPropertyChanged(string propertyName) => PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName));
protected void OnPropertyChanged([CallerMemberName] string propertyName = null) => PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName));
}
}

View File

@ -0,0 +1,12 @@
// Copyright (c) Microsoft Corporation
// The Microsoft Corporation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
using System.ComponentModel;
namespace Microsoft.PowerToys.Settings.UI.Library;
public interface IAdvancedPasteAction : INotifyPropertyChanged
{
public bool IsShown { get; }
}

View File

@ -24,6 +24,18 @@
<ImageSource x:Key="DialogHeaderImage">ms-appx:///Assets/Settings/Modules/APDialog.light.png</ImageSource>
</ResourceDictionary>
</ResourceDictionary.ThemeDictionaries>
<DataTemplate x:Key="AdditionalActionTemplate" x:DataType="models:AdvancedPasteAdditionalAction">
<StackPanel Orientation="Horizontal" Spacing="4">
<controls:ShortcutControl
MinWidth="{StaticResource SettingActionControlMinWidth}"
AllowDisable="True"
HotkeySettings="{x:Bind Shortcut, Mode=TwoWay}" />
<ToggleSwitch
IsOn="{x:Bind IsShown, Mode=TwoWay, UpdateSourceTrigger=PropertyChanged}"
OffContent=""
OnContent="" />
</StackPanel>
</DataTemplate>
</ResourceDictionary>
</Page.Resources>
<Grid>
@ -129,6 +141,7 @@
AllowDisable="True"
HotkeySettings="{x:Bind Path=ViewModel.PasteAsJsonShortcut, Mode=TwoWay}" />
</tkcontrols:SettingsCard>
<ItemsControl
x:Name="CustomActions"
x:Uid="CustomActions"
@ -155,7 +168,7 @@
HotkeySettings="{x:Bind Path=Shortcut, Mode=TwoWay}" />
<ToggleSwitch
x:Uid="Enable_CustomAction"
AutomationProperties.HelpText="{x:Bind Name}"
AutomationProperties.HelpText="{x:Bind Name, Mode=OneWay}"
IsOn="{x:Bind IsShown, Mode=TwoWay, UpdateSourceTrigger=PropertyChanged}"
OffContent=""
OnContent="" />
@ -202,6 +215,56 @@
IsTabStop="{x:Bind ViewModel.IsConflictingCopyShortcut, Mode=OneWay}"
Severity="Warning" />
</controls:SettingsGroup>
<controls:SettingsGroup x:Uid="AdvancedPaste_Additional_Actions_GroupSettings" IsEnabled="{x:Bind ViewModel.IsEnabled, Mode=OneWay}">
<tkcontrols:SettingsCard x:Uid="ImageToText" DataContext="{x:Bind ViewModel.AdditionalActions.ImageToText, Mode=OneWay}">
<ContentControl ContentTemplate="{StaticResource AdditionalActionTemplate}" />
</tkcontrols:SettingsCard>
<tkcontrols:SettingsExpander
x:Uid="PasteAsFile"
DataContext="{x:Bind ViewModel.AdditionalActions.PasteAsFile, Mode=OneWay}"
HeaderIcon="{ui:FontIcon Glyph=&#xEC50;}"
IsExpanded="{Binding IsShown, Mode=OneWay}">
<tkcontrols:SettingsExpander.Content>
<ToggleSwitch
IsOn="{Binding IsShown, Mode=TwoWay}"
OffContent=""
OnContent="" />
</tkcontrols:SettingsExpander.Content>
<tkcontrols:SettingsExpander.Items>
<!-- HACK: For some weird reason, a ShortcutControl does not work correctly if it's the first or last item in the expander, so we add an invisible card. -->
<tkcontrols:SettingsCard Visibility="Collapsed" />
<tkcontrols:SettingsCard
x:Uid="PasteAsTxtFile"
DataContext="{Binding PasteAsTxtFile, Mode=TwoWay}"
IsEnabled="{x:Bind ViewModel.AdditionalActions.PasteAsFile.IsShown, Mode=OneWay}">
<ContentControl ContentTemplate="{StaticResource AdditionalActionTemplate}" />
</tkcontrols:SettingsCard>
<tkcontrols:SettingsCard
x:Uid="PasteAsPngFile"
DataContext="{Binding PasteAsPngFile, Mode=TwoWay}"
IsEnabled="{x:Bind ViewModel.AdditionalActions.PasteAsFile.IsShown, Mode=OneWay}">
<ContentControl ContentTemplate="{StaticResource AdditionalActionTemplate}" />
</tkcontrols:SettingsCard>
<tkcontrols:SettingsCard
x:Uid="PasteAsHtmlFile"
DataContext="{Binding PasteAsHtmlFile, Mode=TwoWay}"
IsEnabled="{x:Bind ViewModel.AdditionalActions.PasteAsFile.IsShown, Mode=OneWay}">
<ContentControl ContentTemplate="{StaticResource AdditionalActionTemplate}" />
</tkcontrols:SettingsCard>
<!-- HACK: For some weird reason, a ShortcutControl does not work correctly if it's the first or last item in the expander, so we add an invisible card. -->
<tkcontrols:SettingsCard Visibility="Collapsed" />
</tkcontrols:SettingsExpander.Items>
</tkcontrols:SettingsExpander>
<InfoBar
x:Uid="AdvancedPaste_ShortcutWarning"
IsClosable="False"
IsOpen="{x:Bind ViewModel.IsAdditionalActionConflictingCopyShortcut, Mode=OneWay}"
IsTabStop="{x:Bind ViewModel.IsAdditionalActionConflictingCopyShortcut, Mode=OneWay}"
Severity="Warning" />
</controls:SettingsGroup>
</StackPanel>
</controls:SettingsPageControl.ModuleContent>
<controls:SettingsPageControl.PrimaryLinks>

View File

@ -725,6 +725,9 @@
<data name="AdvancedPaste_Direct_Access_Hotkeys_GroupSettings.Header" xml:space="preserve">
<value>Actions</value>
</data>
<data name="AdvancedPaste_Additional_Actions_GroupSettings.Header" xml:space="preserve">
<value>Additional actions</value>
</data>
<data name="RemapKeysList.[using:Microsoft.UI.Xaml.Automation]AutomationProperties.Name" xml:space="preserve">
<value>Current Key Remappings</value>
</data>
@ -2046,6 +2049,21 @@ Made with 💗 by Microsoft and the PowerToys community.</value>
<data name="PasteAsCustom_Shortcut.Header" xml:space="preserve">
<value>Paste as Custom with AI directly</value>
</data>
<data name="ImageToText.Header" xml:space="preserve">
<value>Image to text</value>
</data>
<data name="PasteAsFile.Header" xml:space="preserve">
<value>Paste as file</value>
</data>
<data name="PasteAsTxtFile.Header" xml:space="preserve">
<value>Paste as .txt file</value>
</data>
<data name="PasteAsPngFile.Header" xml:space="preserve">
<value>Paste as .png file</value>
</data>
<data name="PasteAsHtmlFile.Header" xml:space="preserve">
<value>Paste as .html file</value>
</data>
<data name="AdvancedPaste_EnableAIDialogOpenAIApiKey.Text" xml:space="preserve">
<value>OpenAI API key:</value>
</data>

View File

@ -38,6 +38,7 @@ namespace Microsoft.PowerToys.Settings.UI.ViewModels
private readonly object _delayedActionLock = new object();
private readonly AdvancedPasteSettings _advancedPasteSettings;
private readonly AdvancedPasteAdditionalActions _additionalActions;
private readonly ObservableCollection<AdvancedPasteCustomAction> _customActions;
private Timer _delayedTimer;
@ -69,6 +70,7 @@ namespace Microsoft.PowerToys.Settings.UI.ViewModels
_advancedPasteSettings = advancedPasteSettingsRepository.SettingsConfig;
_additionalActions = _advancedPasteSettings.Properties.AdditionalActions;
_customActions = _advancedPasteSettings.Properties.CustomActions.Value;
InitializeEnabledValue();
@ -81,6 +83,11 @@ namespace Microsoft.PowerToys.Settings.UI.ViewModels
_delayedTimer.Elapsed += DelayedTimer_Tick;
_delayedTimer.AutoReset = false;
foreach (var action in _additionalActions.AllActions)
{
action.PropertyChanged += OnAdditionalActionPropertyChanged;
}
foreach (var customAction in _customActions)
{
customAction.PropertyChanged += OnCustomActionPropertyChanged;
@ -143,6 +150,8 @@ namespace Microsoft.PowerToys.Settings.UI.ViewModels
public ObservableCollection<AdvancedPasteCustomAction> CustomActions => _customActions;
public AdvancedPasteAdditionalActions AdditionalActions => _additionalActions;
private bool OpenAIKeyExists()
{
PasswordVault vault = new PasswordVault();
@ -336,12 +345,16 @@ namespace Microsoft.PowerToys.Settings.UI.ViewModels
}
}
public bool IsConflictingCopyShortcut
{
get => _customActions.Select(customAction => customAction.Shortcut)
.Concat([PasteAsPlainTextShortcut, AdvancedPasteUIShortcut, PasteAsMarkdownShortcut, PasteAsJsonShortcut])
.Any(hotkey => WarnHotkeys.Contains(hotkey.ToString()));
}
public bool IsConflictingCopyShortcut =>
_customActions.Select(customAction => customAction.Shortcut)
.Concat([PasteAsPlainTextShortcut, AdvancedPasteUIShortcut, PasteAsMarkdownShortcut, PasteAsJsonShortcut])
.Any(hotkey => WarnHotkeys.Contains(hotkey.ToString()));
public bool IsAdditionalActionConflictingCopyShortcut =>
_additionalActions.AllActions
.OfType<AdvancedPasteAdditionalAction>()
.Select(additionalAction => additionalAction.Shortcut)
.Any(hotkey => WarnHotkeys.Contains(hotkey.ToString()));
private void DelayedTimer_Tick(object sender, EventArgs e)
{
@ -461,6 +474,16 @@ namespace Microsoft.PowerToys.Settings.UI.ViewModels
NotifySettingsChanged();
}
private void OnAdditionalActionPropertyChanged(object sender, PropertyChangedEventArgs e)
{
SaveAndNotifySettings();
if (e.PropertyName == nameof(AdvancedPasteAdditionalAction.Shortcut))
{
OnPropertyChanged(nameof(IsAdditionalActionConflictingCopyShortcut));
}
}
private void OnCustomActionPropertyChanged(object sender, PropertyChangedEventArgs e)
{
if (typeof(AdvancedPasteCustomAction).GetProperty(e.PropertyName).GetCustomAttribute<JsonIgnoreAttribute>() == null)