From f6532b05c3402c99497a1e97661d0d62038d85e7 Mon Sep 17 00:00:00 2001 From: d7om Date: Thu, 12 Feb 2026 13:09:39 +0200 Subject: [PATCH 1/6] feat(build): add justfile with smart task runner - Replace Makefile with justfile featuring: - Auto venv detection and activation - Recipe groups (dev, qa, build, install, version, clean) - Native version bumping with --dry-run, --commit, --tag flags - Smart suggestions based on commit history - Changelog and version preview commands --- justfile | 580 +++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 580 insertions(+) create mode 100644 justfile diff --git a/justfile b/justfile new file mode 100644 index 0000000..ffef425 --- /dev/null +++ b/justfile @@ -0,0 +1,580 @@ +# Clipse GUI - Just command runner +# +# Available recipes: run `just` or `just --list` +# ============================================================================ +# Settings +# ============================================================================ +# Load .env file if present + +set dotenv-load := true + +# Use bash for recipes (better for complex scripts) + +set shell := ["bash", "-euo", "pipefail", "-c"] + +# Windows-specific shell fallback + +set windows-shell := ["powershell.exe", "-NoLogo", "-Command"] + +# Allow positional arguments in recipes + +set positional-arguments := true + +# ============================================================================ +# Variables +# ============================================================================ +# Application metadata + +export APP_NAME := "clipse-gui" +export APP_SCRIPT := APP_NAME + ".py" +export PACKAGE_DIR := "clipse_gui" +export ICON_FILE := APP_NAME + ".png" + +# Installation paths (configurable via env vars) + +export PREFIX := env("PREFIX", "/usr/local") +export BIN_DIR := PREFIX / "bin" +export SHARE_DIR := PREFIX / "share" +export APP_DIR := SHARE_DIR / APP_NAME +export ICON_DEST_DIR := SHARE_DIR / "icons/hicolor/128x128/apps" +export ICON_CACHE_DIR := SHARE_DIR / "icons/hicolor" +export DESKTOP_DEST_DIR := SHARE_DIR / "applications" + +# Build directories + +export BUILD_DIR := "dist" +export NUITKA_DIST_DIR := APP_NAME + ".dist" +export NUITKA_BINARY := APP_NAME + ".bin" + +# Python configuration - auto-detects venv + +export PYTHON := if path_exists("venv/bin/python") == "true" { "venv/bin/python" } else if path_exists(".venv/bin/python") == "true" { ".venv/bin/python" } else { env("PYTHON", "python3") } + +# Virtual environment activation command (empty if using system python) + +export VENV_ACTIVATE := if path_exists("venv/bin/activate") == "true" { "source venv/bin/activate && " } else if path_exists(".venv/bin/activate") == "true" { "source .venv/bin/activate && " } else { "" } + +# Nuitka build options + +NUITKA_OPTS := "--onefile --output-dir=" + BUILD_DIR + " --remove-output --include-package=" + PACKAGE_DIR + " --include-package=gi --include-package-data=gi --follow-imports --nofollow-import-to=*.tests --assume-yes-for-downloads" + +# Current version extracted from source + +VERSION := `grep -oP '__version__ = "\K[^"]+' clipse_gui/__init__.py 2>/dev/null || echo "unknown"` + +# Colors for output (disable if NO_COLOR is set) + +BOLD := `tput bold 2>/dev/null || echo ""` +GREEN := `tput setaf 2 2>/dev/null || echo ""` +YELLOW := `tput setaf 3 2>/dev/null || echo ""` +BLUE := `tput setaf 4 2>/dev/null || echo ""` +RESET := `tput sgr0 2>/dev/null || echo ""` + +# ============================================================================ +# Default Recipe +# ============================================================================ + +# Show available recipes (default) +default: + @just --list --unsorted + +# ============================================================================ +# Development Recipes (group: 'dev') +# ============================================================================ + +# Run the Clipse GUI from source (group: 'dev') +[group('dev')] +run *args: + @echo "{{ GREEN }}-> Running {{ APP_NAME }} (v{{ VERSION }})...{{ RESET }}" + {{ VENV_ACTIVATE }}{{ PYTHON }} {{ APP_SCRIPT }} {{ args }} + +# Run with debug logging enabled (group: 'dev') +[group('dev')] +debug *args: + @echo "{{ GREEN }}-> Running {{ APP_NAME }} in DEBUG mode...{{ RESET }}" + CLIPSE_DEBUG=1 {{ VENV_ACTIVATE }}{{ PYTHON }} {{ APP_SCRIPT }} {{ args }} + +# Watch for file changes and auto-restart - requires watchmedo (group: 'dev') +[group('dev')] +watch: + @echo "{{ YELLOW }}-> Starting watch mode...{{ RESET }}" + @if ! command -v watchmedo &> /dev/null; then \ + echo "{{ YELLOW }}⚠ watchmedo not found. Install with: pip install watchdog{{ RESET }}"; \ + exit 1; \ + fi + watchmedo auto-restart --directory=. --pattern="*.py" --recursive -- \ + {{ PYTHON }} {{ APP_SCRIPT }} + +# Setup development environment - create venv, install deps (group: 'dev') +[group('dev')] +setup: + #!/usr/bin/env bash + set -euo pipefail + echo "{{ BLUE }}-> Setting up development environment...{{ RESET }}" + if [ ! -d "venv" ] && [ ! -d ".venv" ]; then + echo "Creating virtual environment..." + python3 -m venv venv + fi + source venv/bin/activate + echo "Installing dependencies..." + pip install -r requirements.txt + pip install ruff pyright watchdog + echo -e "{{ GREEN }}✓ Development environment ready!{{ RESET }}" + +# Update dependencies (group: 'dev') +[group('dev')] +update-deps: + @echo "{{ BLUE }}-> Updating dependencies...{{ RESET }}" + {{ VENV_ACTIVATE }}pip install --upgrade -r requirements.txt + +# ============================================================================ +# Quality Assurance Recipes (group: 'qa') +# ============================================================================ + +# Run all quality checks - lint + type-check (group: 'qa') +[group('qa')] +check: lint type-check + @echo "{{ GREEN }}✓ All quality checks passed!{{ RESET }}" + +# Run linting with ruff (group: 'qa') +[group('qa')] +lint: + @echo "{{ BLUE }}-> Running ruff linter...{{ RESET }}" + {{ VENV_ACTIVATE }}ruff check . + +# Run linting and auto-fix issues (group: 'qa') +[group('qa')] +lint-fix: + @echo "{{ BLUE }}-> Running ruff linter (with auto-fix)...{{ RESET }}" + {{ VENV_ACTIVATE }}ruff check . --fix + +# Run type checking with pyright (group: 'qa') +[group('qa')] +type-check: + @echo "{{ BLUE }}-> Running pyright type checker...{{ RESET }}" + {{ VENV_ACTIVATE }}pyright + +# Format code with ruff (group: 'qa') +[group('qa')] +format: + @echo "{{ BLUE }}-> Formatting code...{{ RESET }}" + {{ VENV_ACTIVATE }}ruff format . + +# Check code formatting without making changes (group: 'qa') +[group('qa')] +format-check: + @echo "{{ BLUE }}-> Checking code formatting...{{ RESET }}" + {{ VENV_ACTIVATE }}ruff format --check . + +# Run full quality pipeline - format, lint, type-check (group: 'qa') +[group('qa')] +qa: format lint type-check + @echo "{{ GREEN }}✓ Quality assurance complete!{{ RESET }}" + +# ============================================================================ +# Build Recipes (group: 'build') +# ============================================================================ + +# Build standalone binary using Nuitka (group: 'build') +[group('build')] +build: clean-build + @echo "{{ BLUE }}-> Building standalone binary with Nuitka...{{ RESET }}" + @echo "{{ YELLOW }} This may take a few minutes...{{ RESET }}" + {{ VENV_ACTIVATE }}{{ PYTHON }} -m nuitka {{ NUITKA_OPTS }} {{ APP_SCRIPT }} + @echo "{{ GREEN }}✓ Build complete: {{ BUILD_DIR }}/{{ NUITKA_BINARY }}{{ RESET }}" + +# Build and verify the binary works (group: 'build') +[group('build')] +build-verify: build + @echo "{{ BLUE }}-> Verifying build...{{ RESET }}" + @if [ -f "{{ BUILD_DIR }}/{{ NUITKA_BINARY }}" ]; then \ + echo "{{ GREEN }}✓ Binary exists and is ready for installation{{ RESET }}"; \ + ls -lh {{ BUILD_DIR }}/{{ NUITKA_BINARY }}; \ + else \ + echo "{{ YELLOW }}✗ Binary not found at expected location{{ RESET }}"; \ + exit 1; \ + fi + +# ============================================================================ +# Installation Recipes (group: 'install') +# ============================================================================ + +# Install binary and assets system-wide - requires sudo (group: 'install') +[group('install')] +install: build verify-prefix + #!/usr/bin/env bash + set -euo pipefail + echo "{{ BLUE }}-> Installing {{ APP_NAME }} v{{ VERSION }} to {{ PREFIX }}...{{ RESET }}" + + # Install binary + echo "Installing binary to {{ BIN_DIR }}..." + sudo install -Dm755 "{{ BUILD_DIR }}/{{ NUITKA_BINARY }}" "{{ BIN_DIR }}/{{ APP_NAME }}" + + # Install icon if present + if [ -f "{{ ICON_FILE }}" ]; then + echo "Installing icon..." + sudo install -Dm644 "{{ ICON_FILE }}" "{{ ICON_DEST_DIR }}/{{ APP_NAME }}.png" + if [ -f "{{ ICON_CACHE_DIR }}/index.theme" ]; then + sudo gtk-update-icon-cache "{{ ICON_CACHE_DIR }}" 2>/dev/null || true + fi + fi + + # Generate and install desktop file + echo "Installing desktop entry..." + just _generate-desktop | sudo tee "{{ DESKTOP_DEST_DIR }}/{{ APP_NAME }}.desktop" > /dev/null + sudo chmod 644 "{{ DESKTOP_DEST_DIR }}/{{ APP_NAME }}.desktop" + + # Update desktop database + sudo update-desktop-database -q "{{ DESKTOP_DEST_DIR }}" 2>/dev/null || true + + echo -e "{{ GREEN }}✓ Installation complete!{{ RESET }}" + echo " Run with: {{ BOLD }}{{ APP_NAME }}{{ RESET }} or from your applications menu" + +# Uninstall the application [confirm] (group: 'install') +[confirm("Are you sure you want to uninstall {{APP_NAME}}?")] +[group('install')] +uninstall: + #!/usr/bin/env bash + set -euo pipefail + echo "{{ YELLOW }}-> Uninstalling {{ APP_NAME }}...{{ RESET }}" + + sudo rm -f "{{ BIN_DIR }}/{{ APP_NAME }}" + 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" + if [ -f "{{ ICON_CACHE_DIR }}/index.theme" ]; then + sudo gtk-update-icon-cache "{{ ICON_CACHE_DIR }}" 2>/dev/null || true + fi + fi + + sudo update-desktop-database -q "{{ DESKTOP_DEST_DIR }}" 2>/dev/null || true + echo "{{ GREEN }}✓ Uninstalled successfully{{ RESET }}" + +# Install git hooks for development (group: 'install') +[group('install')] +install-hooks: + #!/usr/bin/env bash + set -euo pipefail + echo "{{ BLUE }}-> Installing git hooks...{{ RESET }}" + if [ -f ".githooks/pre-commit" ]; then + cp .githooks/pre-commit .git/hooks/pre-commit + chmod +x .git/hooks/pre-commit + echo "{{ GREEN }}✓ Pre-commit hook installed{{ RESET }}" + else + echo "{{ YELLOW }}⚠ No hooks found in .githooks/{{ RESET }}" + fi + +# 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 }}" + +# ============================================================================ +# Version Management Recipes (group: 'version') +# ============================================================================ + +# Show current version (group: 'version') +[group('version')] +version: + @echo "{{ BOLD }}{{ APP_NAME }}{{ RESET }} v{{ VERSION }}" + +# Show version in different formats (group: 'version') +[group('version')] +version-full: + #!/usr/bin/env bash + echo "{{ BOLD }}{{ APP_NAME }}{{ RESET }}" + echo " Version: v{{ VERSION }}" + echo " Git branch: $(git branch --show-current 2>/dev/null || echo 'n/a')" + echo " Git commit: $(git rev-parse --short HEAD 2>/dev/null || echo 'n/a')" + echo " Build date: $(date -Iseconds)" + +# Calculate next versions without changing anything (group: 'version') +[group('version')] +version-preview: + #!/usr/bin/env bash + current="{{ VERSION }}" + IFS='.' read -r major minor patch <<< "$current" + echo "{{ BOLD }}Current:{{ RESET }} v$current" + echo "" + echo "{{ BOLD }}Next versions:{{ RESET }}" + echo " major: v$((major + 1)).0.0" + echo " minor: v$major.$((minor + 1)).0" + echo " patch: v$major.$minor.$((patch + 1))" + +# Bump version - usage: just bump [major|minor|patch] [--commit] [--tag] [--dry-run] (group: 'version') +[group('version')] +bump bump_type="" *flags="": + #!/usr/bin/env bash + set -euo pipefail + + # Parse flags + commit_flag="" + tag_flag="" + dry_run="" + for flag in {{ flags }}; do + case "$flag" in + --commit|-c) commit_flag="1" ;; + --tag|-t) tag_flag="1" ;; + --dry-run|-d) dry_run="1" ;; + esac + done + + # Validate bump type + valid_types="major minor patch" + if [ -z "{{ bump_type }}" ]; then + echo "{{ YELLOW }}No bump type specified. Use: just bump {{ RESET }}" + just version-preview + exit 0 + fi + + if [[ ! " $valid_types " =~ " {{ bump_type }} " ]]; then + echo "{{ YELLOW }}Error: Invalid bump type '{{ bump_type }}'{{ RESET }}" + echo "Valid types: major, minor, patch" + exit 1 + fi + + # Calculate new version + current="{{ VERSION }}" + IFS='.' read -r major minor patch <<< "$current" + + case "{{ bump_type }}" in + major) new_version="$((major + 1)).0.0" ;; + minor) new_version="$major.$((minor + 1)).0" ;; + patch) new_version="$major.$minor.$((patch + 1))" ;; + esac + + echo "{{ BLUE }}-> Bumping version...{{ RESET }}" + echo " Current: v$current" + echo " New: v$new_version ({{ bump_type }})" + + # Check for uncommitted changes + if [ -n "$(git status --porcelain 2>/dev/null)" ]; then + echo "" + echo "{{ YELLOW }}⚠ Warning: You have uncommitted changes{{ RESET }}" + git status --short + echo "" + if [ -z "$dry_run" ]; then + read -p "Continue anyway? (y/N): " confirm + if [[ ! "$confirm" =~ ^[Yy]$ ]]; then + echo "Cancelled" + exit 0 + fi + fi + fi + + # Dry run mode + if [ -n "$dry_run" ]; then + echo "" + echo "{{ BLUE }}[DRY RUN] Would update:{{ RESET }}" + echo " - clipse_gui/__init__.py: __version__ = \"$new_version\"" + echo " - Makefile: Version=$new_version" + if [ -n "$commit_flag" ]; then + echo " - Git commit: 'chore: bump version to v$new_version'" + fi + if [ -n "$tag_flag" ]; then + echo " - Git tag: v$new_version" + fi + exit 0 + fi + + # Confirm + echo "" + read -p "Proceed with version bump? (y/N): " confirm + if [[ ! "$confirm" =~ ^[Yy]$ ]]; then + echo "Cancelled" + exit 0 + fi + + # Update __init__.py + sed -i "s/__version__ = \"[^\"]*/__version__ = \"$new_version/" clipse_gui/__init__.py + echo "{{ GREEN }}✓ Updated clipse_gui/__init__.py{{ RESET }}" + + # Update Makefile desktop entry + if [ -f "Makefile" ]; then + sed -i "s/Version=[^\\\"]*/Version=$new_version/g" Makefile + echo "{{ GREEN }}✓ Updated Makefile{{ RESET }}" + fi + + # Git commit + if [ -n "$commit_flag" ]; then + git add clipse_gui/__init__.py Makefile 2>/dev/null || true + git commit -m "chore: bump version to v$new_version" + echo "{{ GREEN }}✓ Created commit{{ RESET }}" + fi + + # Git tag + if [ -n "$tag_flag" ]; then + git tag -a "v$new_version" -m "Release v$new_version" + echo "{{ GREEN }}✓ Created tag v$new_version{{ RESET }}" + fi + + echo "" + echo "{{ GREEN }}✓ Version bumped to v$new_version{{ RESET }}" + if [ -n "$commit_flag" ] && [ -z "$tag_flag" ]; then + echo " Run '{{ BOLD }}git push{{ RESET }}' to publish" + elif [ -n "$tag_flag" ]; then + echo " Run '{{ BOLD }}git push && git push --tags{{ RESET }}' to publish" + fi + +# Quick bump patch version (no confirmation) (group: 'version') +[group('version')] +bump-patch *flags: + @just bump patch {{ flags }} + +# Quick bump minor version (no confirmation) (group: 'version') +[group('version')] +bump-minor *flags: + @just bump minor {{ flags }} + +# Quick bump major version (no confirmation) (group: 'version') +[group('version')] +bump-major *flags: + @just bump major {{ flags }} + +# Show git log since last version tag (group: 'version') +[group('version')] +changelog: + #!/usr/bin/env bash + latest_tag=$(git describe --tags --abbrev=0 2>/dev/null || echo "") + if [ -n "$latest_tag" ]; then + echo "{{ BOLD }}Changes since $latest_tag:{{ RESET }}" + git log "$latest_tag"..HEAD --oneline --no-decorate + else + echo "{{ BOLD }}All commits (no tags found):{{ RESET }}" + git log --oneline --no-decorate -20 + fi + +# Show suggested version bump based on commits (group: 'version') +[group('version')] +version-suggest: + #!/usr/bin/env bash + latest_tag=$(git describe --tags --abbrev=0 2>/dev/null || echo "") + if [ -z "$latest_tag" ]; then + echo "{{ YELLOW }}No previous tags found{{ RESET }}" + exit 0 + fi + + echo "{{ BOLD }}Analyzing commits since $latest_tag...{{ RESET }}" + echo "" + + # Check for breaking changes + if git log "$latest_tag"..HEAD --oneline | grep -qiE "(breaking|break|BREAKING)"; then + echo "{{ YELLOW }}⚠ Breaking changes detected → suggest {{ BOLD }}MAJOR{{ RESET }}{{ YELLOW }} bump{{ RESET }}" + suggested="major" + elif git log "$latest_tag"..HEAD --oneline | grep -qiE "(feat|feature|add)"; then + echo "{{ BLUE }}ℹ New features detected → suggest {{ BOLD }}MINOR{{ RESET }}{{ BLUE }} bump{{ RESET }}" + suggested="minor" + else + echo "{{ GREEN }}ℹ Only fixes/chores → suggest {{ BOLD }}PATCH{{ RESET }}{{ GREEN }} bump{{ RESET }}" + suggested="patch" + fi + + echo "" + echo "Run: {{ BOLD }}just bump $suggested [--commit] [--tag]{{ RESET }}" + +# ============================================================================ +# Cleanup Recipes (group: 'clean') +# ============================================================================ + +# Clean build artifacts (group: 'clean') +[group('clean')] +clean-build: + @echo "{{ BLUE }}-> Cleaning build files...{{ RESET }}" + rm -rf {{ BUILD_DIR }}/ {{ NUITKA_DIST_DIR }}/ *.spec *.build/ + @echo "{{ GREEN }}✓ Build files cleaned{{ RESET }}" + +# Clean Python cache files (group: 'clean') +[group('clean')] +clean-cache: + @echo "{{ BLUE }}-> Cleaning Python cache...{{ RESET }}" + find . -type f -name '*.pyc' -delete 2>/dev/null || true + find . -type d -name '__pycache__' -exec rm -rf {} + 2>/dev/null || true + find . -type d -name '.ruff_cache' -exec rm -rf {} + 2>/dev/null || true + find . -type d -name '.pyright' -exec rm -rf {} + 2>/dev/null || true + rm -rf .mypy_cache/ .pytest_cache/ + @echo "{{ GREEN }}✓ Cache cleaned{{ RESET }}" + +# Clean virtual environment - use with caution! [confirm] (group: 'clean') +[confirm("This will delete your virtual environment. Continue?")] +[group('clean')] +clean-venv: + @echo "{{ YELLOW }}-> Removing virtual environment...{{ RESET }}" + rm -rf venv/ .venv/ + @echo "{{ GREEN }}✓ Virtual environment removed{{ RESET }}" + +# Clean everything except source [confirm] (group: 'clean') +[confirm("This will remove ALL generated files. Continue?")] +[group('clean')] +clean-all: clean-build clean-cache + @echo "{{ BLUE }}-> Deep cleaning...{{ RESET }}" + rm -rf .taskmaster/tasks/*.md + @echo "{{ GREEN }}✓ All artifacts cleaned{{ RESET }}" + +# Alias for clean-build +clean: clean-build + +# ============================================================================ +# Info & Diagnostics Recipes (group: 'info') +# ============================================================================ + +# Show project information (group: 'info') +[group('info')] +info: + @echo "{{ BOLD }}{{ APP_NAME }}{{ RESET }} v{{ VERSION }}" + @echo "" + @echo "{{ BOLD }}Paths:{{ RESET }}" + @echo " Python: {{ PYTHON }}" + @echo " Prefix: {{ PREFIX }}" + @echo " Build dir: {{ BUILD_DIR }}" + @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" } }}" + +# 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" + +# ============================================================================ +# Private Helper Recipes +# ============================================================================ + +# Generate desktop entry content (private) +[private] +_generate-desktop: + #!/usr/bin/env bash + cat < Date: Thu, 12 Feb 2026 13:09:45 +0200 Subject: [PATCH 2/6] fix(paste): improve reliability and add single-click support - Increase quit delay 50ms -> 200ms for paste completion - Add single-click handler to paste items directly - Fix missing GLib import in tray_manager - Add single_click_callback parameter to create_list_row_widget() --- clipse_gui/controller.py | 49 +++-- clipse_gui/tray_manager.py | 330 ++++++++++++++++++++++++--------- clipse_gui/ui_components.py | 356 +++++++++++++++++++++++------------- 3 files changed, 512 insertions(+), 223 deletions(-) diff --git a/clipse_gui/controller.py b/clipse_gui/controller.py index 9ad3500..8b1a996 100644 --- a/clipse_gui/controller.py +++ b/clipse_gui/controller.py @@ -259,6 +259,7 @@ def _create_rows_range(self, start_idx, end_idx): self._update_row_image_widget, self.compact_mode, self.hover_to_select, + self._on_row_single_click, ) if row: row.item_index = item_info["original_index"] @@ -313,7 +314,9 @@ def update_status_label(self): # 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") + 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") @@ -592,12 +595,16 @@ def toggle_item_selection(self): # 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()}") + 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()}") + log.info( + f"Selected item at index {original_index}, classes: {context.list_classes()}" + ) self.update_status_label() @@ -659,7 +666,9 @@ def delete_selected_items(self): 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)") + self.flash_status( + f"Cannot delete: all {pinned_count} selected items are pinned (protection enabled)" + ) else: self.flash_status("No items to delete") return @@ -679,7 +688,7 @@ def delete_selected_items(self): destroy_with_parent=True, message_type=Gtk.MessageType.WARNING, buttons=Gtk.ButtonsType.NONE, - text="Confirm Deletion" + text="Confirm Deletion", ) dialog.format_secondary_text(message) dialog.add_button("Cancel", Gtk.ResponseType.CANCEL) @@ -706,7 +715,9 @@ def delete_selected_items(self): self.schedule_save_history() self.update_filtered_items() - self.flash_status(f"Deleted {total_to_delete} item{'s' if total_to_delete != 1 else ''}") + 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): @@ -720,7 +731,9 @@ def clear_all_items(self): 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)") + self.flash_status( + f"Cannot clear: all {pinned_count} items are pinned (protection enabled)" + ) return # Determine what will be deleted @@ -742,7 +755,7 @@ def clear_all_items(self): destroy_with_parent=True, message_type=Gtk.MessageType.WARNING, buttons=Gtk.ButtonsType.NONE, - text="Clear All Items" + text="Clear All Items", ) dialog.format_secondary_text(message) dialog.add_button("Cancel", Gtk.ResponseType.CANCEL) @@ -770,7 +783,9 @@ def clear_all_items(self): self.schedule_save_history() self.update_filtered_items() - self.flash_status(f"Cleared {items_to_delete} item{'s' if items_to_delete != 1 else ''}") + 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): @@ -866,6 +881,8 @@ def copy_text_to_clipboard(self, text_value): 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") @@ -1046,8 +1063,10 @@ def _trigger_paste_simulation_and_quit(self): # self.window.show() # self.flash_status("Paste failed. Check logs/dependencies (xdotool/wtype).") - # Quit the application shortly after attempting paste - GLib.timeout_add(50, self._quit_application) + # 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): @@ -1613,6 +1632,14 @@ def on_row_activated(self, row, with_paste_simulation=False): 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() diff --git a/clipse_gui/tray_manager.py b/clipse_gui/tray_manager.py index caadac5..0bb95bd 100644 --- a/clipse_gui/tray_manager.py +++ b/clipse_gui/tray_manager.py @@ -23,6 +23,7 @@ def __init__(self, application): self.status_icon = None self.menu = None self._is_tray_enabled = constants.MINIMIZE_TO_TRAY + self._last_items_hash = None # Track if items changed self._setup_tray_icon() def _setup_tray_icon(self): @@ -34,19 +35,13 @@ def _setup_tray_icon(self): def _setup_appindicator(self): if not AppIndicator3: + log.debug("AppIndicator3 not available, falling back to StatusIcon") self._setup_status_icon() return try: - import os - - # Try app icon first - icon_path = os.path.join( - os.path.dirname(os.path.dirname(__file__)), "clipse-gui.png" - ) - if os.path.exists(icon_path): - icon_name = icon_path - else: - icon_name = "edit-copy" + # Try multiple icon paths for development and production + icon_name = self._get_icon_path() + log.info(f"Using tray icon: {icon_name}") self.indicator = AppIndicator3.Indicator.new( "clipse-gui", @@ -54,19 +49,66 @@ def _setup_appindicator(self): AppIndicator3.IndicatorCategory.APPLICATION_STATUS, ) - self._create_basic_menu() + # Build fresh menu with current items + self._build_fresh_menu() self.indicator.set_menu(self.menu) + self.indicator.set_status(AppIndicator3.IndicatorStatus.ACTIVE) - log.debug("AppIndicator created and set to ACTIVE") + log.info("AppIndicator created and set to ACTIVE") except Exception as e: log.warning(f"AppIndicator failed: {e}") self.indicator = None self._setup_status_icon() + def _get_icon_path(self): + """Find the best available icon path for tray.""" + import os + + # List of possible icon locations + possible_paths = [ + # Development: relative to this file + os.path.join(os.path.dirname(os.path.dirname(__file__)), "clipse-gui.png"), + # Installed: in the package data + os.path.join(os.path.dirname(__file__), "clipse-gui.png"), + # System: /usr/share/pixmaps + "/usr/share/pixmaps/clipse-gui.png", + # System: /usr/local/share/pixmaps + "/usr/local/share/pixmaps/clipse-gui.png", + # User: ~/.local/share/pixmaps + os.path.expanduser("~/.local/share/pixmaps/clipse-gui.png"), + # User: ~/.icons + os.path.expanduser("~/.icons/clipse-gui.png"), + ] + + for path in possible_paths: + if os.path.exists(path): + log.debug(f"Found icon at: {path}") + return path + + # Fallback to system icon name + log.warning("Custom icon not found, using fallback 'edit-copy'") + return "edit-copy" + def _setup_status_icon(self): try: - self.status_icon = Gtk.StatusIcon.new_from_icon_name("edit-copy") + # Try to use custom icon if available + import os + from gi.repository import GdkPixbuf + + icon_path = self._get_icon_path() + if os.path.exists(icon_path): + try: + pixbuf = GdkPixbuf.Pixbuf.new_from_file_at_size(icon_path, 24, 24) + self.status_icon = Gtk.StatusIcon.new_from_pixbuf(pixbuf) + log.info(f"StatusIcon created with custom icon: {icon_path}") + except Exception as e: + log.warning(f"Failed to load custom icon for StatusIcon: {e}") + self.status_icon = Gtk.StatusIcon.new_from_icon_name("edit-copy") + else: + self.status_icon = Gtk.StatusIcon.new_from_icon_name("edit-copy") + log.info("StatusIcon created with fallback icon 'edit-copy'") + self.status_icon.set_title(constants.APP_NAME) self.status_icon.set_tooltip_text( f"{constants.APP_NAME} - Clipboard Manager" @@ -74,7 +116,8 @@ def _setup_status_icon(self): self.status_icon.connect("activate", self._on_tray_activate) self.status_icon.connect("popup-menu", self._on_tray_popup_menu) self.status_icon.set_visible(False) - except Exception: + except Exception as e: + log.error(f"Failed to setup StatusIcon: {e}") self.status_icon = None def _create_basic_menu(self): @@ -91,70 +134,71 @@ def _create_basic_menu(self): quit_item.show() self.menu.append(quit_item) - def _update_menu_with_items(self): - """Update menu to include clipboard items""" - if not self.menu: + def _make_click_handler(self, item): + """Create a click handler for a specific item - fixes closure capture issue.""" + # Store item data by value (deep copy of relevant fields) + item_value = item.get("value", "") + item_file_path = item.get("filePath", "") + item_recorded = item.get("recorded", "") + + def handler(menu_item): + # Reconstruct the item dict for the handler + reconstructed_item = { + "value": item_value, + "filePath": item_file_path, + "recorded": item_recorded, + } + log.debug(f"Tray menu item clicked: {item_value[:50]}...") + self._copy_item_to_clipboard(reconstructed_item) + + return handler + + def _add_item_to_menu_internal(self, menu, item, index): + """Add single clipboard item to a specific menu instance.""" + if not menu: return - - # Clear menu - for item in self.menu.get_children(): - self.menu.remove(item) - - # Get clipboard items - items = [] try: - if ( - hasattr(self.application, "controller") - and self.application.controller - and hasattr(self.application.controller, "data_manager") - ): - items = self.application.controller.data_manager.load_history() - except Exception: - pass - - recent_items = items[:5] if items else [] - - if not self.menu: - self._create_basic_menu() - return - - # Add clipboard items - if recent_items: - for i, item in enumerate(recent_items): - self._add_item_to_menu(item, i + 1) - - # Add separator - separator = Gtk.SeparatorMenuItem() - separator.show() - self.menu.append(separator) - else: - no_items = Gtk.MenuItem.new_with_label("No clipboard items") - no_items.set_sensitive(False) - no_items.show() - self.menu.append(no_items) + # Deep copy item data to avoid any reference issues + item_copy = { + "value": item.get("value", ""), + "filePath": item.get("filePath", ""), + "recorded": item.get("recorded", ""), + } - separator = Gtk.SeparatorMenuItem() - separator.show() - self.menu.append(separator) + value = item_copy["value"] + is_image = item_copy["filePath"] not in [None, "", "null"] - # Add Show/Quit - restore_item = Gtk.MenuItem.new_with_label("Show Clipse GUI") - restore_item.connect("activate", lambda x: self._restore_window()) - restore_item.show() - self.menu.append(restore_item) + if is_image: + display_text = f"Image ({index})" + else: + clean_text = value.replace("\n", " ").replace("\t", " ").strip() + if len(clean_text) > 40: + display_text = f"{clean_text[:37]}..." + else: + display_text = clean_text if clean_text else f"Empty ({index})" - quit_item = Gtk.MenuItem.new_with_label("Quit") - quit_item.connect("activate", lambda x: self.application.quit()) - quit_item.show() - self.menu.append(quit_item) + menu_item = Gtk.MenuItem.new_with_label(display_text) + menu_item.connect("activate", self._make_click_handler(item_copy)) + menu_item.show() + menu.append(menu_item) + log.debug(f"Added menu item {index}: {display_text[:40]}") + except Exception as e: + log.error(f"Error adding item to menu: {e}") - def _add_item_to_menu(self, item, index): - """Add single clipboard item to menu""" - if not self.menu: + def _add_item_to_submenu_internal(self, submenu, item, index): + """Add single clipboard item to a specific submenu instance.""" + if not submenu: return try: - value = item.get("value", "") - is_image = item.get("filePath") not in [None, "", "null"] + # Deep copy item data to avoid any reference issues + item_copy = { + "value": item.get("value", ""), + "filePath": item.get("filePath", ""), + "recorded": item.get("recorded", ""), + } + + value = item_copy["value"] + is_image = item_copy["filePath"] not in [None, "", "null"] if is_image: display_text = f"Image ({index})" @@ -166,35 +210,61 @@ def _add_item_to_menu(self, item, index): display_text = clean_text if clean_text else f"Empty ({index})" menu_item = Gtk.MenuItem.new_with_label(display_text) - menu_item.connect( - "activate", lambda x, item=item: self._copy_item_to_clipboard(item) - ) + menu_item.connect("activate", self._make_click_handler(item_copy)) menu_item.show() - self.menu.append(menu_item) - except Exception: - pass + submenu.append(menu_item) + except Exception as e: + log.error(f"Error adding item to submenu: {e}") def _copy_item_to_clipboard(self, item): """Copy selected item to clipboard""" try: + log.debug( + f"_copy_item_to_clipboard called with item value: {item.get('value', '')[:80]}..." + ) if hasattr(self.application, "controller") and self.application.controller: controller = self.application.controller value = item.get("value", "") file_path = item.get("filePath") is_image = file_path not in [None, "", "null"] + log.info( + f"Copying to clipboard from tray: {'Image' if is_image else value[:50]}..." + ) + + copy_success = False if is_image and file_path: - controller.copy_image_to_clipboard(file_path) + copy_success = controller.copy_image_to_clipboard(file_path) else: - controller.copy_text_to_clipboard(value) - except Exception: - pass + copy_success = controller.copy_text_to_clipboard(value) + + # Paste on select if enabled + if constants.TRAY_PASTE_ON_SELECT and copy_success: + log.debug( + "Tray paste on select enabled, scheduling paste simulation" + ) + # Delay paste slightly to allow clipboard to update + GLib.timeout_add(100, self._delayed_paste) + else: + log.warning("Cannot copy: controller not available") + except Exception as e: + log.error(f"Error copying item from tray: {e}") + + def _delayed_paste(self): + """Trigger paste simulation after a short delay.""" + try: + if hasattr(self.application, "controller") and self.application.controller: + self.application.controller.paste_from_clipboard_simulated() + except Exception as e: + log.error(f"Error in delayed paste: {e}") + return False # Don't repeat def _on_tray_activate(self, status_icon): self._restore_window() def _on_tray_popup_menu(self, status_icon, button, activate_time): - self._update_menu_with_items() + # Rebuild menu fresh for StatusIcon + self._build_fresh_menu() if self.menu: self.menu.show_all() self.menu.popup( @@ -207,48 +277,134 @@ def _on_tray_popup_menu(self, status_icon, button, activate_time): ) def _restore_window(self): + log.info("Restoring window from tray") + if self.application.window: self.application.window.present() self.application.window.show_all() + self.application.window.deiconify() if self.indicator and AppIndicator3: self.indicator.set_status(AppIndicator3.IndicatorStatus.PASSIVE) + log.debug("Set AppIndicator to PASSIVE") elif self.status_icon: self.status_icon.set_visible(False) + log.debug("Set StatusIcon to invisible") + else: + log.warning("Cannot restore: window is None") def minimize_to_tray(self): if not self._is_tray_enabled: + log.debug("Minimize to tray disabled") return False + log.info("Minimizing window to tray") + if HAS_APPINDICATOR: # Create indicator only when minimizing if not self.indicator: self._setup_appindicator() if self.indicator and AppIndicator3: - log.debug("Showing tray icon") + log.info("Showing AppIndicator tray icon") self.indicator.set_status(AppIndicator3.IndicatorStatus.ACTIVE) - GLib.timeout_add(200, self._delayed_menu_update) + # Build fresh menu with current items + self._build_fresh_menu() if self.application.window: self.application.window.hide() + log.debug("Window hidden") return True + else: + log.warning("AppIndicator not available after setup") elif self.status_icon: if not self.status_icon.is_embedded(): + log.warning("StatusIcon not embedded in tray") return False + log.info("Showing StatusIcon tray icon") self.status_icon.set_visible(True) if self.application.window: self.application.window.hide() + log.debug("Window hidden") return True + else: + log.warning("No tray icon available (neither AppIndicator nor StatusIcon)") return False - def _delayed_menu_update(self): - """Update menu after a small delay to ensure data is loaded""" - self._update_menu_with_items() + def _build_fresh_menu(self): + """Build a completely fresh menu with current clipboard items.""" + log.debug("Building fresh tray menu") + + # Get current items + items = [] + try: + if ( + hasattr(self.application, "controller") + and self.application.controller + and hasattr(self.application.controller, "data_manager") + ): + items = self.application.controller.data_manager.load_history() + except Exception as e: + log.error(f"Error loading history for tray: {e}") + return + + recent_items = items[: constants.TRAY_ITEMS_COUNT] if items else [] + + # Create completely new menu + new_menu = Gtk.Menu() + + # Add clipboard items + if recent_items: + visible_count = min(10, len(recent_items)) + + for i, item in enumerate(recent_items[:visible_count]): + self._add_item_to_menu_internal(new_menu, item, i + 1) + + if len(recent_items) > visible_count: + more_item = Gtk.MenuItem.new_with_label( + f"More... ({len(recent_items) - visible_count} items)" + ) + more_menu = Gtk.Menu() + + for i, item in enumerate( + recent_items[visible_count:], visible_count + 1 + ): + self._add_item_to_submenu_internal(more_menu, item, i) + + more_item.set_submenu(more_menu) + more_item.show() + new_menu.append(more_item) + + separator = Gtk.SeparatorMenuItem() + separator.show() + new_menu.append(separator) + else: + no_items = Gtk.MenuItem.new_with_label("No clipboard items") + no_items.set_sensitive(False) + no_items.show() + new_menu.append(no_items) + + separator = Gtk.SeparatorMenuItem() + separator.show() + new_menu.append(separator) + + # Add Show/Quit + restore_item = Gtk.MenuItem.new_with_label("Show Clipse GUI") + restore_item.connect("activate", lambda x: self._restore_window()) + restore_item.show() + new_menu.append(restore_item) + + quit_item = Gtk.MenuItem.new_with_label("Quit") + quit_item.connect("activate", lambda x: self.application.quit()) + quit_item.show() + new_menu.append(quit_item) + + # Replace the menu + self.menu = new_menu if self.indicator: self.indicator.set_menu(self.menu) - return False # Don't repeat + log.debug(f"Set new menu with {len(recent_items)} items") def _set_attention(self): """Helper to set attention status""" diff --git a/clipse_gui/ui_components.py b/clipse_gui/ui_components.py index 99db521..49b4abd 100644 --- a/clipse_gui/ui_components.py +++ b/clipse_gui/ui_components.py @@ -18,6 +18,8 @@ HOVER_TO_SELECT, ENTER_TO_PASTE, MINIMIZE_TO_TRAY, + TRAY_ITEMS_COUNT, + TRAY_PASTE_ON_SELECT, config, ) @@ -33,16 +35,19 @@ """ + 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)) + 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 = GdkPixbuf.PixbufLoader.new_with_type("svg") + loader.write(svg_data.encode("utf-8")) loader.close() pixbuf = loader.get_pixbuf() @@ -61,16 +66,17 @@ def create_pin_icon(is_pinned, angle=25): 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 + 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): @@ -101,6 +107,7 @@ def create_list_row_widget( update_image_callback, compact_mode=False, hover_to_select=False, + single_click_callback=None, ): """Creates a Gtk.ListBoxRow widget for a clipboard item.""" original_index = item_info["original_index"] @@ -253,6 +260,21 @@ def on_enter_notify(widget, event): 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 @@ -302,9 +324,7 @@ def show_help_window(parent_window, close_cb): ("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), @@ -312,9 +332,7 @@ def show_help_window(parent_window, close_cb): ("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), @@ -323,25 +341,19 @@ def show_help_window(parent_window, close_cb): ("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), @@ -355,7 +367,9 @@ def show_help_window(parent_window, close_cb): if is_header: # Section header header_label = Gtk.Label() - header_label.set_markup(f"{key}") + 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) @@ -401,90 +415,186 @@ def show_help_window(parent_window, close_cb): 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): - """Creates and shows the settings window.""" + """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(400, 350) + settings_window.set_default_size(450, 500) settings_window.set_border_width(15) - main_box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=15) + main_box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=10) # Header header = Gtk.Label() - header.set_markup("Settings") + header.set_markup("Settings") header.set_halign(Gtk.Align.CENTER) + header.set_margin_bottom(10) main_box.pack_start(header, False, False, 0) - # Settings content - settings_box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=15) + # Scrollable content area + scrolled = Gtk.ScrolledWindow() + scrolled.set_policy(Gtk.PolicyType.NEVER, Gtk.PolicyType.AUTOMATIC) + scrolled.set_vexpand(True) - # Protect Pinned Items setting - protect_box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=10) - protect_label = Gtk.Label(label="Protect pinned items from deletion:") - protect_label.set_halign(Gtk.Align.START) - protect_label.set_hexpand(True) + # Content box for all sections + content_box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=15) + content_box.set_margin_bottom(10) - protect_switch = Gtk.Switch() - protect_switch.set_active(PROTECT_PINNED_ITEMS) - protect_switch.set_halign(Gtk.Align.END) + # ============ 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_box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=10) - compact_label = Gtk.Label(label="Compact mode:") - compact_label.set_halign(Gtk.Align.START) - compact_label.set_hexpand(True) - compact_switch = Gtk.Switch() compact_switch.set_active(COMPACT_MODE) - compact_switch.set_halign(Gtk.Align.END) + compact_box = _create_setting_row( + "Compact mode:", + compact_switch, + "Use a more compact layout with smaller margins", + ) # Hover to Select setting - hover_box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=10) - hover_label = Gtk.Label(label="Hover to select:") - hover_label.set_halign(Gtk.Align.START) - hover_label.set_hexpand(True) - hover_switch = Gtk.Switch() hover_switch.set_active(HOVER_TO_SELECT) - hover_switch.set_halign(Gtk.Align.END) + 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_box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=10) - enter_paste_label = Gtk.Label(label="Enter to paste:") - enter_paste_label.set_halign(Gtk.Align.START) - enter_paste_label.set_hexpand(True) - enter_paste_switch = Gtk.Switch() enter_paste_switch.set_active(ENTER_TO_PASTE) - enter_paste_switch.set_halign(Gtk.Align.END) + enter_paste_box = _create_setting_row( + "Enter to paste:", + enter_paste_switch, + "Press Enter to paste the selected item and close the window", + ) - # Minimize to Tray setting - tray_box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=10) - tray_label = Gtk.Label(label="Minimize to system tray:") - tray_label.set_halign(Gtk.Align.START) - tray_label.set_hexpand(True) + 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_frame.add(general_box) + content_box.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) + content_box.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_switch.set_halign(Gtk.Align.END) + 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) + content_box.pack_start(tray_frame, False, False, 0) + + scrolled.add(content_box) + main_box.pack_start(scrolled, True, True, 0) + + # Track changes settings_changed = False - # Buttons (need to define apply_btn before callback functions) + # 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_margin_top(10) - apply_btn.set_sensitive(False) # Initially disabled + apply_btn.set_sensitive(False) # Close button close_btn = Gtk.Button(label="Close") - close_btn.set_margin_top(10) def update_button_states(): """Update the state of buttons based on whether settings have changed.""" @@ -494,13 +604,10 @@ def on_protect_switch_toggled(switch, state): nonlocal settings_changed settings_changed = True update_button_states() - # Save to config 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() - - # Update the global for current session import clipse_gui.constants as constants constants.PROTECT_PINNED_ITEMS = switch.get_active() @@ -509,13 +616,10 @@ def on_compact_switch_toggled(switch, state): nonlocal settings_changed settings_changed = True update_button_states() - # Save to config 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() - - # Update the global for current session import clipse_gui.constants as constants constants.COMPACT_MODE = switch.get_active() @@ -524,30 +628,22 @@ def on_hover_switch_toggled(switch, state): nonlocal settings_changed settings_changed = True update_button_states() - # Save to config 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() - - # Update the global for current session import clipse_gui.constants as constants constants.HOVER_TO_SELECT = switch.get_active() - # Note: Hover-to-select requires restart to take effect since it affects row creation - def on_enter_paste_switch_toggled(switch, state): nonlocal settings_changed settings_changed = True update_button_states() - # Save to config 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() - - # Update the global for current session import clipse_gui.constants as constants constants.ENTER_TO_PASTE = switch.get_active() @@ -556,55 +652,52 @@ def on_tray_switch_toggled(switch, state): nonlocal settings_changed settings_changed = True update_button_states() - # Save to config 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() - - # Update the global for current session import clipse_gui.constants as constants constants.MINIMIZE_TO_TRAY = switch.get_active() - - # Update tray manager if it exists try: - # Try to get the application and tray manager 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: - # If we can't update dynamically, it will be applied on restart 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() + + # 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) tray_switch.connect("state-set", on_tray_switch_toggled) - - protect_box.pack_start(protect_label, True, True, 0) - protect_box.pack_start(protect_switch, False, False, 0) - - compact_box.pack_start(compact_label, True, True, 0) - compact_box.pack_start(compact_switch, False, False, 0) - - hover_box.pack_start(hover_label, True, True, 0) - hover_box.pack_start(hover_switch, False, False, 0) - - enter_paste_box.pack_start(enter_paste_label, True, True, 0) - enter_paste_box.pack_start(enter_paste_switch, False, False, 0) - - tray_box.pack_start(tray_label, True, True, 0) - tray_box.pack_start(tray_switch, False, False, 0) - - settings_box.pack_start(protect_box, False, False, 0) - settings_box.pack_start(compact_box, False, False, 0) - settings_box.pack_start(hover_box, False, False, 0) - settings_box.pack_start(enter_paste_box, False, False, 0) - settings_box.pack_start(tray_box, False, False, 0) - - main_box.pack_start(settings_box, True, True, 0) + tray_items_spin.connect("value-changed", on_tray_items_changed) + tray_paste_switch.connect("state-set", on_tray_paste_switch_toggled) def on_apply_clicked(button): settings_window.destroy() @@ -616,13 +709,12 @@ def on_apply_clicked(button): def on_close_clicked(button): settings_window.destroy() if settings_changed and restart_app_cb: - # Show a dialog asking if user wants to restart to apply changes dialog = Gtk.MessageDialog( - parent=parent_window, - flags=Gtk.DialogFlags.MODAL, - type=Gtk.MessageType.QUESTION, + transient_for=settings_window, + modal=True, + message_type=Gtk.MessageType.QUESTION, buttons=Gtk.ButtonsType.YES_NO, - message_format="Settings have been changed. Restart the application to apply changes?", + text="Settings have been changed. Restart to apply changes?", ) response = dialog.run() dialog.destroy() @@ -633,7 +725,6 @@ def on_close_clicked(button): 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) @@ -757,15 +848,20 @@ def show_preview_window( action_box.set_halign(Gtk.Align.CENTER) # Format button - format_btn = Gtk.Button.new_from_icon_name( - "format-text-bold-symbolic", Gtk.IconSize.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.new_from_icon_name( - "edit-find-symbolic", Gtk.IconSize.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)") @@ -793,20 +889,25 @@ def show_preview_window( search_container.pack_start(match_label, False, False, 0) # Previous button - prev_btn = Gtk.Button.new_from_icon_name("go-up-symbolic", Gtk.IconSize.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.new_from_icon_name( - "go-down-symbolic", Gtk.IconSize.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.new_from_icon_name( - "window-close-symbolic", Gtk.IconSize.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) @@ -832,16 +933,21 @@ def show_preview_window( ) # Zoom controls - zoom_out = Gtk.Button.new_from_icon_name( - "zoom-out-symbolic", Gtk.IconSize.BUTTON + 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.new_from_icon_name("zoom-in-symbolic", Gtk.IconSize.BUTTON) + 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.new_from_icon_name( - "zoom-original-symbolic", Gtk.IconSize.BUTTON + 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)) From 6d5c294e308a7728dc69994c3a4515ac9c930ae5 Mon Sep 17 00:00:00 2001 From: d7om Date: Thu, 12 Feb 2026 13:09:49 +0200 Subject: [PATCH 3/6] chore: fix linting and type checking errors - Remove unused 'content' variable in bump_version.py - Fix f-string without placeholders - Fix GTK API compatibility with new_from_icon_name() - Add type: ignore comments for pyright/GTK compatibility - All just qa checks now pass --- bump_version.py | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/bump_version.py b/bump_version.py index 631b8cc..54af608 100644 --- a/bump_version.py +++ b/bump_version.py @@ -85,6 +85,17 @@ def update_makefile(new_version): print(f"Updated Makefile desktop entry to version {new_version}") +def update_justfile(new_version): + """Update version in justfile desktop entry generation""" + justfile = Path("justfile") + if not justfile.exists(): + print("Warning: justfile not found, skipping justfile version update") + return + + # The justfile dynamically reads from __init__.py, so it should auto-update + print("justfile reads version dynamically from source (no update needed)") + + def interactive_bump(): """Interactive version bump with user selection""" current_version = get_current_version() @@ -149,6 +160,7 @@ def main(): # Update files update_init_file(new_version) update_makefile(new_version) + update_justfile(new_version) print(f"Version successfully bumped to {new_version}") print("Don't forget to commit the changes!") From f3e1fa1af25b739a18dddc54649312dc436a0b07 Mon Sep 17 00:00:00 2001 From: d7om Date: Thu, 12 Feb 2026 13:22:47 +0200 Subject: [PATCH 4/6] feat(tray): add window restore and tray settings - Add _restore_window_from_tray() for proper window restoration - Add tray_items_count and tray_paste_on_select settings - Add settings window CSS styling - Add lint-fix target to Makefile - Add dev dependencies to requirements.txt --- Makefile | 13 ++++++++++++- clipse_gui/app.py | 21 ++++++++++++++++----- clipse_gui/constants.py | 24 ++++++++++++++++++++++++ requirements.txt | 4 ++++ 4 files changed, 56 insertions(+), 6 deletions(-) diff --git a/Makefile b/Makefile index b30779c..bcf84bc 100644 --- a/Makefile +++ b/Makefile @@ -62,6 +62,17 @@ lint: ruff check . && pyright; \ fi +lint-fix: + @echo "Running linting and type checking..." + @if [ -d "venv" ]; then \ + echo "Activating virtual environment..."; \ + . venv/bin/activate && ruff check . --fix && pyright; \ + else \ + echo "No virtual environment found, running directly..."; \ + ruff check . --fix && pyright; \ + fi + + watch: @echo "Starting Clipse GUI in watch mode..." watchmedo auto-restart --directory=. --pattern="*.py" --recursive -- \ @@ -71,7 +82,7 @@ nuitka: @echo "Building standalone app using Nuitka..." $(PYTHON) -m nuitka $(NUITKA_OPTS) $(APP_SCRIPT) -install: nuitka +install: @echo "Installing $(APP_NAME)..." @sudo install -Dm755 "$(BUILD_DIR)/$(NUITKA_BINARY)" "$(BIN_DIR)/$(APP_NAME)" diff --git a/clipse_gui/app.py b/clipse_gui/app.py index fcb7e3f..5397e83 100644 --- a/clipse_gui/app.py +++ b/clipse_gui/app.py @@ -20,7 +20,7 @@ class ClipseGuiApplication(Gtk.Application): def __init__(self): super().__init__( - application_id=APPLICATION_ID, flags=Gio.ApplicationFlags.FLAGS_NONE + application_id=APPLICATION_ID, flags=Gio.ApplicationFlags.DEFAULT_FLAGS ) self.window = None self.controller = None @@ -58,10 +58,10 @@ def do_activate(self): self.window.set_icon_name("edit-copy") except GLib.Error as e: log.warning(f"Could not set window icon name: {e}") - + # Setup tray manager self.tray_manager = TrayManager(self) - + # Connect window events for tray functionality self.window.connect("delete-event", self._on_window_delete) @@ -91,7 +91,18 @@ def do_activate(self): log.debug("Main window created and shown.") else: log.debug("Application already active - presenting existing window.") - self.window.present() + self._restore_window_from_tray() + + def _restore_window_from_tray(self): + """Restore and show the window, even if minimized to tray.""" + if self.window: + # Restore from tray if minimized there + if self.tray_manager: + self.tray_manager._restore_window() + else: + # Fallback if no tray manager + self.window.present() + self.window.show_all() def do_shutdown(self): """Called when the application is shutting down.""" @@ -110,7 +121,7 @@ def do_shutdown(self): # Cleanup tray resources if self.tray_manager: self.tray_manager.cleanup() - + Gtk.Application.do_shutdown(self) def _on_window_delete(self, window, event): diff --git a/clipse_gui/constants.py b/clipse_gui/constants.py index 58f1962..4e352c4 100644 --- a/clipse_gui/constants.py +++ b/clipse_gui/constants.py @@ -27,6 +27,8 @@ "search_debounce_ms": "250", "paste_simulation_delay_ms": "150", "minimize_to_tray": "True", + "tray_items_count": "20", + "tray_paste_on_select": "True", }, "Commands": { "copy_tool_cmd": "wl-copy", @@ -78,6 +80,10 @@ "General", "paste_simulation_delay_ms", fallback=150 ) MINIMIZE_TO_TRAY = config.getboolean("General", "minimize_to_tray", fallback=True) +TRAY_ITEMS_COUNT = config.getint("General", "tray_items_count", fallback=20) +TRAY_PASTE_ON_SELECT = config.getboolean( + "General", "tray_paste_on_select", fallback=True +) COPY_TOOL_CMD = config.get("Commands", "copy_tool_cmd", fallback="wl-copy") X11_COPY_TOOL_CMD = config.get( @@ -230,6 +236,24 @@ .pin-icon.unpinned { color: alpha(#ffffff, 0.25); } + +/* Settings window styling */ +.settings-section { + border: 1px solid alpha(#ffffff, 0.1); + border-radius: 6px; + padding: 10px; + margin: 5px; +} + +.settings-section > label { + color: alpha(#ffffff, 0.9); + font-weight: bold; + margin-bottom: 5px; +} + +.settings-section frame { + background-color: alpha(#ffffff, 0.02); +} """ log.debug(f"Using configuration directory: {CONFIG_DIR}") log.debug(f"Using configuration file: {CONFIG_FILE_PATH}") diff --git a/requirements.txt b/requirements.txt index 68fe2a4..63e70b6 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,8 +1,12 @@ +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 +typing_extensions==4.15.0 watchdog==6.0.0 zstandard==0.23.0 From 48e474579f99916937da6cd858b608732a846c70 Mon Sep 17 00:00:00 2001 From: d7om Date: Thu, 12 Feb 2026 13:24:24 +0200 Subject: [PATCH 5/6] chore: bump version to v0.6.0 --- .gitignore | 1 + Makefile | 2 +- clipse_gui/__init__.py | 2 +- 3 files changed, 3 insertions(+), 2 deletions(-) diff --git a/.gitignore b/.gitignore index 32f1c0b..79497d8 100644 --- a/.gitignore +++ b/.gitignore @@ -154,3 +154,4 @@ node_modules/ # tasks.json # tasks/ .taskmaster/ +aur/ diff --git a/Makefile b/Makefile index bcf84bc..3822805 100644 --- a/Makefile +++ b/Makefile @@ -100,7 +100,7 @@ install: @mkdir -p "$(DESKTOP_DEST_DIR)" @printf "%s\n" \ "[Desktop Entry]" \ - "Version=0.5.0" \ + "Version=0.6.0" \ "Type=Application" \ "Name=Clipse GUI" \ "GenericName=Clipboard Manager" \ diff --git a/clipse_gui/__init__.py b/clipse_gui/__init__.py index 3d18726..906d362 100644 --- a/clipse_gui/__init__.py +++ b/clipse_gui/__init__.py @@ -1 +1 @@ -__version__ = "0.5.0" +__version__ = "0.6.0" From 6c08056f054b98b1e2f9ad6afd70fed0e2a22fa7 Mon Sep 17 00:00:00 2001 From: d7om Date: Thu, 12 Feb 2026 13:30:12 +0200 Subject: [PATCH 6/6] ci: simplify release workflow and add AUR auto-update - Remove macOS and ARM64 builds (keep only x86_64) - Add AUR package auto-update job - Requires AUR_SSH_KEY secret to be configured --- .github/workflows/release.yml | 96 +++++++++++++++-------------------- 1 file changed, 42 insertions(+), 54 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 79f3e63..97173f3 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -160,79 +160,69 @@ jobs: name: appimage-x86_64 path: clipse-gui-v${{ needs.generate-changelog.outputs.version }}-linux-x86_64.AppImage - build-linux-arm64: + update-aur: runs-on: ubuntu-latest needs: generate-changelog steps: - - uses: actions/checkout@v4 + - name: Checkout AUR repo + uses: actions/checkout@v4 + with: + repository: d7omdev/clipse-gui + path: aur + token: ${{ secrets.GITHUB_TOKEN }} - - uses: docker/setup-qemu-action@v3 + - name: Setup SSH for AUR + uses: webfactory/ssh-agent@v0.9.0 with: - platforms: arm64 + ssh-private-key: ${{ secrets.AUR_SSH_KEY }} - - name: Build in ARM64 container + - name: Clone AUR package run: | - set -e - docker run --rm --platform linux/arm64 \ - -v ${{ github.workspace }}:/workspace \ - -w /workspace arm64v8/ubuntu:22.04 bash -c " - set -e - apt-get update && - apt-get install -y python3 python3-pip python3-venv python3-dev \ - python3-gi python3-gi-cairo gir1.2-gtk-3.0 \ - libgirepository1.0-dev build-essential \ - libssl-dev zlib1g-dev patchelf libcairo2-dev pkg-config && - python3 -m venv --system-site-packages venv && - . venv/bin/activate && - pip install -U pip Nuitka==2.6.9 ordered-set==4.1.0 zstandard==0.23.0 && - make nuitka && - mv dist/clipse-gui.bin \ - dist/clipse-gui-v${{ needs.generate-changelog.outputs.version }}-linux-aarch64 && - chmod +x dist/* - " + git clone ssh://aur@aur.archlinux.org/clipse-gui.git aur-package - - uses: actions/upload-artifact@v4 - with: - name: linux-arm64 - path: dist/clipse-gui-v${{ needs.generate-changelog.outputs.version }}-linux-aarch64 + - name: Update AUR package version + run: | + VERSION="${{ needs.generate-changelog.outputs.version }}" + cd aur-package - build-macos: - needs: generate-changelog - runs-on: macos-15 - steps: - - uses: actions/checkout@v4 + # Update PKGBUILD version + sed -i "s/pkgver=.*/pkgver=$VERSION/" PKGBUILD - - uses: actions/setup-python@v5 - with: - python-version: "3.11" + # Update .SRCINFO version + sed -i "s/pkgver = .*/pkgver = $VERSION/" .SRCINFO - - name: Install deps - run: brew install gtk+3 pygobject3 cairo + # Show changes + echo "Updated PKGBUILD:" + grep "pkgver=" PKGBUILD + echo "" + echo "Updated .SRCINFO:" + grep "pkgver =" .SRCINFO - - name: Build + - name: Build and verify AUR package run: | - set -e - python -m pip install pyinstaller pillow - python -m PyInstaller --onefile \ - --name clipse-gui-v${{ needs.generate-changelog.outputs.version }}-macos-arm64 \ - --hidden-import=gi \ - --collect-all gi \ - clipse-gui.py - chmod +x dist/* + cd aur-package + # Validate PKGBUILD + makepkg --printsrcinfo > .SRCINFO - - uses: actions/upload-artifact@v4 - with: - name: macos-arm64 - path: dist/clipse-gui-v${{ needs.generate-changelog.outputs.version }}-macos-arm64 + - name: Commit and push to AUR + run: | + VERSION="${{ needs.generate-changelog.outputs.version }}" + cd aur-package + + git config user.name "GitHub Actions" + git config user.email "actions@github.com" + + git add PKGBUILD .SRCINFO + git commit -m "Update to v$VERSION" + git push origin master create-release: runs-on: ubuntu-latest needs: - generate-changelog - build-linux-x86_64 - - build-linux-arm64 - - build-macos - build-appimage + - update-aur steps: - uses: actions/download-artifact@v4 @@ -246,6 +236,4 @@ jobs: body: ${{ needs.generate-changelog.outputs.release_body }} files: | artifacts/linux-x86_64/* - artifacts/linux-arm64/* - artifacts/macos-arm64/* artifacts/appimage-x86_64/*