Files
Modus/mine/modules/launcher/plugins/clipboard.py
T
2026-07-12 18:56:10 +05:45

263 lines
8.4 KiB
Python

from collections.abc import Iterator
from fabric.utils import Gio, GLib, idle_add, os, remove_handler
from fabric.widgets.box import Box
from fabric.widgets.button import Button
from fabric.widgets.label import Label
from config.data import CLIPBOARD_DB_PATH, CLIPBOARD_THUMBS_DIR
from modules.launcher.base import LauncherPlugin
from utils.functions import copy_image, copy_text, run_command, trigger_paste_shortcut
class ClipboardPlugin(LauncherPlugin):
@property
def name(self) -> str:
return "clipboard"
@property
def icon(self) -> str:
return "edit-paste-symbolic"
@property
def keywords(self) -> list[str]:
return ["clip"]
def __init__(self, handler):
super().__init__(handler)
self._query_handler: int = 0
self.history: list[dict] = []
self.cache_dir = CLIPBOARD_THUMBS_DIR
self.monitor: Gio.FileMonitor | None = None
self.max_slots_to_render: int = 30
self._rendered_slots_count: int = 0
os.makedirs(self.cache_dir, exist_ok=True)
self._setup_file_monitor()
def on_search(self, text: str) -> None:
self.query_clipboard(text)
def stop(self):
if self._query_handler:
remove_handler(self._query_handler)
self._query_handler = 0
if self.monitor:
self.monitor.cancel()
self.monitor = None
def _setup_file_monitor(self) -> None:
if not os.path.exists(CLIPBOARD_DB_PATH):
return
file = Gio.File.new_for_path(CLIPBOARD_DB_PATH)
self.monitor = file.monitor_file(Gio.FileMonitorFlags.NONE, None)
self.monitor.connect("changed", self._on_db_changed)
def _on_db_changed(self, _monitor, _file, _other_file, event_type):
if event_type in (
Gio.FileMonitorEvent.CHANGES_DONE_HINT,
Gio.FileMonitorEvent.CREATED,
):
idle_add(self.refresh_history)
def refresh_history(self) -> None:
self._load_history()
def _load_history(self) -> None:
self.history.clear()
result = run_command(["cliphist", "list"], timeout=5)
output = result.stdout if isinstance(result.stdout, str) else ""
lines = output.splitlines()[:100]
for line in lines:
if "\t" not in line:
continue
identifier, content = line.split("\t", 1)
content = content.strip()
entry = {
"type": "text",
"identifier": identifier,
"raw": line,
"content": content,
}
if "binary data" in content:
entry["type"] = "image"
self.history.append(entry)
def _cliphist_decode(self, raw: str) -> bytes | None:
result = run_command(["cliphist", "decode"], input=raw.encode(), text=False)
return result.stdout if isinstance(result.stdout, (bytes, bytearray)) else None
def _cache_image(self, raw_data: str, identifier: str) -> str | None:
img_path = os.path.join(self.cache_dir, f"{identifier}.png")
if os.path.exists(img_path):
return str(img_path)
decoded = self._cliphist_decode(raw_data)
if not decoded:
return None
with open(img_path, "wb") as f:
f.write(decoded)
return str(img_path)
def query_clipboard(self, text: str) -> None:
self.handler.start("clipboard")
self.refresh_history()
self._rendered_slots_count = 0
if not self.history:
self._show_no_results()
return
filtered_entries = self._filter_entries(text)
self._query_handler = idle_add(
self._bake_next_clipboard_slot, filtered_entries, pin=True
)
def _show_no_results(self):
self.handler.slot_ready(
Button(
style_classes="app-slot clipboard-no-results",
child=Box(
orientation="h",
spacing=12,
children=[
Label(
label="No clipboard history found",
style_classes="clipboard-no-results-text",
h_align="center",
),
],
),
),
"clipboard",
)
self.handler.done()
def _filter_entries(self, text: str) -> Iterator[dict]:
if not text.strip():
return iter(self.history)
return iter(
entry
for entry in self.history
if text.lower() in entry.get("content", "").lower()
)
def _bake_next_clipboard_slot(self, iterator: Iterator[dict]) -> bool:
if self._rendered_slots_count >= self.max_slots_to_render:
idle_add(self.handler.done)
return False
entry = next(iterator, None)
if entry is None:
idle_add(self.handler.done)
return False
self._create_clipboard_slot(entry)
self._rendered_slots_count += 1
return True
def _create_clipboard_slot(self, entry: dict) -> None:
if not self._is_image_entry(entry):
self._create_text_slot(entry)
return
self._create_image_slot(entry)
def _is_image_entry(self, entry: dict) -> bool:
return entry["type"] == "image"
def _create_image_slot(self, entry: dict) -> None:
image_path = entry.get("path")
if not image_path or not os.path.exists(image_path):
image_path = self._cache_image(entry["raw"], entry["identifier"]) or ""
if image_path:
entry["path"] = image_path
if not image_path:
self.handler.slot_ready(
Button(
style_classes="app-slot clipboard-text",
child=Box(
orientation="h",
children=[
Label(
label="Image",
style_classes="clipboard-content",
h_align="center",
v_align="center",
max_chars_width=40,
ellipsization="end",
)
],
),
on_clicked=lambda *_: self._copy_clipboard_entry(entry),
),
"clipboard",
)
return
self.handler.slot_ready(
Button(
style_classes="app-slot wallpaper clipboard-image",
child=Box(h_expand=True, v_expand=True)
.build()
.set_style(
f"background-image: url('file://{image_path}');",
compile=False,
)
.unwrap(),
on_clicked=lambda *_: self._copy_clipboard_entry(entry),
),
"clipboard",
)
def _create_text_slot(self, entry: dict) -> None:
content = entry["content"]
display_content = content[:50] + "..." if len(content) > 50 else content
self.handler.slot_ready(
Button(
style_classes="app-slot clipboard-text",
child=Box(
orientation="h",
children=[
Label(
label=display_content,
style_classes="clipboard-content",
h_align="center",
v_align="center",
max_chars_width=40,
ellipsization="end",
)
],
),
tooltip_text=entry["content"],
on_clicked=lambda *_: self._copy_clipboard_entry(entry),
),
"clipboard",
)
def _copy_clipboard_entry(self, entry: dict) -> None:
if entry["type"] == "image" and entry.get("path"):
copy_image(entry["path"])
else:
decoded = self._cliphist_decode(entry["raw"])
if not decoded:
return
copy_text(decoded.decode())
self.handler.launched()
GLib.timeout_add(5, lambda: (trigger_paste_shortcut(0), False)[1])
def handle_external(self, command: str, args: str) -> None:
# Map external commands if needed
pass