diff --git a/src/cad_documenter/gui.py b/src/cad_documenter/gui.py index 4a6daf3..788e223 100644 --- a/src/cad_documenter/gui.py +++ b/src/cad_documenter/gui.py @@ -5,6 +5,9 @@ from tkinter import ttk, filedialog, messagebox from pathlib import Path import threading import json +import shutil +import re +from datetime import datetime try: import customtkinter as ctk @@ -20,13 +23,136 @@ from .incremental import IncrementalProcessor from .config import load_config +def slugify(text: str) -> str: + """Convert text to a filename-safe slug.""" + text = text.lower().strip() + text = re.sub(r'[^\w\s-]', '', text) + text = re.sub(r'[\s_-]+', '-', text) + return text[:50] # Limit length + + +class AddVideoDialog: + """Dialog for adding a video with topic.""" + + def __init__(self, parent, video_path: Path, gen_number: int): + self.result = None + self.video_path = video_path + self.gen_number = gen_number + + self.dialog = ctk.CTkToplevel(parent) if USE_CTK else tk.Toplevel(parent) + self.dialog.title("Add Video") + self.dialog.geometry("500x250") + self.dialog.transient(parent) + self.dialog.grab_set() + + # Make modal + self.dialog.focus_set() + + # Center on parent + self.dialog.update_idletasks() + x = parent.winfo_x() + (parent.winfo_width() - 500) // 2 + y = parent.winfo_y() + (parent.winfo_height() - 250) // 2 + self.dialog.geometry(f"+{x}+{y}") + + self._create_widgets() + + def _create_widgets(self): + frame = ctk.CTkFrame(self.dialog) if USE_CTK else ttk.Frame(self.dialog) + frame.pack(fill="both", expand=True, padx=20, pady=20) + + # Video info + info_text = f"Adding: {self.video_path.name}" + if USE_CTK: + ctk.CTkLabel(frame, text=info_text, wraplength=450).pack(anchor="w", pady=(0, 10)) + else: + ttk.Label(frame, text=info_text, wraplength=450).pack(anchor="w", pady=(0, 10)) + + # Generation number + gen_text = f"This will be: Generation {self.gen_number:03d}" + if USE_CTK: + ctk.CTkLabel(frame, text=gen_text, text_color="gray").pack(anchor="w", pady=(0, 15)) + else: + ttk.Label(frame, text=gen_text).pack(anchor="w", pady=(0, 15)) + + # Topic input + if USE_CTK: + ctk.CTkLabel(frame, text="Session Topic (optional):").pack(anchor="w") + else: + ttk.Label(frame, text="Session Topic (optional):").pack(anchor="w") + + self.topic_var = tk.StringVar() + if USE_CTK: + self.topic_entry = ctk.CTkEntry(frame, textvariable=self.topic_var, width=400, + placeholder_text="e.g., lateral-support-detail, stage2-joints") + else: + self.topic_entry = ttk.Entry(frame, textvariable=self.topic_var, width=50) + self.topic_entry.pack(fill="x", pady=(5, 10)) + self.topic_entry.focus_set() + + # Preview + self.preview_var = tk.StringVar() + self._update_preview() + self.topic_var.trace_add("write", lambda *args: self._update_preview()) + + if USE_CTK: + ctk.CTkLabel(frame, text="Will be saved as:").pack(anchor="w", pady=(10, 0)) + self.preview_label = ctk.CTkLabel(frame, textvariable=self.preview_var, text_color="cyan") + else: + ttk.Label(frame, text="Will be saved as:").pack(anchor="w", pady=(10, 0)) + self.preview_label = ttk.Label(frame, textvariable=self.preview_var) + self.preview_label.pack(anchor="w") + + # Buttons + btn_frame = ctk.CTkFrame(frame) if USE_CTK else ttk.Frame(frame) + btn_frame.pack(fill="x", pady=(20, 0)) + + if USE_CTK: + ctk.CTkButton(btn_frame, text="Cancel", width=100, command=self._cancel).pack(side="right", padx=5) + ctk.CTkButton(btn_frame, text="Add Video", width=100, command=self._confirm).pack(side="right", padx=5) + else: + ttk.Button(btn_frame, text="Cancel", command=self._cancel).pack(side="right", padx=5) + ttk.Button(btn_frame, text="Add Video", command=self._confirm).pack(side="right", padx=5) + + # Bind Enter key + self.dialog.bind("", lambda e: self._confirm()) + self.dialog.bind("", lambda e: self._cancel()) + + def _update_preview(self): + topic = self.topic_var.get().strip() + topic_slug = slugify(topic) if topic else "session" + timestamp = datetime.now().strftime("%Y%m%d-%H%M") + new_name = f"gen-{self.gen_number:03d}_{topic_slug}_{timestamp}{self.video_path.suffix}" + self.preview_var.set(new_name) + + def _confirm(self): + topic = self.topic_var.get().strip() + topic_slug = slugify(topic) if topic else "session" + timestamp = datetime.now().strftime("%Y%m%d-%H%M") + new_name = f"gen-{self.gen_number:03d}_{topic_slug}_{timestamp}{self.video_path.suffix}" + + self.result = { + "topic": topic, + "new_filename": new_name, + "gen_number": self.gen_number + } + self.dialog.destroy() + + def _cancel(self): + self.result = None + self.dialog.destroy() + + def show(self): + self.dialog.wait_window() + return self.result + + class CADDocumenterGUI: """Main GUI application.""" def __init__(self): self.root = ctk.CTk() if USE_CTK else tk.Tk() self.root.title("CAD Documenter") - self.root.geometry("700x600") + self.root.geometry("750x650") self.current_project: Project | None = None self.config = load_config() @@ -40,45 +166,55 @@ class CADDocumenterGUI: main_frame = ctk.CTkFrame(self.root) if USE_CTK else ttk.Frame(self.root) main_frame.pack(fill="both", expand=True, padx=10, pady=10) - # Project selection + # === Project Section === project_frame = ctk.CTkFrame(main_frame) if USE_CTK else ttk.LabelFrame(main_frame, text="Project") project_frame.pack(fill="x", pady=(0, 10)) if USE_CTK: - ctk.CTkLabel(project_frame, text="Project:").pack(side="left", padx=5) - else: - ttk.Label(project_frame, text="Project:").pack(side="left", padx=5) + ctk.CTkLabel(project_frame, text="Project:", font=("", 14, "bold")).pack(anchor="w", padx=10, pady=(10, 5)) + + proj_row = ctk.CTkFrame(project_frame) if USE_CTK else ttk.Frame(project_frame) + proj_row.pack(fill="x", padx=10, pady=(0, 10)) self.project_var = tk.StringVar() self.project_combo = ctk.CTkComboBox( - project_frame, + proj_row, variable=self.project_var, values=[], + width=400, command=self._on_project_selected - ) if USE_CTK else ttk.Combobox(project_frame, textvariable=self.project_var) - self.project_combo.pack(side="left", fill="x", expand=True, padx=5) + ) if USE_CTK else ttk.Combobox(proj_row, textvariable=self.project_var, width=50) + self.project_combo.pack(side="left", padx=(0, 10)) if not USE_CTK: self.project_combo.bind("<>", lambda e: self._on_project_selected(None)) - btn_new = ctk.CTkButton(project_frame, text="+ New", width=70, command=self._new_project) if USE_CTK else ttk.Button(project_frame, text="+ New", command=self._new_project) + btn_new = ctk.CTkButton(proj_row, text="+ New", width=80, command=self._new_project) if USE_CTK else ttk.Button(proj_row, text="+ New", command=self._new_project) btn_new.pack(side="left", padx=5) - btn_open = ctk.CTkButton(project_frame, text="Open", width=70, command=self._open_project) if USE_CTK else ttk.Button(project_frame, text="Open", command=self._open_project) + btn_open = ctk.CTkButton(proj_row, text="Open", width=80, command=self._open_project) if USE_CTK else ttk.Button(proj_row, text="Open", command=self._open_project) btn_open.pack(side="left", padx=5) - # Videos list + # Project info + self.project_info_var = tk.StringVar(value="No project loaded") + if USE_CTK: + self.project_info = ctk.CTkLabel(project_frame, textvariable=self.project_info_var, text_color="gray") + else: + self.project_info = ttk.Label(project_frame, textvariable=self.project_info_var) + self.project_info.pack(anchor="w", padx=10, pady=(0, 10)) + + # === Videos Section === videos_frame = ctk.CTkFrame(main_frame) if USE_CTK else ttk.LabelFrame(main_frame, text="Videos") videos_frame.pack(fill="both", expand=True, pady=(0, 10)) if USE_CTK: - ctk.CTkLabel(videos_frame, text="Videos:").pack(anchor="w", padx=5, pady=5) + ctk.CTkLabel(videos_frame, text="Session Videos:", font=("", 14, "bold")).pack(anchor="w", padx=10, pady=(10, 5)) # Listbox for videos list_frame = ctk.CTkFrame(videos_frame) if USE_CTK else ttk.Frame(videos_frame) - list_frame.pack(fill="both", expand=True, padx=5, pady=5) + list_frame.pack(fill="both", expand=True, padx=10, pady=5) - self.video_listbox = tk.Listbox(list_frame, height=10, selectmode="extended") + self.video_listbox = tk.Listbox(list_frame, height=12, selectmode="extended", font=("Consolas", 10)) self.video_listbox.pack(side="left", fill="both", expand=True) scrollbar = ttk.Scrollbar(list_frame, orient="vertical", command=self.video_listbox.yview) @@ -87,67 +223,100 @@ class CADDocumenterGUI: # Video buttons video_btn_frame = ctk.CTkFrame(videos_frame) if USE_CTK else ttk.Frame(videos_frame) - video_btn_frame.pack(fill="x", padx=5, pady=5) + video_btn_frame.pack(fill="x", padx=10, pady=10) - btn_add = ctk.CTkButton(video_btn_frame, text="Add Videos", command=self._add_videos) if USE_CTK else ttk.Button(video_btn_frame, text="Add Videos", command=self._add_videos) - btn_add.pack(side="left", padx=5) + btn_add = ctk.CTkButton(video_btn_frame, text="+ Add Video", width=120, command=self._add_videos) if USE_CTK else ttk.Button(video_btn_frame, text="+ Add Video", command=self._add_videos) + btn_add.pack(side="left", padx=(0, 10)) - btn_remove = ctk.CTkButton(video_btn_frame, text="Remove", command=self._remove_video) if USE_CTK else ttk.Button(video_btn_frame, text="Remove", command=self._remove_video) - btn_remove.pack(side="left", padx=5) + btn_remove = ctk.CTkButton(video_btn_frame, text="Remove", width=100, command=self._remove_video, fg_color="gray") if USE_CTK else ttk.Button(video_btn_frame, text="Remove", command=self._remove_video) + btn_remove.pack(side="left") - # Drop zone hint + # Next gen hint + self.next_gen_var = tk.StringVar(value="") if USE_CTK: - ctk.CTkLabel(video_btn_frame, text="(Drag & drop videos here)", text_color="gray").pack(side="right", padx=5) + ctk.CTkLabel(video_btn_frame, textvariable=self.next_gen_var, text_color="cyan").pack(side="right", padx=10) + else: + ttk.Label(video_btn_frame, textvariable=self.next_gen_var).pack(side="right", padx=10) - # Options + # === Options Section === options_frame = ctk.CTkFrame(main_frame) if USE_CTK else ttk.LabelFrame(main_frame, text="Options") options_frame.pack(fill="x", pady=(0, 10)) if USE_CTK: - ctk.CTkLabel(options_frame, text="Options:").pack(anchor="w", padx=5, pady=5) + ctk.CTkLabel(options_frame, text="Processing Options:", font=("", 14, "bold")).pack(anchor="w", padx=10, pady=(10, 5)) + + opts_inner = ctk.CTkFrame(options_frame) if USE_CTK else ttk.Frame(options_frame) + opts_inner.pack(fill="x", padx=10, pady=(0, 10)) self.export_only_var = tk.BooleanVar(value=True) - cb_export = ctk.CTkCheckBox(options_frame, text="Export for Clawdbot (no API)", variable=self.export_only_var) if USE_CTK else ttk.Checkbutton(options_frame, text="Export for Clawdbot (no API)", variable=self.export_only_var) - cb_export.pack(anchor="w", padx=20, pady=2) + cb_export = ctk.CTkCheckBox(opts_inner, text="Export for Clawdbot (recommended)", variable=self.export_only_var) if USE_CTK else ttk.Checkbutton(opts_inner, text="Export for Clawdbot (recommended)", variable=self.export_only_var) + cb_export.pack(anchor="w", pady=2) self.scene_detect_var = tk.BooleanVar(value=True) - cb_scene = ctk.CTkCheckBox(options_frame, text="Use scene detection", variable=self.scene_detect_var) if USE_CTK else ttk.Checkbutton(options_frame, text="Use scene detection", variable=self.scene_detect_var) - cb_scene.pack(anchor="w", padx=20, pady=2) + cb_scene = ctk.CTkCheckBox(opts_inner, text="Smart frame extraction (scene detection)", variable=self.scene_detect_var) if USE_CTK else ttk.Checkbutton(opts_inner, text="Smart frame extraction", variable=self.scene_detect_var) + cb_scene.pack(anchor="w", pady=2) self.transcribe_var = tk.BooleanVar(value=True) - cb_trans = ctk.CTkCheckBox(options_frame, text="Whisper transcription", variable=self.transcribe_var) if USE_CTK else ttk.Checkbutton(options_frame, text="Whisper transcription", variable=self.transcribe_var) - cb_trans.pack(anchor="w", padx=20, pady=2) + cb_trans = ctk.CTkCheckBox(opts_inner, text="Whisper transcription (local GPU)", variable=self.transcribe_var) if USE_CTK else ttk.Checkbutton(opts_inner, text="Whisper transcription", variable=self.transcribe_var) + cb_trans.pack(anchor="w", pady=2) - # Process button + # === Process Button === self.btn_process = ctk.CTkButton( main_frame, - text="Process Videos", - height=40, + text="ā–¶ Process Pending Videos", + height=45, + font=("", 16, "bold"), command=self._process_videos - ) if USE_CTK else ttk.Button(main_frame, text="Process Videos", command=self._process_videos) + ) if USE_CTK else ttk.Button(main_frame, text="Process Pending Videos", command=self._process_videos) self.btn_process.pack(fill="x", pady=(0, 10)) - # Status + # === Status Section === status_frame = ctk.CTkFrame(main_frame) if USE_CTK else ttk.Frame(main_frame) status_frame.pack(fill="x") - self.status_var = tk.StringVar(value="Ready") + self.status_var = tk.StringVar(value="Ready — Open or create a project to start") status_label = ctk.CTkLabel(status_frame, textvariable=self.status_var) if USE_CTK else ttk.Label(status_frame, textvariable=self.status_var) status_label.pack(side="left", padx=5) - self.progress = ctk.CTkProgressBar(status_frame) if USE_CTK else ttk.Progressbar(status_frame, mode="indeterminate") - self.progress.pack(side="right", fill="x", expand=True, padx=5) + self.progress = ctk.CTkProgressBar(status_frame, width=200) if USE_CTK else ttk.Progressbar(status_frame, mode="indeterminate", length=200) + self.progress.pack(side="right", padx=5) if USE_CTK: self.progress.set(0) + def _get_next_gen_number(self) -> int: + """Get the next generation number for the current project.""" + if not self.current_project: + return 1 + + # Count existing videos as generations + return len(self.current_project.manifest.videos) + 1 + def _update_project_list(self): """Update the project dropdown with recent projects.""" - # For now, just clear - could load from config/history - pass + pass # Could load from config/history + + def _update_project_info(self): + """Update the project info display.""" + if not self.current_project: + self.project_info_var.set("No project loaded") + self.next_gen_var.set("") + return + + m = self.current_project.manifest + total = len(m.videos) + pending = len(self.current_project.get_pending_videos()) + processed = total - pending + + info = f"šŸ“ {m.name} — {total} videos ({processed} processed, {pending} pending)" + self.project_info_var.set(info) + + next_gen = self._get_next_gen_number() + self.next_gen_var.set(f"Next: Gen {next_gen:03d}") def _on_project_selected(self, value): """Handle project selection.""" self._refresh_video_list() + self._update_project_info() def _new_project(self): """Create a new project.""" @@ -156,20 +325,21 @@ class CADDocumenterGUI: return name = Path(folder).name - # Simple dialog for project name - dialog = ctk.CTkInputDialog( - text="Enter project name:", - title="New Project" - ) if USE_CTK else None + # Dialog for project name if USE_CTK: + dialog = ctk.CTkInputDialog( + text="Enter project name:", + title="New Project" + ) name = dialog.get_input() or name try: self.current_project = Project.create(Path(folder), name, "") - self.project_var.set(f"{name} ({folder})") - self.status_var.set(f"Created project: {name}") + self.project_var.set(f"{name}") + self.status_var.set(f"āœ“ Created project: {name}") self._refresh_video_list() + self._update_project_info() except Exception as e: messagebox.showerror("Error", str(e)) @@ -181,9 +351,12 @@ class CADDocumenterGUI: try: self.current_project = Project.load(Path(folder)) - self.project_var.set(f"{self.current_project.manifest.name} ({folder})") - self.status_var.set(f"Loaded project: {self.current_project.manifest.name}") + self.project_var.set(f"{self.current_project.manifest.name}") + self.status_var.set(f"āœ“ Loaded: {self.current_project.manifest.name}") self._refresh_video_list() + self._update_project_info() + except FileNotFoundError: + messagebox.showerror("Error", f"No project.json found in {folder}\n\nUse '+ New' to create a new project.") except Exception as e: messagebox.showerror("Error", f"Failed to load project: {e}") @@ -194,7 +367,7 @@ class CADDocumenterGUI: if not self.current_project: return - for video in self.current_project.manifest.videos: + for i, video in enumerate(self.current_project.manifest.videos, 1): status_icon = { "pending": "ā³", "processed": "āœ“", @@ -202,35 +375,80 @@ class CADDocumenterGUI: "error": "āœ—" }.get(video.status, "?") - self.video_listbox.insert(tk.END, f"{status_icon} {video.filename}") + self.video_listbox.insert(tk.END, f"{status_icon} Gen {i:03d}: {video.filename}") + + self._update_project_info() def _add_videos(self): - """Add videos to the project.""" + """Add videos to the project with topic dialog.""" if not self.current_project: messagebox.showwarning("Warning", "Please create or open a project first") return files = filedialog.askopenfilenames( - title="Select videos", + title="Select video to add", filetypes=[ ("Video files", "*.mp4 *.mkv *.avi *.mov *.webm"), ("All files", "*.*") ] ) + added = 0 for f in files: + video_path = Path(f) + gen_num = self._get_next_gen_number() + + # Show dialog for topic + dialog = AddVideoDialog(self.root, video_path, gen_num) + result = dialog.show() + + if result is None: + continue # Cancelled + try: - self.current_project.add_video(Path(f), copy=True) + # Copy with new name + dest = self.current_project.videos_dir / result["new_filename"] + shutil.copy2(video_path, dest) + + # Add to manifest (using new filename) + from .project import VideoEntry + entry = VideoEntry( + filename=result["new_filename"], + added_at=datetime.now().isoformat(), + ) + self.current_project.manifest.videos.append(entry) + self.current_project.save() + + added += 1 except Exception as e: messagebox.showerror("Error", f"Failed to add {f}: {e}") - self._refresh_video_list() - self.status_var.set(f"Added {len(files)} video(s)") + if added > 0: + self._refresh_video_list() + self.status_var.set(f"āœ“ Added {added} video(s)") def _remove_video(self): """Remove selected video from project.""" - # TODO: Implement video removal - messagebox.showinfo("Info", "Video removal not yet implemented") + selection = self.video_listbox.curselection() + if not selection: + return + + if not messagebox.askyesno("Confirm", "Remove selected video(s) from project?"): + return + + # Remove in reverse order to maintain indices + for idx in reversed(selection): + video = self.current_project.manifest.videos[idx] + # Delete file + video_path = self.current_project.videos_dir / video.filename + if video_path.exists(): + video_path.unlink() + # Remove from manifest + del self.current_project.manifest.videos[idx] + + self.current_project.save() + self._refresh_video_list() + self.status_var.set(f"āœ“ Removed {len(selection)} video(s)") def _process_videos(self): """Process videos in background thread.""" @@ -245,7 +463,7 @@ class CADDocumenterGUI: # Disable button during processing self.btn_process.configure(state="disabled") - self.status_var.set("Processing...") + self.status_var.set(f"Processing {len(pending)} video(s)...") if USE_CTK: self.progress.set(0) @@ -262,6 +480,8 @@ class CADDocumenterGUI: self.root.after(0, lambda: self._on_process_complete(results)) except Exception as e: + import traceback + traceback.print_exc() self.root.after(0, lambda: self._on_process_error(str(e))) thread = threading.Thread(target=process_thread, daemon=True) @@ -278,11 +498,13 @@ class CADDocumenterGUI: self.btn_process.configure(state="normal") self._refresh_video_list() - msg = f"Processed {results.get('processed', 0)} video(s)" - if self.export_only_var.get(): - msg += f"\n\nExports ready! Tell Clawdbot:\n\"Process CAD report for {self.current_project.manifest.name}\"" + name = self.current_project.manifest.name + msg = f"āœ“ Processed {results.get('processed', 0)} video(s)" - self.status_var.set("Complete!") + if self.export_only_var.get(): + msg += f"\n\nšŸ“¤ Exports ready in clawdbot_export/\n\nTell Mario:\n\"Process CAD session for {name}\"" + + self.status_var.set("āœ“ Complete!") messagebox.showinfo("Success", msg) def _on_process_error(self, error): @@ -294,8 +516,8 @@ class CADDocumenterGUI: self.progress.stop() self.btn_process.configure(state="normal") - self.status_var.set("Error") - messagebox.showerror("Error", f"Processing failed: {error}") + self.status_var.set("āœ— Error") + messagebox.showerror("Error", f"Processing failed:\n\n{error}") def run(self): """Run the application."""