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:
6
.gitignore
vendored
6
.gitignore
vendored
@@ -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
118
DEBUGGING.md
Normal 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
178
build_app.sh
Normal file → Executable 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
120
build_app_debug.sh
Executable 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\""
|
||||||
@@ -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."""
|
||||||
|
|||||||
@@ -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)
|
""")
|
||||||
|
log_layout.addWidget(self.log_text)
|
||||||
|
|
||||||
# Configure log text tags for different log levels
|
layout.addWidget(log_group, stretch=1)
|
||||||
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)
|
|
||||||
|
|
||||||
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
|
|
||||||
self.root.after(5000, self._update_status)
|
|
||||||
|
|
||||||
def _on_close(self):
|
def closeEvent(self, event):
|
||||||
"""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
30
run_app_debug.sh
Executable 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
|
||||||
Reference in New Issue
Block a user