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

View File

@@ -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'<span style="color: #6a9955;">[{timestamp}]</span> <span style="color: {color};">{record.getMessage()}</span><br>'
# 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__":