diff --git a/README.md b/README.md index ea9285c..a8b6b6e 100644 --- a/README.md +++ b/README.md @@ -16,7 +16,6 @@ An uppercase I looks like a 1, 3 from i3, and 8 because the song [We Are 138](ht - acpi: [optional] for battery status. It will still work even without this package, but uses it if it is installed. Required for the battery monitor with sound alerts. - bc: For the information panel. -- clipster: clipboard manager - dex: [optional] Alternative method for auto starting applications. - i3-wm: The i3 window manager. - jq: for getting the current workspace @@ -58,7 +57,6 @@ When using I38 with Sway instead of i3, the following Wayland-native alternative - **Not needed on Sway/Wayland** (these are X11-only): - xbrlapi: X11 braille helper - not needed on Wayland, BRLTTY works directly - x11bell: X11 bell support - Wayland has native alternatives - - clipster: X11 clipboard manager - use clipman/wl-clipboard instead - xdotool: X11 window manipulation - Sway uses native IPC instead - xprop: X11 window properties - Sway uses native IPC instead - xrandr: X11 display configuration - use wlr-randr or brightnessctl instead diff --git a/i38.sh b/i38.sh index 7fc8de0..a45d765 100755 --- a/i38.sh +++ b/i38.sh @@ -531,6 +531,7 @@ done # Load saved configuration if available configLoaded=0 +configChanged=0 if load_config; then configLoaded=1 dialog --title "I38" --msgbox "Loaded saved preferences from $configFile\n\nMissing or invalid values will be prompted." 0 0 @@ -689,6 +690,7 @@ else fi # Terminal emulator if [[ -z "$terminalEmulator" ]] || { [[ ! -x "$terminalEmulator" ]] && ! command -v "$terminalEmulator" &> /dev/null; }; then + configChanged=1 programList=() for i in mate-terminal lxterminal gnome-terminal terminator xfce4-terminal tilix ptyxis kgx sakura roxterm termit guake tilda qterminal konsole ; do if command -v "$i" &> /dev/null ; then @@ -816,8 +818,8 @@ if [[ $personalModeExists -ne 0 ]]; then fi fi -# Save configuration if requested (only on first run) -if [[ $configLoaded -eq 0 ]]; then +# Save configuration if requested on first run or after filling missing saved values. +if [[ $configLoaded -eq 0 || $configChanged -eq 1 ]]; then save_config fi @@ -888,7 +890,7 @@ bindsym \$mod+F2 exec ${i3Path}/scripts/run_dialog.sh bindsym \$mod+Control+b exec ${i3Path}/scripts/bookmarks.sh # Clipboard manager -bindsym \$mod+Control+c exec clipster -s +bindsym \$mod+Control+c exec ${i3Path}/scripts/i38-clipboard.py --show # gtk bar bindsym \$mod+Control+Delete exec --no-startup-id sgtk-bar @@ -899,7 +901,7 @@ bindsym \$mod+XF86AudioRaiseVolume exec --no-startup-id pactl set-sink-volume @D # Decrease system volume bindsym \$mod+XF86AudioLowerVolume exec --no-startup-id pactl set-sink-volume @DEFAULT_SINK@ -${volumeJump}% & play -qnG synth 0.03 sin 440 # Mute/unmute system volume -bindsym \$mod+XF86AudioMute exec --no-startup-id ${i3Path}/scrip/ts/mute-unmute.sh +bindsym \$mod+XF86AudioMute exec --no-startup-id ${i3Path}/scripts/mute-unmute.sh # Music player controls # Increase music volume @@ -1280,8 +1282,8 @@ else echo 'exec wl-paste -t text --watch clipman store' fi else - # i3: use X11 clipboard manager - echo 'exec --no-startup-id clipster -d' + # i3: use I38's accessible clipboard history daemon + echo "exec_always --no-startup-id ${i3Path}/scripts/i38-clipboard.py --daemon" fi echo "exec $screenReader" echo "exec_always --no-startup-id ${i3Path}/scripts/desktop.sh" diff --git a/scripts/i38-clipboard.py b/scripts/i38-clipboard.py new file mode 100755 index 0000000..aad2353 --- /dev/null +++ b/scripts/i38-clipboard.py @@ -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 . + + +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())