fix: small logic fixes and improvements

This commit is contained in:
JNIH
2026-07-31 12:57:30 +02:00
parent 00deea0ca1
commit 04dcba521b
9 changed files with 296 additions and 343 deletions
-1
View File
@@ -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
+43 -7
View File
@@ -1,5 +1,6 @@
using Microsoft.CommandPalette.Extensions; using Microsoft.CommandPalette.Extensions;
using Microsoft.CommandPalette.Extensions.Toolkit; using Microsoft.CommandPalette.Extensions.Toolkit;
using Olive.Helpers;
using Olive.Klipy; using Olive.Klipy;
using Olive.Services; using Olive.Services;
@@ -7,30 +8,53 @@ namespace Olive.Commands;
internal sealed partial class CopyGifCommand : InvokableCommand 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; _gif = gif;
_settingsManager = settingsManager;
Name = "Copy GIF"; Name = "Copy GIF";
Icon = new IconInfo("\uE8C8"); Icon = new IconInfo("\uE8C8");
} }
public override ICommandResult Invoke() public override ICommandResult Invoke()
{ {
_ = CopyAsync(); var closeAfterCopy = _settingsManager.CloseAfterCopy;
return CommandResult.Hide(); 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)); 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 try
{ {
var targetWindowTask = CapturePasteTargetAsync(closeAfterCopy, pasteAfterCopy, cancellation.Token);
ShowStatus("Downloading GIF...", MessageState.Info, 800); 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); 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) catch (OperationCanceledException)
{ {
@@ -41,6 +65,18 @@ internal sealed partial class CopyGifCommand : InvokableCommand
{ {
ShowStatus("Could not copy this GIF.", MessageState.Error, 3500); ShowStatus("Could not copy this GIF.", MessageState.Error, 3500);
} }
finally
{
CopyPasteGate.Release();
}
}
private static async Task<nint> 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) private static void ShowStatus(string message, MessageState state, int duration)
+40 -5
View File
@@ -11,11 +11,13 @@ internal sealed class SettingsManager : JsonSettingsManager
FilePath = SettingsJsonPath(); FilePath = SettingsJsonPath();
Settings.Add(_klipyApiKey); Settings.Add(_klipyApiKey);
Settings.Add(_resultCount); Settings.Add(_resultCount);
Settings.Add(_closeAfterCopy);
Settings.Add(_pasteAfterCopy);
// Load settings from file upon initialization // Load settings from file upon initialization
LoadSettings(); LoadSettings();
Settings.SettingsChanged += (_, _) => SaveSettings(); Settings.SettingsChanged += (_, _) => { SaveSettings(); };
} }
public string SettingsPath => FilePath; public string SettingsPath => FilePath;
@@ -31,19 +33,34 @@ internal sealed class SettingsManager : JsonSettingsManager
ErrorMessage = "Klipy API key is required." ErrorMessage = "Klipy API key is required."
}; };
private const int MinResultCount = 30;
private const int MaxResultCount = 100;
private readonly IntegerSetting _resultCount = new( private readonly IntegerSetting _resultCount = new(
Namespaced(nameof(ResultCount)), Namespaced(nameof(ResultCount)),
"Result count", "Result count",
"Number of GIF results to load for each search.", "Number of GIF results to load for each search.",
50, 50,
20, MinResultCount,
100) MaxResultCount)
{ {
Placeholder = "Min 20, max 100", Placeholder = $"Min {MinResultCount}, max {MaxResultCount}",
IsRequired = true, 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 public string KlipyApiKey
{ {
get 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() private static string SettingsJsonPath()
{ {
var directory = Utilities.BaseSettingsPath("Olive"); var directory = Utilities.BaseSettingsPath("Olive");
+15 -1
View File
@@ -24,8 +24,22 @@ public sealed partial class OliveCommandsProvider : CommandProvider
{ {
Title = "Olive GIF Picker", Title = "Olive GIF Picker",
Subtitle = "Browse through lots of GIFs and find the one that suits you best!", 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 Icon = Icon
},
new CommandItem(_settingsManager.Settings.SettingsPage)
{
Title = "Olive Settings",
Subtitle = "Configure the Klipy API key and result count.",
Icon = new IconInfo("\uE713")
} }
]; ];
} }
+3 -3
View File
@@ -11,11 +11,11 @@
<Identity <Identity
Name="Olive" Name="Olive"
Publisher="CN=OlivePrivate" Publisher="CN=OlivePrivate"
Version="0.0.42.0"/> Version="0.0.60.0"/>
<Properties> <Properties>
<DisplayName>Olive</DisplayName> <DisplayName>Olive</DisplayName>
<PublisherDisplayName>Private</PublisherDisplayName> <PublisherDisplayName>EndMove 'contact@endmove.eu'</PublisherDisplayName>
<Logo>Assets\StoreLogo.png</Logo> <Logo>Assets\AppLogo150.scale-200.png</Logo>
</Properties> </Properties>
<Dependencies> <Dependencies>
+42 -11
View File
@@ -17,25 +17,27 @@ internal sealed class GifListItemFactory
_settingsManager = settingsManager; _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, Title = string.Empty,
Subtitle = string.Empty, Subtitle = string.Empty,
Icon = new IconInfo(gif.ThumbnailGifUrl.ToString()) Icon = new IconInfo(gif.ThumbnailGifUrl.ToString()),
MoreCommands = CommonMoreCommands()
}; };
} }
public CommandItem InitialContent() 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 Subtitle = _settingsManager.HasKlipyApiKey
? "Type something like thanks, excited, cat, or confused." ? "Type something like thanks, excited, cat, or confused."
: "Open Olive settings and set the Klipy API key.", : "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...", Title = "Loading GIFs...",
Subtitle = $"Searching Klipy for \"{search}\"", Subtitle = $"Searching Klipy for \"{search}\"",
Icon = _icon Icon = _icon,
MoreCommands = CommonMoreCommands()
}; };
} }
public CommandItem MissingApiKeyContent() public CommandItem MissingApiKeyContent()
{ {
return new CommandItem(new NoOpCommand()) return new CommandItem(_settingsManager.Settings.SettingsPage)
{ {
Title = "Klipy API key missing", Title = "Klipy API key missing",
Subtitle = "Open Olive settings and paste your Klipy API key.", 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", Title = "No GIF found",
Subtitle = $"Try another search for \"{search}\".", Subtitle = $"Try another search for \"{search}\".",
Icon = _icon Icon = _icon,
MoreCommands = CommonMoreCommands()
}; };
} }
@@ -75,7 +80,33 @@ internal sealed class GifListItemFactory
{ {
Title = "Search failed", Title = "Search failed",
Subtitle = ex.Message, 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)
}; };
} }
} }
+3 -3
View File
@@ -9,7 +9,7 @@ namespace Olive.Pages;
internal sealed partial class GifPickerPage : IDynamicListPage internal sealed partial class GifPickerPage : IDynamicListPage
{ {
private const int KlipyMaxPageSize = 50; 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 Lock _stateLock = new();
private readonly SettingsManager _settingsManager; private readonly SettingsManager _settingsManager;
@@ -63,7 +63,7 @@ internal sealed partial class GifPickerPage : IDynamicListPage
public IFilters? Filters => null; 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 public ICommandItem EmptyContent
{ {
@@ -181,7 +181,7 @@ internal sealed partial class GifPickerPage : IDynamicListPage
return; return;
} }
ReplaceState(gifs.Select(GifListItemFactory.CreateGifItem).ToArray()); ReplaceState(gifs.Select(_itemFactory.CreateGifItem).ToArray());
} }
catch (OperationCanceledException) catch (OperationCanceledException)
{ {
+119
View File
@@ -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<Input>());
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;
}
}
+31 -312
View File
@@ -1,120 +1,19 @@
# Olive # Olive
Olive 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!
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!
## Requirements ## Requirements
- - Windows 10/11 with PowerToys and Command Palette enabled.
- .NET SDK 10.
Windows - Windows SDK `10.0.22621.0` or a compatible newer SDK.
10/11 - A Klipy API key.
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 ## Klipy API Key
Olive 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.
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 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.
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 ## Build
@@ -128,251 +27,71 @@ dotnet build .\Olive.sln -c Debug -p:Platform=x64
.\scripts\Build-OlivePackage.ps1 .\scripts\Build-OlivePackage.ps1
``` ```
Or Or double-click:
double-click:
```text ```text
scripts\Build-OlivePackage.cmd scripts\Build-OlivePackage.cmd
``` ```
This This creates the shareable package in `dist\OlivePackage`.
creates
the
shareable
package
in
`dist\OlivePackage`.
The 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.
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 ## Install
From From `dist\OlivePackage`:
`dist\OlivePackage`:
```powershell ```powershell
.\Install-Olive.ps1 .\Install-Olive.ps1
``` ```
Or Or double-click:
double-click:
```text ```text
Install-Olive.cmd Install-Olive.cmd
``` ```
The The script asks for administrator rights, imports `OlivePrivate.cer` into `Cert:\LocalMachine\Root`, then opens the `.msix` with Windows App Installer.
script
asks
for
administrator
rights,
imports
`OlivePrivate.cer`
into
`Cert:\LocalMachine\Root`,
then
opens
the
`.msix`
with
Windows
App
Installer.
Restart Restart PowerToys after installation.
PowerToys
after
installation.
## Uninstall ## Uninstall
From From `dist\OlivePackage` or `scripts\`:
`dist\OlivePackage`
or
`scripts\`:
```powershell ```powershell
.\Uninstall-Olive.ps1 .\Uninstall-Olive.ps1
``` ```
Or Or double-click:
double-click:
```text ```text
Uninstall-Olive.cmd Uninstall-Olive.cmd
``` ```
By By default, uninstall removes the Olive package, the Olive certificate from `Cert:\LocalMachine\Root`, and Olive user data.
default,
uninstall
removes
the
Olive
package,
the
Olive
certificate
from
`Cert:\LocalMachine\Root`,
and
Olive
user
data.
Use Use `-KeepCertificate` to keep the certificate.
`-KeepCertificate`
to
keep
the
certificate.
Use Use `-KeepUserData` to keep Olive user data.
`-KeepUserData`
to
keep
Olive
user
data.
## Usage ## Usage
1. 1. Open PowerToys Command Palette.
2. Launch `Olive GIF Picker`.
Open 3. Search for a GIF.
PowerToys 4. Select a result with Enter.
Command 5. Paste with Ctrl+V in an app that accepts animated GIF files.
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 ## Sharing
- - Share only `dist\OlivePackage`.
- Do not share `dist\private\OlivePrivate.pfx`.
Share - Do not include the Klipy API key in the package.
only - Each user must set the Klipy key in Olive settings.
`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 ## Notes
- - Olive copies GIFs as files to preserve animation.
- Some apps may reject file pasting or use fallback text only.
Olive - GIFs are downloaded in memory when copied.
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.