From 5817a117376a68947e48f56c21ca9449d9b8cf16 Mon Sep 17 00:00:00 2001 From: d7om Date: Wed, 15 Apr 2026 13:22:36 +0200 Subject: [PATCH 1/5] build: migrate from Nuitka onefile to pyproject.toml + python-installer Nuitka froze a pip-installed PyGObject into the binary; the frozen gi/overrides asserted against the live system GLib and crashed when glib2 evolved (#13, #14). Wheel + python-installer keeps gi in lockstep with the user's system python-gobject by construction. - Add pyproject.toml with [project.scripts] entry point - Extract main() into clipse_gui/cli.py so the entry point is importable - Reduce root clipse-gui.py to a thin dev shim - Drop Nuitka and the PyGObject pin from requirements.txt --- clipse-gui.py | 122 ++++------------------------------------------ clipse_gui/cli.py | 121 +++++++++++++++++++++++++++++++++++++++++++++ pyproject.toml | 44 +++++++++++++++++ requirements.txt | 10 +++- 4 files changed, 183 insertions(+), 114 deletions(-) create mode 100644 clipse_gui/cli.py diff --git a/clipse-gui.py b/clipse-gui.py index f7b21e2..52f0ddc 100755 --- a/clipse-gui.py +++ b/clipse-gui.py @@ -1,120 +1,18 @@ #!/usr/bin/env python3 -import argparse -import logging -import os -import sys -import traceback -from logging.handlers import RotatingFileHandler -from pathlib import Path -import gi -gi.require_version("Gtk", "3.0") -gi.require_version("Gdk", "3.0") -gi.require_version("Pango", "1.0") -gi.require_version("GdkPixbuf", "2.0") -gi.require_version("GLib", "2.0") - -from clipse_gui import __version__, constants # noqa: E402 - -PACKAGE_PARENT = ".." -SCRIPT_DIR = os.path.dirname( - os.path.realpath(os.path.join(os.getcwd(), os.path.expanduser(__file__))) -) -sys.path.append(os.path.normpath(os.path.join(SCRIPT_DIR, PACKAGE_PARENT))) - -log = None - - -class ColorFormatter(logging.Formatter): - COLORS = { - "DEBUG": "\033[1;36m", - "INFO": "\033[1;32m", - "WARNING": "\033[1;33m", - "ERROR": "\033[1;31m", - "CRITICAL": "\033[1;41m", - "RESET": "\033[0m", - } - - def format(self, record): - level_color = self.COLORS.get(record.levelname, "") - reset = self.COLORS["RESET"] - record.levelname = f"{level_color}{record.levelname}{reset}" - record.name = f"\033[1;34m{record.name}\033[0m" - record.asctime = f"\033[1;37m{self.formatTime(record, self.datefmt)}\033[0m" - return super().format(record) - - -def parse_args_from_sys_argv(): - parser = argparse.ArgumentParser( - description="Start the Clipse GUI application.", - formatter_class=argparse.ArgumentDefaultsHelpFormatter, - ) - parser.add_argument( - "-d", "--debug", action="store_true", help="Enable debug logging" - ) - parser.add_argument( - "-v", "--version", action="version", version=f"Clipse GUI v{__version__}" - ) - return parser.parse_known_args() - +"""Dev entry shim for in-tree runs. -def setup_logging(debug=False): - global log - try: - log_format = "%(asctime)s - %(name)s - %(levelname)s - %(message)s" - date_format = "%Y-%m-%d %H:%M:%S" +The canonical entry point is `clipse_gui.cli:main`, exposed as `clipse-gui` +via `[project.scripts]` once installed from the wheel. This file just lets +`python clipse-gui.py` work from a fresh checkout. +""" - stream_handler = logging.StreamHandler(sys.stdout) - stream_handler.setLevel(logging.DEBUG if debug else logging.INFO) - stream_handler.setFormatter(ColorFormatter(log_format, datefmt=date_format)) - - handlers = [stream_handler] - - # File logging only in debug mode — RotatingFileHandler adds ~100ms I/O on every start - if debug: - log_file_path = os.path.join(constants.CONFIG_DIR, "clipse-gui.log") - file_handler = RotatingFileHandler( - filename=log_file_path, - maxBytes=5 * 1024 * 1024, - backupCount=3, - ) - file_handler.setLevel(logging.DEBUG) - file_handler.setFormatter(logging.Formatter(log_format, datefmt=date_format)) - handlers.append(file_handler) - - logging.basicConfig(level=logging.DEBUG, handlers=handlers) - log = logging.getLogger(__name__) - log.info(f"Logging initialized ({'DEBUG + file' if debug else 'INFO'})") - except Exception as e: - print(f"CRITICAL: Failed to set up logging: {e}", file=sys.stderr) - traceback.print_exc() - sys.exit(1) - - -def main(): - args, gtk_args = parse_args_from_sys_argv() - setup_logging(debug=args.debug) - - if log is None: - print("CRITICAL: Logging setup failed. Exiting.", file=sys.stderr) - sys.exit(1) - - try: - Path(constants.CONFIG_DIR).mkdir(parents=True, exist_ok=True) - except Exception as e: - log.critical(f"Failed to create config directory: {e}", exc_info=True) - sys.exit(1) - - try: - from clipse_gui.app import ClipseGuiApplication +import os +import sys - app = ClipseGuiApplication() - exit_status = app.run(gtk_args) - log.info(f"Application exited with status {exit_status}.") - sys.exit(exit_status) - except Exception as e: - log.critical(f"Unhandled exception in main: {e}", exc_info=True) - sys.exit(1) +# Make the package importable when running from source without installing. +sys.path.insert(0, os.path.dirname(os.path.realpath(__file__))) +from clipse_gui.cli import main # noqa: E402 if __name__ == "__main__": main() diff --git a/clipse_gui/cli.py b/clipse_gui/cli.py new file mode 100644 index 0000000..c5fbdc2 --- /dev/null +++ b/clipse_gui/cli.py @@ -0,0 +1,121 @@ +"""Console entry point for clipse-gui. + +Referenced by `[project.scripts]` in pyproject.toml as `clipse_gui.cli:main`. +The root `clipse-gui.py` is a thin shim that calls into here for in-tree development. +""" + +import argparse +import logging +import os +import sys +import traceback +from logging.handlers import RotatingFileHandler +from pathlib import Path + +import gi + +gi.require_version("Gtk", "3.0") +gi.require_version("Gdk", "3.0") +gi.require_version("Pango", "1.0") +gi.require_version("GdkPixbuf", "2.0") +gi.require_version("GLib", "2.0") + +from . import __version__, constants # noqa: E402 + +log: logging.Logger | None = None + + +class ColorFormatter(logging.Formatter): + COLORS = { + "DEBUG": "\033[1;36m", + "INFO": "\033[1;32m", + "WARNING": "\033[1;33m", + "ERROR": "\033[1;31m", + "CRITICAL": "\033[1;41m", + "RESET": "\033[0m", + } + + def format(self, record): + level_color = self.COLORS.get(record.levelname, "") + reset = self.COLORS["RESET"] + record.levelname = f"{level_color}{record.levelname}{reset}" + record.name = f"\033[1;34m{record.name}\033[0m" + record.asctime = f"\033[1;37m{self.formatTime(record, self.datefmt)}\033[0m" + return super().format(record) + + +def parse_args_from_sys_argv(): + parser = argparse.ArgumentParser( + description="Start the Clipse GUI application.", + formatter_class=argparse.ArgumentDefaultsHelpFormatter, + ) + parser.add_argument( + "-d", "--debug", action="store_true", help="Enable debug logging" + ) + parser.add_argument( + "-v", "--version", action="version", version=f"Clipse GUI v{__version__}" + ) + return parser.parse_known_args() + + +def setup_logging(debug=False): + global log + try: + log_format = "%(asctime)s - %(name)s - %(levelname)s - %(message)s" + date_format = "%Y-%m-%d %H:%M:%S" + + stream_handler = logging.StreamHandler(sys.stdout) + stream_handler.setLevel(logging.DEBUG if debug else logging.INFO) + stream_handler.setFormatter(ColorFormatter(log_format, datefmt=date_format)) + + handlers = [stream_handler] + + # File logging only in debug mode — RotatingFileHandler adds ~100ms I/O on every start + if debug: + log_file_path = os.path.join(constants.CONFIG_DIR, "clipse-gui.log") + file_handler = RotatingFileHandler( + filename=log_file_path, + maxBytes=5 * 1024 * 1024, + backupCount=3, + ) + file_handler.setLevel(logging.DEBUG) + file_handler.setFormatter(logging.Formatter(log_format, datefmt=date_format)) + handlers.append(file_handler) + + logging.basicConfig(level=logging.DEBUG, handlers=handlers) + log = logging.getLogger(__name__) + log.info(f"Logging initialized ({'DEBUG + file' if debug else 'INFO'})") + except Exception as e: + print(f"CRITICAL: Failed to set up logging: {e}", file=sys.stderr) + traceback.print_exc() + sys.exit(1) + + +def main(): + args, gtk_args = parse_args_from_sys_argv() + setup_logging(debug=args.debug) + + if log is None: + print("CRITICAL: Logging setup failed. Exiting.", file=sys.stderr) + sys.exit(1) + + try: + Path(constants.CONFIG_DIR).mkdir(parents=True, exist_ok=True) + except Exception as e: + log.critical(f"Failed to create config directory: {e}", exc_info=True) + sys.exit(1) + + try: + from clipse_gui.app import ClipseGuiApplication + + app = ClipseGuiApplication() + exit_status = app.run(gtk_args) + log.info(f"Application exited with status {exit_status}.") + sys.exit(exit_status) + except Exception as e: + log.critical(f"Unhandled exception in main: {e}", exc_info=True) + sys.exit(1) + + +if __name__ == "__main__": + main() diff --git a/pyproject.toml b/pyproject.toml index deb39f4..e38ef66 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,3 +1,47 @@ +[build-system] +requires = ["setuptools>=64", "wheel"] +build-backend = "setuptools.build_meta" + +[project] +name = "clipse-gui" +description = "A GTK3 GUI for the clipse clipboard manager" +readme = "README.md" +requires-python = ">=3.10" +license = { file = "LICENSE" } +authors = [{ name = "D7OMDEV", email = "hello@d7om.dev" }] +keywords = ["clipboard", "clipse", "gtk", "gtk3", "linux"] +classifiers = [ + "Development Status :: 4 - Beta", + "Environment :: X11 Applications :: GTK", + "Intended Audience :: End Users/Desktop", + "License :: OSI Approved :: MIT License", + "Operating System :: POSIX :: Linux", + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3 :: Only", + "Topic :: Desktop Environment", + "Topic :: Utilities", +] +# Runtime deps are intentionally empty: PyGObject/GTK ship via the system +# package manager (python-gobject + gtk3) so they stay in lockstep with the +# system's GLib. Bundling them is what caused #13 / #14. +dependencies = [] +dynamic = ["version"] + +[project.urls] +Homepage = "https://github.com/d7omdev/clipse-gui" +Repository = "https://github.com/d7omdev/clipse-gui" +Issues = "https://github.com/d7omdev/clipse-gui/issues" + +[project.scripts] +clipse-gui = "clipse_gui.cli:main" + +[tool.setuptools.dynamic] +version = { attr = "clipse_gui.__version__" } + +[tool.setuptools.packages.find] +include = ["clipse_gui*"] +exclude = ["tests*", "aur*", "docs*"] + [tool.pyright] reportMissingModuleSource = false reportOptionalMemberAccess = true diff --git a/requirements.txt b/requirements.txt index 63e70b6..c256261 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,9 +1,15 @@ +# Development-only dependencies (formatters, linters, type stubs, optional +# image/file-watch helpers used during development). Runtime deps come from +# the system: python-gobject + gtk3 (via the AUR package or your distro's +# equivalent). See pyproject.toml for the canonical install path. +# +# Nuitka and PyGObject were removed: bundling PyGObject froze a copy of +# `gi/overrides/` into the binary that crashed against newer system GLib. +# See #14 for the postmortem. nodeenv==1.10.0 -Nuitka==2.6.9 ordered-set==4.1.0 pillow==11.2.1 pycairo==1.27.0 -PyGObject==3.52.3 PyGObject-stubs==2.13.0 pyright==1.1.408 ruff==0.15.0 From dc7c507cf9ff3226888636d84d91fe6c13668602 Mon Sep 17 00:00:00 2001 From: d7om Date: Wed, 15 Apr 2026 13:31:57 +0200 Subject: [PATCH 2/5] build: adopt SPDX license expression in pyproject.toml Setuptools deprecated `license = { file = "..." }` and the `License :: OSI Approved` classifiers; both are slated for removal in Feb 2027. Switch to PEP 639 SPDX form (`license = "MIT"` + `license-files = ["LICENSE"]`) and drop the redundant classifier. Bump build-system pin to setuptools>=77, the minimum that supports this syntax. --- pyproject.toml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index e38ef66..f433250 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,5 +1,5 @@ [build-system] -requires = ["setuptools>=64", "wheel"] +requires = ["setuptools>=77", "wheel"] build-backend = "setuptools.build_meta" [project] @@ -7,14 +7,14 @@ name = "clipse-gui" description = "A GTK3 GUI for the clipse clipboard manager" readme = "README.md" requires-python = ">=3.10" -license = { file = "LICENSE" } +license = "MIT" +license-files = ["LICENSE"] authors = [{ name = "D7OMDEV", email = "hello@d7om.dev" }] keywords = ["clipboard", "clipse", "gtk", "gtk3", "linux"] classifiers = [ "Development Status :: 4 - Beta", "Environment :: X11 Applications :: GTK", "Intended Audience :: End Users/Desktop", - "License :: OSI Approved :: MIT License", "Operating System :: POSIX :: Linux", "Programming Language :: Python :: 3", "Programming Language :: Python :: 3 :: Only", From c6e862e14d02b3495179e8b0078de1e6ec270d8f Mon Sep 17 00:00:00 2001 From: d7om Date: Wed, 15 Apr 2026 13:44:03 +0200 Subject: [PATCH 3/5] build(justfile): switch canonical build/install recipes to wheel + python-installer - `just build` now produces a PEP 517 wheel via `python -m build --no-isolation` - `just install` uses `python -m installer` to drop the entry-point script and package into the system prefix; icon/desktop file installed alongside as before - `just uninstall` reconstructs the wheel manifest in a temp destdir and removes every listed file from the live system, then strips the assets - `just dry-install` enumerates the manifest so users see exactly what lands where - `just build-nuitka` preserves the legacy Nuitka path for non-pacman distribution - Drop the dead duplicate `nuitka` recipe; consolidate into `build-nuitka` - Clean wheel artifacts (build/, *.egg-info) in `clean-build` - `info` and `paths` updated to reflect the new install layout --- justfile | 181 +++++++++++++++++++++++++++++++++---------------------- 1 file changed, 109 insertions(+), 72 deletions(-) diff --git a/justfile b/justfile index f46b3d8..04b5a22 100644 --- a/justfile +++ b/justfile @@ -43,6 +43,9 @@ export DESKTOP_DEST_DIR := SHARE_DIR / "applications" # Build directories export BUILD_DIR := "dist" +export WHEEL_GLOB := BUILD_DIR + "/*.whl" +export PEP517_BUILD_DIR := "build" +export EGG_INFO_DIR := PACKAGE_DIR + ".egg-info" export NUITKA_DIST_DIR := APP_NAME + ".dist" export NUITKA_BINARY := APP_NAME @@ -175,70 +178,89 @@ qa: format lint type-check # Build Recipes (group: 'build') # ============================================================================ -# Ensure Python 3.13 venv exists using uv (downloads Python if needed) +# Verify python-build / python-installer / setuptools / wheel are available [group('build')] -_ensure-python: +_ensure-build-tools: #!/usr/bin/env bash set -euo pipefail - PYTHON_VERSION=$(venv/bin/python -c "import sys; print(f'{sys.version_info.major}.{sys.version_info.minor}')" 2>/dev/null || echo "none") - if [ ! -d "venv" ] || [ "$PYTHON_VERSION" != "3.13" ]; then - echo "{{ YELLOW }}-> Creating venv with Python 3.13 (downloading if needed)...{{ RESET }}" - rm -rf venv - uv venv --python 3.13 venv - fi - if ! venv/bin/python -c "import nuitka" 2>/dev/null; then - echo "{{ YELLOW }}-> Installing nuitka...{{ RESET }}" - uv pip install nuitka --python venv/bin/python - fi - if ! venv/bin/python -c "import gi" 2>/dev/null; then - echo "{{ YELLOW }}-> Installing PyGObject...{{ RESET }}" - uv pip install PyGObject --python venv/bin/python + missing=() + for mod in build installer setuptools wheel; do + if ! python3 -c "import $mod" 2>/dev/null; then + missing+=("$mod") + fi + done + if [ "${#missing[@]}" -ne 0 ]; then + echo "{{ YELLOW }}-> Missing Python build modules: ${missing[*]}{{ RESET }}" + echo " On Arch: sudo pacman -S python-build python-installer python-setuptools python-wheel" + echo " On Debian: sudo apt install python3-build python3-installer python3-setuptools python3-wheel" + exit 1 fi -# Build standalone binary using Nuitka (group: 'build') +# Build a Python wheel via PEP 517 (group: 'build') [group('build')] -build: clean-build _ensure-python - @echo "{{ BLUE }}-> Building standalone binary with Nuitka...{{ RESET }}" - @echo "{{ YELLOW }} This may take a few minutes...{{ RESET }}" - venv/bin/python -m nuitka {{ NUITKA_OPTS }} {{ APP_SCRIPT }} - @if [ -f "{{ BUILD_DIR }}/{{ APP_NAME }}.bin" ]; then \ - mv "{{ BUILD_DIR }}/{{ APP_NAME }}.bin" "{{ BUILD_DIR }}/{{ APP_NAME }}"; \ - fi - @echo "{{ GREEN }}✓ Build complete: {{ BUILD_DIR }}/{{ APP_NAME }}{{ RESET }}" +build: clean-build _ensure-build-tools + @echo "{{ BLUE }}-> Building wheel via PEP 517 (no isolation)...{{ RESET }}" + python3 -m build --wheel --no-isolation + @echo "{{ GREEN }}✓ Build complete: {{ BUILD_DIR }}/clipse_gui-{{ VERSION }}-py3-none-any.whl{{ RESET }}" -# Build and verify the binary works (group: 'build') +# Build and verify the wheel exists (group: 'build') [group('build')] build-verify: build @echo "{{ BLUE }}-> Verifying build...{{ RESET }}" - @if [ -f "{{ BUILD_DIR }}/{{ APP_NAME }}" ]; then \ - echo "{{ GREEN }}✓ Binary exists and is ready for installation{{ RESET }}"; \ - ls -lh {{ BUILD_DIR }}/{{ APP_NAME }}; \ + @wheel=$(ls {{ WHEEL_GLOB }} 2>/dev/null | head -1); \ + if [ -n "$wheel" ]; then \ + echo "{{ GREEN }}✓ Wheel exists and is ready for installation{{ RESET }}"; \ + ls -lh "$wheel"; \ else \ - echo "{{ YELLOW }}✗ Binary not found at expected location{{ RESET }}"; \ + echo "{{ YELLOW }}✗ Wheel not found in {{ BUILD_DIR }}/{{ RESET }}"; \ exit 1; \ fi +# Legacy: build standalone Nuitka binary (kept for non-pacman distribution) +[group('build')] +build-nuitka: clean-build + #!/usr/bin/env bash + set -euo pipefail + echo "{{ YELLOW }}-> Building Nuitka onefile binary (legacy path)...{{ RESET }}" + echo "{{ YELLOW }} Note: bundles gi/overrides; can break against newer system GLib (#13, #14).{{ RESET }}" + if ! command -v uv &>/dev/null; then + echo "{{ YELLOW }}-> uv not found; this recipe needs uv to bootstrap the build venv.{{ RESET }}" + exit 1 + fi + PYTHON_VERSION=$(venv/bin/python -c "import sys; print(f'{sys.version_info.major}.{sys.version_info.minor}')" 2>/dev/null || echo "none") + if [ ! -d "venv" ] || [ "$PYTHON_VERSION" != "3.13" ]; then + rm -rf venv + uv venv --python 3.13 venv + fi + venv/bin/python -c "import nuitka" 2>/dev/null || uv pip install nuitka --python venv/bin/python + venv/bin/python -c "import gi" 2>/dev/null || uv pip install PyGObject --python venv/bin/python + venv/bin/python -m nuitka {{ NUITKA_OPTS }} {{ APP_SCRIPT }} + if [ -f "{{ BUILD_DIR }}/{{ APP_NAME }}.bin" ]; then + mv "{{ BUILD_DIR }}/{{ APP_NAME }}.bin" "{{ BUILD_DIR }}/{{ APP_NAME }}" + fi + echo "{{ GREEN }}✓ Nuitka binary built: {{ BUILD_DIR }}/{{ APP_NAME }}{{ RESET }}" + # ============================================================================ # Installation Recipes (group: 'install') # ============================================================================ -# Install binary and assets system-wide - requires sudo (group: 'install') +# Install wheel + assets system-wide via python-installer - bypasses pacman, requires sudo (group: 'install') [group('install')] install: build verify-prefix #!/usr/bin/env bash set -euo pipefail - echo "{{ BLUE }}-> Installing {{ APP_NAME }} v{{ VERSION }}...{{ RESET }}" + echo "{{ BLUE }}-> Installing {{ APP_NAME }} v{{ VERSION }} via python-installer...{{ RESET }}" + echo "{{ YELLOW }} Note: this bypasses pacman; use 'just uninstall' to remove cleanly.{{ RESET }}" - # Remove old installation and copy new one to /opt - echo "Installing to {{ APP_DIR }}..." - sudo rm -rf "{{ APP_DIR }}" - sudo mkdir -p "{{ APP_DIR }}" - sudo cp "{{ BUILD_DIR }}/{{ APP_NAME }}" "{{ APP_DIR }}/{{ APP_NAME }}" - sudo chmod +x "{{ APP_DIR }}/{{ APP_NAME }}" + wheel=$(ls {{ WHEEL_GLOB }} 2>/dev/null | head -1) + if [ -z "$wheel" ]; then + echo "{{ YELLOW }}✗ No wheel found in {{ BUILD_DIR }}/. Run 'just build' first.{{ RESET }}" + exit 1 + fi - # Create symlink - echo "Creating symlink..." - sudo ln -sf "{{ APP_DIR }}/{{ APP_NAME }}" "{{ BIN_DIR }}/{{ APP_NAME }}" + # python-installer drops the entry-point script in {{ BIN_DIR }}/{{ APP_NAME }} + # and the package under site-packages — no manual /opt copy needed. + sudo python3 -m installer --prefix="{{ PREFIX }}" "$wheel" # Install icon if present if [ -f "{{ ICON_FILE }}" ]; then @@ -260,7 +282,7 @@ install: build verify-prefix echo -e "{{ GREEN }}✓ Installation complete!{{ RESET }}" echo " Run with: {{ BOLD }}{{ APP_NAME }}{{ RESET }} or from your applications menu" -# Uninstall the application [confirm] (group: 'install') +# Uninstall the application - reconstructs wheel manifest then removes files [confirm] (group: 'install') [confirm("Are you sure you want to uninstall clipse-gui?")] [group('install')] uninstall: @@ -268,10 +290,25 @@ uninstall: set -euo pipefail echo "{{ YELLOW }}-> Uninstalling {{ APP_NAME }}...{{ RESET }}" - sudo rm -f "{{ BIN_DIR }}/{{ APP_NAME }}" - sudo rm -rf "{{ APP_DIR }}" - sudo rm -f "{{ DESKTOP_DEST_DIR }}/{{ APP_NAME }}.desktop" + wheel=$(ls {{ WHEEL_GLOB }} 2>/dev/null | head -1) + if [ -n "$wheel" ]; then + manifest_root=$(mktemp -d) + trap "rm -rf '$manifest_root'" EXIT + python3 -m installer --destdir="$manifest_root" --prefix="{{ PREFIX }}" "$wheel" 2>/dev/null + # Remove every file the wheel would have installed + find "$manifest_root" -type f | sed "s|^$manifest_root||" | while read -r f; do + sudo rm -f "$f" + done + # Drop empty package dirs + sudo find "{{ PREFIX }}/lib" -depth -type d -name "{{ PACKAGE_DIR }}*" -empty -exec rmdir {} + 2>/dev/null || true + else + echo "{{ YELLOW }}⚠ No wheel found; falling back to best-effort path cleanup.{{ RESET }}" + sudo rm -f "{{ BIN_DIR }}/{{ APP_NAME }}" + sudo find "{{ PREFIX }}/lib" -maxdepth 4 -type d -name "{{ PACKAGE_DIR }}*" -exec rm -rf {} + 2>/dev/null || true + fi + # Assets installed outside the wheel + sudo rm -f "{{ DESKTOP_DEST_DIR }}/{{ APP_NAME }}.desktop" if [ -f "{{ ICON_DEST_DIR }}/{{ APP_NAME }}.png" ]; then echo "Removing icon..." sudo rm -f "{{ ICON_DEST_DIR }}/{{ APP_NAME }}.png" @@ -300,12 +337,26 @@ install-hooks: # Dry-run install - show what would be installed (group: 'install') [group('install')] dry-install: build - @echo "{{ BLUE }}-> Dry-run install (would install to {{ PREFIX }}):{{ RESET }}" - @echo " Binary: {{ BUILD_DIR }}/{{ NUITKA_BINARY }} → {{ BIN_DIR }}/{{ APP_NAME }}" - @echo " Icon: {{ ICON_FILE }} → {{ ICON_DEST_DIR }}/{{ APP_NAME }}.png" - @echo " Desktop: {{ DESKTOP_DEST_DIR }}/{{ APP_NAME }}.desktop" - @echo "" - @echo "{{ YELLOW }}Run 'just install' to perform actual installation{{ RESET }}" + #!/usr/bin/env bash + set -euo pipefail + echo "{{ BLUE }}-> Dry-run install (would install to {{ PREFIX }}):{{ RESET }}" + wheel=$(ls {{ WHEEL_GLOB }} 2>/dev/null | head -1) + if [ -z "$wheel" ]; then + echo "{{ YELLOW }}✗ No wheel found in {{ BUILD_DIR }}/.{{ RESET }}" + exit 1 + fi + manifest_root=$(mktemp -d) + trap "rm -rf '$manifest_root'" EXIT + python3 -m installer --destdir="$manifest_root" --prefix="{{ PREFIX }}" "$wheel" 2>/dev/null + echo "" + echo "{{ BOLD }}Files from wheel ($wheel):{{ RESET }}" + find "$manifest_root" -type f | sed "s|^$manifest_root| |" | sort + echo "" + echo "{{ BOLD }}Additional assets:{{ RESET }}" + echo " {{ ICON_FILE }} → {{ ICON_DEST_DIR }}/{{ APP_NAME }}.png" + echo " {{ DESKTOP_DEST_DIR }}/{{ APP_NAME }}.desktop" + echo "" + echo "{{ YELLOW }}Run 'just install' to perform actual installation{{ RESET }}" # ============================================================================ # Version Management Recipes (group: 'version') @@ -518,7 +569,7 @@ version-suggest: [group('clean')] clean-build: @echo "{{ BLUE }}-> Cleaning build files...{{ RESET }}" - rm -rf {{ BUILD_DIR }}/ {{ NUITKA_DIST_DIR }}/ *.spec *.build/ + rm -rf {{ BUILD_DIR }}/ {{ PEP517_BUILD_DIR }}/ {{ EGG_INFO_DIR }}/ {{ NUITKA_DIST_DIR }}/ *.spec *.build/ @echo "{{ GREEN }}✓ Build files cleaned{{ RESET }}" # Clean Python cache files (group: 'clean') @@ -567,15 +618,18 @@ info: @echo "" @echo "{{ BOLD }}Status:{{ RESET }}" @echo " Venv: {{ if path_exists("venv") == "true" { "✓ present" } else { "✗ not found" } }}" - @echo " Build: {{ if path_exists(BUILD_DIR / NUITKA_BINARY) == "true" { "✓ available" } else { "✗ not built" } }}" + @echo -n " Wheel: " + @if ls {{ WHEEL_GLOB }} >/dev/null 2>&1; then echo "✓ $(ls {{ WHEEL_GLOB }} | head -1)"; else echo "✗ not built (run 'just build')"; fi + @echo " Nuitka bin: {{ if path_exists(BUILD_DIR / NUITKA_BINARY) == "true" { "✓ available" } else { "✗ not built" } }}" # Show installation paths - where files would be installed (group: 'info') [group('info')] paths: @echo "{{ BOLD }}Installation Paths (PREFIX={{ PREFIX }}):{{ RESET }}" - @echo " Binary: {{ BIN_DIR }}/{{ APP_NAME }}" - @echo " Icon: {{ ICON_DEST_DIR }}/{{ APP_NAME }}.png" - @echo " Desktop: {{ DESKTOP_DEST_DIR }}/{{ APP_NAME }}.desktop" + @echo " Entry point: {{ BIN_DIR }}/{{ APP_NAME }} (generated by python-installer)" + @echo " Package: {{ PREFIX }}/lib/python3.X/site-packages/{{ PACKAGE_DIR }}/" + @echo " Icon: {{ ICON_DEST_DIR }}/{{ APP_NAME }}.png" + @echo " Desktop: {{ DESKTOP_DEST_DIR }}/{{ APP_NAME }}.desktop" # ============================================================================ # Private Helper Recipes @@ -600,23 +654,6 @@ _generate-desktop: StartupWMClass=org.d7om.ClipseGUI EOF -# Build a standalone binary with Nuitka (group: 'build') -[group('build')] -nuitka: - #!/usr/bin/env bash - set -euo pipefail - mkdir -p dist - python3 -m nuitka \ - --onefile \ - --linux-onefile-compression=none \ - --output-dir=dist \ - --output-filename=clipse-gui.bin \ - --include-package=clipse_gui \ - --include-package-data=clipse_gui \ - --enable-plugin=gi \ - --noinclude-default-mode=nofollow \ - clipse-gui.py - # Verify prefix directory exists and is writable (private) [private] verify-prefix: From d3285a20fe3a68d9c629942ab1e4df7404e9efcd Mon Sep 17 00:00:00 2001 From: d7om Date: Thu, 16 Apr 2026 01:44:39 +0200 Subject: [PATCH 4/5] refactor: split controller.py and ui_components.py into focused modules Break two monoliths into navigable modules: - controller.py: 1863 -> 128 lines, composes 11 mixins under controller_mixins/ (data, style, list_view, search, item_ops, selection, clipboard, preview, scroll, keyboard, misc) - ui_components.py: 1742 -> 43-line shim, splits into 7 modules under ui/ (icons, detection, text, list_row, help, settings, preview) The shim re-exports every public name (including the legacy toggle_search_bar alias) so existing imports keep working. Pure restructuring -- no behavior changes intended. --- clipse_gui/controller.py | 1807 +---------------- clipse_gui/controller_mixins/__init__.py | 31 + .../controller_mixins/clipboard_mixin.py | 372 ++++ clipse_gui/controller_mixins/data_mixin.py | 62 + .../controller_mixins/item_ops_mixin.py | 294 +++ .../controller_mixins/keyboard_mixin.py | 315 +++ .../controller_mixins/list_view_mixin.py | 147 ++ clipse_gui/controller_mixins/misc_mixin.py | 51 + clipse_gui/controller_mixins/preview_mixin.py | 244 +++ clipse_gui/controller_mixins/scroll_mixin.py | 99 + clipse_gui/controller_mixins/search_mixin.py | 62 + .../controller_mixins/selection_mixin.py | 94 + clipse_gui/controller_mixins/style_mixin.py | 159 ++ clipse_gui/ui/__init__.py | 12 + clipse_gui/ui/detection.py | 37 + clipse_gui/ui/help.py | 139 ++ clipse_gui/ui/icons.py | 82 + clipse_gui/ui/list_row.py | 287 +++ clipse_gui/ui/preview.py | 480 +++++ clipse_gui/ui/settings.py | 608 ++++++ clipse_gui/ui/text.py | 124 ++ clipse_gui/ui_components.py | 1775 +--------------- 22 files changed, 3773 insertions(+), 3508 deletions(-) create mode 100644 clipse_gui/controller_mixins/__init__.py create mode 100644 clipse_gui/controller_mixins/clipboard_mixin.py create mode 100644 clipse_gui/controller_mixins/data_mixin.py create mode 100644 clipse_gui/controller_mixins/item_ops_mixin.py create mode 100644 clipse_gui/controller_mixins/keyboard_mixin.py create mode 100644 clipse_gui/controller_mixins/list_view_mixin.py create mode 100644 clipse_gui/controller_mixins/misc_mixin.py create mode 100644 clipse_gui/controller_mixins/preview_mixin.py create mode 100644 clipse_gui/controller_mixins/scroll_mixin.py create mode 100644 clipse_gui/controller_mixins/search_mixin.py create mode 100644 clipse_gui/controller_mixins/selection_mixin.py create mode 100644 clipse_gui/controller_mixins/style_mixin.py create mode 100644 clipse_gui/ui/__init__.py create mode 100644 clipse_gui/ui/detection.py create mode 100644 clipse_gui/ui/help.py create mode 100644 clipse_gui/ui/icons.py create mode 100644 clipse_gui/ui/list_row.py create mode 100644 clipse_gui/ui/preview.py create mode 100644 clipse_gui/ui/settings.py create mode 100644 clipse_gui/ui/text.py diff --git a/clipse_gui/controller.py b/clipse_gui/controller.py index 8dc019d..22c6e39 100644 --- a/clipse_gui/controller.py +++ b/clipse_gui/controller.py @@ -1,50 +1,50 @@ +"""Main application controller. + +Thin assembler that composes domain-specific mixins from `controller_mixins/`. +Holds only `__init__` (state + widget wiring) and `_connect_signals` (event wiring). +All behavior lives in the mixins. +""" + import logging -import subprocess import os -import shlex import threading -from functools import partial -import mimetypes -from .constants import ( - ENTER_TO_PASTE, - HIGHLIGHT_SEARCH, - HOVER_TO_SELECT, - IMAGE_CACHE_MAX_SIZE, - INITIAL_LOAD_COUNT, - LOAD_BATCH_SIZE, - LOAD_THRESHOLD_FACTOR, - COPY_TOOL_CMD, - PASTE_SIMULATION_CMD_WAYLAND, - PASTE_SIMULATION_CMD_X11, - PASTE_SIMULATION_DELAY_MS, - PROTECT_PINNED_ITEMS, - SAVE_DEBOUNCE_MS, - SEARCH_DEBOUNCE_MS, - X11_COPY_TOOL_CMD, - DEFAULT_WINDOW_WIDTH, - DEFAULT_WINDOW_HEIGHT, - OPEN_LINKS_WITH_BROWSER, - config, - get_app_css, + +from gi.repository import GLib, Gtk + +from .constants import HOVER_TO_SELECT, IMAGE_CACHE_MAX_SIZE, config +from .controller_mixins import ( + ClipboardMixin, + DataMixin, + ItemOpsMixin, + KeyboardMixin, + ListViewMixin, + MiscMixin, + PreviewMixin, + ScrollMixin, + SearchMixin, + SelectionMixin, + StyleMixin, ) from .data_manager import DataManager from .image_handler import ImageHandler -from .ui_components import ( - create_list_row_widget, - show_help_window, - show_preview_window, - show_settings_window, - animate_pin_shake, -) from .ui_builder import build_main_window_content -from .utils import fuzzy_search - -from gi.repository import Gdk, GLib, Gtk, Pango # noqa: E402 log = logging.getLogger(__name__) -class ClipboardHistoryController: +class ClipboardHistoryController( + DataMixin, + StyleMixin, + ListViewMixin, + SearchMixin, + ItemOpsMixin, + SelectionMixin, + ClipboardMixin, + PreviewMixin, + ScrollMixin, + KeyboardMixin, + MiscMixin, +): """ Manages the application logic, state, and interactions for the main window. """ @@ -126,1738 +126,3 @@ def _connect_signals(self): ) else: log.warning("Could not get vertical adjustment for lazy loading.") - - def _apply_css(self): - """Applies the application-wide CSS including current zoom level.""" - screen = Gdk.Screen.get_default() - if not screen: - log.error("Cannot get default GdkScreen to apply CSS") - return - - zoom_css = f"* {{ font-size: {round(self.zoom_level * 100)}%; }}".encode() - try: - if not hasattr(self, "style_provider"): - log.debug("Creating and adding application CSS provider.") - self.style_provider = Gtk.CssProvider() - self.style_provider.load_from_data( - self._get_current_css().encode() + b"\n" + zoom_css - ) - Gtk.StyleContext.add_provider_for_screen( - screen, - self.style_provider, - Gtk.STYLE_PROVIDER_PRIORITY_APPLICATION, - ) - else: - self.style_provider.load_from_data( - self._get_current_css().encode() + b"\n" + zoom_css - ) - except GLib.Error as e: - log.error(f"Failed to load CSS: {e}") - except Exception as e: - log.error(f"Unexpected error applying CSS: {e}") - - def _get_current_css(self): - """Get current CSS with applied style settings.""" - import clipse_gui.constants as constants - css = get_app_css( - border_radius=constants.BORDER_RADIUS, - accent_color=constants.ACCENT_COLOR, - selection_color=constants.SELECTION_COLOR, - visual_mode_color=constants.VISUAL_MODE_COLOR, - ) - log.debug(f"Generated CSS with border_radius={constants.BORDER_RADIUS}") - return css - - def update_style_css(self, border_radius=None, accent_color=None, - selection_color=None, visual_mode_color=None): - """Update CSS styles on-the-fly.""" - # Update global constants - import clipse_gui.constants as constants - - if border_radius is not None: - constants.BORDER_RADIUS = border_radius - if accent_color is not None: - constants.ACCENT_COLOR = accent_color - if selection_color is not None: - constants.SELECTION_COLOR = selection_color - if visual_mode_color is not None: - constants.VISUAL_MODE_COLOR = visual_mode_color - - # Regenerate and apply CSS - if hasattr(self, "style_provider"): - try: - css = self._get_current_css() - screen = Gdk.Screen.get_default() - - # Remove old provider - if screen: - Gtk.StyleContext.remove_provider_for_screen( - screen, self.style_provider - ) - - # Create new provider with updated CSS - self.style_provider = Gtk.CssProvider() - self.style_provider.load_from_data(css.encode()) - - if screen: - Gtk.StyleContext.add_provider_for_screen( - screen, - self.style_provider, - Gtk.STYLE_PROVIDER_PRIORITY_APPLICATION, - ) - - log.debug("CSS reloaded successfully") - - # Force window refresh - if self.window: - self.window.queue_draw() - self._invalidate_style_contexts(self.window) - except Exception as e: - log.error(f"Failed to update CSS: {e}") - - def _invalidate_style_contexts(self, widget): - """Recursively invalidate style contexts to force CSS reload.""" - if hasattr(widget, 'get_style_context'): - widget.get_style_context().invalidate() - if hasattr(widget, 'get_children'): - for child in widget.get_children(): - self._invalidate_style_contexts(child) - - def _on_history_updated(self, loaded_items): - """Callback function called when the file watcher detects a change.""" - log.debug("Received history update signal from DataManager.") - self.items = loaded_items - self.update_filtered_items() - - def _load_initial_data(self): - """Loads history in background thread.""" - loaded_items = self.data_manager.load_history() - GLib.idle_add(self._finish_initial_load, loaded_items) - self.data_manager._start_history_watcher(self._on_history_updated) - - def _finish_initial_load(self, loaded_items): - """Updates UI after initial data load.""" - self.items = loaded_items - self.update_filtered_items() - if not self.items: - self.status_label.set_text("No history items found. Press ? for help.") - else: - GLib.idle_add(self._focus_first_item) - return False - - def _focus_first_item(self): - """Selects and focuses the first item in the list.""" - if len(self.list_box.get_children()) > 0: - first_row = self.list_box.get_row_at_index(0) - if first_row: - self.list_box.select_row(first_row) - first_row.grab_focus() - return False - - def update_filtered_items(self): - """Filters master list based on search and pin status, then updates UI.""" - - self.filtered_items = fuzzy_search( - items=self.items, - search_term=self.search_term, - value_key="value", - path_key="filePath", - pinned_key="pinned", - show_only_pinned=self.show_only_pinned, - ) - self.populate_list_view() - self.update_status_label() - GLib.idle_add(self.check_load_more) - - def schedule_save_history(self): - """Schedules saving the history after a debounce delay.""" - if self._save_timer_id: - GLib.source_remove(self._save_timer_id) - self._save_timer_id = GLib.timeout_add( - int(SAVE_DEBOUNCE_MS or 300), self._trigger_save - ) - - def _trigger_save(self): - """Calls the DataManager to save history.""" - log.debug("Triggering history save.") - self.data_manager.save_history(self.items, self._handle_save_error) - self._save_timer_id = None - return False - - def _handle_save_error(self, error_message): - """Callback for DataManager save errors.""" - self.flash_status(error_message) - - def populate_list_view(self): - """Clears and populates the list view with the initial batch of filtered items.""" - if not self.list_box: - return - - if self.vadj and self._vadjustment_handler_id: - try: - self.vadj.disconnect(self._vadjustment_handler_id) - except TypeError: - pass - self._vadjustment_handler_id = None - - self.list_box.freeze_child_notify() - for child in self.list_box.get_children(): - self.list_box.remove(child) - self.list_box.thaw_child_notify() - - self._loading_more = False - load_count = min(INITIAL_LOAD_COUNT or 30, len(self.filtered_items)) - log.debug(f"Populating initial {load_count} rows.") - if load_count > 0: - self._create_rows_range(0, load_count) - self.list_box.show_all() - - if self.vadj and not self._vadjustment_handler_id: - self._vadjustment_handler_id = self.vadj.connect( - "value-changed", self.on_vadjustment_changed - ) - - def _create_rows_range(self, start_idx, end_idx): - """Creates and adds rows for a given range of filtered items.""" - end_idx = min(end_idx, len(self.filtered_items)) - log.debug(f"Creating rows from filtered index {start_idx} to {end_idx - 1}") - - self.list_box.freeze_child_notify() - for i in range(start_idx, end_idx): - if i < len(self.filtered_items): - item_info = self.filtered_items[i] - item_info["filtered_index"] = i - row = create_list_row_widget( - item_info, - self.image_handler, - self._update_row_image_widget, - self.compact_mode, - self.hover_to_select, - self._on_row_single_click, - self.search_term, - HIGHLIGHT_SEARCH, - ) - if row: - row.item_index = item_info["original_index"] - file_path = item_info["item"].get("filePath") - row.is_image = bool(file_path and isinstance(file_path, str)) - row.item_value = item_info["item"].get("value") - row.item_pinned = item_info["item"].get("pinned", False) - - # Apply selection styling if this item is selected - if row.item_index in self.selected_indices: - context = row.get_style_context() - context.add_class("selected-row") - - self.list_box.add(row) - else: - log.warning(f"Attempted to create row for out-of-bounds index {i}") - self.list_box.thaw_child_notify() - - def _update_row_image_widget( - self, image_container, placeholder, pixbuf, error_message - ): - """Callback passed to ImageHandler to update the UI for a specific row's image.""" - if not image_container or not image_container.get_realized(): - return - if placeholder and not placeholder.get_realized(): - placeholder = None - - try: - current_child = image_container.get_child() - if current_child: - image_container.remove(current_child) - - if pixbuf: - image = Gtk.Image.new_from_pixbuf(pixbuf) - image.set_halign(Gtk.Align.CENTER) - image.set_valign(Gtk.Align.CENTER) - image_container.add(image) - image.show() - elif placeholder: - placeholder.set_label(error_message or "[Load Error]") - image_container.add(placeholder) - placeholder.show() - except Exception as e: - log.error(f"Error updating row image widget: {e}") - - def update_status_label(self): - """Updates the status bar text.""" - count = len(self.filtered_items) - total = len(self.items) - status_parts = [] - - # Show selection count if in selection mode - if self.selection_mode and self.selected_indices: - selected_count = len(self.selected_indices) - status_parts.append( - f"{selected_count} item{'s' if selected_count != 1 else ''} selected" - ) - - if self.show_only_pinned: - status_parts.append(f"Showing {count} pinned items") - elif self.search_term: - status_parts.append(f"Found {count} items ({total} total)") - else: - status_parts.append(f"{total} items") - - if not self.selection_mode: - status_parts.append("Press ? for help") - - final_status = " • ".join(status_parts) - if self.status_label.get_text() != final_status: - self.status_label.set_text(final_status) - - def flash_status(self, message, duration=2500): - """Temporarily displays a message in the status bar.""" - current_status = self.status_label.get_text() - log.info(f"Status Flash: {message}") - self.status_label.set_text(message) - - def revert_status(original_text): - if self.status_label.get_text() == message: - self.update_status_label() - return False - - GLib.timeout_add(duration, partial(revert_status, current_status)) - - def update_row_pin_status(self, original_index): - """Updates the visual state of a row when its pin status changes.""" - is_pinned = self.items[original_index].get("pinned", False) - for row in self.list_box.get_children(): - if hasattr(row, "item_index") and row.item_index == original_index: - row.item_pinned = is_pinned - try: - widget = row.get_child() - if isinstance(widget, Gtk.Box): - hbox = widget.get_children()[0] - if isinstance(hbox, Gtk.Box): - # Animate the rotation wiggle effect - animate_pin_shake(hbox, is_pinned) - except (AttributeError, IndexError, TypeError) as e: - log.warning( - f"Could not update pin icon for row {original_index}: {e}" - ) - - context = row.get_style_context() - if is_pinned: - context.add_class("pinned-row") - else: - context.remove_class("pinned-row") - break - - def update_zoom(self): - """Applies the current zoom level to the application CSS.""" - self.zoom_level = max(0.5, min(self.zoom_level, 3.0)) - self._apply_css() - log.debug(f"Zoom updated to {self.zoom_level:.2f}") - - def on_vadjustment_changed(self, adjustment): - """Callback when the scrollbar position changes, triggers lazy load if needed.""" - if self._loading_more: - return - current_value = adjustment.get_value() - upper = adjustment.get_upper() - page_size = adjustment.get_page_size() - if ( - upper > page_size - and current_value >= (upper - page_size) * LOAD_THRESHOLD_FACTOR - and len(self.list_box.get_children()) < len(self.filtered_items) - ): - self.check_load_more() - - def on_list_box_size_allocate(self, list_box, allocation): - """Callback when list box size changes, check if viewport needs filling.""" - GLib.idle_add(self.check_load_more) - - def check_load_more(self): - """Checks if more items should be loaded based on scroll position or viewport fill.""" - if self._loading_more: - return False - if not self.list_box.get_realized() or not self.vadj: - return False - - current_row_count = len(self.list_box.get_children()) - total_filtered_count = len(self.filtered_items) - - if current_row_count < total_filtered_count: - needs_load = False - upper = self.vadj.get_upper() or 0 - page_size = self.vadj.get_page_size() or 0 - threshold_factor = LOAD_THRESHOLD_FACTOR or 1.0 - - if upper <= page_size + 5: - needs_load = True - elif ( - upper > page_size - and self.vadj.get_value() >= (upper - page_size) * threshold_factor - ): - needs_load = True - - if needs_load: - self._loading_more = True - start_idx = current_row_count - end_idx = min(start_idx + (LOAD_BATCH_SIZE or 20), total_filtered_count) - log.debug(f"Scheduling load more: rows {start_idx} to {end_idx - 1}") - GLib.idle_add(self._do_load_more, start_idx, end_idx) - return False - - return False - - def _do_load_more(self, start_idx, end_idx): - """Performs the actual row creation for lazy loading.""" - log.debug(f"Executing load more: rows {start_idx} to {end_idx - 1}") - self._create_rows_range(start_idx, end_idx) - self.list_box.show_all() - self._loading_more = False - GLib.idle_add(self.check_load_more) - return False - - def toggle_pin_selected(self): - """Toggles the pin status of the currently selected item.""" - selected_row = self.list_box.get_selected_row() - if selected_row and hasattr(selected_row, "item_index"): - original_index = selected_row.item_index - if 0 <= original_index < len(self.items): - item = self.items[original_index] - new_pin_state = not item.get("pinned", False) - item["pinned"] = new_pin_state - self.update_row_pin_status(original_index) - self.schedule_save_history() - self.flash_status("Item pinned" if new_pin_state else "Item unpinned") - if self.show_only_pinned and not new_pin_state: - self._remove_row_from_view(selected_row) - else: - log.error(f"Invalid original_index {original_index} for toggle pin.") - self.flash_status("Error: Item index invalid.") - else: - log.warning("Toggle pin called with no valid row selected.") - - def remove_selected_item(self): - """Removes the currently selected item from history and view.""" - selected_row = self.list_box.get_selected_row() - if selected_row and hasattr(selected_row, "item_index"): - original_index_to_remove = selected_row.item_index - if 0 <= original_index_to_remove < len(self.items): - item = self.items[original_index_to_remove] - - # Check if the item is pinned and protection is enabled - if PROTECT_PINNED_ITEMS and item.get("pinned", False): - self.flash_status("Cannot delete pinned item: protection enabled") - return - - item_value_preview = str( - self.items[original_index_to_remove].get("value", "") - )[:30] - log.info(f"Removing item at original index {original_index_to_remove}") - - del self.items[original_index_to_remove] - self.schedule_save_history() - removed_filtered_index = self._remove_row_from_view(selected_row) - - # Update original indices for subsequent items/rows - for fi in self.filtered_items: - if fi["original_index"] > original_index_to_remove: - fi["original_index"] -= 1 - current_rows = self.list_box.get_children() - for i in range(removed_filtered_index, len(current_rows)): - row = current_rows[i] - if ( - hasattr(row, "item_index") - and row.item_index > original_index_to_remove - ): - row.item_index -= 1 - - self.flash_status(f"Item removed: '{item_value_preview}...'.") - self.update_status_label() - self._select_nearby_row( - removed_filtered_index - ) # Reselect after removal - else: - log.error( - f"Invalid original_index {original_index_to_remove} for remove." - ) - self.flash_status("Error: Item index invalid for removal.") - else: - log.warning("Remove item called with no valid row selected.") - - def _remove_row_from_view(self, row_to_remove): - """Helper to remove a row from the ListBox and update filtered list.""" - removed_filtered_index = -1 - original_index_removed = getattr(row_to_remove, "item_index", -1) - children = self.list_box.get_children() - try: - removed_filtered_index = children.index(row_to_remove) - except ValueError: - log.warning( - f"Row with original index {original_index_removed} not found in list_box children." - ) - for idx, child in enumerate(children): # Fallback find - if getattr(child, "item_index", -1) == original_index_removed: - removed_filtered_index = idx - row_to_remove = child - break - if removed_filtered_index == -1: - return -1 - - self.list_box.remove(row_to_remove) - self.filtered_items = [ - fi - for fi in self.filtered_items - if fi["original_index"] != original_index_removed - ] - return removed_filtered_index - - def _select_nearby_row(self, index_before_removal): - """Selects a row near the index of a previously removed row.""" - if index_before_removal != -1: - new_count = len(self.list_box.get_children()) - if new_count > 0: - select_idx = min(index_before_removal, new_count - 1) - new_row = self.list_box.get_row_at_index(select_idx) - if new_row: - self.list_box.select_row(new_row) - new_row.grab_focus() - else: - self.list_box.grab_focus() - else: - self.search_entry.grab_focus() - - def toggle_selection_mode(self): - """Toggles selection mode on/off.""" - self.selection_mode = not self.selection_mode - - if self.selection_mode: - # Entering selection mode - self.main_box.get_style_context().add_class("selection-mode") - # Show visual indicator - self.selection_mode_banner.show() - log.info("Entered selection mode") - self.flash_status("Selection mode: ON (Space to select, v to exit)") - else: - # Exiting selection mode - clear selections - self.deselect_all_items() - self.main_box.get_style_context().remove_class("selection-mode") - # Hide visual indicator - self.selection_mode_banner.hide() - log.info("Exited selection mode") - self.flash_status("Selection mode: OFF") - - self.update_status_label() - - def toggle_item_selection(self): - """Toggles the selection state of the currently focused item.""" - if not self.selection_mode: - log.warning("Cannot toggle item selection: not in selection mode") - return - - selected_row = self.list_box.get_selected_row() - if not selected_row or not hasattr(selected_row, "item_index"): - return - - original_index = selected_row.item_index - context = selected_row.get_style_context() - - if original_index in self.selected_indices: - # Deselect - self.selected_indices.remove(original_index) - context.remove_class("selected-row") - log.info( - f"Deselected item at index {original_index}, classes: {context.list_classes()}" - ) - else: - # Select - self.selected_indices.add(original_index) - context.add_class("selected-row") - log.info( - f"Selected item at index {original_index}, classes: {context.list_classes()}" - ) - - self.update_status_label() - - def select_all_items(self): - """Selects all currently visible items.""" - if not self.selection_mode: - # Auto-enter selection mode if not already in it - self.toggle_selection_mode() - - self.selected_indices.clear() - - for row in self.list_box.get_children(): - if hasattr(row, "item_index"): - original_index = row.item_index - self.selected_indices.add(original_index) - context = row.get_style_context() - context.add_class("selected-row") - - count = len(self.selected_indices) - log.info(f"Selected all {count} visible items") - self.flash_status(f"Selected {count} items") - self.update_status_label() - - def deselect_all_items(self): - """Clears all selections.""" - for row in self.list_box.get_children(): - if hasattr(row, "item_index"): - context = row.get_style_context() - context.remove_class("selected-row") - - count = len(self.selected_indices) - self.selected_indices.clear() - log.info(f"Deselected all items (was {count})") - if count > 0: - self.flash_status("All items deselected") - self.update_status_label() - - def delete_selected_items(self): - """Deletes all selected items with confirmation.""" - if not self.selected_indices: - self.flash_status("No items selected for deletion") - return - - # Count pinned vs non-pinned selected items - pinned_count = 0 - non_pinned_count = 0 - indices_to_delete = [] - - for idx in self.selected_indices: - if 0 <= idx < len(self.items): - item = self.items[idx] - if item.get("pinned", False): - pinned_count += 1 - if not PROTECT_PINNED_ITEMS: - indices_to_delete.append(idx) - else: - non_pinned_count += 1 - indices_to_delete.append(idx) - - if not indices_to_delete: - if PROTECT_PINNED_ITEMS and pinned_count > 0: - self.flash_status( - f"Cannot delete: all {pinned_count} selected items are pinned (protection enabled)" - ) - else: - self.flash_status("No items to delete") - return - - # Build confirmation message - total_to_delete = len(indices_to_delete) - protected_count = pinned_count if PROTECT_PINNED_ITEMS else 0 - - message = f"Delete {total_to_delete} selected item{'s' if total_to_delete != 1 else ''}?" - if protected_count > 0: - message += f"\n\n({protected_count} pinned item{'s' if protected_count != 1 else ''} will be skipped due to protection)" - - # Show confirmation dialog - dialog = Gtk.MessageDialog( - transient_for=self.window, - modal=True, - destroy_with_parent=True, - message_type=Gtk.MessageType.WARNING, - buttons=Gtk.ButtonsType.NONE, - text="Confirm Deletion", - ) - dialog.format_secondary_text(message) - dialog.add_button("Cancel", Gtk.ResponseType.CANCEL) - delete_button = dialog.add_button("Delete", Gtk.ResponseType.OK) - delete_button.get_style_context().add_class("destructive-action") - - response = dialog.run() - dialog.destroy() - - if response == Gtk.ResponseType.OK: - # Sort indices in descending order to delete from end to beginning - indices_to_delete.sort(reverse=True) - - for idx in indices_to_delete: - if 0 <= idx < len(self.items): - del self.items[idx] - - # Exit selection mode and clear selections - self.selection_mode = False - self.selected_indices.clear() - self.main_box.get_style_context().remove_class("selection-mode") - - # Save and refresh - self.schedule_save_history() - self.update_filtered_items() - - self.flash_status( - f"Deleted {total_to_delete} item{'s' if total_to_delete != 1 else ''}" - ) - log.info(f"Deleted {total_to_delete} selected items") - - def clear_all_items(self): - """Clears all non-pinned items with confirmation.""" - if not self.items: - self.flash_status("No items to clear") - return - - # Count pinned vs non-pinned items - pinned_count = sum(1 for item in self.items if item.get("pinned", False)) - non_pinned_count = len(self.items) - pinned_count - - if PROTECT_PINNED_ITEMS and non_pinned_count == 0: - self.flash_status( - f"Cannot clear: all {pinned_count} items are pinned (protection enabled)" - ) - return - - # Determine what will be deleted - if PROTECT_PINNED_ITEMS: - items_to_delete = non_pinned_count - message = f"Delete all {non_pinned_count} non-pinned item{'s' if non_pinned_count != 1 else ''}?" - if pinned_count > 0: - message += f"\n\n({pinned_count} pinned item{'s' if pinned_count != 1 else ''} will be kept)" - else: - items_to_delete = len(self.items) - message = f"Delete ALL {items_to_delete} item{'s' if items_to_delete != 1 else ''}?" - if pinned_count > 0: - message += f"\n\nWarning: This includes {pinned_count} pinned item{'s' if pinned_count != 1 else ''}!" - - # Show confirmation dialog - dialog = Gtk.MessageDialog( - transient_for=self.window, - modal=True, - destroy_with_parent=True, - message_type=Gtk.MessageType.WARNING, - buttons=Gtk.ButtonsType.NONE, - text="Clear All Items", - ) - dialog.format_secondary_text(message) - dialog.add_button("Cancel", Gtk.ResponseType.CANCEL) - clear_button = dialog.add_button("Clear All", Gtk.ResponseType.OK) - clear_button.get_style_context().add_class("destructive-action") - - response = dialog.run() - dialog.destroy() - - if response == Gtk.ResponseType.OK: - if PROTECT_PINNED_ITEMS: - # Keep only pinned items - self.items = [item for item in self.items if item.get("pinned", False)] - else: - # Delete everything - self.items = [] - - # Exit selection mode if active - if self.selection_mode: - self.selection_mode = False - self.selected_indices.clear() - self.main_box.get_style_context().remove_class("selection-mode") - - # Save and refresh - self.schedule_save_history() - self.update_filtered_items() - - self.flash_status( - f"Cleared {items_to_delete} item{'s' if items_to_delete != 1 else ''}" - ) - log.info(f"Cleared {items_to_delete} items") - - def _run_paste_command(self, cmd_args, input_data=None, is_binary=False): - """Helper to run the paste command subprocess.""" - try: - log.info(f"Running paste command: {cmd_args}") - process = subprocess.Popen( - cmd_args, - stdin=subprocess.PIPE, - stdout=subprocess.PIPE, - stderr=subprocess.PIPE, - ) - stdout_output, stderr_output = None, None - try: - input_bytes = ( - input_data - if is_binary - else input_data.encode("utf-8") - if input_data is not None - else None - ) - stdout_output, stderr_output = process.communicate( - input=input_bytes, timeout=5 - ) - except subprocess.TimeoutExpired: - log.error(f"Paste command timed out: {cmd_args}") - process.kill() - stdout_output, stderr_output = process.communicate() - self.flash_status("Error: Paste command timed out") - return False - except OSError as e: - log.error(f"OSError during paste command communicate: {e}") - if process.stderr: - stderr_output = process.stderr.read() - self.flash_status(f"Error communicating with paste command: {e}") - return False - except Exception as e: - log.error(f"Unexpected error during paste command communicate: {e}") - if process.stderr: - stderr_output = process.stderr.read() - self.flash_status(f"Error running paste command: {e}") - return False - - if process.returncode != 0: - stderr_text = ( - stderr_output.decode("utf-8", errors="ignore").strip() - if stderr_output - else "No stderr output" - ) - log.error( - f"Paste command failed with code {process.returncode}: {stderr_text}" - ) - self.flash_status(f"Paste command error: {stderr_text[:100]}") - return False - else: - log.info("Paste command successful.") - return True - except FileNotFoundError: - log.error(f"Paste command not found: {cmd_args[0]}") - self.flash_status(f"Error: Command '{cmd_args[0]}' not found.") - return False - except Exception as e: - log.error(f"Error invoking paste command {cmd_args}: {e}") - self.flash_status(f"Error starting paste command: {str(e)}") - return False - - def _get_copy_command(self): - """Gets the appropriate command for copying TO the clipboard.""" - if self._is_wayland: - return str(COPY_TOOL_CMD) - else: - return str(X11_COPY_TOOL_CMD or COPY_TOOL_CMD) - - def copy_text_to_clipboard(self, text_value): - """Use the configured command to place text into the clipboard.""" - copy_cmd = self._get_copy_command() - if not copy_cmd: - self.flash_status("Error: No copy command configured.") - return False - try: - cmd_args = shlex.split(copy_cmd) - except Exception as e: - log.error(f"Could not parse COPY_TOOL_CMD ('{COPY_TOOL_CMD}'): {e}") - self.flash_status("Error: Invalid copy command in config") - return False - try: - process = subprocess.Popen( - cmd_args, - stdin=subprocess.PIPE, - stdout=subprocess.DEVNULL, - stderr=subprocess.DEVNULL, - ) - if process.stdin: - process.stdin.write(text_value.encode("utf-8")) - process.stdin.close() - # Wait for process to complete to ensure clipboard is updated - process.wait(timeout=5) - else: - log.error("Process stdin is None. Cannot write to clipboard.") - self.flash_status("Error: Unable to write to clipboard") - return False - - return True - except subprocess.TimeoutExpired: - log.error(f"Copy command timed out: {copy_cmd}") - self.flash_status("Error: Copy command timed out") - return False - except FileNotFoundError: - log.error(f"Copy command not found: {cmd_args[0]}") - self.flash_status(f"Error: Copy command '{cmd_args[0]}' not found.") - return False - except Exception as e: - log.error(f"Error copying text to clipboard: {e}") - self.flash_status(f"Error copying text: {str(e)[:100]}") - return False - - def copy_image_to_clipboard(self, image_path): - """Use the configured command to place an image into the clipboard.""" - copy_cmd_base = self._get_copy_command() - if not copy_cmd_base: - self.flash_status("Error: No copy command configured.") - return False - - try: - if not os.path.isfile(image_path): - log.error(f"Image file does not exist: {image_path}") - self.flash_status("Error: Image file not found") - return False - - mimetype, _ = mimetypes.guess_type(image_path) - if not mimetype or not mimetype.startswith("image/"): - image_ext = os.path.splitext(image_path)[1].lower() - mimetype = ( - f"image/{image_ext.lstrip('.')}" if image_ext else "image/png" - ) - log.warning( - f"Could not guess mimetype for {image_path}, using {mimetype}" - ) - - try: - base_cmd_args = shlex.split(copy_cmd_base) - except Exception as e: - log.error(f"Could not parse copy command ('{copy_cmd_base}'): {e}") - self.flash_status( - f"Error: Invalid copy command: {copy_cmd_base[:50]}..." - ) - return False - - cmd_args = base_cmd_args - if "wl-copy" in os.path.basename(base_cmd_args[0]): - cmd_args = base_cmd_args + ["--type", mimetype] - - with open(image_path, "rb") as img_file: - try: - process = subprocess.Popen( - cmd_args, - stdin=img_file, - stdout=subprocess.DEVNULL, - stderr=subprocess.DEVNULL, - ) - stdout_data, stderr_data = process.communicate(timeout=10) - - if process.returncode != 0: - err_msg = ( - stderr_data.decode("utf-8", errors="ignore").strip() - or stdout_data.decode("utf-8", errors="ignore").strip() - ) - log.error( - f"Image copy command failed (code {process.returncode}): {err_msg}" - ) - self.flash_status(f"Image copy failed: {err_msg[:100]}") - return False - - # self.flash_status("Image copied to clipboard") - log.info("Image copied successfully.") - return True - - except subprocess.TimeoutExpired: - log.error(f"Image copy command timed out: {cmd_args}") - self.flash_status("Error: Image copy timed out") - return False - except FileNotFoundError: - log.error(f"Copy command not found: {cmd_args[0]}") - self.flash_status(f"Error: Copy command '{cmd_args[0]}' not found.") - return False - except Exception as e: - log.error(f"Error copying image to clipboard: {e}") - self.flash_status(f"Error copying image: {str(e)[:100]}") - return False - - except Exception as e: - log.error(f"Unexpected error preparing image copy: {e}", exc_info=True) - self.flash_status(f"Error copying image: {str(e)[:100]}") - return False - - def copy_selected_item_to_clipboard(self, with_paste_simulation=False): - """Copies the selected item to the system clipboard and closes the window.""" - selected_row = self.list_box.get_selected_row() - exit_timeout = 150 - if not selected_row: - log.warning("Copy called with no row selected.") - return - - original_index = getattr(selected_row, "item_index", -1) - is_image = getattr(selected_row, "file_path") not in [None, "null"] - item_value = getattr(selected_row, "item_value", None) - - if original_index == -1: - log.error("Selected row missing valid item_index attribute.") - self.flash_status("Error: Invalid selected item data.") - return - - try: - if not (0 <= original_index < len(self.items)): - log.error( - f"Item with original index {original_index} no longer exists in master list." - ) - self.flash_status("Error: Selected item no longer exists.") - return - - item = self.items[original_index] - - def close_window_callback(window): - if window and window.get_realized(): - log.info("Closing window after successful copy.") - window.get_application().quit() - return False - - copy_successful = False - if is_image: - image_path = item.get("filePath") - if image_path and os.path.exists(image_path): - copy_successful = self.copy_image_to_clipboard(image_path) - else: - log.error( - f"Image path invalid or file missing for item {original_index}: {image_path}" - ) - self.flash_status("Image path invalid or file missing") - else: - text_to_copy = item.get("value") - if text_to_copy is not None: - copy_successful = self.copy_text_to_clipboard(item_value) - else: - log.error(f"Text item {original_index} has None value in data.") - self.flash_status("Cannot copy null text value.") - - if copy_successful: - if ENTER_TO_PASTE or with_paste_simulation: - log.debug("Hiding window and scheduling paste simulation.") - self.window.hide() - GLib.timeout_add( - PASTE_SIMULATION_DELAY_MS or 150, - self._trigger_paste_simulation_and_quit, - ) - else: - GLib.timeout_add(100, self._quit_application) - else: - log.error("Copy operation failed.") - self.flash_status("Error: Copy operation failed.") - GLib.timeout_add(exit_timeout, close_window_callback, self.window) - - except Exception as e: - log.error(f"Unexpected error during copy selection: {e}", exc_info=True) - self.flash_status(f"Error copying: {str(e)}") - - def _trigger_paste_simulation_and_quit(self): - """Called after a delay to run paste simulation and then quit.""" - log.debug("Attempting paste simulation...") - paste_success = self.paste_from_clipboard_simulated() - if paste_success: - log.info("Paste simulation command successful.") - else: - log.warning("Paste simulation command failed or skipped.") - # Optional: Show the window again if paste fails? - # self.window.show() - # self.flash_status("Paste failed. Check logs/dependencies (xdotool/wtype).") - - # Quit the application after a longer delay to ensure paste completes - # Some applications need more time to receive and process the paste - quit_delay = 200 # ms - increased from 50ms for better reliability - GLib.timeout_add(quit_delay, self._quit_application) - return False # Prevent timer from repeating - - def _quit_application(self): - """Safely quits the GTK application.""" - log.info("Quitting application.") - app = self.window.get_application() - if app: - app.quit() - return False # Prevent timer from repeating - - def paste_from_clipboard_simulated(self): - """Pastes FROM the clipboard by simulating key presses (Ctrl+V).""" - if self._is_wayland: - cmd_str = str(PASTE_SIMULATION_CMD_WAYLAND) - tool_name = "wtype" - else: - cmd_str = str(PASTE_SIMULATION_CMD_X11) - tool_name = "xdotool" - - if not cmd_str: - log.error( - f"Paste simulation command not configured for {'Wayland' if self._is_wayland else 'X11'}." - ) - self.flash_status("Error: Paste simulation command not configured.") - return False - - try: - cmd_args = shlex.split(cmd_str) - except Exception as e: - log.error(f"Could not parse paste simulation command ('{cmd_str}'): {e}") - self.flash_status(f"Error: Invalid Paste command: {cmd_str[:50]}...") - return False - - log.debug(f"Executing paste simulation command: {cmd_args}") - try: - # Use run for simplicity, capture output for errors - result = subprocess.run( - cmd_args, - capture_output=True, - text=True, - timeout=5, # Timeout for the simulation command - check=False, # Don't raise exception on non-zero exit code, check manually - ) - - if result.returncode != 0: - error_output = result.stderr.strip() or result.stdout.strip() - error_msg = f"Paste simulation ({tool_name}) failed (code {result.returncode}): {error_output}" - log.error(error_msg) - # Don't flash here, happens after window is hidden - # self.flash_status(f"{tool_name} error: {error_output[:100]}") - return False - - log.info(f"Paste simulation ({tool_name}) command successful.") - return True - - except FileNotFoundError: - error_msg = f"Paste simulation command not found: '{cmd_args[0]}'. Is '{tool_name}' installed?" - log.error(error_msg) - # self.flash_status(error_msg) - return False - except subprocess.TimeoutExpired: - error_msg = f"Paste simulation command timed out: '{cmd_str}'" - log.error(error_msg) - # self.flash_status(error_msg) - return False - except Exception as e: - error_msg = f"Error running paste simulation command '{cmd_str}': {e}" - log.error(error_msg) - # self.flash_status(error_msg[:150]) - return False - - def open_url_with_gtk(self, url): - """Open a URL using Gtk.show_uri_on_window (respects the user's default browser).""" - try: - log.info(f"Opening URL: {url}") - Gtk.show_uri_on_window(None, url, Gdk.CURRENT_TIME) - self.flash_status(f"Opening: {url[:60]}…", duration=2000) - except Exception as e: - log.error(f"Failed to open URL: {e}") - self.flash_status(f"Error opening URL: {e}") - - def show_item_preview(self): - """Shows the preview window for the selected item.""" - selected_row = self.list_box.get_selected_row() - if not selected_row: - return - - original_index = getattr(selected_row, "item_index", -1) - file_path_attr = getattr(selected_row, "file_path", None) - is_image = file_path_attr is not None and file_path_attr != "null" - - if original_index == -1: - log.error("Preview called on row with invalid item_index.") - self.flash_status("Error: Invalid selected item data.") - return - - try: - if not (0 <= original_index < len(self.items)): - log.error( - f"Item with original index {original_index} no longer exists for preview." - ) - self.flash_status("Error: Selected item no longer exists.") - return - - item = self.items[original_index] - - show_preview_window( - self.window, - item, - is_image, - self.change_preview_text_size, - self.reset_preview_text_size, - self.on_preview_key_press, - ) - except Exception as e: - log.error(f"Error creating preview window: {e}", exc_info=True) - self.flash_status(f"Error showing preview: {str(e)}") - - # --- Preview Window Callbacks --- - - def change_preview_text_size(self, text_view, delta): - """Callback to change font size in the preview TextView.""" - try: - pango_context = text_view.get_pango_context() - font_desc = pango_context.get_font_description() or Pango.FontDescription() - if ( - not hasattr(text_view, "base_font_size") - or text_view.base_font_size <= 0 - ): - base_size_pango = font_desc.get_size() - text_view.base_font_size = ( - (base_size_pango / Pango.SCALE) if base_size_pango > 0 else 10.0 - ) - current_size_pts = font_desc.get_size() / Pango.SCALE - if current_size_pts <= 0: - current_size_pts = text_view.base_font_size - new_size_pts = max(4.0, current_size_pts + delta) - font_desc.set_size(int(new_size_pts * Pango.SCALE)) - text_view.override_font(font_desc) - except Exception as e: - log.error(f"Error changing preview text size: {e}") - - def reset_preview_text_size(self, text_view): - """Callback to reset font size in the preview TextView.""" - try: - text_view.override_font(None) - pango_context = text_view.get_pango_context() - font_desc = pango_context.get_font_description() or Pango.FontDescription() - if hasattr(text_view, "base_font_size") and text_view.base_font_size > 0: - font_desc.set_size(int(text_view.base_font_size * Pango.SCALE)) - text_view.override_font(font_desc) - except Exception as e: - log.error(f"Error resetting preview text size: {e}") - - def on_help_window_close(self, window): - """Callback for when the help window is closed.""" - window.destroy() - if self.window: - self.window.present() - if self.list_box: - self.list_box.grab_focus() - else: - self.window.grab_focus() - - def on_settings_window_close(self, window): - """Callback for when the settings window is closed.""" - window.destroy() - if self.window: - self.window.present() - if self.list_box: - self.list_box.grab_focus() - else: - self.window.grab_focus() - - def restart_application(self): - """Restarts the application to apply settings changes.""" - import sys - import os - import subprocess - import shutil - - log.info("Restarting application to apply settings changes...") - app = self.window.get_application() - if app: - app.quit() - - try: - clipse_gui_path = shutil.which("clipse-gui") - - if clipse_gui_path: - args = [clipse_gui_path] + sys.argv[1:] - log.debug(f"Restarting with system executable: {args}") - subprocess.Popen(args, cwd=os.getcwd()) - elif getattr(sys, "frozen", False): - executable = sys.executable - args = [executable] + sys.argv[1:] - log.debug(f"Restarting with frozen executable: {args}") - subprocess.Popen(args, cwd=os.getcwd()) - else: - original_cmd = sys.argv[0] - if os.path.isfile(original_cmd) and os.access(original_cmd, os.X_OK): - args = [original_cmd] + sys.argv[1:] - log.debug(f"Restarting with original command: {args}") - subprocess.Popen(args, cwd=os.getcwd()) - else: - raise Exception(f"Cannot find executable: {original_cmd}") - - except Exception as e: - log.error(f"Failed to restart application: {e}") - - if app: - app.quit() - else: - sys.exit(0) - - def on_preview_key_press(self, preview_window, event): - """Handles key presses within the preview window.""" - keyval = event.keyval - ctrl = event.state & Gdk.ModifierType.CONTROL_MASK - - if keyval == Gdk.KEY_Escape or (ctrl and keyval == Gdk.KEY_w): - preview_window.destroy() - if self.window: - self.window.present() - if self.list_box: - self.list_box.grab_focus() - return True - - def find_textview(widget): - if isinstance(widget, Gtk.TextView): - return widget - if hasattr(widget, "get_children"): - for child in widget.get_children(): - found = find_textview(child) - if found: - return found - if hasattr(widget, "get_child"): - child = widget.get_child() - if child: - return find_textview(child) - return None - - textview = find_textview(preview_window) - - if textview: - if ctrl and keyval == Gdk.KEY_f: - # Find search bar and toggle it - def find_search_bar(widget): - if isinstance(widget, Gtk.SearchBar): - return widget - if hasattr(widget, "get_children"): - for child in widget.get_children(): - result = find_search_bar(child) - if result: - return result - return None - - search_bar = find_search_bar(preview_window) - if search_bar: - # Find the search entry - def find_search_entry(widget): - if isinstance(widget, Gtk.SearchEntry): - return widget - if hasattr(widget, "get_children"): - for child in widget.get_children(): - result = find_search_entry(child) - if result: - return result - return None - - def find_buttons_and_label(widget): - """Find prev, next, close buttons and match label.""" - prev_btn = next_btn = close_btn = match_label = None - - def search_widget(w): - nonlocal prev_btn, next_btn, close_btn, match_label - if isinstance(w, Gtk.Button): - tooltip = w.get_tooltip_text() or "" - if "Previous" in tooltip: - prev_btn = w - elif "Next" in tooltip: - next_btn = w - elif "Close" in tooltip: - close_btn = w - elif isinstance(w, Gtk.Label): - text = w.get_text() - if "/" in text or text in ["0/0", ""]: - match_label = w - - if hasattr(w, "get_children"): - for child in w.get_children(): - search_widget(child) - - search_widget(widget) - return prev_btn, next_btn, close_btn, match_label - - search_entry = find_search_entry(search_bar) - if search_entry: - from .ui_components import _toggle_search_bar - - # Find buttons and label - prev_btn, next_btn, close_btn, match_label = ( - find_buttons_and_label(preview_window) - ) - - _toggle_search_bar( - search_bar, - search_entry, - textview, - match_label, - prev_btn, - next_btn, - close_btn, - ) - return True - if ctrl and keyval == Gdk.KEY_b: - # Format text with Ctrl+B - from .ui_components import _format_text_content - - _format_text_content(textview) - return True - if ctrl and keyval == Gdk.KEY_c: - buffer = textview.get_buffer() - clipboard = Gtk.Clipboard.get(Gdk.SELECTION_CLIPBOARD) - if buffer.get_has_selection(): - buffer.copy_clipboard(clipboard) - self.flash_status("Selection copied from preview", duration=1500) - else: - start, end = buffer.get_bounds() - buffer.select_range(start, end) - buffer.copy_clipboard(clipboard) - buffer.delete_selection(False, False) - self.flash_status("All text copied from preview", duration=1500) - return True - if ctrl and keyval in [Gdk.KEY_plus, Gdk.KEY_equal]: - self.change_preview_text_size(textview, 1.0) - return True - if ctrl and keyval == Gdk.KEY_minus: - self.change_preview_text_size(textview, -1.0) - return True - if ctrl and keyval == Gdk.KEY_0: - self.reset_preview_text_size(textview) - return True - return False - - # --- Main Window Event Handlers --- - - def on_window_destroy(self, widget): - log.info("Main window closed.") - - def on_key_press(self, widget, event): - """Handles key presses on the main window.""" - keyval = event.keyval - ctrl = event.state & Gdk.ModifierType.CONTROL_MASK - shift = event.state & Gdk.ModifierType.SHIFT_MASK - - if self.search_entry.has_focus(): - if keyval == Gdk.KEY_Escape: - # Priority order: exit selection mode -> clear search + unfocus - if self.selection_mode: - self.toggle_selection_mode() - return True - - # Clear search text if present - if self.search_entry.get_text(): - self.search_entry.set_text("") - - # Unfocus search entry and focus first list item (deferred) - def unfocus_search(): - if self.list_box: - # Select and focus first row - first_row = self.list_box.get_row_at_index(0) - if first_row: - self.list_box.select_row(first_row) - first_row.grab_focus() - else: - self.list_box.grab_focus() - else: - self.window.grab_focus() - return False - GLib.idle_add(unfocus_search) - return True - - if keyval in [Gdk.KEY_Up, Gdk.KEY_Down, Gdk.KEY_Page_Up, Gdk.KEY_Page_Down]: - focusable_elements = self.list_box.get_children() - if not focusable_elements: - return False - - current_focus = self.window.get_focus() - current_index = ( - focusable_elements.index(current_focus) - if current_focus in focusable_elements - else -1 - ) - - target_index = current_index - if keyval == Gdk.KEY_Down: - target_index = 0 if current_index == -1 else current_index + 1 - elif keyval == Gdk.KEY_Up: - target_index = ( - len(focusable_elements) - 1 - if current_index == -1 - else current_index - 1 - ) - elif keyval == Gdk.KEY_Page_Down: - target_index = ( - 0 - if current_index == -1 - else min(current_index + 5, len(focusable_elements) - 1) - ) - elif keyval == Gdk.KEY_Page_Up: - target_index = ( - len(focusable_elements) - 1 - if current_index == -1 - else max(current_index - 5, 0) - ) - - if 0 <= target_index < len(focusable_elements): - row = focusable_elements[target_index] - self.list_box.select_row(row) - row.grab_focus() - allocation = row.get_allocation() - adj = self.scrolled_window.get_vadjustment() - if adj: - adj.set_value( - min(allocation.y, adj.get_upper() - adj.get_page_size()) - ) - return True - - return False - - # When search entry has focus, insert bound keys as text instead of - # triggering their global actions. Let all other keys pass through normally. - key_to_char = { - Gdk.KEY_v: "v", - Gdk.KEY_x: "x", - Gdk.KEY_p: "p", - Gdk.KEY_j: "j", - Gdk.KEY_k: "k", - Gdk.KEY_f: "f", - Gdk.KEY_slash: "/", - Gdk.KEY_question: "?", - Gdk.KEY_space: " ", - } - - if keyval in key_to_char: - # Insert the character into the search entry - char = key_to_char[keyval] - current_text = self.search_entry.get_text() - # Get cursor position (if available) or append to end - if hasattr(self.search_entry, 'get_position'): - pos = self.search_entry.get_position() - new_text = current_text[:pos] + char + current_text[pos:] - self.search_entry.set_text(new_text) - self.search_entry.set_position(pos + 1) - else: - self.search_entry.set_text(current_text + char) - return True # Block the global action - - # Block Tab and Return from triggering actions, but don't insert them - if keyval in (Gdk.KEY_Tab, Gdk.KEY_Return): - return True - - # Let all other keys pass through normally - return False - - selected_row = self.list_box.get_selected_row() - - if keyval == Gdk.KEY_Return: - if selected_row: - self.on_row_activated(self.list_box, shift and not ENTER_TO_PASTE) - elif self.list_box.get_children(): - first_row = self.list_box.get_row_at_index(0) - if first_row: - self.list_box.select_row(first_row) - first_row.grab_focus() - self.on_row_activated(self.list_box) - else: - self.search_entry.grab_focus() - return True - - # Navigation Aliases - if keyval == Gdk.KEY_k: - return self.list_box.emit("move-cursor", Gtk.MovementStep.DISPLAY_LINES, -1) - if keyval == Gdk.KEY_j: - return self.list_box.emit("move-cursor", Gtk.MovementStep.DISPLAY_LINES, 1) - - # Actions - if ( - keyval == Gdk.KEY_slash - or keyval == Gdk.KEY_f - and not self.search_entry.has_focus() - ): - self.search_entry.show() - self.search_entry.grab_focus() - self.search_entry.select_region(0, -1) - return True - if keyval == Gdk.KEY_v: - # Toggle selection mode - self.toggle_selection_mode() - return True - if keyval == Gdk.KEY_space: - if self.selection_mode: - # In selection mode, space toggles item selection - self.toggle_item_selection() - return True - elif selected_row: - # If it's a URL and browser-open is enabled, open it - if OPEN_LINKS_WITH_BROWSER and getattr(selected_row, "is_url", False): - url = getattr(selected_row, "website_url", None) or getattr(selected_row, "item_value", "") - if url: - self.open_url_with_gtk(url) - return True - # Otherwise show preview - self.show_item_preview() - return True - if ctrl and keyval == Gdk.KEY_a: - if shift: - # Ctrl+Shift+A: Deselect all - self.deselect_all_items() - else: - # Ctrl+A: Select all - self.select_all_items() - return True - if ctrl and keyval == Gdk.KEY_x: - # Ctrl+X: Delete selected items - if self.selection_mode and self.selected_indices: - self.delete_selected_items() - return True - if shift and keyval == Gdk.KEY_Delete: - # Shift+Delete: Delete selected items - if self.selection_mode and self.selected_indices: - self.delete_selected_items() - return True - if ctrl and shift and keyval == Gdk.KEY_Delete: - # Ctrl+Shift+Delete: Clear all non-pinned items - self.clear_all_items() - return True - if ctrl and keyval == Gdk.KEY_d: - # Ctrl+D: Clear all non-pinned items (alternative) - self.clear_all_items() - return True - if keyval == Gdk.KEY_p: - if selected_row: - self.toggle_pin_selected() - return True - if keyval in [Gdk.KEY_x, Gdk.KEY_Delete]: - if selected_row and not self.selection_mode: - self.remove_selected_item() - return True - if keyval == Gdk.KEY_question or (shift and keyval == Gdk.KEY_slash): - show_help_window(self.window, self.on_help_window_close) - return True - if ctrl and keyval == Gdk.KEY_comma: - style_defaults = { - "border_radius": 6, - "accent_color": "#ffcc00", - "selection_color": "#4a90e2", - "visual_mode_color": "#9b59b6", - } - show_settings_window( - self.window, - self.on_settings_window_close, - self.restart_application, - update_style_cb=self.update_style_css, - style_defaults=style_defaults, - ) - return True - if keyval == Gdk.KEY_Tab: - self.pin_filter_button.set_active(not self.pin_filter_button.get_active()) - self.list_box.grab_focus() - return True - if keyval == Gdk.KEY_Escape: - # Priority order: exit selection mode -> clear search -> quit - if self.selection_mode: - self.toggle_selection_mode() - return True - elif self.search_entry.get_text(): - self.search_entry.set_text("") - self.list_box.grab_focus() - else: - app = self.window.get_application() - if app: - # Try to minimize to tray if enabled, otherwise quit - from . import constants - - if ( - hasattr(app, "tray_manager") - and app.tray_manager - and constants.MINIMIZE_TO_TRAY - ): - if app.tray_manager.minimize_to_tray(): - return True # Successfully minimized to tray - app.quit() - else: - log.warning("Application instance is None. Cannot quit.") - return True - if ctrl and keyval == Gdk.KEY_q: - app = self.window.get_application() - if app: - app.quit() - else: - log.warning("Application instance is None. Cannot quit.") - return True - - # Zoom - if ctrl and keyval in [Gdk.KEY_plus, Gdk.KEY_equal]: - self.zoom_level *= 1.1 - self.update_zoom() - return True - if ctrl and keyval == Gdk.KEY_minus: - self.zoom_level /= 1.1 - self.update_zoom() - return True - if ctrl and keyval == Gdk.KEY_0: - self.zoom_level = 1.0 - self.update_zoom() - return True - - return False - - def on_row_activated(self, row, with_paste_simulation=False): - """Handles double-click or Enter on a list row.""" - log.debug(f"Row activated: original_index={getattr(row, 'item_index', 'N/A')}") - self.copy_selected_item_to_clipboard(with_paste_simulation) - - def _on_row_single_click(self, row): - """Handles single-click on a list row - copies and pastes.""" - log.debug(f"Row single-clicked: original_index={getattr(row, 'item_index', 'N/A')}") - # Select the row first - self.list_box.select_row(row) - # Trigger copy with paste simulation - self.copy_selected_item_to_clipboard(with_paste_simulation=True) - - def on_search_changed(self, entry): - """Handles changes in the search entry, debounced.""" - new_search_term = entry.get_text() - if new_search_term != self.search_term: - self.search_term = new_search_term - if self._search_timer_id: - GLib.source_remove(self._search_timer_id) - self._search_timer_id = GLib.timeout_add( - int(SEARCH_DEBOUNCE_MS or 250), self._trigger_filter_update - ) - - def _trigger_filter_update(self): - """Updates filtering after search debounce timeout.""" - log.debug(f"Triggering filter update for search: '{self.search_term}'") - self.update_filtered_items() - self._search_timer_id = None - return False - - def on_pin_filter_toggled(self, button): - """Handles toggling the 'Pinned Only' filter button.""" - is_active = button.get_active() - if is_active != self.show_only_pinned: - self.show_only_pinned = is_active - log.debug(f"Pin filter toggled: {'ON' if self.show_only_pinned else 'OFF'}") - self.update_filtered_items() - if len(self.list_box.get_children()) > 0: - GLib.idle_add(self._focus_first_item) - - def on_compact_mode_toggled(self, button): - """Handles compact mode toggle button state changes.""" - self.compact_mode = button.get_active() - self.update_compact_mode() - # Save the setting - if not config.config.has_section("General"): - config.config.add_section("General") - config.config.set("General", "compact_mode", str(self.compact_mode)) - config._save_config() - - def update_compact_mode(self, skip_populate=False): - """Updates the UI based on compact mode state.""" - if self.compact_mode: - self.main_box.get_style_context().add_class("compact-mode") - self.window.resize( - int(DEFAULT_WINDOW_WIDTH * 0.6), int(DEFAULT_WINDOW_HEIGHT * 0.6) - ) - self.search_entry.hide() - else: - self.main_box.get_style_context().remove_class("compact-mode") - self.window.resize(DEFAULT_WINDOW_WIDTH, DEFAULT_WINDOW_HEIGHT) - self.search_entry.show() - - if not skip_populate: - self.populate_list_view() - - def update_hover_to_select(self): - """Updates hover-to-select setting and repopulates the list.""" - self.hover_to_select = HOVER_TO_SELECT - # Repopulate the list to apply hover-to-select to existing rows - self.populate_list_view() - - # --- Scrolling Helpers --- - - def scroll_to_bottom(self): - """Scrolls the list view to the bottom.""" - if not self.vadj: - return - - def _do_scroll(): - if self.vadj: - upper = self.vadj.get_upper() - page_size = self.vadj.get_page_size() - self.vadj.set_value(max(0, upper - page_size)) - return False - - GLib.idle_add(_do_scroll) - - def scroll_to_top(self): - """Scrolls the list view to the top.""" - if not self.vadj: - return - - def _do_scroll(): - if self.vadj: - self.vadj.set_value(self.vadj.get_lower()) - return False - - GLib.idle_add(_do_scroll) - - def on_search_focus_out(self, entry, event): - """Handles when search entry loses focus.""" - if self.compact_mode: - self.search_entry.hide() - return False diff --git a/clipse_gui/controller_mixins/__init__.py b/clipse_gui/controller_mixins/__init__.py new file mode 100644 index 0000000..820f2cb --- /dev/null +++ b/clipse_gui/controller_mixins/__init__.py @@ -0,0 +1,31 @@ +"""Mixins composing the ClipboardHistoryController. + +Each mixin owns one domain (data, style, list view, search, item ops, etc.). +The controller class assembles them all via multiple inheritance. +""" + +from .clipboard_mixin import ClipboardMixin +from .data_mixin import DataMixin +from .item_ops_mixin import ItemOpsMixin +from .keyboard_mixin import KeyboardMixin +from .list_view_mixin import ListViewMixin +from .misc_mixin import MiscMixin +from .preview_mixin import PreviewMixin +from .scroll_mixin import ScrollMixin +from .search_mixin import SearchMixin +from .selection_mixin import SelectionMixin +from .style_mixin import StyleMixin + +__all__ = [ + "ClipboardMixin", + "DataMixin", + "ItemOpsMixin", + "KeyboardMixin", + "ListViewMixin", + "MiscMixin", + "PreviewMixin", + "ScrollMixin", + "SearchMixin", + "SelectionMixin", + "StyleMixin", +] diff --git a/clipse_gui/controller_mixins/clipboard_mixin.py b/clipse_gui/controller_mixins/clipboard_mixin.py new file mode 100644 index 0000000..c73d3bd --- /dev/null +++ b/clipse_gui/controller_mixins/clipboard_mixin.py @@ -0,0 +1,372 @@ +"""Clipboard copy/paste operations and paste-simulation.""" + +import logging +import mimetypes +import os +import shlex +import subprocess + +from gi.repository import GLib + +from ..constants import ( + COPY_TOOL_CMD, + ENTER_TO_PASTE, + PASTE_SIMULATION_CMD_WAYLAND, + PASTE_SIMULATION_CMD_X11, + PASTE_SIMULATION_DELAY_MS, + X11_COPY_TOOL_CMD, +) + +log = logging.getLogger(__name__) + + +class ClipboardMixin: + def _run_paste_command(self, cmd_args, input_data=None, is_binary=False): + """Helper to run the paste command subprocess.""" + try: + log.info(f"Running paste command: {cmd_args}") + process = subprocess.Popen( + cmd_args, + stdin=subprocess.PIPE, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + ) + stdout_output, stderr_output = None, None + try: + input_bytes = ( + input_data + if is_binary + else input_data.encode("utf-8") + if input_data is not None + else None + ) + stdout_output, stderr_output = process.communicate( + input=input_bytes, timeout=5 + ) + except subprocess.TimeoutExpired: + log.error(f"Paste command timed out: {cmd_args}") + process.kill() + stdout_output, stderr_output = process.communicate() + self.flash_status("Error: Paste command timed out") + return False + except OSError as e: + log.error(f"OSError during paste command communicate: {e}") + if process.stderr: + stderr_output = process.stderr.read() + self.flash_status(f"Error communicating with paste command: {e}") + return False + except Exception as e: + log.error(f"Unexpected error during paste command communicate: {e}") + if process.stderr: + stderr_output = process.stderr.read() + self.flash_status(f"Error running paste command: {e}") + return False + + if process.returncode != 0: + stderr_text = ( + stderr_output.decode("utf-8", errors="ignore").strip() + if stderr_output + else "No stderr output" + ) + log.error( + f"Paste command failed with code {process.returncode}: {stderr_text}" + ) + self.flash_status(f"Paste command error: {stderr_text[:100]}") + return False + else: + log.info("Paste command successful.") + return True + except FileNotFoundError: + log.error(f"Paste command not found: {cmd_args[0]}") + self.flash_status(f"Error: Command '{cmd_args[0]}' not found.") + return False + except Exception as e: + log.error(f"Error invoking paste command {cmd_args}: {e}") + self.flash_status(f"Error starting paste command: {str(e)}") + return False + + def _get_copy_command(self): + """Gets the appropriate command for copying TO the clipboard.""" + if self._is_wayland: + return str(COPY_TOOL_CMD) + else: + return str(X11_COPY_TOOL_CMD or COPY_TOOL_CMD) + + def copy_text_to_clipboard(self, text_value): + """Use the configured command to place text into the clipboard.""" + copy_cmd = self._get_copy_command() + if not copy_cmd: + self.flash_status("Error: No copy command configured.") + return False + try: + cmd_args = shlex.split(copy_cmd) + except Exception as e: + log.error(f"Could not parse COPY_TOOL_CMD ('{COPY_TOOL_CMD}'): {e}") + self.flash_status("Error: Invalid copy command in config") + return False + try: + process = subprocess.Popen( + cmd_args, + stdin=subprocess.PIPE, + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + ) + if process.stdin: + process.stdin.write(text_value.encode("utf-8")) + process.stdin.close() + # Wait for process to complete to ensure clipboard is updated + process.wait(timeout=5) + else: + log.error("Process stdin is None. Cannot write to clipboard.") + self.flash_status("Error: Unable to write to clipboard") + return False + + return True + except subprocess.TimeoutExpired: + log.error(f"Copy command timed out: {copy_cmd}") + self.flash_status("Error: Copy command timed out") + return False + except FileNotFoundError: + log.error(f"Copy command not found: {cmd_args[0]}") + self.flash_status(f"Error: Copy command '{cmd_args[0]}' not found.") + return False + except Exception as e: + log.error(f"Error copying text to clipboard: {e}") + self.flash_status(f"Error copying text: {str(e)[:100]}") + return False + + def copy_image_to_clipboard(self, image_path): + """Use the configured command to place an image into the clipboard.""" + copy_cmd_base = self._get_copy_command() + if not copy_cmd_base: + self.flash_status("Error: No copy command configured.") + return False + + try: + if not os.path.isfile(image_path): + log.error(f"Image file does not exist: {image_path}") + self.flash_status("Error: Image file not found") + return False + + mimetype, _ = mimetypes.guess_type(image_path) + if not mimetype or not mimetype.startswith("image/"): + image_ext = os.path.splitext(image_path)[1].lower() + mimetype = ( + f"image/{image_ext.lstrip('.')}" if image_ext else "image/png" + ) + log.warning( + f"Could not guess mimetype for {image_path}, using {mimetype}" + ) + + try: + base_cmd_args = shlex.split(copy_cmd_base) + except Exception as e: + log.error(f"Could not parse copy command ('{copy_cmd_base}'): {e}") + self.flash_status( + f"Error: Invalid copy command: {copy_cmd_base[:50]}..." + ) + return False + + cmd_args = base_cmd_args + if "wl-copy" in os.path.basename(base_cmd_args[0]): + cmd_args = base_cmd_args + ["--type", mimetype] + + with open(image_path, "rb") as img_file: + try: + process = subprocess.Popen( + cmd_args, + stdin=img_file, + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + ) + stdout_data, stderr_data = process.communicate(timeout=10) + + if process.returncode != 0: + err_msg = ( + stderr_data.decode("utf-8", errors="ignore").strip() + or stdout_data.decode("utf-8", errors="ignore").strip() + ) + log.error( + f"Image copy command failed (code {process.returncode}): {err_msg}" + ) + self.flash_status(f"Image copy failed: {err_msg[:100]}") + return False + + # self.flash_status("Image copied to clipboard") + log.info("Image copied successfully.") + return True + + except subprocess.TimeoutExpired: + log.error(f"Image copy command timed out: {cmd_args}") + self.flash_status("Error: Image copy timed out") + return False + except FileNotFoundError: + log.error(f"Copy command not found: {cmd_args[0]}") + self.flash_status(f"Error: Copy command '{cmd_args[0]}' not found.") + return False + except Exception as e: + log.error(f"Error copying image to clipboard: {e}") + self.flash_status(f"Error copying image: {str(e)[:100]}") + return False + + except Exception as e: + log.error(f"Unexpected error preparing image copy: {e}", exc_info=True) + self.flash_status(f"Error copying image: {str(e)[:100]}") + return False + + def copy_selected_item_to_clipboard(self, with_paste_simulation=False): + """Copies the selected item to the system clipboard and closes the window.""" + selected_row = self.list_box.get_selected_row() + exit_timeout = 150 + if not selected_row: + log.warning("Copy called with no row selected.") + return + + original_index = getattr(selected_row, "item_index", -1) + is_image = getattr(selected_row, "file_path") not in [None, "null"] + item_value = getattr(selected_row, "item_value", None) + + if original_index == -1: + log.error("Selected row missing valid item_index attribute.") + self.flash_status("Error: Invalid selected item data.") + return + + try: + if not (0 <= original_index < len(self.items)): + log.error( + f"Item with original index {original_index} no longer exists in master list." + ) + self.flash_status("Error: Selected item no longer exists.") + return + + item = self.items[original_index] + + def close_window_callback(window): + if window and window.get_realized(): + log.info("Closing window after successful copy.") + window.get_application().quit() + return False + + copy_successful = False + if is_image: + image_path = item.get("filePath") + if image_path and os.path.exists(image_path): + copy_successful = self.copy_image_to_clipboard(image_path) + else: + log.error( + f"Image path invalid or file missing for item {original_index}: {image_path}" + ) + self.flash_status("Image path invalid or file missing") + else: + text_to_copy = item.get("value") + if text_to_copy is not None: + copy_successful = self.copy_text_to_clipboard(item_value) + else: + log.error(f"Text item {original_index} has None value in data.") + self.flash_status("Cannot copy null text value.") + + if copy_successful: + if ENTER_TO_PASTE or with_paste_simulation: + log.debug("Hiding window and scheduling paste simulation.") + self.window.hide() + GLib.timeout_add( + PASTE_SIMULATION_DELAY_MS or 150, + self._trigger_paste_simulation_and_quit, + ) + else: + GLib.timeout_add(100, self._quit_application) + else: + log.error("Copy operation failed.") + self.flash_status("Error: Copy operation failed.") + GLib.timeout_add(exit_timeout, close_window_callback, self.window) + + except Exception as e: + log.error(f"Unexpected error during copy selection: {e}", exc_info=True) + self.flash_status(f"Error copying: {str(e)}") + + def _trigger_paste_simulation_and_quit(self): + """Called after a delay to run paste simulation and then quit.""" + log.debug("Attempting paste simulation...") + paste_success = self.paste_from_clipboard_simulated() + if paste_success: + log.info("Paste simulation command successful.") + else: + log.warning("Paste simulation command failed or skipped.") + # Optional: Show the window again if paste fails? + # self.window.show() + # self.flash_status("Paste failed. Check logs/dependencies (xdotool/wtype).") + + # Quit the application after a longer delay to ensure paste completes + # Some applications need more time to receive and process the paste + quit_delay = 200 # ms - increased from 50ms for better reliability + GLib.timeout_add(quit_delay, self._quit_application) + return False # Prevent timer from repeating + + def _quit_application(self): + """Safely quits the GTK application.""" + log.info("Quitting application.") + app = self.window.get_application() + if app: + app.quit() + return False # Prevent timer from repeating + + def paste_from_clipboard_simulated(self): + """Pastes FROM the clipboard by simulating key presses (Ctrl+V).""" + if self._is_wayland: + cmd_str = str(PASTE_SIMULATION_CMD_WAYLAND) + tool_name = "wtype" + else: + cmd_str = str(PASTE_SIMULATION_CMD_X11) + tool_name = "xdotool" + + if not cmd_str: + log.error( + f"Paste simulation command not configured for {'Wayland' if self._is_wayland else 'X11'}." + ) + self.flash_status("Error: Paste simulation command not configured.") + return False + + try: + cmd_args = shlex.split(cmd_str) + except Exception as e: + log.error(f"Could not parse paste simulation command ('{cmd_str}'): {e}") + self.flash_status(f"Error: Invalid Paste command: {cmd_str[:50]}...") + return False + + log.debug(f"Executing paste simulation command: {cmd_args}") + try: + # Use run for simplicity, capture output for errors + result = subprocess.run( + cmd_args, + capture_output=True, + text=True, + timeout=5, # Timeout for the simulation command + check=False, # Don't raise exception on non-zero exit code, check manually + ) + + if result.returncode != 0: + error_output = result.stderr.strip() or result.stdout.strip() + error_msg = f"Paste simulation ({tool_name}) failed (code {result.returncode}): {error_output}" + log.error(error_msg) + # Don't flash here, happens after window is hidden + # self.flash_status(f"{tool_name} error: {error_output[:100]}") + return False + + log.info(f"Paste simulation ({tool_name}) command successful.") + return True + + except FileNotFoundError: + error_msg = f"Paste simulation command not found: '{cmd_args[0]}'. Is '{tool_name}' installed?" + log.error(error_msg) + # self.flash_status(error_msg) + return False + except subprocess.TimeoutExpired: + error_msg = f"Paste simulation command timed out: '{cmd_str}'" + log.error(error_msg) + # self.flash_status(error_msg) + return False + except Exception as e: + error_msg = f"Error running paste simulation command '{cmd_str}': {e}" + log.error(error_msg) + # self.flash_status(error_msg[:150]) + return False diff --git a/clipse_gui/controller_mixins/data_mixin.py b/clipse_gui/controller_mixins/data_mixin.py new file mode 100644 index 0000000..54985a7 --- /dev/null +++ b/clipse_gui/controller_mixins/data_mixin.py @@ -0,0 +1,62 @@ +"""Data loading, persistence, and history-update callbacks.""" + +import logging + +from gi.repository import GLib + +from ..constants import SAVE_DEBOUNCE_MS + +log = logging.getLogger(__name__) + + +class DataMixin: + + def _on_history_updated(self, loaded_items): + """Callback function called when the file watcher detects a change.""" + log.debug("Received history update signal from DataManager.") + self.items = loaded_items + self.update_filtered_items() + + def _load_initial_data(self): + """Loads history in background thread.""" + loaded_items = self.data_manager.load_history() + GLib.idle_add(self._finish_initial_load, loaded_items) + self.data_manager._start_history_watcher(self._on_history_updated) + + def _finish_initial_load(self, loaded_items): + """Updates UI after initial data load.""" + self.items = loaded_items + self.update_filtered_items() + if not self.items: + self.status_label.set_text("No history items found. Press ? for help.") + else: + GLib.idle_add(self._focus_first_item) + return False + + def _focus_first_item(self): + """Selects and focuses the first item in the list.""" + if len(self.list_box.get_children()) > 0: + first_row = self.list_box.get_row_at_index(0) + if first_row: + self.list_box.select_row(first_row) + first_row.grab_focus() + return False + + def schedule_save_history(self): + """Schedules saving the history after a debounce delay.""" + if self._save_timer_id: + GLib.source_remove(self._save_timer_id) + self._save_timer_id = GLib.timeout_add( + int(SAVE_DEBOUNCE_MS or 300), self._trigger_save + ) + + def _trigger_save(self): + """Calls the DataManager to save history.""" + log.debug("Triggering history save.") + self.data_manager.save_history(self.items, self._handle_save_error) + self._save_timer_id = None + return False + + def _handle_save_error(self, error_message): + """Callback for DataManager save errors.""" + self.flash_status(error_message) diff --git a/clipse_gui/controller_mixins/item_ops_mixin.py b/clipse_gui/controller_mixins/item_ops_mixin.py new file mode 100644 index 0000000..ad74d04 --- /dev/null +++ b/clipse_gui/controller_mixins/item_ops_mixin.py @@ -0,0 +1,294 @@ +"""Item operations: pin toggle, single delete, batch delete, clear all.""" + +import logging + +from gi.repository import Gtk + +from ..constants import PROTECT_PINNED_ITEMS +from ..ui_components import animate_pin_shake + +log = logging.getLogger(__name__) + + +class ItemOpsMixin: + + def update_row_pin_status(self, original_index): + """Updates the visual state of a row when its pin status changes.""" + is_pinned = self.items[original_index].get("pinned", False) + for row in self.list_box.get_children(): + if hasattr(row, "item_index") and row.item_index == original_index: + row.item_pinned = is_pinned + try: + widget = row.get_child() + if isinstance(widget, Gtk.Box): + hbox = widget.get_children()[0] + if isinstance(hbox, Gtk.Box): + # Animate the rotation wiggle effect + animate_pin_shake(hbox, is_pinned) + except (AttributeError, IndexError, TypeError) as e: + log.warning( + f"Could not update pin icon for row {original_index}: {e}" + ) + + context = row.get_style_context() + if is_pinned: + context.add_class("pinned-row") + else: + context.remove_class("pinned-row") + break + + def toggle_pin_selected(self): + """Toggles the pin status of the currently selected item.""" + selected_row = self.list_box.get_selected_row() + if selected_row and hasattr(selected_row, "item_index"): + original_index = selected_row.item_index + if 0 <= original_index < len(self.items): + item = self.items[original_index] + new_pin_state = not item.get("pinned", False) + item["pinned"] = new_pin_state + self.update_row_pin_status(original_index) + self.schedule_save_history() + self.flash_status("Item pinned" if new_pin_state else "Item unpinned") + if self.show_only_pinned and not new_pin_state: + self._remove_row_from_view(selected_row) + else: + log.error(f"Invalid original_index {original_index} for toggle pin.") + self.flash_status("Error: Item index invalid.") + else: + log.warning("Toggle pin called with no valid row selected.") + + def remove_selected_item(self): + """Removes the currently selected item from history and view.""" + selected_row = self.list_box.get_selected_row() + if selected_row and hasattr(selected_row, "item_index"): + original_index_to_remove = selected_row.item_index + if 0 <= original_index_to_remove < len(self.items): + item = self.items[original_index_to_remove] + + # Check if the item is pinned and protection is enabled + if PROTECT_PINNED_ITEMS and item.get("pinned", False): + self.flash_status("Cannot delete pinned item: protection enabled") + return + + item_value_preview = str( + self.items[original_index_to_remove].get("value", "") + )[:30] + log.info(f"Removing item at original index {original_index_to_remove}") + + del self.items[original_index_to_remove] + self.schedule_save_history() + removed_filtered_index = self._remove_row_from_view(selected_row) + + # Update original indices for subsequent items/rows + for fi in self.filtered_items: + if fi["original_index"] > original_index_to_remove: + fi["original_index"] -= 1 + current_rows = self.list_box.get_children() + for i in range(removed_filtered_index, len(current_rows)): + row = current_rows[i] + if ( + hasattr(row, "item_index") + and row.item_index > original_index_to_remove + ): + row.item_index -= 1 + + self.flash_status(f"Item removed: '{item_value_preview}...'.") + self.update_status_label() + self._select_nearby_row( + removed_filtered_index + ) # Reselect after removal + else: + log.error( + f"Invalid original_index {original_index_to_remove} for remove." + ) + self.flash_status("Error: Item index invalid for removal.") + else: + log.warning("Remove item called with no valid row selected.") + + def _remove_row_from_view(self, row_to_remove): + """Helper to remove a row from the ListBox and update filtered list.""" + removed_filtered_index = -1 + original_index_removed = getattr(row_to_remove, "item_index", -1) + children = self.list_box.get_children() + try: + removed_filtered_index = children.index(row_to_remove) + except ValueError: + log.warning( + f"Row with original index {original_index_removed} not found in list_box children." + ) + for idx, child in enumerate(children): # Fallback find + if getattr(child, "item_index", -1) == original_index_removed: + removed_filtered_index = idx + row_to_remove = child + break + if removed_filtered_index == -1: + return -1 + + self.list_box.remove(row_to_remove) + self.filtered_items = [ + fi + for fi in self.filtered_items + if fi["original_index"] != original_index_removed + ] + return removed_filtered_index + + def _select_nearby_row(self, index_before_removal): + """Selects a row near the index of a previously removed row.""" + if index_before_removal != -1: + new_count = len(self.list_box.get_children()) + if new_count > 0: + select_idx = min(index_before_removal, new_count - 1) + new_row = self.list_box.get_row_at_index(select_idx) + if new_row: + self.list_box.select_row(new_row) + new_row.grab_focus() + else: + self.list_box.grab_focus() + else: + self.search_entry.grab_focus() + + def delete_selected_items(self): + """Deletes all selected items with confirmation.""" + if not self.selected_indices: + self.flash_status("No items selected for deletion") + return + + # Count pinned vs non-pinned selected items + pinned_count = 0 + non_pinned_count = 0 + indices_to_delete = [] + + for idx in self.selected_indices: + if 0 <= idx < len(self.items): + item = self.items[idx] + if item.get("pinned", False): + pinned_count += 1 + if not PROTECT_PINNED_ITEMS: + indices_to_delete.append(idx) + else: + non_pinned_count += 1 + indices_to_delete.append(idx) + + if not indices_to_delete: + if PROTECT_PINNED_ITEMS and pinned_count > 0: + self.flash_status( + f"Cannot delete: all {pinned_count} selected items are pinned (protection enabled)" + ) + else: + self.flash_status("No items to delete") + return + + # Build confirmation message + total_to_delete = len(indices_to_delete) + protected_count = pinned_count if PROTECT_PINNED_ITEMS else 0 + + message = f"Delete {total_to_delete} selected item{'s' if total_to_delete != 1 else ''}?" + if protected_count > 0: + message += f"\n\n({protected_count} pinned item{'s' if protected_count != 1 else ''} will be skipped due to protection)" + + # Show confirmation dialog + dialog = Gtk.MessageDialog( + transient_for=self.window, + modal=True, + destroy_with_parent=True, + message_type=Gtk.MessageType.WARNING, + buttons=Gtk.ButtonsType.NONE, + text="Confirm Deletion", + ) + dialog.format_secondary_text(message) + dialog.add_button("Cancel", Gtk.ResponseType.CANCEL) + delete_button = dialog.add_button("Delete", Gtk.ResponseType.OK) + delete_button.get_style_context().add_class("destructive-action") + + response = dialog.run() + dialog.destroy() + + if response == Gtk.ResponseType.OK: + # Sort indices in descending order to delete from end to beginning + indices_to_delete.sort(reverse=True) + + for idx in indices_to_delete: + if 0 <= idx < len(self.items): + del self.items[idx] + + # Exit selection mode and clear selections + self.selection_mode = False + self.selected_indices.clear() + self.main_box.get_style_context().remove_class("selection-mode") + + # Save and refresh + self.schedule_save_history() + self.update_filtered_items() + + self.flash_status( + f"Deleted {total_to_delete} item{'s' if total_to_delete != 1 else ''}" + ) + log.info(f"Deleted {total_to_delete} selected items") + + def clear_all_items(self): + """Clears all non-pinned items with confirmation.""" + if not self.items: + self.flash_status("No items to clear") + return + + # Count pinned vs non-pinned items + pinned_count = sum(1 for item in self.items if item.get("pinned", False)) + non_pinned_count = len(self.items) - pinned_count + + if PROTECT_PINNED_ITEMS and non_pinned_count == 0: + self.flash_status( + f"Cannot clear: all {pinned_count} items are pinned (protection enabled)" + ) + return + + # Determine what will be deleted + if PROTECT_PINNED_ITEMS: + items_to_delete = non_pinned_count + message = f"Delete all {non_pinned_count} non-pinned item{'s' if non_pinned_count != 1 else ''}?" + if pinned_count > 0: + message += f"\n\n({pinned_count} pinned item{'s' if pinned_count != 1 else ''} will be kept)" + else: + items_to_delete = len(self.items) + message = f"Delete ALL {items_to_delete} item{'s' if items_to_delete != 1 else ''}?" + if pinned_count > 0: + message += f"\n\nWarning: This includes {pinned_count} pinned item{'s' if pinned_count != 1 else ''}!" + + # Show confirmation dialog + dialog = Gtk.MessageDialog( + transient_for=self.window, + modal=True, + destroy_with_parent=True, + message_type=Gtk.MessageType.WARNING, + buttons=Gtk.ButtonsType.NONE, + text="Clear All Items", + ) + dialog.format_secondary_text(message) + dialog.add_button("Cancel", Gtk.ResponseType.CANCEL) + clear_button = dialog.add_button("Clear All", Gtk.ResponseType.OK) + clear_button.get_style_context().add_class("destructive-action") + + response = dialog.run() + dialog.destroy() + + if response == Gtk.ResponseType.OK: + if PROTECT_PINNED_ITEMS: + # Keep only pinned items + self.items = [item for item in self.items if item.get("pinned", False)] + else: + # Delete everything + self.items = [] + + # Exit selection mode if active + if self.selection_mode: + self.selection_mode = False + self.selected_indices.clear() + self.main_box.get_style_context().remove_class("selection-mode") + + # Save and refresh + self.schedule_save_history() + self.update_filtered_items() + + self.flash_status( + f"Cleared {items_to_delete} item{'s' if items_to_delete != 1 else ''}" + ) + log.info(f"Cleared {items_to_delete} items") diff --git a/clipse_gui/controller_mixins/keyboard_mixin.py b/clipse_gui/controller_mixins/keyboard_mixin.py new file mode 100644 index 0000000..3b53e36 --- /dev/null +++ b/clipse_gui/controller_mixins/keyboard_mixin.py @@ -0,0 +1,315 @@ +"""Main keyboard event handler and row activation callbacks.""" + +import logging + +from gi.repository import Gdk, GLib, Gtk + +from ..constants import ENTER_TO_PASTE, OPEN_LINKS_WITH_BROWSER, config +from ..ui_components import show_help_window, show_settings_window + +log = logging.getLogger(__name__) + + +class KeyboardMixin: + def on_key_press(self, widget, event): + """Handles key presses on the main window.""" + keyval = event.keyval + ctrl = event.state & Gdk.ModifierType.CONTROL_MASK + shift = event.state & Gdk.ModifierType.SHIFT_MASK + + if self.search_entry.has_focus(): + if keyval == Gdk.KEY_Escape: + # Priority order: exit selection mode -> clear search + unfocus + if self.selection_mode: + self.toggle_selection_mode() + return True + + # Clear search text if present + if self.search_entry.get_text(): + self.search_entry.set_text("") + + # Unfocus search entry and focus first list item (deferred) + def unfocus_search(): + if self.list_box: + # Select and focus first row + first_row = self.list_box.get_row_at_index(0) + if first_row: + self.list_box.select_row(first_row) + first_row.grab_focus() + else: + self.list_box.grab_focus() + else: + self.window.grab_focus() + return False + GLib.idle_add(unfocus_search) + return True + + if keyval in [Gdk.KEY_Up, Gdk.KEY_Down, Gdk.KEY_Page_Up, Gdk.KEY_Page_Down]: + focusable_elements = self.list_box.get_children() + if not focusable_elements: + return False + + current_focus = self.window.get_focus() + current_index = ( + focusable_elements.index(current_focus) + if current_focus in focusable_elements + else -1 + ) + + target_index = current_index + if keyval == Gdk.KEY_Down: + target_index = 0 if current_index == -1 else current_index + 1 + elif keyval == Gdk.KEY_Up: + target_index = ( + len(focusable_elements) - 1 + if current_index == -1 + else current_index - 1 + ) + elif keyval == Gdk.KEY_Page_Down: + target_index = ( + 0 + if current_index == -1 + else min(current_index + 5, len(focusable_elements) - 1) + ) + elif keyval == Gdk.KEY_Page_Up: + target_index = ( + len(focusable_elements) - 1 + if current_index == -1 + else max(current_index - 5, 0) + ) + + if 0 <= target_index < len(focusable_elements): + row = focusable_elements[target_index] + self.list_box.select_row(row) + row.grab_focus() + allocation = row.get_allocation() + adj = self.scrolled_window.get_vadjustment() + if adj: + adj.set_value( + min(allocation.y, adj.get_upper() - adj.get_page_size()) + ) + return True + + return False + + # When search entry has focus, insert bound keys as text instead of + # triggering their global actions. Let all other keys pass through normally. + key_to_char = { + Gdk.KEY_v: "v", + Gdk.KEY_x: "x", + Gdk.KEY_p: "p", + Gdk.KEY_j: "j", + Gdk.KEY_k: "k", + Gdk.KEY_f: "f", + Gdk.KEY_slash: "/", + Gdk.KEY_question: "?", + Gdk.KEY_space: " ", + } + + if keyval in key_to_char: + # Insert the character into the search entry + char = key_to_char[keyval] + current_text = self.search_entry.get_text() + # Get cursor position (if available) or append to end + if hasattr(self.search_entry, 'get_position'): + pos = self.search_entry.get_position() + new_text = current_text[:pos] + char + current_text[pos:] + self.search_entry.set_text(new_text) + self.search_entry.set_position(pos + 1) + else: + self.search_entry.set_text(current_text + char) + return True # Block the global action + + # Block Tab and Return from triggering actions, but don't insert them + if keyval in (Gdk.KEY_Tab, Gdk.KEY_Return): + return True + + # Let all other keys pass through normally + return False + + selected_row = self.list_box.get_selected_row() + + if keyval == Gdk.KEY_Return: + if selected_row: + self.on_row_activated(self.list_box, shift and not ENTER_TO_PASTE) + elif self.list_box.get_children(): + first_row = self.list_box.get_row_at_index(0) + if first_row: + self.list_box.select_row(first_row) + first_row.grab_focus() + self.on_row_activated(self.list_box) + else: + self.search_entry.grab_focus() + return True + + # Navigation Aliases + if keyval == Gdk.KEY_k: + return self.list_box.emit("move-cursor", Gtk.MovementStep.DISPLAY_LINES, -1) + if keyval == Gdk.KEY_j: + return self.list_box.emit("move-cursor", Gtk.MovementStep.DISPLAY_LINES, 1) + + # Actions + if ( + keyval == Gdk.KEY_slash + or keyval == Gdk.KEY_f + and not self.search_entry.has_focus() + ): + # In compact mode the search entry is set_no_show_all(True). Re-allow show. + self.search_entry.set_no_show_all(False) + self.search_entry.show() + + # Defer focus + select: GTK realizes the widget on the next idle tick, + # grab_focus() before realization is silently dropped. + def _focus_search(): + self.search_entry.grab_focus() + self.search_entry.select_region(0, -1) + return False + + GLib.idle_add(_focus_search) + return True + if keyval == Gdk.KEY_v: + # Toggle selection mode + self.toggle_selection_mode() + return True + if keyval == Gdk.KEY_space: + if self.selection_mode: + # In selection mode, space toggles item selection + self.toggle_item_selection() + return True + elif selected_row: + # If it's a URL and browser-open is enabled, open it + if OPEN_LINKS_WITH_BROWSER and getattr(selected_row, "is_url", False): + url = getattr(selected_row, "website_url", None) or getattr(selected_row, "item_value", "") + if url: + self.open_url_with_gtk(url) + return True + # Otherwise show preview + self.show_item_preview() + return True + if ctrl and keyval == Gdk.KEY_a: + if shift: + # Ctrl+Shift+A: Deselect all + self.deselect_all_items() + else: + # Ctrl+A: Select all + self.select_all_items() + return True + if ctrl and keyval == Gdk.KEY_x: + # Ctrl+X: Delete selected items + if self.selection_mode and self.selected_indices: + self.delete_selected_items() + return True + if shift and keyval == Gdk.KEY_Delete: + # Shift+Delete: Delete selected items + if self.selection_mode and self.selected_indices: + self.delete_selected_items() + return True + if ctrl and shift and keyval == Gdk.KEY_Delete: + # Ctrl+Shift+Delete: Clear all non-pinned items + self.clear_all_items() + return True + if ctrl and keyval == Gdk.KEY_d: + # Ctrl+D: Clear all non-pinned items (alternative) + self.clear_all_items() + return True + if keyval == Gdk.KEY_p: + if selected_row: + self.toggle_pin_selected() + return True + if keyval in [Gdk.KEY_x, Gdk.KEY_Delete]: + if selected_row and not self.selection_mode: + self.remove_selected_item() + return True + if keyval == Gdk.KEY_question or (shift and keyval == Gdk.KEY_slash): + show_help_window(self.window, self.on_help_window_close) + return True + if ctrl and keyval == Gdk.KEY_comma: + style_defaults = { + "border_radius": 6, + "accent_color": "#ffcc00", + "selection_color": "#4a90e2", + "visual_mode_color": "#9b59b6", + } + show_settings_window( + self.window, + self.on_settings_window_close, + self.restart_application, + update_style_cb=self.update_style_css, + style_defaults=style_defaults, + ) + return True + if keyval == Gdk.KEY_Tab: + self.pin_filter_button.set_active(not self.pin_filter_button.get_active()) + self.list_box.grab_focus() + return True + if keyval == Gdk.KEY_Escape: + # Priority order: exit selection mode -> clear search -> quit + if self.selection_mode: + self.toggle_selection_mode() + return True + elif self.search_entry.get_text(): + self.search_entry.set_text("") + self.list_box.grab_focus() + else: + app = self.window.get_application() + if app: + # Try to minimize to tray if enabled, otherwise quit + from .. import constants + + if ( + hasattr(app, "tray_manager") + and app.tray_manager + and constants.MINIMIZE_TO_TRAY + ): + if app.tray_manager.minimize_to_tray(): + return True # Successfully minimized to tray + app.quit() + else: + log.warning("Application instance is None. Cannot quit.") + return True + if ctrl and keyval == Gdk.KEY_q: + app = self.window.get_application() + if app: + app.quit() + else: + log.warning("Application instance is None. Cannot quit.") + return True + + # Zoom + if ctrl and keyval in [Gdk.KEY_plus, Gdk.KEY_equal]: + self.zoom_level *= 1.1 + self.update_zoom() + return True + if ctrl and keyval == Gdk.KEY_minus: + self.zoom_level /= 1.1 + self.update_zoom() + return True + if ctrl and keyval == Gdk.KEY_0: + self.zoom_level = 1.0 + self.update_zoom() + return True + + return False + + def on_row_activated(self, row, with_paste_simulation=False): + """Handles double-click or Enter on a list row.""" + log.debug(f"Row activated: original_index={getattr(row, 'item_index', 'N/A')}") + self.copy_selected_item_to_clipboard(with_paste_simulation) + + def _on_row_single_click(self, row): + """Handles single-click on a list row - copies and pastes.""" + log.debug(f"Row single-clicked: original_index={getattr(row, 'item_index', 'N/A')}") + # Select the row first + self.list_box.select_row(row) + # Trigger copy with paste simulation + self.copy_selected_item_to_clipboard(with_paste_simulation=True) + + def on_compact_mode_toggled(self, button): + """Handles compact mode toggle button state changes.""" + self.compact_mode = button.get_active() + self.update_compact_mode() + # Save the setting + if not config.config.has_section("General"): + config.config.add_section("General") + config.config.set("General", "compact_mode", str(self.compact_mode)) + config._save_config() diff --git a/clipse_gui/controller_mixins/list_view_mixin.py b/clipse_gui/controller_mixins/list_view_mixin.py new file mode 100644 index 0000000..2c94474 --- /dev/null +++ b/clipse_gui/controller_mixins/list_view_mixin.py @@ -0,0 +1,147 @@ +"""List view population, row creation, status label, and flash messages.""" + +import logging +from functools import partial + +from gi.repository import GLib, Gtk + +from ..constants import HIGHLIGHT_SEARCH, INITIAL_LOAD_COUNT +from ..ui_components import create_list_row_widget + +log = logging.getLogger(__name__) + + +class ListViewMixin: + + def populate_list_view(self): + """Clears and populates the list view with the initial batch of filtered items.""" + if not self.list_box: + return + + if self.vadj and self._vadjustment_handler_id: + try: + self.vadj.disconnect(self._vadjustment_handler_id) + except TypeError: + pass + self._vadjustment_handler_id = None + + self.list_box.freeze_child_notify() + for child in self.list_box.get_children(): + self.list_box.remove(child) + self.list_box.thaw_child_notify() + + self._loading_more = False + load_count = min(INITIAL_LOAD_COUNT or 30, len(self.filtered_items)) + log.debug(f"Populating initial {load_count} rows.") + if load_count > 0: + self._create_rows_range(0, load_count) + self.list_box.show_all() + + if self.vadj and not self._vadjustment_handler_id: + self._vadjustment_handler_id = self.vadj.connect( + "value-changed", self.on_vadjustment_changed + ) + + def _create_rows_range(self, start_idx, end_idx): + """Creates and adds rows for a given range of filtered items.""" + end_idx = min(end_idx, len(self.filtered_items)) + log.debug(f"Creating rows from filtered index {start_idx} to {end_idx - 1}") + + self.list_box.freeze_child_notify() + for i in range(start_idx, end_idx): + if i < len(self.filtered_items): + item_info = self.filtered_items[i] + item_info["filtered_index"] = i + row = create_list_row_widget( + item_info, + self.image_handler, + self._update_row_image_widget, + self.compact_mode, + self.hover_to_select, + self._on_row_single_click, + self.search_term, + HIGHLIGHT_SEARCH, + ) + if row: + row.item_index = item_info["original_index"] + file_path = item_info["item"].get("filePath") + row.is_image = bool(file_path and isinstance(file_path, str)) + row.item_value = item_info["item"].get("value") + row.item_pinned = item_info["item"].get("pinned", False) + + # Apply selection styling if this item is selected + if row.item_index in self.selected_indices: + context = row.get_style_context() + context.add_class("selected-row") + + self.list_box.add(row) + else: + log.warning(f"Attempted to create row for out-of-bounds index {i}") + self.list_box.thaw_child_notify() + + def _update_row_image_widget( + self, image_container, placeholder, pixbuf, error_message + ): + """Callback passed to ImageHandler to update the UI for a specific row's image.""" + if not image_container or not image_container.get_realized(): + return + if placeholder and not placeholder.get_realized(): + placeholder = None + + try: + current_child = image_container.get_child() + if current_child: + image_container.remove(current_child) + + if pixbuf: + image = Gtk.Image.new_from_pixbuf(pixbuf) + image.set_halign(Gtk.Align.CENTER) + image.set_valign(Gtk.Align.CENTER) + image_container.add(image) + image.show() + elif placeholder: + placeholder.set_label(error_message or "[Load Error]") + image_container.add(placeholder) + placeholder.show() + except Exception as e: + log.error(f"Error updating row image widget: {e}") + + def update_status_label(self): + """Updates the status bar text.""" + count = len(self.filtered_items) + total = len(self.items) + status_parts = [] + + # Show selection count if in selection mode + if self.selection_mode and self.selected_indices: + selected_count = len(self.selected_indices) + status_parts.append( + f"{selected_count} item{'s' if selected_count != 1 else ''} selected" + ) + + if self.show_only_pinned: + status_parts.append(f"Showing {count} pinned items") + elif self.search_term: + status_parts.append(f"Found {count} items ({total} total)") + else: + status_parts.append(f"{total} items") + + if not self.selection_mode: + status_parts.append("Press ? for help") + + final_status = " • ".join(status_parts) + if self.status_label.get_text() != final_status: + self.status_label.set_text(final_status) + + def flash_status(self, message, duration=2500): + """Temporarily displays a message in the status bar.""" + current_status = self.status_label.get_text() + log.info(f"Status Flash: {message}") + self.status_label.set_text(message) + + def revert_status(original_text): + if self.status_label.get_text() == message: + self.update_status_label() + return False + + GLib.timeout_add(duration, partial(revert_status, current_status)) diff --git a/clipse_gui/controller_mixins/misc_mixin.py b/clipse_gui/controller_mixins/misc_mixin.py new file mode 100644 index 0000000..8288d1c --- /dev/null +++ b/clipse_gui/controller_mixins/misc_mixin.py @@ -0,0 +1,51 @@ +"""Application lifecycle: restart and window destroy callback.""" + +import logging + +log = logging.getLogger(__name__) + + +class MiscMixin: + def restart_application(self): + """Restarts the application to apply settings changes.""" + import sys + import os + import subprocess + import shutil + + log.info("Restarting application to apply settings changes...") + app = self.window.get_application() + if app: + app.quit() + + try: + clipse_gui_path = shutil.which("clipse-gui") + + if clipse_gui_path: + args = [clipse_gui_path] + sys.argv[1:] + log.debug(f"Restarting with system executable: {args}") + subprocess.Popen(args, cwd=os.getcwd()) + elif getattr(sys, "frozen", False): + executable = sys.executable + args = [executable] + sys.argv[1:] + log.debug(f"Restarting with frozen executable: {args}") + subprocess.Popen(args, cwd=os.getcwd()) + else: + original_cmd = sys.argv[0] + if os.path.isfile(original_cmd) and os.access(original_cmd, os.X_OK): + args = [original_cmd] + sys.argv[1:] + log.debug(f"Restarting with original command: {args}") + subprocess.Popen(args, cwd=os.getcwd()) + else: + raise Exception(f"Cannot find executable: {original_cmd}") + + except Exception as e: + log.error(f"Failed to restart application: {e}") + + if app: + app.quit() + else: + sys.exit(0) + + def on_window_destroy(self, widget): + log.info("Main window closed.") diff --git a/clipse_gui/controller_mixins/preview_mixin.py b/clipse_gui/controller_mixins/preview_mixin.py new file mode 100644 index 0000000..27791f6 --- /dev/null +++ b/clipse_gui/controller_mixins/preview_mixin.py @@ -0,0 +1,244 @@ +"""Preview window dispatch and in-preview key handling.""" + +import logging + +from gi.repository import Gdk, Gtk, Pango + +from ..ui_components import show_preview_window + +log = logging.getLogger(__name__) + + +class PreviewMixin: + def open_url_with_gtk(self, url): + """Open a URL using Gtk.show_uri_on_window (respects the user's default browser).""" + try: + log.info(f"Opening URL: {url}") + Gtk.show_uri_on_window(None, url, Gdk.CURRENT_TIME) + self.flash_status(f"Opening: {url[:60]}…", duration=2000) + except Exception as e: + log.error(f"Failed to open URL: {e}") + self.flash_status(f"Error opening URL: {e}") + + def show_item_preview(self): + """Shows the preview window for the selected item.""" + selected_row = self.list_box.get_selected_row() + if not selected_row: + return + + original_index = getattr(selected_row, "item_index", -1) + file_path_attr = getattr(selected_row, "file_path", None) + is_image = file_path_attr is not None and file_path_attr != "null" + + if original_index == -1: + log.error("Preview called on row with invalid item_index.") + self.flash_status("Error: Invalid selected item data.") + return + + try: + if not (0 <= original_index < len(self.items)): + log.error( + f"Item with original index {original_index} no longer exists for preview." + ) + self.flash_status("Error: Selected item no longer exists.") + return + + item = self.items[original_index] + + show_preview_window( + self.window, + item, + is_image, + self.change_preview_text_size, + self.reset_preview_text_size, + self.on_preview_key_press, + ) + except Exception as e: + log.error(f"Error creating preview window: {e}", exc_info=True) + self.flash_status(f"Error showing preview: {str(e)}") + + # --- Preview Window Callbacks --- + + def change_preview_text_size(self, text_view, delta): + """Callback to change font size in the preview TextView.""" + try: + pango_context = text_view.get_pango_context() + font_desc = pango_context.get_font_description() or Pango.FontDescription() + if ( + not hasattr(text_view, "base_font_size") + or text_view.base_font_size <= 0 + ): + base_size_pango = font_desc.get_size() + text_view.base_font_size = ( + (base_size_pango / Pango.SCALE) if base_size_pango > 0 else 10.0 + ) + current_size_pts = font_desc.get_size() / Pango.SCALE + if current_size_pts <= 0: + current_size_pts = text_view.base_font_size + new_size_pts = max(4.0, current_size_pts + delta) + font_desc.set_size(int(new_size_pts * Pango.SCALE)) + text_view.override_font(font_desc) + except Exception as e: + log.error(f"Error changing preview text size: {e}") + + def reset_preview_text_size(self, text_view): + """Callback to reset font size in the preview TextView.""" + try: + text_view.override_font(None) + pango_context = text_view.get_pango_context() + font_desc = pango_context.get_font_description() or Pango.FontDescription() + if hasattr(text_view, "base_font_size") and text_view.base_font_size > 0: + font_desc.set_size(int(text_view.base_font_size * Pango.SCALE)) + text_view.override_font(font_desc) + except Exception as e: + log.error(f"Error resetting preview text size: {e}") + + def on_help_window_close(self, window): + """Callback for when the help window is closed.""" + window.destroy() + if self.window: + self.window.present() + if self.list_box: + self.list_box.grab_focus() + else: + self.window.grab_focus() + + def on_settings_window_close(self, window): + """Callback for when the settings window is closed.""" + window.destroy() + if self.window: + self.window.present() + if self.list_box: + self.list_box.grab_focus() + else: + self.window.grab_focus() + + def on_preview_key_press(self, preview_window, event): + """Handles key presses within the preview window.""" + keyval = event.keyval + ctrl = event.state & Gdk.ModifierType.CONTROL_MASK + + if keyval == Gdk.KEY_Escape or (ctrl and keyval == Gdk.KEY_w): + preview_window.destroy() + if self.window: + self.window.present() + if self.list_box: + self.list_box.grab_focus() + return True + + def find_textview(widget): + if isinstance(widget, Gtk.TextView): + return widget + if hasattr(widget, "get_children"): + for child in widget.get_children(): + found = find_textview(child) + if found: + return found + if hasattr(widget, "get_child"): + child = widget.get_child() + if child: + return find_textview(child) + return None + + textview = find_textview(preview_window) + + if textview: + if ctrl and keyval == Gdk.KEY_f: + # Find search bar and toggle it + def find_search_bar(widget): + if isinstance(widget, Gtk.SearchBar): + return widget + if hasattr(widget, "get_children"): + for child in widget.get_children(): + result = find_search_bar(child) + if result: + return result + return None + + search_bar = find_search_bar(preview_window) + if search_bar: + # Find the search entry + def find_search_entry(widget): + if isinstance(widget, Gtk.SearchEntry): + return widget + if hasattr(widget, "get_children"): + for child in widget.get_children(): + result = find_search_entry(child) + if result: + return result + return None + + def find_buttons_and_label(widget): + """Find prev, next, close buttons and match label.""" + prev_btn = next_btn = close_btn = match_label = None + + def search_widget(w): + nonlocal prev_btn, next_btn, close_btn, match_label + if isinstance(w, Gtk.Button): + tooltip = w.get_tooltip_text() or "" + if "Previous" in tooltip: + prev_btn = w + elif "Next" in tooltip: + next_btn = w + elif "Close" in tooltip: + close_btn = w + elif isinstance(w, Gtk.Label): + text = w.get_text() + if "/" in text or text in ["0/0", ""]: + match_label = w + + if hasattr(w, "get_children"): + for child in w.get_children(): + search_widget(child) + + search_widget(widget) + return prev_btn, next_btn, close_btn, match_label + + search_entry = find_search_entry(search_bar) + if search_entry: + from ..ui_components import _toggle_search_bar + + # Find buttons and label + prev_btn, next_btn, close_btn, match_label = ( + find_buttons_and_label(preview_window) + ) + + _toggle_search_bar( + search_bar, + search_entry, + textview, + match_label, + prev_btn, + next_btn, + close_btn, + ) + return True + if ctrl and keyval == Gdk.KEY_b: + # Format text with Ctrl+B + from ..ui_components import _format_text_content + + _format_text_content(textview) + return True + if ctrl and keyval == Gdk.KEY_c: + buffer = textview.get_buffer() + clipboard = Gtk.Clipboard.get(Gdk.SELECTION_CLIPBOARD) + if buffer.get_has_selection(): + buffer.copy_clipboard(clipboard) + self.flash_status("Selection copied from preview", duration=1500) + else: + start, end = buffer.get_bounds() + buffer.select_range(start, end) + buffer.copy_clipboard(clipboard) + buffer.delete_selection(False, False) + self.flash_status("All text copied from preview", duration=1500) + return True + if ctrl and keyval in [Gdk.KEY_plus, Gdk.KEY_equal]: + self.change_preview_text_size(textview, 1.0) + return True + if ctrl and keyval == Gdk.KEY_minus: + self.change_preview_text_size(textview, -1.0) + return True + if ctrl and keyval == Gdk.KEY_0: + self.reset_preview_text_size(textview) + return True + return False diff --git a/clipse_gui/controller_mixins/scroll_mixin.py b/clipse_gui/controller_mixins/scroll_mixin.py new file mode 100644 index 0000000..4b19236 --- /dev/null +++ b/clipse_gui/controller_mixins/scroll_mixin.py @@ -0,0 +1,99 @@ +"""Scroll handling and lazy-loading row creation.""" + +import logging + +from gi.repository import GLib + +from ..constants import LOAD_BATCH_SIZE, LOAD_THRESHOLD_FACTOR + +log = logging.getLogger(__name__) + + +class ScrollMixin: + + def on_vadjustment_changed(self, adjustment): + """Callback when the scrollbar position changes, triggers lazy load if needed.""" + if self._loading_more: + return + current_value = adjustment.get_value() + upper = adjustment.get_upper() + page_size = adjustment.get_page_size() + if ( + upper > page_size + and current_value >= (upper - page_size) * LOAD_THRESHOLD_FACTOR + and len(self.list_box.get_children()) < len(self.filtered_items) + ): + self.check_load_more() + + def on_list_box_size_allocate(self, list_box, allocation): + """Callback when list box size changes, check if viewport needs filling.""" + GLib.idle_add(self.check_load_more) + + def check_load_more(self): + """Checks if more items should be loaded based on scroll position or viewport fill.""" + if self._loading_more: + return False + if not self.list_box.get_realized() or not self.vadj: + return False + + current_row_count = len(self.list_box.get_children()) + total_filtered_count = len(self.filtered_items) + + if current_row_count < total_filtered_count: + needs_load = False + upper = self.vadj.get_upper() or 0 + page_size = self.vadj.get_page_size() or 0 + threshold_factor = LOAD_THRESHOLD_FACTOR or 1.0 + + if upper <= page_size + 5: + needs_load = True + elif ( + upper > page_size + and self.vadj.get_value() >= (upper - page_size) * threshold_factor + ): + needs_load = True + + if needs_load: + self._loading_more = True + start_idx = current_row_count + end_idx = min(start_idx + (LOAD_BATCH_SIZE or 20), total_filtered_count) + log.debug(f"Scheduling load more: rows {start_idx} to {end_idx - 1}") + GLib.idle_add(self._do_load_more, start_idx, end_idx) + return False + + return False + + def _do_load_more(self, start_idx, end_idx): + """Performs the actual row creation for lazy loading.""" + log.debug(f"Executing load more: rows {start_idx} to {end_idx - 1}") + self._create_rows_range(start_idx, end_idx) + self.list_box.show_all() + self._loading_more = False + GLib.idle_add(self.check_load_more) + return False + + def scroll_to_bottom(self): + """Scrolls the list view to the bottom.""" + if not self.vadj: + return + + def _do_scroll(): + if self.vadj: + upper = self.vadj.get_upper() + page_size = self.vadj.get_page_size() + self.vadj.set_value(max(0, upper - page_size)) + return False + + GLib.idle_add(_do_scroll) + + def scroll_to_top(self): + """Scrolls the list view to the top.""" + if not self.vadj: + return + + def _do_scroll(): + if self.vadj: + self.vadj.set_value(self.vadj.get_lower()) + return False + + GLib.idle_add(_do_scroll) diff --git a/clipse_gui/controller_mixins/search_mixin.py b/clipse_gui/controller_mixins/search_mixin.py new file mode 100644 index 0000000..d11108a --- /dev/null +++ b/clipse_gui/controller_mixins/search_mixin.py @@ -0,0 +1,62 @@ +"""Search input handling, filtering, and pin-filter toggle.""" + +import logging + +from gi.repository import GLib + +from ..constants import SEARCH_DEBOUNCE_MS +from ..utils import fuzzy_search + +log = logging.getLogger(__name__) + + +class SearchMixin: + + def update_filtered_items(self): + """Filters master list based on search and pin status, then updates UI.""" + + self.filtered_items = fuzzy_search( + items=self.items, + search_term=self.search_term, + value_key="value", + path_key="filePath", + pinned_key="pinned", + show_only_pinned=self.show_only_pinned, + ) + self.populate_list_view() + self.update_status_label() + GLib.idle_add(self.check_load_more) + + def on_search_changed(self, entry): + """Handles changes in the search entry, debounced.""" + new_search_term = entry.get_text() + if new_search_term != self.search_term: + self.search_term = new_search_term + if self._search_timer_id: + GLib.source_remove(self._search_timer_id) + self._search_timer_id = GLib.timeout_add( + int(SEARCH_DEBOUNCE_MS or 250), self._trigger_filter_update + ) + + def _trigger_filter_update(self): + """Updates filtering after search debounce timeout.""" + log.debug(f"Triggering filter update for search: '{self.search_term}'") + self.update_filtered_items() + self._search_timer_id = None + return False + + def on_pin_filter_toggled(self, button): + """Handles toggling the 'Pinned Only' filter button.""" + is_active = button.get_active() + if is_active != self.show_only_pinned: + self.show_only_pinned = is_active + log.debug(f"Pin filter toggled: {'ON' if self.show_only_pinned else 'OFF'}") + self.update_filtered_items() + if len(self.list_box.get_children()) > 0: + GLib.idle_add(self._focus_first_item) + + def on_search_focus_out(self, entry, event): + """Handles when search entry loses focus.""" + if self.compact_mode: + self.search_entry.hide() + return False diff --git a/clipse_gui/controller_mixins/selection_mixin.py b/clipse_gui/controller_mixins/selection_mixin.py new file mode 100644 index 0000000..f71367c --- /dev/null +++ b/clipse_gui/controller_mixins/selection_mixin.py @@ -0,0 +1,94 @@ +"""Multi-select mode and per-item selection toggles.""" + +import logging + +log = logging.getLogger(__name__) + + +class SelectionMixin: + + def toggle_selection_mode(self): + """Toggles selection mode on/off.""" + self.selection_mode = not self.selection_mode + + if self.selection_mode: + # Entering selection mode + self.main_box.get_style_context().add_class("selection-mode") + # Show visual indicator + self.selection_mode_banner.show() + log.info("Entered selection mode") + self.flash_status("Selection mode: ON (Space to select, v to exit)") + else: + # Exiting selection mode - clear selections + self.deselect_all_items() + self.main_box.get_style_context().remove_class("selection-mode") + # Hide visual indicator + self.selection_mode_banner.hide() + log.info("Exited selection mode") + self.flash_status("Selection mode: OFF") + + self.update_status_label() + + def toggle_item_selection(self): + """Toggles the selection state of the currently focused item.""" + if not self.selection_mode: + log.warning("Cannot toggle item selection: not in selection mode") + return + + selected_row = self.list_box.get_selected_row() + if not selected_row or not hasattr(selected_row, "item_index"): + return + + original_index = selected_row.item_index + context = selected_row.get_style_context() + + if original_index in self.selected_indices: + # Deselect + self.selected_indices.remove(original_index) + context.remove_class("selected-row") + log.info( + f"Deselected item at index {original_index}, classes: {context.list_classes()}" + ) + else: + # Select + self.selected_indices.add(original_index) + context.add_class("selected-row") + log.info( + f"Selected item at index {original_index}, classes: {context.list_classes()}" + ) + + self.update_status_label() + + def select_all_items(self): + """Selects all currently visible items.""" + if not self.selection_mode: + # Auto-enter selection mode if not already in it + self.toggle_selection_mode() + + self.selected_indices.clear() + + for row in self.list_box.get_children(): + if hasattr(row, "item_index"): + original_index = row.item_index + self.selected_indices.add(original_index) + context = row.get_style_context() + context.add_class("selected-row") + + count = len(self.selected_indices) + log.info(f"Selected all {count} visible items") + self.flash_status(f"Selected {count} items") + self.update_status_label() + + def deselect_all_items(self): + """Clears all selections.""" + for row in self.list_box.get_children(): + if hasattr(row, "item_index"): + context = row.get_style_context() + context.remove_class("selected-row") + + count = len(self.selected_indices) + self.selected_indices.clear() + log.info(f"Deselected all items (was {count})") + if count > 0: + self.flash_status("All items deselected") + self.update_status_label() diff --git a/clipse_gui/controller_mixins/style_mixin.py b/clipse_gui/controller_mixins/style_mixin.py new file mode 100644 index 0000000..603d008 --- /dev/null +++ b/clipse_gui/controller_mixins/style_mixin.py @@ -0,0 +1,159 @@ +"""CSS, theming, zoom, compact mode, and hover-to-select styling.""" + +import logging + +from gi.repository import Gdk, GLib, Gtk + +from ..constants import ( + DEFAULT_WINDOW_HEIGHT, + DEFAULT_WINDOW_WIDTH, + HOVER_TO_SELECT, + get_app_css, +) + +log = logging.getLogger(__name__) + + +class StyleMixin: + + def _apply_css(self): + """Applies the application-wide CSS including current zoom level.""" + screen = Gdk.Screen.get_default() + if not screen: + log.error("Cannot get default GdkScreen to apply CSS") + return + + zoom_css = f"* {{ font-size: {round(self.zoom_level * 100)}%; }}".encode() + try: + if not hasattr(self, "style_provider"): + log.debug("Creating and adding application CSS provider.") + self.style_provider = Gtk.CssProvider() + self.style_provider.load_from_data( + self._get_current_css().encode() + b"\n" + zoom_css + ) + Gtk.StyleContext.add_provider_for_screen( + screen, + self.style_provider, + Gtk.STYLE_PROVIDER_PRIORITY_APPLICATION, + ) + else: + self.style_provider.load_from_data( + self._get_current_css().encode() + b"\n" + zoom_css + ) + except GLib.Error as e: + log.error(f"Failed to load CSS: {e}") + except Exception as e: + log.error(f"Unexpected error applying CSS: {e}") + + def _get_current_css(self): + """Get current CSS with applied style settings.""" + import clipse_gui.constants as constants + css = get_app_css( + border_radius=constants.BORDER_RADIUS, + accent_color=constants.ACCENT_COLOR, + selection_color=constants.SELECTION_COLOR, + visual_mode_color=constants.VISUAL_MODE_COLOR, + ) + log.debug(f"Generated CSS with border_radius={constants.BORDER_RADIUS}") + return css + + def update_style_css(self, border_radius=None, accent_color=None, + selection_color=None, visual_mode_color=None): + """Update CSS styles on-the-fly.""" + # Update global constants + import clipse_gui.constants as constants + + if border_radius is not None: + constants.BORDER_RADIUS = border_radius + if accent_color is not None: + constants.ACCENT_COLOR = accent_color + if selection_color is not None: + constants.SELECTION_COLOR = selection_color + if visual_mode_color is not None: + constants.VISUAL_MODE_COLOR = visual_mode_color + + # Regenerate and apply CSS + if hasattr(self, "style_provider"): + try: + css = self._get_current_css() + screen = Gdk.Screen.get_default() + + # Remove old provider + if screen: + Gtk.StyleContext.remove_provider_for_screen( + screen, self.style_provider + ) + + # Create new provider with updated CSS + self.style_provider = Gtk.CssProvider() + self.style_provider.load_from_data(css.encode()) + + if screen: + Gtk.StyleContext.add_provider_for_screen( + screen, + self.style_provider, + Gtk.STYLE_PROVIDER_PRIORITY_APPLICATION, + ) + + log.debug("CSS reloaded successfully") + + # Force window refresh + if self.window: + self.window.queue_draw() + self._invalidate_style_contexts(self.window) + except Exception as e: + log.error(f"Failed to update CSS: {e}") + + def _invalidate_style_contexts(self, widget): + """Recursively invalidate style contexts to force CSS reload.""" + if hasattr(widget, 'get_style_context'): + widget.get_style_context().invalidate() + if hasattr(widget, 'get_children'): + for child in widget.get_children(): + self._invalidate_style_contexts(child) + + def update_zoom(self): + """Applies the current zoom level to the application CSS.""" + self.zoom_level = max(0.5, min(self.zoom_level, 3.0)) + self._apply_css() + log.debug(f"Zoom updated to {self.zoom_level:.2f}") + + def update_compact_mode(self, skip_populate=False): + """Updates the UI based on compact mode state. + + Note: window resize only takes proper effect when this runs at startup + (before/just after first show). Live toggling at runtime requires the + Apply & Restart path in the settings dialog. + """ + pin_btn = getattr(self, "pin_filter_button", None) + + if self.compact_mode: + self.main_box.get_style_context().add_class("compact-mode") + target_w = int(DEFAULT_WINDOW_WIDTH * 0.6) + target_h = int(DEFAULT_WINDOW_HEIGHT * 0.6) + # set_no_show_all = persistent hide, survives show_all() calls + self.search_entry.set_no_show_all(True) + self.search_entry.hide() + if pin_btn: + pin_btn.set_no_show_all(True) + pin_btn.hide() + else: + self.main_box.get_style_context().remove_class("compact-mode") + target_w = DEFAULT_WINDOW_WIDTH + target_h = DEFAULT_WINDOW_HEIGHT + self.search_entry.set_no_show_all(False) + self.search_entry.show() + if pin_btn: + pin_btn.set_no_show_all(False) + pin_btn.show() + + self.window.resize(target_w, target_h) + + if not skip_populate: + self.populate_list_view() + + def update_hover_to_select(self): + """Updates hover-to-select setting and repopulates the list.""" + self.hover_to_select = HOVER_TO_SELECT + # Repopulate the list to apply hover-to-select to existing rows + self.populate_list_view() diff --git a/clipse_gui/ui/__init__.py b/clipse_gui/ui/__init__.py new file mode 100644 index 0000000..eb50199 --- /dev/null +++ b/clipse_gui/ui/__init__.py @@ -0,0 +1,12 @@ +"""Focused UI modules split from ui_components.py. + +Import the specific submodule you need: + from .ui.icons import create_pin_icon + from .ui.text import highlight_search_term + from .ui.list_row import create_list_row_widget + from .ui.help import show_help_window + from .ui.settings import show_settings_window + from .ui.preview import show_preview_window + +The legacy `clipse_gui.ui_components` module re-exports everything for back-compat. +""" diff --git a/clipse_gui/ui/detection.py b/clipse_gui/ui/detection.py new file mode 100644 index 0000000..12297a1 --- /dev/null +++ b/clipse_gui/ui/detection.py @@ -0,0 +1,37 @@ +"""Content type detectors for clipboard text values (URLs, images, SVG, data URIs).""" + +_IMAGE_EXTENSIONS = (".jpg", ".jpeg", ".png", ".gif", ".webp", ".svg", ".bmp", ".ico", ".tiff", ".tif") + + +def _is_image_url(text): + """True if text is an http(s) URL pointing to a recognised image extension.""" + if not text or not isinstance(text, str): + return False + t = text.strip().lower() + if not (t.startswith("http://") or t.startswith("https://")): + return False + return any(t.split("?")[0].endswith(ext) for ext in _IMAGE_EXTENSIONS) + + +def _is_svg_content(text): + """True if text appears to be inline SVG markup.""" + if not text or not isinstance(text, str): + return False + t = text.strip() + return t.startswith("Keyboard Shortcuts") + header.set_halign(Gtk.Align.CENTER) + header.set_margin_bottom(10) + main_box.pack_start(header, False, False, 0) + + # Scrolled window for content + scrolled = Gtk.ScrolledWindow() + scrolled.set_policy(Gtk.PolicyType.NEVER, Gtk.PolicyType.AUTOMATIC) + + # Main grid for table-like layout + main_grid = Gtk.Grid() + main_grid.set_column_spacing(30) + main_grid.set_row_spacing(4) + main_grid.set_margin_start(25) + main_grid.set_margin_end(25) + main_grid.set_margin_top(15) + main_grid.set_margin_bottom(15) + + # Define all shortcuts in order with section headers + shortcuts_data = [ + # Navigation + ("NAVIGATION", None, True), + ("Slash / f", "Focus search field", False), + ("↑ / k", "Navigate up", False), + ("↓ / j", "Navigate down", False), + ("PgUp", "Scroll page up", False), + ("PgDn", "Scroll page down", False), + ("Home", "Go to top", False), + ("End", "Go to bottom (of loaded items)", False), + ("Tab", "Toggle 'Pinned Only' filter", False), + ("", None, False), # Spacer + # Actions + ("ACTIONS", None, True), + ("Enter", "Copy selected item to clipboard", False), + ("Shift+Enter", "Copy & paste selected item", False), + ("Space", "Show full preview", False), + ("p", "Toggle pin status", False), + ("x / Del", "Delete selected item", False), + ("", None, False), # Spacer + # Multi-Select Mode + ("MULTI-SELECT MODE", None, True), + ("v", "Toggle selection mode", False), + ("Space", "Toggle item selection (in selection mode)", False), + ("Ctrl+A", "Select all visible items", False), + ("Ctrl+Shift+A", "Deselect all items", False), + ("Ctrl+X / Shift+Del", "Delete selected items", False), + ("Ctrl+Shift+Del / Ctrl+D", "Clear all non-pinned items", False), + ("", None, False), # Spacer + # View + ("VIEW", None, True), + ("Ctrl +", "Zoom in", False), + ("Ctrl -", "Zoom out", False), + ("Ctrl 0", "Reset zoom", False), + ("", None, False), # Spacer + # Preview Window + ("PREVIEW WINDOW", None, True), + ("Ctrl+F", "Find text in preview", False), + ("Ctrl+B", "Format text (pretty-print JSON)", False), + ("Ctrl+C", "Copy text from preview", False), + ("", None, False), # Spacer + # General + ("GENERAL", None, True), + ("?", "Show this help window", False), + ("Ctrl+,", "Open settings", False), + ("Esc", "Clear search / Close window / Exit mode", False), + ("Ctrl+Q", "Quit application", False), + ] + + row = 0 + for key, desc, is_header in shortcuts_data: + if is_header: + # Section header + header_label = Gtk.Label() + header_label.set_markup( + f"{key}" + ) + header_label.set_halign(Gtk.Align.START) + header_label.set_margin_top(10 if row > 0 else 0) + header_label.set_margin_bottom(8) + main_grid.attach(header_label, 0, row, 2, 1) + row += 1 + elif key == "": + # Spacer row + spacer = Gtk.Label(label="") + spacer.set_size_request(-1, 10) + main_grid.attach(spacer, 0, row, 2, 1) + row += 1 + else: + # Regular shortcut row + key_label = Gtk.Label(label=key) + key_label.set_halign(Gtk.Align.START) + key_label.set_margin_end(25) + key_label.get_style_context().add_class("key-shortcut") + + desc_label = Gtk.Label(label=desc) + desc_label.set_halign(Gtk.Align.START) + desc_label.set_line_wrap(False) + desc_label.set_xalign(0) + + main_grid.attach(key_label, 0, row, 1, 1) + main_grid.attach(desc_label, 1, row, 1, 1) + row += 1 + + scrolled.add(main_grid) + main_box.pack_start(scrolled, True, True, 0) + + # Close button + close_btn = Gtk.Button(label="Close") + close_btn.set_margin_top(10) + close_btn.connect("clicked", lambda b: help_window.destroy()) + main_box.pack_end(close_btn, False, False, 0) + + help_window.add(main_box) + help_window.connect( + "key-press-event", + lambda w, e: close_cb(w) if e.keyval == Gdk.KEY_Escape else None, + ) + help_window.show_all() + close_btn.grab_focus() diff --git a/clipse_gui/ui/icons.py b/clipse_gui/ui/icons.py new file mode 100644 index 0000000..189080d --- /dev/null +++ b/clipse_gui/ui/icons.py @@ -0,0 +1,82 @@ +"""Pin icon creation and shake animation.""" + +import logging + +from gi.repository import GdkPixbuf, GLib, Gtk + +log = logging.getLogger(__name__) + +# SVG icon data for pushpin (rotated 25 degrees to the right for a natural look) +PIN_SVG_BASE = """ + + + + + +""" + + +def create_pin_icon(is_pinned, angle=25): + """Creates a pin icon from SVG data with color based on pinned state.""" + try: + # Replace currentColor with actual color + color = "#ffcc00" if is_pinned else "rgba(255,255,255,0.25)" + svg_data = PIN_SVG_BASE.replace("currentColor", color).replace( + "{angle}", str(angle) + ) + + # Load SVG into pixbuf + loader = GdkPixbuf.PixbufLoader.new_with_type("svg") + loader.write(svg_data.encode("utf-8")) + loader.close() + pixbuf = loader.get_pixbuf() + + # Create image from pixbuf + image = Gtk.Image.new_from_pixbuf(pixbuf) + image.get_style_context().add_class("pin-icon") + if is_pinned: + image.get_style_context().add_class("pinned") + else: + image.get_style_context().add_class("unpinned") + + return image + except Exception as e: + log.error(f"Error creating pin icon: {e}") + # Fallback to label + label = Gtk.Label(label="📌") + return label + + +def animate_pin_shake(container, is_pinned): + """Animates a gentle rotation wiggle effect by recreating the icon at different angles.""" + # Gentle rotation sequence: base angle ± small rotations + base_angle = 25 + rotation_sequence = [ + base_angle + 8, # Rotate right + base_angle - 8, # Rotate left + base_angle + 5, # Rotate right (less) + base_angle - 5, # Rotate left (less) + base_angle, # Back to normal + ] + + def apply_wiggle(index): + if index < len(rotation_sequence): + # Remove old icon + children = container.get_children() + if children: + old_icon = children[-1] + container.remove(old_icon) + + # Create new icon with rotated angle + new_icon = create_pin_icon(is_pinned, rotation_sequence[index]) + new_icon.set_tooltip_text("Pinned" if is_pinned else "Not Pinned") + new_icon.set_valign(Gtk.Align.START) # Keep top alignment + new_icon.set_margin_top(2) # Keep top margin + new_icon.show() + container.pack_end(new_icon, False, False, 0) + + GLib.timeout_add(70, apply_wiggle, index + 1) + return False + + apply_wiggle(0) diff --git a/clipse_gui/ui/list_row.py b/clipse_gui/ui/list_row.py new file mode 100644 index 0000000..513d1d6 --- /dev/null +++ b/clipse_gui/ui/list_row.py @@ -0,0 +1,287 @@ +"""List row widget creation for clipboard items (text, image, URL, SVG, data URI).""" + +import os + +from gi.repository import Gdk, Gtk, Pango + +from ..constants import ( + LIST_ITEM_IMAGE_HEIGHT, + LIST_ITEM_IMAGE_WIDTH, + PREVIEW_RICH_CONTENT, +) +from ..utils import format_date +from .detection import _is_data_uri, _is_image_url, _is_svg_content, _is_url +from .icons import create_pin_icon +from .text import highlight_search_term + + +def create_list_row_widget( + item_info, + image_handler, + update_image_callback, + compact_mode=False, + hover_to_select=False, + single_click_callback=None, + search_term="", + highlight_search=False, +): + """Creates a Gtk.ListBoxRow widget for a clipboard item.""" + original_index = item_info["original_index"] + item = item_info["item"] + filtered_index = item_info["filtered_index"] + row = Gtk.ListBoxRow() + row.item_index = original_index + row.filtered_index = filtered_index + row.item_value = item.get("value", "") + row.item_pinned = item.get("pinned", False) + row.file_path = item.get("filePath", "") + row.is_image = item.get("filePath") not in [None, "null", ""] + + # Detect special content types for text items + text_value = item.get("value", "") + row.is_url_image = False + row.is_svg_content = False + row.is_data_uri = False + row.is_url = False + row.image_url = None + row.website_url = None + if not row.is_image: + if _is_image_url(text_value): + row.is_url_image = True + row.image_url = text_value.strip() + elif _is_svg_content(text_value): + row.is_svg_content = True + elif _is_data_uri(text_value): + row.is_data_uri = True + elif _is_url(text_value): + row.is_url = True + row.website_url = text_value.strip() + + style_context = row.get_style_context() + if row.item_pinned: + style_context.add_class("pinned-row") + style_context.add_class("list-row") + + # Use the passed compact mode parameter + is_compact = compact_mode + + # Adjust sizes based on compact mode + if is_compact: + row.set_size_request(-1, 28) # Compact but readable height + vbox = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=0) + vbox.set_margin_top(1) + vbox.set_margin_bottom(1) + vbox.set_margin_start(1) + vbox.set_margin_end(1) + else: + row.set_size_request(-1, 35) # Reduced default height + vbox = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=2) + vbox.set_margin_top(2) + vbox.set_margin_bottom(2) + vbox.set_margin_start(3) + vbox.set_margin_end(3) + + vbox.set_homogeneous(False) + vbox.set_property("expand", False) + + hbox = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=5) + content_box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=1) + + if row.is_image: + image_path = item.get("filePath") + image_container = Gtk.Frame() + # Adjust image size based on compact mode + if is_compact: + image_container.set_size_request( + int(LIST_ITEM_IMAGE_WIDTH * 0.3), int(LIST_ITEM_IMAGE_HEIGHT * 0.3) + ) + else: + image_container.set_size_request( + int(LIST_ITEM_IMAGE_WIDTH * 0.8), int(LIST_ITEM_IMAGE_HEIGHT * 0.8) + ) + image_container.set_shadow_type(Gtk.ShadowType.NONE) + placeholder = Gtk.Label(label="[Loading image...]") + placeholder.set_halign(Gtk.Align.CENTER) + placeholder.set_valign(Gtk.Align.CENTER) + image_container.add(placeholder) + content_box.pack_start(image_container, False, False, 0) + + # Request image loading via the handler + image_handler.load_image_async( + image_path, + image_container, + placeholder, + LIST_ITEM_IMAGE_WIDTH, + LIST_ITEM_IMAGE_HEIGHT, + update_image_callback, + ) + + title_label = Gtk.Label(label=os.path.basename(item.get("value", "Image"))) + title_label.set_ellipsize(Pango.EllipsizeMode.MIDDLE) + title_label.set_max_width_chars(20) # Reduced from 25 + title_label.set_halign(Gtk.Align.START) + content_box.pack_start(title_label, False, False, 0) + elif (row.is_url_image or row.is_data_uri) and PREVIEW_RICH_CONTENT: + # Remote image URL or base64 data URI — show thumbnail + thumb_w = int(LIST_ITEM_IMAGE_WIDTH * (0.3 if is_compact else 0.8)) + thumb_h = int(LIST_ITEM_IMAGE_HEIGHT * (0.3 if is_compact else 0.8)) + image_container = Gtk.Frame() + image_container.set_shadow_type(Gtk.ShadowType.NONE) + image_container.set_size_request(thumb_w, thumb_h) + placeholder = Gtk.Label(label="…") + placeholder.set_halign(Gtk.Align.CENTER) + placeholder.set_valign(Gtk.Align.CENTER) + image_container.add(placeholder) + content_box.pack_start(image_container, False, False, 0) + + if row.is_data_uri: + image_handler.load_data_uri_async( + text_value.strip(), image_container, placeholder, + LIST_ITEM_IMAGE_WIDTH, LIST_ITEM_IMAGE_HEIGHT, update_image_callback, + ) + else: + image_handler.load_remote_image_async( + row.image_url, image_container, placeholder, + LIST_ITEM_IMAGE_WIDTH, LIST_ITEM_IMAGE_HEIGHT, update_image_callback, + ) + + badge = Gtk.Label(label="[image url]" if row.is_url_image else "[base64]") + badge.get_style_context().add_class("url-badge") + badge.set_halign(Gtk.Align.START) + content_box.pack_start(badge, False, False, 0) + elif row.is_svg_content and PREVIEW_RICH_CONTENT: + # Inline SVG — render as thumbnail + thumb_w = int(LIST_ITEM_IMAGE_WIDTH * (0.3 if is_compact else 0.8)) + thumb_h = int(LIST_ITEM_IMAGE_HEIGHT * (0.3 if is_compact else 0.8)) + image_container = Gtk.Frame() + image_container.set_shadow_type(Gtk.ShadowType.NONE) + image_container.set_size_request(thumb_w, thumb_h) + placeholder = Gtk.Label(label="…") + placeholder.set_halign(Gtk.Align.CENTER) + placeholder.set_valign(Gtk.Align.CENTER) + image_container.add(placeholder) + content_box.pack_start(image_container, False, False, 0) + + image_handler.load_svg_async( + text_value.strip(), image_container, placeholder, + LIST_ITEM_IMAGE_WIDTH, LIST_ITEM_IMAGE_HEIGHT, update_image_callback, + ) + + badge = Gtk.Label(label="[svg]") + badge.get_style_context().add_class("url-badge") + badge.set_halign(Gtk.Align.START) + content_box.pack_start(badge, False, False, 0) + elif row.is_url: + # Regular URL — link icon + URL text + row_box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=4) + icon = Gtk.Image.new_from_icon_name("external-link-symbolic", Gtk.IconSize.MENU) + icon.get_style_context().add_class("url-link") + row_box.pack_start(icon, False, False, 0) + max_chars = 50 if is_compact else 80 + display_url = text_value.strip() + if len(display_url) > max_chars: + display_url = display_url[:max_chars - 1] + "…" + lbl = Gtk.Label(label=display_url) + lbl.set_xalign(0) + lbl.set_ellipsize(Pango.EllipsizeMode.END) + lbl.get_style_context().add_class("url-link") + row_box.pack_start(lbl, True, True, 0) + content_box.pack_start(row_box, False, False, 0) + else: + # Limit to 1 line in compact mode, 3 lines otherwise + max_lines = 1 if is_compact else 3 + display_text = "\n".join(text_value.splitlines()[:max_lines]) + if len(text_value.splitlines()) > max_lines or len(display_text) > ( + 80 if is_compact else 150 + ): + cutoff = 80 if is_compact else 150 + last_space = display_text[:cutoff].rfind(" ") + if last_space > cutoff * 0.8: + cutoff = last_space + display_text = display_text[:cutoff] + "..." + + label = Gtk.Label() + # Apply search highlighting if enabled + if highlight_search and search_term: + marked_up = highlight_search_term(display_text, search_term) + label.set_markup(marked_up) + else: + label.set_text(display_text) + + label.set_line_wrap(True) + label.set_line_wrap_mode(Pango.WrapMode.WORD) + label.set_xalign(0) + label.set_max_width_chars( + 35 if is_compact else 50 + ) # Reduced width in compact mode + label.set_ellipsize(Pango.EllipsizeMode.END) + + # Adjust label size based on compact mode + if is_compact: + label.set_size_request(-1, 22) # Compact but readable height + else: + label.set_size_request(-1, 30) + + content_box.pack_start(label, False, False, 0) + + # Ensure content box doesn't expand + content_box.set_property("expand", False) + + hbox.pack_start(content_box, False, True, 0) + + # Use custom SVG pin icon + pin_icon = create_pin_icon(row.item_pinned) + pin_icon.set_tooltip_text("Pinned" if row.item_pinned else "Not Pinned") + pin_icon.set_valign(Gtk.Align.START) # Align to top + pin_icon.set_margin_top(2) # Small margin from the very top + hbox.pack_end(pin_icon, False, False, 0) + + vbox.pack_start(hbox, False, False, 0) + + timestamp = format_date(item.get("recorded", "")) + time_label = Gtk.Label(label=timestamp) + time_label.set_halign(Gtk.Align.START) + time_label.get_style_context().add_class("timestamp") + vbox.pack_start(time_label, False, False, 0) + + row.add(vbox) + + # Add hover-to-select functionality if enabled + if hover_to_select: + # Add an EventBox to capture mouse events reliably + event_box = Gtk.EventBox() + event_box.set_events(Gdk.EventMask.ENTER_NOTIFY_MASK) + event_box.set_visible_window(False) # Make it transparent + + # Move the vbox content into the event box + row.remove(vbox) + event_box.add(vbox) + row.add(event_box) + + def on_enter_notify(widget, event): + # Get the ListBoxRow parent + listbox_row = widget.get_parent() # EventBox -> ListBoxRow + if listbox_row and isinstance(listbox_row, Gtk.ListBoxRow): + listbox = listbox_row.get_parent() # ListBoxRow -> ListBox + if listbox and hasattr(listbox, "select_row"): + listbox.select_row(listbox_row) + return False + + event_box.connect("enter-notify-event", on_enter_notify) + + # Add single-click support if callback provided + if single_click_callback: + + def on_button_press(widget, event): + # Single-click (left button) triggers paste + if event.button == 1: # Left mouse button + # Check if it's a single click (not double-click) + # Double-click is handled by row-activated signal + if event.type == Gdk.EventType.BUTTON_PRESS: + single_click_callback(row) + return True # Stop propagation + return False + + row.connect("button-press-event", on_button_press) + + return row diff --git a/clipse_gui/ui/preview.py b/clipse_gui/ui/preview.py new file mode 100644 index 0000000..64debe6 --- /dev/null +++ b/clipse_gui/ui/preview.py @@ -0,0 +1,480 @@ +"""Preview window for clipboard items and in-preview search bar.""" + +import os + +from gi.repository import Gdk, GdkPixbuf, GLib, Gtk, Pango + +from ..constants import ( + DEFAULT_PREVIEW_IMG_HEIGHT, + DEFAULT_PREVIEW_IMG_WIDTH, + DEFAULT_PREVIEW_TEXT_HEIGHT, + DEFAULT_PREVIEW_TEXT_WIDTH, +) +from .text import _format_text_content + + +def show_preview_window( + parent_window, item, is_image, change_text_size_cb, reset_text_size_cb, key_press_cb +): + """Creates and shows the item preview window.""" + preview_window = Gtk.Window(title="Preview") + preview_window.set_position(Gtk.WindowPosition.CENTER_ON_PARENT) + preview_window.set_transient_for(parent_window) + preview_window.set_modal(True) + preview_window.connect("key-press-event", key_press_cb) + + vbox = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=5) + vbox.set_border_width(5) + + if is_image: + image_path = item.get("filePath") + if image_path and os.path.exists(image_path): + try: + pixbuf = GdkPixbuf.Pixbuf.new_from_file(image_path) + if pixbuf is None: + raise GLib.Error( + GLib.ErrorDomain.G_FILE, + GLib.ErrorEnum.INVALID_ARGUMENT, + ) + + image = Gtk.Image.new_from_pixbuf(pixbuf) + image.set_halign(Gtk.Align.CENTER) + image.set_valign(Gtk.Align.CENTER) + + display = parent_window.get_display() + # Get GDK window from GTK window + gdk_window = parent_window.get_window() + monitor = ( + display.get_monitor_at_window(gdk_window) if gdk_window else None + ) + + if monitor: + geometry = monitor.get_geometry() + max_w = geometry.width * 0.8 + max_h = geometry.height * 0.8 + else: + max_w = 1200 + max_h = 800 + + img_w = pixbuf.get_width() + img_h = pixbuf.get_height() + # Calculate scaling while maintaining aspect ratio + if img_w > max_w or img_h > max_h: + scale = min(max_w / img_w, max_h / img_h) + w = int(img_w * scale) + h = int(img_h * scale) + else: + w = img_w + h = img_h + + # Create scaled pixbuf + scaled_pixbuf = pixbuf.scale_simple(w, h, GdkPixbuf.InterpType.BILINEAR) + image.set_from_pixbuf(scaled_pixbuf) + + preview_window.set_default_size(w, h) + scrolled = Gtk.ScrolledWindow() + scrolled.set_policy(Gtk.PolicyType.AUTOMATIC, Gtk.PolicyType.AUTOMATIC) + scrolled.add(image) + vbox.pack_start(scrolled, True, True, 0) + + except GLib.Error as e: + label = Gtk.Label(label=f"Error loading image preview:\n{e.message}") + label.set_line_wrap(True) + label.set_halign(Gtk.Align.CENTER) + label.set_valign(Gtk.Align.CENTER) + vbox.pack_start(label, True, True, 0) + preview_window.set_default_size( + DEFAULT_PREVIEW_IMG_WIDTH, DEFAULT_PREVIEW_IMG_HEIGHT + ) + else: # Text Preview + text_value = item.get("value", "") + scrolled_window = Gtk.ScrolledWindow() + scrolled_window.set_policy(Gtk.PolicyType.AUTOMATIC, Gtk.PolicyType.AUTOMATIC) + scrolled_window.set_hexpand(True) + scrolled_window.set_vexpand(True) + + preview_text_view = Gtk.TextView() + preview_text_view.get_buffer().set_text(text_value) + preview_text_view.set_editable(False) + preview_text_view.set_wrap_mode(Gtk.WrapMode.WORD_CHAR) + context = preview_text_view.get_style_context() + provider = Gtk.CssProvider() + provider.load_from_data(b"textview { font-family: Monospace; }") + context.add_provider(provider, Gtk.STYLE_PROVIDER_PRIORITY_USER) + + pango_context = preview_text_view.get_pango_context() + if pango_context: + font_desc = pango_context.get_font_description() + if font_desc: + base_font_size = font_desc.get_size() / Pango.SCALE + if base_font_size <= 0: + base_font_size = 10 + else: + base_font_size = 10 + else: + base_font_size = 10 + preview_text_view.base_font_size = base_font_size + + scrolled_window.add(preview_text_view) + vbox.pack_start(scrolled_window, True, True, 0) + + action_box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=5) + action_box.set_halign(Gtk.Align.CENTER) + + # Format button + format_btn = Gtk.Button() + format_btn.set_image( + Gtk.Image.new_from_icon_name( + "format-text-bold-symbolic", + Gtk.IconSize.BUTTON, # type: ignore + ) + ) + format_btn.set_tooltip_text("Format text (pretty-print JSON) - Ctrl+B") + format_btn.connect("clicked", lambda b: _format_text_content(preview_text_view)) + + # Find button + find_btn = Gtk.Button() + find_btn.set_image( + Gtk.Image.new_from_icon_name("edit-find-symbolic", Gtk.IconSize.BUTTON) # type: ignore + ) + find_btn.set_tooltip_text("Find text (Ctrl+F)") + + # Create enhanced search bar (initially hidden) + search_bar = Gtk.SearchBar() + + # Create search container with entry and buttons + search_container = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=5) + search_container.set_margin_left(5) + search_container.set_margin_right(5) + search_container.set_margin_top(3) + search_container.set_margin_bottom(3) + + # Search entry + search_entry = Gtk.SearchEntry() + search_entry.set_placeholder_text("Search text...") + search_entry.set_hexpand(True) + search_container.pack_start(search_entry, True, True, 0) + + # Match counter label + match_label = Gtk.Label() + match_label.set_text("0/0") + match_label.set_margin_left(5) + match_label.set_margin_right(5) + search_container.pack_start(match_label, False, False, 0) + + # Previous button + prev_btn = Gtk.Button() + prev_btn.set_image( + Gtk.Image.new_from_icon_name("go-up-symbolic", Gtk.IconSize.BUTTON) # type: ignore + ) + prev_btn.set_tooltip_text("Previous match (Shift+Enter)") + search_container.pack_start(prev_btn, False, False, 0) + + # Next button + next_btn = Gtk.Button() + next_btn.set_image( + Gtk.Image.new_from_icon_name("go-down-symbolic", Gtk.IconSize.BUTTON) # type: ignore + ) + next_btn.set_tooltip_text("Next match (Enter)") + search_container.pack_start(next_btn, False, False, 0) + + # Close button + close_btn = Gtk.Button() + close_btn.set_image( + Gtk.Image.new_from_icon_name("window-close-symbolic", Gtk.IconSize.BUTTON) # type: ignore + ) + close_btn.set_tooltip_text("Close search (Escape)") + search_container.pack_start(close_btn, False, False, 0) + + search_bar.add(search_container) + search_bar.connect_entry(search_entry) + + # Insert search bar after scrolled window + vbox.pack_start(search_bar, False, False, 0) + vbox.reorder_child(search_bar, 1) # Place after scrolled window + + find_btn.connect( + "clicked", + lambda b: _toggle_search_bar( + search_bar, + search_entry, + preview_text_view, + match_label, + prev_btn, + next_btn, + close_btn, + ), + ) + + # Zoom controls + zoom_out = Gtk.Button() + zoom_out.set_image( + Gtk.Image.new_from_icon_name("zoom-out-symbolic", Gtk.IconSize.BUTTON) # type: ignore + ) + zoom_out.connect( + "clicked", lambda b: change_text_size_cb(preview_text_view, -1) + ) + zoom_in = Gtk.Button() + zoom_in.set_image( + Gtk.Image.new_from_icon_name("zoom-in-symbolic", Gtk.IconSize.BUTTON) # type: ignore + ) + zoom_in.connect("clicked", lambda b: change_text_size_cb(preview_text_view, 1)) + zoom_reset = Gtk.Button() + zoom_reset.set_image( + Gtk.Image.new_from_icon_name("zoom-original-symbolic", Gtk.IconSize.BUTTON) # type: ignore + ) + zoom_reset.connect("clicked", lambda b: reset_text_size_cb(preview_text_view)) + + action_box.pack_start(format_btn, False, False, 0) + action_box.pack_start(find_btn, False, False, 0) + action_box.pack_start( + Gtk.Separator(orientation=Gtk.Orientation.VERTICAL), False, False, 5 + ) + action_box.pack_start(Gtk.Label(label="Zoom:"), False, False, 0) + action_box.pack_start(zoom_out, False, False, 0) + action_box.pack_start(zoom_reset, False, False, 0) + action_box.pack_start(zoom_in, False, False, 0) + vbox.pack_end(action_box, False, False, 5) + + preview_window.set_default_size( + DEFAULT_PREVIEW_TEXT_WIDTH, DEFAULT_PREVIEW_TEXT_HEIGHT + ) + + preview_window.add(vbox) + preview_window.show_all() + + +def _toggle_search_bar( + search_bar, + search_entry, + text_view, + match_label=None, + prev_btn=None, + next_btn=None, + close_btn=None, +): + """Toggles the enhanced search bar visibility and handles search functionality.""" + if search_bar.get_search_mode(): + # Hide search bar and clear highlights + search_bar.set_search_mode(False) + buffer = text_view.get_buffer() + start, end = buffer.get_bounds() + buffer.remove_all_tags(start, end) + else: + # Show search bar and focus entry + search_bar.set_search_mode(True) + search_entry.grab_focus() + + # Set up enhanced search functionality if not already done + if not hasattr(search_entry, "_search_setup"): + search_entry._search_setup = True + search_entry._search_state = { + "last_search": "", + "matches": [], + "current_index": 0, + "highlight_tag": None, + "current_tag": None, + } + + def update_match_label(): + """Update the match counter label.""" + if match_label: + matches = search_entry._search_state["matches"] + if matches: + current = search_entry._search_state["current_index"] + 1 + total = len(matches) + match_label.set_text(f"{current}/{total}") + else: + match_label.set_text("0/0") + + def perform_search(): + """Perform search and highlight all matches.""" + search_text = search_entry.get_text() + buffer = text_view.get_buffer() + start, end = buffer.get_bounds() + + # Clear previous highlights + buffer.remove_all_tags(start, end) + + if not search_text: + search_entry._search_state["matches"] = [] + update_match_label() + return + + content = buffer.get_text(start, end, False) + + # Find all matches (case insensitive) + matches = [] + search_lower = search_text.lower() + content_lower = content.lower() + start_pos = 0 + + while True: + pos = content_lower.find(search_lower, start_pos) + if pos == -1: + break + matches.append(pos) + start_pos = pos + 1 + + search_entry._search_state["matches"] = matches + + if matches: + # Create highlight tags with different colors + if search_entry._search_state["highlight_tag"]: + buffer.get_tag_table().remove( + search_entry._search_state["highlight_tag"] + ) + if search_entry._search_state["current_tag"]: + buffer.get_tag_table().remove( + search_entry._search_state["current_tag"] + ) + + # All matches - light blue background + highlight_tag = buffer.create_tag( + None, background="#87CEEB", foreground="black" + ) + search_entry._search_state["highlight_tag"] = highlight_tag + + # Current match - orange background + current_tag = buffer.create_tag( + None, background="#FFA500", foreground="black" + ) + search_entry._search_state["current_tag"] = current_tag + + # Highlight all matches + for match_pos in matches: + start_iter = buffer.get_iter_at_offset(match_pos) + end_iter = buffer.get_iter_at_offset( + match_pos + len(search_text) + ) + buffer.apply_tag(highlight_tag, start_iter, end_iter) + + # Jump to first match if this is a new search + if search_entry._search_state["last_search"] != search_text: + search_entry._search_state["current_index"] = 0 + search_entry._search_state["last_search"] = search_text + + highlight_current_match() + + update_match_label() + + def highlight_current_match(): + """Highlight the current match with a different color.""" + matches = search_entry._search_state["matches"] + if not matches: + return + + current_index = search_entry._search_state["current_index"] + current_match_pos = matches[current_index] + search_text = search_entry.get_text() + + # Remove current highlight from all matches + buffer = text_view.get_buffer() + start, end = buffer.get_bounds() + if search_entry._search_state["current_tag"]: + buffer.remove_tag( + search_entry._search_state["current_tag"], start, end + ) + + # Highlight current match + start_iter = buffer.get_iter_at_offset(current_match_pos) + end_iter = buffer.get_iter_at_offset( + current_match_pos + len(search_text) + ) + buffer.apply_tag( + search_entry._search_state["current_tag"], start_iter, end_iter + ) + + # Scroll to current match + text_view.scroll_to_iter(start_iter, 0.0, False, 0.0, 0.0) + buffer.place_cursor(start_iter) + + update_match_label() + + def find_next(): + """Go to next match.""" + matches = search_entry._search_state["matches"] + if matches: + search_entry._search_state["current_index"] = ( + search_entry._search_state["current_index"] + 1 + ) % len(matches) + highlight_current_match() + + def find_previous(): + """Go to previous match.""" + matches = search_entry._search_state["matches"] + if matches: + search_entry._search_state["current_index"] = ( + search_entry._search_state["current_index"] - 1 + ) % len(matches) + highlight_current_match() + + def close_search(): + """Close the search bar.""" + search_bar.set_search_mode(False) + buffer = text_view.get_buffer() + start, end = buffer.get_bounds() + buffer.remove_all_tags(start, end) + + # Store functions on search_entry for access from button callbacks + search_entry._find_next = find_next + search_entry._find_previous = find_previous + search_entry._close_search = close_search + search_entry._perform_search = perform_search + + # Connect signals + search_entry.connect("search-changed", lambda e: perform_search()) + search_entry.connect("activate", lambda e: find_next()) + + # Handle keyboard shortcuts + def on_key_press(widget, event): + if event.keyval == Gdk.KEY_Escape: + close_search() + return True + elif ( + event.state & Gdk.ModifierType.SHIFT_MASK + and event.keyval == Gdk.KEY_Return + ): + find_previous() + return True + return False + + search_entry.connect("key-press-event", on_key_press) + + # Connect button signals every time (in case buttons were recreated) + if next_btn and hasattr(search_entry, "_find_next"): + # Disconnect all existing handlers to avoid duplicates + try: + if hasattr(next_btn, "_search_handler_ids"): + for handler_id in next_btn._search_handler_ids: + next_btn.disconnect(handler_id) + except Exception: + pass + handler_id = next_btn.connect( + "clicked", lambda b: search_entry._find_next() + ) + next_btn._search_handler_ids = [handler_id] + + if prev_btn and hasattr(search_entry, "_find_previous"): + try: + if hasattr(prev_btn, "_search_handler_ids"): + for handler_id in prev_btn._search_handler_ids: + prev_btn.disconnect(handler_id) + except Exception: + pass + handler_id = prev_btn.connect( + "clicked", lambda b: search_entry._find_previous() + ) + prev_btn._search_handler_ids = [handler_id] + + if close_btn and hasattr(search_entry, "_close_search"): + try: + if hasattr(close_btn, "_search_handler_ids"): + for handler_id in close_btn._search_handler_ids: + close_btn.disconnect(handler_id) + except Exception: + pass + handler_id = close_btn.connect( + "clicked", lambda b: search_entry._close_search() + ) + close_btn._search_handler_ids = [handler_id] diff --git a/clipse_gui/ui/settings.py b/clipse_gui/ui/settings.py new file mode 100644 index 0000000..35e9426 --- /dev/null +++ b/clipse_gui/ui/settings.py @@ -0,0 +1,608 @@ +"""Settings window with General and Style tabs.""" + +import logging + +from gi.repository import Gdk, Gtk + +from ..constants import ( + ACCENT_COLOR, + BORDER_RADIUS, + COMPACT_MODE, + ENTER_TO_PASTE, + HIGHLIGHT_SEARCH, + HOVER_TO_SELECT, + MINIMIZE_TO_TRAY, + OPEN_LINKS_WITH_BROWSER, + PREVIEW_RICH_CONTENT, + PROTECT_PINNED_ITEMS, + SELECTION_COLOR, + TRAY_ITEMS_COUNT, + TRAY_PASTE_ON_SELECT, + VISUAL_MODE_COLOR, + config, +) + + +def _create_section_frame(title): + """Helper to create a framed section with a label.""" + frame = Gtk.Frame() + frame.set_shadow_type(Gtk.ShadowType.NONE) + + label = Gtk.Label() + label.set_markup(f"{title}") + label.set_halign(Gtk.Align.START) + frame.set_label_widget(label) + + frame.get_style_context().add_class("settings-section") + + return frame + + +def _create_setting_row(label_text, widget, tooltip=None): + """Helper to create a setting row with label and widget.""" + box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=10) + box.set_margin_start(10) + box.set_margin_end(10) + box.set_margin_top(5) + box.set_margin_bottom(5) + + label = Gtk.Label(label=label_text) + label.set_halign(Gtk.Align.START) + label.set_hexpand(True) + if tooltip: + label.set_tooltip_text(tooltip) + + widget.set_halign(Gtk.Align.END) + if tooltip: + widget.set_tooltip_text(tooltip) + + box.pack_start(label, True, True, 0) + box.pack_start(widget, False, False, 0) + + return box + + +def show_settings_window(parent_window, close_cb, restart_app_cb=None, + update_style_cb=None, style_defaults=None): + """Creates and shows the enhanced settings window with sections.""" + settings_window = Gtk.Window(title="Settings") + settings_window.set_type_hint(Gdk.WindowTypeHint.DIALOG) + settings_window.set_position(Gtk.WindowPosition.CENTER_ON_PARENT) + settings_window.set_transient_for(parent_window) + settings_window.set_default_size(500, 550) + settings_window.set_border_width(15) + + main_box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=10) + + # Header + header = Gtk.Label() + header.set_markup("Settings") + header.set_halign(Gtk.Align.CENTER) + header.set_margin_bottom(10) + main_box.pack_start(header, False, False, 0) + + # Create notebook for tabs + notebook = Gtk.Notebook() + notebook.set_vexpand(True) + + # ============ GENERAL TAB ============ + general_tab = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=15) + general_tab.set_margin_top(10) + general_tab.set_margin_bottom(10) + general_tab.set_margin_start(10) + general_tab.set_margin_end(10) + + # General Section + general_frame = _create_section_frame("General") + general_box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=5) + general_box.set_margin_top(10) + general_box.set_margin_bottom(10) + + # Compact Mode setting + compact_switch = Gtk.Switch() + compact_switch.set_active(COMPACT_MODE) + compact_box = _create_setting_row( + "Compact mode:", + compact_switch, + "Use a more compact layout with smaller margins", + ) + + # Hover to Select setting + hover_switch = Gtk.Switch() + hover_switch.set_active(HOVER_TO_SELECT) + hover_box = _create_setting_row( + "Hover to select:", + hover_switch, + "Select items by hovering over them with the mouse", + ) + + # Enter to Paste setting + enter_paste_switch = Gtk.Switch() + enter_paste_switch.set_active(ENTER_TO_PASTE) + enter_paste_box = _create_setting_row( + "Enter to paste:", + enter_paste_switch, + "Press Enter to paste the selected item and close the window", + ) + + # Highlight Search setting + highlight_search_switch = Gtk.Switch() + highlight_search_switch.set_active(HIGHLIGHT_SEARCH) + highlight_search_box = _create_setting_row( + "Highlight search:", + highlight_search_switch, + "Highlight matching search terms in the results list", + ) + + # Open links in browser setting + open_links_switch = Gtk.Switch() + open_links_switch.set_active(OPEN_LINKS_WITH_BROWSER) + open_links_box = _create_setting_row( + "Open links on Space:", + open_links_switch, + "Press Space on a URL item to open it in the browser (disable to show text preview)", + ) + + # Preview rich content setting + rich_content_switch = Gtk.Switch() + rich_content_switch.set_active(PREVIEW_RICH_CONTENT) + rich_content_box = _create_setting_row( + "Preview rich content:", + rich_content_switch, + "Render image URLs, SVGs, and base64 images as thumbnails in the list", + ) + + general_box.pack_start(compact_box, False, False, 0) + general_box.pack_start(hover_box, False, False, 0) + general_box.pack_start(enter_paste_box, False, False, 0) + general_box.pack_start(highlight_search_box, False, False, 0) + general_box.pack_start(open_links_box, False, False, 0) + general_box.pack_start(rich_content_box, False, False, 0) + general_frame.add(general_box) + general_tab.pack_start(general_frame, False, False, 0) + + # ============ CLIPBOARD SECTION ============ + clipboard_frame = _create_section_frame("Clipboard") + clipboard_box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=5) + clipboard_box.set_margin_top(10) + clipboard_box.set_margin_bottom(10) + + # Protect Pinned Items setting + protect_switch = Gtk.Switch() + protect_switch.set_active(PROTECT_PINNED_ITEMS) + protect_box = _create_setting_row( + "Protect pinned items:", + protect_switch, + "Prevent pinned items from being deleted when clearing history", + ) + + clipboard_box.pack_start(protect_box, False, False, 0) + clipboard_frame.add(clipboard_box) + general_tab.pack_start(clipboard_frame, False, False, 0) + + # ============ SYSTEM TRAY SECTION ============ + tray_frame = _create_section_frame("System Tray") + tray_box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=5) + tray_box.set_margin_top(10) + tray_box.set_margin_bottom(10) + + # Minimize to Tray setting + tray_switch = Gtk.Switch() + tray_switch.set_active(MINIMIZE_TO_TRAY) + tray_enable_box = _create_setting_row( + "Minimize to system tray:", + tray_switch, + "Keep the app running in the system tray when closing the window", + ) + + # Tray Items Count setting + tray_items_spin = Gtk.SpinButton.new_with_range(5, 50, 1) + tray_items_spin.set_value(TRAY_ITEMS_COUNT) + tray_items_box = _create_setting_row( + "Number of tray items:", + tray_items_spin, + "How many recent items to show in the system tray menu", + ) + + # Tray Paste on Select setting + tray_paste_switch = Gtk.Switch() + tray_paste_switch.set_active(TRAY_PASTE_ON_SELECT) + tray_paste_box = _create_setting_row( + "Paste on select from tray:", + tray_paste_switch, + "Automatically paste the item when selected from the tray menu", + ) + + tray_box.pack_start(tray_enable_box, False, False, 0) + tray_box.pack_start(tray_items_box, False, False, 0) + tray_box.pack_start(tray_paste_box, False, False, 0) + tray_frame.add(tray_box) + general_tab.pack_start(tray_frame, False, False, 0) + + # Add General tab to notebook + general_scroll = Gtk.ScrolledWindow() + general_scroll.set_policy(Gtk.PolicyType.NEVER, Gtk.PolicyType.AUTOMATIC) + general_scroll.add(general_tab) + notebook.append_page(general_scroll, Gtk.Label(label="General")) + + # ============ STYLE TAB ============ + style_tab = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=15) + style_tab.set_margin_top(10) + style_tab.set_margin_bottom(10) + style_tab.set_margin_start(10) + style_tab.set_margin_end(10) + + # Appearance Section + appearance_frame = _create_section_frame("Appearance") + appearance_box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=2) + appearance_box.set_margin_top(10) + appearance_box.set_margin_bottom(10) + + # Border Radius + radius_spin = Gtk.SpinButton.new_with_range(0, 20, 1) + radius_spin.set_value(BORDER_RADIUS) + radius_row = _create_setting_row( + "Border radius:", + radius_spin, + "Corner roundness applied to buttons, lists, and dialogs", + ) + + # Accent Color + accent_button = Gtk.ColorButton() + accent_rgba = Gdk.RGBA() + accent_rgba.parse(ACCENT_COLOR) + accent_button.set_rgba(accent_rgba) + accent_row = _create_setting_row( + "Accent color (pins):", + accent_button, + "Color used for pinned items and pin filter button", + ) + + # Selection Color + selection_button = Gtk.ColorButton() + selection_rgba = Gdk.RGBA() + selection_rgba.parse(SELECTION_COLOR) + selection_button.set_rgba(selection_rgba) + selection_row = _create_setting_row( + "Selection color:", + selection_button, + "Color used for selected list rows and focus rings", + ) + + # Visual Mode Color + visual_button = Gtk.ColorButton() + visual_rgba = Gdk.RGBA() + visual_rgba.parse(VISUAL_MODE_COLOR) + visual_button.set_rgba(visual_rgba) + visual_row = _create_setting_row( + "Visual mode color:", + visual_button, + "Color used for multi-select / visual mode highlights", + ) + + appearance_box.pack_start(radius_row, False, False, 0) + appearance_box.pack_start(accent_row, False, False, 0) + appearance_box.pack_start(selection_row, False, False, 0) + appearance_box.pack_start(visual_row, False, False, 0) + + # Reset button — right-aligned, compact, not full-width + reset_btn = Gtk.Button(label="Reset to Defaults") + reset_btn.set_tooltip_text("Reset all style settings to default values") + reset_row = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=10) + reset_row.set_margin_start(10) + reset_row.set_margin_end(10) + reset_row.set_margin_top(12) + reset_row.set_halign(Gtk.Align.END) + reset_row.pack_end(reset_btn, False, False, 0) + appearance_box.pack_start(reset_row, False, False, 0) + + appearance_frame.add(appearance_box) + style_tab.pack_start(appearance_frame, False, False, 0) + + # Add Style tab to notebook + style_scroll = Gtk.ScrolledWindow() + style_scroll.set_policy(Gtk.PolicyType.NEVER, Gtk.PolicyType.AUTOMATIC) + style_scroll.add(style_tab) + notebook.append_page(style_scroll, Gtk.Label(label="Style")) + + main_box.pack_start(notebook, True, True, 0) + + # Track changes + settings_changed = False + + # Buttons + button_box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=10) + button_box.set_homogeneous(True) + button_box.set_margin_top(10) + + # Apply & Restart button (initially disabled) + apply_btn = Gtk.Button(label="Apply & Restart") + apply_btn.set_sensitive(False) + + # Close button + close_btn = Gtk.Button(label="Close") + + def update_button_states(): + """Update the state of buttons based on whether settings have changed.""" + apply_btn.set_sensitive(settings_changed) + + def on_protect_switch_toggled(switch, state): + nonlocal settings_changed + settings_changed = True + update_button_states() + if not config.config.has_section("General"): + config.config.add_section("General") + config.config.set("General", "protect_pinned_items", str(switch.get_active())) + config._save_config() + import clipse_gui.constants as constants + + constants.PROTECT_PINNED_ITEMS = switch.get_active() + + def on_compact_switch_toggled(switch, state): + nonlocal settings_changed + settings_changed = True + update_button_states() + if not config.config.has_section("General"): + config.config.add_section("General") + config.config.set("General", "compact_mode", str(switch.get_active())) + config._save_config() + import clipse_gui.constants as constants + + constants.COMPACT_MODE = switch.get_active() + + def on_hover_switch_toggled(switch, state): + nonlocal settings_changed + settings_changed = True + update_button_states() + if not config.config.has_section("General"): + config.config.add_section("General") + config.config.set("General", "hover_to_select", str(switch.get_active())) + config._save_config() + import clipse_gui.constants as constants + + constants.HOVER_TO_SELECT = switch.get_active() + + def on_enter_paste_switch_toggled(switch, state): + nonlocal settings_changed + settings_changed = True + update_button_states() + if not config.config.has_section("General"): + config.config.add_section("General") + config.config.set("General", "enter_to_paste", str(switch.get_active())) + config._save_config() + import clipse_gui.constants as constants + + constants.ENTER_TO_PASTE = switch.get_active() + + def on_highlight_search_switch_toggled(switch, state): + nonlocal settings_changed + settings_changed = True + update_button_states() + if not config.config.has_section("General"): + config.config.add_section("General") + config.config.set("General", "highlight_search", str(switch.get_active())) + config._save_config() + import clipse_gui.constants as constants + + constants.HIGHLIGHT_SEARCH = switch.get_active() + + def on_open_links_switch_toggled(switch, state): + nonlocal settings_changed + settings_changed = True + update_button_states() + if not config.config.has_section("General"): + config.config.add_section("General") + config.config.set("General", "open_links_with_browser", str(switch.get_active())) + config._save_config() + import clipse_gui.constants as constants + + constants.OPEN_LINKS_WITH_BROWSER = switch.get_active() + + def on_rich_content_switch_toggled(switch, state): + nonlocal settings_changed + settings_changed = True + update_button_states() + if not config.config.has_section("General"): + config.config.add_section("General") + config.config.set("General", "preview_rich_content", str(switch.get_active())) + config._save_config() + import clipse_gui.constants as constants + + constants.PREVIEW_RICH_CONTENT = switch.get_active() + + def on_tray_switch_toggled(switch, state): + nonlocal settings_changed + settings_changed = True + update_button_states() + if not config.config.has_section("General"): + config.config.add_section("General") + config.config.set("General", "minimize_to_tray", str(switch.get_active())) + config._save_config() + import clipse_gui.constants as constants + + constants.MINIMIZE_TO_TRAY = switch.get_active() + try: + app = parent_window.get_application() + if hasattr(app, "tray_manager") and app.tray_manager: + app.tray_manager.set_tray_enabled(switch.get_active()) + except Exception as e: + logging.debug(f"Could not update tray manager dynamically: {e}") + + def on_tray_items_changed(spin): + nonlocal settings_changed + settings_changed = True + update_button_states() + if not config.config.has_section("General"): + config.config.add_section("General") + config.config.set("General", "tray_items_count", str(int(spin.get_value()))) + config._save_config() + import clipse_gui.constants as constants + + constants.TRAY_ITEMS_COUNT = int(spin.get_value()) + + def on_tray_paste_switch_toggled(switch, state): + nonlocal settings_changed + settings_changed = True + update_button_states() + if not config.config.has_section("General"): + config.config.add_section("General") + config.config.set("General", "tray_paste_on_select", str(switch.get_active())) + config._save_config() + import clipse_gui.constants as constants + + constants.TRAY_PASTE_ON_SELECT = switch.get_active() + + # Style signal handlers + def on_radius_changed(spin): + nonlocal settings_changed + settings_changed = True + update_button_states() + value = int(spin.get_value()) + if not config.config.has_section("Style"): + config.config.add_section("Style") + config.config.set("Style", "border_radius", str(value)) + config._save_config() + import clipse_gui.constants as constants + constants.BORDER_RADIUS = value + if update_style_cb: + update_style_cb(border_radius=value) + + def on_accent_color_changed(button): + nonlocal settings_changed + settings_changed = True + update_button_states() + rgba = button.get_rgba() + color = f"#{int(rgba.red * 255):02x}{int(rgba.green * 255):02x}{int(rgba.blue * 255):02x}" + if not config.config.has_section("Style"): + config.config.add_section("Style") + config.config.set("Style", "accent_color", color) + config._save_config() + import clipse_gui.constants as constants + constants.ACCENT_COLOR = color + if update_style_cb: + update_style_cb(accent_color=color) + + def on_selection_color_changed(button): + nonlocal settings_changed + settings_changed = True + update_button_states() + rgba = button.get_rgba() + color = f"#{int(rgba.red * 255):02x}{int(rgba.green * 255):02x}{int(rgba.blue * 255):02x}" + if not config.config.has_section("Style"): + config.config.add_section("Style") + config.config.set("Style", "selection_color", color) + config._save_config() + import clipse_gui.constants as constants + constants.SELECTION_COLOR = color + if update_style_cb: + update_style_cb(selection_color=color) + + def on_visual_color_changed(button): + nonlocal settings_changed + settings_changed = True + update_button_states() + rgba = button.get_rgba() + color = f"#{int(rgba.red * 255):02x}{int(rgba.green * 255):02x}{int(rgba.blue * 255):02x}" + if not config.config.has_section("Style"): + config.config.add_section("Style") + config.config.set("Style", "visual_mode_color", color) + config._save_config() + import clipse_gui.constants as constants + constants.VISUAL_MODE_COLOR = color + if update_style_cb: + update_style_cb(visual_mode_color=color) + + def on_reset_styles(button): + if not style_defaults: + return + # Reset to defaults + radius_spin.set_value(style_defaults.get("border_radius", 6)) + + accent_rgba = Gdk.RGBA() + accent_rgba.parse(style_defaults.get("accent_color", "#ffcc00")) + accent_button.set_rgba(accent_rgba) + + selection_rgba = Gdk.RGBA() + selection_rgba.parse(style_defaults.get("selection_color", "#4a90e2")) + selection_button.set_rgba(selection_rgba) + + visual_rgba = Gdk.RGBA() + visual_rgba.parse(style_defaults.get("visual_mode_color", "#9b59b6")) + visual_button.set_rgba(visual_rgba) + + # Save defaults to config + if not config.config.has_section("Style"): + config.config.add_section("Style") + for key, value in style_defaults.items(): + config.config.set("Style", key, str(value)) + config._save_config() + + # Update constants and apply + import clipse_gui.constants as constants + constants.BORDER_RADIUS = style_defaults.get("border_radius", 6) + constants.ACCENT_COLOR = style_defaults.get("accent_color", "#ffcc00") + constants.SELECTION_COLOR = style_defaults.get("selection_color", "#4a90e2") + constants.VISUAL_MODE_COLOR = style_defaults.get("visual_mode_color", "#9b59b6") + + if update_style_cb: + update_style_cb( + border_radius=constants.BORDER_RADIUS, + accent_color=constants.ACCENT_COLOR, + selection_color=constants.SELECTION_COLOR, + visual_mode_color=constants.VISUAL_MODE_COLOR, + ) + + # Connect signals + protect_switch.connect("state-set", on_protect_switch_toggled) + compact_switch.connect("state-set", on_compact_switch_toggled) + hover_switch.connect("state-set", on_hover_switch_toggled) + enter_paste_switch.connect("state-set", on_enter_paste_switch_toggled) + highlight_search_switch.connect("state-set", on_highlight_search_switch_toggled) + open_links_switch.connect("state-set", on_open_links_switch_toggled) + rich_content_switch.connect("state-set", on_rich_content_switch_toggled) + tray_switch.connect("state-set", on_tray_switch_toggled) + tray_items_spin.connect("value-changed", on_tray_items_changed) + tray_paste_switch.connect("state-set", on_tray_paste_switch_toggled) + + # Style signals + radius_spin.connect("value-changed", on_radius_changed) + accent_button.connect("color-set", on_accent_color_changed) + selection_button.connect("color-set", on_selection_color_changed) + visual_button.connect("color-set", on_visual_color_changed) + reset_btn.connect("clicked", on_reset_styles) + + def on_apply_clicked(button): + settings_window.destroy() + if restart_app_cb: + restart_app_cb() + + apply_btn.connect("clicked", on_apply_clicked) + + def on_close_clicked(button): + settings_window.destroy() + if settings_changed and restart_app_cb: + dialog = Gtk.MessageDialog( + transient_for=settings_window, + modal=True, + message_type=Gtk.MessageType.QUESTION, + buttons=Gtk.ButtonsType.YES_NO, + text="Settings have been changed. Restart to apply changes?", + ) + response = dialog.run() + dialog.destroy() + if response == Gtk.ResponseType.YES: + restart_app_cb() + + close_btn.connect("clicked", on_close_clicked) + + button_box.pack_start(apply_btn, True, True, 0) + button_box.pack_start(close_btn, True, True, 0) + main_box.pack_end(button_box, False, False, 0) + + settings_window.add(main_box) + settings_window.connect( + "key-press-event", + lambda w, e: close_cb(w) if e.keyval == Gdk.KEY_Escape else None, + ) + settings_window.show_all() + close_btn.grab_focus() diff --git a/clipse_gui/ui/text.py b/clipse_gui/ui/text.py new file mode 100644 index 0000000..88b3ce9 --- /dev/null +++ b/clipse_gui/ui/text.py @@ -0,0 +1,124 @@ +"""Text utilities: markup escaping, search highlighting, and content formatting.""" + +import json + +from gi.repository import GLib + + +def escape_markup(text): + """Escape special characters for Pango markup.""" + return text.replace("&", "&").replace("<", "<").replace(">", ">") + + +def highlight_search_term(text, search_term): + """Highlight search term matches in text using Pango markup.""" + if not search_term or not search_term.strip(): + return escape_markup(text) + + search_lower = search_term.lower() + text_lower = text.lower() + + result = [] + last_end = 0 + + while True: + idx = text_lower.find(search_lower, last_end) + if idx == -1: + break + + # Add text before match + if idx > last_end: + result.append(escape_markup(text[last_end:idx])) + + # Add highlighted match with inline background color + match = text[idx:idx + len(search_term)] + result.append(f'{escape_markup(match)}') + + last_end = idx + len(search_term) + + # Add remaining text + if last_end < len(text): + result.append(escape_markup(text[last_end:])) + + return "".join(result) + + +def _format_text_content(text_view): + """Formats the text content in the TextView, with special handling for JSON.""" + buffer = text_view.get_buffer() + start, end = buffer.get_bounds() + text = buffer.get_text(start, end, False) + + if not text.strip(): + return + + formatted_text = None + + # Try to format as JSON first + try: + # Remove any leading/trailing whitespace and check if it looks like JSON + stripped_text = text.strip() + if (stripped_text.startswith("{") and stripped_text.endswith("}")) or ( + stripped_text.startswith("[") and stripped_text.endswith("]") + ): + # Try to parse and format as JSON + parsed_json = json.loads(stripped_text) + formatted_text = json.dumps( + parsed_json, indent=2, ensure_ascii=False, sort_keys=True + ) + except (json.JSONDecodeError, ValueError): + # Not valid JSON, try other formatting + pass + + # If not JSON, try to format as other structured text + if formatted_text is None: + # Basic text formatting - normalize whitespace and line breaks + lines = text.split("\n") + formatted_lines = [] + + for line in lines: + # Remove excessive whitespace but preserve intentional indentation + stripped = line.rstrip() + if stripped: + # Preserve leading whitespace for indentation + leading_spaces = len(line) - len(line.lstrip()) + formatted_lines.append(" " * leading_spaces + stripped) + else: + formatted_lines.append("") + + # Remove excessive blank lines (more than 2 consecutive) + result_lines = [] + blank_count = 0 + for line in formatted_lines: + if line.strip() == "": + blank_count += 1 + if blank_count <= 2: + result_lines.append(line) + else: + blank_count = 0 + result_lines.append(line) + + formatted_text = "\n".join(result_lines) + + # Update the buffer with formatted text + if formatted_text and formatted_text != text: + buffer.set_text(formatted_text) + # Show a brief status message + GLib.timeout_add(100, lambda: _flash_format_status(text_view, "Text formatted")) + else: + GLib.timeout_add( + 100, lambda: _flash_format_status(text_view, "No formatting applied") + ) + + +def _flash_format_status(text_view, message): + """Shows a brief status message by temporarily changing the tooltip.""" + original_tooltip = text_view.get_tooltip_text() + text_view.set_tooltip_text(message) + + def restore_tooltip(): + text_view.set_tooltip_text(original_tooltip) + return False + + GLib.timeout_add(2000, restore_tooltip) + return False diff --git a/clipse_gui/ui_components.py b/clipse_gui/ui_components.py index b033691..26632ed 100644 --- a/clipse_gui/ui_components.py +++ b/clipse_gui/ui_components.py @@ -1,1742 +1,43 @@ -import os -import json -import logging -from gi.repository import Gtk, Gdk, Pango, GdkPixbuf, GLib +"""Back-compat shim. Re-exports public names from the focused `ui/` modules. -from .utils import format_date -from .constants import ( - LIST_ITEM_IMAGE_WIDTH, - LIST_ITEM_IMAGE_HEIGHT, - DEFAULT_PREVIEW_TEXT_WIDTH, - DEFAULT_PREVIEW_TEXT_HEIGHT, - DEFAULT_PREVIEW_IMG_WIDTH, - DEFAULT_PREVIEW_IMG_HEIGHT, - DEFAULT_HELP_WIDTH, - DEFAULT_HELP_HEIGHT, - PROTECT_PINNED_ITEMS, - COMPACT_MODE, - HOVER_TO_SELECT, - ENTER_TO_PASTE, - HIGHLIGHT_SEARCH, - BORDER_RADIUS, - ACCENT_COLOR, - SELECTION_COLOR, - VISUAL_MODE_COLOR, - MINIMIZE_TO_TRAY, - TRAY_ITEMS_COUNT, - TRAY_PASTE_ON_SELECT, - OPEN_LINKS_WITH_BROWSER, - PREVIEW_RICH_CONTENT, - config, -) - -log = logging.getLogger(__name__) - -# SVG icon data for pushpin (rotated 25 degrees to the right for a natural look) -PIN_SVG_BASE = """ - - - - - +New code should import directly from `clipse_gui.ui.`. This shim exists +so existing internal imports (and the runtime `from .ui_components import ...` +calls in the controller) keep working unchanged. """ +from .ui.detection import _is_data_uri, _is_image_url, _is_svg_content, _is_url +from .ui.help import show_help_window +from .ui.icons import animate_pin_shake, create_pin_icon +from .ui.list_row import create_list_row_widget +from .ui.preview import _toggle_search_bar, show_preview_window +from .ui.settings import _create_section_frame, _create_setting_row, show_settings_window +from .ui.text import ( + _flash_format_status, + _format_text_content, + escape_markup, + highlight_search_term, +) -def create_pin_icon(is_pinned, angle=25): - """Creates a pin icon from SVG data with color based on pinned state.""" - try: - # Replace currentColor with actual color - color = "#ffcc00" if is_pinned else "rgba(255,255,255,0.25)" - svg_data = PIN_SVG_BASE.replace("currentColor", color).replace( - "{angle}", str(angle) - ) - - # Load SVG into pixbuf - loader = GdkPixbuf.PixbufLoader.new_with_type("svg") - loader.write(svg_data.encode("utf-8")) - loader.close() - pixbuf = loader.get_pixbuf() - - # Create image from pixbuf - image = Gtk.Image.new_from_pixbuf(pixbuf) - image.get_style_context().add_class("pin-icon") - if is_pinned: - image.get_style_context().add_class("pinned") - else: - image.get_style_context().add_class("unpinned") - - return image - except Exception as e: - log.error(f"Error creating pin icon: {e}") - # Fallback to label - label = Gtk.Label(label="📌") - return label - - -def animate_pin_shake(container, is_pinned): - """Animates a gentle rotation wiggle effect by recreating the icon at different angles.""" - # Gentle rotation sequence: base angle ± small rotations - base_angle = 25 - rotation_sequence = [ - base_angle + 8, # Rotate right - base_angle - 8, # Rotate left - base_angle + 5, # Rotate right (less) - base_angle - 5, # Rotate left (less) - base_angle, # Back to normal - ] - - def apply_wiggle(index): - if index < len(rotation_sequence): - # Remove old icon - children = container.get_children() - if children: - old_icon = children[-1] - container.remove(old_icon) - - # Create new icon with rotated angle - new_icon = create_pin_icon(is_pinned, rotation_sequence[index]) - new_icon.set_tooltip_text("Pinned" if is_pinned else "Not Pinned") - new_icon.set_valign(Gtk.Align.START) # Keep top alignment - new_icon.set_margin_top(2) # Keep top margin - new_icon.show() - container.pack_end(new_icon, False, False, 0) - - GLib.timeout_add(70, apply_wiggle, index + 1) - return False - - apply_wiggle(0) - - -_IMAGE_EXTENSIONS = (".jpg", ".jpeg", ".png", ".gif", ".webp", ".svg", ".bmp", ".ico", ".tiff", ".tif") - - -def _is_image_url(text): - """True if text is an http(s) URL pointing to a recognised image extension.""" - if not text or not isinstance(text, str): - return False - t = text.strip().lower() - if not (t.startswith("http://") or t.startswith("https://")): - return False - return any(t.split("?")[0].endswith(ext) for ext in _IMAGE_EXTENSIONS) - - -def _is_svg_content(text): - """True if text appears to be inline SVG markup.""" - if not text or not isinstance(text, str): - return False - t = text.strip() - return t.startswith("", ">") - - -def highlight_search_term(text, search_term): - """Highlight search term matches in text using Pango markup.""" - if not search_term or not search_term.strip(): - return escape_markup(text) - - search_lower = search_term.lower() - text_lower = text.lower() - - result = [] - last_end = 0 - - while True: - idx = text_lower.find(search_lower, last_end) - if idx == -1: - break - - # Add text before match - if idx > last_end: - result.append(escape_markup(text[last_end:idx])) - - # Add highlighted match with inline background color - match = text[idx:idx + len(search_term)] - result.append(f'{escape_markup(match)}') - - last_end = idx + len(search_term) - - # Add remaining text - if last_end < len(text): - result.append(escape_markup(text[last_end:])) - - return "".join(result) - - -def create_list_row_widget( - item_info, - image_handler, - update_image_callback, - compact_mode=False, - hover_to_select=False, - single_click_callback=None, - search_term="", - highlight_search=False, -): - """Creates a Gtk.ListBoxRow widget for a clipboard item.""" - original_index = item_info["original_index"] - item = item_info["item"] - filtered_index = item_info["filtered_index"] - row = Gtk.ListBoxRow() - row.item_index = original_index - row.filtered_index = filtered_index - row.item_value = item.get("value", "") - row.item_pinned = item.get("pinned", False) - row.file_path = item.get("filePath", "") - row.is_image = item.get("filePath") not in [None, "null", ""] - - # Detect special content types for text items - text_value = item.get("value", "") - row.is_url_image = False - row.is_svg_content = False - row.is_data_uri = False - row.is_url = False - row.image_url = None - row.website_url = None - if not row.is_image: - if _is_image_url(text_value): - row.is_url_image = True - row.image_url = text_value.strip() - elif _is_svg_content(text_value): - row.is_svg_content = True - elif _is_data_uri(text_value): - row.is_data_uri = True - elif _is_url(text_value): - row.is_url = True - row.website_url = text_value.strip() - - style_context = row.get_style_context() - if row.item_pinned: - style_context.add_class("pinned-row") - style_context.add_class("list-row") - - # Use the passed compact mode parameter - is_compact = compact_mode - - # Adjust sizes based on compact mode - if is_compact: - row.set_size_request(-1, 28) # Compact but readable height - vbox = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=0) - vbox.set_margin_top(1) - vbox.set_margin_bottom(1) - vbox.set_margin_start(1) - vbox.set_margin_end(1) - else: - row.set_size_request(-1, 35) # Reduced default height - vbox = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=2) - vbox.set_margin_top(2) - vbox.set_margin_bottom(2) - vbox.set_margin_start(3) - vbox.set_margin_end(3) - - vbox.set_homogeneous(False) - vbox.set_property("expand", False) - - hbox = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=5) - content_box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=1) - - if row.is_image: - image_path = item.get("filePath") - image_container = Gtk.Frame() - # Adjust image size based on compact mode - if is_compact: - image_container.set_size_request( - int(LIST_ITEM_IMAGE_WIDTH * 0.3), int(LIST_ITEM_IMAGE_HEIGHT * 0.3) - ) - else: - image_container.set_size_request( - int(LIST_ITEM_IMAGE_WIDTH * 0.8), int(LIST_ITEM_IMAGE_HEIGHT * 0.8) - ) - image_container.set_shadow_type(Gtk.ShadowType.NONE) - placeholder = Gtk.Label(label="[Loading image...]") - placeholder.set_halign(Gtk.Align.CENTER) - placeholder.set_valign(Gtk.Align.CENTER) - image_container.add(placeholder) - content_box.pack_start(image_container, False, False, 0) - - # Request image loading via the handler - image_handler.load_image_async( - image_path, - image_container, - placeholder, - LIST_ITEM_IMAGE_WIDTH, - LIST_ITEM_IMAGE_HEIGHT, - update_image_callback, - ) - - title_label = Gtk.Label(label=os.path.basename(item.get("value", "Image"))) - title_label.set_ellipsize(Pango.EllipsizeMode.MIDDLE) - title_label.set_max_width_chars(20) # Reduced from 25 - title_label.set_halign(Gtk.Align.START) - content_box.pack_start(title_label, False, False, 0) - elif (row.is_url_image or row.is_data_uri) and PREVIEW_RICH_CONTENT: - # Remote image URL or base64 data URI — show thumbnail - thumb_w = int(LIST_ITEM_IMAGE_WIDTH * (0.3 if is_compact else 0.8)) - thumb_h = int(LIST_ITEM_IMAGE_HEIGHT * (0.3 if is_compact else 0.8)) - image_container = Gtk.Frame() - image_container.set_shadow_type(Gtk.ShadowType.NONE) - image_container.set_size_request(thumb_w, thumb_h) - placeholder = Gtk.Label(label="…") - placeholder.set_halign(Gtk.Align.CENTER) - placeholder.set_valign(Gtk.Align.CENTER) - image_container.add(placeholder) - content_box.pack_start(image_container, False, False, 0) - - if row.is_data_uri: - image_handler.load_data_uri_async( - text_value.strip(), image_container, placeholder, - LIST_ITEM_IMAGE_WIDTH, LIST_ITEM_IMAGE_HEIGHT, update_image_callback, - ) - else: - image_handler.load_remote_image_async( - row.image_url, image_container, placeholder, - LIST_ITEM_IMAGE_WIDTH, LIST_ITEM_IMAGE_HEIGHT, update_image_callback, - ) - - badge = Gtk.Label(label="[image url]" if row.is_url_image else "[base64]") - badge.get_style_context().add_class("url-badge") - badge.set_halign(Gtk.Align.START) - content_box.pack_start(badge, False, False, 0) - elif row.is_svg_content and PREVIEW_RICH_CONTENT: - # Inline SVG — render as thumbnail - thumb_w = int(LIST_ITEM_IMAGE_WIDTH * (0.3 if is_compact else 0.8)) - thumb_h = int(LIST_ITEM_IMAGE_HEIGHT * (0.3 if is_compact else 0.8)) - image_container = Gtk.Frame() - image_container.set_shadow_type(Gtk.ShadowType.NONE) - image_container.set_size_request(thumb_w, thumb_h) - placeholder = Gtk.Label(label="…") - placeholder.set_halign(Gtk.Align.CENTER) - placeholder.set_valign(Gtk.Align.CENTER) - image_container.add(placeholder) - content_box.pack_start(image_container, False, False, 0) - - image_handler.load_svg_async( - text_value.strip(), image_container, placeholder, - LIST_ITEM_IMAGE_WIDTH, LIST_ITEM_IMAGE_HEIGHT, update_image_callback, - ) - - badge = Gtk.Label(label="[svg]") - badge.get_style_context().add_class("url-badge") - badge.set_halign(Gtk.Align.START) - content_box.pack_start(badge, False, False, 0) - elif row.is_url: - # Regular URL — link icon + URL text - row_box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=4) - icon = Gtk.Image.new_from_icon_name("external-link-symbolic", Gtk.IconSize.MENU) - icon.get_style_context().add_class("url-link") - row_box.pack_start(icon, False, False, 0) - max_chars = 50 if is_compact else 80 - display_url = text_value.strip() - if len(display_url) > max_chars: - display_url = display_url[:max_chars - 1] + "…" - lbl = Gtk.Label(label=display_url) - lbl.set_xalign(0) - lbl.set_ellipsize(Pango.EllipsizeMode.END) - lbl.get_style_context().add_class("url-link") - row_box.pack_start(lbl, True, True, 0) - content_box.pack_start(row_box, False, False, 0) - else: - # Limit to 1 line in compact mode, 3 lines otherwise - max_lines = 1 if is_compact else 3 - display_text = "\n".join(text_value.splitlines()[:max_lines]) - if len(text_value.splitlines()) > max_lines or len(display_text) > ( - 80 if is_compact else 150 - ): - cutoff = 80 if is_compact else 150 - last_space = display_text[:cutoff].rfind(" ") - if last_space > cutoff * 0.8: - cutoff = last_space - display_text = display_text[:cutoff] + "..." - - label = Gtk.Label() - # Apply search highlighting if enabled - if highlight_search and search_term: - marked_up = highlight_search_term(display_text, search_term) - label.set_markup(marked_up) - else: - label.set_text(display_text) - - label.set_line_wrap(True) - label.set_line_wrap_mode(Pango.WrapMode.WORD) - label.set_xalign(0) - label.set_max_width_chars( - 35 if is_compact else 50 - ) # Reduced width in compact mode - label.set_ellipsize(Pango.EllipsizeMode.END) - - # Adjust label size based on compact mode - if is_compact: - label.set_size_request(-1, 22) # Compact but readable height - else: - label.set_size_request(-1, 30) - - content_box.pack_start(label, False, False, 0) - - # Ensure content box doesn't expand - content_box.set_property("expand", False) - - hbox.pack_start(content_box, False, True, 0) - - # Use custom SVG pin icon - pin_icon = create_pin_icon(row.item_pinned) - pin_icon.set_tooltip_text("Pinned" if row.item_pinned else "Not Pinned") - pin_icon.set_valign(Gtk.Align.START) # Align to top - pin_icon.set_margin_top(2) # Small margin from the very top - hbox.pack_end(pin_icon, False, False, 0) - - vbox.pack_start(hbox, False, False, 0) - - timestamp = format_date(item.get("recorded", "")) - time_label = Gtk.Label(label=timestamp) - time_label.set_halign(Gtk.Align.START) - time_label.get_style_context().add_class("timestamp") - vbox.pack_start(time_label, False, False, 0) - - row.add(vbox) - - # Add hover-to-select functionality if enabled - if hover_to_select: - # Add an EventBox to capture mouse events reliably - event_box = Gtk.EventBox() - event_box.set_events(Gdk.EventMask.ENTER_NOTIFY_MASK) - event_box.set_visible_window(False) # Make it transparent - - # Move the vbox content into the event box - row.remove(vbox) - event_box.add(vbox) - row.add(event_box) - - def on_enter_notify(widget, event): - # Get the ListBoxRow parent - listbox_row = widget.get_parent() # EventBox -> ListBoxRow - if listbox_row and isinstance(listbox_row, Gtk.ListBoxRow): - listbox = listbox_row.get_parent() # ListBoxRow -> ListBox - if listbox and hasattr(listbox, "select_row"): - listbox.select_row(listbox_row) - return False - - event_box.connect("enter-notify-event", on_enter_notify) - - # Add single-click support if callback provided - if single_click_callback: - - def on_button_press(widget, event): - # Single-click (left button) triggers paste - if event.button == 1: # Left mouse button - # Check if it's a single click (not double-click) - # Double-click is handled by row-activated signal - if event.type == Gdk.EventType.BUTTON_PRESS: - single_click_callback(row) - return True # Stop propagation - return False - - row.connect("button-press-event", on_button_press) - - return row - - -# --- Help Window --- - - -def show_help_window(parent_window, close_cb): - """Creates and shows the keyboard shortcuts help window.""" - help_window = Gtk.Window(title="Keyboard Shortcuts") - help_window.set_type_hint(Gdk.WindowTypeHint.DIALOG) - help_window.set_position(Gtk.WindowPosition.CENTER_ON_PARENT) - help_window.set_transient_for(parent_window) - help_window.set_default_size(DEFAULT_HELP_WIDTH, DEFAULT_HELP_HEIGHT) - help_window.set_border_width(20) - - main_box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=15) - - # Header - header = Gtk.Label() - header.set_markup("Keyboard Shortcuts") - header.set_halign(Gtk.Align.CENTER) - header.set_margin_bottom(10) - main_box.pack_start(header, False, False, 0) - - # Scrolled window for content - scrolled = Gtk.ScrolledWindow() - scrolled.set_policy(Gtk.PolicyType.NEVER, Gtk.PolicyType.AUTOMATIC) - - # Main grid for table-like layout - main_grid = Gtk.Grid() - main_grid.set_column_spacing(30) - main_grid.set_row_spacing(4) - main_grid.set_margin_start(25) - main_grid.set_margin_end(25) - main_grid.set_margin_top(15) - main_grid.set_margin_bottom(15) - - # Define all shortcuts in order with section headers - shortcuts_data = [ - # Navigation - ("NAVIGATION", None, True), - ("Slash / f", "Focus search field", False), - ("↑ / k", "Navigate up", False), - ("↓ / j", "Navigate down", False), - ("PgUp", "Scroll page up", False), - ("PgDn", "Scroll page down", False), - ("Home", "Go to top", False), - ("End", "Go to bottom (of loaded items)", False), - ("Tab", "Toggle 'Pinned Only' filter", False), - ("", None, False), # Spacer - # Actions - ("ACTIONS", None, True), - ("Enter", "Copy selected item to clipboard", False), - ("Shift+Enter", "Copy & paste selected item", False), - ("Space", "Show full preview", False), - ("p", "Toggle pin status", False), - ("x / Del", "Delete selected item", False), - ("", None, False), # Spacer - # Multi-Select Mode - ("MULTI-SELECT MODE", None, True), - ("v", "Toggle selection mode", False), - ("Space", "Toggle item selection (in selection mode)", False), - ("Ctrl+A", "Select all visible items", False), - ("Ctrl+Shift+A", "Deselect all items", False), - ("Ctrl+X / Shift+Del", "Delete selected items", False), - ("Ctrl+Shift+Del / Ctrl+D", "Clear all non-pinned items", False), - ("", None, False), # Spacer - # View - ("VIEW", None, True), - ("Ctrl +", "Zoom in", False), - ("Ctrl -", "Zoom out", False), - ("Ctrl 0", "Reset zoom", False), - ("", None, False), # Spacer - # Preview Window - ("PREVIEW WINDOW", None, True), - ("Ctrl+F", "Find text in preview", False), - ("Ctrl+B", "Format text (pretty-print JSON)", False), - ("Ctrl+C", "Copy text from preview", False), - ("", None, False), # Spacer - # General - ("GENERAL", None, True), - ("?", "Show this help window", False), - ("Ctrl+,", "Open settings", False), - ("Esc", "Clear search / Close window / Exit mode", False), - ("Ctrl+Q", "Quit application", False), - ] - - row = 0 - for key, desc, is_header in shortcuts_data: - if is_header: - # Section header - header_label = Gtk.Label() - header_label.set_markup( - f"{key}" - ) - header_label.set_halign(Gtk.Align.START) - header_label.set_margin_top(10 if row > 0 else 0) - header_label.set_margin_bottom(8) - main_grid.attach(header_label, 0, row, 2, 1) - row += 1 - elif key == "": - # Spacer row - spacer = Gtk.Label(label="") - spacer.set_size_request(-1, 10) - main_grid.attach(spacer, 0, row, 2, 1) - row += 1 - else: - # Regular shortcut row - key_label = Gtk.Label(label=key) - key_label.set_halign(Gtk.Align.START) - key_label.set_margin_end(25) - key_label.get_style_context().add_class("key-shortcut") - - desc_label = Gtk.Label(label=desc) - desc_label.set_halign(Gtk.Align.START) - desc_label.set_line_wrap(False) - desc_label.set_xalign(0) - - main_grid.attach(key_label, 0, row, 1, 1) - main_grid.attach(desc_label, 1, row, 1, 1) - row += 1 - - scrolled.add(main_grid) - main_box.pack_start(scrolled, True, True, 0) - - # Close button - close_btn = Gtk.Button(label="Close") - close_btn.set_margin_top(10) - close_btn.connect("clicked", lambda b: help_window.destroy()) - main_box.pack_end(close_btn, False, False, 0) - - help_window.add(main_box) - help_window.connect( - "key-press-event", - lambda w, e: close_cb(w) if e.keyval == Gdk.KEY_Escape else None, - ) - help_window.show_all() - close_btn.grab_focus() - - -def _create_section_frame(title): - """Helper to create a framed section with a label.""" - frame = Gtk.Frame() - frame.set_shadow_type(Gtk.ShadowType.NONE) - - label = Gtk.Label() - label.set_markup(f"{title}") - label.set_halign(Gtk.Align.START) - frame.set_label_widget(label) - - frame.get_style_context().add_class("settings-section") - - return frame - - -def _create_setting_row(label_text, widget, tooltip=None): - """Helper to create a setting row with label and widget.""" - box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=10) - box.set_margin_start(10) - box.set_margin_end(10) - box.set_margin_top(5) - box.set_margin_bottom(5) - - label = Gtk.Label(label=label_text) - label.set_halign(Gtk.Align.START) - label.set_hexpand(True) - if tooltip: - label.set_tooltip_text(tooltip) - - widget.set_halign(Gtk.Align.END) - if tooltip: - widget.set_tooltip_text(tooltip) - - box.pack_start(label, True, True, 0) - box.pack_start(widget, False, False, 0) - - return box - - -def show_settings_window(parent_window, close_cb, restart_app_cb=None, - update_style_cb=None, style_defaults=None): - """Creates and shows the enhanced settings window with sections.""" - settings_window = Gtk.Window(title="Settings") - settings_window.set_type_hint(Gdk.WindowTypeHint.DIALOG) - settings_window.set_position(Gtk.WindowPosition.CENTER_ON_PARENT) - settings_window.set_transient_for(parent_window) - settings_window.set_default_size(500, 550) - settings_window.set_border_width(15) - - main_box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=10) - - # Header - header = Gtk.Label() - header.set_markup("Settings") - header.set_halign(Gtk.Align.CENTER) - header.set_margin_bottom(10) - main_box.pack_start(header, False, False, 0) - - # Create notebook for tabs - notebook = Gtk.Notebook() - notebook.set_vexpand(True) - - # ============ GENERAL TAB ============ - general_tab = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=15) - general_tab.set_margin_top(10) - general_tab.set_margin_bottom(10) - general_tab.set_margin_start(10) - general_tab.set_margin_end(10) - - # General Section - general_frame = _create_section_frame("General") - general_box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=5) - general_box.set_margin_top(10) - general_box.set_margin_bottom(10) - - # Compact Mode setting - compact_switch = Gtk.Switch() - compact_switch.set_active(COMPACT_MODE) - compact_box = _create_setting_row( - "Compact mode:", - compact_switch, - "Use a more compact layout with smaller margins", - ) - - # Hover to Select setting - hover_switch = Gtk.Switch() - hover_switch.set_active(HOVER_TO_SELECT) - hover_box = _create_setting_row( - "Hover to select:", - hover_switch, - "Select items by hovering over them with the mouse", - ) - - # Enter to Paste setting - enter_paste_switch = Gtk.Switch() - enter_paste_switch.set_active(ENTER_TO_PASTE) - enter_paste_box = _create_setting_row( - "Enter to paste:", - enter_paste_switch, - "Press Enter to paste the selected item and close the window", - ) - - # Highlight Search setting - highlight_search_switch = Gtk.Switch() - highlight_search_switch.set_active(HIGHLIGHT_SEARCH) - highlight_search_box = _create_setting_row( - "Highlight search:", - highlight_search_switch, - "Highlight matching search terms in the results list", - ) - - # Open links in browser setting - open_links_switch = Gtk.Switch() - open_links_switch.set_active(OPEN_LINKS_WITH_BROWSER) - open_links_box = _create_setting_row( - "Open links on Space:", - open_links_switch, - "Press Space on a URL item to open it in the browser (disable to show text preview)", - ) - - # Preview rich content setting - rich_content_switch = Gtk.Switch() - rich_content_switch.set_active(PREVIEW_RICH_CONTENT) - rich_content_box = _create_setting_row( - "Preview rich content:", - rich_content_switch, - "Render image URLs, SVGs, and base64 images as thumbnails in the list", - ) - - general_box.pack_start(compact_box, False, False, 0) - general_box.pack_start(hover_box, False, False, 0) - general_box.pack_start(enter_paste_box, False, False, 0) - general_box.pack_start(highlight_search_box, False, False, 0) - general_box.pack_start(open_links_box, False, False, 0) - general_box.pack_start(rich_content_box, False, False, 0) - general_frame.add(general_box) - general_tab.pack_start(general_frame, False, False, 0) - - # ============ CLIPBOARD SECTION ============ - clipboard_frame = _create_section_frame("Clipboard") - clipboard_box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=5) - clipboard_box.set_margin_top(10) - clipboard_box.set_margin_bottom(10) - - # Protect Pinned Items setting - protect_switch = Gtk.Switch() - protect_switch.set_active(PROTECT_PINNED_ITEMS) - protect_box = _create_setting_row( - "Protect pinned items:", - protect_switch, - "Prevent pinned items from being deleted when clearing history", - ) - - clipboard_box.pack_start(protect_box, False, False, 0) - clipboard_frame.add(clipboard_box) - general_tab.pack_start(clipboard_frame, False, False, 0) - - # ============ SYSTEM TRAY SECTION ============ - tray_frame = _create_section_frame("System Tray") - tray_box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=5) - tray_box.set_margin_top(10) - tray_box.set_margin_bottom(10) - - # Minimize to Tray setting - tray_switch = Gtk.Switch() - tray_switch.set_active(MINIMIZE_TO_TRAY) - tray_enable_box = _create_setting_row( - "Minimize to system tray:", - tray_switch, - "Keep the app running in the system tray when closing the window", - ) - - # Tray Items Count setting - tray_items_spin = Gtk.SpinButton.new_with_range(5, 50, 1) - tray_items_spin.set_value(TRAY_ITEMS_COUNT) - tray_items_box = _create_setting_row( - "Number of tray items:", - tray_items_spin, - "How many recent items to show in the system tray menu", - ) - - # Tray Paste on Select setting - tray_paste_switch = Gtk.Switch() - tray_paste_switch.set_active(TRAY_PASTE_ON_SELECT) - tray_paste_box = _create_setting_row( - "Paste on select from tray:", - tray_paste_switch, - "Automatically paste the item when selected from the tray menu", - ) - - tray_box.pack_start(tray_enable_box, False, False, 0) - tray_box.pack_start(tray_items_box, False, False, 0) - tray_box.pack_start(tray_paste_box, False, False, 0) - tray_frame.add(tray_box) - general_tab.pack_start(tray_frame, False, False, 0) - - # Add General tab to notebook - general_scroll = Gtk.ScrolledWindow() - general_scroll.set_policy(Gtk.PolicyType.NEVER, Gtk.PolicyType.AUTOMATIC) - general_scroll.add(general_tab) - notebook.append_page(general_scroll, Gtk.Label(label="General")) - - # ============ STYLE TAB ============ - style_tab = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=15) - style_tab.set_margin_top(10) - style_tab.set_margin_bottom(10) - style_tab.set_margin_start(10) - style_tab.set_margin_end(10) - - # Appearance Section - appearance_frame = _create_section_frame("Appearance") - appearance_box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=10) - appearance_box.set_margin_top(10) - appearance_box.set_margin_bottom(10) - - # Border Radius - radius_box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=10) - radius_box.set_margin_start(10) - radius_box.set_margin_end(10) - radius_label = Gtk.Label(label="Border radius:") - radius_label.set_halign(Gtk.Align.START) - radius_label.set_hexpand(True) - radius_spin = Gtk.SpinButton.new_with_range(0, 20, 1) - radius_spin.set_value(BORDER_RADIUS) - radius_box.pack_start(radius_label, True, True, 0) - radius_box.pack_start(radius_spin, False, False, 0) - appearance_box.pack_start(radius_box, False, False, 0) - - # Accent Color - accent_box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=10) - accent_box.set_margin_start(10) - accent_box.set_margin_end(10) - accent_label = Gtk.Label(label="Accent color (pins):") - accent_label.set_halign(Gtk.Align.START) - accent_label.set_hexpand(True) - accent_button = Gtk.ColorButton() - accent_rgba = Gdk.RGBA() - accent_rgba.parse(ACCENT_COLOR) - accent_button.set_rgba(accent_rgba) - accent_box.pack_start(accent_label, True, True, 0) - accent_box.pack_start(accent_button, False, False, 0) - appearance_box.pack_start(accent_box, False, False, 0) - - # Selection Color - selection_box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=10) - selection_box.set_margin_start(10) - selection_box.set_margin_end(10) - selection_label = Gtk.Label(label="Selection color:") - selection_label.set_halign(Gtk.Align.START) - selection_label.set_hexpand(True) - selection_button = Gtk.ColorButton() - selection_rgba = Gdk.RGBA() - selection_rgba.parse(SELECTION_COLOR) - selection_button.set_rgba(selection_rgba) - selection_box.pack_start(selection_label, True, True, 0) - selection_box.pack_start(selection_button, False, False, 0) - appearance_box.pack_start(selection_box, False, False, 0) - - # Visual Mode Color - visual_box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=10) - visual_box.set_margin_start(10) - visual_box.set_margin_end(10) - visual_label = Gtk.Label(label="Visual mode color:") - visual_label.set_halign(Gtk.Align.START) - visual_label.set_hexpand(True) - visual_button = Gtk.ColorButton() - visual_rgba = Gdk.RGBA() - visual_rgba.parse(VISUAL_MODE_COLOR) - visual_button.set_rgba(visual_rgba) - visual_box.pack_start(visual_label, True, True, 0) - visual_box.pack_start(visual_button, False, False, 0) - appearance_box.pack_start(visual_box, False, False, 0) - - # Reset button - reset_box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=10) - reset_box.set_margin_top(15) - reset_box.set_margin_start(10) - reset_box.set_margin_end(10) - reset_btn = Gtk.Button(label="Reset to Defaults") - reset_btn.set_tooltip_text("Reset all style settings to default values") - reset_box.pack_start(reset_btn, True, True, 0) - appearance_box.pack_start(reset_box, False, False, 0) - - appearance_frame.add(appearance_box) - style_tab.pack_start(appearance_frame, False, False, 0) - - # Add Style tab to notebook - style_scroll = Gtk.ScrolledWindow() - style_scroll.set_policy(Gtk.PolicyType.NEVER, Gtk.PolicyType.AUTOMATIC) - style_scroll.add(style_tab) - notebook.append_page(style_scroll, Gtk.Label(label="Style")) - - main_box.pack_start(notebook, True, True, 0) - - # Track changes - settings_changed = False - - # Buttons - button_box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=10) - button_box.set_homogeneous(True) - button_box.set_margin_top(10) - - # Apply & Restart button (initially disabled) - apply_btn = Gtk.Button(label="Apply & Restart") - apply_btn.set_sensitive(False) - - # Close button - close_btn = Gtk.Button(label="Close") - - def update_button_states(): - """Update the state of buttons based on whether settings have changed.""" - apply_btn.set_sensitive(settings_changed) - - def on_protect_switch_toggled(switch, state): - nonlocal settings_changed - settings_changed = True - update_button_states() - if not config.config.has_section("General"): - config.config.add_section("General") - config.config.set("General", "protect_pinned_items", str(switch.get_active())) - config._save_config() - import clipse_gui.constants as constants - - constants.PROTECT_PINNED_ITEMS = switch.get_active() - - def on_compact_switch_toggled(switch, state): - nonlocal settings_changed - settings_changed = True - update_button_states() - if not config.config.has_section("General"): - config.config.add_section("General") - config.config.set("General", "compact_mode", str(switch.get_active())) - config._save_config() - import clipse_gui.constants as constants - - constants.COMPACT_MODE = switch.get_active() - - def on_hover_switch_toggled(switch, state): - nonlocal settings_changed - settings_changed = True - update_button_states() - if not config.config.has_section("General"): - config.config.add_section("General") - config.config.set("General", "hover_to_select", str(switch.get_active())) - config._save_config() - import clipse_gui.constants as constants - - constants.HOVER_TO_SELECT = switch.get_active() - - def on_enter_paste_switch_toggled(switch, state): - nonlocal settings_changed - settings_changed = True - update_button_states() - if not config.config.has_section("General"): - config.config.add_section("General") - config.config.set("General", "enter_to_paste", str(switch.get_active())) - config._save_config() - import clipse_gui.constants as constants - - constants.ENTER_TO_PASTE = switch.get_active() - - def on_highlight_search_switch_toggled(switch, state): - nonlocal settings_changed - settings_changed = True - update_button_states() - if not config.config.has_section("General"): - config.config.add_section("General") - config.config.set("General", "highlight_search", str(switch.get_active())) - config._save_config() - import clipse_gui.constants as constants - - constants.HIGHLIGHT_SEARCH = switch.get_active() - - def on_open_links_switch_toggled(switch, state): - nonlocal settings_changed - settings_changed = True - update_button_states() - if not config.config.has_section("General"): - config.config.add_section("General") - config.config.set("General", "open_links_with_browser", str(switch.get_active())) - config._save_config() - import clipse_gui.constants as constants - - constants.OPEN_LINKS_WITH_BROWSER = switch.get_active() - - def on_rich_content_switch_toggled(switch, state): - nonlocal settings_changed - settings_changed = True - update_button_states() - if not config.config.has_section("General"): - config.config.add_section("General") - config.config.set("General", "preview_rich_content", str(switch.get_active())) - config._save_config() - import clipse_gui.constants as constants - - constants.PREVIEW_RICH_CONTENT = switch.get_active() - - def on_tray_switch_toggled(switch, state): - nonlocal settings_changed - settings_changed = True - update_button_states() - if not config.config.has_section("General"): - config.config.add_section("General") - config.config.set("General", "minimize_to_tray", str(switch.get_active())) - config._save_config() - import clipse_gui.constants as constants - - constants.MINIMIZE_TO_TRAY = switch.get_active() - try: - app = parent_window.get_application() - if hasattr(app, "tray_manager") and app.tray_manager: - app.tray_manager.set_tray_enabled(switch.get_active()) - except Exception as e: - logging.debug(f"Could not update tray manager dynamically: {e}") - - def on_tray_items_changed(spin): - nonlocal settings_changed - settings_changed = True - update_button_states() - if not config.config.has_section("General"): - config.config.add_section("General") - config.config.set("General", "tray_items_count", str(int(spin.get_value()))) - config._save_config() - import clipse_gui.constants as constants - - constants.TRAY_ITEMS_COUNT = int(spin.get_value()) - - def on_tray_paste_switch_toggled(switch, state): - nonlocal settings_changed - settings_changed = True - update_button_states() - if not config.config.has_section("General"): - config.config.add_section("General") - config.config.set("General", "tray_paste_on_select", str(switch.get_active())) - config._save_config() - import clipse_gui.constants as constants - - constants.TRAY_PASTE_ON_SELECT = switch.get_active() - - # Style signal handlers - def on_radius_changed(spin): - nonlocal settings_changed - settings_changed = True - update_button_states() - value = int(spin.get_value()) - if not config.config.has_section("Style"): - config.config.add_section("Style") - config.config.set("Style", "border_radius", str(value)) - config._save_config() - import clipse_gui.constants as constants - constants.BORDER_RADIUS = value - if update_style_cb: - update_style_cb(border_radius=value) - - def on_accent_color_changed(button): - nonlocal settings_changed - settings_changed = True - update_button_states() - rgba = button.get_rgba() - color = f"#{int(rgba.red * 255):02x}{int(rgba.green * 255):02x}{int(rgba.blue * 255):02x}" - if not config.config.has_section("Style"): - config.config.add_section("Style") - config.config.set("Style", "accent_color", color) - config._save_config() - import clipse_gui.constants as constants - constants.ACCENT_COLOR = color - if update_style_cb: - update_style_cb(accent_color=color) - - def on_selection_color_changed(button): - nonlocal settings_changed - settings_changed = True - update_button_states() - rgba = button.get_rgba() - color = f"#{int(rgba.red * 255):02x}{int(rgba.green * 255):02x}{int(rgba.blue * 255):02x}" - if not config.config.has_section("Style"): - config.config.add_section("Style") - config.config.set("Style", "selection_color", color) - config._save_config() - import clipse_gui.constants as constants - constants.SELECTION_COLOR = color - if update_style_cb: - update_style_cb(selection_color=color) - - def on_visual_color_changed(button): - nonlocal settings_changed - settings_changed = True - update_button_states() - rgba = button.get_rgba() - color = f"#{int(rgba.red * 255):02x}{int(rgba.green * 255):02x}{int(rgba.blue * 255):02x}" - if not config.config.has_section("Style"): - config.config.add_section("Style") - config.config.set("Style", "visual_mode_color", color) - config._save_config() - import clipse_gui.constants as constants - constants.VISUAL_MODE_COLOR = color - if update_style_cb: - update_style_cb(visual_mode_color=color) - - def on_reset_styles(button): - if not style_defaults: - return - # Reset to defaults - radius_spin.set_value(style_defaults.get("border_radius", 6)) - - accent_rgba = Gdk.RGBA() - accent_rgba.parse(style_defaults.get("accent_color", "#ffcc00")) - accent_button.set_rgba(accent_rgba) - - selection_rgba = Gdk.RGBA() - selection_rgba.parse(style_defaults.get("selection_color", "#4a90e2")) - selection_button.set_rgba(selection_rgba) - - visual_rgba = Gdk.RGBA() - visual_rgba.parse(style_defaults.get("visual_mode_color", "#9b59b6")) - visual_button.set_rgba(visual_rgba) - - # Save defaults to config - if not config.config.has_section("Style"): - config.config.add_section("Style") - for key, value in style_defaults.items(): - config.config.set("Style", key, str(value)) - config._save_config() - - # Update constants and apply - import clipse_gui.constants as constants - constants.BORDER_RADIUS = style_defaults.get("border_radius", 6) - constants.ACCENT_COLOR = style_defaults.get("accent_color", "#ffcc00") - constants.SELECTION_COLOR = style_defaults.get("selection_color", "#4a90e2") - constants.VISUAL_MODE_COLOR = style_defaults.get("visual_mode_color", "#9b59b6") - - if update_style_cb: - update_style_cb( - border_radius=constants.BORDER_RADIUS, - accent_color=constants.ACCENT_COLOR, - selection_color=constants.SELECTION_COLOR, - visual_mode_color=constants.VISUAL_MODE_COLOR, - ) - - # Connect signals - protect_switch.connect("state-set", on_protect_switch_toggled) - compact_switch.connect("state-set", on_compact_switch_toggled) - hover_switch.connect("state-set", on_hover_switch_toggled) - enter_paste_switch.connect("state-set", on_enter_paste_switch_toggled) - highlight_search_switch.connect("state-set", on_highlight_search_switch_toggled) - open_links_switch.connect("state-set", on_open_links_switch_toggled) - rich_content_switch.connect("state-set", on_rich_content_switch_toggled) - tray_switch.connect("state-set", on_tray_switch_toggled) - tray_items_spin.connect("value-changed", on_tray_items_changed) - tray_paste_switch.connect("state-set", on_tray_paste_switch_toggled) - - # Style signals - radius_spin.connect("value-changed", on_radius_changed) - accent_button.connect("color-set", on_accent_color_changed) - selection_button.connect("color-set", on_selection_color_changed) - visual_button.connect("color-set", on_visual_color_changed) - reset_btn.connect("clicked", on_reset_styles) - - def on_apply_clicked(button): - settings_window.destroy() - if restart_app_cb: - restart_app_cb() - - apply_btn.connect("clicked", on_apply_clicked) - - def on_close_clicked(button): - settings_window.destroy() - if settings_changed and restart_app_cb: - dialog = Gtk.MessageDialog( - transient_for=settings_window, - modal=True, - message_type=Gtk.MessageType.QUESTION, - buttons=Gtk.ButtonsType.YES_NO, - text="Settings have been changed. Restart to apply changes?", - ) - response = dialog.run() - dialog.destroy() - if response == Gtk.ResponseType.YES: - restart_app_cb() - - close_btn.connect("clicked", on_close_clicked) - - button_box.pack_start(apply_btn, True, True, 0) - button_box.pack_start(close_btn, True, True, 0) - main_box.pack_end(button_box, False, False, 0) - - settings_window.add(main_box) - settings_window.connect( - "key-press-event", - lambda w, e: close_cb(w) if e.keyval == Gdk.KEY_Escape else None, - ) - settings_window.show_all() - close_btn.grab_focus() - - -# --- Preview Window --- - - -def show_preview_window( - parent_window, item, is_image, change_text_size_cb, reset_text_size_cb, key_press_cb -): - """Creates and shows the item preview window.""" - preview_window = Gtk.Window(title="Preview") - preview_window.set_position(Gtk.WindowPosition.CENTER_ON_PARENT) - preview_window.set_transient_for(parent_window) - preview_window.set_modal(True) - preview_window.connect("key-press-event", key_press_cb) - - vbox = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=5) - vbox.set_border_width(5) - - if is_image: - image_path = item.get("filePath") - if image_path and os.path.exists(image_path): - try: - pixbuf = GdkPixbuf.Pixbuf.new_from_file(image_path) - if pixbuf is None: - raise GLib.Error( - GLib.ErrorDomain.G_FILE, - GLib.ErrorEnum.INVALID_ARGUMENT, - ) - - image = Gtk.Image.new_from_pixbuf(pixbuf) - image.set_halign(Gtk.Align.CENTER) - image.set_valign(Gtk.Align.CENTER) - - display = parent_window.get_display() - # Get GDK window from GTK window - gdk_window = parent_window.get_window() - monitor = ( - display.get_monitor_at_window(gdk_window) if gdk_window else None - ) - - if monitor: - geometry = monitor.get_geometry() - max_w = geometry.width * 0.8 - max_h = geometry.height * 0.8 - else: - max_w = 1200 - max_h = 800 - - img_w = pixbuf.get_width() - img_h = pixbuf.get_height() - # Calculate scaling while maintaining aspect ratio - if img_w > max_w or img_h > max_h: - scale = min(max_w / img_w, max_h / img_h) - w = int(img_w * scale) - h = int(img_h * scale) - else: - w = img_w - h = img_h - - # Create scaled pixbuf - scaled_pixbuf = pixbuf.scale_simple(w, h, GdkPixbuf.InterpType.BILINEAR) - image.set_from_pixbuf(scaled_pixbuf) - - preview_window.set_default_size(w, h) - scrolled = Gtk.ScrolledWindow() - scrolled.set_policy(Gtk.PolicyType.AUTOMATIC, Gtk.PolicyType.AUTOMATIC) - scrolled.add(image) - vbox.pack_start(scrolled, True, True, 0) - - except GLib.Error as e: - label = Gtk.Label(label=f"Error loading image preview:\n{e.message}") - label.set_line_wrap(True) - label.set_halign(Gtk.Align.CENTER) - label.set_valign(Gtk.Align.CENTER) - vbox.pack_start(label, True, True, 0) - preview_window.set_default_size( - DEFAULT_PREVIEW_IMG_WIDTH, DEFAULT_PREVIEW_IMG_HEIGHT - ) - else: # Text Preview - text_value = item.get("value", "") - scrolled_window = Gtk.ScrolledWindow() - scrolled_window.set_policy(Gtk.PolicyType.AUTOMATIC, Gtk.PolicyType.AUTOMATIC) - scrolled_window.set_hexpand(True) - scrolled_window.set_vexpand(True) - - preview_text_view = Gtk.TextView() - preview_text_view.get_buffer().set_text(text_value) - preview_text_view.set_editable(False) - preview_text_view.set_wrap_mode(Gtk.WrapMode.WORD_CHAR) - context = preview_text_view.get_style_context() - provider = Gtk.CssProvider() - provider.load_from_data(b"textview { font-family: Monospace; }") - context.add_provider(provider, Gtk.STYLE_PROVIDER_PRIORITY_USER) - - pango_context = preview_text_view.get_pango_context() - if pango_context: - font_desc = pango_context.get_font_description() - if font_desc: - base_font_size = font_desc.get_size() / Pango.SCALE - if base_font_size <= 0: - base_font_size = 10 - else: - base_font_size = 10 - else: - base_font_size = 10 - preview_text_view.base_font_size = base_font_size - - scrolled_window.add(preview_text_view) - vbox.pack_start(scrolled_window, True, True, 0) - - action_box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=5) - action_box.set_halign(Gtk.Align.CENTER) - - # Format button - format_btn = Gtk.Button() - format_btn.set_image( - Gtk.Image.new_from_icon_name( - "format-text-bold-symbolic", - Gtk.IconSize.BUTTON, # type: ignore - ) - ) - format_btn.set_tooltip_text("Format text (pretty-print JSON) - Ctrl+B") - format_btn.connect("clicked", lambda b: _format_text_content(preview_text_view)) - - # Find button - find_btn = Gtk.Button() - find_btn.set_image( - Gtk.Image.new_from_icon_name("edit-find-symbolic", Gtk.IconSize.BUTTON) # type: ignore - ) - find_btn.set_tooltip_text("Find text (Ctrl+F)") - - # Create enhanced search bar (initially hidden) - search_bar = Gtk.SearchBar() - - # Create search container with entry and buttons - search_container = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=5) - search_container.set_margin_left(5) - search_container.set_margin_right(5) - search_container.set_margin_top(3) - search_container.set_margin_bottom(3) - - # Search entry - search_entry = Gtk.SearchEntry() - search_entry.set_placeholder_text("Search text...") - search_entry.set_hexpand(True) - search_container.pack_start(search_entry, True, True, 0) - - # Match counter label - match_label = Gtk.Label() - match_label.set_text("0/0") - match_label.set_margin_left(5) - match_label.set_margin_right(5) - search_container.pack_start(match_label, False, False, 0) - - # Previous button - prev_btn = Gtk.Button() - prev_btn.set_image( - Gtk.Image.new_from_icon_name("go-up-symbolic", Gtk.IconSize.BUTTON) # type: ignore - ) - prev_btn.set_tooltip_text("Previous match (Shift+Enter)") - search_container.pack_start(prev_btn, False, False, 0) - - # Next button - next_btn = Gtk.Button() - next_btn.set_image( - Gtk.Image.new_from_icon_name("go-down-symbolic", Gtk.IconSize.BUTTON) # type: ignore - ) - next_btn.set_tooltip_text("Next match (Enter)") - search_container.pack_start(next_btn, False, False, 0) - - # Close button - close_btn = Gtk.Button() - close_btn.set_image( - Gtk.Image.new_from_icon_name("window-close-symbolic", Gtk.IconSize.BUTTON) # type: ignore - ) - close_btn.set_tooltip_text("Close search (Escape)") - search_container.pack_start(close_btn, False, False, 0) - - search_bar.add(search_container) - search_bar.connect_entry(search_entry) - - # Insert search bar after scrolled window - vbox.pack_start(search_bar, False, False, 0) - vbox.reorder_child(search_bar, 1) # Place after scrolled window - - find_btn.connect( - "clicked", - lambda b: _toggle_search_bar( - search_bar, - search_entry, - preview_text_view, - match_label, - prev_btn, - next_btn, - close_btn, - ), - ) - - # Zoom controls - zoom_out = Gtk.Button() - zoom_out.set_image( - Gtk.Image.new_from_icon_name("zoom-out-symbolic", Gtk.IconSize.BUTTON) # type: ignore - ) - zoom_out.connect( - "clicked", lambda b: change_text_size_cb(preview_text_view, -1) - ) - zoom_in = Gtk.Button() - zoom_in.set_image( - Gtk.Image.new_from_icon_name("zoom-in-symbolic", Gtk.IconSize.BUTTON) # type: ignore - ) - zoom_in.connect("clicked", lambda b: change_text_size_cb(preview_text_view, 1)) - zoom_reset = Gtk.Button() - zoom_reset.set_image( - Gtk.Image.new_from_icon_name("zoom-original-symbolic", Gtk.IconSize.BUTTON) # type: ignore - ) - zoom_reset.connect("clicked", lambda b: reset_text_size_cb(preview_text_view)) - - action_box.pack_start(format_btn, False, False, 0) - action_box.pack_start(find_btn, False, False, 0) - action_box.pack_start( - Gtk.Separator(orientation=Gtk.Orientation.VERTICAL), False, False, 5 - ) - action_box.pack_start(Gtk.Label(label="Zoom:"), False, False, 0) - action_box.pack_start(zoom_out, False, False, 0) - action_box.pack_start(zoom_reset, False, False, 0) - action_box.pack_start(zoom_in, False, False, 0) - vbox.pack_end(action_box, False, False, 5) - - preview_window.set_default_size( - DEFAULT_PREVIEW_TEXT_WIDTH, DEFAULT_PREVIEW_TEXT_HEIGHT - ) - - preview_window.add(vbox) - preview_window.show_all() - - -def _format_text_content(text_view): - """Formats the text content in the TextView, with special handling for JSON.""" - buffer = text_view.get_buffer() - start, end = buffer.get_bounds() - text = buffer.get_text(start, end, False) - - if not text.strip(): - return - - formatted_text = None - - # Try to format as JSON first - try: - # Remove any leading/trailing whitespace and check if it looks like JSON - stripped_text = text.strip() - if (stripped_text.startswith("{") and stripped_text.endswith("}")) or ( - stripped_text.startswith("[") and stripped_text.endswith("]") - ): - # Try to parse and format as JSON - parsed_json = json.loads(stripped_text) - formatted_text = json.dumps( - parsed_json, indent=2, ensure_ascii=False, sort_keys=True - ) - except (json.JSONDecodeError, ValueError): - # Not valid JSON, try other formatting - pass - - # If not JSON, try to format as other structured text - if formatted_text is None: - # Basic text formatting - normalize whitespace and line breaks - lines = text.split("\n") - formatted_lines = [] - - for line in lines: - # Remove excessive whitespace but preserve intentional indentation - stripped = line.rstrip() - if stripped: - # Preserve leading whitespace for indentation - leading_spaces = len(line) - len(line.lstrip()) - formatted_lines.append(" " * leading_spaces + stripped) - else: - formatted_lines.append("") - - # Remove excessive blank lines (more than 2 consecutive) - result_lines = [] - blank_count = 0 - for line in formatted_lines: - if line.strip() == "": - blank_count += 1 - if blank_count <= 2: - result_lines.append(line) - else: - blank_count = 0 - result_lines.append(line) - - formatted_text = "\n".join(result_lines) - - # Update the buffer with formatted text - if formatted_text and formatted_text != text: - buffer.set_text(formatted_text) - # Show a brief status message - GLib.timeout_add(100, lambda: _flash_format_status(text_view, "Text formatted")) - else: - GLib.timeout_add( - 100, lambda: _flash_format_status(text_view, "No formatting applied") - ) - - -def _flash_format_status(text_view, message): - """Shows a brief status message by temporarily changing the tooltip.""" - original_tooltip = text_view.get_tooltip_text() - text_view.set_tooltip_text(message) - - def restore_tooltip(): - text_view.set_tooltip_text(original_tooltip) - return False - - GLib.timeout_add(2000, restore_tooltip) - return False - - -def _toggle_search_bar( - search_bar, - search_entry, - text_view, - match_label=None, - prev_btn=None, - next_btn=None, - close_btn=None, -): - """Toggles the enhanced search bar visibility and handles search functionality.""" - if search_bar.get_search_mode(): - # Hide search bar and clear highlights - search_bar.set_search_mode(False) - buffer = text_view.get_buffer() - start, end = buffer.get_bounds() - buffer.remove_all_tags(start, end) - else: - # Show search bar and focus entry - search_bar.set_search_mode(True) - search_entry.grab_focus() - - # Set up enhanced search functionality if not already done - if not hasattr(search_entry, "_search_setup"): - search_entry._search_setup = True - search_entry._search_state = { - "last_search": "", - "matches": [], - "current_index": 0, - "highlight_tag": None, - "current_tag": None, - } - - def update_match_label(): - """Update the match counter label.""" - if match_label: - matches = search_entry._search_state["matches"] - if matches: - current = search_entry._search_state["current_index"] + 1 - total = len(matches) - match_label.set_text(f"{current}/{total}") - else: - match_label.set_text("0/0") - - def perform_search(): - """Perform search and highlight all matches.""" - search_text = search_entry.get_text() - buffer = text_view.get_buffer() - start, end = buffer.get_bounds() - - # Clear previous highlights - buffer.remove_all_tags(start, end) - - if not search_text: - search_entry._search_state["matches"] = [] - update_match_label() - return - - content = buffer.get_text(start, end, False) - - # Find all matches (case insensitive) - matches = [] - search_lower = search_text.lower() - content_lower = content.lower() - start_pos = 0 - - while True: - pos = content_lower.find(search_lower, start_pos) - if pos == -1: - break - matches.append(pos) - start_pos = pos + 1 - - search_entry._search_state["matches"] = matches - - if matches: - # Create highlight tags with different colors - if search_entry._search_state["highlight_tag"]: - buffer.get_tag_table().remove( - search_entry._search_state["highlight_tag"] - ) - if search_entry._search_state["current_tag"]: - buffer.get_tag_table().remove( - search_entry._search_state["current_tag"] - ) - - # All matches - light blue background - highlight_tag = buffer.create_tag( - None, background="#87CEEB", foreground="black" - ) - search_entry._search_state["highlight_tag"] = highlight_tag - - # Current match - orange background - current_tag = buffer.create_tag( - None, background="#FFA500", foreground="black" - ) - search_entry._search_state["current_tag"] = current_tag - - # Highlight all matches - for match_pos in matches: - start_iter = buffer.get_iter_at_offset(match_pos) - end_iter = buffer.get_iter_at_offset( - match_pos + len(search_text) - ) - buffer.apply_tag(highlight_tag, start_iter, end_iter) - - # Jump to first match if this is a new search - if search_entry._search_state["last_search"] != search_text: - search_entry._search_state["current_index"] = 0 - search_entry._search_state["last_search"] = search_text - - highlight_current_match() - - update_match_label() - - def highlight_current_match(): - """Highlight the current match with a different color.""" - matches = search_entry._search_state["matches"] - if not matches: - return - - current_index = search_entry._search_state["current_index"] - current_match_pos = matches[current_index] - search_text = search_entry.get_text() - - # Remove current highlight from all matches - buffer = text_view.get_buffer() - start, end = buffer.get_bounds() - if search_entry._search_state["current_tag"]: - buffer.remove_tag( - search_entry._search_state["current_tag"], start, end - ) - - # Highlight current match - start_iter = buffer.get_iter_at_offset(current_match_pos) - end_iter = buffer.get_iter_at_offset( - current_match_pos + len(search_text) - ) - buffer.apply_tag( - search_entry._search_state["current_tag"], start_iter, end_iter - ) - - # Scroll to current match - text_view.scroll_to_iter(start_iter, 0.0, False, 0.0, 0.0) - buffer.place_cursor(start_iter) - - update_match_label() - - def find_next(): - """Go to next match.""" - matches = search_entry._search_state["matches"] - if matches: - search_entry._search_state["current_index"] = ( - search_entry._search_state["current_index"] + 1 - ) % len(matches) - highlight_current_match() - - def find_previous(): - """Go to previous match.""" - matches = search_entry._search_state["matches"] - if matches: - search_entry._search_state["current_index"] = ( - search_entry._search_state["current_index"] - 1 - ) % len(matches) - highlight_current_match() - - def close_search(): - """Close the search bar.""" - search_bar.set_search_mode(False) - buffer = text_view.get_buffer() - start, end = buffer.get_bounds() - buffer.remove_all_tags(start, end) - - # Store functions on search_entry for access from button callbacks - search_entry._find_next = find_next - search_entry._find_previous = find_previous - search_entry._close_search = close_search - search_entry._perform_search = perform_search - - # Connect signals - search_entry.connect("search-changed", lambda e: perform_search()) - search_entry.connect("activate", lambda e: find_next()) - - # Handle keyboard shortcuts - def on_key_press(widget, event): - if event.keyval == Gdk.KEY_Escape: - close_search() - return True - elif ( - event.state & Gdk.ModifierType.SHIFT_MASK - and event.keyval == Gdk.KEY_Return - ): - find_previous() - return True - return False - - search_entry.connect("key-press-event", on_key_press) - - # Connect button signals every time (in case buttons were recreated) - if next_btn and hasattr(search_entry, "_find_next"): - # Disconnect all existing handlers to avoid duplicates - try: - if hasattr(next_btn, "_search_handler_ids"): - for handler_id in next_btn._search_handler_ids: - next_btn.disconnect(handler_id) - except Exception: - pass - handler_id = next_btn.connect( - "clicked", lambda b: search_entry._find_next() - ) - next_btn._search_handler_ids = [handler_id] - - if prev_btn and hasattr(search_entry, "_find_previous"): - try: - if hasattr(prev_btn, "_search_handler_ids"): - for handler_id in prev_btn._search_handler_ids: - prev_btn.disconnect(handler_id) - except Exception: - pass - handler_id = prev_btn.connect( - "clicked", lambda b: search_entry._find_previous() - ) - prev_btn._search_handler_ids = [handler_id] - - if close_btn and hasattr(search_entry, "_close_search"): - try: - if hasattr(close_btn, "_search_handler_ids"): - for handler_id in close_btn._search_handler_ids: - close_btn.disconnect(handler_id) - except Exception: - pass - handler_id = close_btn.connect( - "clicked", lambda b: search_entry._close_search() - ) - close_btn._search_handler_ids = [handler_id] - - -# Legacy function alias for backward compatibility +# Legacy alias preserved from the original module toggle_search_bar = _toggle_search_bar + +__all__ = [ + "_create_section_frame", + "_create_setting_row", + "_flash_format_status", + "_format_text_content", + "_is_data_uri", + "_is_image_url", + "_is_svg_content", + "_is_url", + "_toggle_search_bar", + "animate_pin_shake", + "create_list_row_widget", + "create_pin_icon", + "escape_markup", + "highlight_search_term", + "show_help_window", + "show_preview_window", + "show_settings_window", + "toggle_search_bar", +] From 38d354265084e5d596e9fa13605a97a4bcb9b91a Mon Sep 17 00:00:00 2001 From: d7om Date: Thu, 16 Apr 2026 01:45:04 +0200 Subject: [PATCH 5/5] feat(ui): polish header, settings appearance, and lifecycle handling - Header search + pin filter button: dedicated CSS classes, matching heights, accent-colored active state on pin toggle - Spinbutton: flatten internal entry and buttons into one unified control; remove duplicate focus rings and inner borders - Switch: drop redundant focus ring (state already visible via slider) - List rows: round all four corners (was right-only) - Compact mode: pick window size at startup based on config so the WM honors smaller dimensions; persistently hide search + pin filter via set_no_show_all when compact is on - Graceful exit: handle SIGINT/SIGTERM via GLib unix signal hook so Ctrl+C in terminal triggers clean shutdown (saves history, cleans up tray) --- clipse_gui/app.py | 30 +++++++- clipse_gui/constants.py | 144 +++++++++++++++++++++++++++++++++------ clipse_gui/ui_builder.py | 18 ++++- 3 files changed, 169 insertions(+), 23 deletions(-) diff --git a/clipse_gui/app.py b/clipse_gui/app.py index 303d7e4..9865a9d 100644 --- a/clipse_gui/app.py +++ b/clipse_gui/app.py @@ -1,4 +1,5 @@ import logging +import signal from . import constants from .constants import ( APP_NAME, @@ -30,6 +31,21 @@ def do_startup(self): """Called once when the application starts.""" Gtk.Application.do_startup(self) log.debug(f"Application {APPLICATION_ID} starting up.") + self._install_signal_handlers() + + def _install_signal_handlers(self): + """Wire SIGINT (Ctrl+C) and SIGTERM into the GLib main loop for graceful exit.""" + def _on_signal(sig_name): + log.info(f"{sig_name} received — quitting application gracefully.") + self.quit() + return GLib.SOURCE_REMOVE + + GLib.unix_signal_add( + GLib.PRIORITY_DEFAULT, signal.SIGINT, _on_signal, "SIGINT" + ) + GLib.unix_signal_add( + GLib.PRIORITY_DEFAULT, signal.SIGTERM, _on_signal, "SIGTERM" + ) def do_activate(self): """Called when the application is launched. Creates the main window and controller.""" @@ -37,7 +53,19 @@ def do_activate(self): log.debug("Activating application - creating main window.") self.window = Gtk.ApplicationWindow(application=self, title=APP_NAME) - self.window.set_default_size(DEFAULT_WINDOW_WIDTH, DEFAULT_WINDOW_HEIGHT) + + # Pick initial window size based on compact mode setting from config. + # This must happen BEFORE first show so the WM honors it. + compact_mode_on = constants.config.getboolean( + "General", "compact_mode", fallback=False + ) + if compact_mode_on: + init_w = int(DEFAULT_WINDOW_WIDTH * 0.6) + init_h = int(DEFAULT_WINDOW_HEIGHT * 0.6) + else: + init_w = DEFAULT_WINDOW_WIDTH + init_h = DEFAULT_WINDOW_HEIGHT + self.window.set_default_size(init_w, init_h) try: self.window.set_icon_name("edit-copy") except GLib.Error as e: diff --git a/clipse_gui/constants.py b/clipse_gui/constants.py index f5a13a2..55a36e8 100644 --- a/clipse_gui/constants.py +++ b/clipse_gui/constants.py @@ -166,14 +166,14 @@ def get_app_css( border-left: 3px solid {accent_color}; background-color: alpha({accent_color}, 0.01); font-weight: 500; - border-radius: 0 {border_radius}px {border_radius}px 0; + border-radius: {border_radius}px; }} .list-row {{ padding: 8px 12px; margin-top: 1px; margin-bottom: 1px; border-left: 3px solid transparent; - border-radius: 0 {border_radius}px {border_radius}px 0; + border-radius: {border_radius}px; transition: background-color 0.2s ease, border-left-color 0.2s ease; }} @@ -332,9 +332,15 @@ def get_app_css( padding: 6px 10px; }} -entry:focus {{ +/* Focus ring only on standalone entries — not when nested inside spinbutton */ +entry:focus:not(:backdrop) {{ outline: none; - box-shadow: 0 0 0 2px alpha({selection_color}, 0.5); + box-shadow: inset 0 0 0 1px alpha({selection_color}, 0.55); +}} + +spinbutton > entry:focus, +spinbutton entry:focus {{ + box-shadow: none; }} /* Switch styling - applies to all windows */ @@ -352,10 +358,10 @@ def get_app_css( background-color: {selection_color}; }} -/* Remove focus ring from switch */ +/* Remove focus ring from switch — switch already shows state via slider position */ switch:focus {{ outline: none; - box-shadow: 0 0 0 2px alpha({selection_color}, 0.5); + box-shadow: none; }} /* GTK3 switch slider (thumb) - always circular */ @@ -366,29 +372,58 @@ def get_app_css( background-color: #ffffff; }} -/* Spinbutton styling - applies to all windows */ -spinbutton, -spinbutton.entry {{ +/* Spinbutton — flat unified container, kill internal separators */ +spinbutton {{ border-radius: {border_radius}px; outline: none; }} -/* Remove default focus ring from spinbutton, use custom shadow */ -spinbutton:focus, -spinbutton.entry:focus {{ +spinbutton entry, +spinbutton > entry {{ + background: transparent; + background-color: transparent; + background-image: none; + border: none; + border-right: none; + box-shadow: none; outline: none; - box-shadow: 0 0 0 2px alpha({selection_color}, 0.5); + min-height: 26px; + padding: 2px 8px; +}} + +spinbutton entry:focus, +spinbutton > entry:focus {{ + background: transparent; + background-color: transparent; + background-image: none; + border: none; + box-shadow: none; + outline: none; +}} + +spinbutton:focus-within {{ + outline: none; + box-shadow: 0 0 0 1px alpha({selection_color}, 0.45); }} /* Spinbutton up/down buttons */ spinbutton button {{ border-radius: {max(2, border_radius - 2)}px; - padding: 2px 6px; + padding: 2px 8px; margin: 1px; + background: transparent; + background-image: none; + border: none; + border-left: none; + box-shadow: none; }} -/* Spinbutton button focus - completely remove any ring */ -spinbutton button:focus {{ +spinbutton button:hover {{ + background-color: alpha(#ffffff, 0.08); +}} + +spinbutton button:focus, +spinbutton button:active {{ outline: none; box-shadow: none; border-color: transparent; @@ -427,7 +462,7 @@ def get_app_css( .main-window list row, .main-window listbox row {{ - border-radius: 0 {border_radius}px {border_radius}px 0; + border-radius: {border_radius}px; outline: none; }} @@ -435,12 +470,12 @@ def get_app_css( .main-window listbox row:focus {{ outline: none; box-shadow: inset 0 0 0 2px alpha({selection_color}, 0.4); - border-radius: 0 {border_radius}px {border_radius}px 0; + border-radius: {border_radius}px; }} .main-window list row:selected, .main-window listbox row:selected {{ - border-radius: 0 {border_radius}px {border_radius}px 0; + border-radius: {border_radius}px; }} /* URL / link item styling */ @@ -453,6 +488,77 @@ def get_app_css( color: alpha(#ffffff, 0.45); font-style: italic; }} + +/* ============================================ + HEADER BAR (search + pin filter) + ============================================ */ + +.main-window .header-bar {{ + margin-bottom: 4px; +}} + +.main-window .header-search {{ + min-height: 34px; + border-radius: {border_radius}px; +}} + +.main-window .header-search:focus, +.main-window .header-search:focus-within {{ + box-shadow: inset 0 0 0 1px alpha({selection_color}, 0.55); +}} + +.main-window .pin-toggle {{ + min-height: 34px; + padding: 0 14px; + border-radius: {border_radius}px; + border: 1px solid alpha(#ffffff, 0.12); + background-color: alpha(#ffffff, 0.04); + background-image: none; + color: alpha(#ffffff, 0.72); + font-weight: 500; + transition: background-color 0.18s ease, + border-color 0.18s ease, + color 0.18s ease; +}} + +.main-window .pin-toggle:hover {{ + background-color: alpha(#ffffff, 0.09); + border-color: alpha(#ffffff, 0.20); + color: alpha(#ffffff, 0.95); +}} + +.main-window .pin-toggle:checked, +.main-window .pin-toggle:active {{ + background-color: alpha({accent_color}, 0.18); + background-image: none; + border-color: alpha({accent_color}, 0.55); + color: {accent_color}; + font-weight: 600; +}} + +.main-window .pin-toggle:checked:hover {{ + background-color: alpha({accent_color}, 0.26); + border-color: {accent_color}; +}} + +.main-window .pin-toggle image {{ + -gtk-icon-transform: scale(0.92); +}} + +/* ============================================ + SETTINGS — APPEARANCE TAB POLISH + ============================================ */ + +colorbutton {{ + min-width: 64px; + min-height: 30px; +}} + +colorbutton button {{ + min-width: 64px; + min-height: 30px; + padding: 2px; +}} """ diff --git a/clipse_gui/ui_builder.py b/clipse_gui/ui_builder.py index 9920ea9..4856e32 100644 --- a/clipse_gui/ui_builder.py +++ b/clipse_gui/ui_builder.py @@ -37,12 +37,24 @@ def build_main_window_content() -> dict: # --- Header --- header_box = Gtk.Box( - orientation=Gtk.Orientation.HORIZONTAL, spacing=5 if not COMPACT_MODE else 1 + orientation=Gtk.Orientation.HORIZONTAL, spacing=8 if not COMPACT_MODE else 4 ) - search_entry = Gtk.SearchEntry(placeholder_text="Search...") + header_box.get_style_context().add_class("header-bar") + + search_entry = Gtk.SearchEntry(placeholder_text="Search clipboard…") + search_entry.get_style_context().add_class("header-search") + search_entry.set_hexpand(True) header_box.pack_start(search_entry, True, True, 0) - pin_filter_button = Gtk.ToggleButton(label="Pinned Only") + pin_filter_button = Gtk.ToggleButton() + pin_filter_button.set_tooltip_text("Show pinned items only (Tab)") + pin_filter_button.get_style_context().add_class("pin-toggle") + pin_inner = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=6) + pin_icon_img = Gtk.Image.new_from_icon_name("starred-symbolic", Gtk.IconSize.BUTTON) + pin_label = Gtk.Label(label="Pinned") + pin_inner.pack_start(pin_icon_img, False, False, 0) + pin_inner.pack_start(pin_label, False, False, 0) + pin_filter_button.add(pin_inner) if not COMPACT_MODE: header_box.pack_start(pin_filter_button, False, False, 0)