Replaced clipster with built in clipboard manager.

This commit is contained in:
Storm Dragon
2026-05-13 17:58:39 -04:00
parent 14b0a27c2f
commit 695e046d75
3 changed files with 537 additions and 8 deletions
-2
View File
@@ -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
+8 -6
View File
@@ -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"
+529
View File
@@ -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())