diff --git a/src/common/interop/Constants.cpp b/src/common/interop/Constants.cpp index dc4641822b..7a01cd225a 100644 --- a/src/common/interop/Constants.cpp +++ b/src/common/interop/Constants.cpp @@ -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; diff --git a/src/common/interop/Constants.h b/src/common/interop/Constants.h index b59cd3e62c..09a884e50b 100644 --- a/src/common/interop/Constants.h +++ b/src/common/interop/Constants.h @@ -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(); diff --git a/src/common/interop/Constants.idl b/src/common/interop/Constants.idl index a681ee5a81..9ae489c62b 100644 --- a/src/common/interop/Constants.idl +++ b/src/common/interop/Constants.idl @@ -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(); diff --git a/src/common/interop/shared_constants.h b/src/common/interop/shared_constants.h index 3f9c350b7b..c98b143104 100644 --- a/src/common/interop/shared_constants.h +++ b/src/common/interop/shared_constants.h @@ -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 diff --git a/src/modules/AdvancedPaste/AdvancedPaste/AdvancedPasteXAML/App.xaml.cs b/src/modules/AdvancedPaste/AdvancedPaste/AdvancedPasteXAML/App.xaml.cs index 3f990ef6fa..77f8551803 100644 --- a/src/modules/AdvancedPaste/AdvancedPaste/AdvancedPasteXAML/App.xaml.cs +++ b/src/modules/AdvancedPaste/AdvancedPaste/AdvancedPasteXAML/App.xaml.cs @@ -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 AdditionalActionIPCKeys = + typeof(PasteFormats).GetFields() + .Where(field => field.IsLiteral) + .Select(field => (Format: (PasteFormats)field.GetRawConstantValue(), field.GetCustomAttribute().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(); services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); }).Build(); viewModel = GetService(); @@ -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) { diff --git a/src/modules/AdvancedPaste/AdvancedPaste/AdvancedPasteXAML/Controls/PromptBox.xaml b/src/modules/AdvancedPaste/AdvancedPaste/AdvancedPasteXAML/Controls/PromptBox.xaml index 27891312ac..2c0d9ea937 100644 --- a/src/modules/AdvancedPaste/AdvancedPaste/AdvancedPasteXAML/Controls/PromptBox.xaml +++ b/src/modules/AdvancedPaste/AdvancedPaste/AdvancedPasteXAML/Controls/PromptBox.xaml @@ -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}}"> - + @@ -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}" /> (); + InitializeComponent(); ViewModel = App.GetService(); - 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)); } } diff --git a/src/modules/AdvancedPaste/AdvancedPaste/AdvancedPasteXAML/Converters/PasteFormatsToHeightConverter.cs b/src/modules/AdvancedPaste/AdvancedPaste/AdvancedPasteXAML/Converters/PasteFormatsToHeightConverter.cs new file mode 100644 index 0000000000..2061bdefc9 --- /dev/null +++ b/src/modules/AdvancedPaste/AdvancedPaste/AdvancedPasteXAML/Converters/PasteFormatsToHeightConverter.cs @@ -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(); +} diff --git a/src/modules/AdvancedPaste/AdvancedPaste/AdvancedPasteXAML/MainWindow.xaml b/src/modules/AdvancedPaste/AdvancedPaste/AdvancedPasteXAML/MainWindow.xaml index 8aa111219c..33a0dec49f 100644 --- a/src/modules/AdvancedPaste/AdvancedPaste/AdvancedPasteXAML/MainWindow.xaml +++ b/src/modules/AdvancedPaste/AdvancedPaste/AdvancedPasteXAML/MainWindow.xaml @@ -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" diff --git a/src/modules/AdvancedPaste/AdvancedPaste/AdvancedPasteXAML/MainWindow.xaml.cs b/src/modules/AdvancedPaste/AdvancedPaste/AdvancedPasteXAML/MainWindow.xaml.cs index 314e503ed8..8f675e4d4e 100644 --- a/src/modules/AdvancedPaste/AdvancedPaste/AdvancedPasteXAML/MainWindow.xaml.cs +++ b/src/modules/AdvancedPaste/AdvancedPaste/AdvancedPasteXAML/MainWindow.xaml.cs @@ -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(); + var optionsViewModel = App.GetService(); 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(); } diff --git a/src/modules/AdvancedPaste/AdvancedPaste/AdvancedPasteXAML/Pages/MainPage.xaml b/src/modules/AdvancedPaste/AdvancedPaste/AdvancedPasteXAML/Pages/MainPage.xaml index f45a222866..b4e99ebaed 100644 --- a/src/modules/AdvancedPaste/AdvancedPaste/AdvancedPasteXAML/Pages/MainPage.xaml +++ b/src/modules/AdvancedPaste/AdvancedPaste/AdvancedPasteXAML/Pages/MainPage.xaml @@ -16,8 +16,9 @@ + + - - - - - - - - - - - - - + @@ -166,9 +186,9 @@ BorderThickness="0,1,0,0" RowSpacing="4"> + - - + @@ -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}" diff --git a/src/modules/AdvancedPaste/AdvancedPaste/AdvancedPasteXAML/Pages/MainPage.xaml.cs b/src/modules/AdvancedPaste/AdvancedPaste/AdvancedPasteXAML/Pages/MainPage.xaml.cs index ab25db1d7c..d3e4ff1829 100644 --- a/src/modules/AdvancedPaste/AdvancedPaste/AdvancedPasteXAML/Pages/MainPage.xaml.cs +++ b/src/modules/AdvancedPaste/AdvancedPaste/AdvancedPasteXAML/Pages/MainPage.xaml.cs @@ -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); } } diff --git a/src/modules/AdvancedPaste/AdvancedPaste/Helpers/ClipboardHelper.cs b/src/modules/AdvancedPaste/AdvancedPaste/Helpers/ClipboardHelper.cs index 4c1bf5b24e..9ec1dd1618 100644 --- a/src/modules/AdvancedPaste/AdvancedPaste/Helpers/ClipboardHelper.cs +++ b/src/modules/AdvancedPaste/AdvancedPaste/Helpers/ClipboardHelper.cs @@ -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 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 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 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 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 GetClipboardHtmlContentAsync(DataPackageView clipboardData) => + clipboardData.Contains(StandardDataFormats.Html) ? await clipboardData.GetHtmlFormatAsync() : string.Empty; + + internal static async Task 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 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; + } } } diff --git a/src/modules/AdvancedPaste/AdvancedPaste/Helpers/IUserSettings.cs b/src/modules/AdvancedPaste/AdvancedPaste/Helpers/IUserSettings.cs index fd72f67262..49dbfda945 100644 --- a/src/modules/AdvancedPaste/AdvancedPaste/Helpers/IUserSettings.cs +++ b/src/modules/AdvancedPaste/AdvancedPaste/Helpers/IUserSettings.cs @@ -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 CustomActions { get; } + public IReadOnlyList CustomActions { get; } + + public IReadOnlyList AdditionalActions { get; } + + public event EventHandler Changed; } } diff --git a/src/modules/AdvancedPaste/AdvancedPaste/Helpers/OcrHelpers.cs b/src/modules/AdvancedPaste/AdvancedPaste/Helpers/OcrHelpers.cs new file mode 100644 index 0000000000..25b46fd160 --- /dev/null +++ b/src/modules/AdvancedPaste/AdvancedPaste/Helpers/OcrHelpers.cs @@ -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 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(); + } +} diff --git a/src/modules/AdvancedPaste/AdvancedPaste/Helpers/UserSettings.cs b/src/modules/AdvancedPaste/AdvancedPaste/Helpers/UserSettings.cs index 0bfb50c898..fe60dd7f53 100644 --- a/src/modules/AdvancedPaste/AdvancedPaste/Helpers/UserSettings.cs +++ b/src/modules/AdvancedPaste/AdvancedPaste/Helpers/UserSettings.cs @@ -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 _additionalActions; + private readonly List _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 CustomActions { get; private set; } + public IReadOnlyList AdditionalActions => _additionalActions; + + public IReadOnlyList 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 diff --git a/src/modules/AdvancedPaste/AdvancedPaste/Models/ClipboardFormat.cs b/src/modules/AdvancedPaste/AdvancedPaste/Models/ClipboardFormat.cs new file mode 100644 index 0000000000..a63e79735e --- /dev/null +++ b/src/modules/AdvancedPaste/AdvancedPaste/Models/ClipboardFormat.cs @@ -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, +} diff --git a/src/modules/AdvancedPaste/AdvancedPaste/Models/ClipboardItem.cs b/src/modules/AdvancedPaste/AdvancedPaste/Models/ClipboardItem.cs index 1ff749753e..3927949064 100644 --- a/src/modules/AdvancedPaste/AdvancedPaste/Models/ClipboardItem.cs +++ b/src/modules/AdvancedPaste/AdvancedPaste/Models/ClipboardItem.cs @@ -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; } } diff --git a/src/modules/AdvancedPaste/AdvancedPaste/Models/CustomActionActivatedEventArgs.cs b/src/modules/AdvancedPaste/AdvancedPaste/Models/CustomActionActivatedEventArgs.cs index d321cb01f6..d4846e01be 100644 --- a/src/modules/AdvancedPaste/AdvancedPaste/Models/CustomActionActivatedEventArgs.cs +++ b/src/modules/AdvancedPaste/AdvancedPaste/Models/CustomActionActivatedEventArgs.cs @@ -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; } diff --git a/src/modules/AdvancedPaste/AdvancedPaste/Models/PasteActionException.cs b/src/modules/AdvancedPaste/AdvancedPaste/Models/PasteActionException.cs new file mode 100644 index 0000000000..fed4e24c50 --- /dev/null +++ b/src/modules/AdvancedPaste/AdvancedPaste/Models/PasteActionException.cs @@ -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) +{ +} diff --git a/src/modules/AdvancedPaste/AdvancedPaste/Models/PasteActionSource.cs b/src/modules/AdvancedPaste/AdvancedPaste/Models/PasteActionSource.cs new file mode 100644 index 0000000000..bdfabfbcc3 --- /dev/null +++ b/src/modules/AdvancedPaste/AdvancedPaste/Models/PasteActionSource.cs @@ -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, +} diff --git a/src/modules/AdvancedPaste/AdvancedPaste/Models/PasteFormat.cs b/src/modules/AdvancedPaste/AdvancedPaste/Models/PasteFormat.cs index 6f5103be3e..c38a54b843 100644 --- a/src/modules/AdvancedPaste/AdvancedPaste/Models/PasteFormat.cs +++ b/src/modules/AdvancedPaste/AdvancedPaste/Models/PasteFormat.cs @@ -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 MetadataDict = + typeof(PasteFormats).GetFields() + .Where(field => field.IsLiteral) + .ToDictionary(field => (PasteFormats)field.GetRawConstantValue(), field => field.GetCustomAttribute()); - [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 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; } diff --git a/src/modules/AdvancedPaste/AdvancedPaste/Models/PasteFormatMetadataAttribute.cs b/src/modules/AdvancedPaste/AdvancedPaste/Models/PasteFormatMetadataAttribute.cs new file mode 100644 index 0000000000..cb3a8a954e --- /dev/null +++ b/src/modules/AdvancedPaste/AdvancedPaste/Models/PasteFormatMetadataAttribute.cs @@ -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; } +} diff --git a/src/modules/AdvancedPaste/AdvancedPaste/Models/PasteFormats.cs b/src/modules/AdvancedPaste/AdvancedPaste/Models/PasteFormats.cs index ba48fe6586..fe710d5410 100644 --- a/src/modules/AdvancedPaste/AdvancedPaste/Models/PasteFormats.cs +++ b/src/modules/AdvancedPaste/AdvancedPaste/Models/PasteFormats.cs @@ -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, } diff --git a/src/modules/AdvancedPaste/AdvancedPaste/Services/IPasteFormatExecutor.cs b/src/modules/AdvancedPaste/AdvancedPaste/Services/IPasteFormatExecutor.cs new file mode 100644 index 0000000000..e0bb39ab7c --- /dev/null +++ b/src/modules/AdvancedPaste/AdvancedPaste/Services/IPasteFormatExecutor.cs @@ -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 ExecutePasteFormatAsync(PasteFormat pasteFormat, PasteActionSource source); +} diff --git a/src/modules/AdvancedPaste/AdvancedPaste/Services/PasteFormatExecutor.cs b/src/modules/AdvancedPaste/AdvancedPaste/Services/PasteFormatExecutor.cs new file mode 100644 index 0000000000..f77cf8ef99 --- /dev/null +++ b/src/modules/AdvancedPaste/AdvancedPaste/Services/PasteFormatExecutor.cs @@ -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 ExecutePasteFormatAsync(PasteFormat pasteFormat, PasteActionSource source) + { + if (!pasteFormat.IsEnabled) + { + return null; + } + + WriteTelemetry(pasteFormat.Format, source); + + return await ExecutePasteFormatCoreAsync(pasteFormat, Clipboard.GetContent()); + } + + private async Task 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"); + } + + /// + /// Removes leading CF_HTML metadata from HTML clipboard data. + /// See: https://learn.microsoft.com/en-us/windows/win32/dataxchg/html-clipboard-format + /// + 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 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), + }; +} diff --git a/src/modules/AdvancedPaste/AdvancedPaste/Strings/en-us/Resources.resw b/src/modules/AdvancedPaste/AdvancedPaste/Strings/en-us/Resources.resw index dd2720732c..0ec81547e2 100644 --- a/src/modules/AdvancedPaste/AdvancedPaste/Strings/en-us/Resources.resw +++ b/src/modules/AdvancedPaste/AdvancedPaste/Strings/en-us/Resources.resw @@ -120,9 +120,12 @@ AI can make mistakes. - - Clipboard data is not text + + Clipboard does not contain any usable formats + + Clipboard data is not text + To custom with AI is not enabled @@ -135,6 +138,9 @@ OpenAI request failed with status code: + + An error occurred during the paste operation + Clipboard history @@ -151,7 +157,7 @@ Privacy - Connecting to AI services and generating output.. + Generating output... Paste as JSON @@ -162,6 +168,18 @@ Paste as plain text + + Image to text + + + Paste as .txt file + + + Paste as .png file + + + Paste as .html file + Paste @@ -228,4 +246,7 @@ Ctrl + + PowerToys_Paste_ + \ No newline at end of file diff --git a/src/modules/AdvancedPaste/AdvancedPaste/ViewModels/OptionsViewModel.cs b/src/modules/AdvancedPaste/AdvancedPaste/ViewModels/OptionsViewModel.cs index 4d59e327af..f2e461e47f 100644 --- a/src/modules/AdvancedPaste/AdvancedPaste/ViewModels/OptionsViewModel.cs +++ b/src/modules/AdvancedPaste/AdvancedPaste/ViewModels/OptionsViewModel.cs @@ -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 StandardPasteFormats { get; } = []; public ObservableCollection 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 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(); + 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 FilterAndSort(IEnumerable 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 collection, IEnumerable 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() + .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 GeneratedResponses { get; set; } = new ObservableCollection(); + public ObservableCollection 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 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(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; } diff --git a/src/modules/AdvancedPaste/AdvancedPasteModuleInterface/dllmain.cpp b/src/modules/AdvancedPaste/AdvancedPasteModuleInterface/dllmain.cpp index 649342bc2f..4da030d6ba 100644 --- a/src/modules/AdvancedPaste/AdvancedPasteModuleInterface/dllmain.cpp +++ b/src/modules/AdvancedPaste/AdvancedPasteModuleInterface/dllmain.cpp @@ -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 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 m_custom_action_hotkeys; - std::vector m_custom_action_ids; + template + struct ActionData + { + Id id; + Hotkey hotkey; + }; + + using AdditionalAction = ActionData; + std::vector m_additional_actions; + + using CustomAction = ActionData; + std::vector 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(object.GetNamedNumber(JSON_KEY_ID))); + const CustomAction customActionData + { + static_cast(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 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; diff --git a/src/modules/AdvancedPaste/AdvancedPasteModuleInterface/trace.cpp b/src/modules/AdvancedPaste/AdvancedPasteModuleInterface/trace.cpp index 87d610682f..0ae92a7187 100644 --- a/src/modules/AdvancedPaste/AdvancedPasteModuleInterface/trace.cpp +++ b/src/modules/AdvancedPaste/AdvancedPasteModuleInterface/trace.cpp @@ -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& 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 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") ); } diff --git a/src/modules/AdvancedPaste/AdvancedPasteModuleInterface/trace.h b/src/modules/AdvancedPaste/AdvancedPasteModuleInterface/trace.h index d64d1cd874..7c0504d58e 100644 --- a/src/modules/AdvancedPaste/AdvancedPasteModuleInterface/trace.h +++ b/src/modules/AdvancedPaste/AdvancedPasteModuleInterface/trace.h @@ -1,5 +1,6 @@ #pragma once #include +#include 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& additionalActionsHotkeys) noexcept; }; diff --git a/src/settings-ui/Settings.UI.Library/AdvancedPasteAdditionalAction.cs b/src/settings-ui/Settings.UI.Library/AdvancedPasteAdditionalAction.cs new file mode 100644 index 0000000000..7a6fd3081a --- /dev/null +++ b/src/settings-ui/Settings.UI.Library/AdvancedPasteAdditionalAction.cs @@ -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); + } +} diff --git a/src/settings-ui/Settings.UI.Library/AdvancedPasteAdditionalActions.cs b/src/settings-ui/Settings.UI.Library/AdvancedPasteAdditionalActions.cs new file mode 100644 index 0000000000..ce26962b02 --- /dev/null +++ b/src/settings-ui/Settings.UI.Library/AdvancedPasteAdditionalActions.cs @@ -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 AllActions => new IAdvancedPasteAction[] { ImageToText, PasteAsFile }.Concat(PasteAsFile.SubActions); +} diff --git a/src/settings-ui/Settings.UI.Library/AdvancedPasteCustomAction.cs b/src/settings-ui/Settings.UI.Library/AdvancedPasteCustomAction.cs index 585663026b..f3bb4431ca 100644 --- a/src/settings-ui/Settings.UI.Library/AdvancedPasteCustomAction.cs +++ b/src/settings-ui/Settings.UI.Library/AdvancedPasteCustomAction.cs @@ -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; diff --git a/src/settings-ui/Settings.UI.Library/AdvancedPastePasteAsFileAction.cs b/src/settings-ui/Settings.UI.Library/AdvancedPastePasteAsFileAction.cs new file mode 100644 index 0000000000..979e967d4a --- /dev/null +++ b/src/settings-ui/Settings.UI.Library/AdvancedPastePasteAsFileAction.cs @@ -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 SubActions => [PasteAsTxtFile, PasteAsPngFile, PasteAsHtmlFile]; +} diff --git a/src/settings-ui/Settings.UI.Library/AdvancedPasteProperties.cs b/src/settings-ui/Settings.UI.Library/AdvancedPasteProperties.cs index 8e6ebb8238..8322302ddf 100644 --- a/src/settings-ui/Settings.UI.Library/AdvancedPasteProperties.cs +++ b/src/settings-ui/Settings.UI.Library/AdvancedPasteProperties.cs @@ -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); diff --git a/src/settings-ui/Settings.UI.Library/Helpers/Observable.cs b/src/settings-ui/Settings.UI.Library/Helpers/Observable.cs index a77099b4b8..79b4e9d2ee 100644 --- a/src/settings-ui/Settings.UI.Library/Helpers/Observable.cs +++ b/src/settings-ui/Settings.UI.Library/Helpers/Observable.cs @@ -11,17 +11,19 @@ namespace Microsoft.PowerToys.Settings.UI.Library.Helpers { public event PropertyChangedEventHandler PropertyChanged; - protected void Set(ref T storage, T value, [CallerMemberName] string propertyName = null) + protected bool Set(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)); } } diff --git a/src/settings-ui/Settings.UI.Library/IAdvancedPasteAction.cs b/src/settings-ui/Settings.UI.Library/IAdvancedPasteAction.cs new file mode 100644 index 0000000000..4c31557010 --- /dev/null +++ b/src/settings-ui/Settings.UI.Library/IAdvancedPasteAction.cs @@ -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; } +} diff --git a/src/settings-ui/Settings.UI/SettingsXAML/Views/AdvancedPaste.xaml b/src/settings-ui/Settings.UI/SettingsXAML/Views/AdvancedPaste.xaml index 1598beecc8..1d7205de96 100644 --- a/src/settings-ui/Settings.UI/SettingsXAML/Views/AdvancedPaste.xaml +++ b/src/settings-ui/Settings.UI/SettingsXAML/Views/AdvancedPaste.xaml @@ -24,6 +24,18 @@ ms-appx:///Assets/Settings/Modules/APDialog.light.png + + + + + + @@ -129,6 +141,7 @@ AllowDisable="True" HotkeySettings="{x:Bind Path=ViewModel.PasteAsJsonShortcut, Mode=TwoWay}" /> + @@ -202,6 +215,56 @@ IsTabStop="{x:Bind ViewModel.IsConflictingCopyShortcut, Mode=OneWay}" Severity="Warning" /> + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/settings-ui/Settings.UI/Strings/en-us/Resources.resw b/src/settings-ui/Settings.UI/Strings/en-us/Resources.resw index afd694d95f..ed662f87d0 100644 --- a/src/settings-ui/Settings.UI/Strings/en-us/Resources.resw +++ b/src/settings-ui/Settings.UI/Strings/en-us/Resources.resw @@ -725,6 +725,9 @@ Actions + + Additional actions + Current Key Remappings @@ -2046,6 +2049,21 @@ Made with 💗 by Microsoft and the PowerToys community. Paste as Custom with AI directly + + Image to text + + + Paste as file + + + Paste as .txt file + + + Paste as .png file + + + Paste as .html file + OpenAI API key: diff --git a/src/settings-ui/Settings.UI/ViewModels/AdvancedPasteViewModel.cs b/src/settings-ui/Settings.UI/ViewModels/AdvancedPasteViewModel.cs index 94787ae513..a0533d8d63 100644 --- a/src/settings-ui/Settings.UI/ViewModels/AdvancedPasteViewModel.cs +++ b/src/settings-ui/Settings.UI/ViewModels/AdvancedPasteViewModel.cs @@ -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 _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 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() + .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() == null)