#!/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. """ import tkinter as tk from tkinter import ttk, scrolledtext import logging import queue from datetime import datetime from pathlib import Path 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 PathJugglerApp: """Main GUI Application.""" def __init__(self): self.root = tk.Tk() self.root.title("Path Juggler") self.root.geometry("700x500") self.root.minsize(500, 300) # Set macOS-like appearance self.root.configure(bg="#f0f0f0") # Core juggler instance self.juggler = PathJuggler(dry_run=False) # Logging queue for thread-safe log updates self.log_queue: queue.Queue = queue.Queue() # Setup logging to our queue self._setup_logging() # Build UI self._create_widgets() # Start polling the log queue self._poll_log_queue() # Update status periodically self._update_status() # Handle window close self.root.protocol("WM_DELETE_WINDOW", self._on_close) 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) # === Header / Status Section === header_frame = ttk.Frame(main_frame) header_frame.pack(fill=tk.X, pady=(0, 10)) # Title title_label = ttk.Label( header_frame, text="Path Juggler", font=("SF Pro Display", 18, "bold") ) title_label.pack(side=tk.LEFT) # Status indicator self.status_frame = ttk.Frame(header_frame) self.status_frame.pack(side=tk.RIGHT) 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_label = ttk.Label( self.status_frame, text="Stopped", font=("SF Pro Text", 12) ) self.status_label.pack(side=tk.LEFT) # === Info Section === info_frame = ttk.LabelFrame(main_frame, text="Status", padding="10") info_frame.pack(fill=tk.X, pady=(0, 10)) # 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)) # 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_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)) # 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_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)) # === Control Buttons === button_frame = ttk.Frame(main_frame) button_frame.pack(fill=tk.X, pady=(0, 10)) # 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 = 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 = ttk.Button( 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 = ttk.Button( button_frame, text="Clear Log", command=self._clear_log, width=10 ) clear_button.pack(side=tk.RIGHT) # === Log Section === log_frame = ttk.LabelFrame(main_frame, text="Activity Log", padding="5") log_frame.pack(fill=tk.BOTH, expand=True) # 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) # 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) def _start(self): """Start the file watcher.""" self.start_button.config(state=tk.DISABLED) self.stop_button.config(state=tk.NORMAL) self._draw_status_dot("running") self.status_label.config(text="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.juggler.stop() self.start_button.config(state=tk.NORMAL) self._draw_status_dot("stopped") self.status_label.config(text="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) def _poll_log_queue(self): """Poll the log queue and update the text widget.""" while True: try: record = self.log_queue.get_nowait() self._append_log(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") # Insert message with level-based coloring level = record.levelname message = f"{record.getMessage()}\n" self.log_text.insert(tk.END, message, level) # Auto-scroll to bottom self.log_text.see(tk.END) self.log_text.config(state=tk.DISABLED) 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.config(text=names, foreground="") else: self.volumes_label.config(text="No HONEY volumes mounted", foreground="#ff3b30") # Check if juggler stopped unexpectedly if not self.juggler.is_running() and self.stop_button["state"] == tk.NORMAL: self._stop() # Schedule next update self.root.after(5000, self._update_status) def _on_close(self): """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() def main(): app = PathJugglerApp() app.run() if __name__ == "__main__": main()