From a0acfe4af9ec112796c037d88f005dd777483eca Mon Sep 17 00:00:00 2001 From: Burhanuddin Kalawadwala <147647006+saurondark22@users.noreply.github.com> Date: Thu, 4 Jun 2026 21:56:26 +0530 Subject: [PATCH 1/3] Add Linux local build script and update compile instructions - Add build-and-install-local-linux.sh for local install with launcher icon - Update build docs with clearer venv setup, platform-specific activation, and Linux install steps - Add autostart option and post-install usage info - Ignore Python venv, packaging, dist, and spec files in Windows_and_Linux - Fix README link to point to main branch instead of specific commit --- .gitignore | 7 +- .../To Compile the Application Yourself.md | 53 ++++-- ...ing Tools Directly from the Source Code.md | 8 + README.md | 4 +- .../build-and-install-local-linux.sh | 75 ++++++++ Windows_and_Linux/install-local-linux.sh | 176 ++++++++++++++++++ Windows_and_Linux/pyinstaller-build-script.py | 60 ++++-- 7 files changed, 354 insertions(+), 29 deletions(-) create mode 100755 Windows_and_Linux/build-and-install-local-linux.sh create mode 100755 Windows_and_Linux/install-local-linux.sh 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..b967a508 100644 --- a/README's Linked Content/To Compile the Application Yourself.md +++ b/README's Linked Content/To Compile the Application Yourself.md @@ -3,32 +3,59 @@ ### Windows and Linux Version build instructions: Here's how to compile it with PyInstaller and a virtual environment: -1. First, create and activate a virtual environment: +1. Open Terminal (or Command Prompt) and enter the Windows/Linux app folder: ```bash -# Install virtualenv if you haven't already -pip install virtualenv +cd /path/to/WritingTools/Windows_and_Linux +``` -# Create a new virtual environment -virtualenv myvenv +2. Create and activate a virtual environment: +```bash +python3 -m venv .venv -# Activate it -# On Windows: -myvenv\Scripts\activate -# On Linux: -source myvenv/bin/activate -``` +# Linux: +source .venv/bin/activate -2. Once activated, install the required packages: +# 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 + +5. Linux only (optional, recommended): install locally with launcher icon: +```bash +bash build-and-install-local-linux.sh +``` + +This creates: +- `~/.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. + ### 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/build-and-install-local-linux.sh b/Windows_and_Linux/build-and-install-local-linux.sh new file mode 100755 index 00000000..51c88f86 --- /dev/null +++ b/Windows_and_Linux/build-and-install-local-linux.sh @@ -0,0 +1,75 @@ +#!/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 + +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..c3b04fd0 --- /dev/null +++ b/Windows_and_Linux/install-local-linux.sh @@ -0,0 +1,176 @@ +#!/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: Running in Wayland session; global hotkey/focus behavior may be limited." +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() From ddd9c196b2775d70bb19d9fdb2c1fe756e53764c Mon Sep 17 00:00:00 2001 From: Burhanuddin Kalawadwala <147647006+saurondark22@users.noreply.github.com> Date: Fri, 7 Aug 2026 20:46:15 +0530 Subject: [PATCH 2/3] build(linux): automate venv setup and dependency installation Create a virtual environment and install requirements in build-and-install-local-linux.sh before running the installer. Update compile instructions to reflect the new Linux build process. --- .../To Compile the Application Yourself.md | 58 ++++++++++--------- .../build-and-install-local-linux.sh | 9 +++ 2 files changed, 40 insertions(+), 27 deletions(-) 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 b967a508..55c0049f 100644 --- a/README's Linked Content/To Compile the Application Yourself.md +++ b/README's Linked Content/To Compile the Application Yourself.md @@ -1,19 +1,44 @@ # 👨‍💻 To compile the application yourself: -### Windows and Linux Version build instructions: +### 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. + +### Windows build instructions: Here's how to compile it with PyInstaller and a virtual environment: -1. Open Terminal (or Command Prompt) and enter the Windows/Linux app folder: +1. Open Command Prompt (or PowerShell) and enter the Windows app folder: ```bash cd /path/to/WritingTools/Windows_and_Linux ``` 2. Create and activate a virtual environment: ```bash -python3 -m venv .venv - -# Linux: -source .venv/bin/activate +python -m venv .venv # Windows (PowerShell): .venv\Scripts\Activate.ps1 @@ -35,27 +60,6 @@ python pyinstaller-build-script.py The compiled binary is written to: ~/Windows_and_Linux/dist/Writing Tools -5. Linux only (optional, recommended): install locally with launcher icon: -```bash -bash build-and-install-local-linux.sh -``` - -This creates: -- `~/.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. - ### macOS Version (by [Aryamirsepasi](https://github.com/Aryamirsepasi)) build instructions: 1. **Install Xcode** diff --git a/Windows_and_Linux/build-and-install-local-linux.sh b/Windows_and_Linux/build-and-install-local-linux.sh index 51c88f86..219beb21 100755 --- a/Windows_and_Linux/build-and-install-local-linux.sh +++ b/Windows_and_Linux/build-and-install-local-linux.sh @@ -48,6 +48,15 @@ if ! command -v python3 >/dev/null 2>&1; then 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}" From b2497117727d0366647ce7bb6c33d8d545208e6c Mon Sep 17 00:00:00 2001 From: Burhanuddin Kalawadwala <147647006+saurondark22@users.noreply.github.com> Date: Sun, 16 Aug 2026 14:29:40 +0530 Subject: [PATCH 3/3] feat(wayland): add native clipboard and keystroke injection support pynput and pyperclip are unreliable on Wayland sessions: pynput only reaches XWayland windows and pyperclip's wl-copy subprocess blocks. Add a Wayland-native IO path using wl-clipboard and ydotool, with automatic fallback to the existing X11 path when the tools are missing. Also update the Linux installer and compile docs to list the required system packages, and fix the Windows build workflow zip command. --- .../To Compile the Application Yourself.md | 36 + Windows_and_Linux/WritingToolApp.py | 725 ++++++++++++------ Windows_and_Linux/_wayland_io.py | 152 ++++ Windows_and_Linux/install-local-linux.sh | 10 +- 4 files changed, 672 insertions(+), 251 deletions(-) create mode 100644 Windows_and_Linux/_wayland_io.py 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 55c0049f..5da82575 100644 --- a/README's Linked Content/To Compile the Application Yourself.md +++ b/README's Linked Content/To Compile the Application Yourself.md @@ -28,6 +28,42 @@ writing-tools No daily recompilation is needed unless you changed source code and want a newer build. +#### Linux system dependencies + +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 +# 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). + +Without these tools on Wayland, the app falls back to the X11 path, which only works inside XWayland windows. + ### Windows build instructions: Here's how to compile it with PyInstaller and a virtual environment: 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/install-local-linux.sh b/Windows_and_Linux/install-local-linux.sh index c3b04fd0..660a2c48 100755 --- a/Windows_and_Linux/install-local-linux.sh +++ b/Windows_and_Linux/install-local-linux.sh @@ -172,5 +172,13 @@ if ! command -v xclip >/dev/null 2>&1 && ! command -v xsel >/dev/null 2>&1 && ! fi if [[ "${XDG_SESSION_TYPE:-}" == "wayland" ]]; then - echo "NOTICE: Running in Wayland session; global hotkey/focus behavior may be limited." + 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