fix: small logic fixes and improvements

This commit is contained in:
JNIH
2026-07-31 12:47:04 +02:00
parent 00deea0ca1
commit 2498e56201
7 changed files with 265 additions and 30 deletions
+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.59.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;
}
}