""" File operations mixin for pyPhotoAlbum """ from PyQt6.QtWidgets import ( QFileDialog, QDialog, QVBoxLayout, QHBoxLayout, QLabel, QDoubleSpinBox, QSpinBox, QPushButton, QGroupBox, QRadioButton, QButtonGroup, QLineEdit, QTextEdit ) from pyPhotoAlbum.decorators import ribbon_action, numerical_input from pyPhotoAlbum.project import Project, Page from pyPhotoAlbum.async_project_loader import AsyncProjectLoader from pyPhotoAlbum.loading_widget import LoadingWidget from pyPhotoAlbum.project_serializer import save_to_zip from pyPhotoAlbum.models import set_asset_resolution_context from pyPhotoAlbum.version_manager import format_version_info, CURRENT_DATA_VERSION from pyPhotoAlbum.asset_heal_dialog import AssetHealDialog class FileOperationsMixin: """Mixin providing file-related operations""" @ribbon_action( label="New", tooltip="Create a new project", tab="Home", group="File", shortcut="Ctrl+N" ) def new_project(self): """Create a new project with initial setup dialog""" # Create new project setup dialog dialog = QDialog(self) dialog.setWindowTitle("New Project Setup") dialog.setMinimumWidth(450) layout = QVBoxLayout() # Project name group name_group = QGroupBox("Project Name") name_layout = QVBoxLayout() name_input = QLineEdit() name_input.setText("New Project") name_input.selectAll() name_layout.addWidget(name_input) name_group.setLayout(name_layout) layout.addWidget(name_group) # Default page size group size_group = QGroupBox("Default Page Size") size_layout = QVBoxLayout() info_label = QLabel("This will be the default size for all new pages in this project.") info_label.setWordWrap(True) info_label.setStyleSheet("font-size: 9pt; color: gray;") size_layout.addWidget(info_label) # Width width_layout = QHBoxLayout() width_layout.addWidget(QLabel("Width:")) width_spinbox = QDoubleSpinBox() width_spinbox.setRange(10, 1000) width_spinbox.setValue(140) # Default 14cm width_spinbox.setSuffix(" mm") width_layout.addWidget(width_spinbox) size_layout.addLayout(width_layout) # Height height_layout = QHBoxLayout() height_layout.addWidget(QLabel("Height:")) height_spinbox = QDoubleSpinBox() height_spinbox.setRange(10, 1000) height_spinbox.setValue(140) # Default 14cm height_spinbox.setSuffix(" mm") height_layout.addWidget(height_spinbox) size_layout.addLayout(height_layout) # Add common size presets presets_layout = QHBoxLayout() presets_layout.addWidget(QLabel("Presets:")) def set_preset(w, h): width_spinbox.setValue(w) height_spinbox.setValue(h) preset_a4 = QPushButton("A4 (210×297)") preset_a4.clicked.connect(lambda: set_preset(210, 297)) presets_layout.addWidget(preset_a4) preset_a5 = QPushButton("A5 (148×210)") preset_a5.clicked.connect(lambda: set_preset(148, 210)) presets_layout.addWidget(preset_a5) preset_square = QPushButton("Square (200×200)") preset_square.clicked.connect(lambda: set_preset(200, 200)) presets_layout.addWidget(preset_square) presets_layout.addStretch() size_layout.addLayout(presets_layout) size_group.setLayout(size_layout) layout.addWidget(size_group) # DPI settings group dpi_group = QGroupBox("DPI Settings") dpi_layout = QVBoxLayout() # Working DPI working_dpi_layout = QHBoxLayout() working_dpi_layout.addWidget(QLabel("Working DPI:")) working_dpi_spinbox = QSpinBox() working_dpi_spinbox.setRange(72, 1200) working_dpi_spinbox.setValue(300) working_dpi_layout.addWidget(working_dpi_spinbox) dpi_layout.addLayout(working_dpi_layout) # Export DPI export_dpi_layout = QHBoxLayout() export_dpi_layout.addWidget(QLabel("Export DPI:")) export_dpi_spinbox = QSpinBox() export_dpi_spinbox.setRange(72, 1200) export_dpi_spinbox.setValue(300) export_dpi_layout.addWidget(export_dpi_spinbox) dpi_layout.addLayout(export_dpi_layout) dpi_group.setLayout(dpi_layout) layout.addWidget(dpi_group) # Buttons button_layout = QHBoxLayout() cancel_btn = QPushButton("Cancel") cancel_btn.clicked.connect(dialog.reject) create_btn = QPushButton("Create Project") create_btn.clicked.connect(dialog.accept) create_btn.setDefault(True) button_layout.addStretch() button_layout.addWidget(cancel_btn) button_layout.addWidget(create_btn) layout.addLayout(button_layout) dialog.setLayout(layout) # Show dialog if dialog.exec() == QDialog.DialogCode.Accepted: # Get values project_name = name_input.text().strip() or "New Project" width_mm = width_spinbox.value() height_mm = height_spinbox.value() working_dpi = working_dpi_spinbox.value() export_dpi = export_dpi_spinbox.value() # Cleanup old project if it exists if hasattr(self, 'project') and self.project: self.project.cleanup() # Create project with custom settings self.project = Project(project_name) self.project.page_size_mm = (width_mm, height_mm) self.project.working_dpi = working_dpi self.project.export_dpi = export_dpi # Set asset resolution context set_asset_resolution_context(self.project.folder_path) # Update view self.update_view() self.show_status(f"New project created: {project_name} ({width_mm}×{height_mm} mm)") print(f"New project created: {project_name}, default page size: {width_mm}×{height_mm} mm") else: # User cancelled - keep current project print("New project creation cancelled") @ribbon_action( label="Open", tooltip="Open an existing project", tab="Home", group="File", shortcut="Ctrl+O" ) def open_project(self): """Open an existing project with async loading and progress bar""" file_path, _ = QFileDialog.getOpenFileName( self, "Open Project", "", "pyPhotoAlbum Projects (*.ppz);;All Files (*)" ) if file_path: print(f"Opening project: {file_path}") # Create loading widget if not exists if not hasattr(self, '_loading_widget'): self._loading_widget = LoadingWidget(self) # Show loading widget self._loading_widget.show_loading("Opening project...") # Create and configure async loader self._project_loader = AsyncProjectLoader(file_path) # Connect signals self._project_loader.progress_updated.connect(self._on_load_progress) self._project_loader.load_complete.connect(self._on_load_complete) self._project_loader.load_failed.connect(self._on_load_failed) # Start async loading self._project_loader.start() def _on_load_progress(self, current: int, total: int, message: str): """Handle loading progress updates""" if hasattr(self, '_loading_widget'): self._loading_widget.set_progress(current, total) self._loading_widget.set_status(message) def _on_load_complete(self, project): """Handle successful project load""" # Cleanup old project if it exists if hasattr(self, 'project') and self.project: self.project.cleanup() # Set new project self.project = project self.gl_widget.current_page_index = 0 # Reset to first page # Hide loading widget if hasattr(self, '_loading_widget'): self._loading_widget.hide_loading() # Update view (this will trigger progressive image loading) self.update_view() self.show_status(f"Project opened: {project.name}") print(f"Successfully loaded project: {project.name}") def _on_load_failed(self, error_msg: str): """Handle project load failure""" # Hide loading widget if hasattr(self, '_loading_widget'): self._loading_widget.hide_loading() error_msg = f"Failed to open project: {error_msg}" self.show_status(error_msg) self.show_error("Load Failed", error_msg) print(error_msg) @ribbon_action( label="Save", tooltip="Save the current project", tab="Home", group="File", shortcut="Ctrl+S" ) def save_project(self): """Save the current project""" file_path, _ = QFileDialog.getSaveFileName( self, "Save Project", "", "pyPhotoAlbum Projects (*.ppz);;All Files (*)" ) if file_path: print(f"Saving project to: {file_path}") # Save project to ZIP success, error = save_to_zip(self.project, file_path) if success: self.show_status(f"Project saved: {file_path}") print(f"Successfully saved project to: {file_path}") else: error_msg = f"Failed to save project: {error}" self.show_status(error_msg) print(error_msg) @ribbon_action( label="Heal Assets", tooltip="Reconnect missing image assets", tab="Home", group="File" ) def heal_assets(self): """Open the asset healing dialog to reconnect missing images""" dialog = AssetHealDialog(self.project, self) dialog.exec() # Update the view to reflect any changes self.update_view() @ribbon_action( label="Project Settings", tooltip="Configure project-wide page size and defaults", tab="Home", group="File" ) @numerical_input( fields=[ ('width', 'Width', 'mm', 10, 1000), ('height', 'Height', 'mm', 10, 1000) ] ) def project_settings(self): """Configure project-wide settings including default page size""" # Create dialog dialog = QDialog(self) dialog.setWindowTitle("Project Settings") dialog.setMinimumWidth(500) layout = QVBoxLayout() # Page size group size_group = QGroupBox("Default Page Size") size_layout = QVBoxLayout() # Width width_layout = QHBoxLayout() width_layout.addWidget(QLabel("Width:")) width_spinbox = QDoubleSpinBox() width_spinbox.setRange(10, 1000) width_spinbox.setValue(self.project.page_size_mm[0]) width_spinbox.setSuffix(" mm") width_layout.addWidget(width_spinbox) size_layout.addLayout(width_layout) # Height height_layout = QHBoxLayout() height_layout.addWidget(QLabel("Height:")) height_spinbox = QDoubleSpinBox() height_spinbox.setRange(10, 1000) height_spinbox.setValue(self.project.page_size_mm[1]) height_spinbox.setSuffix(" mm") height_layout.addWidget(height_spinbox) size_layout.addLayout(height_layout) size_group.setLayout(size_layout) layout.addWidget(size_group) # DPI settings group dpi_group = QGroupBox("DPI Settings") dpi_layout = QVBoxLayout() # Working DPI working_dpi_layout = QHBoxLayout() working_dpi_layout.addWidget(QLabel("Working DPI:")) working_dpi_spinbox = QSpinBox() working_dpi_spinbox.setRange(72, 1200) working_dpi_spinbox.setValue(self.project.working_dpi) working_dpi_layout.addWidget(working_dpi_spinbox) dpi_layout.addLayout(working_dpi_layout) # Export DPI export_dpi_layout = QHBoxLayout() export_dpi_layout.addWidget(QLabel("Export DPI:")) export_dpi_spinbox = QSpinBox() export_dpi_spinbox.setRange(72, 1200) export_dpi_spinbox.setValue(self.project.export_dpi) export_dpi_layout.addWidget(export_dpi_spinbox) dpi_layout.addLayout(export_dpi_layout) dpi_group.setLayout(dpi_layout) layout.addWidget(dpi_group) # Content scaling options (only if pages exist and size is changing) scaling_group = None scaling_buttons = None if self.project.pages: scaling_group = QGroupBox("Apply to Existing Pages") scaling_layout = QVBoxLayout() info_label = QLabel("How should existing content be adjusted?\n(Pages with manual sizing will not be affected)") info_label.setWordWrap(True) scaling_layout.addWidget(info_label) scaling_buttons = QButtonGroup() proportional_radio = QRadioButton("Resize proportionally (fit to smallest axis)") proportional_radio.setToolTip("Scale content uniformly to fit the new page size") scaling_buttons.addButton(proportional_radio, 0) scaling_layout.addWidget(proportional_radio) stretch_radio = QRadioButton("Resize on both axes (stretch)") stretch_radio.setToolTip("Scale width and height independently") scaling_buttons.addButton(stretch_radio, 1) scaling_layout.addWidget(stretch_radio) reposition_radio = QRadioButton("Keep content size, reposition to center") reposition_radio.setToolTip("Maintain element sizes but center them on new page") scaling_buttons.addButton(reposition_radio, 2) scaling_layout.addWidget(reposition_radio) none_radio = QRadioButton("Don't adjust content (page size only)") none_radio.setToolTip("Only change page size, leave content as-is") none_radio.setChecked(True) # Default scaling_buttons.addButton(none_radio, 3) scaling_layout.addWidget(none_radio) scaling_group.setLayout(scaling_layout) layout.addWidget(scaling_group) # Buttons button_layout = QHBoxLayout() cancel_btn = QPushButton("Cancel") cancel_btn.clicked.connect(dialog.reject) ok_btn = QPushButton("OK") ok_btn.clicked.connect(dialog.accept) ok_btn.setDefault(True) button_layout.addStretch() button_layout.addWidget(cancel_btn) button_layout.addWidget(ok_btn) layout.addLayout(button_layout) dialog.setLayout(layout) # Show dialog if dialog.exec() == QDialog.DialogCode.Accepted: # Get new values new_width = width_spinbox.value() new_height = height_spinbox.value() new_working_dpi = working_dpi_spinbox.value() new_export_dpi = export_dpi_spinbox.value() # Determine scaling mode scaling_mode = 'none' if scaling_buttons: selected_id = scaling_buttons.checkedId() modes = {0: 'proportional', 1: 'stretch', 2: 'reposition', 3: 'none'} scaling_mode = modes.get(selected_id, 'none') # Apply settings old_size = self.project.page_size_mm self.project.page_size_mm = (new_width, new_height) self.project.working_dpi = new_working_dpi self.project.export_dpi = new_export_dpi # Update existing pages (exclude manually sized ones) if self.project.pages and old_size != (new_width, new_height): self._apply_page_size_to_project(old_size, (new_width, new_height), scaling_mode) self.update_view() self.show_status(f"Project settings updated: {new_width}×{new_height} mm", 2000) print(f"Project settings updated: {new_width}×{new_height} mm, scaling mode: {scaling_mode}") def _apply_page_size_to_project(self, old_size, new_size, scaling_mode): """ Apply new page size to all non-manually-sized pages Args: old_size: Old page size (width, height) in mm new_size: New page size (width, height) in mm scaling_mode: 'proportional', 'stretch', 'reposition', or 'none' """ old_width, old_height = old_size new_width, new_height = new_size width_ratio = new_width / old_width if old_width > 0 else 1.0 height_ratio = new_height / old_height if old_height > 0 else 1.0 for page in self.project.pages: # Skip manually sized pages if page.manually_sized: continue # Update page size old_page_width, old_page_height = page.layout.size # For double spreads, maintain the 2x multiplier if page.is_double_spread: page.layout.size = (new_width * 2, new_height) else: page.layout.size = (new_width, new_height) # Apply content scaling based on mode if scaling_mode == 'proportional': # Use smallest ratio to fit content scale = min(width_ratio, height_ratio) self._scale_page_elements(page, scale, scale) elif scaling_mode == 'stretch': # Scale independently on each axis self._scale_page_elements(page, width_ratio, height_ratio) elif scaling_mode == 'reposition': # Keep size, center content self._reposition_page_elements(page, old_size, new_size) # 'none' - do nothing to elements def _scale_page_elements(self, page, x_scale, y_scale): """ Scale all elements on a page Args: page: Page object x_scale: Horizontal scale factor y_scale: Vertical scale factor """ for element in page.layout.elements: # Scale position x, y = element.position element.position = (x * x_scale, y * y_scale) # Scale size width, height = element.size element.size = (width * x_scale, height * y_scale) def _reposition_page_elements(self, page, old_size, new_size): """ Reposition elements to center them on the new page size Args: page: Page object old_size: Old page size (width, height) in mm new_size: New page size (width, height) in mm """ old_width, old_height = old_size new_width, new_height = new_size x_offset = (new_width - old_width) / 2.0 y_offset = (new_height - old_height) / 2.0 for element in page.layout.elements: x, y = element.position element.position = (x + x_offset, y + y_offset) @ribbon_action( label="Export PDF", tooltip="Export project to PDF", tab="Export", group="Export" ) def export_pdf(self): """Export project to PDF using async backend (non-blocking)""" # Check if we have pages to export if not self.project or not self.project.pages: self.show_status("No pages to export") return # Show file save dialog file_path, _ = QFileDialog.getSaveFileName( self, "Export to PDF", "", "PDF Files (*.pdf);;All Files (*)" ) if not file_path: return # Ensure .pdf extension if not file_path.lower().endswith('.pdf'): file_path += '.pdf' # Use async PDF export (non-blocking, UI stays responsive) success = self.gl_widget.export_pdf_async(self.project, file_path, export_dpi=300) if success: self.show_status("PDF export started...", 2000) else: self.show_status("PDF export failed to start", 3000) @ribbon_action( label="About", tooltip="About pyPhotoAlbum and data format version", tab="Home", group="File" ) def show_about(self): """Show about dialog with version information""" dialog = QDialog(self) dialog.setWindowTitle("About pyPhotoAlbum") dialog.setMinimumWidth(600) dialog.setMinimumHeight(400) layout = QVBoxLayout() # Application info app_info = QLabel("

pyPhotoAlbum

") app_info.setWordWrap(True) layout.addWidget(app_info) description = QLabel( "A photo album layout and design application with advanced " "page composition features and PDF export capabilities." ) description.setWordWrap(True) layout.addWidget(description) # Version information version_text = QTextEdit() version_text.setReadOnly(True) version_text.setPlainText(format_version_info()) layout.addWidget(version_text) # Close button close_button = QPushButton("Close") close_button.clicked.connect(dialog.accept) layout.addWidget(close_button) dialog.setLayout(layout) dialog.exec()