♻ refactor(clipboard): enhance clipboard plugin with forever image caching and improved search

This commit is contained in:
sankalp committed 2025-07-20 22:15:23 +05:45
1 parent d5eb8eb3ad
commit f2fdc20ff3
4 files changed
+665 -44

No files matched your search

+505
View File
@@ -0,0 +1,505 @@
import os
import re
import subprocess
import sys
import tempfile
from fabric.utils import idle_add, remove_handler
from fabric.utils.helpers import get_relative_path
from fabric.widgets.box import Box
from fabric.widgets.button import Button
from fabric.widgets.entry import Entry
from fabric.widgets.image import Image
from fabric.widgets.label import Label
from fabric.widgets.scrolledwindow import ScrolledWindow
from gi.repository import Gdk, GdkPixbuf, GLib
import modules.icons as icons
class ClipHistory(Box):
def __init__(self, **kwargs):
super().__init__(
name="clip-history",
visible=False,
all_visible=False,
**kwargs,
)
self.tmp_dir = tempfile.mkdtemp(prefix="cliphist-")
self.image_cache = {}
self.notch = kwargs["notch"]
self.selected_index = -1
self._arranger_handler = 0
self.clipboard_items = []
self._loading = False
self._pending_updates = False
self.viewport = Box(name="viewport", spacing=4, orientation="v")
self.search_entry = Entry(
name="search-entry",
placeholder="Search Clipboard History...",
h_expand=True,
h_align="fill",
notify_text=self.filter_items,
on_activate=lambda entry, *_: self.use_selected_item(),
on_key_press_event=self.on_search_entry_key_press,
)
self.search_entry.props.xalign = 0.5
self.scrolled_window = ScrolledWindow(
name="scrolled-window",
spacing=10,
h_expand=True,
v_expand=True,
h_align="fill",
v_align="fill",
child=self.viewport,
propagate_width=False,
propagate_height=False,
)
self.header_box = Box(
name="header_box",
spacing=10,
orientation="h",
children=[
Button(
name="clear-button",
child=Label(name="clear-label", markup=icons.trash),
on_clicked=lambda *_: self.clear_history(),
),
self.search_entry,
Button(
name="close-button",
child=Label(name="close-label", markup=icons.cancel),
tooltip_text="Exit",
on_clicked=lambda *_: self.close()
),
],
)
self.history_box = Box(
name="launcher-box",
spacing=10,
h_expand=True,
orientation="v",
children=[
self.header_box,
self.scrolled_window,
],
)
self.add(self.history_box)
self.show_all()
def close(self):
"""Close the clipboard history panel"""
self.viewport.children = []
self.selected_index = -1
self.notch.close_notch()
def open(self):
"""Open the clipboard history panel and load items"""
if self._loading:
return
self._loading = True
self.search_entry.set_text("")
self.search_entry.grab_focus()
GLib.idle_add(self._load_clipboard_items_thread)
def _load_clipboard_items_thread(self):
"""Worker for loading clipboard items, now runs in main loop via idle_add"""
try:
result = subprocess.run(
["cliphist", "list"],
capture_output=True,
check=True
)
# Decode stdout with error handling
stdout_str = result.stdout.decode('utf-8', errors='replace')
lines = stdout_str.strip().split('\n')
new_items = []
for line in lines:
if not line or "<meta http-equiv" in line:
continue
new_items.append(line)
self._update_items(new_items)
except subprocess.CalledProcessError as e:
print(f"Error loading clipboard history: {e}", file=sys.stderr)
except Exception as e:
print(f"Unexpected error: {e}", file=sys.stderr)
finally:
self._loading = False
if self._pending_updates:
self._pending_updates = False
GLib.idle_add(self._load_clipboard_items_thread)
return False
def _update_items(self, new_items):
"""Update the items list from main thread"""
self.clipboard_items = new_items
self.display_clipboard_items()
def display_clipboard_items(self, filter_text=""):
"""Display clipboard items in the viewport"""
remove_handler(self._arranger_handler) if self._arranger_handler else None
self.viewport.children = []
self.selected_index = -1
filtered_items = []
for item in self.clipboard_items:
content = item.split('\t', 1)[1] if '\t' in item else item
if filter_text.lower() in content.lower():
filtered_items.append(item)
if not filtered_items:
container = Box(
name="no-clip-container",
orientation="v",
h_align="center",
v_align="center",
h_expand=True,
v_expand=True
)
label = Label(
name="no-clip",
markup=icons.clipboard,
h_align="center",
v_align="center",
)
container.add(label)
self.viewport.add(container)
return
self._display_items_batch(filtered_items, 0, 10)
def _display_items_batch(self, items, start, batch_size):
"""Display items in batches to keep UI responsive"""
end = min(start + batch_size, len(items))
for i in range(start, end):
item = items[i]
self.viewport.add(self.create_clipboard_item(item))
if end < len(items):
GLib.idle_add(self._display_items_batch, items, end, batch_size)
else:
if self.search_entry.get_text() and self.viewport.get_children():
self.update_selection(0)
def create_clipboard_item(self, item):
"""Create a button for a clipboard item"""
parts = item.split('\t', 1)
item_id = parts[0] if len(parts) > 1 else "0"
content = parts[1] if len(parts) > 1 else item
display_text = content.strip()
if len(display_text) > 100:
display_text = display_text[:97] + "..."
is_image = self.is_image_data(content)
if is_image:
button = Button(
name="slot-button",
child=Box(
name="slot-box",
orientation="h",
spacing=10,
children=[
Image(name="clip-icon", h_align="start"),
Label(
name="clip-label",
label="[Image]",
ellipsization="end",
v_align="center",
h_align="start",
h_expand=True,
),
],
),
tooltip_text="Image in clipboard",
on_clicked=lambda *_, id=item_id: self.paste_item(id),
)
self._load_image_preview_async(item_id, button)
else:
button = self.create_text_item_button(item_id, display_text)
button.connect("key-press-event", lambda widget, event, id=item_id: self.on_item_key_press(widget, event, id))
button.set_can_focus(True)
button.add_events(Gdk.EventMask.KEY_PRESS_MASK)
return button
def _load_image_preview_async(self, item_id, button):
"""Load image preview using GLib.idle_add instead of threads"""
def load_image():
try:
if item_id in self.image_cache:
pixbuf = self.image_cache[item_id]
else:
result = subprocess.run(
["cliphist", "decode", item_id],
capture_output=True,
check=True
)
loader = GdkPixbuf.PixbufLoader()
loader.write(result.stdout)
loader.close()
pixbuf = loader.get_pixbuf()
width, height = pixbuf.get_width(), pixbuf.get_height()
max_size = 72
if width > height:
new_width = max_size
new_height = int(height * (max_size / width))
else:
new_height = max_size
new_width = int(width * (max_size / height))
pixbuf = pixbuf.scale_simple(new_width, new_height, GdkPixbuf.InterpType.BILINEAR)
self.image_cache[item_id] = pixbuf
self._update_image_button(button, pixbuf)
except Exception as e:
print(f"Error loading image preview: {e}", file=sys.stderr)
return False
GLib.idle_add(load_image)
def _update_image_button(self, button, pixbuf):
"""Update the button with the loaded image preview"""
box = button.get_child()
if box and len(box.get_children()) > 0:
image_widget = box.get_children()[0]
if isinstance(image_widget, Image):
image_widget.set_from_pixbuf(pixbuf)
def create_text_item_button(self, item_id, display_text):
"""Create a button for a text clipboard item"""
return Button(
name="slot-button",
child=Box(
name="slot-box",
orientation="h",
spacing=10,
children=[
Label(
name="clip-icon",
markup=icons.clip_text,
h_align="start",
),
Label(
name="clip-label",
label=display_text,
ellipsization="end",
v_align="center",
h_align="start",
h_expand=True,
),
],
),
tooltip_text=display_text,
on_clicked=lambda *_: self.paste_item(item_id),
)
def is_image_data(self, content):
"""Determine if clipboard content is likely an image"""
return (
content.startswith("data:image/") or
content.startswith("\x89PNG") or
content.startswith("GIF8") or
content.startswith("\xff\xd8\xff") or
re.match(r'^\s*<img\s+', content) is not None or
"binary" in content.lower() and any(ext in content.lower() for ext in ["jpg", "jpeg", "png", "bmp", "gif"])
)
def paste_item(self, item_id):
"""Copy the selected item to the clipboard and close (GLib.idle_add)"""
def paste():
try:
result = subprocess.run(
["cliphist", "decode", item_id],
capture_output=True,
check=True
)
subprocess.run(
["wl-copy"],
input=result.stdout,
check=True
)
GLib.idle_add(self.close)
except subprocess.CalledProcessError as e:
print(f"Error pasting clipboard item: {e}", file=sys.stderr)
return False
GLib.idle_add(paste)
def delete_item(self, item_id):
"""Delete the selected clipboard item (GLib.idle_add)"""
def delete():
try:
subprocess.run(
["cliphist", "delete", item_id],
check=True
)
self._pending_updates = True
if not self._loading:
GLib.idle_add(self._load_clipboard_items_thread)
except subprocess.CalledProcessError as e:
print(f"Error deleting clipboard item: {e}", file=sys.stderr)
return False
GLib.idle_add(delete)
def clear_history(self):
"""Clear all clipboard history (GLib.idle_add)"""
def clear():
try:
subprocess.run(["cliphist", "wipe"], check=True)
self._pending_updates = True
if not self._loading:
GLib.idle_add(self._load_clipboard_items_thread)
except subprocess.CalledProcessError as e:
print(f"Error clearing clipboard history: {e}", file=sys.stderr)
return False
GLib.idle_add(clear)
def filter_items(self, entry, *_):
"""Filter clipboard items based on search text"""
self.display_clipboard_items(entry.get_text())
def on_search_entry_key_press(self, widget, event):
"""Handle key presses in the search entry"""
if event.keyval == Gdk.KEY_Down:
self.move_selection(1)
return True
elif event.keyval == Gdk.KEY_Up:
self.move_selection(-1)
return True
elif event.keyval in (Gdk.KEY_Return, Gdk.KEY_KP_Enter):
self.use_selected_item()
return True
elif event.keyval == Gdk.KEY_Delete:
self.delete_selected_item()
return True
elif event.keyval == Gdk.KEY_Escape:
self.close()
return True
return False
def update_selection(self, new_index):
"""Update the selected item in the viewport"""
children = self.viewport.get_children()
if self.selected_index != -1 and self.selected_index < len(children):
current_button = children[self.selected_index]
current_button.get_style_context().remove_class("selected")
if new_index != -1 and new_index < len(children):
new_button = children[new_index]
new_button.get_style_context().add_class("selected")
self.selected_index = new_index
self.scroll_to_selected(new_button)
else:
self.selected_index = -1
def move_selection(self, delta):
"""Move the selection up or down"""
children = self.viewport.get_children()
if not children:
return
if self.selected_index == -1 and delta == 1:
new_index = 0
else:
new_index = self.selected_index + delta
new_index = max(0, min(new_index, len(children) - 1))
self.update_selection(new_index)
def scroll_to_selected(self, button):
"""Scroll to ensure the selected item is visible"""
def scroll():
adj = self.scrolled_window.get_vadjustment()
alloc = button.get_allocation()
if alloc.height == 0:
return False
y = alloc.y
height = alloc.height
page_size = adj.get_page_size()
current_value = adj.get_value()
visible_top = current_value
visible_bottom = current_value + page_size
if y < visible_top:
adj.set_value(y)
elif y + height > visible_bottom:
new_value = y + height - page_size
adj.set_value(new_value)
return False
GLib.idle_add(scroll)
def use_selected_item(self):
"""Use (paste) the selected clipboard item"""
children = self.viewport.get_children()
if not children or self.selected_index == -1 or self.selected_index >= len(self.clipboard_items):
return
item_line = self.clipboard_items[self.selected_index]
item_id = item_line.split('\t', 1)[0]
self.paste_item(item_id)
def delete_selected_item(self):
"""Delete the selected clipboard item"""
children = self.viewport.get_children()
if not children or self.selected_index == -1:
return
item_line = self.clipboard_items[self.selected_index]
item_id = item_line.split('\t', 1)[0]
self.delete_item(item_id)
def on_item_key_press(self, widget, event, item_id):
"""Handle key press events on clipboard items"""
if event.keyval in (Gdk.KEY_Return, Gdk.KEY_KP_Enter):
self.paste_item(item_id)
return True
return False
def __del__(self):
"""Clean up temporary files on destruction"""
try:
if hasattr(self, 'tmp_dir') and os.path.exists(self.tmp_dir):
import shutil
shutil.rmtree(self.tmp_dir)
self.image_cache.clear()
except Exception as e:
print(f"Error cleaning up temporary files: {e}", file=sys.stderr)
+63 -44
View File
@@ -20,18 +20,16 @@ class ClipboardPlugin(PluginBase):
self.display_name = "Clipboard History"
self.description = "Search and manage clipboard history using cliphist"
# Performance settings
self.max_results = 20
# Performance settings - show more history like example_cliphist.py
self.max_results = 50
# Cache clipboard items for 5 seconds (more responsive)
self.cache_ttl = 5
self.image_cache_ttl = 300 # Cache images for 5 minutes
# Initialize cache and temp directory
self.tmp_dir = tempfile.mkdtemp(prefix="cliphist-")
self.image_cache: Dict[str, GdkPixbuf.Pixbuf] = {}
self.image_cache: Dict[str, GdkPixbuf.Pixbuf] = {} # Cache images forever like example_cliphist.py
self.clipboard_items_cache: List[str] = []
self.cache_timestamp = 0
self.image_cache_timestamps: Dict[str, float] = {}
# Threading
self.executor = ThreadPoolExecutor(
@@ -73,7 +71,6 @@ class ClipboardPlugin(PluginBase):
with self.cache_lock:
self.image_cache.clear()
self.clipboard_items_cache.clear()
self.image_cache_timestamps.clear()
except Exception as e:
print(f"Error cleaning up temporary files: {e}", file=sys.stderr)
@@ -82,8 +79,7 @@ class ClipboardPlugin(PluginBase):
with self.cache_lock:
self.clipboard_items_cache.clear()
self.cache_timestamp = 0
self.image_cache.clear()
self.image_cache_timestamps.clear()
# Keep image cache forever like example_cliphist.py
def _force_launcher_refresh(self):
"""Force the launcher to refresh its results."""
@@ -174,10 +170,7 @@ class ClipboardPlugin(PluginBase):
self.clipboard_items_cache = items
self.cache_timestamp = current_time
# If data changed significantly, clear image cache too
if data_changed:
self.image_cache.clear()
self.image_cache_timestamps.clear()
# Keep image cache forever like example_cliphist.py - don't clear on data changes
return items
except subprocess.CalledProcessError as e:
@@ -217,9 +210,13 @@ class ClipboardPlugin(PluginBase):
return None
def _is_image_data(self, content: str) -> bool:
"""Determine if clipboard content is likely an image."""
return "binary" in content.lower() and any(
ext in content.lower() for ext in ["jpg", "jpeg", "png", "bmp", "gif"]
"""Determine if clipboard content is likely an image (like example_cliphist.py)."""
return (
content.startswith("data:image/") or
content.startswith("\x89PNG") or
content.startswith("GIF8") or
content.startswith("\xff\xd8\xff") or
"binary" in content.lower() and any(ext in content.lower() for ext in ["jpg", "jpeg", "png", "bmp", "gif"])
)
def _get_text_preview(self, content: str) -> str:
@@ -229,17 +226,10 @@ class ClipboardPlugin(PluginBase):
return content
def _load_image_preview_cached(self, item_id: str) -> Optional[GdkPixbuf.Pixbuf]:
"""Load image preview with caching and timeout."""
current_time = time.time()
# Check cache first
"""Load image preview with forever caching (like example_cliphist.py)."""
# Check cache first - cache forever like example_cliphist.py
with self.cache_lock:
if (
item_id in self.image_cache
and item_id in self.image_cache_timestamps
and current_time - self.image_cache_timestamps[item_id]
< self.image_cache_ttl
):
if item_id in self.image_cache:
return self.image_cache[item_id]
try:
@@ -253,7 +243,7 @@ class ClipboardPlugin(PluginBase):
if pixbuf:
with self.cache_lock:
self.image_cache[item_id] = pixbuf
self.image_cache_timestamps[item_id] = current_time
return pixbuf
except Exception as e:
print(f"Error loading image preview: {e}", file=sys.stderr)
@@ -312,15 +302,15 @@ class ClipboardPlugin(PluginBase):
item_id = parts[0] if len(parts) > 1 else str(i)
content = parts[1] if len(parts) > 1 else item
# Handle image content with lazy loading
# Handle image content like example_cliphist.py
if self._is_image_data(content):
# Check if image is already cached
# Check if image is already cached (forever cache like example_cliphist.py)
cached_pixbuf = None
with self.cache_lock:
cached_pixbuf = self.image_cache.get(item_id)
if cached_pixbuf:
# Use cached image
# Use cached image immediately
result = Result(
title="Image from clipboard",
subtitle="Click to copy image to clipboard",
@@ -332,19 +322,45 @@ class ClipboardPlugin(PluginBase):
data={"bypass_max_results": True},
)
else:
# Show placeholder and load image in background
result = Result(
title="Image from clipboard",
subtitle="Loading preview...",
description="Image content",
icon_name="image-x-generic",
relevance=relevance,
plugin_name=self.name,
action=lambda id=item_id: self._copy_to_clipboard(id),
data={"bypass_max_results": True},
)
# Load image in background (don't wait for it)
self.executor.submit(self._load_image_preview_cached, item_id)
# Try to load image immediately like example_cliphist.py
try:
immediate_pixbuf = self._load_image_preview_cached(item_id)
if immediate_pixbuf:
# Successfully loaded immediately
result = Result(
title="Image from clipboard",
subtitle="Click to copy image to clipboard",
description="Image content",
icon=immediate_pixbuf,
relevance=relevance,
plugin_name=self.name,
action=lambda id=item_id: self._copy_to_clipboard(id),
data={"bypass_max_results": True},
)
else:
# Show placeholder if loading failed
result = Result(
title="Image from clipboard",
subtitle="Click to copy image to clipboard",
description="Image content",
icon_name="image-x-generic",
relevance=relevance,
plugin_name=self.name,
action=lambda id=item_id: self._copy_to_clipboard(id),
data={"bypass_max_results": True},
)
except Exception:
# Fallback to placeholder
result = Result(
title="Image from clipboard",
subtitle="Click to copy image to clipboard",
description="Image content",
icon_name="image-x-generic",
relevance=relevance,
plugin_name=self.name,
action=lambda id=item_id: self._copy_to_clipboard(id),
data={"bypass_max_results": True},
)
results.append(result)
continue
@@ -400,8 +416,11 @@ class ClipboardPlugin(PluginBase):
timeout=3,
)
# Invalidate cache since clipboard content has changed
self.invalidate_cache()
# Don't invalidate image cache - keep images cached forever like example_cliphist.py
# Only invalidate clipboard items cache
with self.cache_lock:
self.clipboard_items_cache.clear()
self.cache_timestamp = 0
except subprocess.SubprocessError as e:
print(f"Error copying to clipboard: {e}", file=sys.stderr)
+16
View File
@@ -36,11 +36,27 @@ def test_clipboard_search():
for i, result in enumerate(results[:5]): # Show first 5
print(f" {i+1}. {result.relevance:.2f} - {result.title[:50]}...")
# Test image handling
print("\n🖼️ Testing image handling...")
image_results = [r for r in results if "Image from clipboard" in r.title]
if image_results:
print(f"Found {len(image_results)} image results")
for img_result in image_results[:3]: # Show first 3
has_icon = hasattr(img_result, 'icon') and img_result.icon is not None
print(f" - {img_result.subtitle} (Icon loaded: {has_icon})")
else:
print("No image results found (normal if no images in clipboard)")
print("\n✅ Clipboard search test completed!")
print("The plugin now:")
print(" - Searches through ALL clipboard items (not just first 20)")
print(" - Sorts results by relevance (exact matches first)")
print(" - Shows up to 50 results (increased from 20)")
print(" - Limits results only after filtering and sorting")
print(" - FOREVER image caching (like example_cliphist.py)")
print(" - Better image detection (supports more formats)")
print(" - Immediate image loading when cached")
print(" - No cache expiration - images stay cached until restart")
plugin.cleanup()
+81
View File
@@ -0,0 +1,81 @@
#!/usr/bin/env python3
"""
Test script to verify image caching works forever like example_cliphist.py.
"""
import sys
import os
import time
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
def test_image_cache():
"""Test image caching functionality."""
try:
from modules.launcher.plugins.clipboard import ClipboardPlugin
print("Testing image cache forever functionality...")
# Create plugin instance
plugin = ClipboardPlugin()
plugin.initialize()
print("✅ Plugin initialized successfully")
# Test image caching
print("\n🖼️ Testing image cache...")
# First query - should load images and cache them
print("First query (loading images)...")
results1 = plugin.query("")
image_results1 = [r for r in results1 if "Image from clipboard" in r.title]
if image_results1:
print(f"Found {len(image_results1)} image results")
# Check cache size
cache_size_before = len(plugin.image_cache)
print(f"Image cache size: {cache_size_before}")
# Wait a moment
time.sleep(1)
# Second query - should use cached images
print("\nSecond query (should use cache)...")
results2 = plugin.query("")
image_results2 = [r for r in results2 if "Image from clipboard" in r.title]
cache_size_after = len(plugin.image_cache)
print(f"Image cache size after second query: {cache_size_after}")
# Check if images have icons (meaning they're loaded)
cached_images = 0
for img_result in image_results2:
if hasattr(img_result, 'icon') and img_result.icon is not None:
cached_images += 1
print(f"Images with loaded icons: {cached_images}/{len(image_results2)}")
if cached_images > 0:
print("✅ Images are being cached and reused!")
else:
print("⚠️ No cached images found")
else:
print("No image results found (normal if no images in clipboard)")
print("\n✅ Image cache test completed!")
print("Cache behavior:")
print(" - Images are cached forever (no expiration)")
print(" - Cache survives multiple queries")
print(" - Images load immediately when cached")
print(" - Cache only clears on plugin restart")
plugin.cleanup()
except Exception as e:
print(f"❌ Error testing image cache: {e}")
import traceback
traceback.print_exc()
if __name__ == "__main__":
test_image_cache()