443 lines
17 KiB
Python

#!/usr/bin/env python3
"""
Refactored main application entry point for pyPhotoAlbum
This version uses the mixin architecture with auto-generated ribbon configuration.
"""
import sys
from pathlib import Path
from PyQt6.QtWidgets import (
QApplication, QMainWindow, QVBoxLayout, QWidget,
QStatusBar, QScrollBar, QHBoxLayout, QMessageBox
)
from PyQt6.QtCore import Qt, QSize, QTimer
from PyQt6.QtGui import QIcon
from pyPhotoAlbum.project import Project
from pyPhotoAlbum.template_manager import TemplateManager
from pyPhotoAlbum.ribbon_widget import RibbonWidget
from pyPhotoAlbum.ribbon_builder import build_ribbon_config, print_ribbon_summary
from pyPhotoAlbum.gl_widget import GLWidget
from pyPhotoAlbum.autosave_manager import AutosaveManager
# Import mixins
from pyPhotoAlbum.mixins.base import ApplicationStateMixin
from pyPhotoAlbum.mixins.operations import (
FileOperationsMixin,
EditOperationsMixin,
ElementOperationsMixin,
PageOperationsMixin,
TemplateOperationsMixin,
ViewOperationsMixin,
AlignmentOperationsMixin,
DistributionOperationsMixin,
SizeOperationsMixin,
ZOrderOperationsMixin,
MergeOperationsMixin,
)
class MainWindow(
QMainWindow,
ApplicationStateMixin,
FileOperationsMixin,
EditOperationsMixin,
ElementOperationsMixin,
PageOperationsMixin,
TemplateOperationsMixin,
ViewOperationsMixin,
AlignmentOperationsMixin,
DistributionOperationsMixin,
SizeOperationsMixin,
ZOrderOperationsMixin,
MergeOperationsMixin,
):
"""
Main application window using mixin architecture.
This class composes functionality from multiple mixins rather than
implementing everything directly. The ribbon configuration is
automatically generated from decorated methods in the mixins.
"""
def __init__(self):
super().__init__()
# Initialize autosave manager
self._autosave_manager = AutosaveManager()
# Initialize shared state first
self._init_state()
# Initialize UI
self._init_ui()
# Check for checkpoint recovery
self._check_checkpoint_recovery()
# Setup autosave timer (every 5 minutes)
self._autosave_timer = QTimer(self)
self._autosave_timer.timeout.connect(self._perform_autosave)
self._autosave_timer.start(5 * 60 * 1000) # 5 minutes in milliseconds
# Add a sample page for demonstration
#self._add_sample_page()
def _init_state(self):
"""Initialize shared application state"""
# Initialize project
self._project = Project("My Photo Album")
# Set asset resolution context
from pyPhotoAlbum.models import set_asset_resolution_context
set_asset_resolution_context(self._project.folder_path)
# Initialize template manager
self._template_manager = TemplateManager()
def _init_ui(self):
"""Initialize user interface"""
# Basic window setup
self.setWindowTitle("pyPhotoAlbum")
self.resize(1200, 800)
# Set window icon
icon_path = Path(__file__).parent / "icons" / "icon.png"
print(f"Window icon path: {icon_path}")
print(f"Icon exists: {icon_path.exists()}")
if icon_path.exists():
icon = QIcon(str(icon_path))
print(f"Icon is null: {icon.isNull()}")
self.setWindowIcon(icon)
# Create main widget with layout
main_widget = QWidget()
main_layout = QVBoxLayout()
main_layout.setContentsMargins(0, 0, 0, 0)
main_layout.setSpacing(0)
main_widget.setLayout(main_layout)
# Build ribbon config from decorated methods
ribbon_config = build_ribbon_config(self.__class__)
# Print summary (for debugging)
print_ribbon_summary(ribbon_config)
# Create ribbon with auto-generated config
self.ribbon = RibbonWidget(self, ribbon_config)
main_layout.addWidget(self.ribbon, 0)
# Create canvas area with GL widget and scroll bars
canvas_widget = QWidget()
canvas_layout = QVBoxLayout()
canvas_layout.setContentsMargins(0, 0, 0, 0)
canvas_layout.setSpacing(0)
# Top row: GL widget + vertical scrollbar
top_layout = QHBoxLayout()
top_layout.setContentsMargins(0, 0, 0, 0)
top_layout.setSpacing(0)
# Create OpenGL widget
self._gl_widget = GLWidget(self)
top_layout.addWidget(self._gl_widget, 1)
# Vertical scrollbar
self._v_scrollbar = QScrollBar(Qt.Orientation.Vertical)
self._v_scrollbar.setRange(-10000, 10000)
self._v_scrollbar.setValue(0)
self._v_scrollbar.valueChanged.connect(self._on_vertical_scroll)
top_layout.addWidget(self._v_scrollbar, 0)
canvas_layout.addLayout(top_layout, 1)
# Bottom row: horizontal scrollbar
self._h_scrollbar = QScrollBar(Qt.Orientation.Horizontal)
self._h_scrollbar.setRange(-10000, 10000)
self._h_scrollbar.setValue(0)
self._h_scrollbar.valueChanged.connect(self._on_horizontal_scroll)
canvas_layout.addWidget(self._h_scrollbar, 0)
canvas_widget.setLayout(canvas_layout)
main_layout.addWidget(canvas_widget, 1)
self.setCentralWidget(main_widget)
# Create status bar
self._status_bar = QStatusBar()
self.setStatusBar(self._status_bar)
# Register keyboard shortcuts
self._register_shortcuts()
# Track scrollbar updates to prevent feedback loops
self._updating_scrollbars = False
# Track scrollbar visibility changes to prevent resize-triggered recentering
self._updating_scrollbar_visibility = False
def _on_vertical_scroll(self, value):
"""Handle vertical scrollbar changes"""
if not self._updating_scrollbars:
# Invert scrollbar value to pan offset (scrolling down = negative pan)
self._gl_widget.pan_offset[1] = -value
self._gl_widget.update()
def _on_horizontal_scroll(self, value):
"""Handle horizontal scrollbar changes"""
if not self._updating_scrollbars:
# Invert scrollbar value to pan offset (scrolling right = negative pan)
self._gl_widget.pan_offset[0] = -value
self._gl_widget.update()
def update_scrollbars(self):
"""Update scrollbar positions and ranges based on current content and pan offset"""
print(f"UPDATE_SCROLLBARS: START - pan_offset = [{self._gl_widget.pan_offset[0]:.1f}, {self._gl_widget.pan_offset[1]:.1f}]")
self._updating_scrollbars = True
# Block signals to prevent feedback loop
signals_were_blocked_v = self._v_scrollbar.signalsBlocked()
signals_were_blocked_h = self._h_scrollbar.signalsBlocked()
self._v_scrollbar.blockSignals(True)
self._h_scrollbar.blockSignals(True)
print(f"UPDATE_SCROLLBARS: Blocked signals (v was {signals_were_blocked_v}, h was {signals_were_blocked_h})")
# Get content bounds
bounds = self._gl_widget.get_content_bounds()
viewport_width = self._gl_widget.width()
viewport_height = self._gl_widget.height()
content_height = bounds['height']
content_width = bounds['width']
print(f"UPDATE_SCROLLBARS: content={content_width:.0f}x{content_height:.0f}, viewport={viewport_width}x{viewport_height}")
# Vertical scrollbar
# Scrollbar value 0 = top of content
# Scrollbar value max = bottom of content
# Pan offset is inverted: positive pan = content moved down = view at top
# negative pan = content moved up = view at bottom
v_range = int(max(0, content_height - viewport_height))
v_value = int(max(0, min(v_range, -self._gl_widget.pan_offset[1])))
print(f"UPDATE_SCROLLBARS: Setting v_scrollbar range=0-{v_range}, value={v_value} (from pan_y={self._gl_widget.pan_offset[1]:.1f})")
self._v_scrollbar.setRange(0, v_range)
self._v_scrollbar.setPageStep(int(viewport_height))
# Invert pan_offset for scrollbar position
self._v_scrollbar.setValue(v_value)
# Show/hide vertical scrollbar based on whether scrolling is needed
# Set flag to prevent resizeGL from recentering when scrollbar visibility changes
self._updating_scrollbar_visibility = True
self._v_scrollbar.setVisible(v_range > 0)
# Horizontal scrollbar
h_range = int(max(0, content_width - viewport_width))
self._h_scrollbar.setRange(0, h_range)
self._h_scrollbar.setPageStep(int(viewport_width))
# Invert pan_offset for scrollbar position
self._h_scrollbar.setValue(int(max(0, min(h_range, -self._gl_widget.pan_offset[0]))))
# Show/hide horizontal scrollbar based on whether scrolling is needed
self._h_scrollbar.setVisible(h_range > 0)
self._updating_scrollbar_visibility = False
# Unblock signals
self._v_scrollbar.blockSignals(False)
self._h_scrollbar.blockSignals(False)
print(f"UPDATE_SCROLLBARS: Unblocked signals")
self._updating_scrollbars = False
print(f"UPDATE_SCROLLBARS: END - pan_offset = [{self._gl_widget.pan_offset[0]:.1f}, {self._gl_widget.pan_offset[1]:.1f}]")
def _register_shortcuts(self):
"""Register keyboard shortcuts from decorated methods"""
from PyQt6.QtGui import QShortcut, QKeySequence
from pyPhotoAlbum.ribbon_builder import get_keyboard_shortcuts
shortcuts = get_keyboard_shortcuts(self.__class__)
for shortcut_str, method_name in shortcuts.items():
if hasattr(self, method_name):
shortcut = QShortcut(QKeySequence(shortcut_str), self)
method = getattr(self, method_name)
shortcut.activated.connect(method)
print(f"Registered shortcut: {shortcut_str} -> {method_name}")
# Register additional Ctrl+Shift+Z shortcut for redo
if hasattr(self, 'redo'):
redo_shortcut = QShortcut(QKeySequence("Ctrl+Shift+Z"), self)
redo_shortcut.activated.connect(self.redo)
print("Registered shortcut: Ctrl+Shift+Z -> redo")
def resizeEvent(self, event):
"""Handle window resize to reposition loading widget"""
super().resizeEvent(event)
if hasattr(self, '_loading_widget'):
self._loading_widget.resizeParent()
def _add_sample_page(self):
"""Add a sample page with some elements for demonstration"""
from pyPhotoAlbum.project import Page
from pyPhotoAlbum.page_layout import PageLayout, GridLayout
from pyPhotoAlbum.models import ImageData, TextBoxData, PlaceholderData
# Create a page with project default size
width_mm, height_mm = self.project.page_size_mm
page_layout = PageLayout(width=width_mm, height=height_mm)
grid = GridLayout(rows=2, columns=2, spacing=20.0)
page_layout.set_grid_layout(grid)
# Add some sample elements (scaled to new default size)
image = ImageData(image_path="sample.jpg", x=20, y=20, width=50, height=50)
page_layout.add_element(image)
text_box = TextBoxData(text_content="Sample Text", x=80, y=20, width=50, height=20)
page_layout.add_element(text_box)
placeholder = PlaceholderData(placeholder_type="image", x=20, y=80, width=50, height=50)
page_layout.add_element(placeholder)
# Create and add the page
page = Page(layout=page_layout, page_number=1)
page.manually_sized = False # Not manually sized, uses defaults
self.project.add_page(page)
def _perform_autosave(self):
"""Perform automatic checkpoint save"""
if self.project and self.project.is_dirty():
success, message = self._autosave_manager.create_checkpoint(self.project)
if success:
print(f"Autosave: {message}")
else:
print(f"Autosave failed: {message}")
def _check_checkpoint_recovery(self):
"""Check for available checkpoints on startup and offer recovery"""
if not self._autosave_manager.has_checkpoints():
return
# Get the latest checkpoint
checkpoint_info = self._autosave_manager.get_latest_checkpoint()
if not checkpoint_info:
return
checkpoint_path, metadata = checkpoint_info
project_name = metadata.get('project_name', 'Unknown')
timestamp_str = metadata.get('timestamp', 'Unknown time')
# Parse timestamp for better display
try:
from datetime import datetime
timestamp = datetime.fromisoformat(timestamp_str)
time_display = timestamp.strftime("%Y-%m-%d %H:%M:%S")
except:
time_display = timestamp_str
# Ask user if they want to recover
reply = QMessageBox.question(
self,
"Checkpoint Recovery",
f"A checkpoint was found:\n\n"
f"Project: {project_name}\n"
f"Time: {time_display}\n\n"
f"Would you like to recover this checkpoint?",
QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No,
QMessageBox.StandardButton.Yes
)
if reply == QMessageBox.StandardButton.Yes:
# Load the checkpoint
success, result = self._autosave_manager.load_checkpoint(checkpoint_path)
if success:
# Replace current project with recovered one
if hasattr(self, '_project') and self._project:
self._project.cleanup()
self._project = result
self.gl_widget.current_page_index = 0
self.update_view()
self.show_status(f"Recovered checkpoint: {project_name}")
print(f"Successfully recovered checkpoint: {project_name}")
else:
error_msg = f"Failed to recover checkpoint: {result}"
self.show_error("Recovery Failed", error_msg)
print(error_msg)
def closeEvent(self, event):
"""Handle window close event"""
# Check if project has unsaved changes
if self.project and self.project.is_dirty():
reply = QMessageBox.question(
self,
"Unsaved Changes",
"You have unsaved changes. Would you like to save before exiting?",
QMessageBox.StandardButton.Save | QMessageBox.StandardButton.Discard | QMessageBox.StandardButton.Cancel,
QMessageBox.StandardButton.Save
)
if reply == QMessageBox.StandardButton.Save:
# Trigger save
self.save_project()
# Check if save was successful (project should be clean now)
if self.project.is_dirty():
# User cancelled save dialog or save failed
event.ignore()
return
elif reply == QMessageBox.StandardButton.Cancel:
# User cancelled exit
event.ignore()
return
# If Discard, continue with exit
# Clean up checkpoints on successful exit
if self.project:
self._autosave_manager.delete_all_checkpoints(self.project.name)
self.project.cleanup()
# Stop autosave timer
if hasattr(self, '_autosave_timer'):
self._autosave_timer.stop()
# Cleanup old checkpoints
self._autosave_manager.cleanup_old_checkpoints()
event.accept()
def main():
"""Application entry point"""
app = QApplication(sys.argv)
# Set application identity for proper taskbar/window manager integration
app.setApplicationName("pyPhotoAlbum")
app.setApplicationDisplayName("pyPhotoAlbum")
app.setDesktopFileName("pyphotoalbum.desktop")
# Set application icon
icon_path = Path(__file__).parent / "icons" / "icon.png"
print(f"Application icon path: {icon_path}")
print(f"Icon exists: {icon_path.exists()}")
if icon_path.exists():
icon = QIcon(str(icon_path))
print(f"Icon is null: {icon.isNull()}")
app.setWindowIcon(icon)
# Enable high DPI scaling
try:
app.setAttribute(Qt.ApplicationAttribute.AA_EnableHighDpiScaling, True)
app.setAttribute(Qt.ApplicationAttribute.AA_UseHighDpiPixmaps, True)
except AttributeError:
pass # Qt version doesn't support these attributes
window = MainWindow()
window.show()
sys.exit(app.exec())
if __name__ == "__main__":
main()