From 978c79abc0394155084c6b570374eb612360039b Mon Sep 17 00:00:00 2001 From: Mario Lavoie Date: Mon, 9 Feb 2026 21:55:16 +0000 Subject: [PATCH] Add Browse button for projects folder selection - Added Browse... button to select projects folder - Saves selected folder to config file (persistent) - Works with SeaDrive paths - Graceful handling when no folder selected - Auto-detects common paths on startup --- src/cad_documenter/gui_capture.py | 318 ++++++++++++++++++++---------- 1 file changed, 219 insertions(+), 99 deletions(-) diff --git a/src/cad_documenter/gui_capture.py b/src/cad_documenter/gui_capture.py index 7c7fbde..a2411fa 100644 --- a/src/cad_documenter/gui_capture.py +++ b/src/cad_documenter/gui_capture.py @@ -6,9 +6,11 @@ Uses CustomTkinter for a native look. """ import sys +import json import threading from pathlib import Path from typing import Optional +from tkinter import filedialog try: import customtkinter as ctk @@ -26,7 +28,7 @@ from .session import SessionType, ClipStatus # Colors COLORS = { "idle": "#6B7280", # Gray - "session": "#10B981", # Green + "session": "#10B981", # Green "recording": "#EF4444", # Red "preview": "#F59E0B", # Amber "bg_dark": "#1F2937", @@ -38,7 +40,7 @@ COLORS = { class ClipCard(CTkFrame): """A card showing a single clip.""" - + def __init__( self, master, @@ -50,9 +52,9 @@ class ClipCard(CTkFrame): **kwargs ): super().__init__(master, **kwargs) - + self.configure(fg_color=COLORS["bg_light"], corner_radius=8) - + # Status indicator status_colors = { ClipStatus.KEPT: "#10B981", @@ -61,18 +63,18 @@ class ClipCard(CTkFrame): 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, @@ -80,7 +82,7 @@ class ClipCard(CTkFrame): text_color=COLORS["text"], ) title.pack(anchor="w") - + subtitle = CTkLabel( info_frame, text=f"{duration:.1f}s" + (f" • {note}" if note else ""), @@ -88,7 +90,7 @@ class ClipCard(CTkFrame): text_color=COLORS["text_dim"], ) subtitle.pack(anchor="w") - + # Duration duration_label = CTkLabel( self, @@ -97,7 +99,7 @@ class ClipCard(CTkFrame): 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( @@ -112,49 +114,122 @@ class ClipCard(CTkFrame): delete_btn.grid(row=0, column=3, padx=(0, 10)) +def get_config_path() -> Path: + """Get path to config file.""" + if sys.platform == "win32": + config_dir = Path.home() / "AppData" / "Local" / "KBCapture" + else: + config_dir = Path.home() / ".config" / "kb-capture" + config_dir.mkdir(parents=True, exist_ok=True) + return config_dir / "config.json" + + +def load_config() -> dict: + """Load config from file.""" + config_path = get_config_path() + if config_path.exists(): + try: + with open(config_path) as f: + return json.load(f) + except: + pass + return {} + + +def save_config(config: dict) -> None: + """Save config to file.""" + config_path = get_config_path() + with open(config_path, "w") as f: + json.dump(config, f, indent=2) + + class KBCaptureGUI: """Main GUI window for KB Capture.""" - - def __init__(self, projects_root: Path): + + def __init__(self, projects_root: Optional[Path] = None): if not HAS_CTK: raise RuntimeError("CustomTkinter not installed") - - self.projects_root = projects_root - - # App - self.app = KBCaptureApp( - projects_root=projects_root, - on_status_change=self._on_status_change, - ) - + + # Load saved 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"): + 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) + self.app = None + if self.projects_root: + self._init_app() + # Window ctk.set_appearance_mode("dark") ctk.set_default_color_theme("blue") - + self.window = CTk() self.window.title("KB Capture") - self.window.geometry("500x600") - self.window.minsize(400, 500) - + self.window.geometry("500x650") + self.window.minsize(400, 550) + # Build UI self._build_ui() - - # Start app - self.app.start() - + + # Start app if ready + if self.app: + self.app.start() + # Cleanup on close self.window.protocol("WM_DELETE_WINDOW", self._on_close) - + + def _init_app(self): + """Initialize the app with current projects_root.""" + self.app = KBCaptureApp( + projects_root=self.projects_root, + on_status_change=self._on_status_change, + ) + def _build_ui(self): """Build the main UI.""" self.window.grid_columnconfigure(0, weight=1) - self.window.grid_rowconfigure(2, 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 + ) + + self.folder_label = CTkLabel( + folder_frame, + text=str(self.projects_root) if self.projects_root else "No folder selected", + font=("", 12), + text_color=COLORS["text"] if self.projects_root else COLORS["text_dim"], + anchor="w", + ) + self.folder_label.grid(row=0, column=1, sticky="ew", padx=5, pady=10) + + browse_btn = CTkButton( + folder_frame, + text="Browse...", + width=80, + height=28, + 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=0, column=0, sticky="ew", padx=20, pady=(20, 10)) + header.grid(row=1, column=0, sticky="ew", padx=20, pady=(10, 10)) header.grid_columnconfigure(1, weight=1) - + self.status_indicator = CTkLabel( header, text="●", @@ -162,7 +237,7 @@ class KBCaptureGUI: text_color=COLORS["idle"], ) self.status_indicator.grid(row=0, column=0, padx=(0, 10)) - + self.status_label = CTkLabel( header, text="Ready", @@ -170,7 +245,7 @@ class KBCaptureGUI: text_color=COLORS["text"], ) self.status_label.grid(row=0, column=1, sticky="w") - + self.duration_label = CTkLabel( header, text="", @@ -178,29 +253,32 @@ class KBCaptureGUI: 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=1, column=0, sticky="ew", padx=20, pady=10) + 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 - projects = self.app.session_manager.list_projects() - if not projects: - projects = ["(No projects found)"] - + 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, @@ -211,7 +289,7 @@ class KBCaptureGUI: 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" @@ -222,7 +300,7 @@ class KBCaptureGUI: 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" @@ -233,7 +311,7 @@ class KBCaptureGUI: 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, @@ -243,19 +321,19 @@ class KBCaptureGUI: 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=2, column=0, sticky="new", padx=20, pady=(10, 0)) + clips_header.grid(row=3, column=0, sticky="new", padx=20, pady=(10, 0)) clips_header.grid_columnconfigure(0, weight=1) - + CTkLabel( clips_header, text="Clips", font=("", 16, "bold"), text_color=COLORS["text"], ).grid(row=0, column=0, sticky="w") - + self.clips_count = CTkLabel( clips_header, text="0 clips • 0:00", @@ -263,15 +341,15 @@ class KBCaptureGUI: text_color=COLORS["text_dim"], ) self.clips_count.grid(row=0, column=1, sticky="e") - + self.clips_frame = CTkScrollableFrame( self.window, fg_color="transparent", ) - self.clips_frame.grid(row=3, column=0, sticky="nsew", padx=20, pady=10) + 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(3, weight=1) - + self.window.grid_rowconfigure(4, weight=1) + # Empty state self.empty_label = CTkLabel( self.clips_frame, @@ -281,12 +359,12 @@ class KBCaptureGUI: 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=4, column=0, sticky="sew", pady=0) + controls.grid(row=5, column=0, sticky="sew", pady=0) controls.grid_columnconfigure((0, 1, 2), weight=1) - + self.record_btn = CTkButton( controls, text="⏺️ Record", @@ -298,7 +376,7 @@ class KBCaptureGUI: state="disabled", ) self.record_btn.grid(row=0, column=0, padx=5, pady=10, sticky="ew") - + self.keep_btn = CTkButton( controls, text="✓ Keep", @@ -310,7 +388,7 @@ class KBCaptureGUI: state="disabled", ) self.keep_btn.grid(row=0, column=1, padx=5, pady=10, sticky="ew") - + self.end_btn = CTkButton( controls, text="End Session", @@ -322,7 +400,7 @@ class KBCaptureGUI: state="disabled", ) self.end_btn.grid(row=0, column=2, padx=5, pady=10, sticky="ew") - + # Hotkey hints hints = CTkLabel( controls, @@ -331,32 +409,73 @@ class KBCaptureGUI: text_color=COLORS["text_dim"], ) hints.grid(row=1, column=0, columnspan=3, pady=(0, 10)) - + + def _browse_folder(self): + """Browse for projects folder.""" + initial_dir = str(self.projects_root) if self.projects_root else str(Path.home()) + + folder = filedialog.askdirectory( + title="Select Projects Folder", + initialdir=initial_dir, + ) + + if folder: + self.projects_root = Path(folder) + self.folder_label.configure( + text=str(self.projects_root), + text_color=COLORS["text"], + ) + + # Save to config + self.config["projects_root"] = str(self.projects_root) + save_config(self.config) + + # Initialize or reinitialize app + if self.app: + self.app.stop() + self._init_app() + self.app.start() + + # Refresh projects list + self._refresh_projects() + + # Update status + self.status_label.configure(text="Ready") + 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)") + 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 == "(No projects found)": + 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 - + 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") @@ -364,15 +483,15 @@ class KBCaptureGUI: self.project_menu.configure(state="disabled") self.name_entry.configure(state="disabled") self.type_menu.configure(state="disabled") - + 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") @@ -381,7 +500,7 @@ class KBCaptureGUI: 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() @@ -393,12 +512,12 @@ class KBCaptureGUI: justify="center", ) self.empty_label.grid(row=0, column=0, pady=40) - + 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 @@ -408,11 +527,11 @@ class KBCaptureGUI: 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"]) @@ -425,7 +544,7 @@ class KBCaptureGUI: self.record_btn.configure(text="⏺️ Record", fg_color=COLORS["recording"]) if status.state != AppState.IDLE: self.keep_btn.configure(state="disabled") - + # Duration if status.state == AppState.RECORDING: secs = int(status.current_clip_duration) @@ -435,25 +554,27 @@ class KBCaptureGUI: self.duration_label.configure(text=f"Total: {secs//60}:{secs%60:02d}") else: 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: + return session = self.app.session_manager.current_session if not session: return - + # Clear existing for widget in self.clips_frame.winfo_children(): widget.destroy() - + if not session.clips: self.empty_label = CTkLabel( self.clips_frame, @@ -464,7 +585,7 @@ class KBCaptureGUI: ) self.empty_label.grid(row=0, column=0, pady=40) return - + # Add clip cards (reversed for newest first) for i, clip in enumerate(reversed(session.clips)): if clip.status != ClipStatus.DELETED: @@ -477,17 +598,18 @@ class KBCaptureGUI: 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() - + def _on_close(self): """Handle window close.""" - self.app.stop() + if self.app: + self.app.stop() self.window.destroy() - + def run(self): """Run the GUI main loop.""" self.window.mainloop() @@ -500,18 +622,18 @@ def main(): print("Install with: pip install customtkinter") sys.exit(1) - # Default projects location - # Windows: Look for ATODrive or Documents + # 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": - # Try common Windows locations candidates = [ Path("D:/ATODrive/Projects"), Path("C:/ATODrive/Projects"), + # Common SeaDrive paths + Path.home() / "SeaDrive" / "My Libraries" / "ATODrive" / "Projects", Path.home() / "Documents" / "Projects", ] else: - # Linux/Clawdbot - use Syncthing path candidates = [ Path.home() / "obsidian-vault" / "2-Projects", Path.home() / "ATODrive" / "Projects", @@ -521,15 +643,13 @@ def main(): 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: - # Fallback: create in Documents - projects_root = Path.home() / "Documents" / "Projects" - projects_root.mkdir(parents=True, exist_ok=True) - print(f"No projects folder found. Using: {projects_root}") - - print(f"Projects root: {projects_root}") + print("No projects folder found automatically.") + print("You'll be prompted to select one.") gui = KBCaptureGUI(projects_root=projects_root) gui.run()