From 04dcba521bc07ea9104e49c279b450efa0b04ea0 Mon Sep 17 00:00:00 2001 From: JNIH Date: Fri, 31 Jul 2026 08:39:53 +0200 Subject: [PATCH] fix: small logic fixes and improvements --- Olive/BundleArtifacts/x64.txt | 1 - Olive/Commands/CopyGifCommand.cs | 50 +++- Olive/Helpers/SettingsManager.cs | 45 +++- Olive/OliveCommandsProvider.cs | 16 +- Olive/Package.appxmanifest | 6 +- Olive/Pages/GifListItemFactory.cs | 53 +++- Olive/Pages/GifPickerPage.cs | 6 +- Olive/Services/PasteShortcutService.cs | 119 +++++++++ README.md | 343 +++---------------------- 9 files changed, 296 insertions(+), 343 deletions(-) delete mode 100644 Olive/BundleArtifacts/x64.txt create mode 100644 Olive/Services/PasteShortcutService.cs diff --git a/Olive/BundleArtifacts/x64.txt b/Olive/BundleArtifacts/x64.txt deleted file mode 100644 index 1d1db6f..0000000 --- a/Olive/BundleArtifacts/x64.txt +++ /dev/null @@ -1 +0,0 @@ -MainPackage=C:\Users\jnih\Downloads\gifBrowser\Olive\bin\x64\Release\net10.0-windows10.0.22621.0\win-x64\Olive_0.0.7.0_x64.msix diff --git a/Olive/Commands/CopyGifCommand.cs b/Olive/Commands/CopyGifCommand.cs index bb30416..5c44428 100644 --- a/Olive/Commands/CopyGifCommand.cs +++ b/Olive/Commands/CopyGifCommand.cs @@ -1,5 +1,6 @@ using Microsoft.CommandPalette.Extensions; using Microsoft.CommandPalette.Extensions.Toolkit; +using Olive.Helpers; using Olive.Klipy; using Olive.Services; @@ -7,30 +8,53 @@ namespace Olive.Commands; internal sealed partial class CopyGifCommand : InvokableCommand { - private readonly KlipyGif _gif; + private static readonly SemaphoreSlim CopyPasteGate = new(1, 1); - public CopyGifCommand(KlipyGif gif) + private readonly KlipyGif _gif; + private readonly SettingsManager _settingsManager; + + public CopyGifCommand(KlipyGif gif, SettingsManager settingsManager) { _gif = gif; + _settingsManager = settingsManager; Name = "Copy GIF"; Icon = new IconInfo("\uE8C8"); } public override ICommandResult Invoke() { - _ = CopyAsync(); - return CommandResult.Hide(); + var closeAfterCopy = _settingsManager.CloseAfterCopy; + var pasteAfterCopy = _settingsManager.PasteAfterCopy; + + _ = CopyAsync(closeAfterCopy, pasteAfterCopy); + return closeAfterCopy ? CommandResult.Dismiss() : CommandResult.KeepOpen(); } - private async Task CopyAsync() + private async Task CopyAsync(bool closeAfterCopy, bool pasteAfterCopy) { using var cancellation = new CancellationTokenSource(TimeSpan.FromSeconds(30)); + if (!await CopyPasteGate.WaitAsync(0, cancellation.Token).ConfigureAwait(false)) + { + ShowStatus("A GIF is already being copied.", MessageState.Warning, 2000); + return; + } + try { + var targetWindowTask = CapturePasteTargetAsync(closeAfterCopy, pasteAfterCopy, cancellation.Token); + ShowStatus("Downloading GIF...", MessageState.Info, 800); - var bytes = await KlipyClient.DownloadGifBytesAsync(_gif.GifUrl, cancellation.Token).ConfigureAwait(false); + var downloadTask = KlipyClient.DownloadGifBytesAsync(_gif.GifUrl, cancellation.Token); + + var bytes = await downloadTask.ConfigureAwait(false); + var targetWindow = await targetWindowTask.ConfigureAwait(false); await ClipboardService.CopyGifBytesAsync(bytes, cancellation.Token).ConfigureAwait(false); - ShowStatus("GIF copied - paste with Ctrl + V", MessageState.Success, 3500); + + if (pasteAfterCopy) + await PasteShortcutService.SendPasteAfterRestoringFocusAsync(targetWindow, cancellation.Token) + .ConfigureAwait(false); + else + ShowStatus("GIF copied - paste with Ctrl + V", MessageState.Success, 3500); } catch (OperationCanceledException) { @@ -41,6 +65,18 @@ internal sealed partial class CopyGifCommand : InvokableCommand { ShowStatus("Could not copy this GIF.", MessageState.Error, 3500); } + finally + { + CopyPasteGate.Release(); + } + } + + private static async Task CapturePasteTargetAsync(bool closeAfterCopy, bool pasteAfterCopy, + CancellationToken cancellationToken) + { + if (!pasteAfterCopy) return 0; + if (closeAfterCopy) await Task.Delay(150, cancellationToken).ConfigureAwait(false); + return PasteShortcutService.GetForegroundWindowHandle(); } private static void ShowStatus(string message, MessageState state, int duration) diff --git a/Olive/Helpers/SettingsManager.cs b/Olive/Helpers/SettingsManager.cs index cc4ecf0..41433a0 100644 --- a/Olive/Helpers/SettingsManager.cs +++ b/Olive/Helpers/SettingsManager.cs @@ -11,11 +11,13 @@ internal sealed class SettingsManager : JsonSettingsManager FilePath = SettingsJsonPath(); Settings.Add(_klipyApiKey); Settings.Add(_resultCount); + Settings.Add(_closeAfterCopy); + Settings.Add(_pasteAfterCopy); // Load settings from file upon initialization LoadSettings(); - Settings.SettingsChanged += (_, _) => SaveSettings(); + Settings.SettingsChanged += (_, _) => { SaveSettings(); }; } public string SettingsPath => FilePath; @@ -31,19 +33,34 @@ internal sealed class SettingsManager : JsonSettingsManager ErrorMessage = "Klipy API key is required." }; + private const int MinResultCount = 30; + private const int MaxResultCount = 100; + private readonly IntegerSetting _resultCount = new( Namespaced(nameof(ResultCount)), "Result count", "Number of GIF results to load for each search.", 50, - 20, - 100) + MinResultCount, + MaxResultCount) { - Placeholder = "Min 20, max 100", + Placeholder = $"Min {MinResultCount}, max {MaxResultCount}", IsRequired = true, - ErrorMessage = "Enter a number between 20 and 100." + ErrorMessage = $"Enter a number between {MinResultCount} and {MaxResultCount}." }; + private readonly ToggleSetting _closeAfterCopy = new( + Namespaced(nameof(CloseAfterCopy)), + "Close after copy", + "Automatically close Olive after copying a GIF.", + true); + + private readonly ToggleSetting _pasteAfterCopy = new( + Namespaced(nameof(PasteAfterCopy)), + "Paste after copy", + "After copying a GIF, send Ctrl+V.", + false); + public string KlipyApiKey { get @@ -64,6 +81,24 @@ internal sealed class SettingsManager : JsonSettingsManager } } + public bool CloseAfterCopy + { + get + { + LoadSettings(); + return _closeAfterCopy.Value; + } + } + + public bool PasteAfterCopy + { + get + { + LoadSettings(); + return _pasteAfterCopy.Value; + } + } + private static string SettingsJsonPath() { var directory = Utilities.BaseSettingsPath("Olive"); diff --git a/Olive/OliveCommandsProvider.cs b/Olive/OliveCommandsProvider.cs index 19c7d09..d1d2398 100644 --- a/Olive/OliveCommandsProvider.cs +++ b/Olive/OliveCommandsProvider.cs @@ -24,8 +24,22 @@ public sealed partial class OliveCommandsProvider : CommandProvider { Title = "Olive GIF Picker", Subtitle = "Browse through lots of GIFs and find the one that suits you best!", - MoreCommands = [new CommandContextItem(_settingsManager.Settings.SettingsPage)], + MoreCommands = + [ + new CommandContextItem(_settingsManager.Settings.SettingsPage) + { + Title = "Olive Settings", + Subtitle = "Configure the Klipy API key and result count.", + Icon = new IconInfo("\uE713") + } + ], Icon = Icon + }, + new CommandItem(_settingsManager.Settings.SettingsPage) + { + Title = "Olive Settings", + Subtitle = "Configure the Klipy API key and result count.", + Icon = new IconInfo("\uE713") } ]; } diff --git a/Olive/Package.appxmanifest b/Olive/Package.appxmanifest index 418058f..aeb189f 100644 --- a/Olive/Package.appxmanifest +++ b/Olive/Package.appxmanifest @@ -11,11 +11,11 @@ + Version="0.0.60.0"/> Olive - Private - Assets\StoreLogo.png + EndMove 'contact@endmove.eu' + Assets\AppLogo150.scale-200.png diff --git a/Olive/Pages/GifListItemFactory.cs b/Olive/Pages/GifListItemFactory.cs index 0f7b4bd..ba822f9 100644 --- a/Olive/Pages/GifListItemFactory.cs +++ b/Olive/Pages/GifListItemFactory.cs @@ -17,25 +17,27 @@ internal sealed class GifListItemFactory _settingsManager = settingsManager; } - public static ListItem CreateGifItem(KlipyGif gif) + public ListItem CreateGifItem(KlipyGif gif) { - return new ListItem(new CopyGifCommand(gif)) + return new ListItem(new CopyGifCommand(gif, _settingsManager)) { Title = string.Empty, Subtitle = string.Empty, - Icon = new IconInfo(gif.ThumbnailGifUrl.ToString()) + Icon = new IconInfo(gif.ThumbnailGifUrl.ToString()), + MoreCommands = CommonMoreCommands() }; } public CommandItem InitialContent() { - return new CommandItem(new NoOpCommand()) + return new CommandItem(_settingsManager.HasKlipyApiKey ? new NoOpCommand() : _settingsManager.Settings.SettingsPage) { - Title = _settingsManager.HasKlipyApiKey ? "Search GIFs" : "Klipy API key missing", + Title = _settingsManager.HasKlipyApiKey ? "Search GIFs from Klipy.com" : "Klipy API key missing", Subtitle = _settingsManager.HasKlipyApiKey ? "Type something like thanks, excited, cat, or confused." : "Open Olive settings and set the Klipy API key.", - Icon = _icon + Icon = _icon, + MoreCommands = CommonMoreCommands() }; } @@ -45,17 +47,19 @@ internal sealed class GifListItemFactory { Title = "Loading GIFs...", Subtitle = $"Searching Klipy for \"{search}\"", - Icon = _icon + Icon = _icon, + MoreCommands = CommonMoreCommands() }; } public CommandItem MissingApiKeyContent() { - return new CommandItem(new NoOpCommand()) + return new CommandItem(_settingsManager.Settings.SettingsPage) { Title = "Klipy API key missing", Subtitle = "Open Olive settings and paste your Klipy API key.", - Icon = _icon + Icon = _icon, + MoreCommands = CommonMoreCommands() }; } @@ -65,7 +69,8 @@ internal sealed class GifListItemFactory { Title = "No GIF found", Subtitle = $"Try another search for \"{search}\".", - Icon = _icon + Icon = _icon, + MoreCommands = CommonMoreCommands() }; } @@ -75,7 +80,33 @@ internal sealed class GifListItemFactory { Title = "Search failed", Subtitle = ex.Message, - Icon = _icon + Icon = _icon, + MoreCommands = CommonMoreCommands() + }; + } + + private IContextItem[] CommonMoreCommands() + { + return + [ + new CommandContextItem(_settingsManager.Settings.SettingsPage) + { + Title = "Olive Settings", + Subtitle = "Configure Olive.", + Icon = new IconInfo("\uE713") + }, + LinkCommand("Visit Klipy", "Open klipy.com", "https://klipy.com", "\uE774"), + LinkCommand("Source code", "Open the Olive repository", "https://git.endmove.eu/EndMove/Olive", "\uE943") + ]; + } + + private static CommandContextItem LinkCommand(string title, string subtitle, string url, string icon) + { + return new CommandContextItem(new OpenUrlCommand(url) { Result = CommandResult.Dismiss() }) + { + Title = title, + Subtitle = subtitle, + Icon = new IconInfo(icon) }; } } diff --git a/Olive/Pages/GifPickerPage.cs b/Olive/Pages/GifPickerPage.cs index ca5c11e..fbe7174 100644 --- a/Olive/Pages/GifPickerPage.cs +++ b/Olive/Pages/GifPickerPage.cs @@ -9,7 +9,7 @@ namespace Olive.Pages; internal sealed partial class GifPickerPage : IDynamicListPage { private const int KlipyMaxPageSize = 50; - private static readonly TimeSpan SearchDebounceDelay = TimeSpan.FromMilliseconds(500); + private static readonly TimeSpan SearchDebounceDelay = TimeSpan.FromMilliseconds(350); private readonly Lock _stateLock = new(); private readonly SettingsManager _settingsManager; @@ -63,7 +63,7 @@ internal sealed partial class GifPickerPage : IDynamicListPage public IFilters? Filters => null; - public IGridProperties GridProperties { get; } = new GalleryGridLayout { ShowTitle = true, ShowSubtitle = false }; + public IGridProperties GridProperties { get; } = new GalleryGridLayout { ShowTitle = false, ShowSubtitle = false }; public ICommandItem EmptyContent { @@ -181,7 +181,7 @@ internal sealed partial class GifPickerPage : IDynamicListPage return; } - ReplaceState(gifs.Select(GifListItemFactory.CreateGifItem).ToArray()); + ReplaceState(gifs.Select(_itemFactory.CreateGifItem).ToArray()); } catch (OperationCanceledException) { diff --git a/Olive/Services/PasteShortcutService.cs b/Olive/Services/PasteShortcutService.cs new file mode 100644 index 0000000..e498ddb --- /dev/null +++ b/Olive/Services/PasteShortcutService.cs @@ -0,0 +1,119 @@ +using System.Runtime.InteropServices; + +namespace Olive.Services; + +internal static class PasteShortcutService +{ + private const ushort ControlKey = 0x11; + private const ushort VKey = 0x56; + private const uint KeyUp = 0x0002; + private const uint InputKeyboard = 1; + private const int RestoreWindow = 9; + private static readonly TimeSpan FocusSettleDelay = TimeSpan.FromMilliseconds(450); + + public static nint GetForegroundWindowHandle() + { + return GetForegroundWindow(); + } + + public static async Task SendPasteAfterRestoringFocusAsync(nint targetWindow, CancellationToken cancellationToken) + { + RestoreTargetWindow(targetWindow); + await Task.Delay(FocusSettleDelay, cancellationToken).ConfigureAwait(false); + SendKeyChord(ControlKey, VKey); + } + + private static void RestoreTargetWindow(nint targetWindow) + { + if (targetWindow == 0 || !IsWindow(targetWindow)) return; + + if (IsIconic(targetWindow)) ShowWindow(targetWindow, RestoreWindow); + SetForegroundWindow(targetWindow); + } + + private static void SendKeyChord(ushort modifierKey, ushort key) + { + var inputs = new[] + { + KeyDown(modifierKey), + KeyDown(key), + KeyUpInput(key), + KeyUpInput(modifierKey) + }; + + var sent = SendInput((uint)inputs.Length, inputs, Marshal.SizeOf()); + if (sent != inputs.Length) throw new InvalidOperationException("Could not send Ctrl+V to the active app."); + } + + private static Input KeyDown(ushort key) + { + return new Input + { + Type = InputKeyboard, + Data = new InputUnion { Keyboard = new KeyboardInput { VirtualKey = key } } + }; + } + + private static Input KeyUpInput(ushort key) + { + return new Input + { + Type = InputKeyboard, + Data = new InputUnion { Keyboard = new KeyboardInput { VirtualKey = key, Flags = KeyUp } } + }; + } + + [DllImport("user32.dll", SetLastError = true)] + private static extern uint SendInput(uint inputCount, Input[] inputs, int inputSize); + + [DllImport("user32.dll")] + private static extern nint GetForegroundWindow(); + + [DllImport("user32.dll")] + private static extern bool IsWindow(nint windowHandle); + + [DllImport("user32.dll")] + private static extern bool IsIconic(nint windowHandle); + + [DllImport("user32.dll")] + private static extern bool ShowWindow(nint windowHandle, int commandShow); + + [DllImport("user32.dll")] + private static extern bool SetForegroundWindow(nint windowHandle); + + [StructLayout(LayoutKind.Sequential)] + private struct Input + { + public uint Type; + public InputUnion Data; + } + + [StructLayout(LayoutKind.Explicit)] + private struct InputUnion + { + [FieldOffset(0)] public MouseInput Mouse; + + [FieldOffset(0)] public KeyboardInput Keyboard; + } + + [StructLayout(LayoutKind.Sequential)] + private struct MouseInput + { + public int X; + public int Y; + public uint MouseData; + public uint Flags; + public uint Time; + public nint ExtraInfo; + } + + [StructLayout(LayoutKind.Sequential)] + private struct KeyboardInput + { + public ushort VirtualKey; + public ushort ScanCode; + public uint Flags; + public uint Time; + public nint ExtraInfo; + } +} diff --git a/README.md b/README.md index 1891ca8..a8c2294 100644 --- a/README.md +++ b/README.md @@ -1,120 +1,19 @@ # Olive -Olive -is -a -private -Microsoft -PowerToys -Command -Palette -extension. -It -searches -animated -GIFs -with -Klipy -and -allow -you -to -quickly -the -one -that -suits -you -best! +Olive is a private Microsoft PowerToys Command Palette extension. It searches animated GIFs with Klipy and allows you to quickly use the one that suits you best! ## Requirements -- - -Windows -10/11 -with -PowerToys -and -Command -Palette -enabled. - -- - -.NET -SDK - -10. - -- - -Windows -SDK -`10.0.22621.0` -or -a -compatible -newer -SDK. - -- - -A -Klipy -API -key. +- Windows 10/11 with PowerToys and Command Palette enabled. +- .NET SDK 10. +- Windows SDK `10.0.22621.0` or a compatible newer SDK. +- A Klipy API key. ## Klipy API Key -Olive -stores -the -Klipy -key -locally -in -its -Command -Palette -settings. -Do -not -put -the -key -in -source -code, -Git, -environment -variables, -or -the -package. +Olive stores the Klipy key locally in its Command Palette settings. Do not put the key in source code, Git, environment variables, or the package. -To -configure -it, -open -Command -Palette, -open -the -context -commands -for -`Olive GIF Picker`, -open -Olive -settings, -paste -the -key -into -`Klipy API key`, -then -save. +To configure it, open Command Palette, open the context commands for `Olive GIF Picker`, open Olive settings, paste the key into `Klipy API key`, then save. ## Build @@ -128,251 +27,71 @@ dotnet build .\Olive.sln -c Debug -p:Platform=x64 .\scripts\Build-OlivePackage.ps1 ``` -Or -double-click: +Or double-click: ```text scripts\Build-OlivePackage.cmd ``` -This -creates -the -shareable -package -in -`dist\OlivePackage`. +This creates the shareable package in `dist\OlivePackage`. -The -private -signing -file -is -exported -to -`dist\private\OlivePrivate.pfx`. -Do -not -share -it -unless -the -recipient -must -be -able -to -sign -Olive -builds. +The private signing file is exported to `dist\private\OlivePrivate.pfx`. Do not share it unless the recipient must be able to sign Olive builds. ## Install -From -`dist\OlivePackage`: +From `dist\OlivePackage`: ```powershell .\Install-Olive.ps1 ``` -Or -double-click: +Or double-click: ```text Install-Olive.cmd ``` -The -script -asks -for -administrator -rights, -imports -`OlivePrivate.cer` -into -`Cert:\LocalMachine\Root`, -then -opens -the -`.msix` -with -Windows -App -Installer. +The script asks for administrator rights, imports `OlivePrivate.cer` into `Cert:\LocalMachine\Root`, then opens the `.msix` with Windows App Installer. -Restart -PowerToys -after -installation. +Restart PowerToys after installation. ## Uninstall -From -`dist\OlivePackage` -or -`scripts\`: +From `dist\OlivePackage` or `scripts\`: ```powershell .\Uninstall-Olive.ps1 ``` -Or -double-click: +Or double-click: ```text Uninstall-Olive.cmd ``` -By -default, -uninstall -removes -the -Olive -package, -the -Olive -certificate -from -`Cert:\LocalMachine\Root`, -and -Olive -user -data. +By default, uninstall removes the Olive package, the Olive certificate from `Cert:\LocalMachine\Root`, and Olive user data. -Use -`-KeepCertificate` -to -keep -the -certificate. +Use `-KeepCertificate` to keep the certificate. -Use -`-KeepUserData` -to -keep -Olive -user -data. +Use `-KeepUserData` to keep Olive user data. ## Usage -1. - -Open -PowerToys -Command -Palette. - -2. - -Launch -`Olive GIF Picker`. - -3. - -Search -for -a -GIF. - -4. - -Select -a -result -with -Enter. - -5. - -Paste -with -Ctrl+V -in -an -app -that -accepts -animated -GIF -files. +1. Open PowerToys Command Palette. +2. Launch `Olive GIF Picker`. +3. Search for a GIF. +4. Select a result with Enter. +5. Paste with Ctrl+V in an app that accepts animated GIF files. ## Sharing -- - -Share -only -`dist\OlivePackage`. - -- - -Do -not -share -`dist\private\OlivePrivate.pfx`. - -- - -Do -not -include -the -Klipy -API -key -in -the -package. - -- - -Each -user -must -set -the -Klipy -key -in -Olive -settings. +- Share only `dist\OlivePackage`. +- Do not share `dist\private\OlivePrivate.pfx`. +- Do not include the Klipy API key in the package. +- Each user must set the Klipy key in Olive settings. ## Notes -- - -Olive -copies -GIFs -as -files -to -preserve -animation. - -- - -Some -apps -may -reject -file -pasting -or -use -fallback -text -only. - -- - -GIFs -are -downloaded -in -memory -when -copied. +- Olive copies GIFs as files to preserve animation. +- Some apps may reject file pasting or use fallback text only. +- GIFs are downloaded in memory when copied.