diff --git a/.gitignore b/.gitignore index c46af629..ee9c8732 100644 --- a/.gitignore +++ b/.gitignore @@ -10,4 +10,9 @@ Windows_and_Linux/config.json # Xcode user-specific data **/xcuserdata/ -**/project.xcworkspace/xcuserdata/ \ No newline at end of file +**/project.xcworkspace/xcuserdata/ + +Windows_and_Linux/.venv/ +Windows_and_Linux/packaging/ +Windows_and_Linux/dist/ +Windows_and_Linux/Writing Tools.spec \ No newline at end of file diff --git a/README's Linked Content/To Compile the Application Yourself.md b/README's Linked Content/To Compile the Application Yourself.md index 829c9e9e..5da82575 100644 --- a/README's Linked Content/To Compile the Application Yourself.md +++ b/README's Linked Content/To Compile the Application Yourself.md @@ -1,34 +1,101 @@ # 👨‍💻 To compile the application yourself: -### Windows and Linux Version build instructions: -Here's how to compile it with PyInstaller and a virtual environment: +### Linux build instructions: + +1. Open Terminal and enter the Linux app folder: +```bash +cd Windows_and_Linux +``` + +2. Build and install locally (recommended, creates launcher and app menu entry): +```bash +bash build-and-install-local-linux.sh +``` + +This creates a virtual environment, installs dependencies, builds with PyInstaller, and installs: +- `~/.local/bin/writing-tools` +- `~/.local/share/applications/writing-tools.desktop` + +Optional autostart: +```bash +bash build-and-install-local-linux.sh --enable-autostart +``` + +After local install, you can launch Writing Tools with: +```bash +writing-tools +``` + +No daily recompilation is needed unless you changed source code and want a newer build. + +#### Linux system dependencies -1. First, create and activate a virtual environment: +The Python dependencies (in `requirements.txt`) are installed into the venv automatically. A few **system** packages are needed for the clipboard and keystroke-injection flow; which ones depend on your display server: + +**X11 (Xorg) session:** +```bash +# Clipboard backend (one of): +sudo apt install xclip # Debian/Ubuntu +sudo pacman -S xclip # Arch/CachyOS +``` +pynput (bundled via pip) handles keystroke injection on X11 via the XTest extension — no extra system tool needed. + +**Wayland session (e.g. KDE Plasma Wayland, GNOME Wayland, Hyprland):** +pynput's injection and pyperclip's clipboard calls are unreliable on native Wayland. Writing Tools auto-detects a Wayland session and switches to native tools. Install: ```bash -# Install virtualenv if you haven't already -pip install virtualenv +# Arch / CachyOS: +sudo pacman -S wl-clipboard ydotool +# Debian / Ubuntu: +sudo apt install wl-clipboard ydotool +# Fedora: +sudo dnf install wl-clipboard ydotool +``` +Then enable the ydotool daemon and grant input access: +```bash +# Enable the daemon (socket-activated; runs per-user) +systemctl --user enable --now ydotool + +# Grant /dev/uinput access (required for keystroke injection) +sudo usermod -aG input "$USER" +# Log out and back in for the group change to take effect. +``` +- `wl-clipboard` provides `wl-copy`/`wl-paste` for clipboard read/write. +- `ydotool` injects Ctrl+C / Ctrl+V at the kernel uinput layer, reaching both native Wayland and XWayland windows (pynput only reaches XWayland). -# Create a new virtual environment -virtualenv myvenv +Without these tools on Wayland, the app falls back to the X11 path, which only works inside XWayland windows. -# Activate it -# On Windows: -myvenv\Scripts\activate -# On Linux: -source myvenv/bin/activate +### Windows build instructions: +Here's how to compile it with PyInstaller and a virtual environment: + +1. Open Command Prompt (or PowerShell) and enter the Windows app folder: +```bash +cd /path/to/WritingTools/Windows_and_Linux ``` -2. Once activated, install the required packages: +2. Create and activate a virtual environment: +```bash +python -m venv .venv + +# Windows (PowerShell): +.venv\Scripts\Activate.ps1 + +# Windows (cmd): +.venv\Scripts\activate.bat +``` +3. Install dependencies: ```bash +python -m pip install --upgrade pip pip install -r requirements.txt ``` -3. Build Writing Tools: +4. Build Writing Tools: ```bash python pyinstaller-build-script.py ``` +The compiled binary is written to: ~/Windows_and_Linux/dist/Writing Tools + ### macOS Version (by [Aryamirsepasi](https://github.com/Aryamirsepasi)) build instructions: 1. **Install Xcode** diff --git a/README's Linked Content/To Run Writing Tools Directly from the Source Code.md b/README's Linked Content/To Run Writing Tools Directly from the Source Code.md index 329df109..37d97f82 100644 --- a/README's Linked Content/To Run Writing Tools Directly from the Source Code.md +++ b/README's Linked Content/To Run Writing Tools Directly from the Source Code.md @@ -34,5 +34,13 @@ Of course, you'll need to have [Python installed](https://www.python.org/downloa python3 main.py ``` +If you want a launcher icon in your app menu and a reusable local install (so you do not need to run from terminal every day), use: + +```bash +bash build-and-install-local-linux.sh +``` + +from the `Windows_and_Linux` folder. + ### [**◀️ Back to main page**](https://github.com/theJayTea/WritingTools) diff --git a/README.md b/README.md index c1a1fcd0..f4829ae4 100644 --- a/README.md +++ b/README.md @@ -90,6 +90,8 @@ Aside from being the only Windows/Linux program like Apple's Writing Tools, and ### **🐧 Linux (work-in-progress)**: [Run it from the source code](https://github.com/theJayTea/WritingTools/blob/main/README's%20Linked%20Content/To%20Run%20Writing%20Tools%20Directly%20from%20the%20Source%20Code.md) +[Compile and install locally with launcher icon](https://github.com/theJayTea/WritingTools/blob/main/README's%20Linked%20Content/To%20Compile%20the%20Application%20Yourself.md) + Writing Tools works well on x11. On Wayland, there are a few caveats: - [it works on XWayland apps](https://github.com/theJayTea/WritingTools/issues/34#issuecomment-2461633556) - [and it works if you disable Wayland for individual Flatpaks with Flatseal.](https://github.com/theJayTea/WritingTools/issues/93#issuecomment-2576511041) @@ -220,7 +222,7 @@ These instructions are for any Writing Tools version, using the OpenAI-Compatibl ## 👨‍💻 To Compile the Application Yourself: -[Instructions here!](https://github.com/theJayTea/WritingTools/blob/8713e5a5de63a7892b05a43b9753172e692768fb/README's%20Linked%20Content/To%20Compile%20the%20Application%20Yourself.md) +[Instructions here!](https://github.com/theJayTea/WritingTools/blob/main/README's%20Linked%20Content/To%20Compile%20the%20Application%20Yourself.md) ## 🌟 Contributors diff --git a/Windows_and_Linux/WritingToolApp.py b/Windows_and_Linux/WritingToolApp.py index b230a12f..359bb486 100644 --- a/Windows_and_Linux/WritingToolApp.py +++ b/Windows_and_Linux/WritingToolApp.py @@ -9,12 +9,20 @@ import darkdetect import pyperclip +import _wayland_io import ui.AboutWindow import ui.CustomPopupWindow import ui.OnboardingWindow import ui.ResponseWindow import ui.SettingsWindow -from aiprovider import GeminiProvider, OllamaProvider, OpenAICompatibleProvider, obfuscate_api_key +from aiprovider import ( + GeminiProvider, + OllamaCloudProvider, + OllamaProvider, + OpenAICompatibleProvider, + obfuscate_api_key, +) +from history_manager import HistoryManager from pynput import keyboard as pykeyboard from PySide6 import QtCore, QtGui, QtWidgets from PySide6.QtCore import QLocale, Signal, Slot @@ -31,6 +39,7 @@ class _SelectedTextHolder: `process_option_thread`. The capture thread sets `text` and signals `ready` once done. """ + __slots__ = ("text", "ready") def __init__(self): @@ -42,16 +51,17 @@ class WritingToolApp(QtWidgets.QApplication): """ The main application class for Writing Tools. """ + output_ready_signal = Signal(str) show_message_signal = Signal(str, str) # a signal for showing message boxes hotkey_triggered_signal = Signal() followup_response_signal = Signal(str) - + history_updated_signal = Signal() def __init__(self, argv): super().__init__(argv) self.current_response_window = None - logging.debug('Initializing WritingToolApp') + logging.debug("Initializing WritingToolApp") self.output_ready_signal.connect(self.replace_text) self.show_message_signal.connect(self.show_message_box) self.hotkey_triggered_signal.connect(self.on_hotkey_pressed) @@ -65,6 +75,10 @@ def __init__(self, argv): self.options = None self.options_path = None self.load_options() + self.history_manager = HistoryManager( + os.path.dirname(sys.argv[0]), on_updated=self.history_updated_signal.emit + ) + self.history_updated_signal.connect(self.history_manager.refresh_window) self.onboarding_window = None self.popup_window = None self.tray_icon = None @@ -93,29 +107,51 @@ def __init__(self, argv): self.setup_ctrl_c_listener() # Setup available AI providers - self.providers = [GeminiProvider(self), OpenAICompatibleProvider(self), OllamaProvider(self)] + # Order matters: the FIRST entry is the default for new installs and + # the order they're presented to the user in the Settings dropdown. + # 1) Gemini (Recommended) — daily-quota free tier, very fast + # 2) Ollama Cloud (Recommended) — weekly-quota free tier, no install + # 3) OpenAI Compatible — for users with their own key + # 4) Ollama Local — for users running Ollama themselves + self.providers = [ + GeminiProvider(self), + OllamaCloudProvider(self), + OpenAICompatibleProvider(self), + OllamaProvider(self), + ] if not self.config: - logging.debug('No config found, showing onboarding') + logging.debug("No config found, showing onboarding") self.show_onboarding() else: - logging.debug('Config found, setting up hotkey and tray icon') + logging.debug("Config found, setting up hotkey and tray icon") # Initialize the current provider, defaulting to Gemini - provider_name = self.config.get('provider', 'Gemini') - - self.current_provider = next((provider for provider in self.providers if provider.provider_name == provider_name), None) + provider_name = self.config.get("provider", "Gemini") + + self.current_provider = next( + ( + provider + for provider in self.providers + if provider.provider_name == provider_name + ), + None, + ) if not self.current_provider: - logging.warning(f'Provider {provider_name} not found. Using default provider.') + logging.warning( + f"Provider {provider_name} not found. Using default provider." + ) self.current_provider = self.providers[0] - self.current_provider.load_config(self.config.get("providers", {}).get(provider_name, {})) + self.current_provider.load_config( + self.config.get("providers", {}).get(provider_name, {}) + ) self.create_tray_icon() self.register_hotkey() try: - lang = self.config['locale'] + lang = self.config["locale"] except KeyError: lang = None self.change_language(lang) @@ -130,13 +166,13 @@ def __init__(self, argv): def setup_translations(self, lang=None): if not lang: - lang = QLocale.system().name().split('_')[0] + lang = QLocale.system().name().split("_")[0] try: translation = gettext.translation( - 'messages', - localedir=os.path.join(os.path.dirname(__file__), 'locales'), - languages=[lang] + "messages", + localedir=os.path.join(os.path.dirname(__file__), "locales"), + languages=[lang], ) except FileNotFoundError: translation = gettext.NullTranslations() @@ -149,6 +185,7 @@ def setup_translations(self, lang=None): ui.ResponseWindow._ = self._ ui.OnboardingWindow._ = self._ ui.CustomPopupWindow._ = self._ + self.history_manager.set_translation_function(self._) def retranslate_ui(self): self.update_tray_menu() @@ -159,7 +196,7 @@ def change_language(self, lang): # Update all other windows for widget in QApplication.topLevelWidgets(): - if widget != self and hasattr(widget, 'retranslate_ui'): + if widget != self and hasattr(widget, "retranslate_ui"): widget.retranslate_ui() def check_trigger_spam(self): @@ -168,14 +205,15 @@ def check_trigger_spam(self): Returns True if spam is detected. """ current_time = time.time() - + # Add current trigger self.recent_triggers.append(current_time) - + # Remove old triggers outside the window - self.recent_triggers = [t for t in self.recent_triggers - if current_time - t <= self.TRIGGER_WINDOW] - + self.recent_triggers = [ + t for t in self.recent_triggers if current_time - t <= self.TRIGGER_WINDOW + ] + # Check if we have too many triggers in the window return len(self.recent_triggers) >= self.MAX_TRIGGERS @@ -183,14 +221,14 @@ def load_config(self): """ Load the configuration file. """ - self.config_path = os.path.join(os.path.dirname(sys.argv[0]), 'config.json') - logging.debug(f'Loading config from {self.config_path}') + self.config_path = os.path.join(os.path.dirname(sys.argv[0]), "config.json") + logging.debug(f"Loading config from {self.config_path}") if os.path.exists(self.config_path): - with open(self.config_path, 'r') as f: + with open(self.config_path, "r") as f: self.config = json.load(f) - logging.debug('Config loaded successfully') + logging.debug("Config loaded successfully") else: - logging.debug('Config file not found') + logging.debug("Config file not found") self.config = None def _migrate_config(self): @@ -224,38 +262,38 @@ def _migrate_config(self): """ CURRENT_CONFIG_VERSION = 9 # Default for new installs and migrating users. - NEW_DEFAULT_MODEL = 'gemini-flash-latest' + NEW_DEFAULT_MODEL = "gemini-flash-latest" # v8 -> v9 model mapping. Every retired preset is bumped to the new # default so users get the fast Flash-tier experience by default. V8_TO_V9_MAP = { - 'gemma-3-27b-it': NEW_DEFAULT_MODEL, - 'gemma-3-4b-it': NEW_DEFAULT_MODEL, - 'gemini-flash-lite-latest': NEW_DEFAULT_MODEL, + "gemma-3-27b-it": NEW_DEFAULT_MODEL, + "gemma-3-4b-it": NEW_DEFAULT_MODEL, + "gemini-flash-lite-latest": NEW_DEFAULT_MODEL, # 'gemini-flash-latest' itself is already current — no entry needed. } # New user (no config yet) — onboarding will create a fresh, current # config; nothing to migrate. if not self.config: - logging.debug('No config to migrate (new user)') + logging.debug("No config to migrate (new user)") return - needs_v8 = not self.config.get('is_config_file_updated_for_v8', False) - needs_v9 = not self.config.get('is_config_file_updated_for_v9', False) + needs_v8 = not self.config.get("is_config_file_updated_for_v8", False) + needs_v9 = not self.config.get("is_config_file_updated_for_v9", False) if not needs_v8 and not needs_v9: - logging.debug('Config already up-to-date, no migration needed') + logging.debug("Config already up-to-date, no migration needed") return - logging.info(f'Running config migration (needs_v8={needs_v8}, needs_v9={needs_v9})...') + logging.info( + f"Running config migration (needs_v8={needs_v8}, needs_v9={needs_v9})..." + ) config_changed = False - gemini_config = ( - self.config.get('providers', {}).get('Gemini (Recommended)') - ) + gemini_config = self.config.get("providers", {}).get("Gemini (Recommended)") if gemini_config is not None: - old_model = gemini_config.get('model_name', '') + old_model = gemini_config.get("model_name", "") # v8: pre-v8 users didn't have a custom-model field, so we can # bump unconditionally. We skip the historical "v8 default of @@ -263,19 +301,21 @@ def _migrate_config(self): # current default. if needs_v8: if old_model != NEW_DEFAULT_MODEL: - gemini_config['model_name'] = NEW_DEFAULT_MODEL - logging.info(f'[v8] Bumped Gemini model "{old_model}" -> "{NEW_DEFAULT_MODEL}"') + gemini_config["model_name"] = NEW_DEFAULT_MODEL + logging.info( + f'[v8] Bumped Gemini model "{old_model}" -> "{NEW_DEFAULT_MODEL}"' + ) config_changed = True # Obfuscate the API key. The helper is idempotent — already- # obfuscated keys (with the `enc:` prefix) pass through # unchanged. - api_key = gemini_config.get('api_key', '') + api_key = gemini_config.get("api_key", "") if api_key: new_key = obfuscate_api_key(api_key) if new_key != api_key: - gemini_config['api_key'] = new_key - logging.info('[v8] Obfuscated plaintext Gemini API key') + gemini_config["api_key"] = new_key + logging.info("[v8] Obfuscated plaintext Gemini API key") config_changed = True # v9: only runs for users coming from v8. Preserve tier choice via @@ -285,18 +325,20 @@ def _migrate_config(self): elif needs_v9: new_model = V8_TO_V9_MAP.get(old_model) if new_model is not None and new_model != old_model: - gemini_config['model_name'] = new_model - logging.info(f'[v9] Bumped Gemini model "{old_model}" -> "{new_model}"') + gemini_config["model_name"] = new_model + logging.info( + f'[v9] Bumped Gemini model "{old_model}" -> "{new_model}"' + ) config_changed = True # Stamp every version flag up to current so we never re-run on # subsequent startups, even if no fields actually needed changing # (e.g., a v8 user who'd already picked a custom non-deprecated model). for n in range(8, CURRENT_CONFIG_VERSION + 1): - self.config[f'is_config_file_updated_for_v{n}'] = True + self.config[f"is_config_file_updated_for_v{n}"] = True self.save_config(self.config) - logging.info('Config migration complete') + logging.info("Config migration complete") # Single restart popup, regardless of how many versions we jumped. if config_changed: @@ -304,10 +346,10 @@ def _migrate_config(self): # point in __init__. QMessageBox.information( None, - 'Writing Tools Updated', - 'Writing Tools has just completed an internal update ' - '(your config.json was migrated to the current format).\n\n' - 'Please restart Writing Tools.' + "Writing Tools Updated", + "Writing Tools has just completed an internal update " + "(your config.json was migrated to the current format).\n\n" + "Please restart Writing Tools.", ) sys.exit(0) @@ -315,30 +357,30 @@ def load_options(self): """ Load the options file. """ - self.options_path = os.path.join(os.path.dirname(sys.argv[0]), 'options.json') - logging.debug(f'Loading options from {self.options_path}') + self.options_path = os.path.join(os.path.dirname(sys.argv[0]), "options.json") + logging.debug(f"Loading options from {self.options_path}") if os.path.exists(self.options_path): - with open(self.options_path, 'r') as f: + with open(self.options_path, "r") as f: self.options = json.load(f) - logging.debug('Options loaded successfully') + logging.debug("Options loaded successfully") else: - logging.debug('Options file not found') + logging.debug("Options file not found") self.options = None def save_config(self, config): """ Save the configuration file. """ - with open(self.config_path, 'w') as f: + with open(self.config_path, "w") as f: json.dump(config, f, indent=4) - logging.debug('Config saved successfully') + logging.debug("Config saved successfully") self.config = config def show_onboarding(self): """ Show the onboarding window for first-time users. """ - logging.debug('Showing onboarding window') + logging.debug("Showing onboarding window") self.onboarding_window = ui.OnboardingWindow.OnboardingWindow(self) self.onboarding_window.close_signal.connect(self.exit_app) self.onboarding_window.show() @@ -350,9 +392,8 @@ def _to_pynput_hotkey(hotkey_str): pynput's `+j` / `++` format. Single-char keys stay as-is; multi-char keys (modifiers, named keys) get wrapped in <>. """ - return '+'.join( - f'{t}' if len(t) <= 1 else f'<{t}>' - for t in hotkey_str.split('+') + return "+".join( + f"{t}" if len(t) <= 1 else f"<{t}>" for t in hotkey_str.split("+") ) def start_hotkey_listener(self): @@ -375,7 +416,7 @@ def start_hotkey_listener(self): hotkey_map = {} # --- Global Writing Tools hotkey ---------------------------------- - orig_shortcut = self.config.get('shortcut', 'ctrl+space') + orig_shortcut = self.config.get("shortcut", "ctrl+space") self.registered_hotkey = orig_shortcut try: global_parsed = self._to_pynput_hotkey(orig_shortcut) @@ -385,11 +426,11 @@ def start_hotkey_listener(self): def on_global_activate(): if self.paused: return - logging.debug('triggered global hotkey') + logging.debug("triggered global hotkey") self.hotkey_triggered_signal.emit() hotkey_map[global_parsed] = on_global_activate - logging.debug(f'Registered global hotkey: {global_parsed}') + logging.debug(f"Registered global hotkey: {global_parsed}") except Exception as e: logging.error(f'Failed to parse global hotkey "{orig_shortcut}": {e}') @@ -398,9 +439,9 @@ def on_global_activate(): # so a "fire directly" hotkey doesn't make sense for it. if self.options: for button_name, button_cfg in self.options.items(): - if button_name == 'Custom': + if button_name == "Custom": continue - raw = (button_cfg.get('hotkey') or '').strip() + raw = (button_cfg.get("hotkey") or "").strip() if not raw: continue try: @@ -415,20 +456,22 @@ def on_global_activate(): if parsed in hotkey_map: logging.warning( f'Hotkey "{raw}" for button "{button_name}" ' - f'conflicts with an already-registered binding; skipping' + f"conflicts with an already-registered binding; skipping" ) continue hotkey_map[parsed] = self._make_button_hotkey_callback(button_name) - logging.debug(f'Registered button hotkey: {parsed} -> {button_name}') + logging.debug( + f"Registered button hotkey: {parsed} -> {button_name}" + ) if not hotkey_map: - logging.warning('No hotkeys to register') + logging.warning("No hotkeys to register") return self.hotkey_listener = pykeyboard.GlobalHotKeys(hotkey_map) self.hotkey_listener.start() except Exception as e: - logging.error(f'Failed to register hotkey listener: {e}') + logging.error(f"Failed to register hotkey listener: {e}") def _make_button_hotkey_callback(self, button_name): """ @@ -440,6 +483,7 @@ def _make_button_hotkey_callback(self, button_name): invokes callbacks on its listener thread; popup/clipboard work needs to happen on the main thread. """ + def callback(): if self.paused: return @@ -454,8 +498,9 @@ def callback(): self, "_fire_button_directly", QtCore.Qt.ConnectionType.QueuedConnection, - QtCore.Q_ARG(str, button_name) + QtCore.Q_ARG(str, button_name), ) + return callback @Slot(str) @@ -493,22 +538,22 @@ def register_hotkey(self): """ Register the global hotkey for activating Writing Tools. """ - logging.debug('Registering hotkey') + logging.debug("Registering hotkey") self.start_hotkey_listener() - logging.debug('Hotkey registered') + logging.debug("Hotkey registered") def on_hotkey_pressed(self): """ Handle the hotkey press event. """ - logging.debug('Hotkey pressed') - + logging.debug("Hotkey pressed") + # Check for spam triggers if self.check_trigger_spam(): - logging.warning('Hotkey spam detected - quitting application') + logging.warning("Hotkey spam detected - quitting application") self.exit_app() return - + # Original hotkey handling continues... if self.current_provider: logging.debug("Cancelling current provider's request") @@ -516,7 +561,9 @@ def on_hotkey_pressed(self): self.output_queue = "" # noinspection PyTypeChecker - QtCore.QMetaObject.invokeMethod(self, "_show_popup", QtCore.Qt.ConnectionType.QueuedConnection) + QtCore.QMetaObject.invokeMethod( + self, "_show_popup", QtCore.Qt.ConnectionType.QueuedConnection + ) @Slot() def _show_popup(self): @@ -530,7 +577,7 @@ def _show_popup(self): `process_option_thread` waits on the holder before kicking off the AI request. """ - logging.debug('Showing popup window') + logging.debug("Showing popup window") # Fresh holder per popup. Fire Ctrl+C *before* we create the popup # so the keystroke is queued while focus is still on the user's @@ -540,25 +587,28 @@ def _show_popup(self): try: if self.popup_window is not None: - logging.debug('Existing popup window found') + logging.debug("Existing popup window found") if self.popup_window.isVisible(): - logging.debug('Closing existing visible popup window') + logging.debug("Closing existing visible popup window") self.popup_window.close() self.popup_window = None - logging.debug('Creating new popup window') + logging.debug("Creating new popup window") self.popup_window = ui.CustomPopupWindow.CustomPopupWindow(self) # Set the window icon - icon_path = os.path.join(os.path.dirname(sys.argv[0]), 'icons', 'app_icon.png') - if os.path.exists(icon_path): self.setWindowIcon(QtGui.QIcon(icon_path)) + icon_path = os.path.join( + os.path.dirname(sys.argv[0]), "icons", "app_icon.png" + ) + if os.path.exists(icon_path): + self.setWindowIcon(QtGui.QIcon(icon_path)) # Get the screen containing the cursor cursor_pos = QCursor.pos() screen = QGuiApplication.screenAt(cursor_pos) if screen is None: screen = QGuiApplication.primaryScreen() screen_geometry = screen.geometry() - logging.debug(f'Cursor is on screen: {screen.name()}') - logging.debug(f'Screen geometry: {screen_geometry}') + logging.debug(f"Cursor is on screen: {screen.name()}") + logging.debug(f"Screen geometry: {screen_geometry}") # Show the popup to get its size self.popup_window.show() self.popup_window.adjustSize() @@ -578,9 +628,9 @@ def _show_popup(self): if y + popup_height > screen_geometry.bottom(): y = cursor_pos.y() - popup_height - 10 # 10 pixels above cursor self.popup_window.move(x, y) - logging.debug(f'Popup window moved to position: ({x}, {y})') + logging.debug(f"Popup window moved to position: ({x}, {y})") except Exception as e: - logging.error(f'Error showing popup window: {e}', exc_info=True) + logging.error(f"Error showing popup window: {e}", exc_info=True) def _fire_ctrl_c_and_capture_async(self, holder): """ @@ -595,45 +645,79 @@ def _fire_ctrl_c_and_capture_async(self, holder): pressed the hotkey without actually selecting anything, which `process_option_thread` reports as a normal error. """ + wayland = _wayland_io.is_wayland_active() + try: - clipboard_backup = pyperclip.paste() + clipboard_backup = ( + _wayland_io.paste_text() if wayland else pyperclip.paste() + ) except Exception: - clipboard_backup = '' - - self.clear_clipboard() + clipboard_backup = "" + + if wayland: + _wayland_io.clear_clipboard() + # Let the clear propagate before injecting Ctrl+C, so the poll's + # "changed from backup" check doesn't race with the clear. + time.sleep(0.1) + _wayland_io.inject_ctrl_c() + else: + self.clear_clipboard() - kbrd = pykeyboard.Controller() - try: - kbrd.press(pykeyboard.Key.ctrl.value) - kbrd.press('c') - kbrd.release('c') - kbrd.release(pykeyboard.Key.ctrl.value) - except Exception as e: - logging.error(f'Error simulating Ctrl+C: {e}') + kbrd = pykeyboard.Controller() + try: + kbrd.press(pykeyboard.Key.ctrl.value) + kbrd.press("c") + kbrd.release("c") + kbrd.release(pykeyboard.Key.ctrl.value) + except Exception as e: + logging.error(f"Error simulating Ctrl+C: {e}") def _poll_clipboard(): # Lock so concurrent hotkey presses don't trample each other's # in-flight captures. with self._capture_lock: - text = '' + text = "" + captured = False + # Give the injected Ctrl+C time to reach the focused app and + # populate the clipboard before the first read. ydotool -> + # ydotoold -> uinput -> KWin -> app -> wl-clipboard is not + # instant. + time.sleep(0.2) try: deadline = time.time() + 2.0 while time.time() < deadline: try: - text = pyperclip.paste() or '' + if wayland: + text = _wayland_io.paste_text() or "" + else: + text = pyperclip.paste() or "" except Exception as e: - logging.error(f'Error reading clipboard during poll: {e}') - text = '' - if text: - break + logging.error(f"Error reading clipboard during poll: {e}") + text = "" + # On Wayland the clipboard clear may be re-offered by + # KDE Klipper, so a non-empty value may be the backup. + # Accept the capture only when content actually changed. + if wayland: + if text and text != clipboard_backup: + captured = True + break + else: + if text: + captured = True + break time.sleep(0.05) + if not captured: + text = "" holder.text = text - logging.debug(f'Captured selected text (len={len(text)})') + logging.debug(f"Captured selected text (len={len(text)})") finally: try: - pyperclip.copy(clipboard_backup) + if wayland: + _wayland_io.copy_text(clipboard_backup) + else: + pyperclip.copy(clipboard_backup) except Exception as e: - logging.error(f'Error restoring clipboard: {e}') + logging.error(f"Error restoring clipboard: {e}") holder.ready.set() threading.Thread(target=_poll_clipboard, daemon=True).start() @@ -644,9 +728,9 @@ def clear_clipboard(): Clear the system clipboard. """ try: - pyperclip.copy('') + pyperclip.copy("") except Exception as e: - logging.error(f'Error clearing clipboard: {e}') + logging.error(f"Error clearing clipboard: {e}") def process_option(self, option, custom_change=None): """ @@ -655,18 +739,18 @@ def process_option(self, option, custom_change=None): the popup's click handler returns immediately and the GUI thread is never blocked on the clipboard read. """ - logging.debug(f'Processing option: {option}') + logging.debug(f"Processing option: {option}") # Drop any stale ref so a previous run's late-arriving response can't # land in a now-irrelevant window. The new window (if any) is created # by the worker via `_setup_response_window` once the text is in. - if hasattr(self, 'current_response_window'): - delattr(self, 'current_response_window') + if hasattr(self, "current_response_window"): + delattr(self, "current_response_window") + + self.history_manager.clear_pending_inline_history() threading.Thread( - target=self.process_option_thread, - args=(option, custom_change), - daemon=True + target=self.process_option_thread, args=(option, custom_change), daemon=True ).start() @Slot(str, str) @@ -681,7 +765,7 @@ def _setup_response_window(self, option, selected_text): self.current_response_window.chat_history = [ { "role": "user", - "content": f"Original text to {option.lower()}:\n\n{selected_text}" + "content": f"Original text to {option.lower()}:\n\n{selected_text}", } ] @@ -691,7 +775,7 @@ def process_option_thread(self, option, custom_change=None): either open a response window (for window-mode options) or set up for inline replacement, and finally run the AI request. """ - logging.debug(f'Starting processing thread for option: {option}') + logging.debug(f"Starting processing thread for option: {option}") # Typically near-instant since the user took time to read the popup # and click. The 3s ceiling is a safety net for genuinely sluggish @@ -699,65 +783,117 @@ def process_option_thread(self, option, custom_change=None): # first, the event is already set and this returns immediately. holder = self.current_text_holder if holder is None or not holder.ready.wait(timeout=3.0): - logging.warning('Timed out waiting for selected text capture') - selected_text = (holder.text if holder else '') or '' + logging.warning("Timed out waiting for selected text capture") + selected_text = (holder.text if holder else "") or "" if not selected_text.strip(): # The chat-mode fallback that used to fire here was removed when # popup show became instant — we no longer have a way to detect # "user wants to chat" vs "capture failed", so we pick the safer # interpretation and surface the error. - self.show_message_signal.emit('Error', 'Please select text to use this option.') + self.show_message_signal.emit( + "Error", "Please select text to use this option." + ) return - if self.options[option]['open_in_window']: + open_in_window = self.options[option]["open_in_window"] + if open_in_window: QtCore.QMetaObject.invokeMethod( self, - '_setup_response_window', + "_setup_response_window", QtCore.Qt.ConnectionType.BlockingQueuedConnection, QtCore.Q_ARG(str, option), - QtCore.Q_ARG(str, selected_text) + QtCore.Q_ARG(str, selected_text), ) + else: + self.history_manager.set_pending_inline_history(option, selected_text) + response_window = None try: - selected_prompt = self.options.get(option, ('', '')) - prompt_prefix = selected_prompt['prefix'] - system_instruction = selected_prompt['instruction'] - if option == 'Custom': + selected_prompt = self.options.get(option, ("", "")) + prompt_prefix = selected_prompt["prefix"] + system_instruction = selected_prompt["instruction"] + if option == "Custom": prompt = f"{prompt_prefix}Described change: {custom_change}\n\nText: {selected_text}" else: prompt = f"{prompt_prefix}{selected_text}" self.output_queue = "" - logging.debug(f'Getting response from provider for option: {option}') - - if self.options[option]['open_in_window']: - logging.debug('Getting response for window display') - response = self.current_provider.get_response(system_instruction, prompt, return_response=True) - logging.debug(f'Got response of length: {len(response) if response else 0}') - - if hasattr(self, 'current_response_window'): + logging.debug(f"Getting response from provider for option: {option}") + + if open_in_window: + logging.debug("Getting response for window display") + # Capture the window ref now: after a cancel + re-trigger, a + # stale worker must not touch the *new* request's window. + response_window = getattr(self, "current_response_window", None) + response = self.current_provider.get_response( + system_instruction, prompt, return_response=True + ) + response = response or "" + logging.debug( + f"Got response of length: {len(response) if response else 0}" + ) + + cleaned_response = response.rstrip("\n") + if not cleaned_response.strip(): + # Cancelled or failed request: the window would spin on + # its thinking animation forever — close it instead. + # Must go through the event loop: we're on a worker thread. + if response_window is not None: + QtCore.QMetaObject.invokeMethod( + response_window, + "close", + QtCore.Qt.ConnectionType.QueuedConnection, + ) + return + history_entry_id = self.history_manager.record_entry( + option=option, + input_text=selected_text, + output_text=cleaned_response, + conversation=[ + {"role": "user", "content": selected_text}, + {"role": "assistant", "content": cleaned_response}, + ], + ) + self.history_manager.attach_entry_to_response_window( + response_window, history_entry_id + ) + + if response_window and cleaned_response.strip(): # noinspection PyTypeChecker QtCore.QMetaObject.invokeMethod( - self.current_response_window, - 'set_text', + response_window, + "set_text", QtCore.Qt.ConnectionType.QueuedConnection, - QtCore.Q_ARG(str, response) + QtCore.Q_ARG(str, response), ) - logging.debug('Invoked set_text on response window') + logging.debug("Invoked set_text on response window") else: - logging.debug('Getting response for direct replacement') + logging.debug("Getting response for direct replacement") self.current_provider.get_response(system_instruction, prompt) - logging.debug('Response processed') + logging.debug("Response processed") except Exception as e: - logging.error(f'An error occurred: {e}', exc_info=True) + logging.error(f"An error occurred: {e}", exc_info=True) + self.history_manager.clear_pending_inline_history() + + # A window-mode request that died (timeout, network error) would + # otherwise keep its thinking animation spinning forever. + if response_window is not None: + QtCore.QMetaObject.invokeMethod( + response_window, + "close", + QtCore.Qt.ConnectionType.QueuedConnection, + ) if "Resource has been exhausted" in str(e): - self.show_message_signal.emit('Error - Rate Limit Hit', 'Whoops! You\'ve hit the per-minute rate limit of the Gemini API. Please try again in a few moments.\n\nIf this happens often, simply switch to a Gemini model with a higher usage limit in Settings.') + self.show_message_signal.emit( + "Error - Rate Limit Hit", + "Whoops! You've hit the per-minute rate limit of the Gemini API. Please try again in a few moments.\n\nIf this happens often, simply switch to a Gemini model with a higher usage limit in Settings.", + ) else: - self.show_message_signal.emit('Error', f'An error occurred: {e}') + self.show_message_signal.emit("Error", f"An error occurred: {e}") @Slot(str, str) def show_message_box(self, title, message): @@ -779,74 +915,123 @@ def replace_text(self, new_text): """ Replaces the text by pasting in the LLM generated text. With "Key Points" and "Summary", invokes a window with the output instead. """ - error_message = 'ERROR_TEXT_INCOMPATIBLE_WITH_REQUEST' + error_message = "ERROR_TEXT_INCOMPATIBLE_WITH_REQUEST" # Confirm new_text exists and is a string if new_text and isinstance(new_text, str): self.output_queue += new_text - current_output = self.output_queue.strip() # Strip whitespace for comparison + current_output = ( + self.output_queue.strip() + ) # Strip whitespace for comparison # If the new text is the error message, show a message box if current_output == error_message: - self.show_message_signal.emit('Error', 'The text is incompatible with the requested change.') + self.history_manager.clear_pending_inline_history() + self.show_message_signal.emit( + "Error", "The text is incompatible with the requested change." + ) return # Check if we're building up to the error message (to prevent partial pasting) if len(current_output) <= len(error_message): - clean_current = ''.join(current_output.split()) - clean_error = ''.join(error_message.split()) - if clean_current == clean_error[:len(clean_current)]: + clean_current = "".join(current_output.split()) + clean_error = "".join(error_message.split()) + if clean_current == clean_error[: len(clean_current)]: return - logging.debug('Processing output text') + logging.debug("Processing output text") try: # For Summary and Key Points, show in response window - if hasattr(self, 'current_response_window'): + if hasattr(self, "current_response_window"): self.current_response_window.append_text(new_text) - + # If this is the initial response, add it to chat history - if len(self.current_response_window.chat_history) == 1: # Only original text exists - self.current_response_window.chat_history.append({ - "role": "assistant", - "content": self.output_queue.rstrip('\n') - }) + if ( + len(self.current_response_window.chat_history) == 1 + ): # Only original text exists + self.current_response_window.chat_history.append( + { + "role": "assistant", + "content": self.output_queue.rstrip("\n"), + } + ) else: # For other options, use the original clipboard-based replacement - clipboard_backup = pyperclip.paste() - cleaned_text = self.output_queue.rstrip('\n') - pyperclip.copy(cleaned_text) - - kbrd = pykeyboard.Controller() - def press_ctrl_v(): - kbrd.press(pykeyboard.Key.ctrl.value) - kbrd.press('v') - kbrd.release('v') - kbrd.release(pykeyboard.Key.ctrl.value) - - press_ctrl_v() - time.sleep(0.2) - pyperclip.copy(clipboard_backup) - - if not hasattr(self, 'current_response_window'): + cleaned_text = self.output_queue.rstrip("\n") + wayland = _wayland_io.is_wayland_active() + + if wayland: + # Sequence: put AI text in clipboard, inject Ctrl+V into + # the (hopefully still-focused) source app, then restore + # the user's prior clipboard contents. + clipboard_backup = _wayland_io.paste_text() + _wayland_io.copy_text(cleaned_text) + + # Give KWin a moment to settle focus after the popup + # closed; Wayland does NOT reliably refocus the source + # the way X11/Windows do. Default 300ms on Wayland; + # tunable via WT_PASTE_SETTLE_MS for slow compositors. + try: + settle_ms = int(os.environ.get("WT_PASTE_SETTLE_MS", "300")) + except ValueError: + settle_ms = 300 + time.sleep(settle_ms / 1000.0) + + _wayland_io.inject_ctrl_v() + time.sleep(0.3) + _wayland_io.copy_text(clipboard_backup) + else: + clipboard_backup = pyperclip.paste() + pyperclip.copy(cleaned_text) + + # Let the compositor settle focus after the popup closed. + # On Wayland (KWin) closing the popup does NOT reliably + # refocus the source app the way X11/Windows do; a brief + # settle here gives the WM a chance to restore focus to the + # window that had it before the popup. Tunable via env var + # WT_PASTE_SETTLE_MS so users on slow compositors can bump it. + settle_ms = 0 + try: + settle_ms = int(os.environ.get("WT_PASTE_SETTLE_MS", "0")) + except ValueError: + settle_ms = 0 + if settle_ms > 0: + time.sleep(settle_ms / 1000.0) + + kbrd = pykeyboard.Controller() + + def press_ctrl_v(): + kbrd.press(pykeyboard.Key.ctrl.value) + kbrd.press("v") + kbrd.release("v") + kbrd.release(pykeyboard.Key.ctrl.value) + + press_ctrl_v() + time.sleep(0.2) + pyperclip.copy(clipboard_backup) + self.history_manager.consume_pending_inline_history(cleaned_text) + + if not hasattr(self, "current_response_window"): self.output_queue = "" except Exception as e: - logging.error(f'Error processing output: {e}') + logging.error(f"Error processing output: {e}") + self.history_manager.clear_pending_inline_history() else: - logging.debug('No new text to process') + logging.debug("No new text to process") def create_tray_icon(self): """ Create the system tray icon for the application. """ if self.tray_icon: - logging.debug('Tray icon already exists') + logging.debug("Tray icon already exists") return - logging.debug('Creating system tray icon') - icon_path = os.path.join(os.path.dirname(sys.argv[0]), 'icons', 'app_icon.png') + logging.debug("Creating system tray icon") + icon_path = os.path.join(os.path.dirname(sys.argv[0]), "icons", "app_icon.png") if not os.path.exists(icon_path): - logging.warning(f'Tray icon not found at {icon_path}') + logging.warning(f"Tray icon not found at {icon_path}") # Use a default icon if not found self.tray_icon = QtWidgets.QSystemTrayIcon(self) else: @@ -858,7 +1043,7 @@ def create_tray_icon(self): self.update_tray_menu() self.tray_icon.show() - logging.debug('Tray icon displayed') + logging.debug("Tray icon displayed") def update_tray_menu(self): """ @@ -870,28 +1055,34 @@ def update_tray_menu(self): # Apply dark mode styles using darkdetect self.apply_dark_mode_styles(self.tray_menu) + # History menu item + history_action = self.tray_menu.addAction(self._("History")) + history_action.triggered.connect(self.history_manager.show_window) + # Settings menu item - settings_action = self.tray_menu.addAction(self._('Settings')) + settings_action = self.tray_menu.addAction(self._("Settings")) settings_action.triggered.connect(self.show_settings) - # Pause/Resume toggle action - self.toggle_action = self.tray_menu.addAction(self._('Resume') if self.paused else self._('Pause')) + # Pause/Resume toggle action + self.toggle_action = self.tray_menu.addAction( + self._("Resume") if self.paused else self._("Pause") + ) self.toggle_action.triggered.connect(self.toggle_paused) # About menu item - about_action = self.tray_menu.addAction(self._('About')) + about_action = self.tray_menu.addAction(self._("About")) about_action.triggered.connect(self.show_about) # Exit menu item - exit_action = self.tray_menu.addAction(self._('Exit')) + exit_action = self.tray_menu.addAction(self._("Exit")) exit_action.triggered.connect(self.exit_app) - + def toggle_paused(self): """Toggle the paused state of the application.""" - logging.debug('Toggle paused state') + logging.debug("Toggle paused state") self.paused = not self.paused - self.toggle_action.setText(self._('Resume') if self.paused else self._('Pause')) - logging.debug('App is paused' if self.paused else 'App is resumed') + self.toggle_action.setText(self._("Resume") if self.paused else self._("Pause")) + logging.debug("App is paused" if self.paused else "App is resumed") @staticmethod def apply_dark_mode_styles(menu): @@ -902,19 +1093,26 @@ def apply_dark_mode_styles(menu): palette = menu.palette() if is_dark_mode: - logging.debug('Tray icon dark') + logging.debug("Tray icon dark") # Dark mode colors - palette.setColor(QtGui.QPalette.Window, QtGui.QColor("#2d2d2d")) # Dark background - palette.setColor(QtGui.QPalette.WindowText, QtGui.QColor("#ffffff")) # White text + palette.setColor( + QtGui.QPalette.Window, QtGui.QColor("#2d2d2d") + ) # Dark background + palette.setColor( + QtGui.QPalette.WindowText, QtGui.QColor("#ffffff") + ) # White text else: - logging.debug('Tray icon light') + logging.debug("Tray icon light") # Light mode colors - palette.setColor(QtGui.QPalette.Window, QtGui.QColor("#ffffff")) # Light background - palette.setColor(QtGui.QPalette.WindowText, QtGui.QColor("#000000")) # Black text + palette.setColor( + QtGui.QPalette.Window, QtGui.QColor("#ffffff") + ) # Light background + palette.setColor( + QtGui.QPalette.WindowText, QtGui.QColor("#000000") + ) # Black text menu.setPalette(palette) - """ The function below (process_followup_question) processes follow-up questions in the chat interface for Summary, Key Points, and Table operations. @@ -960,30 +1158,32 @@ def process_followup_question(self, response_window, question): """ Process a follow-up question in the chat window. """ - logging.debug(f'Processing follow-up question: {question}') - + logging.debug(f"Processing follow-up question: {question}") + def process_thread(): - logging.debug('Starting follow-up processing thread') + logging.debug("Starting follow-up processing thread") try: if not response_window.chat_history: logging.error("No chat history found") - self.show_message_signal.emit('Error', 'Chat history not found') + self.show_message_signal.emit("Error", "Chat history not found") return # Add current question to chat history - response_window.chat_history.append({ - "role": "user", - "content": question - }) - + response_window.chat_history.append( + {"role": "user", "content": question} + ) + history_entry_id = getattr(response_window, "history_entry_id", None) + if history_entry_id: + self.history_manager.append_turn(history_entry_id, "user", question) + # Get chat history history = response_window.chat_history.copy() - + # System instruction based on original option system_instruction = "You are a helpful AI assistant. Provide clear and direct responses, maintaining the same format and style as your previous responses. If appropriate, use Markdown formatting to make your response more readable." - - logging.debug('Sending request to AI provider') - + + logging.debug("Sending request to AI provider") + # Format conversation differently based on provider if isinstance(self.current_provider, GeminiProvider): # Gemini takes the system instruction via its config object, @@ -991,26 +1191,28 @@ def process_thread(): # (user/assistant turns); GeminiProvider handles role mapping # and drops any "system" entries internally. response_text = self.current_provider.get_response( - system_instruction, - history, - return_response=True + system_instruction, history, return_response=True ) - elif isinstance(self.current_provider, OllamaProvider): # - # For Ollama, prepare messages with system instruction and history + elif isinstance( + self.current_provider, (OllamaProvider, OllamaCloudProvider) + ): + # For both Ollama variants (local server and Ollama Cloud), + # prepare messages with system instruction + history. The + # two providers share the same OpenAI-style message-array + # contract, so they share a branch. The actual endpoint + # (localhost vs ollama.com) and auth are encapsulated in + # each provider's `after_load()`. messages = [{"role": "system", "content": system_instruction}] for msg in history: - messages.append({ - "role": msg["role"], - "content": msg["content"] - }) + messages.append( + {"role": msg["role"], "content": msg["content"]} + ) - # Get response from Ollama + # Get response from Ollama / Ollama Cloud response_text = self.current_provider.get_response( - system_instruction, - messages, - return_response=True + system_instruction, messages, return_response=True ) else: @@ -1022,56 +1224,76 @@ def process_thread(): # Convert 'assistant' role to 'assistant' for OpenAI role = "assistant" if msg["role"] == "assistant" else "user" messages.append({"role": role, "content": msg["content"]}) - + # Get response by passing the full messages array response_text = self.current_provider.get_response( system_instruction, messages, # Pass messages array directly - return_response=True + return_response=True, ) - logging.debug(f'Got response of length: {len(response_text)}') - + logging.debug(f"Got response of length: {len(response_text)}") + + # Cancelled (hotkey re-press) or timed-out requests return "" — + # don't pollute chat history with an empty assistant turn. The + # signal still fires: the window's handler re-enables the input + # and stops the thinking animation (it skips empty messages). + if not response_text.strip(): + self.followup_response_signal.emit("") + return + # Add response to chat history - response_window.chat_history.append({ - "role": "assistant", - "content": response_text - }) - + response_window.chat_history.append( + {"role": "assistant", "content": response_text} + ) + if history_entry_id and response_text: + self.history_manager.append_turn( + history_entry_id, "assistant", response_text + ) + # Emit response via signal self.followup_response_signal.emit(response_text) except Exception as e: - logging.error(f'Error processing follow-up question: {e}', exc_info=True) + logging.error( + f"Error processing follow-up question: {e}", exc_info=True + ) if "Resource has been exhausted" in str(e): - self.show_message_signal.emit('Error - Rate Limit Hit', 'Whoops! You\'ve hit the per-minute rate limit of the Gemini API. Please try again in a few moments.\n\nIf this happens often, simply switch to a Gemini model with a higher usage limit in Settings.') - self.followup_response_signal.emit("Sorry, an error occurred while processing your question.") + self.show_message_signal.emit( + "Error - Rate Limit Hit", + "Whoops! You've hit the per-minute rate limit of the Gemini API. Please try again in a few moments.\n\nIf this happens often, simply switch to a Gemini model with a higher usage limit in Settings.", + ) + self.followup_response_signal.emit( + "Sorry, an error occurred while processing your question." + ) else: - self.show_message_signal.emit('Error', f'An error occurred: {e}') - self.followup_response_signal.emit("Sorry, an error occurred while processing your question.") + self.show_message_signal.emit("Error", f"An error occurred: {e}") + self.followup_response_signal.emit( + "Sorry, an error occurred while processing your question." + ) # Start the thread threading.Thread(target=process_thread, daemon=True).start() def show_settings(self, providers_only=False): - """ Show the settings window. """ - logging.debug('Showing settings window') + logging.debug("Showing settings window") # Always create a new settings window to handle providers_only correctly - self.settings_window = ui.SettingsWindow.SettingsWindow(self, providers_only=providers_only) + self.settings_window = ui.SettingsWindow.SettingsWindow( + self, providers_only=providers_only + ) self.settings_window.close_signal.connect(self.exit_app) self.settings_window.retranslate_ui() self.settings_window.show() - def show_about(self): """ Show the about window. """ - logging.debug('Showing about window') + logging.debug("Showing about window") if not self.about_window: self.about_window = ui.AboutWindow.AboutWindow() self.about_window.show() @@ -1080,7 +1302,9 @@ def setup_ctrl_c_listener(self): """ Listener for Ctrl+C to exit the app. """ - signal.signal(signal.SIGINT, lambda signum, frame: self.handle_sigint(signum, frame)) + signal.signal( + signal.SIGINT, lambda signum, frame: self.handle_sigint(signum, frame) + ) # This empty timer is needed to make sure that the sigint handler gets checked inside the main loop: # without it, the sigint handle would trigger only when an event is triggered, either by a hotkey combination # or by another GUI event like spawning a new window. With this we trigger it every 100ms with an empy lambda @@ -1088,6 +1312,7 @@ def setup_ctrl_c_listener(self): self.ctrl_c_timer = QtCore.QTimer() self.ctrl_c_timer.start(100) self.ctrl_c_timer.timeout.connect(lambda: None) + def handle_sigint(self, signum, frame): """ Handle the SIGINT signal (Ctrl+C) to exit the app gracefully. @@ -1099,8 +1324,8 @@ def exit_app(self): """ Exit the application. """ - logging.debug('Stopping the listener') + logging.debug("Stopping the listener") if self.hotkey_listener is not None: self.hotkey_listener.stop() - logging.debug('Exiting application') + logging.debug("Exiting application") self.quit() diff --git a/Windows_and_Linux/_wayland_io.py b/Windows_and_Linux/_wayland_io.py new file mode 100644 index 00000000..c25139e7 --- /dev/null +++ b/Windows_and_Linux/_wayland_io.py @@ -0,0 +1,152 @@ +""" +Wayland-native clipboard + keystroke injection for WritingTools. + +Why this exists: on KDE Plasma Wayland the original pynput+pyperclip flow breaks +for two independent reasons: + +1. pyperclip shells out to `wl-copy` and blocks. wl-copy forks a background + server to serve the selection and does not exit promptly, so + subprocess.run(capture_output=True) hangs/times out. That breaks the + clipboard clear (capture) and the clipboard write (paste-back): the app + reads stale clipboard content and never lands the AI text where it + should — it only surfaces as a secondary Klipper entry. + +2. pynput's Linux backend is X11/XTest. On a Wayland session it only reaches + XWayland windows; native Wayland apps never get the synthetic Ctrl+C/V. + Even XWayland injection is unreliable under KWin. + +This module provides a Wayland path used only when a Wayland session AND the +required tools (wl-clipboard, ydotool + ydotoold) are present. Windows and +X11 callers keep the original pyperclip + pynput path untouched. + +Clipboard operations use wl-clipboard with a fork-and-detach pattern (no +capture_output that waits on the long-lived server). Keystroke injection uses +ydotool, which emits at the kernel uinput layer (verified: a "ydotoold virtual +device" with a kbd handler) and so reaches both native Wayland and XWayland +windows. Requires the ydotoold daemon running and the user in the `input` +group (for /dev/uinput access). +""" + +import logging +import os +import shutil +import subprocess + +# linux/input-event-codes.h raw keycodes (ydotool format ":"). +# KEY_LEFTCTRL=29, KEY_C=46, KEY_V=47. Each token MUST be a separate argv +# element: ydotool 1.0.4 silently emits nothing when keycodes are passed as a +# single space-joined string argument. +_CTRL_C = ["29:1", "46:1", "46:0", "29:0"] +_CTRL_V = ["29:1", "47:1", "47:0", "29:0"] + +_detection = None + + +def is_wayland_active(): + """True once per process if under Wayland with wl-clipboard AND ydotool. + + Cached so repeated calls are cheap. Returns False on Windows/X11 or if + tools are missing, so callers fall back to the original path. + """ + global _detection + if _detection is not None: + return _detection + + if os.environ.get( + "XDG_SESSION_TYPE", "" + ).lower() != "wayland" and not os.environ.get("WAYLAND_DISPLAY", ""): + _detection = False + return False + + have_wl = shutil.which("wl-copy") and shutil.which("wl-paste") + have_yd = shutil.which("ydotool") + if not (have_wl and have_yd): + logging.warning( + "Wayland session detected but native tools missing " + f"(wl-clipboard={'yes' if have_wl else 'no'}, " + f"ydotool={'yes' if have_yd else 'no'}); falling back to pynput/pyperclip. " + "Install wl-clipboard and ydotool (and run ydotoold) for Wayland support." + ) + _detection = False + return False + + logging.info("Wayland native IO path active (wl-clipboard + ydotool)") + _detection = True + return True + + +def paste_text(): + """Read the Wayland clipboard as text. Returns '' on empty/error.""" + try: + out = subprocess.run( + ["wl-paste", "--no-newline"], + capture_output=True, + text=True, + timeout=2.0, + check=False, + ) + if out.returncode != 0: + # Empty clipboard prints "Nothing is copied" to stderr, exits 1. + return "" + return out.stdout + except Exception as e: + logging.error(f"wl-paste failed: {e}") + return "" + + +def copy_text(text): + """Write text to the Wayland clipboard (replaces contents). + + wl-copy forks a background server to serve the selection and does not + exit promptly; we feed it via stdin and detach (DEVNULL + close_fds) so + this returns immediately instead of hanging on the server's pipes. + """ + try: + subprocess.Popen( + ["wl-copy"], + stdin=subprocess.PIPE, + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + close_fds=True, + ).communicate(input=text.encode("utf-8"), timeout=2.0) + except Exception as e: + logging.error(f"wl-copy failed: {e}") + + +def clear_clipboard(): + """Clear the Wayland clipboard. Forks and detaches like copy_text.""" + try: + subprocess.Popen( + ["wl-copy", "--clear"], + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + close_fds=True, + ) + except Exception as e: + logging.error(f"wl-copy --clear failed: {e}") + + +def inject_ctrl_c(): + """Synthesize Ctrl+C at the uinput layer via ydotool.""" + try: + subprocess.run( + ["ydotool", "key", *_CTRL_C], + capture_output=True, + timeout=2.0, + check=False, + ) + except Exception as e: + logging.error(f"ydotool Ctrl+C failed: {e}") + + +def inject_ctrl_v(): + """Synthesize Ctrl+V at the uinput layer via ydotool.""" + try: + subprocess.run( + ["ydotool", "key", *_CTRL_V], + capture_output=True, + timeout=2.0, + check=False, + ) + except Exception as e: + logging.error(f"ydotool Ctrl+V failed: {e}") diff --git a/Windows_and_Linux/build-and-install-local-linux.sh b/Windows_and_Linux/build-and-install-local-linux.sh new file mode 100755 index 00000000..219beb21 --- /dev/null +++ b/Windows_and_Linux/build-and-install-local-linux.sh @@ -0,0 +1,84 @@ +#!/usr/bin/env bash +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +AUTOSTART_FLAG="" +SKIP_BUILD="no" + +print_usage() { + cat <<'USAGE' +Usage: + bash build-and-install-local-linux.sh [--enable-autostart|--disable-autostart] [--skip-build] + +Builds Writing Tools with PyInstaller, then installs it locally with: +- Launcher command: ~/.local/bin/writing-tools +- App menu entry: ~/.local/share/applications/writing-tools.desktop + +Options: + --enable-autostart Enable autostart after install + --disable-autostart Disable autostart after install + --skip-build Skip PyInstaller build and only run local install + -h, --help Show this help message +USAGE +} + +while [[ $# -gt 0 ]]; do + case "$1" in + --enable-autostart|--disable-autostart) + AUTOSTART_FLAG="$1" + ;; + --skip-build) + SKIP_BUILD="yes" + ;; + -h|--help) + print_usage + exit 0 + ;; + *) + echo "ERROR: Unknown option: $1" + print_usage + exit 1 + ;; + esac + shift +done + +if ! command -v python3 >/dev/null 2>&1; then + echo "ERROR: python3 is required but was not found." + exit 1 +fi + +VENV_DIR="${SCRIPT_DIR}/.venv" +if [[ ! -x "${VENV_DIR}/bin/python" ]]; then + echo "Creating virtual environment..." + python3 -m venv "${VENV_DIR}" +fi +source "${VENV_DIR}/bin/activate" +python -m pip install --quiet --upgrade pip +python -m pip install --quiet -r "${SCRIPT_DIR}/requirements.txt" + +INSTALL_SCRIPT="${SCRIPT_DIR}/install-local-linux.sh" +if [[ ! -f "${INSTALL_SCRIPT}" ]]; then + echo "ERROR: Missing installer script at ${INSTALL_SCRIPT}" + exit 1 +fi + +pushd "${SCRIPT_DIR}" >/dev/null + +if [[ "${SKIP_BUILD}" != "yes" ]]; then + echo "Building with PyInstaller..." + python3 pyinstaller-build-script.py +else + echo "Skipping build as requested." +fi + +echo "Installing locally for current user..." +install_args=(--app-source "${SCRIPT_DIR}") +if [[ -n "${AUTOSTART_FLAG}" ]]; then + install_args+=("${AUTOSTART_FLAG}") +fi +bash "${INSTALL_SCRIPT}" "${install_args[@]}" + +popd >/dev/null + +echo "Done. Launch via: writing-tools" diff --git a/Windows_and_Linux/install-local-linux.sh b/Windows_and_Linux/install-local-linux.sh new file mode 100755 index 00000000..660a2c48 --- /dev/null +++ b/Windows_and_Linux/install-local-linux.sh @@ -0,0 +1,184 @@ +#!/usr/bin/env bash +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +APP_SOURCE="${SCRIPT_DIR}" +ENABLE_AUTOSTART="" + +print_usage() { + cat <<'USAGE' +Usage: + bash install-local-linux.sh [--app-source ] [--enable-autostart|--disable-autostart] + +Installs Writing Tools for the current Linux user: +- App files: ~/.local/share/writingtools/app +- Launcher command: ~/.local/bin/writing-tools +- App menu entry: ~/.local/share/applications/writing-tools.desktop + +Options: + --app-source Source directory containing app assets and dist output + --enable-autostart Create ~/.config/autostart/writing-tools.desktop + --disable-autostart Remove ~/.config/autostart/writing-tools.desktop + -h, --help Show this help message +USAGE +} + +while [[ $# -gt 0 ]]; do + case "$1" in + --app-source) + shift + if [[ $# -eq 0 ]]; then + echo "ERROR: --app-source requires a value" + exit 1 + fi + APP_SOURCE="$1" + ;; + --enable-autostart) + ENABLE_AUTOSTART="yes" + ;; + --disable-autostart) + ENABLE_AUTOSTART="no" + ;; + -h|--help) + print_usage + exit 0 + ;; + *) + echo "ERROR: Unknown option: $1" + print_usage + exit 1 + ;; + esac + shift +done + +if [[ "${EUID}" -eq 0 ]]; then + echo "ERROR: Please run this as your regular desktop user, not root." + exit 1 +fi + +if [[ ! -d "${APP_SOURCE}" ]]; then + echo "ERROR: App source directory not found: ${APP_SOURCE}" + exit 1 +fi + +DIST_EXE="" +if [[ -x "${APP_SOURCE}/Writing Tools" ]]; then + DIST_EXE="${APP_SOURCE}/Writing Tools" +elif [[ -x "${APP_SOURCE}/dist/Writing Tools" ]]; then + DIST_EXE="${APP_SOURCE}/dist/Writing Tools" +else + echo "ERROR: Compiled binary not found in ${APP_SOURCE}" + echo "Build first with: python3 pyinstaller-build-script.py" + exit 1 +fi + +for required_dir in icons locales; do + if [[ ! -d "${APP_SOURCE}/${required_dir}" ]]; then + echo "ERROR: Missing required directory: ${APP_SOURCE}/${required_dir}" + exit 1 + fi +done + +for required_file in background.png background_dark.png background_popup.png background_popup_dark.png Latest_Version_for_Update_Check.txt options.json; do + if [[ ! -f "${APP_SOURCE}/${required_file}" ]]; then + echo "ERROR: Missing required file: ${APP_SOURCE}/${required_file}" + exit 1 + fi +done + +DATA_HOME="${XDG_DATA_HOME:-${HOME}/.local/share}" +CONFIG_HOME="${XDG_CONFIG_HOME:-${HOME}/.config}" +INSTALL_ROOT="${DATA_HOME}/writingtools" +APP_DIR="${INSTALL_ROOT}/app" +BIN_DIR="${HOME}/.local/bin" +APPS_DIR="${DATA_HOME}/applications" +AUTOSTART_DIR="${CONFIG_HOME}/autostart" +LAUNCHER_PATH="${BIN_DIR}/writing-tools" +DESKTOP_PATH="${APPS_DIR}/writing-tools.desktop" +AUTOSTART_PATH="${AUTOSTART_DIR}/writing-tools.desktop" + +mkdir -p "${APP_DIR}" "${BIN_DIR}" "${APPS_DIR}" "${AUTOSTART_DIR}" + +install -m 0755 "${DIST_EXE}" "${APP_DIR}/Writing Tools" + +rm -rf "${APP_DIR}/icons" "${APP_DIR}/locales" +cp -a "${APP_SOURCE}/icons" "${APP_DIR}/icons" +cp -a "${APP_SOURCE}/locales" "${APP_DIR}/locales" + +for file_name in background.png background_dark.png background_popup.png background_popup_dark.png Latest_Version_for_Update_Check.txt; do + install -m 0644 "${APP_SOURCE}/${file_name}" "${APP_DIR}/${file_name}" +done + +# Preserve user-customized options on upgrades. +if [[ ! -f "${APP_DIR}/options.json" ]]; then + install -m 0644 "${APP_SOURCE}/options.json" "${APP_DIR}/options.json" +fi + +cat > "${LAUNCHER_PATH}" <<'EOF' +#!/usr/bin/env bash +set -euo pipefail + +APP_DIR="${XDG_DATA_HOME:-${HOME}/.local/share}/writingtools/app" +if [[ -d "${APP_DIR}/lib" ]]; then + export LD_LIBRARY_PATH="${APP_DIR}/lib${LD_LIBRARY_PATH:+:${LD_LIBRARY_PATH}}" +fi +if [[ -d "${APP_DIR}/bin" ]]; then + export PATH="${APP_DIR}/bin:${PATH}" +fi +cd "${APP_DIR}" +exec "${APP_DIR}/Writing Tools" "$@" +EOF +chmod 0755 "${LAUNCHER_PATH}" + +cat > "${DESKTOP_PATH}" < "${AUTOSTART_PATH}" </dev/null 2>&1 && ! command -v xsel >/dev/null 2>&1 && ! command -v wl-copy >/dev/null 2>&1; then + echo "WARNING: No clipboard backend detected (xclip/xsel/wl-copy)." + echo " Install one to ensure copy/replace flow works." +fi + +if [[ "${XDG_SESSION_TYPE:-}" == "wayland" ]]; then + echo "NOTICE: Wayland session detected." + if ! command -v wl-copy >/dev/null 2>&1 || ! command -v ydotool >/dev/null 2>&1; then + echo " For full capture + paste-back support on Wayland, install:" + echo " wl-clipboard (provides wl-copy/wl-paste)" + echo " ydotool (keystroke injection; enable ydotoold service)" + echo " and add your user to the 'input' group, then log out/in." + echo " Without these, the app falls back to the X11 path which only" + echo " works in XWayland windows." + fi +fi diff --git a/Windows_and_Linux/pyinstaller-build-script.py b/Windows_and_Linux/pyinstaller-build-script.py index 56819886..b1a61560 100644 --- a/Windows_and_Linux/pyinstaller-build-script.py +++ b/Windows_and_Linux/pyinstaller-build-script.py @@ -1,9 +1,42 @@ -import os +import shutil import subprocess import sys +from pathlib import Path + + +ROOT_DIR = Path(__file__).resolve().parent +DIST_DIR = ROOT_DIR / 'dist' +BUILD_DIR = ROOT_DIR / 'build' +PYCACHE_DIR = ROOT_DIR / '__pycache__' + + +def remove_path(path: Path): + if path.is_dir(): + shutil.rmtree(path) + elif path.exists(): + path.unlink() + + +def require_path(path: Path, description: str): + if not path.exists(): + print(f"ERROR: Missing {description}: {path}") + sys.exit(1) + + +def run_preflight_checks(): + require_path(ROOT_DIR / 'main.py', 'entrypoint file') + require_path(ROOT_DIR / 'icons' / 'app_icon.ico', 'application icon') + + if shutil.which('pyinstaller') is None: + print('ERROR: pyinstaller is not available in PATH.') + print('Install dependencies first, then re-run this build script.') + print('Example: pip install -r requirements.txt') + sys.exit(1) def run_pyinstaller_build(): + run_preflight_checks() + pyinstaller_command = [ "pyinstaller", "--onefile", @@ -77,29 +110,28 @@ def run_pyinstaller_build(): try: # Remove previous build directories - if os.path.exists('dist'): - os.system("rmdir /s /q dist") - if os.path.exists('build'): - os.system("rmdir /s /q build") - if os.path.exists('__pycache__'): - os.system("rmdir /s /q __pycache__") + remove_path(DIST_DIR) + remove_path(BUILD_DIR) + remove_path(PYCACHE_DIR) # Run PyInstaller - subprocess.run(pyinstaller_command, check=True) - print("Build completed successfully!") + subprocess.run(pyinstaller_command, check=True, cwd=ROOT_DIR) + print(f"Build completed successfully! Output: {DIST_DIR / 'Writing Tools'}") # Clean up unnecessary files - if os.path.exists('build'): - os.system("rmdir /s /q build") - if os.path.exists('__pycache__'): - os.system("rmdir /s /q __pycache__") + remove_path(BUILD_DIR) + remove_path(PYCACHE_DIR) # No need to copy data files manually since they are included # in the executable using --add-data except subprocess.CalledProcessError as e: print(f"Build failed with error: {e}") + sys.exit(e.returncode or 1) + + except OSError as e: + print(f"Build failed with OS error: {e}") sys.exit(1) if __name__ == "__main__": - run_pyinstaller_build() \ No newline at end of file + run_pyinstaller_build()