370 lines
11 KiB
Python
370 lines
11 KiB
Python
#!/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'<span style="color: #6a9955;">[{timestamp}]</span> <span style="color: {color};">{record.getMessage()}</span><br>'
|
|
|
|
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()
|