diff --git a/example_cliphist.py b/example_cliphist.py
new file mode 100644
index 0000000..8371974
--- /dev/null
+++ b/example_cliphist.py
@@ -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 " 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*
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)
diff --git a/modules/launcher/plugins/clipboard.py b/modules/launcher/plugins/clipboard.py
index f33e432..d927554 100644
--- a/modules/launcher/plugins/clipboard.py
+++ b/modules/launcher/plugins/clipboard.py
@@ -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)
diff --git a/test_clipboard_search.py b/test_clipboard_search.py
index e50c045..ba1ac23 100644
--- a/test_clipboard_search.py
+++ b/test_clipboard_search.py
@@ -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()
diff --git a/test_image_cache.py b/test_image_cache.py
new file mode 100644
index 0000000..a7b7a97
--- /dev/null
+++ b/test_image_cache.py
@@ -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()