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/*
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 b30779c..3822805 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)"
@@ -89,7 +100,7 @@ install: nuitka
@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/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!")
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"
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/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))
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 <