Initial development by Claude
This commit is contained in:
326
path_juggler_gui.py
Normal file
326
path_juggler_gui.py
Normal file
@@ -0,0 +1,326 @@
|
||||
#!/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()
|
||||
Reference in New Issue
Block a user