520 lines
18 KiB
Python
520 lines
18 KiB
Python
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()
|
|
|
|
# Use GLib.Thread for proper async execution
|
|
GLib.Thread.new("cliphist-loader", self._load_clipboard_items_thread, None)
|
|
|
|
def _load_clipboard_items_thread(self, user_data):
|
|
"""Background thread worker for loading clipboard items"""
|
|
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)
|
|
# Update UI from main thread
|
|
GLib.idle_add(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:
|
|
GLib.idle_add(self._loading_finished)
|
|
|
|
def _loading_finished(self):
|
|
"""Handle loading completion on main thread"""
|
|
self._loading = False
|
|
if self._pending_updates:
|
|
self._pending_updates = False
|
|
GLib.Thread.new("cliphist-loader", self._load_clipboard_items_thread, None)
|
|
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 asynchronously using background thread"""
|
|
GLib.Thread.new("image-preview", self._load_image_preview_thread, (item_id, button))
|
|
|
|
def _load_image_preview_thread(self, data):
|
|
"""Background thread worker for loading image preview"""
|
|
item_id, button = data
|
|
try:
|
|
if item_id in self.image_cache:
|
|
pixbuf = self.image_cache[item_id]
|
|
GLib.idle_add(self._update_image_button, button, pixbuf)
|
|
return
|
|
|
|
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
|
|
|
|
GLib.idle_add(self._update_image_button, button, pixbuf)
|
|
except Exception as e:
|
|
print(f"Error loading image preview: {e}", file=sys.stderr)
|
|
|
|
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 (async)"""
|
|
GLib.Thread.new("paste-item", self._paste_item_thread, item_id)
|
|
|
|
def _paste_item_thread(self, item_id):
|
|
"""Background thread worker for pasting clipboard item"""
|
|
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)
|
|
|
|
def delete_item(self, item_id):
|
|
"""Delete the selected clipboard item (async)"""
|
|
GLib.Thread.new("delete-item", self._delete_item_thread, item_id)
|
|
|
|
def _delete_item_thread(self, item_id):
|
|
"""Background thread worker for deleting clipboard item"""
|
|
try:
|
|
subprocess.run(
|
|
["cliphist", "delete", item_id],
|
|
check=True
|
|
)
|
|
self._pending_updates = True
|
|
if not self._loading:
|
|
GLib.Thread.new("cliphist-loader", self._load_clipboard_items_thread, None)
|
|
except subprocess.CalledProcessError as e:
|
|
print(f"Error deleting clipboard item: {e}", file=sys.stderr)
|
|
|
|
def clear_history(self):
|
|
"""Clear all clipboard history (async)"""
|
|
GLib.Thread.new("clear-history", self._clear_history_thread, None)
|
|
|
|
def _clear_history_thread(self, user_data):
|
|
"""Background thread worker for clearing clipboard history"""
|
|
try:
|
|
subprocess.run(["cliphist", "wipe"], check=True)
|
|
self._pending_updates = True
|
|
if not self._loading:
|
|
GLib.Thread.new("cliphist-loader", self._load_clipboard_items_thread, None)
|
|
except subprocess.CalledProcessError as e:
|
|
print(f"Error clearing clipboard history: {e}", file=sys.stderr)
|
|
|
|
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)
|