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.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<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)
+40 -5
View File
@@ -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");
+15 -1
View File
@@ -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")
}
];
}
+3 -3
View File
@@ -11,11 +11,11 @@
<Identity
Name="Olive"
Publisher="CN=OlivePrivate"
Version="0.0.42.0"/>
Version="0.0.60.0"/>
<Properties>
<DisplayName>Olive</DisplayName>
<PublisherDisplayName>Private</PublisherDisplayName>
<Logo>Assets\StoreLogo.png</Logo>
<PublisherDisplayName>EndMove 'contact@endmove.eu'</PublisherDisplayName>
<Logo>Assets\AppLogo150.scale-200.png</Logo>
</Properties>
<Dependencies>
+42 -11
View File
@@ -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)
};
}
}
+3 -3
View File
@@ -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)
{
+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
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.