1 #!/usr/bin/env python3

2 """

3 Refactored main application entry point for pyPhotoAlbum

4

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

6 """

7

8 import sys

9 from datetime import datetime

10 from pathlib import Path

11 from PyQt6.QtWidgets import (

12 QApplication,

13 QMainWindow,

14 QVBoxLayout,

15 QWidget,

16 QStatusBar,

17 QScrollBar,

18 QHBoxLayout,

19 QMessageBox,

20 )

21 from PyQt6.QtCore import Qt, QSize, QTimer

22 from PyQt6.QtGui import QIcon

23

24 from pyPhotoAlbum.project import Project

25 from pyPhotoAlbum.template_manager import TemplateManager

26 from pyPhotoAlbum.ribbon_widget import RibbonWidget

27 from pyPhotoAlbum.ribbon_builder import build_ribbon_config, print_ribbon_summary

28 from pyPhotoAlbum.gl_widget import GLWidget

29 from pyPhotoAlbum.autosave_manager import AutosaveManager

30 from pyPhotoAlbum.thumbnail_browser import ThumbnailBrowserDock

31

32 # Import mixins

33 from pyPhotoAlbum.mixins.base import ApplicationStateMixin

34 from pyPhotoAlbum.mixins.asset_path import AssetPathMixin

35 from 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

51 class 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

419 def 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

450 if __name__ == "__main__":

451 main()