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.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.59.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;
}
}