diff --git a/.gitignore b/.gitignore index 36b13f1..26c64cd 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,9 @@ +# macOS +.DS_Store + +# Build artifacts +.build_venv/ + # ---> Python # Byte-compiled / optimized / DLL files __pycache__/ diff --git a/DEBUGGING.md b/DEBUGGING.md new file mode 100644 index 0000000..4a34c36 --- /dev/null +++ b/DEBUGGING.md @@ -0,0 +1,118 @@ +# Debugging Path Juggler + +## Build System + +The build scripts use **uv** to manage Python versions and dependencies, ensuring a truly self-contained build that doesn't depend on system Python installations. This means: +- ✅ No dependency on system Python versions +- ✅ No dependency on Homebrew Python +- ✅ Consistent builds across different systems +- ✅ Self-contained .app bundle with everything included + +## The Problem + +When you launch the built app and nothing happens, it's because: +1. The app is built with `console=False`, so errors are hidden +2. The app is failing silently due to missing dependencies (like `tkinter`) + +## Solution 1: Run from Terminal (Quick Debug) + +Run the existing app from terminal to see errors: + +```bash +./run_app_debug.sh +``` + +Or manually: +```bash +"/Users/justinaspetravicius/Public/Gitea/path-juggler/dist/Path Juggler.app/Contents/MacOS/Path Juggler" +``` + +This will show you any error messages in the terminal. + +## Solution 2: Build Debug Version + +Build a version with console output enabled: + +```bash +./build_app_debug.sh +``` + +This creates `Path Juggler Debug.app` which will show a terminal window with all output. + +## Solution 3: Check System Logs + +On macOS, you can also check system logs: + +```bash +# View recent logs for the app +log show --predicate 'process == "Path Juggler"' --last 5m + +# Or use Console.app (GUI) +open -a Console +``` + +## Common Issues Found + +### Issue: Missing tkinter + +**Error:** `ModuleNotFoundError: No module named 'tkinter'` or `ModuleNotFoundError: No module named '_tkinter'` + +**Root Cause:** The build scripts now use `uv` to install Python 3.12, which includes tkinter by default. If you still see this error, it may be a PyInstaller bundling issue. + +**Fix:** The build scripts use `uv` to: +1. Install Python 3.12 (which has tkinter built-in) +2. Create a virtual environment with that Python +3. Install all dependencies +4. Build with PyInstaller (which bundles everything including tkinter) + +Rebuild the app: +```bash +./build_app.sh +``` + +**If tkinter is still missing:** +1. Make sure `uv` is installed: `brew install uv` or `curl -LsSf https://astral.sh/uv/install.sh | sh` +2. The build script will automatically download Python 3.12 if needed +3. PyInstaller should bundle tkinter automatically - if not, check the PyInstaller output for warnings + +### Issue: Missing watchdog modules + +**Error:** `ModuleNotFoundError: No module named 'watchdog.observers.fsevents'` + +**Fix:** Make sure watchdog is installed in the build environment. The build script handles this automatically. + +## Rebuilding After Fixes + +After fixing issues, rebuild: + +```bash +# Production build (no console) +./build_app.sh + +# Debug build (with console) +./build_app_debug.sh +``` + +## Testing the Fix + +After rebuilding, test: + +1. **Quick test from terminal:** + ```bash + ./run_app_debug.sh + ``` + +2. **Or launch the debug version:** + ```bash + open "dist/Path Juggler Debug.app" + ``` + +3. **Or launch the production version:** + ```bash + open "dist/Path Juggler.app" + ``` + +## Current Status + +The build scripts have been updated to include `tkinter` in the hidden imports. You should rebuild the app to apply the fix. + diff --git a/build_app.sh b/build_app.sh old mode 100644 new mode 100755 index d0bfbcb..e75485f --- a/build_app.sh +++ b/build_app.sh @@ -1,7 +1,8 @@ #!/bin/bash # # Build script for Path Juggler -# Creates a standalone macOS .app bundle with ad-hoc code signing +# Creates a standalone macOS .app bundle using Nuitka +# Uses PySide6 (Qt) for GUI - fully bundleable, no system dependencies # set -e @@ -13,146 +14,107 @@ VENV_DIR="$SCRIPT_DIR/.build_venv" DIST_DIR="$SCRIPT_DIR/dist" echo "=== Path Juggler - Build Script ===" +echo "Using Nuitka + PySide6 for a fully self-contained build" echo "" -# Check for Python 3 -if ! command -v python3 &> /dev/null; then - echo "Error: python3 is required but not found" +# Check for uv +if ! command -v uv &> /dev/null; then + echo "Error: uv is required but not found" + echo "" + echo "Install uv with:" + echo " curl -LsSf https://astral.sh/uv/install.sh | sh" + echo " or: brew install uv" exit 1 fi -PYTHON_VERSION=$(python3 -c 'import sys; print(f"{sys.version_info.major}.{sys.version_info.minor}")') -echo "Using Python $PYTHON_VERSION" - -# Create virtual environment +echo "Using uv version: $(uv --version)" echo "" + +# Create virtual environment with Python 3.11 (good compatibility) echo "=== Creating virtual environment ===" if [ -d "$VENV_DIR" ]; then echo "Removing existing build environment..." rm -rf "$VENV_DIR" fi -python3 -m venv "$VENV_DIR" +uv venv "$VENV_DIR" --python 3.11 source "$VENV_DIR/bin/activate" -# Upgrade pip +PYTHON_VERSION=$(python -c 'import sys; print(f"{sys.version_info.major}.{sys.version_info.minor}")') +echo "Python version: $PYTHON_VERSION" + +# Install build dependencies echo "" echo "=== Installing build dependencies ===" -pip install --upgrade pip --quiet -pip install pyinstaller watchdog --quiet +uv pip install --quiet PySide6 watchdog nuitka ordered-set zstandard -echo "Installed:" -pip list | grep -E "(pyinstaller|watchdog)" +echo "Installed packages:" +uv pip list | grep -iE "(pyside6|watchdog|nuitka)" || true -# Create PyInstaller spec file for more control +# Build with Nuitka echo "" -echo "=== Creating build configuration ===" +echo "=== Building application with Nuitka ===" -cat > "$SCRIPT_DIR/path_juggler.spec" << 'SPEC' -# -*- mode: python ; coding: utf-8 -*- +# Clean previous build +rm -rf "$DIST_DIR/$APP_NAME.app" 2>/dev/null || true +rm -rf "$DIST_DIR"/*.build 2>/dev/null || true +rm -rf "$DIST_DIR"/*.dist 2>/dev/null || true -import sys -from pathlib import Path +python -m nuitka \ + --standalone \ + --macos-create-app-bundle \ + --macos-app-name="$APP_NAME" \ + --product-name="$APP_NAME" \ + --product-version="1.0.0" \ + --company-name="PathJuggler" \ + --file-description="File organization utility" \ + --enable-plugin=pyside6 \ + --include-module=watchdog.observers \ + --include-module=watchdog.observers.fsevents \ + --include-module=watchdog.events \ + --output-dir="$DIST_DIR" \ + --remove-output \ + --assume-yes-for-downloads \ + path_juggler_gui.py -block_cipher = None - -a = Analysis( - ['path_juggler_gui.py'], - pathex=[], - binaries=[], - datas=[], - hiddenimports=[ - 'watchdog.observers', - 'watchdog.observers.fsevents', - 'watchdog.events', - ], - hookspath=[], - hooksconfig={}, - runtime_hooks=[], - excludes=[], - win_no_prefer_redirects=False, - win_private_assemblies=False, - cipher=block_cipher, - noarchive=False, -) - -pyz = PYZ(a.pure, a.zipped_data, cipher=block_cipher) - -exe = EXE( - pyz, - a.scripts, - [], - exclude_binaries=True, - name='Path Juggler', - debug=False, - bootloader_ignore_signals=False, - strip=False, - upx=True, - console=False, # No terminal window - disable_windowed_traceback=False, - argv_emulation=True, # Important for macOS - target_arch=None, - codesign_identity=None, - entitlements_file=None, -) - -coll = COLLECT( - exe, - a.binaries, - a.zipfiles, - a.datas, - strip=False, - upx=True, - upx_exclude=[], - name='Path Juggler', -) - -app = BUNDLE( - coll, - name='Path Juggler.app', - icon=None, - bundle_identifier='com.pathjuggler.app', - info_plist={ - 'CFBundleName': 'Path Juggler', - 'CFBundleDisplayName': 'Path Juggler', - 'CFBundleVersion': '1.0.0', - 'CFBundleShortVersionString': '1.0.0', - 'NSHighResolutionCapable': True, - 'LSMinimumSystemVersion': '10.13.0', - 'NSAppleEventsUsageDescription': 'Path Juggler needs to access files.', - }, -) -SPEC - -# Build the app -echo "" -echo "=== Building application ===" -pyinstaller --clean --noconfirm path_juggler.spec - -# Sign the app with ad-hoc signature -echo "" -echo "=== Signing application (ad-hoc) ===" -APP_PATH="$DIST_DIR/Path Juggler.app" +# Find the generated app +APP_PATH="$DIST_DIR/path_juggler_gui.app" +FINAL_APP_PATH="$DIST_DIR/$APP_NAME.app" if [ -d "$APP_PATH" ]; then - # Remove any existing signatures - codesign --remove-signature "$APP_PATH" 2>/dev/null || true + # Rename to proper name + rm -rf "$FINAL_APP_PATH" 2>/dev/null || true + mv "$APP_PATH" "$FINAL_APP_PATH" - # Sign with ad-hoc signature (dash means ad-hoc) - codesign --force --deep --sign - "$APP_PATH" + # Update Info.plist with proper bundle ID + PLIST="$FINAL_APP_PATH/Contents/Info.plist" + if [ -f "$PLIST" ]; then + /usr/libexec/PlistBuddy -c "Set :CFBundleIdentifier $BUNDLE_ID" "$PLIST" 2>/dev/null || true + /usr/libexec/PlistBuddy -c "Set :CFBundleName $APP_NAME" "$PLIST" 2>/dev/null || true + /usr/libexec/PlistBuddy -c "Set :CFBundleDisplayName $APP_NAME" "$PLIST" 2>/dev/null || true + fi + + # Sign the app + echo "" + echo "=== Signing application (ad-hoc) ===" + codesign --remove-signature "$FINAL_APP_PATH" 2>/dev/null || true + codesign --force --deep --sign - "$FINAL_APP_PATH" echo "Signature applied" - codesign -dv "$APP_PATH" 2>&1 | head -5 + codesign -dv "$FINAL_APP_PATH" 2>&1 | head -5 else - echo "Error: App bundle not found at $APP_PATH" + echo "Error: App bundle not found" + echo "Looking for: $APP_PATH" + ls -la "$DIST_DIR/" exit 1 fi # Clean up echo "" echo "=== Cleaning up ===" -rm -rf "$SCRIPT_DIR/build" -rm -f "$SCRIPT_DIR/path_juggler.spec" +rm -rf "$DIST_DIR"/*.build 2>/dev/null || true +rm -rf "$DIST_DIR"/*.dist 2>/dev/null || true +rm -rf "$DIST_DIR"/*.onefile-build 2>/dev/null || true deactivate rm -rf "$VENV_DIR" @@ -160,7 +122,7 @@ echo "" echo "=== Build complete ===" echo "" echo "Application created at:" -echo " $APP_PATH" +echo " $FINAL_APP_PATH" echo "" echo "To install, drag the app to your Applications folder:" echo " open \"$DIST_DIR\"" diff --git a/build_app_debug.sh b/build_app_debug.sh new file mode 100755 index 0000000..664b2f6 --- /dev/null +++ b/build_app_debug.sh @@ -0,0 +1,120 @@ +#!/bin/bash +# +# Debug build script for Path Juggler +# Creates a standalone macOS .app bundle using Nuitka with debug output +# + +set -e + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +APP_NAME="Path Juggler Debug" +BUNDLE_ID="com.pathjuggler.app.debug" +VENV_DIR="$SCRIPT_DIR/.build_venv" +DIST_DIR="$SCRIPT_DIR/dist" + +echo "=== Path Juggler - Debug Build Script ===" +echo "Using Nuitka + PySide6 for a fully self-contained build" +echo "" + +# Check for uv +if ! command -v uv &> /dev/null; then + echo "Error: uv is required but not found" + echo "" + echo "Install uv with:" + echo " curl -LsSf https://astral.sh/uv/install.sh | sh" + echo " or: brew install uv" + exit 1 +fi + +echo "Using uv version: $(uv --version)" +echo "" + +# Create virtual environment +echo "=== Creating virtual environment ===" +if [ -d "$VENV_DIR" ]; then + echo "Removing existing build environment..." + rm -rf "$VENV_DIR" +fi + +uv venv "$VENV_DIR" --python 3.11 +source "$VENV_DIR/bin/activate" + +PYTHON_VERSION=$(python -c 'import sys; print(f"{sys.version_info.major}.{sys.version_info.minor}")') +echo "Python version: $PYTHON_VERSION" + +# Install build dependencies +echo "" +echo "=== Installing build dependencies ===" +uv pip install --quiet PySide6 watchdog nuitka ordered-set zstandard + +echo "Installed packages:" +uv pip list | grep -iE "(pyside6|watchdog|nuitka)" || true + +# Build with Nuitka (debug mode - keeps console) +echo "" +echo "=== Building DEBUG application with Nuitka ===" + +rm -rf "$DIST_DIR/$APP_NAME.app" 2>/dev/null || true + +python -m nuitka \ + --standalone \ + --macos-create-app-bundle \ + --macos-app-name="$APP_NAME" \ + --product-name="$APP_NAME" \ + --product-version="1.0.0" \ + --company-name="Path Juggler" \ + --enable-plugin=pyside6 \ + --include-module=watchdog.observers \ + --include-module=watchdog.observers.fsevents \ + --include-module=watchdog.events \ + --output-dir="$DIST_DIR" \ + --output-filename="$APP_NAME" \ + --remove-output \ + --assume-yes-for-downloads \ + --enable-console \ + --debug \ + path_juggler_gui.py + +# Find the generated app +APP_PATH="$DIST_DIR/path_juggler_gui.app" +FINAL_APP_PATH="$DIST_DIR/$APP_NAME.app" + +if [ -d "$APP_PATH" ]; then + mv "$APP_PATH" "$FINAL_APP_PATH" + + # Update Info.plist + PLIST="$FINAL_APP_PATH/Contents/Info.plist" + if [ -f "$PLIST" ]; then + /usr/libexec/PlistBuddy -c "Set :CFBundleIdentifier $BUNDLE_ID" "$PLIST" 2>/dev/null || true + /usr/libexec/PlistBuddy -c "Set :CFBundleName $APP_NAME" "$PLIST" 2>/dev/null || true + fi + + # Sign + echo "" + echo "=== Signing application (ad-hoc) ===" + codesign --remove-signature "$FINAL_APP_PATH" 2>/dev/null || true + codesign --force --deep --sign - "$FINAL_APP_PATH" + + echo "Signature applied" +else + echo "Error: App bundle not found" + ls -la "$DIST_DIR/" + exit 1 +fi + +# Clean up +echo "" +echo "=== Cleaning up ===" +rm -rf "$DIST_DIR"/*.build 2>/dev/null || true +rm -rf "$DIST_DIR"/*.dist 2>/dev/null || true +deactivate +rm -rf "$VENV_DIR" + +echo "" +echo "=== Debug Build complete ===" +echo "" +echo "Debug application created at:" +echo " $FINAL_APP_PATH" +echo "" +echo "Run from terminal to see output:" +echo " \"$FINAL_APP_PATH/Contents/MacOS/$APP_NAME\"" diff --git a/path_juggler_core.py b/path_juggler_core.py index eb8c570..dba7cb2 100644 --- a/path_juggler_core.py +++ b/path_juggler_core.py @@ -13,7 +13,7 @@ import time import logging from pathlib import Path from threading import Thread, Event -from typing import Callable +from typing import Callable, Optional from watchdog.observers import Observer from watchdog.events import FileSystemEventHandler @@ -57,7 +57,7 @@ def is_file_open(file_path: Path) -> bool: return True -def get_file_owner_process(file_path: Path) -> str | None: +def get_file_owner_process(file_path: Path) -> Optional[str]: """Get the name of the process that has the file open.""" try: result = subprocess.run( @@ -83,7 +83,7 @@ def get_file_owner_process(file_path: Path) -> str | None: def wait_for_file_ready( file_path: Path, - stop_event: Event | None = None, + stop_event: Optional[Event] = None, check_interval: float = 2.0, stable_duration: float = 5.0, timeout: float = 3600.0 @@ -151,7 +151,7 @@ def get_honey_volumes() -> list[Path]: return sorted(volumes) -def parse_filename(filename: str) -> dict | None: +def parse_filename(filename: str) -> Optional[dict]: """Parse an MXF filename to extract camera and reel info.""" match = FILENAME_PATTERN.match(filename) if not match: @@ -169,7 +169,7 @@ def parse_filename(filename: str) -> dict | None: } -def find_bin_folder(reel_prefix: str, volumes: list[Path]) -> dict | None: +def find_bin_folder(reel_prefix: str, volumes: list[Path]) -> Optional[dict]: """Search HONEY volumes for a bin folder matching the reel prefix.""" for volume in volumes: project_path = volume / PROJECT_FOLDER @@ -312,10 +312,10 @@ class PathJuggler: def __init__(self, dry_run: bool = False): self.dry_run = dry_run - self.observer: Observer | None = None + self.observer: Optional[Observer] = None self.stop_event = Event() self.running = False - self._thread: Thread | None = None + self._thread: Optional[Thread] = None def setup_folders(self): """Ensure watch and destination folders exist.""" diff --git a/path_juggler_gui.py b/path_juggler_gui.py index 9ae775a..7ef7093 100644 --- a/path_juggler_gui.py +++ b/path_juggler_gui.py @@ -4,15 +4,23 @@ Path Juggler - GUI Application A simple macOS GUI for watching Resolve render outputs and organizing them into editorial proxy folder structures. + +Uses PySide6 (Qt) for a modern, fully bundleable GUI. """ -import tkinter as tk -from tkinter import ttk, scrolledtext +import sys import logging import queue from datetime import datetime from pathlib import Path +from PySide6.QtWidgets import ( + QApplication, QMainWindow, QWidget, QVBoxLayout, QHBoxLayout, + QLabel, QPushButton, QTextEdit, QFrame, QGroupBox +) +from PySide6.QtCore import Qt, QTimer, Signal, QObject +from PySide6.QtGui import QFont, QColor, QPalette, QTextCharFormat, QBrush + from path_juggler_core import ( PathJuggler, get_honey_volumes, @@ -32,23 +40,28 @@ class QueueHandler(logging.Handler): self.log_queue.put(record) -class PathJugglerApp: +class LogSignals(QObject): + """Signals for thread-safe log updates.""" + new_log = Signal(object) + + +class PathJugglerApp(QMainWindow): """Main GUI Application.""" def __init__(self): - self.root = tk.Tk() - self.root.title("Path Juggler") - self.root.geometry("700x500") - self.root.minsize(500, 300) + super().__init__() - # Set macOS-like appearance - self.root.configure(bg="#f0f0f0") + self.setWindowTitle("Path Juggler") + self.setMinimumSize(600, 450) + self.resize(750, 550) # Core juggler instance self.juggler = PathJuggler(dry_run=False) # Logging queue for thread-safe log updates self.log_queue: queue.Queue = queue.Queue() + self.log_signals = LogSignals() + self.log_signals.new_log.connect(self._append_log) # Setup logging to our queue self._setup_logging() @@ -57,233 +70,263 @@ class PathJugglerApp: self._create_widgets() # Start polling the log queue - self._poll_log_queue() + self.log_timer = QTimer() + self.log_timer.timeout.connect(self._poll_log_queue) + self.log_timer.start(100) # Update status periodically + self.status_timer = QTimer() + self.status_timer.timeout.connect(self._update_status) + self.status_timer.start(5000) + + # Initial status update self._update_status() - # Handle window close - self.root.protocol("WM_DELETE_WINDOW", self._on_close) + # Initial log messages + logging.info("Path Juggler ready") + logging.info("Click 'Start Juggling' to begin") def _setup_logging(self): """Configure logging to use our queue handler.""" - # Create queue handler queue_handler = QueueHandler(self.log_queue) queue_handler.setLevel(logging.DEBUG) - # Format formatter = logging.Formatter("%(asctime)s - %(levelname)s - %(message)s", "%H:%M:%S") queue_handler.setFormatter(formatter) - # Add to root logger root_logger = logging.getLogger() root_logger.setLevel(logging.INFO) root_logger.addHandler(queue_handler) - # Also add to our module logger core_logger = logging.getLogger("path_juggler_core") core_logger.setLevel(logging.INFO) def _create_widgets(self): """Create all UI widgets.""" - # Main container with padding - main_frame = ttk.Frame(self.root, padding="10") - main_frame.pack(fill=tk.BOTH, expand=True) + # Central widget + central = QWidget() + self.setCentralWidget(central) - # === Header / Status Section === - header_frame = ttk.Frame(main_frame) - header_frame.pack(fill=tk.X, pady=(0, 10)) + # Main layout + layout = QVBoxLayout(central) + layout.setContentsMargins(16, 16, 16, 16) + layout.setSpacing(12) - # Title - title_label = ttk.Label( - header_frame, - text="Path Juggler", - font=("SF Pro Display", 18, "bold") - ) - title_label.pack(side=tk.LEFT) + # === Header === + header = QHBoxLayout() + + title = QLabel("Path Juggler") + title_font = QFont() + title_font.setPointSize(20) + title_font.setBold(True) + title.setFont(title_font) + header.addWidget(title) + + header.addStretch() # Status indicator - self.status_frame = ttk.Frame(header_frame) - self.status_frame.pack(side=tk.RIGHT) + status_layout = QHBoxLayout() + status_layout.setSpacing(8) - self.status_dot = tk.Canvas( - self.status_frame, - width=12, - height=12, - highlightthickness=0, - bg="#f0f0f0" - ) - self.status_dot.pack(side=tk.LEFT, padx=(0, 5)) - self._draw_status_dot("stopped") + self.status_dot = QLabel("●") + self.status_dot.setStyleSheet("color: #8e8e93; font-size: 14px;") + status_layout.addWidget(self.status_dot) - self.status_label = ttk.Label( - self.status_frame, - text="Stopped", - font=("SF Pro Text", 12) - ) - self.status_label.pack(side=tk.LEFT) + self.status_label = QLabel("Stopped") + status_font = QFont() + status_font.setPointSize(13) + self.status_label.setFont(status_font) + status_layout.addWidget(self.status_label) + + header.addLayout(status_layout) + layout.addLayout(header) # === Info Section === - info_frame = ttk.LabelFrame(main_frame, text="Status", padding="10") - info_frame.pack(fill=tk.X, pady=(0, 10)) + info_group = QGroupBox("Status") + info_layout = QVBoxLayout(info_group) + info_layout.setSpacing(6) - # Volumes status - volumes_frame = ttk.Frame(info_frame) - volumes_frame.pack(fill=tk.X, pady=(0, 5)) - - ttk.Label(volumes_frame, text="Volumes:", font=("SF Pro Text", 11, "bold")).pack(side=tk.LEFT) - self.volumes_label = ttk.Label(volumes_frame, text="Checking...", font=("SF Pro Text", 11)) - self.volumes_label.pack(side=tk.LEFT, padx=(5, 0)) + # Volumes + vol_layout = QHBoxLayout() + vol_label = QLabel("Volumes:") + vol_label.setStyleSheet("font-weight: bold;") + vol_layout.addWidget(vol_label) + self.volumes_label = QLabel("Checking...") + vol_layout.addWidget(self.volumes_label) + vol_layout.addStretch() + info_layout.addLayout(vol_layout) # Watch folders - watch_frame = ttk.Frame(info_frame) - watch_frame.pack(fill=tk.X, pady=(0, 5)) - - ttk.Label(watch_frame, text="Watching:", font=("SF Pro Text", 11, "bold")).pack(side=tk.LEFT) + watch_layout = QHBoxLayout() + watch_label = QLabel("Watching:") + watch_label.setStyleSheet("font-weight: bold;") + watch_layout.addWidget(watch_label) watch_paths = ", ".join([f"~/{p.relative_to(Path.home())}" for p in WATCH_FOLDERS.values()]) - ttk.Label(watch_frame, text=watch_paths, font=("SF Pro Text", 11)).pack(side=tk.LEFT, padx=(5, 0)) + watch_layout.addWidget(QLabel(watch_paths)) + watch_layout.addStretch() + info_layout.addLayout(watch_layout) # Destination - dest_frame = ttk.Frame(info_frame) - dest_frame.pack(fill=tk.X) - - ttk.Label(dest_frame, text="Output:", font=("SF Pro Text", 11, "bold")).pack(side=tk.LEFT) + dest_layout = QHBoxLayout() + dest_label = QLabel("Output:") + dest_label.setStyleSheet("font-weight: bold;") + dest_layout.addWidget(dest_label) dest_path = f"~/{DESTINATION_BASE.relative_to(Path.home())}" - ttk.Label(dest_frame, text=dest_path, font=("SF Pro Text", 11)).pack(side=tk.LEFT, padx=(5, 0)) + dest_layout.addWidget(QLabel(dest_path)) + dest_layout.addStretch() + info_layout.addLayout(dest_layout) + + layout.addWidget(info_group) # === Control Buttons === - button_frame = ttk.Frame(main_frame) - button_frame.pack(fill=tk.X, pady=(0, 10)) + button_layout = QHBoxLayout() - # Style for buttons - style = ttk.Style() - style.configure("Start.TButton", font=("SF Pro Text", 12)) - style.configure("Stop.TButton", font=("SF Pro Text", 12)) + self.start_button = QPushButton("▶ Start Juggling") + self.start_button.setMinimumWidth(160) + self.start_button.setMinimumHeight(36) + self.start_button.clicked.connect(self._start) + self.start_button.setStyleSheet(""" + QPushButton { + background-color: #34c759; + color: white; + border: none; + border-radius: 6px; + font-size: 14px; + font-weight: bold; + padding: 8px 16px; + } + QPushButton:hover { + background-color: #2db84e; + } + QPushButton:disabled { + background-color: #c7c7cc; + } + """) + button_layout.addWidget(self.start_button) - self.start_button = ttk.Button( - button_frame, - text="▶ Start Juggling", - command=self._start, - style="Start.TButton", - width=20 - ) - self.start_button.pack(side=tk.LEFT, padx=(0, 10)) + self.stop_button = QPushButton("◼ Stop") + self.stop_button.setMinimumWidth(120) + self.stop_button.setMinimumHeight(36) + self.stop_button.clicked.connect(self._stop) + self.stop_button.setEnabled(False) + self.stop_button.setStyleSheet(""" + QPushButton { + background-color: #ff3b30; + color: white; + border: none; + border-radius: 6px; + font-size: 14px; + font-weight: bold; + padding: 8px 16px; + } + QPushButton:hover { + background-color: #e6352b; + } + QPushButton:disabled { + background-color: #c7c7cc; + } + """) + button_layout.addWidget(self.stop_button) - self.stop_button = ttk.Button( - button_frame, - text="◼ Stop", - command=self._stop, - style="Stop.TButton", - state=tk.DISABLED, - width=15 - ) - self.stop_button.pack(side=tk.LEFT) + button_layout.addStretch() - # Clear log button - clear_button = ttk.Button( - button_frame, - text="Clear Log", - command=self._clear_log, - width=10 - ) - clear_button.pack(side=tk.RIGHT) + clear_button = QPushButton("Clear Log") + clear_button.setMinimumHeight(36) + clear_button.clicked.connect(self._clear_log) + clear_button.setStyleSheet(""" + QPushButton { + background-color: #e5e5ea; + color: #1c1c1e; + border: none; + border-radius: 6px; + font-size: 13px; + padding: 8px 16px; + } + QPushButton:hover { + background-color: #d1d1d6; + } + """) + button_layout.addWidget(clear_button) + + layout.addLayout(button_layout) # === Log Section === - log_frame = ttk.LabelFrame(main_frame, text="Activity Log", padding="5") - log_frame.pack(fill=tk.BOTH, expand=True) + log_group = QGroupBox("Activity Log") + log_layout = QVBoxLayout(log_group) - # Scrolled text widget for logs - self.log_text = scrolledtext.ScrolledText( - log_frame, - wrap=tk.WORD, - font=("SF Mono", 11), - bg="#1e1e1e", - fg="#d4d4d4", - insertbackground="#d4d4d4", - selectbackground="#264f78", - state=tk.DISABLED, - height=15 - ) - self.log_text.pack(fill=tk.BOTH, expand=True) + self.log_text = QTextEdit() + self.log_text.setReadOnly(True) + self.log_text.setStyleSheet(""" + QTextEdit { + background-color: #1e1e1e; + color: #d4d4d4; + border: 1px solid #3a3a3c; + border-radius: 6px; + font-family: 'SF Mono', 'Menlo', 'Monaco', monospace; + font-size: 12px; + padding: 8px; + } + """) + log_layout.addWidget(self.log_text) - # Configure log text tags for different log levels - self.log_text.tag_configure("INFO", foreground="#d4d4d4") - self.log_text.tag_configure("WARNING", foreground="#dcdcaa") - self.log_text.tag_configure("ERROR", foreground="#f14c4c") - self.log_text.tag_configure("DEBUG", foreground="#808080") - self.log_text.tag_configure("timestamp", foreground="#6a9955") - - def _draw_status_dot(self, status: str): - """Draw the status indicator dot.""" - self.status_dot.delete("all") - - colors = { - "running": "#34c759", # Green - "stopped": "#8e8e93", # Gray - "error": "#ff3b30", # Red - } - color = colors.get(status, colors["stopped"]) - - self.status_dot.create_oval(2, 2, 10, 10, fill=color, outline=color) + layout.addWidget(log_group, stretch=1) def _start(self): """Start the file watcher.""" - self.start_button.config(state=tk.DISABLED) - self.stop_button.config(state=tk.NORMAL) + self.start_button.setEnabled(False) + self.stop_button.setEnabled(True) - self._draw_status_dot("running") - self.status_label.config(text="Juggling") + self.status_dot.setStyleSheet("color: #34c759; font-size: 14px;") + self.status_label.setText("Juggling") - # Start juggler in background thread self.juggler.start_async() def _stop(self): """Stop the file watcher.""" - self.stop_button.config(state=tk.DISABLED) + self.stop_button.setEnabled(False) self.juggler.stop() - self.start_button.config(state=tk.NORMAL) - self._draw_status_dot("stopped") - self.status_label.config(text="Stopped") + self.start_button.setEnabled(True) + self.status_dot.setStyleSheet("color: #8e8e93; font-size: 14px;") + self.status_label.setText("Stopped") def _clear_log(self): """Clear the log text area.""" - self.log_text.config(state=tk.NORMAL) - self.log_text.delete(1.0, tk.END) - self.log_text.config(state=tk.DISABLED) + self.log_text.clear() def _poll_log_queue(self): - """Poll the log queue and update the text widget.""" + """Poll the log queue and emit signals for thread-safe updates.""" while True: try: record = self.log_queue.get_nowait() - self._append_log(record) + self.log_signals.new_log.emit(record) except queue.Empty: break - - # Schedule next poll - self.root.after(100, self._poll_log_queue) def _append_log(self, record: logging.LogRecord): """Append a log record to the text widget.""" - self.log_text.config(state=tk.NORMAL) - - # Format: [HH:MM:SS] LEVEL - Message timestamp = datetime.fromtimestamp(record.created).strftime("%H:%M:%S") - # Insert timestamp - self.log_text.insert(tk.END, f"[{timestamp}] ", "timestamp") + # Color based on level + colors = { + "INFO": "#d4d4d4", + "WARNING": "#dcdcaa", + "ERROR": "#f14c4c", + "DEBUG": "#808080", + } + color = colors.get(record.levelname, "#d4d4d4") - # Insert message with level-based coloring - level = record.levelname - message = f"{record.getMessage()}\n" - self.log_text.insert(tk.END, message, level) + html = f'[{timestamp}] {record.getMessage()}
' - # Auto-scroll to bottom - self.log_text.see(tk.END) + cursor = self.log_text.textCursor() + cursor.movePosition(cursor.MoveOperation.End) + self.log_text.setTextCursor(cursor) + self.log_text.insertHtml(html) - self.log_text.config(state=tk.DISABLED) + # Auto-scroll + scrollbar = self.log_text.verticalScrollBar() + scrollbar.setValue(scrollbar.maximum()) def _update_status(self): """Update the volumes status periodically.""" @@ -291,35 +334,35 @@ class PathJugglerApp: if volumes: names = ", ".join([v.name for v in volumes]) - self.volumes_label.config(text=names, foreground="") + self.volumes_label.setText(names) + self.volumes_label.setStyleSheet("") else: - self.volumes_label.config(text="No HONEY volumes mounted", foreground="#ff3b30") + self.volumes_label.setText("No HONEY volumes mounted") + self.volumes_label.setStyleSheet("color: #ff3b30;") # Check if juggler stopped unexpectedly - if not self.juggler.is_running() and self.stop_button["state"] == tk.NORMAL: + if not self.juggler.is_running() and not self.start_button.isEnabled(): self._stop() - - # Schedule next update - self.root.after(5000, self._update_status) - def _on_close(self): + def closeEvent(self, event): """Handle window close.""" if self.juggler.is_running(): self.juggler.stop() - self.root.destroy() - - def run(self): - """Start the application main loop.""" - # Initial log message - logging.info("Path Juggler ready") - logging.info("Click 'Start Juggling' to begin") - - self.root.mainloop() + self.log_timer.stop() + self.status_timer.stop() + event.accept() def main(): - app = PathJugglerApp() - app.run() + app = QApplication(sys.argv) + + # Set app-wide style + app.setStyle("Fusion") + + window = PathJugglerApp() + window.show() + + sys.exit(app.exec()) if __name__ == "__main__": diff --git a/run_app_debug.sh b/run_app_debug.sh new file mode 100755 index 0000000..f88ab39 --- /dev/null +++ b/run_app_debug.sh @@ -0,0 +1,30 @@ +#!/bin/bash +# +# Run the built app from terminal to see error messages +# + +# Try debug app first, then regular app +if [ -d "/Users/justinaspetravicius/Public/Gitea/path-juggler/dist/Path Juggler Debug.app" ]; then + APP_PATH="/Users/justinaspetravicius/Public/Gitea/path-juggler/dist/Path Juggler Debug.app/Contents/MacOS/Path Juggler Debug" +elif [ -d "/Users/justinaspetravicius/Public/Gitea/path-juggler/dist/Path Juggler.app" ]; then + APP_PATH="/Users/justinaspetravicius/Public/Gitea/path-juggler/dist/Path Juggler.app/Contents/MacOS/Path Juggler" +else + echo "Error: No app found in dist/" + echo "" + echo "Please build the app first using:" + echo " ./build_app.sh" + exit 1 +fi + +if [ ! -f "$APP_PATH" ]; then + echo "Error: App executable not found at:" + echo " $APP_PATH" + exit 1 +fi + +echo "Running Path Juggler with console output..." +echo "==========================================" +echo "" + +# Run the app and capture output +"$APP_PATH" 2>&1