From ad863ad706bdf8e9aaad76e8f969140d810d14b2 Mon Sep 17 00:00:00 2001 From: Storm Dragon Date: Sat, 27 Jun 2026 16:55:00 -0400 Subject: [PATCH] Add ability for X clibpard and Fenrir's clipboard to stay in sync. --- config/settings/settings.conf | 19 +- docs/fenrir.adoc | 4 +- docs/user.txt | 4 +- .../commands/commands/00_init_commands.py | 2 +- .../commands/export_clipboard_to_file.py | 2 +- .../commands/export_clipboard_to_x.py | 43 +-- .../commands/import_clipboard_from_file.py | 2 +- .../commands/import_clipboard_from_x.py | 38 +-- .../commands/commands/paste_clipboard.py | 2 +- .../KEY/fenrir/management/reset_defaults.py | 5 + .../core/clipboardSyncManager.py | 171 +++++++++++ src/fenrirscreenreader/core/generalData.py | 1 + src/fenrirscreenreader/core/remoteManager.py | 2 +- src/fenrirscreenreader/core/runtimeData.py | 1 + src/fenrirscreenreader/core/settingsData.py | 9 +- .../core/settingsManager.py | 8 + src/fenrirscreenreader/fenrirVersion.py | 2 +- src/fenrirscreenreader/utils/x_clipboard.py | 140 +++++++++ tests/unit/test_clipboard_sync_manager.py | 272 ++++++++++++++++++ tests/unit/test_x_clipboard.py | 90 ++++++ 20 files changed, 740 insertions(+), 77 deletions(-) create mode 100644 src/fenrirscreenreader/core/clipboardSyncManager.py create mode 100644 src/fenrirscreenreader/utils/x_clipboard.py create mode 100644 tests/unit/test_clipboard_sync_manager.py create mode 100644 tests/unit/test_x_clipboard.py diff --git a/config/settings/settings.conf b/config/settings/settings.conf index dad09595..beab040b 100644 --- a/config/settings/settings.conf +++ b/config/settings/settings.conf @@ -198,11 +198,6 @@ respect_punctuation_pause=True replace_undefined_punctuation_with_space=True # Pause speech briefly at newline characters for better readability new_line_pause=True -number_of_clipboards=50 -# used path for "export_clipboard_to_file" -# $user is replaced by username -#clipboard_export_path=/home/$user/fenrirClipboard -clipboard_export_path=/tmp/fenrirClipboard # Convert text emoticons like :) to descriptive text (e.g., "smiling face") emoticons=True # Define the Fenrir modifier key(s) - used to trigger Fenrir commands @@ -263,6 +258,20 @@ diff_presentation=both # verbose = include diff line content during navigation diff_verbosity=compact +[clipboard] +# Number of clipboard history entries Fenrir keeps. +number_of_clipboards=50 +# used path for "export_clipboard_to_file" +# $user is replaced by username +#clipboard_export_path=/home/$user/fenrirClipboard +clipboard_export_path=/tmp/fenrirClipboard +# Keep Fenrir's clipboard history and the X clipboard synchronized. +# In fenrir -x, an empty sync_display uses the current DISPLAY. +# In console/TTY mode, set sync_display explicitly, for example :0. +sync_enabled=False +sync_display= +sync_interval=0.5 + [focus] # Follow and announce text cursor position changes cursor=True diff --git a/docs/fenrir.adoc b/docs/fenrir.adoc index e0b85a4a..4c98b81c 100644 --- a/docs/fenrir.adoc +++ b/docs/fenrir.adoc @@ -1921,7 +1921,8 @@ link:#export clipboard to file[export clipboard to file]. The variable `+$user+` is replaced by the current logged username. .... -clipboardExportPath=/tmp/fenrirClipboard +[clipboard] +clipboard_export_path=/tmp/fenrirClipboard .... Values: Text, Systemfilepath @@ -1929,6 +1930,7 @@ Values: Text, Systemfilepath The number of available clipboards: .... +[clipboard] number_of_clipboards=10 .... diff --git a/docs/user.txt b/docs/user.txt index f5a5c2de..04ab0c9c 100644 --- a/docs/user.txt +++ b/docs/user.txt @@ -1133,10 +1133,12 @@ Values: on=''True'', off=''False'' Specify the path where the clipboard should be exported to. See [[#export clipboard to file|export clipboard to file]]. The variable ''$user'' is replaced by the current logged username. - clipboardExportPath=/tmp/fenrirClipboard + [clipboard] + clipboard_export_path=/tmp/fenrirClipboard Values: Text, Systemfilepath The number of available clipboards: + [clipboard] number_of_clipboards=10 Values: Integer, 1 - 999 diff --git a/src/fenrirscreenreader/commands/commands/00_init_commands.py b/src/fenrirscreenreader/commands/commands/00_init_commands.py index 6aa33dab..5caacb9c 100644 --- a/src/fenrirscreenreader/commands/commands/00_init_commands.py +++ b/src/fenrirscreenreader/commands/commands/00_init_commands.py @@ -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" ), ) diff --git a/src/fenrirscreenreader/commands/commands/export_clipboard_to_file.py b/src/fenrirscreenreader/commands/commands/export_clipboard_to_file.py index 902ad431..ae89b44e 100644 --- a/src/fenrirscreenreader/commands/commands/export_clipboard_to_file.py +++ b/src/fenrirscreenreader/commands/commands/export_clipboard_to_file.py @@ -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"] ) diff --git a/src/fenrirscreenreader/commands/commands/export_clipboard_to_x.py b/src/fenrirscreenreader/commands/commands/export_clipboard_to_x.py index 9b25c395..e30dfd0b 100644 --- a/src/fenrirscreenreader/commands/commands/export_clipboard_to_x.py +++ b/src/fenrirscreenreader/commands/commands/export_clipboard_to_x.py @@ -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 ) diff --git a/src/fenrirscreenreader/commands/commands/import_clipboard_from_file.py b/src/fenrirscreenreader/commands/commands/import_clipboard_from_file.py index 176eb746..3accd7c2 100644 --- a/src/fenrirscreenreader/commands/commands/import_clipboard_from_file.py +++ b/src/fenrirscreenreader/commands/commands/import_clipboard_from_file.py @@ -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"] ) diff --git a/src/fenrirscreenreader/commands/commands/import_clipboard_from_x.py b/src/fenrirscreenreader/commands/commands/import_clipboard_from_x.py index 76641af3..1c61a523 100644 --- a/src/fenrirscreenreader/commands/commands/import_clipboard_from_x.py +++ b/src/fenrirscreenreader/commands/commands/import_clipboard_from_x.py @@ -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): diff --git a/src/fenrirscreenreader/commands/commands/paste_clipboard.py b/src/fenrirscreenreader/commands/commands/paste_clipboard.py index 411a7315..5402b188 100644 --- a/src/fenrirscreenreader/commands/commands/paste_clipboard.py +++ b/src/fenrirscreenreader/commands/commands/paste_clipboard.py @@ -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" ), ) diff --git a/src/fenrirscreenreader/commands/vmenu-profiles/KEY/fenrir/management/reset_defaults.py b/src/fenrirscreenreader/commands/vmenu-profiles/KEY/fenrir/management/reset_defaults.py index 5a682001..93557a62 100644 --- a/src/fenrirscreenreader/commands/vmenu-profiles/KEY/fenrir/management/reset_defaults.py +++ b/src/fenrirscreenreader/commands/vmenu-profiles/KEY/fenrir/management/reset_defaults.py @@ -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 diff --git a/src/fenrirscreenreader/core/clipboardSyncManager.py b/src/fenrirscreenreader/core/clipboardSyncManager.py new file mode 100644 index 00000000..d6512d9d --- /dev/null +++ b/src/fenrirscreenreader/core/clipboardSyncManager.py @@ -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, + ) diff --git a/src/fenrirscreenreader/core/generalData.py b/src/fenrirscreenreader/core/generalData.py index f58ac208..c85234d5 100644 --- a/src/fenrirscreenreader/core/generalData.py +++ b/src/fenrirscreenreader/core/generalData.py @@ -17,6 +17,7 @@ general_data = { "CursorManager", "ApplicationManager", "CommandManager", + "ClipboardSyncManager", "ScreenManager", "InputManager", "OutputManager", diff --git a/src/fenrirscreenreader/core/remoteManager.py b/src/fenrirscreenreader/core/remoteManager.py index 24150216..44dd8925 100644 --- a/src/fenrirscreenreader/core/remoteManager.py +++ b/src/fenrirscreenreader/core/remoteManager.py @@ -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"] ) diff --git a/src/fenrirscreenreader/core/runtimeData.py b/src/fenrirscreenreader/core/runtimeData.py index 980744e2..3f255a43 100644 --- a/src/fenrirscreenreader/core/runtimeData.py +++ b/src/fenrirscreenreader/core/runtimeData.py @@ -14,6 +14,7 @@ runtime_data = { "RemoteDriver": None, "InputManager": None, "CommandManager": None, + "ClipboardSyncManager": None, "ScreenManager": None, "OutputManager": None, "SpeechHistoryManager": None, diff --git a/src/fenrirscreenreader/core/settingsData.py b/src/fenrirscreenreader/core/settingsData.py index e6bef9b8..67af1681 100644 --- a/src/fenrirscreenreader/core/settingsData.py +++ b/src/fenrirscreenreader/core/settingsData.py @@ -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, diff --git a/src/fenrirscreenreader/core/settingsManager.py b/src/fenrirscreenreader/core/settingsManager.py index 99cb0ba5..fdd89c85 100644 --- a/src/fenrirscreenreader/core/settingsManager.py +++ b/src/fenrirscreenreader/core/settingsManager.py @@ -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) diff --git a/src/fenrirscreenreader/fenrirVersion.py b/src/fenrirscreenreader/fenrirVersion.py index 310e9e7a..7a7eccb1 100644 --- a/src/fenrirscreenreader/fenrirVersion.py +++ b/src/fenrirscreenreader/fenrirVersion.py @@ -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" diff --git a/src/fenrirscreenreader/utils/x_clipboard.py b/src/fenrirscreenreader/utils/x_clipboard.py new file mode 100644 index 00000000..5847a4df --- /dev/null +++ b/src/fenrirscreenreader/utils/x_clipboard.py @@ -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 diff --git a/tests/unit/test_clipboard_sync_manager.py b/tests/unit/test_clipboard_sync_manager.py new file mode 100644 index 00000000..756efab5 --- /dev/null +++ b/tests/unit/test_clipboard_sync_manager.py @@ -0,0 +1,272 @@ +from unittest.mock import Mock + +import pytest + +from fenrirscreenreader.core.clipboardSyncManager import ClipboardSyncManager + + +def build_env( + sync_enabled=False, + sync_display="", + screen_driver="vcsaDriver", + keyboard_driver="evdevDriver", + fenrir_text=None, +): + def get_setting(section, setting): + values = { + ("clipboard", "sync_display"): sync_display, + ("screen", "driver"): screen_driver, + ("keyboard", "driver"): keyboard_driver, + } + return values[(section, setting)] + + settings_manager = Mock( + get_setting_as_bool=Mock(return_value=sync_enabled), + get_setting_as_float=Mock(return_value=0.5), + get_setting=Mock(side_effect=get_setting), + ) + memory_manager = Mock( + is_index_list_empty=Mock(return_value=fenrir_text is None), + get_index_list_element=Mock(return_value=fenrir_text), + add_value_to_first_index=Mock(), + ) + return { + "runtime": { + "SettingsManager": settings_manager, + "MemoryManager": memory_manager, + "DebugManager": Mock(write_debug_out=Mock()), + } + } + + +@pytest.mark.unit +def test_clipboard_sync_disabled_by_default(): + manager = ClipboardSyncManager() + manager.start = Mock() + + manager.initialize(build_env(sync_enabled=False)) + + assert manager.enabled is False + manager.start.assert_not_called() + + +@pytest.mark.unit +def test_clipboard_sync_enabled_in_x_mode_with_current_display(monkeypatch): + monkeypatch.setenv("DISPLAY", ":1") + manager = ClipboardSyncManager() + manager.start = Mock() + + manager.initialize( + build_env( + sync_enabled=True, + screen_driver="ptyDriver", + keyboard_driver="x11Driver", + ) + ) + + assert manager.enabled is True + assert manager.display == ":1" + manager.start.assert_called_once_with() + + +@pytest.mark.unit +def test_clipboard_sync_console_skips_empty_display(monkeypatch): + monkeypatch.delenv("DISPLAY", raising=False) + manager = ClipboardSyncManager() + manager.start = Mock() + + manager.initialize(build_env(sync_enabled=True)) + + assert manager.enabled is False + manager.start.assert_not_called() + + +@pytest.mark.unit +def test_clipboard_sync_console_allows_configured_display(monkeypatch): + monkeypatch.delenv("DISPLAY", raising=False) + manager = ClipboardSyncManager() + manager.start = Mock() + + manager.initialize(build_env(sync_enabled=True, sync_display=":0")) + + assert manager.enabled is True + assert manager.display == ":0" + manager.start.assert_called_once_with() + + +@pytest.mark.unit +def test_mark_written_to_x_updates_observed_state(): + manager = ClipboardSyncManager() + + manager.mark_written_to_x("manual export") + + assert manager.last_written_to_x == "manual export" + assert manager.last_observed_fenrir == "manual export" + assert manager.last_observed_x == "manual export" + + +@pytest.mark.unit +def test_fenrir_to_x_write_is_not_reimported(monkeypatch): + env = build_env(fenrir_text="from fenrir") + manager = ClipboardSyncManager() + manager.env = env + manager.display = ":1" + write_text = Mock(return_value=True) + read_text = Mock(side_effect=[None, "from fenrir"]) + monkeypatch.setattr( + "fenrirscreenreader.core.clipboardSyncManager.x_clipboard.write_text", + write_text, + ) + monkeypatch.setattr( + "fenrirscreenreader.core.clipboardSyncManager.x_clipboard.read_text", + read_text, + ) + + manager.poll_once() + manager.poll_once() + + write_text.assert_called_once_with("from fenrir", ":1") + env["runtime"]["MemoryManager"].add_value_to_first_index.assert_not_called() + + +@pytest.mark.unit +def test_conflicting_clipboards_do_not_swap_values(monkeypatch): + env = build_env(fenrir_text="from fenrir") + manager = ClipboardSyncManager() + manager.env = env + manager.display = ":1" + write_text = Mock(return_value=True) + read_text = Mock(return_value="from x") + monkeypatch.setattr( + "fenrirscreenreader.core.clipboardSyncManager.x_clipboard.write_text", + write_text, + ) + monkeypatch.setattr( + "fenrirscreenreader.core.clipboardSyncManager.x_clipboard.read_text", + read_text, + ) + + manager.poll_once() + + write_text.assert_called_once_with("from fenrir", ":1") + env["runtime"]["MemoryManager"].add_value_to_first_index.assert_not_called() + assert manager.last_written_to_x == "from fenrir" + assert manager.last_observed_x == "from fenrir" + assert manager.last_observed_fenrir == "from fenrir" + + +@pytest.mark.unit +def test_matching_clipboards_are_observed_without_duplicate_import(monkeypatch): + env = build_env(fenrir_text="same text") + manager = ClipboardSyncManager() + manager.env = env + manager.display = ":1" + write_text = Mock(return_value=True) + read_text = Mock(return_value="same text") + monkeypatch.setattr( + "fenrirscreenreader.core.clipboardSyncManager.x_clipboard.write_text", + write_text, + ) + monkeypatch.setattr( + "fenrirscreenreader.core.clipboardSyncManager.x_clipboard.read_text", + read_text, + ) + + manager.poll_once() + + write_text.assert_not_called() + env["runtime"]["MemoryManager"].add_value_to_first_index.assert_not_called() + assert manager.last_observed_x == "same text" + assert manager.last_observed_fenrir == "same text" + + +@pytest.mark.unit +def test_x_to_fenrir_import_is_not_reexported(monkeypatch): + env = build_env(fenrir_text=None) + manager = ClipboardSyncManager() + manager.env = env + manager.display = ":1" + write_text = Mock(return_value=True) + read_text = Mock(return_value="from x") + monkeypatch.setattr( + "fenrirscreenreader.core.clipboardSyncManager.x_clipboard.write_text", + write_text, + ) + monkeypatch.setattr( + "fenrirscreenreader.core.clipboardSyncManager.x_clipboard.read_text", + read_text, + ) + + manager.poll_once() + env["runtime"]["MemoryManager"].is_index_list_empty.return_value = False + env["runtime"]["MemoryManager"].get_index_list_element.return_value = ( + "from x" + ) + manager.poll_once() + + env["runtime"]["MemoryManager"].add_value_to_first_index.assert_called_once_with( + "clipboardHistory", "from x" + ) + write_text.assert_not_called() + + +@pytest.mark.unit +def test_selected_imported_text_can_export_after_x_changes(monkeypatch): + env = build_env(fenrir_text=None) + manager = ClipboardSyncManager() + manager.env = env + manager.display = ":1" + write_text = Mock(return_value=True) + read_text = Mock(side_effect=["from x", "other x"]) + monkeypatch.setattr( + "fenrirscreenreader.core.clipboardSyncManager.x_clipboard.write_text", + write_text, + ) + monkeypatch.setattr( + "fenrirscreenreader.core.clipboardSyncManager.x_clipboard.read_text", + read_text, + ) + + manager.poll_once() + env["runtime"]["MemoryManager"].is_index_list_empty.return_value = False + env["runtime"]["MemoryManager"].get_index_list_element.return_value = ( + "from x" + ) + manager.last_observed_fenrir = "different fenrir entry" + manager.poll_once() + + write_text.assert_called_once_with("from x", ":1") + + +@pytest.mark.unit +@pytest.mark.parametrize("x_value", [None, "", object()]) +def test_x_clipboard_non_text_values_are_ignored(monkeypatch, x_value): + env = build_env(fenrir_text=None) + manager = ClipboardSyncManager() + manager.env = env + manager.display = ":1" + monkeypatch.setattr( + "fenrirscreenreader.core.clipboardSyncManager.x_clipboard.read_text", + Mock(return_value=x_value), + ) + + manager.poll_once() + + env["runtime"]["MemoryManager"].add_value_to_first_index.assert_not_called() + + +@pytest.mark.unit +def test_x_clipboard_paste_exception_is_ignored(monkeypatch): + env = build_env(fenrir_text=None) + manager = ClipboardSyncManager() + manager.env = env + manager.display = ":1" + monkeypatch.setattr( + "fenrirscreenreader.core.clipboardSyncManager.x_clipboard.read_text", + Mock(side_effect=RuntimeError("no text target")), + ) + + manager.poll_once() + + env["runtime"]["MemoryManager"].add_value_to_first_index.assert_not_called() + env["runtime"]["DebugManager"].write_debug_out.assert_called_once() diff --git a/tests/unit/test_x_clipboard.py b/tests/unit/test_x_clipboard.py new file mode 100644 index 00000000..f361b50b --- /dev/null +++ b/tests/unit/test_x_clipboard.py @@ -0,0 +1,90 @@ +import os +import subprocess +from unittest.mock import Mock + +import pytest + +from fenrirscreenreader.utils import x_clipboard + + +def command_exists(command): + if command == "xclip": + return "/usr/bin/xclip" + return None + + +@pytest.mark.unit +def test_write_text_uses_display_env_without_mutating_process_env( + monkeypatch, +): + monkeypatch.delenv("DISPLAY", raising=False) + monkeypatch.setattr(x_clipboard.shutil, "which", command_exists) + run_command = Mock( + return_value=subprocess.CompletedProcess( + ["xclip"], 0, stdout=b"", stderr=b"" + ) + ) + monkeypatch.setattr(x_clipboard.subprocess, "run", run_command) + + assert x_clipboard.write_text("clipboard text", ":3") is True + + command = run_command.call_args.args[0] + kwargs = run_command.call_args.kwargs + assert command == ["xclip", "-selection", "clipboard"] + assert kwargs["input"] == b"clipboard text" + assert kwargs["env"]["DISPLAY"] == ":3" + assert kwargs["timeout"] == 2.0 + assert os.environ.get("DISPLAY") is None + + +@pytest.mark.unit +def test_read_text_scans_displays_until_text_is_found(monkeypatch): + monkeypatch.delenv("DISPLAY", raising=False) + monkeypatch.setattr(x_clipboard.shutil, "which", command_exists) + displays = [] + + def run_command(command, **kwargs): + displays.append(kwargs["env"].get("DISPLAY")) + if kwargs["env"].get("DISPLAY") == ":1": + return subprocess.CompletedProcess( + command, 0, stdout=b"from x", stderr=b"" + ) + return subprocess.CompletedProcess( + command, 1, stdout=b"", stderr=b"missing display" + ) + + monkeypatch.setattr(x_clipboard.subprocess, "run", run_command) + + assert x_clipboard.read_text(scan_displays=True) == "from x" + assert displays == [":0", ":1"] + + +@pytest.mark.unit +def test_read_text_empty_success_returns_none(monkeypatch): + monkeypatch.setenv("DISPLAY", ":2") + monkeypatch.setattr(x_clipboard.shutil, "which", command_exists) + monkeypatch.setattr( + x_clipboard.subprocess, + "run", + Mock( + return_value=subprocess.CompletedProcess( + ["xclip"], 0, stdout=b"", stderr=b"" + ) + ), + ) + + assert x_clipboard.read_text() is None + + +@pytest.mark.unit +def test_write_text_propagates_clipboard_timeout(monkeypatch): + monkeypatch.setattr(x_clipboard.shutil, "which", command_exists) + timeout = subprocess.TimeoutExpired(["xclip"], 2.0) + monkeypatch.setattr( + x_clipboard.subprocess, + "run", + Mock(side_effect=timeout), + ) + + with pytest.raises(subprocess.TimeoutExpired): + x_clipboard.write_text("clipboard text", ":3")