Replaced clipster with built in clipboard manager.
This commit is contained in:
Executable
+529
@@ -0,0 +1,529 @@
|
||||
#!/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 <https://www.gnu.org/licenses/>.
|
||||
|
||||
|
||||
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())
|
||||
Reference in New Issue
Block a user