Enhance build process and GUI for Path Juggler. Updated build script to use Nuitka for creating a standalone macOS app. Added .DS_Store and build artifacts to .gitignore. Refactored GUI to utilize PySide6, improving layout and styling. Updated logging mechanism for thread-safe operations and enhanced status indicators.

This commit is contained in:
2025-12-09 23:15:23 +02:00
parent 6479b24195
commit 06089799ee
7 changed files with 571 additions and 292 deletions

6
.gitignore vendored
View File

@@ -1,3 +1,9 @@
# macOS
.DS_Store
# Build artifacts
.build_venv/
# ---> Python # ---> Python
# Byte-compiled / optimized / DLL files # Byte-compiled / optimized / DLL files
__pycache__/ __pycache__/

118
DEBUGGING.md Normal file
View File

@@ -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.

178
build_app.sh Normal file → Executable file
View File

@@ -1,7 +1,8 @@
#!/bin/bash #!/bin/bash
# #
# Build script for Path Juggler # 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 set -e
@@ -13,146 +14,107 @@ VENV_DIR="$SCRIPT_DIR/.build_venv"
DIST_DIR="$SCRIPT_DIR/dist" DIST_DIR="$SCRIPT_DIR/dist"
echo "=== Path Juggler - Build Script ===" echo "=== Path Juggler - Build Script ==="
echo "Using Nuitka + PySide6 for a fully self-contained build"
echo "" echo ""
# Check for Python 3 # Check for uv
if ! command -v python3 &> /dev/null; then if ! command -v uv &> /dev/null; then
echo "Error: python3 is required but not found" 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 exit 1
fi fi
PYTHON_VERSION=$(python3 -c 'import sys; print(f"{sys.version_info.major}.{sys.version_info.minor}")') echo "Using uv version: $(uv --version)"
echo "Using Python $PYTHON_VERSION"
# Create virtual environment
echo "" echo ""
# Create virtual environment with Python 3.11 (good compatibility)
echo "=== Creating virtual environment ===" echo "=== Creating virtual environment ==="
if [ -d "$VENV_DIR" ]; then if [ -d "$VENV_DIR" ]; then
echo "Removing existing build environment..." echo "Removing existing build environment..."
rm -rf "$VENV_DIR" rm -rf "$VENV_DIR"
fi fi
python3 -m venv "$VENV_DIR" uv venv "$VENV_DIR" --python 3.11
source "$VENV_DIR/bin/activate" 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 ""
echo "=== Installing build dependencies ===" echo "=== Installing build dependencies ==="
pip install --upgrade pip --quiet uv pip install --quiet PySide6 watchdog nuitka ordered-set zstandard
pip install pyinstaller watchdog --quiet
echo "Installed:" echo "Installed packages:"
pip list | grep -E "(pyinstaller|watchdog)" uv pip list | grep -iE "(pyside6|watchdog|nuitka)" || true
# Create PyInstaller spec file for more control # Build with Nuitka
echo "" echo ""
echo "=== Creating build configuration ===" echo "=== Building application with Nuitka ==="
cat > "$SCRIPT_DIR/path_juggler.spec" << 'SPEC' # Clean previous build
# -*- mode: python ; coding: utf-8 -*- 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 python -m nuitka \
from pathlib import Path --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 # Find the generated app
APP_PATH="$DIST_DIR/path_juggler_gui.app"
a = Analysis( FINAL_APP_PATH="$DIST_DIR/$APP_NAME.app"
['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"
if [ -d "$APP_PATH" ]; then if [ -d "$APP_PATH" ]; then
# Remove any existing signatures # Rename to proper name
codesign --remove-signature "$APP_PATH" 2>/dev/null || true rm -rf "$FINAL_APP_PATH" 2>/dev/null || true
mv "$APP_PATH" "$FINAL_APP_PATH"
# Sign with ad-hoc signature (dash means ad-hoc) # Update Info.plist with proper bundle ID
codesign --force --deep --sign - "$APP_PATH" 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" echo "Signature applied"
codesign -dv "$APP_PATH" 2>&1 | head -5 codesign -dv "$FINAL_APP_PATH" 2>&1 | head -5
else 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 exit 1
fi fi
# Clean up # Clean up
echo "" echo ""
echo "=== Cleaning up ===" echo "=== Cleaning up ==="
rm -rf "$SCRIPT_DIR/build" rm -rf "$DIST_DIR"/*.build 2>/dev/null || true
rm -f "$SCRIPT_DIR/path_juggler.spec" rm -rf "$DIST_DIR"/*.dist 2>/dev/null || true
rm -rf "$DIST_DIR"/*.onefile-build 2>/dev/null || true
deactivate deactivate
rm -rf "$VENV_DIR" rm -rf "$VENV_DIR"
@@ -160,7 +122,7 @@ echo ""
echo "=== Build complete ===" echo "=== Build complete ==="
echo "" echo ""
echo "Application created at:" echo "Application created at:"
echo " $APP_PATH" echo " $FINAL_APP_PATH"
echo "" echo ""
echo "To install, drag the app to your Applications folder:" echo "To install, drag the app to your Applications folder:"
echo " open \"$DIST_DIR\"" echo " open \"$DIST_DIR\""

120
build_app_debug.sh Executable file
View File

@@ -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\""

View File

@@ -13,7 +13,7 @@ import time
import logging import logging
from pathlib import Path from pathlib import Path
from threading import Thread, Event from threading import Thread, Event
from typing import Callable from typing import Callable, Optional
from watchdog.observers import Observer from watchdog.observers import Observer
from watchdog.events import FileSystemEventHandler from watchdog.events import FileSystemEventHandler
@@ -57,7 +57,7 @@ def is_file_open(file_path: Path) -> bool:
return True 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.""" """Get the name of the process that has the file open."""
try: try:
result = subprocess.run( result = subprocess.run(
@@ -83,7 +83,7 @@ def get_file_owner_process(file_path: Path) -> str | None:
def wait_for_file_ready( def wait_for_file_ready(
file_path: Path, file_path: Path,
stop_event: Event | None = None, stop_event: Optional[Event] = None,
check_interval: float = 2.0, check_interval: float = 2.0,
stable_duration: float = 5.0, stable_duration: float = 5.0,
timeout: float = 3600.0 timeout: float = 3600.0
@@ -151,7 +151,7 @@ def get_honey_volumes() -> list[Path]:
return sorted(volumes) 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.""" """Parse an MXF filename to extract camera and reel info."""
match = FILENAME_PATTERN.match(filename) match = FILENAME_PATTERN.match(filename)
if not match: 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.""" """Search HONEY volumes for a bin folder matching the reel prefix."""
for volume in volumes: for volume in volumes:
project_path = volume / PROJECT_FOLDER project_path = volume / PROJECT_FOLDER
@@ -312,10 +312,10 @@ class PathJuggler:
def __init__(self, dry_run: bool = False): def __init__(self, dry_run: bool = False):
self.dry_run = dry_run self.dry_run = dry_run
self.observer: Observer | None = None self.observer: Optional[Observer] = None
self.stop_event = Event() self.stop_event = Event()
self.running = False self.running = False
self._thread: Thread | None = None self._thread: Optional[Thread] = None
def setup_folders(self): def setup_folders(self):
"""Ensure watch and destination folders exist.""" """Ensure watch and destination folders exist."""

View File

@@ -4,15 +4,23 @@ Path Juggler - GUI Application
A simple macOS GUI for watching Resolve render outputs and A simple macOS GUI for watching Resolve render outputs and
organizing them into editorial proxy folder structures. organizing them into editorial proxy folder structures.
Uses PySide6 (Qt) for a modern, fully bundleable GUI.
""" """
import tkinter as tk import sys
from tkinter import ttk, scrolledtext
import logging import logging
import queue import queue
from datetime import datetime from datetime import datetime
from pathlib import Path 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 ( from path_juggler_core import (
PathJuggler, PathJuggler,
get_honey_volumes, get_honey_volumes,
@@ -32,23 +40,28 @@ class QueueHandler(logging.Handler):
self.log_queue.put(record) 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.""" """Main GUI Application."""
def __init__(self): def __init__(self):
self.root = tk.Tk() super().__init__()
self.root.title("Path Juggler")
self.root.geometry("700x500")
self.root.minsize(500, 300)
# Set macOS-like appearance self.setWindowTitle("Path Juggler")
self.root.configure(bg="#f0f0f0") self.setMinimumSize(600, 450)
self.resize(750, 550)
# Core juggler instance # Core juggler instance
self.juggler = PathJuggler(dry_run=False) self.juggler = PathJuggler(dry_run=False)
# Logging queue for thread-safe log updates # Logging queue for thread-safe log updates
self.log_queue: queue.Queue = queue.Queue() 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 # Setup logging to our queue
self._setup_logging() self._setup_logging()
@@ -57,233 +70,263 @@ class PathJugglerApp:
self._create_widgets() self._create_widgets()
# Start polling the log queue # 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 # 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() self._update_status()
# Handle window close # Initial log messages
self.root.protocol("WM_DELETE_WINDOW", self._on_close) logging.info("Path Juggler ready")
logging.info("Click 'Start Juggling' to begin")
def _setup_logging(self): def _setup_logging(self):
"""Configure logging to use our queue handler.""" """Configure logging to use our queue handler."""
# Create queue handler
queue_handler = QueueHandler(self.log_queue) queue_handler = QueueHandler(self.log_queue)
queue_handler.setLevel(logging.DEBUG) queue_handler.setLevel(logging.DEBUG)
# Format
formatter = logging.Formatter("%(asctime)s - %(levelname)s - %(message)s", "%H:%M:%S") formatter = logging.Formatter("%(asctime)s - %(levelname)s - %(message)s", "%H:%M:%S")
queue_handler.setFormatter(formatter) queue_handler.setFormatter(formatter)
# Add to root logger
root_logger = logging.getLogger() root_logger = logging.getLogger()
root_logger.setLevel(logging.INFO) root_logger.setLevel(logging.INFO)
root_logger.addHandler(queue_handler) root_logger.addHandler(queue_handler)
# Also add to our module logger
core_logger = logging.getLogger("path_juggler_core") core_logger = logging.getLogger("path_juggler_core")
core_logger.setLevel(logging.INFO) core_logger.setLevel(logging.INFO)
def _create_widgets(self): def _create_widgets(self):
"""Create all UI widgets.""" """Create all UI widgets."""
# Main container with padding # Central widget
main_frame = ttk.Frame(self.root, padding="10") central = QWidget()
main_frame.pack(fill=tk.BOTH, expand=True) self.setCentralWidget(central)
# === Header / Status Section === # Main layout
header_frame = ttk.Frame(main_frame) layout = QVBoxLayout(central)
header_frame.pack(fill=tk.X, pady=(0, 10)) layout.setContentsMargins(16, 16, 16, 16)
layout.setSpacing(12)
# Title # === Header ===
title_label = ttk.Label( header = QHBoxLayout()
header_frame,
text="Path Juggler", title = QLabel("Path Juggler")
font=("SF Pro Display", 18, "bold") title_font = QFont()
) title_font.setPointSize(20)
title_label.pack(side=tk.LEFT) title_font.setBold(True)
title.setFont(title_font)
header.addWidget(title)
header.addStretch()
# Status indicator # Status indicator
self.status_frame = ttk.Frame(header_frame) status_layout = QHBoxLayout()
self.status_frame.pack(side=tk.RIGHT) status_layout.setSpacing(8)
self.status_dot = tk.Canvas( self.status_dot = QLabel("")
self.status_frame, self.status_dot.setStyleSheet("color: #8e8e93; font-size: 14px;")
width=12, status_layout.addWidget(self.status_dot)
height=12,
highlightthickness=0,
bg="#f0f0f0"
)
self.status_dot.pack(side=tk.LEFT, padx=(0, 5))
self._draw_status_dot("stopped")
self.status_label = ttk.Label( self.status_label = QLabel("Stopped")
self.status_frame, status_font = QFont()
text="Stopped", status_font.setPointSize(13)
font=("SF Pro Text", 12) self.status_label.setFont(status_font)
) status_layout.addWidget(self.status_label)
self.status_label.pack(side=tk.LEFT)
header.addLayout(status_layout)
layout.addLayout(header)
# === Info Section === # === Info Section ===
info_frame = ttk.LabelFrame(main_frame, text="Status", padding="10") info_group = QGroupBox("Status")
info_frame.pack(fill=tk.X, pady=(0, 10)) info_layout = QVBoxLayout(info_group)
info_layout.setSpacing(6)
# Volumes status # Volumes
volumes_frame = ttk.Frame(info_frame) vol_layout = QHBoxLayout()
volumes_frame.pack(fill=tk.X, pady=(0, 5)) vol_label = QLabel("Volumes:")
vol_label.setStyleSheet("font-weight: bold;")
ttk.Label(volumes_frame, text="Volumes:", font=("SF Pro Text", 11, "bold")).pack(side=tk.LEFT) vol_layout.addWidget(vol_label)
self.volumes_label = ttk.Label(volumes_frame, text="Checking...", font=("SF Pro Text", 11)) self.volumes_label = QLabel("Checking...")
self.volumes_label.pack(side=tk.LEFT, padx=(5, 0)) vol_layout.addWidget(self.volumes_label)
vol_layout.addStretch()
info_layout.addLayout(vol_layout)
# Watch folders # Watch folders
watch_frame = ttk.Frame(info_frame) watch_layout = QHBoxLayout()
watch_frame.pack(fill=tk.X, pady=(0, 5)) watch_label = QLabel("Watching:")
watch_label.setStyleSheet("font-weight: bold;")
ttk.Label(watch_frame, text="Watching:", font=("SF Pro Text", 11, "bold")).pack(side=tk.LEFT) watch_layout.addWidget(watch_label)
watch_paths = ", ".join([f"~/{p.relative_to(Path.home())}" for p in WATCH_FOLDERS.values()]) 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 # Destination
dest_frame = ttk.Frame(info_frame) dest_layout = QHBoxLayout()
dest_frame.pack(fill=tk.X) dest_label = QLabel("Output:")
dest_label.setStyleSheet("font-weight: bold;")
ttk.Label(dest_frame, text="Output:", font=("SF Pro Text", 11, "bold")).pack(side=tk.LEFT) dest_layout.addWidget(dest_label)
dest_path = f"~/{DESTINATION_BASE.relative_to(Path.home())}" 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 === # === Control Buttons ===
button_frame = ttk.Frame(main_frame) button_layout = QHBoxLayout()
button_frame.pack(fill=tk.X, pady=(0, 10))
# Style for buttons self.start_button = QPushButton("▶ Start Juggling")
style = ttk.Style() self.start_button.setMinimumWidth(160)
style.configure("Start.TButton", font=("SF Pro Text", 12)) self.start_button.setMinimumHeight(36)
style.configure("Stop.TButton", font=("SF Pro Text", 12)) 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( self.stop_button = QPushButton("◼ Stop")
button_frame, self.stop_button.setMinimumWidth(120)
text="▶ Start Juggling", self.stop_button.setMinimumHeight(36)
command=self._start, self.stop_button.clicked.connect(self._stop)
style="Start.TButton", self.stop_button.setEnabled(False)
width=20 self.stop_button.setStyleSheet("""
) QPushButton {
self.start_button.pack(side=tk.LEFT, padx=(0, 10)) 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_layout.addStretch()
button_frame,
text="◼ Stop",
command=self._stop,
style="Stop.TButton",
state=tk.DISABLED,
width=15
)
self.stop_button.pack(side=tk.LEFT)
# Clear log button clear_button = QPushButton("Clear Log")
clear_button = ttk.Button( clear_button.setMinimumHeight(36)
button_frame, clear_button.clicked.connect(self._clear_log)
text="Clear Log", clear_button.setStyleSheet("""
command=self._clear_log, QPushButton {
width=10 background-color: #e5e5ea;
) color: #1c1c1e;
clear_button.pack(side=tk.RIGHT) 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 Section ===
log_frame = ttk.LabelFrame(main_frame, text="Activity Log", padding="5") log_group = QGroupBox("Activity Log")
log_frame.pack(fill=tk.BOTH, expand=True) log_layout = QVBoxLayout(log_group)
# Scrolled text widget for logs self.log_text = QTextEdit()
self.log_text = scrolledtext.ScrolledText( self.log_text.setReadOnly(True)
log_frame, self.log_text.setStyleSheet("""
wrap=tk.WORD, QTextEdit {
font=("SF Mono", 11), background-color: #1e1e1e;
bg="#1e1e1e", color: #d4d4d4;
fg="#d4d4d4", border: 1px solid #3a3a3c;
insertbackground="#d4d4d4", border-radius: 6px;
selectbackground="#264f78", font-family: 'SF Mono', 'Menlo', 'Monaco', monospace;
state=tk.DISABLED, font-size: 12px;
height=15 padding: 8px;
)
self.log_text.pack(fill=tk.BOTH, expand=True)
# 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"]) """)
log_layout.addWidget(self.log_text)
self.status_dot.create_oval(2, 2, 10, 10, fill=color, outline=color) layout.addWidget(log_group, stretch=1)
def _start(self): def _start(self):
"""Start the file watcher.""" """Start the file watcher."""
self.start_button.config(state=tk.DISABLED) self.start_button.setEnabled(False)
self.stop_button.config(state=tk.NORMAL) self.stop_button.setEnabled(True)
self._draw_status_dot("running") self.status_dot.setStyleSheet("color: #34c759; font-size: 14px;")
self.status_label.config(text="Juggling") self.status_label.setText("Juggling")
# Start juggler in background thread
self.juggler.start_async() self.juggler.start_async()
def _stop(self): def _stop(self):
"""Stop the file watcher.""" """Stop the file watcher."""
self.stop_button.config(state=tk.DISABLED) self.stop_button.setEnabled(False)
self.juggler.stop() self.juggler.stop()
self.start_button.config(state=tk.NORMAL) self.start_button.setEnabled(True)
self._draw_status_dot("stopped") self.status_dot.setStyleSheet("color: #8e8e93; font-size: 14px;")
self.status_label.config(text="Stopped") self.status_label.setText("Stopped")
def _clear_log(self): def _clear_log(self):
"""Clear the log text area.""" """Clear the log text area."""
self.log_text.config(state=tk.NORMAL) self.log_text.clear()
self.log_text.delete(1.0, tk.END)
self.log_text.config(state=tk.DISABLED)
def _poll_log_queue(self): 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: while True:
try: try:
record = self.log_queue.get_nowait() record = self.log_queue.get_nowait()
self._append_log(record) self.log_signals.new_log.emit(record)
except queue.Empty: except queue.Empty:
break break
# Schedule next poll
self.root.after(100, self._poll_log_queue)
def _append_log(self, record: logging.LogRecord): def _append_log(self, record: logging.LogRecord):
"""Append a log record to the text widget.""" """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") timestamp = datetime.fromtimestamp(record.created).strftime("%H:%M:%S")
# Insert timestamp # Color based on level
self.log_text.insert(tk.END, f"[{timestamp}] ", "timestamp") colors = {
"INFO": "#d4d4d4",
"WARNING": "#dcdcaa",
"ERROR": "#f14c4c",
"DEBUG": "#808080",
}
color = colors.get(record.levelname, "#d4d4d4")
# Insert message with level-based coloring html = f'<span style="color: #6a9955;">[{timestamp}]</span> <span style="color: {color};">{record.getMessage()}</span><br>'
level = record.levelname
message = f"{record.getMessage()}\n"
self.log_text.insert(tk.END, message, level)
# Auto-scroll to bottom cursor = self.log_text.textCursor()
self.log_text.see(tk.END) 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): def _update_status(self):
"""Update the volumes status periodically.""" """Update the volumes status periodically."""
@@ -291,35 +334,35 @@ class PathJugglerApp:
if volumes: if volumes:
names = ", ".join([v.name for v in 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: 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 # 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() self._stop()
# Schedule next update def closeEvent(self, event):
self.root.after(5000, self._update_status)
def _on_close(self):
"""Handle window close.""" """Handle window close."""
if self.juggler.is_running(): if self.juggler.is_running():
self.juggler.stop() self.juggler.stop()
self.root.destroy() self.log_timer.stop()
self.status_timer.stop()
def run(self): event.accept()
"""Start the application main loop."""
# Initial log message
logging.info("Path Juggler ready")
logging.info("Click 'Start Juggling' to begin")
self.root.mainloop()
def main(): def main():
app = PathJugglerApp() app = QApplication(sys.argv)
app.run()
# Set app-wide style
app.setStyle("Fusion")
window = PathJugglerApp()
window.show()
sys.exit(app.exec())
if __name__ == "__main__": if __name__ == "__main__":

30
run_app_debug.sh Executable file
View File

@@ -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