#!/usr/bin/env python3 # -*- coding: utf-8 -*- # This file is part of I38. # I38 is free software: you can redistribute it and/or modify it under the terms of the GNU General Public License as published by the Free Software Foundation, # either version 3 of the License, or (at your option) any later version. # I38 is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR # PURPOSE. See the GNU General Public License for more details. # You should have received a copy of the GNU General Public License along with I38. If not, see . import argparse import os import socket import sys from pathlib import Path import gi gi.require_version("Gtk", "3.0") gi.require_version("Gdk", "3.0") gi.require_version("GLib", "2.0") gi.require_version("Pango", "1.0") from gi.repository import Gdk, GLib, Gtk, Pango APP_ID = "i38-clipboard" APP_NAME = "I38 Clipboard History" MAX_ELEMENTS = 50 MAX_ITEM_LENGTH = 80 SOCKET_MESSAGE_SHOW = b"show\n" SOCKET_MESSAGE_PING = b"ping\n" def data_dir(): value = os.environ.get("XDG_DATA_HOME") if value: return Path(value) / APP_ID return Path.home() / ".local" / "share" / APP_ID def runtime_dir(): value = os.environ.get("XDG_RUNTIME_DIR") if value: return Path(value) return Path("/tmp") def socket_path(): return runtime_dir() / f"{APP_ID}-{os.getuid()}.sock" def history_path(): return data_dir() / "history" def format_item(item): text = item.replace("\n", " ").replace("\t", " ") if len(text) <= MAX_ITEM_LENGTH: return text front_length = MAX_ITEM_LENGTH // 2 back_length = max(1, MAX_ITEM_LENGTH - front_length - 3) return text[:front_length] + "..." + text[-back_length:] class History: def __init__(self): self.items = [] self.changed_callbacks = [] def connect_changed(self, callback): self.changed_callbacks.append(callback) def notify_changed(self): for callback in self.changed_callbacks: callback(self.items) def add(self, item, is_from_selection=False): if item is None: return item = str(item) if item == "": return if item in self.items: self.items.remove(item) last_item = self.items[0] if self.items else None if is_from_selection and last_item is not None and ( item.startswith(last_item) or item.endswith(last_item) ): self.items[0] = item else: self.items.insert(0, item) if len(self.items) > MAX_ELEMENTS: self.items = self.items[:MAX_ELEMENTS] self.save() self.notify_changed() def clear(self): self.items = [] self.save() self.notify_changed() def load(self): path = history_path() try: with path.open("rb") as file: length = file.readline() while length: try: bytes_to_read = int(length) except ValueError: break self.items.append(file.read(bytes_to_read).decode("UTF-8")) file.read(1) length = file.readline() except OSError: pass def save(self): path = history_path() try: path.parent.mkdir(parents=True, exist_ok=True) with path.open("wb") as file: for item in self.items: encoded = item.encode("UTF-8") file.write(str(len(encoded)).encode("UTF-8") + b"\n") file.write(encoded + b"\n") except OSError: pass class ClipboardWatcher: def __init__(self, history): self.history = history self.default_clipboard = Gtk.Clipboard.get(Gdk.SELECTION_CLIPBOARD) self.primary_clipboard = Gtk.Clipboard.get(Gdk.SELECTION_PRIMARY) self.default_text = self.default_clipboard.wait_for_text() self.primary_text = self.primary_clipboard.wait_for_text() self.default_clipboard.connect("owner-change", self.on_default_owner_change) self.primary_clipboard.connect("owner-change", self.on_primary_owner_change) if self.default_text: self.history.add(self.default_text) def set_text(self, text): self.default_clipboard.set_text(text, -1) self.primary_clipboard.set_text(text, -1) self.default_text = text self.primary_text = text self.history.add(text) def current_text(self): return self.default_clipboard.wait_for_text() def on_default_owner_change(self, clipboard, event): text = clipboard.wait_for_text() if text != self.default_text: self.default_text = text self.history.add(text) def on_primary_owner_change(self, clipboard, event): text = clipboard.wait_for_text() if text != self.primary_text: self.primary_text = text self.history.add(text, is_from_selection=True) class ClipboardPopup: def __init__(self, history, clipboard): self.history = history self.clipboard = clipboard self.page_rows = 1 self.filter_text = "" self.window = Gtk.Window(title=APP_NAME) self.window.set_default_size(420, 520) self.window.set_border_width(4) self.window.set_skip_taskbar_hint(True) self.window.connect("focus-out-event", self.on_focus_out) box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=4) self.window.add(box) self.liststore = Gtk.ListStore(str, str, str, int, str) self.filter_model = self.liststore.filter_new() self.filter_model.set_visible_func(self.visible_func) self.treeview = Gtk.TreeView(model=self.filter_model) self.treeview.set_headers_visible(False) self.treeview.set_enable_search(False) self.treeview.connect("button-press-event", self.on_tree_button_press) self.treeview.connect("key-press-event", self.on_tree_key_press) self.selection = self.treeview.get_selection() self.add_text_column(0) self.add_text_column(1) self.add_text_column(2, weight_column=3) scrolled_window = Gtk.ScrolledWindow() scrolled_window.set_policy(Gtk.PolicyType.AUTOMATIC, Gtk.PolicyType.AUTOMATIC) scrolled_window.add(self.treeview) box.pack_start(scrolled_window, True, True, 0) self.scrolled_window = scrolled_window self.search_entry = Gtk.Entry() self.search_entry.set_placeholder_text("Search clipboard history") self.search_entry.connect("changed", self.on_search_changed) self.search_entry.connect("key-press-event", self.on_entry_key_press) box.pack_start(self.search_entry, False, True, 0) self.window.get_accessible().set_name(APP_NAME) self.treeview.get_accessible().set_name("Clipboard history") self.search_entry.get_accessible().set_name("Search clipboard history") self.history.connect_changed(lambda items: self.refresh_if_visible()) def add_text_column(self, text_column, weight_column=None): renderer = Gtk.CellRendererText() column = Gtk.TreeViewColumn() column.pack_start(renderer, True) column.add_attribute(renderer, "text", text_column) if weight_column is not None: column.add_attribute(renderer, "weight", weight_column) self.treeview.append_column(column) def on_focus_out(self, widget, event): widget.hide() return False def on_tree_button_press(self, widget, event): if event.button == 1 and event.type == Gdk.EventType._2BUTTON_PRESS: return self.choose_selected() return False def on_tree_key_press(self, widget, event): if event.keyval in (Gdk.KEY_Return, Gdk.KEY_KP_Enter): return self.choose_selected() if event.keyval in (Gdk.KEY_0, Gdk.KEY_KP_0): return self.choose_number(10) if Gdk.KEY_1 <= event.keyval <= Gdk.KEY_9: return self.choose_number(1 + event.keyval - Gdk.KEY_1) if Gdk.KEY_KP_1 <= event.keyval <= Gdk.KEY_KP_9: return self.choose_number(1 + event.keyval - Gdk.KEY_KP_1) if event.keyval == Gdk.KEY_Escape: self.window.hide() return True unicode_char = Gdk.keyval_to_unicode(event.keyval) if unicode_char >= 32: self.search_entry.grab_focus() self.search_entry.insert_text(chr(unicode_char), -1) self.search_entry.set_position(-1) return True return False def on_entry_key_press(self, widget, event): if event.keyval in (Gdk.KEY_Return, Gdk.KEY_KP_Enter): return self.choose_selected() if event.keyval in (Gdk.KEY_Up, Gdk.KEY_KP_Up): self.move_selection(-1) return True if event.keyval in (Gdk.KEY_Down, Gdk.KEY_KP_Down): self.move_selection(1) return True if event.keyval in (Gdk.KEY_Page_Up, Gdk.KEY_KP_Page_Up): self.move_selection(-self.page_rows) return True if event.keyval in (Gdk.KEY_Page_Down, Gdk.KEY_KP_Page_Down): self.move_selection(self.page_rows) return True if event.keyval == Gdk.KEY_Escape: self.window.hide() return True return False def on_search_changed(self, entry): self.renumber_visible_rows(clear=True) self.filter_text = entry.get_text().lower() self.filter_model.refilter() self.renumber_visible_rows(clear=False) self.select_first_row() return True def visible_func(self, model, store_iter, data=None): if self.filter_text == "": return True clipboard_text = model.get_value(store_iter, 4) return self.filter_text in clipboard_text.lower() def show(self): self.fill_store() self.search_entry.set_text("") self.select_first_row() self.position_near_pointer() self.window.show_all() self.window.present_with_time(Gtk.get_current_event_time()) self.treeview.grab_focus() self.update_page_rows() def refresh_if_visible(self): if self.window.get_visible(): self.fill_store() self.select_first_row() def fill_store(self): current_text = self.clipboard.current_text() self.liststore.clear() for item_num, item in enumerate(self.history.items, start=1): if item_num < 10: number = str(item_num) elif item_num == 10: number = "0" else: number = "" if item == current_text: marker = "*" font_weight = Pango.Weight.BOLD else: marker = "" font_weight = Pango.Weight.NORMAL self.liststore.append([number, marker, format_item(item), font_weight, item]) def select_first_row(self): store_iter = self.filter_model.get_iter_first() if store_iter is not None: self.treeview.set_cursor(self.filter_model.get_path(store_iter), None, False) def choose_selected(self): model, store_iter = self.selection.get_selected() if store_iter is None: return False clipboard_text = model.get_value(store_iter, 4) self.clipboard.set_text(clipboard_text) self.window.hide() return True def choose_number(self, item_num): store_iter = self.filter_model.get_iter_first() while item_num > 1 and store_iter is not None: item_num -= 1 store_iter = self.filter_model.iter_next(store_iter) if store_iter is None: return False clipboard_text = self.filter_model.get_value(store_iter, 4) self.clipboard.set_text(clipboard_text) self.window.hide() return True def move_selection(self, num_rows): model, store_iter = self.selection.get_selected() if store_iter is None: self.select_first_row() return if num_rows > 0: iter_direction = model.iter_next else: iter_direction = model.iter_previous num_rows = -num_rows last_iter = store_iter while num_rows > 0 and store_iter is not None: num_rows -= 1 last_iter = store_iter store_iter = iter_direction(store_iter) if store_iter is None: store_iter = last_iter self.treeview.set_cursor(model.get_path(store_iter), None, False) def renumber_visible_rows(self, clear): store_iter = self.filter_model.get_iter_first() item_num = 1 while item_num < 11 and store_iter is not None: number = "" if clear else str(item_num if item_num < 10 else 0) child_iter = self.filter_model.convert_iter_to_child_iter(store_iter) self.liststore.set_value(child_iter, 0, number) item_num += 1 store_iter = self.filter_model.iter_next(store_iter) def update_page_rows(self): model, store_iter = self.selection.get_selected() if store_iter is None: self.page_rows = 1 return row_height = self.treeview.get_cell_area(model.get_path(store_iter), None).height view_height = self.scrolled_window.get_allocated_height() if row_height > 0: self.page_rows = max(1, view_height // row_height) def position_near_pointer(self): display = Gdk.Display.get_default() if display is None: return seat = display.get_default_seat() pointer = seat.get_pointer() screen, x, y = pointer.get_position() monitor = display.get_monitor_at_point(x, y) geometry = monitor.get_geometry() width, height = self.window.get_size() if x + width > geometry.x + geometry.width: x = geometry.x + geometry.width - width if y + height > geometry.y + geometry.height: y = geometry.y + geometry.height - height self.window.set_screen(screen) self.window.move(max(geometry.x, x), max(geometry.y, y)) class ClipboardDaemon: def __init__(self): self.history = History() self.history.load() self.clipboard = ClipboardWatcher(self.history) self.popup = ClipboardPopup(self.history, self.clipboard) self.server_socket = None def run(self): GLib.set_application_name(APP_NAME) self.setup_socket() try: Gtk.main() except KeyboardInterrupt: pass finally: self.cleanup_socket() def setup_socket(self): path = socket_path() path.parent.mkdir(parents=True, exist_ok=True) if path.exists(): if socket_is_live(): print("i38-clipboard daemon is already running") raise SystemExit(0) path.unlink() self.server_socket = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) self.server_socket.setblocking(False) self.server_socket.bind(str(path)) path.chmod(0o600) self.server_socket.listen(5) GLib.io_add_watch(self.server_socket.fileno(), GLib.IO_IN, self.on_socket_ready) def cleanup_socket(self): if self.server_socket is not None: self.server_socket.close() self.server_socket = None try: socket_path().unlink() except OSError: pass def on_socket_ready(self, _fd, condition): if condition & (GLib.IO_ERR | GLib.IO_HUP): return False try: connection, _address = self.server_socket.accept() except BlockingIOError: return True with connection: message = connection.recv(1024).strip() if message == SOCKET_MESSAGE_SHOW.strip(): GLib.idle_add(self.popup.show) connection.sendall(b"ok\n") elif message == SOCKET_MESSAGE_PING.strip(): connection.sendall(b"ok\n") else: connection.sendall(b"unknown command\n") return True def send_request(message): try: with socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) as client: client.settimeout(1) client.connect(str(socket_path())) client.sendall(message) response = client.recv(1024) return response.startswith(b"ok") except OSError: return False def send_show_request(): return send_request(SOCKET_MESSAGE_SHOW) def socket_is_live(): return send_request(SOCKET_MESSAGE_PING) def parse_args(): parser = argparse.ArgumentParser(description=APP_NAME) mode = parser.add_mutually_exclusive_group() mode.add_argument("--daemon", action="store_true", help="run the clipboard history daemon") mode.add_argument("--show", action="store_true", help="show clipboard history from the running daemon") return parser.parse_args() def main(): args = parse_args() if args.show: if send_show_request(): return 0 print("i38-clipboard daemon is not running", file=sys.stderr) return 1 if not Gtk.init_check()[0]: print("i38-clipboard could not connect to a GTK display", file=sys.stderr) return 1 return ClipboardDaemon().run() or 0 if __name__ == "__main__": raise SystemExit(main())