#!/usr/bin/env python3 """ 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 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, WATCH_FOLDERS, DESTINATION_BASE, ) class QueueHandler(logging.Handler): """Logging handler that puts log records into a queue for GUI consumption.""" def __init__(self, log_queue: queue.Queue): super().__init__() self.log_queue = log_queue def emit(self, record): self.log_queue.put(record) class LogSignals(QObject): """Signals for thread-safe log updates.""" new_log = Signal(object) class PathJugglerApp(QMainWindow): """Main GUI Application.""" def __init__(self): super().__init__() 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() # Build UI self._create_widgets() # Start polling the 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() # 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.""" queue_handler = QueueHandler(self.log_queue) queue_handler.setLevel(logging.DEBUG) formatter = logging.Formatter("%(asctime)s - %(levelname)s - %(message)s", "%H:%M:%S") queue_handler.setFormatter(formatter) root_logger = logging.getLogger() root_logger.setLevel(logging.INFO) root_logger.addHandler(queue_handler) core_logger = logging.getLogger("path_juggler_core") core_logger.setLevel(logging.INFO) def _create_widgets(self): """Create all UI widgets.""" # Central widget central = QWidget() self.setCentralWidget(central) # Main layout layout = QVBoxLayout(central) layout.setContentsMargins(16, 16, 16, 16) layout.setSpacing(12) # === 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 status_layout = QHBoxLayout() status_layout.setSpacing(8) self.status_dot = QLabel("●") self.status_dot.setStyleSheet("color: #8e8e93; font-size: 14px;") status_layout.addWidget(self.status_dot) 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_group = QGroupBox("Status") info_layout = QVBoxLayout(info_group) info_layout.setSpacing(6) # 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_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()]) watch_layout.addWidget(QLabel(watch_paths)) watch_layout.addStretch() info_layout.addLayout(watch_layout) # Destination 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())}" dest_layout.addWidget(QLabel(dest_path)) dest_layout.addStretch() info_layout.addLayout(dest_layout) layout.addWidget(info_group) # === Control Buttons === button_layout = QHBoxLayout() 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.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) button_layout.addStretch() 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_group = QGroupBox("Activity Log") log_layout = QVBoxLayout(log_group) 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) layout.addWidget(log_group, stretch=1) def _start(self): """Start the file watcher.""" self.start_button.setEnabled(False) self.stop_button.setEnabled(True) self.status_dot.setStyleSheet("color: #34c759; font-size: 14px;") self.status_label.setText("Juggling") self.juggler.start_async() def _stop(self): """Stop the file watcher.""" self.stop_button.setEnabled(False) self.juggler.stop() 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.clear() def _poll_log_queue(self): """Poll the log queue and emit signals for thread-safe updates.""" while True: try: record = self.log_queue.get_nowait() self.log_signals.new_log.emit(record) except queue.Empty: break def _append_log(self, record: logging.LogRecord): """Append a log record to the text widget.""" timestamp = datetime.fromtimestamp(record.created).strftime("%H:%M:%S") # Color based on level colors = { "INFO": "#d4d4d4", "WARNING": "#dcdcaa", "ERROR": "#f14c4c", "DEBUG": "#808080", } color = colors.get(record.levelname, "#d4d4d4") html = f'[{timestamp}] {record.getMessage()}
' cursor = self.log_text.textCursor() cursor.movePosition(cursor.MoveOperation.End) self.log_text.setTextCursor(cursor) self.log_text.insertHtml(html) # Auto-scroll scrollbar = self.log_text.verticalScrollBar() scrollbar.setValue(scrollbar.maximum()) def _update_status(self): """Update the volumes status periodically.""" volumes = get_honey_volumes() if volumes: names = ", ".join([v.name for v in volumes]) self.volumes_label.setText(names) self.volumes_label.setStyleSheet("") else: 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 not self.start_button.isEnabled(): self._stop() def closeEvent(self, event): """Handle window close.""" if self.juggler.is_running(): self.juggler.stop() self.log_timer.stop() self.status_timer.stop() event.accept() def main(): app = QApplication(sys.argv) # Set app-wide style app.setStyle("Fusion") window = PathJugglerApp() window.show() sys.exit(app.exec()) if __name__ == "__main__": main()