Coverage for pyPhotoAlbum/main.py: 0%
222 statements
« prev ^ index » next coverage.py v7.14.1, created at 2026-06-20 12:55 +0000
« prev ^ index » next coverage.py v7.14.1, created at 2026-06-20 12:55 +0000
1#!/usr/bin/env python3
2"""
3Refactored main application entry point for pyPhotoAlbum
5This version uses the mixin architecture with auto-generated ribbon configuration.
6"""
8import sys
9from datetime import datetime
10from pathlib import Path
11from PyQt6.QtWidgets import (
12 QApplication,
13 QMainWindow,
14 QVBoxLayout,
15 QWidget,
16 QStatusBar,
17 QScrollBar,
18 QHBoxLayout,
19 QMessageBox,
20)
21from PyQt6.QtCore import Qt, QSize, QTimer
22from PyQt6.QtGui import QIcon
24from pyPhotoAlbum.project import Project
25from pyPhotoAlbum.template_manager import TemplateManager
26from pyPhotoAlbum.ribbon_widget import RibbonWidget
27from pyPhotoAlbum.ribbon_builder import build_ribbon_config, print_ribbon_summary
28from pyPhotoAlbum.gl_widget import GLWidget
29from pyPhotoAlbum.autosave_manager import AutosaveManager
30from pyPhotoAlbum.thumbnail_browser import ThumbnailBrowserDock
32# Import mixins
33from pyPhotoAlbum.mixins.base import ApplicationStateMixin
34from pyPhotoAlbum.mixins.asset_path import AssetPathMixin
35from pyPhotoAlbum.mixins.operations import (
36 FileOperationsMixin,
37 EditOperationsMixin,
38 ElementOperationsMixin,
39 PageOperationsMixin,
40 TemplateOperationsMixin,
41 ViewOperationsMixin,
42 AlignmentOperationsMixin,
43 DistributionOperationsMixin,
44 SizeOperationsMixin,
45 ZOrderOperationsMixin,
46 MergeOperationsMixin,
47 StyleOperationsMixin,
48)
51class MainWindow(
52 QMainWindow,
53 ApplicationStateMixin,
54 AssetPathMixin,
55 FileOperationsMixin,
56 EditOperationsMixin,
57 ElementOperationsMixin,
58 PageOperationsMixin,
59 TemplateOperationsMixin,
60 ViewOperationsMixin,
61 AlignmentOperationsMixin,
62 DistributionOperationsMixin,
63 SizeOperationsMixin,
64 ZOrderOperationsMixin,
65 MergeOperationsMixin,
66 StyleOperationsMixin,
67):
68 """
69 Main application window using mixin architecture.
71 This class composes functionality from multiple mixins rather than
72 implementing everything directly. The ribbon configuration is
73 automatically generated from decorated methods in the mixins.
74 """
76 def __init__(self):
77 super().__init__()
79 # Initialize autosave manager
80 self._autosave_manager = AutosaveManager()
82 # Initialize shared state first
83 self._init_state()
85 # Initialize UI
86 self._init_ui()
88 # Check for checkpoint recovery
89 self._check_checkpoint_recovery()
91 # Setup autosave timer (every 5 minutes)
92 self._autosave_timer = QTimer(self)
93 self._autosave_timer.timeout.connect(self._perform_autosave)
94 self._autosave_timer.start(5 * 60 * 1000) # 5 minutes in milliseconds
96 # Add a sample page for demonstration
97 # self._add_sample_page()
99 def _init_state(self):
100 """Initialize shared application state"""
101 # Initialize project
102 self._project = Project("My Photo Album")
104 # Set asset resolution context
105 from pyPhotoAlbum.models import set_asset_resolution_context
107 set_asset_resolution_context(self._project.folder_path)
109 # Initialize template manager
110 self._template_manager = TemplateManager()
112 def _init_ui(self):
113 """Initialize user interface"""
114 # Basic window setup
115 self.setWindowTitle("pyPhotoAlbum")
116 self.resize(1200, 800)
118 # Set window icon
119 icon_path = Path(__file__).parent / "icons" / "icon.png"
120 print(f"Window icon path: {icon_path}")
121 print(f"Icon exists: {icon_path.exists()}")
122 if icon_path.exists():
123 icon = QIcon(str(icon_path))
124 print(f"Icon is null: {icon.isNull()}")
125 self.setWindowIcon(icon)
127 # Create main widget with layout
128 main_widget = QWidget()
129 main_layout = QVBoxLayout()
130 main_layout.setContentsMargins(0, 0, 0, 0)
131 main_layout.setSpacing(0)
132 main_widget.setLayout(main_layout)
134 # Build ribbon config from decorated methods
135 ribbon_config = build_ribbon_config(self.__class__)
137 # Print summary (for debugging)
138 print_ribbon_summary(ribbon_config)
140 # Create ribbon with auto-generated config
141 self.ribbon = RibbonWidget(self, ribbon_config)
142 main_layout.addWidget(self.ribbon, 0)
144 # Create canvas area with GL widget and scroll bars
145 canvas_widget = QWidget()
146 canvas_layout = QVBoxLayout()
147 canvas_layout.setContentsMargins(0, 0, 0, 0)
148 canvas_layout.setSpacing(0)
150 # Top row: GL widget + vertical scrollbar
151 top_layout = QHBoxLayout()
152 top_layout.setContentsMargins(0, 0, 0, 0)
153 top_layout.setSpacing(0)
155 # Create OpenGL widget
156 self._gl_widget = GLWidget(self)
157 top_layout.addWidget(self._gl_widget, 1)
159 # Vertical scrollbar
160 self._v_scrollbar = QScrollBar(Qt.Orientation.Vertical)
161 self._v_scrollbar.setRange(-10000, 10000)
162 self._v_scrollbar.setValue(0)
163 self._v_scrollbar.valueChanged.connect(self._on_vertical_scroll)
164 top_layout.addWidget(self._v_scrollbar, 0)
166 canvas_layout.addLayout(top_layout, 1)
168 # Bottom row: horizontal scrollbar
169 self._h_scrollbar = QScrollBar(Qt.Orientation.Horizontal)
170 self._h_scrollbar.setRange(-10000, 10000)
171 self._h_scrollbar.setValue(0)
172 self._h_scrollbar.valueChanged.connect(self._on_horizontal_scroll)
173 canvas_layout.addWidget(self._h_scrollbar, 0)
175 canvas_widget.setLayout(canvas_layout)
176 main_layout.addWidget(canvas_widget, 1)
178 self.setCentralWidget(main_widget)
180 # Create thumbnail browser dock
181 self._thumbnail_browser = ThumbnailBrowserDock(self)
182 self.addDockWidget(Qt.DockWidgetArea.RightDockWidgetArea, self._thumbnail_browser)
183 self._thumbnail_browser.hide() # Initially hidden
185 # Create status bar
186 self._status_bar = QStatusBar()
187 self.setStatusBar(self._status_bar)
189 # Register keyboard shortcuts
190 self._register_shortcuts()
192 # Track scrollbar updates to prevent feedback loops
193 self._updating_scrollbars = False
194 # Track scrollbar visibility changes to prevent resize-triggered recentering
195 self._updating_scrollbar_visibility = False
197 def _on_vertical_scroll(self, value):
198 """Handle vertical scrollbar changes"""
199 if not self._updating_scrollbars:
200 # Invert scrollbar value to pan offset (scrolling down = negative pan)
201 self._gl_widget.pan_offset[1] = -value
202 self._gl_widget.update()
204 def _on_horizontal_scroll(self, value):
205 """Handle horizontal scrollbar changes"""
206 if not self._updating_scrollbars:
207 # Invert scrollbar value to pan offset (scrolling right = negative pan)
208 self._gl_widget.pan_offset[0] = -value
209 self._gl_widget.update()
211 def update_scrollbars(self):
212 """Update scrollbar positions and ranges based on current content and pan offset"""
213 self._updating_scrollbars = True
215 # Block signals to prevent feedback loop
216 self._v_scrollbar.blockSignals(True)
217 self._h_scrollbar.blockSignals(True)
219 # Get content bounds
220 bounds = self._gl_widget.get_content_bounds()
221 viewport_width = self._gl_widget.width()
222 viewport_height = self._gl_widget.height()
224 content_height = bounds["height"]
225 content_width = bounds["width"]
227 # Vertical scrollbar
228 # Scrollbar value 0 = top of content
229 # Scrollbar value max = bottom of content
230 # Pan offset is inverted: positive pan = content moved down = view at top
231 # negative pan = content moved up = view at bottom
232 v_range = int(max(0, content_height - viewport_height))
233 self._v_scrollbar.setRange(0, v_range)
234 self._v_scrollbar.setPageStep(int(viewport_height))
235 # Invert pan_offset for scrollbar position
236 self._v_scrollbar.setValue(int(max(0, min(v_range, -self._gl_widget.pan_offset[1]))))
238 # Show/hide vertical scrollbar based on whether scrolling is needed
239 # Set flag to prevent resizeGL from recentering when scrollbar visibility changes
240 self._updating_scrollbar_visibility = True
241 self._v_scrollbar.setVisible(v_range > 0)
243 # Horizontal scrollbar
244 h_range = int(max(0, content_width - viewport_width))
245 self._h_scrollbar.setRange(0, h_range)
246 self._h_scrollbar.setPageStep(int(viewport_width))
247 # Invert pan_offset for scrollbar position
248 self._h_scrollbar.setValue(int(max(0, min(h_range, -self._gl_widget.pan_offset[0]))))
250 # Show/hide horizontal scrollbar based on whether scrolling is needed
251 self._h_scrollbar.setVisible(h_range > 0)
252 self._updating_scrollbar_visibility = False
254 # Unblock signals
255 self._v_scrollbar.blockSignals(False)
256 self._h_scrollbar.blockSignals(False)
258 self._updating_scrollbars = False
260 def _register_shortcuts(self):
261 """Register keyboard shortcuts from decorated methods"""
262 from PyQt6.QtGui import QShortcut, QKeySequence
263 from pyPhotoAlbum.ribbon_builder import get_keyboard_shortcuts
265 shortcuts = get_keyboard_shortcuts(self.__class__)
267 for shortcut_str, method_name in shortcuts.items():
268 if hasattr(self, method_name):
269 shortcut = QShortcut(QKeySequence(shortcut_str), self)
270 method = getattr(self, method_name)
271 shortcut.activated.connect(method)
272 print(f"Registered shortcut: {shortcut_str} -> {method_name}")
274 # Register additional Ctrl+Shift+Z shortcut for redo
275 if hasattr(self, "redo"):
276 redo_shortcut = QShortcut(QKeySequence("Ctrl+Shift+Z"), self)
277 redo_shortcut.activated.connect(self.redo)
278 print("Registered shortcut: Ctrl+Shift+Z -> redo")
280 def resizeEvent(self, event):
281 """Handle window resize to reposition loading widget"""
282 super().resizeEvent(event)
283 if hasattr(self, "_loading_widget"):
284 self._loading_widget.resizeParent()
286 def _add_sample_page(self):
287 """Add a sample page with some elements for demonstration"""
288 from pyPhotoAlbum.project import Page
289 from pyPhotoAlbum.page_layout import PageLayout, GridLayout
290 from pyPhotoAlbum.models import ImageData, TextBoxData, PlaceholderData
292 # Create a page with project default size
293 width_mm, height_mm = self.project.page_size_mm
294 page_layout = PageLayout(width=width_mm, height=height_mm)
295 grid = GridLayout(rows=2, columns=2, spacing=20.0)
296 page_layout.set_grid_layout(grid)
298 # Add some sample elements (scaled to new default size)
299 image = ImageData(image_path="sample.jpg", x=20, y=20, width=50, height=50)
300 page_layout.add_element(image)
302 text_box = TextBoxData(text_content="Sample Text", x=80, y=20, width=50, height=20)
303 page_layout.add_element(text_box)
305 placeholder = PlaceholderData(placeholder_type="image", x=20, y=80, width=50, height=50)
306 page_layout.add_element(placeholder)
308 # Create and add the page
309 page = Page(layout=page_layout, page_number=1)
310 page.manually_sized = False # Not manually sized, uses defaults
311 self.project.add_page(page)
313 def _perform_autosave(self):
314 """Perform automatic checkpoint save"""
315 if self.project and self.project.is_dirty():
316 success, message = self._autosave_manager.create_checkpoint(self.project)
317 if success:
318 print(f"Autosave: {message}")
319 else:
320 print(f"Autosave failed: {message}")
322 def _check_checkpoint_recovery(self):
323 """Check for available checkpoints on startup and offer recovery"""
324 if not self._autosave_manager.has_checkpoints():
325 return
327 # Get the latest checkpoint
328 checkpoint_info = self._autosave_manager.get_latest_checkpoint()
329 if not checkpoint_info:
330 return
332 checkpoint_path, metadata = checkpoint_info
333 project_name = metadata.get("project_name", "Unknown")
334 timestamp_str = metadata.get("timestamp", "Unknown time")
336 # Parse timestamp for better display
337 try:
338 timestamp = datetime.fromisoformat(timestamp_str)
339 time_display = timestamp.strftime("%Y-%m-%d %H:%M:%S")
340 except:
341 time_display = timestamp_str
343 # Ask user if they want to recover
344 reply = QMessageBox.question(
345 self,
346 "Checkpoint Recovery",
347 f"A checkpoint was found:\n\n"
348 f"Project: {project_name}\n"
349 f"Time: {time_display}\n\n"
350 f"Would you like to recover this checkpoint?",
351 QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No,
352 QMessageBox.StandardButton.Yes,
353 )
355 if reply == QMessageBox.StandardButton.Yes:
356 # Load the checkpoint
357 success, result = self._autosave_manager.load_checkpoint(checkpoint_path)
359 if success:
360 # Replace current project with recovered one
361 if hasattr(self, "_project") and self._project:
362 self._project.cleanup()
364 self._project = result
365 self.gl_widget.current_page_index = 0
366 self.update_view()
368 self.show_status(f"Recovered checkpoint: {project_name}")
369 print(f"Successfully recovered checkpoint: {project_name}")
370 else:
371 error_msg = f"Failed to recover checkpoint: {result}"
372 self.show_error("Recovery Failed", error_msg)
373 print(error_msg)
375 def closeEvent(self, event):
376 """Handle window close event"""
377 # Check if project has unsaved changes
378 if self.project and self.project.is_dirty():
379 reply = QMessageBox.question(
380 self,
381 "Unsaved Changes",
382 "You have unsaved changes. Would you like to save before exiting?",
383 QMessageBox.StandardButton.Save
384 | QMessageBox.StandardButton.Discard
385 | QMessageBox.StandardButton.Cancel,
386 QMessageBox.StandardButton.Save,
387 )
389 if reply == QMessageBox.StandardButton.Save:
390 # Save is async — ignore event and let on_complete trigger close
391 self._pending_close = True
392 save_started = self.save_project()
393 if not save_started:
394 # User cancelled the file dialog
395 self._pending_close = False
396 event.ignore()
397 return
398 elif reply == QMessageBox.StandardButton.Cancel:
399 # User cancelled exit
400 event.ignore()
401 return
402 # If Discard, continue with exit
404 # Clean up checkpoints on successful exit
405 if self.project:
406 self._autosave_manager.delete_all_checkpoints(self.project.name)
407 self.project.cleanup()
409 # Stop autosave timer
410 if hasattr(self, "_autosave_timer"):
411 self._autosave_timer.stop()
413 # Cleanup old checkpoints
414 self._autosave_manager.cleanup_old_checkpoints()
416 event.accept()
419def main():
420 """Application entry point"""
421 app = QApplication(sys.argv)
423 # Set application identity for proper taskbar/window manager integration
424 app.setApplicationName("pyPhotoAlbum")
425 app.setApplicationDisplayName("pyPhotoAlbum")
426 app.setDesktopFileName("pyphotoalbum.desktop")
428 # Set application icon
429 icon_path = Path(__file__).parent / "icons" / "icon.png"
430 print(f"Application icon path: {icon_path}")
431 print(f"Icon exists: {icon_path.exists()}")
432 if icon_path.exists():
433 icon = QIcon(str(icon_path))
434 print(f"Icon is null: {icon.isNull()}")
435 app.setWindowIcon(icon)
437 # Enable high DPI scaling
438 try:
439 app.setAttribute(Qt.ApplicationAttribute.AA_EnableHighDpiScaling, True)
440 app.setAttribute(Qt.ApplicationAttribute.AA_UseHighDpiPixmaps, True)
441 except AttributeError:
442 pass # Qt version doesn't support these attributes
444 window = MainWindow()
445 window.show()
447 sys.exit(app.exec())
450if __name__ == "__main__":
451 main()