Coverage for pyPhotoAlbum/main.py: 0%

222 statements  

« 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 

4 

5This version uses the mixin architecture with auto-generated ribbon configuration. 

6""" 

7 

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 

23 

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 

31 

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) 

49 

50 

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. 

70 

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

75 

76 def __init__(self): 

77 super().__init__() 

78 

79 # Initialize autosave manager 

80 self._autosave_manager = AutosaveManager() 

81 

82 # Initialize shared state first 

83 self._init_state() 

84 

85 # Initialize UI 

86 self._init_ui() 

87 

88 # Check for checkpoint recovery 

89 self._check_checkpoint_recovery() 

90 

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 

95 

96 # Add a sample page for demonstration 

97 # self._add_sample_page() 

98 

99 def _init_state(self): 

100 """Initialize shared application state""" 

101 # Initialize project 

102 self._project = Project("My Photo Album") 

103 

104 # Set asset resolution context 

105 from pyPhotoAlbum.models import set_asset_resolution_context 

106 

107 set_asset_resolution_context(self._project.folder_path) 

108 

109 # Initialize template manager 

110 self._template_manager = TemplateManager() 

111 

112 def _init_ui(self): 

113 """Initialize user interface""" 

114 # Basic window setup 

115 self.setWindowTitle("pyPhotoAlbum") 

116 self.resize(1200, 800) 

117 

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) 

126 

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) 

133 

134 # Build ribbon config from decorated methods 

135 ribbon_config = build_ribbon_config(self.__class__) 

136 

137 # Print summary (for debugging) 

138 print_ribbon_summary(ribbon_config) 

139 

140 # Create ribbon with auto-generated config 

141 self.ribbon = RibbonWidget(self, ribbon_config) 

142 main_layout.addWidget(self.ribbon, 0) 

143 

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) 

149 

150 # Top row: GL widget + vertical scrollbar 

151 top_layout = QHBoxLayout() 

152 top_layout.setContentsMargins(0, 0, 0, 0) 

153 top_layout.setSpacing(0) 

154 

155 # Create OpenGL widget 

156 self._gl_widget = GLWidget(self) 

157 top_layout.addWidget(self._gl_widget, 1) 

158 

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) 

165 

166 canvas_layout.addLayout(top_layout, 1) 

167 

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) 

174 

175 canvas_widget.setLayout(canvas_layout) 

176 main_layout.addWidget(canvas_widget, 1) 

177 

178 self.setCentralWidget(main_widget) 

179 

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 

184 

185 # Create status bar 

186 self._status_bar = QStatusBar() 

187 self.setStatusBar(self._status_bar) 

188 

189 # Register keyboard shortcuts 

190 self._register_shortcuts() 

191 

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 

196 

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

203 

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

210 

211 def update_scrollbars(self): 

212 """Update scrollbar positions and ranges based on current content and pan offset""" 

213 self._updating_scrollbars = True 

214 

215 # Block signals to prevent feedback loop 

216 self._v_scrollbar.blockSignals(True) 

217 self._h_scrollbar.blockSignals(True) 

218 

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

223 

224 content_height = bounds["height"] 

225 content_width = bounds["width"] 

226 

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])))) 

237 

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) 

242 

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])))) 

249 

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 

253 

254 # Unblock signals 

255 self._v_scrollbar.blockSignals(False) 

256 self._h_scrollbar.blockSignals(False) 

257 

258 self._updating_scrollbars = False 

259 

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 

264 

265 shortcuts = get_keyboard_shortcuts(self.__class__) 

266 

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

273 

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

279 

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

285 

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 

291 

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) 

297 

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) 

301 

302 text_box = TextBoxData(text_content="Sample Text", x=80, y=20, width=50, height=20) 

303 page_layout.add_element(text_box) 

304 

305 placeholder = PlaceholderData(placeholder_type="image", x=20, y=80, width=50, height=50) 

306 page_layout.add_element(placeholder) 

307 

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) 

312 

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

321 

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 

326 

327 # Get the latest checkpoint 

328 checkpoint_info = self._autosave_manager.get_latest_checkpoint() 

329 if not checkpoint_info: 

330 return 

331 

332 checkpoint_path, metadata = checkpoint_info 

333 project_name = metadata.get("project_name", "Unknown") 

334 timestamp_str = metadata.get("timestamp", "Unknown time") 

335 

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 

342 

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 ) 

354 

355 if reply == QMessageBox.StandardButton.Yes: 

356 # Load the checkpoint 

357 success, result = self._autosave_manager.load_checkpoint(checkpoint_path) 

358 

359 if success: 

360 # Replace current project with recovered one 

361 if hasattr(self, "_project") and self._project: 

362 self._project.cleanup() 

363 

364 self._project = result 

365 self.gl_widget.current_page_index = 0 

366 self.update_view() 

367 

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) 

374 

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 ) 

388 

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 

403 

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

408 

409 # Stop autosave timer 

410 if hasattr(self, "_autosave_timer"): 

411 self._autosave_timer.stop() 

412 

413 # Cleanup old checkpoints 

414 self._autosave_manager.cleanup_old_checkpoints() 

415 

416 event.accept() 

417 

418 

419def main(): 

420 """Application entry point""" 

421 app = QApplication(sys.argv) 

422 

423 # Set application identity for proper taskbar/window manager integration 

424 app.setApplicationName("pyPhotoAlbum") 

425 app.setApplicationDisplayName("pyPhotoAlbum") 

426 app.setDesktopFileName("pyphotoalbum.desktop") 

427 

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) 

436 

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 

443 

444 window = MainWindow() 

445 window.show() 

446 

447 sys.exit(app.exec()) 

448 

449 

450if __name__ == "__main__": 

451 main()