"""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()