"""Main Window for ChefSystem. The primary application window with recipe list and details panel. """ import logging from PyQt6.QtWidgets import ( QMainWindow, QWidget, QVBoxLayout, QHBoxLayout, QToolBar, QLineEdit, QListWidget, QSplitter, QLabel, QTextEdit, QPushButton, QListWidgetItem, QMessageBox, QApplication, QComboBox ) from PyQt6.QtCore import Qt, QSettings from PyQt6.QtGui import QAction from src.database.models import Recipe, Output logger = logging.getLogger(__name__) class MainWindow(QMainWindow): """Main application window for ChefSystem. Displays recipe list, search bar, action buttons, and recipe details panel. """ def __init__(self, db, config: dict): """Initialize the main window. Args: db: DatabaseManager instance. config: Application configuration dictionary. """ super().__init__() self.db = db self.config = config self.current_recipe = None self.setWindowTitle("ChefSystem - AI Prompt Manager") self.setMinimumSize(1000, 600) self._setup_ui() self._connect_signals() self._restore_geometry() # Load recipes and populate tag filter on startup self.populate_tag_filter() self.load_recipes() def _setup_ui(self) -> None: """Set up the user interface components.""" # Create toolbar self._create_toolbar() # Create main layout with splitter splitter = QSplitter(Qt.Orientation.Horizontal) # Left panel: Recipe list and search left_panel = self._create_left_panel() splitter.addWidget(left_panel) # Right panel: Recipe details right_panel = self._create_right_panel() splitter.addWidget(right_panel) # Set splitter proportions (30% left, 70% right) splitter.setSizes([300, 700]) # Set central widget central_widget = QWidget() layout = QVBoxLayout() layout.addWidget(splitter) layout.setContentsMargins(0, 0, 0, 0) central_widget.setLayout(layout) self.setCentralWidget(central_widget) def _create_toolbar(self) -> None: """Create the toolbar with action buttons.""" toolbar = QToolBar("Main Toolbar") toolbar.setMovable(False) self.addToolBar(toolbar) # New Recipe action new_action = QAction("New Recipe", self) new_action.setStatusTip("Create a new recipe") new_action.setShortcut("Ctrl+N") new_action.setToolTip("Create a new recipe (Ctrl+N)") new_action.triggered.connect(self.on_new_recipe) toolbar.addAction(new_action) # Edit Recipe action self.edit_action = QAction("Edit", self) self.edit_action.setStatusTip("Edit selected recipe") self.edit_action.setShortcut("Ctrl+E") self.edit_action.setToolTip("Edit selected recipe (Ctrl+E)") self.edit_action.setEnabled(False) self.edit_action.triggered.connect(self.on_edit_recipe) toolbar.addAction(self.edit_action) # Delete Recipe action self.delete_action = QAction("Delete", self) self.delete_action.setStatusTip("Delete selected recipe") self.delete_action.setShortcut("Delete") self.delete_action.setToolTip("Delete selected recipe (Delete key)") self.delete_action.setEnabled(False) self.delete_action.triggered.connect(self.on_delete_recipe) toolbar.addAction(self.delete_action) toolbar.addSeparator() # Copy Prompt action self.copy_action = QAction("Copy Prompt", self) self.copy_action.setStatusTip("Copy prompt to clipboard") self.copy_action.setShortcut("Ctrl+C") self.copy_action.setToolTip("Copy prompt to clipboard (Ctrl+C)") self.copy_action.setEnabled(False) self.copy_action.triggered.connect(self.on_copy_prompt) toolbar.addAction(self.copy_action) toolbar.addSeparator() # Manage Outputs action self.manage_outputs_action = QAction("Manage Outputs", self) self.manage_outputs_action.setStatusTip("Add, view, and manage output files") self.manage_outputs_action.setShortcut("Ctrl+O") self.manage_outputs_action.setToolTip("Manage output files for this recipe (Ctrl+O)") self.manage_outputs_action.setEnabled(False) self.manage_outputs_action.triggered.connect(self.on_manage_outputs) toolbar.addAction(self.manage_outputs_action) def _create_left_panel(self) -> QWidget: """Create the left panel with recipe list and search. Returns: QWidget: Left panel widget. """ panel = QWidget() layout = QVBoxLayout() # Search box search_label = QLabel("Search Recipes:") self.search_box = QLineEdit() self.search_box.setPlaceholderText("Type to search...") layout.addWidget(search_label) layout.addWidget(self.search_box) # Tag filter tag_label = QLabel("Filter by Tag:") self.tag_filter = QComboBox() self.tag_filter.addItem("All Tags", None) # Default: show all layout.addWidget(tag_label) layout.addWidget(self.tag_filter) # Recipe list list_label = QLabel("Recipes:") self.recipe_list = QListWidget() layout.addWidget(list_label) layout.addWidget(self.recipe_list) panel.setLayout(layout) return panel def _create_right_panel(self) -> QWidget: """Create the right panel with recipe details. Returns: QWidget: Right panel widget. """ panel = QWidget() layout = QVBoxLayout() # Recipe details section details_label = QLabel("

Recipe Details

") layout.addWidget(details_label) # Name self.name_label = QLabel("Name: No recipe selected") layout.addWidget(self.name_label) # Tags self.tags_label = QLabel("Tags: -") layout.addWidget(self.tags_label) # Description description_label = QLabel("Description:") self.description_text = QTextEdit() self.description_text.setMaximumHeight(80) self.description_text.setReadOnly(True) layout.addWidget(description_label) layout.addWidget(self.description_text) # Prompt preview prompt_label = QLabel("Prompt Preview:") self.prompt_preview = QTextEdit() self.prompt_preview.setMaximumHeight(150) self.prompt_preview.setReadOnly(True) layout.addWidget(prompt_label) layout.addWidget(self.prompt_preview) # Recent outputs section outputs_label = QLabel("

Recent Outputs

") layout.addWidget(outputs_label) self.outputs_list = QListWidget() self.outputs_list.setMaximumHeight(150) layout.addWidget(self.outputs_list) # Dates section self.dates_label = QLabel("Created: - | Updated: -") layout.addWidget(self.dates_label) layout.addStretch() panel.setLayout(layout) return panel def _connect_signals(self) -> None: """Connect signals to slots.""" self.recipe_list.itemSelectionChanged.connect(self.on_recipe_selected) self.search_box.textChanged.connect(self.on_search_changed) self.tag_filter.currentIndexChanged.connect(self.on_tag_filter_changed) self.outputs_list.itemDoubleClicked.connect(self.on_output_double_clicked) def load_recipes(self, search_query: str = "", tag_filter: str = None) -> None: """Load recipes from database and populate list. Args: search_query: Optional search query to filter recipes. tag_filter: Optional tag to filter recipes by. """ try: # Build tag list for search tags_list = [tag_filter] if tag_filter else None # Get all recipes or search if search_query or tags_list: recipes = Recipe.search(self.db, query=search_query, tags=tags_list) logger.info(f"Searched recipes with query '{search_query}' and tags {tags_list}, found {len(recipes)}") else: recipes = Recipe.get_all(self.db) logger.info(f"Loaded {len(recipes)} recipes") # Clear and populate list self.recipe_list.clear() for recipe in recipes: item = QListWidgetItem(recipe.name) item.setData(Qt.ItemDataRole.UserRole, recipe.id) self.recipe_list.addItem(item) except Exception as e: logger.error(f"Error loading recipes: {e}", exc_info=True) QMessageBox.warning(self, "Error", f"Failed to load recipes: {e}") def populate_tag_filter(self) -> None: """Populate tag filter dropdown with unique tags from all recipes.""" try: # Get all recipes all_recipes = Recipe.get_all(self.db) # Collect all unique tags tags_set = set() for recipe in all_recipes: if recipe.tags: # Split comma-separated tags and strip whitespace recipe_tags = [tag.strip() for tag in recipe.tags.split(',') if tag.strip()] tags_set.update(recipe_tags) # Store current selection current_tag = self.tag_filter.currentData() # Clear and repopulate combo box self.tag_filter.clear() self.tag_filter.addItem("All Tags", None) # Add sorted unique tags for tag in sorted(tags_set): self.tag_filter.addItem(tag, tag) # Restore previous selection if it still exists if current_tag: index = self.tag_filter.findData(current_tag) if index >= 0: self.tag_filter.setCurrentIndex(index) logger.debug(f"Populated tag filter with {len(tags_set)} unique tags") except Exception as e: logger.error(f"Error populating tag filter: {e}", exc_info=True) def on_recipe_selected(self) -> None: """Handle recipe selection change.""" selected_items = self.recipe_list.selectedItems() if not selected_items: # No selection self.current_recipe = None self.update_details_panel(None) self._update_action_states(False) return # Get selected recipe ID item = selected_items[0] recipe_id = item.data(Qt.ItemDataRole.UserRole) # Load recipe from database try: recipe = Recipe.get_by_id(self.db, recipe_id) if recipe: self.current_recipe = recipe self.update_details_panel(recipe) self._update_action_states(True) logger.debug(f"Selected recipe: {recipe.name}") except Exception as e: logger.error(f"Error loading recipe {recipe_id}: {e}", exc_info=True) QMessageBox.warning(self, "Error", f"Failed to load recipe: {e}") def update_details_panel(self, recipe: Recipe | None) -> None: """Update the details panel with recipe information. Args: recipe: Recipe to display, or None to clear. """ if recipe is None: # Clear all fields self.name_label.setText("Name: No recipe selected") self.tags_label.setText("Tags: -") self.description_text.clear() self.prompt_preview.clear() self.outputs_list.clear() self.dates_label.setText("Created: - | Updated: -") return # Update fields self.name_label.setText(f"Name: {recipe.name}") self.tags_label.setText(f"Tags: {recipe.tags if recipe.tags else '-'}") self.description_text.setPlainText(recipe.description if recipe.description else "No description") # Prompt preview (first 300 characters) preview_text = recipe.prompt_text[:300] if len(recipe.prompt_text) > 300: preview_text += "..." self.prompt_preview.setPlainText(preview_text) # Dates created = recipe.created_at.strftime("%Y-%m-%d %H:%M") if recipe.created_at else "-" updated = recipe.updated_at.strftime("%Y-%m-%d %H:%M") if recipe.updated_at else "-" self.dates_label.setText(f"Created: {created} | Updated: {updated}") # Load recent outputs self._load_recent_outputs(recipe.id) def _load_recent_outputs(self, recipe_id: int) -> None: """Load and display recent outputs for a recipe. Args: recipe_id: Recipe ID to load outputs for. """ try: outputs = Output.get_by_recipe(self.db, recipe_id) self.outputs_list.clear() # Show up to 5 most recent for output in outputs[:5]: generated = output.generated_at.strftime("%Y-%m-%d %H:%M") if output.generated_at else "-" item_text = f"{output.filename} ({generated})" item = QListWidgetItem(item_text) item.setData(Qt.ItemDataRole.UserRole, output.id) self.outputs_list.addItem(item) if not outputs: item = QListWidgetItem("No outputs yet") item.setFlags(Qt.ItemFlag.NoItemFlags) self.outputs_list.addItem(item) except Exception as e: logger.error(f"Error loading outputs for recipe {recipe_id}: {e}", exc_info=True) def _update_action_states(self, enabled: bool) -> None: """Update toolbar action enabled states. Args: enabled: Whether actions should be enabled. """ self.edit_action.setEnabled(enabled) self.delete_action.setEnabled(enabled) self.copy_action.setEnabled(enabled) self.manage_outputs_action.setEnabled(enabled) def _select_recipe_by_id(self, recipe_id: int) -> None: """Select a recipe in the list by its ID. Args: recipe_id: ID of recipe to select. """ for i in range(self.recipe_list.count()): item = self.recipe_list.item(i) if item.data(Qt.ItemDataRole.UserRole) == recipe_id: self.recipe_list.setCurrentItem(item) logger.debug(f"Selected recipe ID {recipe_id} in list") break def on_search_changed(self, text: str) -> None: """Handle search box text change. Args: text: Search query text. """ # Get current tag filter current_tag = self.tag_filter.currentData() self.load_recipes(search_query=text, tag_filter=current_tag) def on_tag_filter_changed(self, index: int) -> None: """Handle tag filter change. Args: index: Selected combobox index. """ # Get selected tag (None for "All Tags") selected_tag = self.tag_filter.currentData() # Get current search text search_text = self.search_box.text() self.load_recipes(search_query=search_text, tag_filter=selected_tag) def on_new_recipe(self) -> None: """Handle new recipe action.""" from src.ui.recipe_editor import RecipeEditor try: # Open recipe editor dialog for new recipe editor = RecipeEditor(self.db, recipe_id=None, parent=self) if editor.exec(): # Dialog was accepted (recipe saved) logger.info("New recipe created successfully") # Reload tag filter and recipes list to show the new recipe self.populate_tag_filter() self.load_recipes() # Select the newly created recipe new_recipe_id = editor.get_recipe_id() if new_recipe_id: self._select_recipe_by_id(new_recipe_id) # Show feedback self.statusBar().showMessage("Recipe created successfully", 3000) else: # Dialog was canceled logger.info("New recipe creation canceled") except Exception as e: logger.error(f"Error opening new recipe dialog: {e}", exc_info=True) QMessageBox.critical(self, "Error", f"Failed to open recipe editor: {e}") def on_edit_recipe(self) -> None: """Handle edit recipe action.""" from src.ui.recipe_editor import RecipeEditor if not self.current_recipe: QMessageBox.warning(self, "No Selection", "Please select a recipe to edit.") return try: # Open recipe editor dialog with selected recipe editor = RecipeEditor(self.db, recipe_id=self.current_recipe.id, parent=self) if editor.exec(): # Dialog was accepted (recipe updated) logger.info(f"Recipe updated: {self.current_recipe.name}") # Reload tag filter and recipes list to reflect changes self.populate_tag_filter() self.load_recipes() # Re-select the updated recipe to refresh details self._select_recipe_by_id(self.current_recipe.id) # Show feedback self.statusBar().showMessage("Recipe updated successfully", 3000) else: # Dialog was canceled logger.info(f"Edit recipe canceled: {self.current_recipe.name}") except Exception as e: logger.error(f"Error opening edit recipe dialog: {e}", exc_info=True) QMessageBox.critical(self, "Error", f"Failed to open recipe editor: {e}") def on_delete_recipe(self) -> None: """Handle delete recipe action.""" if not self.current_recipe: return # Show confirmation dialog reply = QMessageBox.question( self, "Confirm Delete", f"Are you sure you want to delete recipe '{self.current_recipe.name}'?\n\n" "This will also delete all associated outputs.", QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No, QMessageBox.StandardButton.No ) if reply == QMessageBox.StandardButton.Yes: try: success = Recipe.delete(self.db, self.current_recipe.id) if success: logger.info(f"Deleted recipe: {self.current_recipe.name}") QMessageBox.information(self, "Success", "Recipe deleted successfully") self.current_recipe = None self.populate_tag_filter() self.load_recipes() else: QMessageBox.warning(self, "Error", "Failed to delete recipe") except Exception as e: logger.error(f"Error deleting recipe: {e}", exc_info=True) QMessageBox.critical(self, "Error", f"Failed to delete recipe: {e}") def on_copy_prompt(self) -> None: """Handle copy prompt to clipboard action.""" if not self.current_recipe: return clipboard = QApplication.clipboard() clipboard.setText(self.current_recipe.prompt_text) logger.info(f"Copied prompt to clipboard: {self.current_recipe.name}") # Show brief feedback self.statusBar().showMessage("Prompt copied to clipboard", 2000) def on_manage_outputs(self) -> None: """Handle manage outputs action.""" from src.ui.output_manager import OutputManager if not self.current_recipe: QMessageBox.warning(self, "No Selection", "Please select a recipe to manage outputs.") return try: # Open output manager dialog manager = OutputManager(self.db, self.current_recipe, parent=self) manager.exec() # Reload recent outputs after dialog closes self._load_recent_outputs(self.current_recipe.id) logger.info(f"Output manager closed for: {self.current_recipe.name}") except Exception as e: logger.error(f"Error opening output manager: {e}", exc_info=True) QMessageBox.critical(self, "Error", f"Failed to open output manager: {e}") def on_output_double_clicked(self, item: QListWidgetItem) -> None: """Handle double-click on output item (opens file). Args: item: The clicked list item. """ from src.services.output_service import OutputService output_id = item.data(Qt.ItemDataRole.UserRole) if output_id: try: output_service = OutputService(self.db) success = output_service.open_output(output_id) if not success: QMessageBox.warning( self, "Error", "Failed to open output file.\n\n" "The file may not exist or no application is configured to open this file type." ) logger.info(f"Output double-clicked and opened: {output_id}") except Exception as e: logger.error(f"Error opening output: {e}", exc_info=True) QMessageBox.critical(self, "Error", f"Failed to open output: {e}") def _restore_geometry(self) -> None: """Restore window geometry from settings.""" settings = QSettings() geometry = settings.value("window_geometry") if geometry: self.restoreGeometry(geometry) logger.debug("Window geometry restored") def closeEvent(self, event) -> None: """Handle window close event. Args: event: Close event. """ # Save window geometry settings = QSettings() settings.setValue("window_geometry", self.saveGeometry()) logger.debug("Window geometry saved") # Close database connection try: self.db.close() logger.info("Database connection closed") except Exception as e: logger.error(f"Error closing database: {e}") event.accept()