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()