from fabric.widgets.box import Box from fabric.widgets.label import Label from fabric.widgets.button import Button from fabric.widgets.entry import Entry from fabric.widgets.image import Image from fabric.widgets.scrolledwindow import ScrolledWindow from fabric.utils import idle_add, remove_handler from fabric.utils.helpers import get_relative_path from gi.repository import GLib, Gdk, GdkPixbuf import subprocess import os import re import sys import tempfile import utils.icons as icons class ClipHistory(Box): def __init__(self, **kwargs): super().__init__( name="clip-history", visible=False, all_visible=False, **kwargs, ) # Create a temporary directory for image icons self.tmp_dir = tempfile.mkdtemp(prefix="cliphist-") self.image_cache = {} # Cache for image previews self.launcher = kwargs["launcher"] self.selected_index = -1 # Track the selected item index 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, 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, min_content_size=(450, 105), max_content_size=(450, 105), child=self.viewport, ) 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, ], ) 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 # Reset selection self.launcher.close() def open(self): """Open the clipboard history panel and load items""" self.viewport.set_visible(True) if self._loading: return self._loading = True self.search_entry.set_text("") # Clear search self.search_entry.grab_focus() # Start loading in background using GLib.idle_add 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, text=True, check=True ) lines = result.stdout.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 # Truncate content for display display_text = content.strip() if len(display_text) > 100: display_text = display_text[:97] + "..." # Check if this is an image by examining the content is_image = self.is_image_data(content) if is_image: # For images, create item with image preview button = Button( name="slot-button", child=Box( name="slot-box", orientation="h", spacing=10, children=[ Image(name="clip-icon", h_align="start"), # Placeholder 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), ) # Load image preview in background self._load_image_preview_async(item_id, button) else: # For text, create regular item button = self.create_text_item_button(item_id, display_text) # Add key press event handler for Enter key button.connect( "key-press-event", lambda widget, event, id=item_id: self.on_item_key_press(widget, event, id), ) # Make sure button can receive focus and key events 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""" # Check for common image data patterns return ( content.startswith("data:image/") or content.startswith("\x89PNG") or content.startswith("GIF8") or content.startswith("\xff\xd8\xff") # JPEG or re.match(r"^\s* visible_bottom: # Item below viewport - align to 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 # Get the item ID from the first part before the tab 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 # Get the item ID from the first part before the tab 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): # Copy item to clipboard and close 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)