diff --git a/src/cad_documenter/gui_capture.py b/src/cad_documenter/gui_capture.py index a2411fa..7be7ae0 100644 --- a/src/cad_documenter/gui_capture.py +++ b/src/cad_documenter/gui_capture.py @@ -1,118 +1,61 @@ """ -KB Capture GUI +KB Capture GUI v2 -Modern, clean interface for recording engineering sessions. -Uses CustomTkinter for a native look. +Professional recording tool for engineering knowledge capture. +Features: +- Browse and select projects folder +- Create new projects with full KB structure +- Visual project cards +- Clip-based recording with keep/delete +- Session history """ import sys import json import threading +import time from pathlib import Path -from typing import Optional -from tkinter import filedialog +from typing import Optional, List +from tkinter import filedialog, messagebox +from datetime import datetime try: import customtkinter as ctk from customtkinter import CTk, CTkFrame, CTkLabel, CTkButton, CTkEntry - from customtkinter import CTkOptionMenu, CTkScrollableFrame, CTkProgressBar + from customtkinter import CTkOptionMenu, CTkScrollableFrame, CTkToplevel + from customtkinter import CTkInputDialog HAS_CTK = True except ImportError: HAS_CTK = False print("CustomTkinter not installed. Run: pip install customtkinter") from .kb_capture import KBCaptureApp, AppState, AppStatus -from .session import SessionType, ClipStatus +from .session import SessionType, ClipStatus, Session -# Colors +# ============================================================================ +# THEME +# ============================================================================ + COLORS = { - "idle": "#6B7280", # Gray - "session": "#10B981", # Green - "recording": "#EF4444", # Red - "preview": "#F59E0B", # Amber - "bg_dark": "#1F2937", - "bg_light": "#374151", - "text": "#F9FAFB", - "text_dim": "#9CA3AF", + "bg": "#0f0f0f", + "bg_card": "#1a1a1a", + "bg_hover": "#252525", + "accent": "#3b82f6", # Blue + "accent_hover": "#2563eb", + "success": "#22c55e", # Green + "danger": "#ef4444", # Red + "warning": "#f59e0b", # Amber + "text": "#ffffff", + "text_secondary": "#a1a1aa", + "text_muted": "#52525b", + "border": "#27272a", } -class ClipCard(CTkFrame): - """A card showing a single clip.""" - - def __init__( - self, - master, - clip_id: str, - duration: float, - status: ClipStatus, - note: str = "", - on_delete: Optional[callable] = None, - **kwargs - ): - super().__init__(master, **kwargs) - - self.configure(fg_color=COLORS["bg_light"], corner_radius=8) - - # Status indicator - status_colors = { - ClipStatus.KEPT: "#10B981", - ClipStatus.PREVIEW: "#F59E0B", - ClipStatus.DELETED: "#EF4444", - ClipStatus.RECORDING: "#EF4444", - } - color = status_colors.get(status, COLORS["idle"]) - - # Layout - self.grid_columnconfigure(1, weight=1) - - # Status dot - dot = CTkLabel(self, text="●", text_color=color, font=("", 16)) - dot.grid(row=0, column=0, padx=(10, 5), pady=10) - - # Clip info - info_frame = CTkFrame(self, fg_color="transparent") - info_frame.grid(row=0, column=1, sticky="w", pady=10) - - title = CTkLabel( - info_frame, - text=clip_id, - font=("", 14, "bold"), - text_color=COLORS["text"], - ) - title.pack(anchor="w") - - subtitle = CTkLabel( - info_frame, - text=f"{duration:.1f}s" + (f" β€’ {note}" if note else ""), - font=("", 12), - text_color=COLORS["text_dim"], - ) - subtitle.pack(anchor="w") - - # Duration - duration_label = CTkLabel( - self, - text=f"{int(duration//60)}:{int(duration%60):02d}", - font=("", 14), - text_color=COLORS["text_dim"], - ) - duration_label.grid(row=0, column=2, padx=10) - - # Delete button (only for preview/kept) - if status in (ClipStatus.PREVIEW, ClipStatus.KEPT) and on_delete: - delete_btn = CTkButton( - self, - text="πŸ—‘οΈ", - width=30, - height=30, - fg_color="transparent", - hover_color=COLORS["bg_dark"], - command=lambda: on_delete(clip_id), - ) - delete_btn.grid(row=0, column=3, padx=(0, 10)) - +# ============================================================================ +# CONFIG +# ============================================================================ def get_config_path() -> Path: """Get path to config file.""" @@ -143,515 +86,1073 @@ def save_config(config: dict) -> None: json.dump(config, f, indent=2) -class KBCaptureGUI: - """Main GUI window for KB Capture.""" +# ============================================================================ +# PROJECT MANAGEMENT +# ============================================================================ - def __init__(self, projects_root: Optional[Path] = None): +def create_project_structure(projects_root: Path, name: str, description: str = "") -> Path: + """ + Create a new project with full KB structure. + + Structure: + / + β”œβ”€β”€ _context.md # Project context + β”œβ”€β”€ KB/ + β”‚ β”œβ”€β”€ _index.md # KB overview + β”‚ β”œβ”€β”€ Design/ + β”‚ β”‚ β”œβ”€β”€ _index.md + β”‚ β”‚ β”œβ”€β”€ architecture/ + β”‚ β”‚ β”œβ”€β”€ components/ + β”‚ β”‚ β”œβ”€β”€ materials/ + β”‚ β”‚ └── dev/ # Session captures go here + β”‚ └── Analysis/ + β”‚ β”œβ”€β”€ _index.md + β”‚ β”œβ”€β”€ models/ + β”‚ β”œβ”€β”€ load-cases/ + β”‚ └── results/ + β”œβ”€β”€ Images/ + β”‚ β”œβ”€β”€ components/ + β”‚ └── screenshot-sessions/ + └── _capture/ # Recording sessions staged here + """ + project_path = projects_root / name + + if project_path.exists(): + raise ValueError(f"Project already exists: {name}") + + # Create directory structure + dirs = [ + "KB/Design/architecture", + "KB/Design/components", + "KB/Design/materials", + "KB/Design/dev", + "KB/Analysis/models", + "KB/Analysis/load-cases", + "KB/Analysis/results", + "Images/components", + "Images/screenshot-sessions", + "_capture", + ] + + for d in dirs: + (project_path / d).mkdir(parents=True, exist_ok=True) + + # Create _context.md + context_content = f"""# {name} + +{description} + +## Project Overview + +*Add project description, objectives, and key requirements here.* + +## Key Contacts + +| Role | Name | Notes | +|------|------|-------| +| Engineer | | | +| Client | | | + +## Timeline + +- **Created:** {datetime.now().strftime("%Y-%m-%d")} +- **Status:** Active + +## Notes + +*Add project-specific notes here.* +""" + (project_path / "_context.md").write_text(context_content) + + # Create KB index + kb_index = f"""# {name} β€” Knowledge Base + +## Overview + +This knowledge base captures all design and analysis knowledge for {name}. + +## Structure + +- **Design/** β€” CAD/design documentation + - `architecture/` β€” System-level design + - `components/` β€” Individual part documentation + - `materials/` β€” Material selections and specs + - `dev/` β€” Session captures (gen-001, gen-002, ...) + +- **Analysis/** β€” FEA/simulation documentation + - `models/` β€” Model definitions + - `load-cases/` β€” Boundary conditions and loads + - `results/` β€” Analysis outputs and validation + +## Recent Sessions + +*Sessions will be listed here as they're processed.* + +--- + +*Last updated: {datetime.now().strftime("%Y-%m-%d")}* +""" + (project_path / "KB" / "_index.md").write_text(kb_index) + + # Create Design index + design_index = f"""# Design Knowledge Base + +## Components + +*Component documentation will appear here.* + +## Architecture + +*System-level design documentation.* + +## Materials + +*Material selections and specifications.* +""" + (project_path / "KB" / "Design" / "_index.md").write_text(design_index) + + # Create Analysis index + analysis_index = f"""# Analysis Knowledge Base + +## Models + +*FEA model documentation.* + +## Load Cases + +*Boundary conditions and load definitions.* + +## Results + +*Analysis results and validation.* +""" + (project_path / "KB" / "Analysis" / "_index.md").write_text(analysis_index) + + return project_path + + +def get_project_stats(project_path: Path) -> dict: + """Get statistics for a project.""" + stats = { + "sessions": 0, + "total_clips": 0, + "total_duration": 0, + "last_session": None, + "has_kb": (project_path / "KB").exists(), + } + + capture_dir = project_path / "_capture" + if capture_dir.exists(): + for session_dir in capture_dir.iterdir(): + if session_dir.is_dir(): + session_file = session_dir / "session.json" + if session_file.exists(): + try: + with open(session_file) as f: + session = json.load(f) + stats["sessions"] += 1 + stats["total_clips"] += len([c for c in session.get("clips", []) if c.get("status") == "kept"]) + stats["total_duration"] += session.get("total_duration", 0) + + created = session.get("created_at") + if created and (not stats["last_session"] or created > stats["last_session"]): + stats["last_session"] = created + except: + pass + + return stats + + +# ============================================================================ +# DIALOGS +# ============================================================================ + +class NewProjectDialog: + """Dialog for creating a new project.""" + + def __init__(self, parent, projects_root: Path): + self.result = None + self.projects_root = projects_root + + self.dialog = CTkToplevel(parent) + self.dialog.title("New Project") + self.dialog.geometry("450x300") + self.dialog.transient(parent) + self.dialog.grab_set() + self.dialog.configure(fg_color=COLORS["bg"]) + + # Center on parent + self.dialog.update_idletasks() + x = parent.winfo_x() + (parent.winfo_width() - 450) // 2 + y = parent.winfo_y() + (parent.winfo_height() - 300) // 2 + self.dialog.geometry(f"+{x}+{y}") + + self._build_ui() + + # Focus + self.name_entry.focus_set() + + def _build_ui(self): + # Title + CTkLabel( + self.dialog, + text="Create New Project", + font=("", 20, "bold"), + text_color=COLORS["text"], + ).pack(pady=(20, 10)) + + CTkLabel( + self.dialog, + text="This will create a project with full KB structure", + font=("", 12), + text_color=COLORS["text_secondary"], + ).pack(pady=(0, 20)) + + # Form + form = CTkFrame(self.dialog, fg_color="transparent") + form.pack(fill="x", padx=30) + + # Name + CTkLabel(form, text="Project Name:", text_color=COLORS["text_secondary"]).pack(anchor="w") + self.name_entry = CTkEntry( + form, + placeholder_text="e.g., P05-NewProject", + width=380, + height=35, + ) + self.name_entry.pack(fill="x", pady=(5, 15)) + + # Description + CTkLabel(form, text="Description (optional):", text_color=COLORS["text_secondary"]).pack(anchor="w") + self.desc_entry = CTkEntry( + form, + placeholder_text="Brief project description", + width=380, + height=35, + ) + self.desc_entry.pack(fill="x", pady=(5, 20)) + + # Buttons + btn_frame = CTkFrame(self.dialog, fg_color="transparent") + btn_frame.pack(fill="x", padx=30, pady=20) + + CTkButton( + btn_frame, + text="Cancel", + width=100, + fg_color="transparent", + border_width=1, + border_color=COLORS["border"], + hover_color=COLORS["bg_hover"], + command=self.dialog.destroy, + ).pack(side="left") + + CTkButton( + btn_frame, + text="Create Project", + width=150, + fg_color=COLORS["accent"], + hover_color=COLORS["accent_hover"], + command=self._create, + ).pack(side="right") + + def _create(self): + name = self.name_entry.get().strip() + if not name: + messagebox.showerror("Error", "Project name is required") + return + + # Sanitize name + name = "".join(c for c in name if c.isalnum() or c in "-_ ") + name = name.replace(" ", "-") + + try: + path = create_project_structure( + self.projects_root, + name, + self.desc_entry.get().strip(), + ) + self.result = name + self.dialog.destroy() + except ValueError as e: + messagebox.showerror("Error", str(e)) + except Exception as e: + messagebox.showerror("Error", f"Failed to create project: {e}") + + +# ============================================================================ +# COMPONENTS +# ============================================================================ + +class ProjectCard(CTkFrame): + """A card displaying a project.""" + + def __init__( + self, + master, + name: str, + stats: dict, + selected: bool = False, + on_select=None, + **kwargs + ): + super().__init__(master, **kwargs) + + self.name = name + self.on_select = on_select + + self.configure( + fg_color=COLORS["accent"] if selected else COLORS["bg_card"], + corner_radius=10, + cursor="hand2", + ) + + # Bind click + self.bind("", lambda e: self._clicked()) + + # Content + self.grid_columnconfigure(0, weight=1) + + # Name + name_label = CTkLabel( + self, + text=name, + font=("", 14, "bold"), + text_color=COLORS["text"], + anchor="w", + ) + name_label.grid(row=0, column=0, sticky="w", padx=15, pady=(12, 2)) + name_label.bind("", lambda e: self._clicked()) + + # Stats + if stats["sessions"] > 0: + stats_text = f"{stats['sessions']} sessions β€’ {int(stats['total_duration']//60)}m recorded" + else: + stats_text = "No recordings yet" + + stats_label = CTkLabel( + self, + text=stats_text, + font=("", 11), + text_color=COLORS["text_secondary"] if not selected else COLORS["text"], + anchor="w", + ) + stats_label.grid(row=1, column=0, sticky="w", padx=15, pady=(0, 12)) + stats_label.bind("", lambda e: self._clicked()) + + # Status indicator + if not stats["has_kb"]: + warning = CTkLabel( + self, + text="⚠️ No KB", + font=("", 10), + text_color=COLORS["warning"], + ) + warning.grid(row=0, column=1, padx=15) + + def _clicked(self): + if self.on_select: + self.on_select(self.name) + + +class ClipCard(CTkFrame): + """A card showing a single clip.""" + + def __init__( + self, + master, + clip_id: str, + duration: float, + status: ClipStatus, + note: str = "", + on_delete=None, + **kwargs + ): + super().__init__(master, **kwargs) + + self.configure(fg_color=COLORS["bg_card"], corner_radius=8) + + # Status colors + status_colors = { + ClipStatus.KEPT: COLORS["success"], + ClipStatus.PREVIEW: COLORS["warning"], + ClipStatus.DELETED: COLORS["danger"], + ClipStatus.RECORDING: COLORS["danger"], + } + color = status_colors.get(status, COLORS["text_muted"]) + + # Layout + self.grid_columnconfigure(1, weight=1) + + # Status dot + CTkLabel(self, text="●", text_color=color, font=("", 14)).grid( + row=0, column=0, padx=(12, 8), pady=10 + ) + + # Clip info + info_frame = CTkFrame(self, fg_color="transparent") + info_frame.grid(row=0, column=1, sticky="w", pady=10) + + CTkLabel( + info_frame, + text=clip_id, + font=("", 12, "bold"), + text_color=COLORS["text"], + ).pack(anchor="w") + + if note: + CTkLabel( + info_frame, + text=note, + font=("", 11), + text_color=COLORS["text_secondary"], + ).pack(anchor="w") + + # Duration + mins = int(duration // 60) + secs = int(duration % 60) + CTkLabel( + self, + text=f"{mins}:{secs:02d}", + font=("", 12), + text_color=COLORS["text_secondary"], + ).grid(row=0, column=2, padx=10) + + # Delete button + if status in (ClipStatus.PREVIEW, ClipStatus.KEPT) and on_delete: + CTkButton( + self, + text="πŸ—‘οΈ", + width=28, + height=28, + fg_color="transparent", + hover_color=COLORS["bg_hover"], + command=lambda: on_delete(clip_id), + ).grid(row=0, column=3, padx=(0, 10)) + + +# ============================================================================ +# MAIN GUI +# ============================================================================ + +class KBCaptureGUI: + """Main application window.""" + + def __init__(self): if not HAS_CTK: raise RuntimeError("CustomTkinter not installed") - - # Load saved config + + # Load config self.config = load_config() - - # Use saved path, provided path, or None - if projects_root and projects_root.exists(): - self.projects_root = projects_root - elif self.config.get("projects_root"): + + # Get projects root from config or None + self.projects_root = None + if self.config.get("projects_root"): saved = Path(self.config["projects_root"]) - self.projects_root = saved if saved.exists() else None - else: - self.projects_root = None - - # App (may be None if no projects root) + if saved.exists(): + self.projects_root = saved + + # App self.app = None - if self.projects_root: - self._init_app() - + self.selected_project = None + # Window ctk.set_appearance_mode("dark") - ctk.set_default_color_theme("blue") - + self.window = CTk() self.window.title("KB Capture") - self.window.geometry("500x650") - self.window.minsize(400, 550) - + self.window.geometry("600x700") + self.window.minsize(500, 600) + self.window.configure(fg_color=COLORS["bg"]) + # Build UI self._build_ui() - - # Start app if ready - if self.app: - self.app.start() - - # Cleanup on close + + # Initialize app if we have a projects root + if self.projects_root: + self._init_app() + self._refresh_projects() + + # Cleanup self.window.protocol("WM_DELETE_WINDOW", self._on_close) - + def _init_app(self): - """Initialize the app with current projects_root.""" + """Initialize the capture app.""" + if self.app: + self.app.stop() + self.app = KBCaptureApp( projects_root=self.projects_root, on_status_change=self._on_status_change, ) - + self.app.start() + def _build_ui(self): - """Build the main UI.""" - self.window.grid_columnconfigure(0, weight=1) - self.window.grid_rowconfigure(3, weight=1) - - # === Projects Folder Selector === - folder_frame = CTkFrame(self.window, fg_color=COLORS["bg_light"], corner_radius=8) - folder_frame.grid(row=0, column=0, sticky="ew", padx=20, pady=(20, 10)) - folder_frame.grid_columnconfigure(1, weight=1) - - CTkLabel(folder_frame, text="πŸ“", font=("", 20)).grid( - row=0, column=0, padx=(15, 5), pady=10 - ) - + """Build the main interface.""" + # Header + header = CTkFrame(self.window, fg_color="transparent", height=60) + header.pack(fill="x", padx=20, pady=(15, 10)) + header.pack_propagate(False) + + CTkLabel( + header, + text="KB Capture", + font=("", 24, "bold"), + text_color=COLORS["text"], + ).pack(side="left", pady=10) + + # Settings button (right side) + CTkButton( + header, + text="βš™οΈ", + width=40, + height=40, + fg_color="transparent", + hover_color=COLORS["bg_hover"], + command=self._show_settings, + ).pack(side="right") + + # Folder selector + folder_frame = CTkFrame(self.window, fg_color=COLORS["bg_card"], corner_radius=10) + folder_frame.pack(fill="x", padx=20, pady=(0, 15)) + + folder_inner = CTkFrame(folder_frame, fg_color="transparent") + folder_inner.pack(fill="x", padx=15, pady=12) + + CTkLabel( + folder_inner, + text="πŸ“", + font=("", 18), + ).pack(side="left", padx=(0, 10)) + self.folder_label = CTkLabel( - folder_frame, - text=str(self.projects_root) if self.projects_root else "No folder selected", + folder_inner, + text=str(self.projects_root) if self.projects_root else "No folder selected β€” click Browse", font=("", 12), - text_color=COLORS["text"] if self.projects_root else COLORS["text_dim"], + text_color=COLORS["text"] if self.projects_root else COLORS["text_muted"], anchor="w", ) - self.folder_label.grid(row=0, column=1, sticky="ew", padx=5, pady=10) - - browse_btn = CTkButton( - folder_frame, + self.folder_label.pack(side="left", fill="x", expand=True) + + CTkButton( + folder_inner, text="Browse...", width=80, - height=28, + height=30, + fg_color=COLORS["accent"], + hover_color=COLORS["accent_hover"], command=self._browse_folder, - ) - browse_btn.grid(row=0, column=2, padx=(5, 15), pady=10) - - # === Header === - header = CTkFrame(self.window, fg_color="transparent") - header.grid(row=1, column=0, sticky="ew", padx=20, pady=(10, 10)) - header.grid_columnconfigure(1, weight=1) - - self.status_indicator = CTkLabel( + ).pack(side="right") + + # Main content area (will switch between project list and recording view) + self.content_frame = CTkFrame(self.window, fg_color="transparent") + self.content_frame.pack(fill="both", expand=True, padx=20, pady=(0, 15)) + + # Show appropriate view + if self.projects_root: + self._show_project_list() + else: + self._show_welcome() + + # Bottom bar (recording controls) - hidden until session starts + self.controls_frame = CTkFrame(self.window, fg_color=COLORS["bg_card"], corner_radius=0) + # Will be shown when session starts + + def _show_welcome(self): + """Show welcome screen when no folder selected.""" + for widget in self.content_frame.winfo_children(): + widget.destroy() + + welcome = CTkFrame(self.content_frame, fg_color="transparent") + welcome.place(relx=0.5, rely=0.4, anchor="center") + + CTkLabel( + welcome, + text="πŸ‘‹ Welcome to KB Capture", + font=("", 20, "bold"), + text_color=COLORS["text"], + ).pack(pady=(0, 10)) + + CTkLabel( + welcome, + text="Select your projects folder to get started", + font=("", 14), + text_color=COLORS["text_secondary"], + ).pack(pady=(0, 20)) + + CTkButton( + welcome, + text="Browse for Projects Folder", + width=200, + height=40, + font=("", 14), + fg_color=COLORS["accent"], + hover_color=COLORS["accent_hover"], + command=self._browse_folder, + ).pack() + + def _show_project_list(self): + """Show project list view.""" + for widget in self.content_frame.winfo_children(): + widget.destroy() + + # Hide controls if visible + self.controls_frame.pack_forget() + + # Header row + header = CTkFrame(self.content_frame, fg_color="transparent") + header.pack(fill="x", pady=(0, 10)) + + CTkLabel( header, + text="Projects", + font=("", 16, "bold"), + text_color=COLORS["text"], + ).pack(side="left") + + CTkButton( + header, + text="+ New Project", + width=120, + height=32, + fg_color=COLORS["success"], + hover_color="#16a34a", + command=self._new_project, + ).pack(side="right") + + CTkButton( + header, + text="↻ Refresh", + width=80, + height=32, + fg_color="transparent", + border_width=1, + border_color=COLORS["border"], + hover_color=COLORS["bg_hover"], + command=self._refresh_projects, + ).pack(side="right", padx=(0, 10)) + + # Project list + self.projects_scroll = CTkScrollableFrame( + self.content_frame, + fg_color="transparent", + ) + self.projects_scroll.pack(fill="both", expand=True) + + # Populate projects + self._populate_projects() + + def _populate_projects(self): + """Populate the projects list.""" + for widget in self.projects_scroll.winfo_children(): + widget.destroy() + + if not self.app: + CTkLabel( + self.projects_scroll, + text="No projects found", + text_color=COLORS["text_muted"], + ).pack(pady=40) + return + + projects = self.app.session_manager.list_projects() + + if not projects: + empty = CTkFrame(self.projects_scroll, fg_color="transparent") + empty.pack(pady=40) + + CTkLabel( + empty, + text="No projects yet", + font=("", 14), + text_color=COLORS["text_secondary"], + ).pack() + + CTkLabel( + empty, + text="Click '+ New Project' to create one", + font=("", 12), + text_color=COLORS["text_muted"], + ).pack(pady=(5, 0)) + return + + for name in projects: + stats = get_project_stats(self.projects_root / name) + card = ProjectCard( + self.projects_scroll, + name=name, + stats=stats, + selected=(name == self.selected_project), + on_select=self._select_project, + ) + card.pack(fill="x", pady=3) + + # Start Session button (shown when project selected) + if self.selected_project: + btn_frame = CTkFrame(self.projects_scroll, fg_color="transparent") + btn_frame.pack(fill="x", pady=(20, 10)) + + CTkButton( + btn_frame, + text=f"▢️ Start Recording Session", + height=45, + font=("", 14, "bold"), + fg_color=COLORS["accent"], + hover_color=COLORS["accent_hover"], + command=self._start_session_dialog, + ).pack(fill="x") + + def _show_recording_view(self): + """Show the recording interface.""" + for widget in self.content_frame.winfo_children(): + widget.destroy() + + # Session info + session = self.app.session_manager.current_session + + info_frame = CTkFrame(self.content_frame, fg_color=COLORS["bg_card"], corner_radius=10) + info_frame.pack(fill="x", pady=(0, 15)) + + info_inner = CTkFrame(info_frame, fg_color="transparent") + info_inner.pack(fill="x", padx=15, pady=12) + + CTkLabel( + info_inner, + text=session.project, + font=("", 12), + text_color=COLORS["text_secondary"], + ).pack(anchor="w") + + CTkLabel( + info_inner, + text=session.name, + font=("", 18, "bold"), + text_color=COLORS["text"], + ).pack(anchor="w") + + type_color = COLORS["accent"] if session.session_type == SessionType.DESIGN else COLORS["warning"] + CTkLabel( + info_inner, + text=f"● {session.session_type.value.title()}", + font=("", 11), + text_color=type_color, + ).pack(anchor="w", pady=(5, 0)) + + # Status display + self.status_frame = CTkFrame(self.content_frame, fg_color="transparent") + self.status_frame.pack(fill="x", pady=(0, 10)) + + self.status_icon = CTkLabel( + self.status_frame, text="●", - font=("", 32), - text_color=COLORS["idle"], + font=("", 40), + text_color=COLORS["success"], ) - self.status_indicator.grid(row=0, column=0, padx=(0, 10)) - + self.status_icon.pack() + self.status_label = CTkLabel( - header, - text="Ready", + self.status_frame, + text="Ready to record", + font=("", 16), + text_color=COLORS["text"], + ) + self.status_label.pack() + + self.duration_label = CTkLabel( + self.status_frame, + text="", font=("", 24, "bold"), text_color=COLORS["text"], ) - self.status_label.grid(row=0, column=1, sticky="w") - - self.duration_label = CTkLabel( - header, - text="", - font=("", 20), - text_color=COLORS["text_dim"], - ) - self.duration_label.grid(row=0, column=2) - - # === Session Info / Start Form === - self.session_frame = CTkFrame(self.window, fg_color=COLORS["bg_light"], corner_radius=12) - self.session_frame.grid(row=2, column=0, sticky="ew", padx=20, pady=10) - self.session_frame.grid_columnconfigure(1, weight=1) - - # Project (dropdown) - CTkLabel(self.session_frame, text="Project:", text_color=COLORS["text_dim"]).grid( - row=0, column=0, padx=15, pady=(15, 5), sticky="w" - ) - - # Get available projects - if self.app: - projects = self.app.session_manager.list_projects() - if not projects: - projects = ["(No projects found)"] - else: - projects = ["(Select folder first)"] - - self.project_menu = CTkOptionMenu( - self.session_frame, - values=projects, - width=250, - ) - self.project_menu.grid(row=0, column=1, padx=(0, 15), pady=(15, 5), sticky="ew") - - # Refresh button - refresh_btn = CTkButton( - self.session_frame, - text="↻", - width=30, - fg_color="transparent", - hover_color=COLORS["bg_dark"], - command=self._refresh_projects, - ) - refresh_btn.grid(row=0, column=2, padx=(0, 15), pady=(15, 5)) - - # Session name - CTkLabel(self.session_frame, text="Session:", text_color=COLORS["text_dim"]).grid( - row=1, column=0, padx=15, pady=5, sticky="w" - ) - self.name_entry = CTkEntry( - self.session_frame, - placeholder_text="What are you working on?", - width=250, - ) - self.name_entry.grid(row=1, column=1, padx=(0, 15), pady=5, sticky="ew") - - # Session type - CTkLabel(self.session_frame, text="Type:", text_color=COLORS["text_dim"]).grid( - row=2, column=0, padx=15, pady=5, sticky="w" - ) - self.type_menu = CTkOptionMenu( - self.session_frame, - values=["Design", "Analysis"], - width=150, - ) - self.type_menu.grid(row=2, column=1, padx=(0, 15), pady=5, sticky="w") - - # Start button - self.start_btn = CTkButton( - self.session_frame, - text="Start Session", - font=("", 14, "bold"), - height=40, - command=self._start_session, - ) - self.start_btn.grid(row=3, column=0, columnspan=2, padx=15, pady=15, sticky="ew") - - # === Clips List === - clips_header = CTkFrame(self.window, fg_color="transparent") - clips_header.grid(row=3, column=0, sticky="new", padx=20, pady=(10, 0)) - clips_header.grid_columnconfigure(0, weight=1) - + self.duration_label.pack(pady=(5, 0)) + + # Clips list + clips_header = CTkFrame(self.content_frame, fg_color="transparent") + clips_header.pack(fill="x", pady=(10, 5)) + CTkLabel( clips_header, text="Clips", - font=("", 16, "bold"), + font=("", 14, "bold"), text_color=COLORS["text"], - ).grid(row=0, column=0, sticky="w") - - self.clips_count = CTkLabel( + ).pack(side="left") + + self.clips_count_label = CTkLabel( clips_header, - text="0 clips β€’ 0:00", - font=("", 14), - text_color=COLORS["text_dim"], + text="0 clips", + font=("", 12), + text_color=COLORS["text_secondary"], ) - self.clips_count.grid(row=0, column=1, sticky="e") - - self.clips_frame = CTkScrollableFrame( - self.window, + self.clips_count_label.pack(side="right") + + self.clips_scroll = CTkScrollableFrame( + self.content_frame, fg_color="transparent", ) - self.clips_frame.grid(row=4, column=0, sticky="nsew", padx=20, pady=10) - self.clips_frame.grid_columnconfigure(0, weight=1) - self.window.grid_rowconfigure(4, weight=1) - - # Empty state - self.empty_label = CTkLabel( - self.clips_frame, - text="No clips yet.\nPress Ctrl+Shift+R to start recording.", - font=("", 14), - text_color=COLORS["text_dim"], - justify="center", - ) - self.empty_label.grid(row=0, column=0, pady=40) - - # === Control Bar === - controls = CTkFrame(self.window, fg_color=COLORS["bg_light"], corner_radius=0) - controls.grid(row=5, column=0, sticky="sew", pady=0) - controls.grid_columnconfigure((0, 1, 2), weight=1) - + self.clips_scroll.pack(fill="both", expand=True) + + # Show controls bar + self._show_controls() + + def _show_controls(self): + """Show the recording controls bar.""" + self.controls_frame.pack(fill="x", side="bottom") + + for widget in self.controls_frame.winfo_children(): + widget.destroy() + + inner = CTkFrame(self.controls_frame, fg_color="transparent") + inner.pack(fill="x", padx=15, pady=15) + inner.grid_columnconfigure((0, 1, 2), weight=1) + + # Record button self.record_btn = CTkButton( - controls, + inner, text="⏺️ Record", - font=("", 14), height=50, - fg_color=COLORS["recording"], - hover_color="#DC2626", - command=self.app.toggle_recording, - state="disabled", + font=("", 14, "bold"), + fg_color=COLORS["danger"], + hover_color="#dc2626", + command=self._toggle_recording, ) - self.record_btn.grid(row=0, column=0, padx=5, pady=10, sticky="ew") - + self.record_btn.grid(row=0, column=0, padx=5, sticky="ew") + + # Keep button self.keep_btn = CTkButton( - controls, + inner, text="βœ“ Keep", - font=("", 14), height=50, - fg_color=COLORS["session"], - hover_color="#059669", - command=lambda: self.app.keep_last_clip(), + font=("", 14), + fg_color=COLORS["success"], + hover_color="#16a34a", + command=self._keep_clip, state="disabled", ) - self.keep_btn.grid(row=0, column=1, padx=5, pady=10, sticky="ew") - + self.keep_btn.grid(row=0, column=1, padx=5, sticky="ew") + + # End button self.end_btn = CTkButton( - controls, + inner, text="End Session", - font=("", 14), height=50, - fg_color=COLORS["idle"], - hover_color="#4B5563", + font=("", 14), + fg_color=COLORS["bg_hover"], + hover_color=COLORS["border"], command=self._end_session, - state="disabled", ) - self.end_btn.grid(row=0, column=2, padx=5, pady=10, sticky="ew") - + self.end_btn.grid(row=0, column=2, padx=5, sticky="ew") + # Hotkey hints - hints = CTkLabel( - controls, + CTkLabel( + self.controls_frame, text="Ctrl+Shift+R: Record β€’ K: Keep β€’ D: Delete β€’ E: End", - font=("", 11), - text_color=COLORS["text_dim"], - ) - hints.grid(row=1, column=0, columnspan=3, pady=(0, 10)) - + font=("", 10), + text_color=COLORS["text_muted"], + ).pack(pady=(0, 10)) + + # ======================================================================== + # ACTIONS + # ======================================================================== + def _browse_folder(self): """Browse for projects folder.""" - initial_dir = str(self.projects_root) if self.projects_root else str(Path.home()) - + initial = str(self.projects_root) if self.projects_root else str(Path.home()) + folder = filedialog.askdirectory( title="Select Projects Folder", - initialdir=initial_dir, + initialdir=initial, ) - + if folder: self.projects_root = Path(folder) self.folder_label.configure( text=str(self.projects_root), text_color=COLORS["text"], ) - - # Save to config + + # Save config self.config["projects_root"] = str(self.projects_root) save_config(self.config) - - # Initialize or reinitialize app - if self.app: - self.app.stop() + + # Initialize app self._init_app() - self.app.start() - - # Refresh projects list + self._show_project_list() self._refresh_projects() - - # Update status - self.status_label.configure(text="Ready") - + + def _new_project(self): + """Show new project dialog.""" + dialog = NewProjectDialog(self.window, self.projects_root) + self.window.wait_window(dialog.dialog) + + if dialog.result: + self.selected_project = dialog.result + self._refresh_projects() + def _refresh_projects(self): - """Refresh the project list.""" - if not self.app: - self.project_menu.configure(values=["(Select folder first)"]) - self.project_menu.set("(Select folder first)") + """Refresh the projects list.""" + self._populate_projects() + + def _select_project(self, name: str): + """Select a project.""" + self.selected_project = name + self._populate_projects() + + def _start_session_dialog(self): + """Show dialog to start a session.""" + if not self.selected_project: return - - projects = self.app.session_manager.list_projects() - if not projects: - projects = ["(No projects found)"] - self.project_menu.configure(values=projects) - if projects: - self.project_menu.set(projects[0]) - - def _start_session(self): - """Start a new session.""" - if not self.app: - self.status_label.configure(text="Select a projects folder first!") - return - - project = self.project_menu.get() - if project in ("(No projects found)", "(Select folder first)"): - self.status_label.configure(text="No project selected!") - return - - name = self.name_entry.get() or "Recording Session" - session_type = SessionType.DESIGN if self.type_menu.get() == "Design" else SessionType.ANALYSIS - + + # Simple dialog for session name + dialog = CTkInputDialog( + text=f"Session name for {self.selected_project}:", + title="Start Session", + ) + name = dialog.get_input() + + if name: + self._start_session(name, SessionType.DESIGN) + + def _start_session(self, name: str, session_type: SessionType): + """Start a recording session.""" try: - self.app.start_session(name, project, session_type) - except ValueError as e: - self.status_label.configure(text=str(e)) - return - - # Update UI - self.start_btn.configure(state="disabled", text="Session Active") - self.record_btn.configure(state="normal") - self.end_btn.configure(state="normal") - self.project_menu.configure(state="disabled") - self.name_entry.configure(state="disabled") - self.type_menu.configure(state="disabled") - + self.app.start_session(name, self.selected_project, session_type) + self._show_recording_view() + except Exception as e: + messagebox.showerror("Error", str(e)) + + def _toggle_recording(self): + """Toggle recording state.""" + if self.app: + self.app.toggle_recording() + + def _keep_clip(self): + """Keep the last clip.""" + if self.app: + self.app.keep_last_clip() + + def _delete_clip(self, clip_id: str): + """Delete a clip.""" + if self.app: + self.app.session_manager.delete_clip(clip_id) + self._update_clips_list() + def _end_session(self): """End the current session.""" - session = self.app.end_session() - - if session: - # Show summary - self.status_label.configure(text="Session Saved!") - - # Reset UI - self.start_btn.configure(state="normal", text="Start Session") - self.record_btn.configure(state="disabled") - self.keep_btn.configure(state="disabled") - self.end_btn.configure(state="disabled") - self.project_menu.configure(state="normal") - self.name_entry.configure(state="normal") - self.type_menu.configure(state="normal") - - # Clear clips - for widget in self.clips_frame.winfo_children(): - widget.destroy() - self.empty_label = CTkLabel( - self.clips_frame, - text=f"Session saved: {session.clip_count} clips, {session.total_duration:.0f}s\nReady for next session.", - font=("", 14), - text_color=COLORS["text_dim"], - justify="center", - ) - self.empty_label.grid(row=0, column=0, pady=40) - + if self.app: + session = self.app.end_session() + if session: + messagebox.showinfo( + "Session Saved", + f"Recorded {session.clip_count} clips ({session.total_duration:.0f}s total)\n\n" + f"Saved to: {self.selected_project}/_capture/{session.id}/" + ) + self._show_project_list() + self._refresh_projects() + + def _show_settings(self): + """Show settings (placeholder).""" + messagebox.showinfo("Settings", "Settings coming soon!") + + # ======================================================================== + # STATUS UPDATES + # ======================================================================== + def _on_status_change(self, status: AppStatus): """Handle status updates from the app.""" - # Run on main thread self.window.after(0, lambda: self._update_ui(status)) - + def _update_ui(self, status: AppStatus): """Update UI based on status.""" - # State colors and labels - state_config = { - AppState.IDLE: (COLORS["idle"], "Ready", ""), - AppState.SESSION_ACTIVE: (COLORS["session"], "Session Active", ""), - AppState.RECORDING: (COLORS["recording"], "Recording", ""), - AppState.PREVIEW: (COLORS["preview"], "Review Clip", ""), - } - - color, label, _ = state_config.get(status.state, (COLORS["idle"], "Ready", "")) - - self.status_indicator.configure(text_color=color) - - if status.state == AppState.RECORDING: - self.status_label.configure(text=f"Recording... {status.current_clip_duration:.1f}s") - self.record_btn.configure(text="⏹️ Stop", fg_color=COLORS["recording"]) - elif status.state == AppState.PREVIEW: - self.status_label.configure(text=f"Keep or Delete? ({status.last_clip_duration:.1f}s)") - self.record_btn.configure(text="⏺️ Record", fg_color=COLORS["session"]) - self.keep_btn.configure(state="normal") - else: - self.status_label.configure(text=label) - self.record_btn.configure(text="⏺️ Record", fg_color=COLORS["recording"]) - if status.state != AppState.IDLE: - self.keep_btn.configure(state="disabled") - - # Duration + if not hasattr(self, 'status_icon'): + return + + # Update status display if status.state == AppState.RECORDING: + self.status_icon.configure(text_color=COLORS["danger"]) + self.status_label.configure(text="Recording...") + self.record_btn.configure(text="⏹️ Stop", fg_color=COLORS["danger"]) + self.keep_btn.configure(state="disabled") + + # Duration secs = int(status.current_clip_duration) self.duration_label.configure(text=f"{secs//60}:{secs%60:02d}") - elif status.total_duration > 0: - secs = int(status.total_duration) - self.duration_label.configure(text=f"Total: {secs//60}:{secs%60:02d}") - else: + + elif status.state == AppState.PREVIEW: + self.status_icon.configure(text_color=COLORS["warning"]) + self.status_label.configure(text="Keep or delete?") + self.record_btn.configure(text="⏺️ Record", fg_color=COLORS["success"]) + self.keep_btn.configure(state="normal") + + secs = int(status.last_clip_duration) + self.duration_label.configure(text=f"{secs//60}:{secs%60:02d}") + + else: # SESSION_ACTIVE + self.status_icon.configure(text_color=COLORS["success"]) + self.status_label.configure(text="Ready to record") + self.record_btn.configure(text="⏺️ Record", fg_color=COLORS["danger"]) + self.keep_btn.configure(state="disabled") self.duration_label.configure(text="") - - # Clips count - self.clips_count.configure( - text=f"{status.clip_count} clips β€’ {int(status.total_duration//60)}:{int(status.total_duration%60):02d}" - ) - + # Update clips list self._update_clips_list() - + def _update_clips_list(self): """Update the clips list display.""" - if not self.app: + if not self.app or not hasattr(self, 'clips_scroll'): return + session = self.app.session_manager.current_session if not session: return - - # Clear existing - for widget in self.clips_frame.winfo_children(): + + # Clear + for widget in self.clips_scroll.winfo_children(): widget.destroy() - - if not session.clips: - self.empty_label = CTkLabel( - self.clips_frame, - text="No clips yet.\nPress Ctrl+Shift+R to start recording.", - font=("", 14), - text_color=COLORS["text_dim"], + + # Count + kept = [c for c in session.clips if c.status != ClipStatus.DELETED] + self.clips_count_label.configure(text=f"{len(kept)} clips β€’ {session.total_duration:.0f}s") + + if not kept: + CTkLabel( + self.clips_scroll, + text="No clips yet\nPress Ctrl+Shift+R to record", + font=("", 12), + text_color=COLORS["text_muted"], justify="center", - ) - self.empty_label.grid(row=0, column=0, pady=40) + ).pack(pady=30) return - - # Add clip cards (reversed for newest first) - for i, clip in enumerate(reversed(session.clips)): - if clip.status != ClipStatus.DELETED: - card = ClipCard( - self.clips_frame, - clip_id=clip.id, - duration=clip.duration_seconds, - status=clip.status, - note=clip.note, - on_delete=self._delete_clip, - ) - card.grid(row=i, column=0, sticky="ew", pady=2) - - def _delete_clip(self, clip_id: str): - """Delete a clip.""" - self.app.session_manager.delete_clip(clip_id) - self._update_clips_list() - + + # Add clips (newest first) + for clip in reversed(kept): + ClipCard( + self.clips_scroll, + clip_id=clip.id, + duration=clip.duration_seconds, + status=clip.status, + note=clip.note, + on_delete=self._delete_clip, + ).pack(fill="x", pady=2) + def _on_close(self): """Handle window close.""" if self.app: self.app.stop() self.window.destroy() - + def run(self): - """Run the GUI main loop.""" + """Run the application.""" self.window.mainloop() def main(): - """Entry point for GUI.""" + """Entry point.""" if not HAS_CTK: print("Error: CustomTkinter not installed") print("Install with: pip install customtkinter") sys.exit(1) - # Try to find a projects folder automatically - # Windows: Look for ATODrive, SeaDrive, or Documents - # Linux: Look for obsidian-vault (Syncthing) or home - if sys.platform == "win32": - candidates = [ - Path("D:/ATODrive/Projects"), - Path("C:/ATODrive/Projects"), - # Common SeaDrive paths - Path.home() / "SeaDrive" / "My Libraries" / "ATODrive" / "Projects", - Path.home() / "Documents" / "Projects", - ] - else: - candidates = [ - Path.home() / "obsidian-vault" / "2-Projects", - Path.home() / "ATODrive" / "Projects", - ] - - projects_root = None - for path in candidates: - if path.exists(): - projects_root = path - print(f"Found projects at: {projects_root}") - break - - # If not found, GUI will prompt user to browse - if not projects_root: - print("No projects folder found automatically.") - print("You'll be prompted to select one.") - - gui = KBCaptureGUI(projects_root=projects_root) + gui = KBCaptureGUI() gui.run()