443 lines
17 KiB
Python
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()
|