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