Files
SDG-DRIFT/Ax_Shell/modules/cliphist.py
T
2026-06-03 21:26:54 +02:00

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)