Files
path-juggler/path_juggler_gui.py

327 lines
10 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.
"""
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()