Add ability for X clibpard and Fenrir's clipboard to stay in sync.

This commit is contained in:
Storm Dragon
2026-06-27 16:55:00 -04:00
parent 447e2cc896
commit ad863ad706
20 changed files with 740 additions and 77 deletions
@@ -22,7 +22,7 @@ class command:
self.env["runtime"]["MemoryManager"].add_index_list(
"clipboardHistory",
self.env["runtime"]["SettingsManager"].get_setting_as_int(
"general", "number_of_clipboards"
"clipboard", "number_of_clipboards"
),
)
@@ -26,7 +26,7 @@ class command:
def run(self):
clipboard_file_path = self.env["runtime"][
"SettingsManager"
].get_setting("general", "clipboard_export_path")
].get_setting("clipboard", "clipboard_export_path")
clipboard_file_path = clipboard_file_path.replace(
"$user", self.env["general"]["curr_user"]
)
@@ -5,12 +5,9 @@
# By Chrys, Storm Dragon, and contributors.
import _thread
import importlib
import os
import pyperclip
from fenrirscreenreader.core.i18n import _
from fenrirscreenreader.utils import x_clipboard
class command:
@@ -46,36 +43,20 @@ class command:
"MemoryManager"
].get_index_list_element("clipboardHistory")
# Remember original display environment variable if it exists
original_display = os.environ.get("DISPLAY", "")
success = False
# Try different display options
for i in range(10):
display = f":{i}"
try:
# Set display environment variable
os.environ["DISPLAY"] = display
# Attempt to set clipboard content
# Weird workaround for some distros
importlib.reload(pyperclip)
pyperclip.copy(clipboard)
# If we get here without exception, we found a working
# display
success = True
break
except Exception:
# Failed for this display, try next one
continue
# Restore original display setting
if original_display:
os.environ["DISPLAY"] = original_display
else:
os.environ.pop("DISPLAY", None)
try:
success = x_clipboard.write_text(
clipboard, scan_displays=True
)
except Exception:
success = False
# Notify the user of the result
if success:
sync_manager = self.env["runtime"].get(
"ClipboardSyncManager"
)
if sync_manager:
sync_manager.mark_written_to_x(clipboard)
self.env["runtime"]["OutputManager"].present_text(
_("exported to the X session."), interrupt=True
)
@@ -26,7 +26,7 @@ class command:
def run(self):
clipboard_file_path = self.env["runtime"][
"SettingsManager"
].get_setting("general", "clipboard_export_path")
].get_setting("clipboard", "clipboard_export_path")
clipboard_file_path = clipboard_file_path.replace(
"$user", self.env["general"]["curr_user"]
)
@@ -5,12 +5,9 @@
# By Chrys, Storm Dragon, and contributors.
import _thread
import importlib
import os
import pyperclip
from fenrirscreenreader.core.i18n import _
from fenrirscreenreader.utils import x_clipboard
class command:
@@ -32,33 +29,12 @@ class command:
def _thread_run(self):
try:
# Remember original display environment variable if it exists
original_display = os.environ.get("DISPLAY", "")
clipboard_content = None
# Try different display options
for i in range(10):
display = f":{i}"
try:
# Set display environment variable
os.environ["DISPLAY"] = display
# Attempt to get clipboard content
# Weird workaround for some distros
importlib.reload(pyperclip)
clipboard_content = pyperclip.paste()
# If we get here without exception, we found a working
# display
if clipboard_content:
break
except Exception:
# Failed for this display, try next one
continue
# Restore original display setting
if original_display:
os.environ["DISPLAY"] = original_display
else:
os.environ.pop("DISPLAY", None)
try:
clipboard_content = x_clipboard.read_text(
scan_displays=True
)
except Exception:
clipboard_content = None
# Process the clipboard content if we found any
if clipboard_content and isinstance(clipboard_content, str):
@@ -18,7 +18,7 @@ class command:
self.env["runtime"]["MemoryManager"].add_index_list(
"clipboardHistory",
self.env["runtime"]["SettingsManager"].get_setting_as_int(
"general", "number_of_clipboards"
"clipboard", "number_of_clipboards"
),
)
@@ -141,7 +141,12 @@ class command(config_command):
self.config["general"] = {
"punctuation_level": "some",
"debug_level": "0",
}
# Basic clipboard defaults
self.config["clipboard"] = {
"number_of_clipboards": "50",
"clipboard_export_path": "/tmp/fenrirClipboard",
}
# Write the configuration
@@ -0,0 +1,171 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
# Fenrir TTY screen reader
# By Chrys, Storm Dragon, and contributors.
import os
import threading
import time
from fenrirscreenreader.core import debug
from fenrirscreenreader.utils import x_clipboard
class ClipboardSyncManager:
def __init__(self):
self.env = None
self.enabled = False
self.display = ""
self.interval = 0.5
self.running = False
self.thread = None
self.last_written_to_x = None
self.last_imported_from_x = None
self.last_observed_fenrir = None
self.last_observed_x = None
def initialize(self, environment):
self.env = environment
self.enabled, self.display = self._resolve_startup()
self.interval = max(
0.1,
self.env["runtime"]["SettingsManager"].get_setting_as_float(
"clipboard", "sync_interval"
),
)
if self.enabled:
self._debug(
"Clipboard sync enabled for display "
+ self.display
+ " at interval "
+ str(self.interval)
)
self.start()
else:
self._debug("Clipboard sync disabled at startup")
def shutdown(self):
self.stop()
def mark_written_to_x(self, text):
if not isinstance(text, str) or not text:
return
self.last_written_to_x = text
self.last_observed_fenrir = text
self.last_observed_x = text
def _resolve_startup(self):
settings_manager = self.env["runtime"]["SettingsManager"]
if not settings_manager.get_setting_as_bool(
"clipboard", "sync_enabled"
):
return False, ""
configured_display = settings_manager.get_setting(
"clipboard", "sync_display"
)
screen_driver = settings_manager.get_setting("screen", "driver")
keyboard_driver = settings_manager.get_setting("keyboard", "driver")
is_x_mode = (
screen_driver == "ptyDriver" and keyboard_driver == "x11Driver"
)
if configured_display:
return True, configured_display
if is_x_mode:
current_display = os.environ.get("DISPLAY", "")
return bool(current_display), current_display
return False, ""
def start(self):
if self.running:
return
self.running = True
self.thread = threading.Thread(target=self._run, daemon=True)
self.thread.start()
def stop(self):
self.running = False
if self.thread:
self.thread.join(timeout=1.0)
self.thread = None
def _run(self):
while self.running:
self.poll_once()
time.sleep(self.interval)
def poll_once(self):
fenrir_text = self._get_fenrir_clipboard_text()
x_text = self._get_x_clipboard_text()
if fenrir_text and x_text and fenrir_text == x_text:
self.last_observed_fenrir = fenrir_text
self.last_observed_x = x_text
return
fenrir_changed = (
fenrir_text
and fenrir_text != self.last_observed_fenrir
)
x_changed = (
x_text
and x_text != self.last_observed_x
)
if fenrir_changed:
if self._write_x_clipboard_text(fenrir_text):
self.last_written_to_x = fenrir_text
self.last_observed_x = fenrir_text
self.last_observed_fenrir = fenrir_text
return
if x_changed:
self.env["runtime"]["MemoryManager"].add_value_to_first_index(
"clipboardHistory", x_text
)
self.last_imported_from_x = x_text
self.last_observed_fenrir = x_text
self.last_observed_x = x_text
return
if fenrir_text:
self.last_observed_fenrir = fenrir_text
if x_text:
self.last_observed_x = x_text
def _get_fenrir_clipboard_text(self):
memory_manager = self.env["runtime"]["MemoryManager"]
if memory_manager.is_index_list_empty("clipboardHistory"):
return None
text = memory_manager.get_index_list_element("clipboardHistory")
if isinstance(text, str) and text:
return text
return None
def _get_x_clipboard_text(self):
try:
text = x_clipboard.read_text(self.display)
if isinstance(text, str) and text:
return text
return None
except Exception as error:
self.env["runtime"]["DebugManager"].write_debug_out(
"ClipboardSyncManager paste failed: " + str(error),
debug.DebugLevel.INFO,
)
return None
def _write_x_clipboard_text(self, text):
try:
return x_clipboard.write_text(text, self.display)
except Exception as error:
self._debug("ClipboardSyncManager copy failed: " + str(error))
return False
def _debug(self, message):
self.env["runtime"]["DebugManager"].write_debug_out(
message,
debug.DebugLevel.INFO,
)
@@ -17,6 +17,7 @@ general_data = {
"CursorManager",
"ApplicationManager",
"CommandManager",
"ClipboardSyncManager",
"ScreenManager",
"InputManager",
"OutputManager",
+1 -1
View File
@@ -422,7 +422,7 @@ class RemoteManager:
def export_clipboard(self):
clipboard_file_path = self.env["runtime"][
"SettingsManager"
].get_setting("general", "clipboard_export_path")
].get_setting("clipboard", "clipboard_export_path")
clipboard_file_path = clipboard_file_path.replace(
"$user", self.env["general"]["curr_user"]
)
@@ -14,6 +14,7 @@ runtime_data = {
"RemoteDriver": None,
"InputManager": None,
"CommandManager": None,
"ClipboardSyncManager": None,
"ScreenManager": None,
"OutputManager": None,
"SpeechHistoryManager": None,
+7 -2
View File
@@ -70,8 +70,6 @@ settings_data = {
"respect_punctuation_pause": True,
"replace_undefined_punctuation_with_space": True,
"new_line_pause": True,
"number_of_clipboards": 10,
"clipboard_export_path": "/tmp/fenrirClipboard",
"emoticons": True,
"fenrir_keys": "KEY_KP0,KEY_META",
"script_keys": "KEY_COMPOSE",
@@ -89,6 +87,13 @@ settings_data = {
"diff_presentation": "both",
"diff_verbosity": "compact",
},
"clipboard": {
"number_of_clipboards": 10,
"clipboard_export_path": "/tmp/fenrirClipboard",
"sync_enabled": False,
"sync_display": "",
"sync_interval": 0.5,
},
"focus": {
"cursor": True,
"highlight": False,
@@ -12,6 +12,7 @@ from configparser import ConfigParser
from fenrirscreenreader.core import applicationManager
from fenrirscreenreader.core import attributeManager
from fenrirscreenreader.core import barrierManager
from fenrirscreenreader.core import clipboardSyncManager
from fenrirscreenreader.core import commandManager
from fenrirscreenreader.core import cursorManager
from fenrirscreenreader.core import debug
@@ -771,6 +772,13 @@ class SettingsManager:
] = commandManager.CommandManager()
environment["runtime"]["CommandManager"].initialize(environment)
environment["runtime"][
"ClipboardSyncManager"
] = clipboardSyncManager.ClipboardSyncManager()
environment["runtime"]["ClipboardSyncManager"].initialize(
environment
)
environment["runtime"]["HelpManager"] = helpManager.HelpManager()
environment["runtime"]["HelpManager"].initialize(environment)
+1 -1
View File
@@ -4,5 +4,5 @@
# Fenrir TTY screen reader
# By Chrys, Storm Dragon, and contributors.
version = "2026.06.18"
version = "2026.06.27"
code_name = "testing"
+140
View File
@@ -0,0 +1,140 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
# Fenrir TTY screen reader
# By Chrys, Storm Dragon, and contributors.
import os
import shutil
import subprocess
import threading
_display_lock = threading.RLock()
_CLIPBOARD_TIMEOUT = 2.0
_ENCODING = "utf-8"
def _command_exists(command):
return shutil.which(command) is not None
def _env_for_display(display):
env = os.environ.copy()
if display:
env["DISPLAY"] = display
return env
def _display_candidates(display="", scan_displays=False):
if display:
return [display]
current_display = os.environ.get("DISPLAY", "")
candidates = []
if current_display:
candidates.append(current_display)
if scan_displays:
candidates.extend(f":{index}" for index in range(10))
if not candidates:
candidates.append("")
return list(dict.fromkeys(candidates))
def _read_commands(display):
commands = []
if display:
if _command_exists("xclip"):
commands.append(["xclip", "-selection", "clipboard", "-o"])
if _command_exists("xsel"):
commands.append(["xsel", "-b", "-o"])
elif os.environ.get("WAYLAND_DISPLAY") and _command_exists("wl-paste"):
commands.append(["wl-paste", "-n", "-t", "text"])
return commands
def _write_commands(display):
commands = []
if display:
if _command_exists("xclip"):
commands.append(["xclip", "-selection", "clipboard"])
if _command_exists("xsel"):
commands.append(["xsel", "-b", "-i"])
elif os.environ.get("WAYLAND_DISPLAY") and _command_exists("wl-copy"):
commands.append(["wl-copy"])
return commands
def _run_command(command, display, input_text=None):
input_bytes = None
if input_text is not None:
input_bytes = input_text.encode(_ENCODING)
return subprocess.run(
command,
input=input_bytes,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
env=_env_for_display(display),
timeout=_CLIPBOARD_TIMEOUT,
check=False,
)
def _command_error(command, result):
stderr = result.stderr.decode(_ENCODING, errors="replace").strip()
if stderr:
return RuntimeError(stderr)
return RuntimeError(
"clipboard command failed: "
+ " ".join(command)
+ " exited with "
+ str(result.returncode)
)
def read_text(display="", scan_displays=False):
last_error = None
command_succeeded = False
with _display_lock:
for candidate in _display_candidates(display, scan_displays):
commands = _read_commands(candidate)
if not commands:
last_error = RuntimeError("no supported clipboard reader found")
for command in commands:
try:
result = _run_command(command, candidate)
if result.returncode != 0:
last_error = _command_error(command, result)
continue
command_succeeded = True
text = result.stdout.decode(_ENCODING)
if text:
return text
except Exception as error:
last_error = error
if last_error:
if command_succeeded:
return None
raise last_error
return None
def write_text(text, display="", scan_displays=False):
if not isinstance(text, str) or not text:
return False
last_error = None
with _display_lock:
for candidate in _display_candidates(display, scan_displays):
commands = _write_commands(candidate)
if not commands:
last_error = RuntimeError("no supported clipboard writer found")
for command in commands:
try:
result = _run_command(command, candidate, text)
if result.returncode == 0:
return True
last_error = _command_error(command, result)
except Exception as error:
last_error = error
if last_error:
raise last_error
return False