1155 lines
48 KiB
Python
1155 lines
48 KiB
Python
"""
|
|
OpenGL widget for pyPhotoAlbum rendering
|
|
"""
|
|
|
|
from PyQt6.QtOpenGLWidgets import QOpenGLWidget
|
|
from PyQt6.QtCore import Qt
|
|
from OpenGL.GL import *
|
|
from pyPhotoAlbum.models import ImageData, PlaceholderData
|
|
from pyPhotoAlbum.commands import AddElementCommand
|
|
|
|
|
|
class GLWidget(QOpenGLWidget):
|
|
"""OpenGL widget for rendering pages and handling user interaction"""
|
|
|
|
def __init__(self, parent=None):
|
|
super().__init__(parent)
|
|
|
|
# Initialize OpenGL
|
|
self.setFormat(self.format())
|
|
self.setUpdateBehavior(QOpenGLWidget.UpdateBehavior.NoPartialUpdate)
|
|
|
|
# Mouse interaction state - multi-select support
|
|
self.selected_elements = set() # Set of selected elements
|
|
self.drag_start_pos = None
|
|
self.drag_start_element_positions = {} # Dict of element: start_position
|
|
self.resize_handle = None # None, 'nw', 'ne', 'sw', 'se'
|
|
self.is_dragging = False
|
|
|
|
# Snap state tracking
|
|
self.snap_state = {
|
|
'is_snapped': False,
|
|
'last_position': None,
|
|
'last_size': None
|
|
}
|
|
self.resize_start_pos = None # Element position at resize start
|
|
self.resize_start_size = None # Element size at resize start
|
|
|
|
# Rotation state
|
|
self.rotation_mode = False # Toggle between move/resize and rotation modes
|
|
self.rotation_start_angle = None # Starting rotation angle
|
|
self.rotation_snap_angle = 15 # Default snap angle in degrees
|
|
|
|
# Zoom and pan state
|
|
self.zoom_level = 1.0
|
|
self.pan_offset = [0, 0]
|
|
self.is_panning = False
|
|
self.initial_zoom_set = False # Track if we've set initial fit-to-screen zoom
|
|
|
|
# Current page tracking for operations that need to know which page to work on
|
|
self.current_page_index = 0
|
|
|
|
# Enable mouse tracking
|
|
self.setMouseTracking(True)
|
|
|
|
# Enable drag and drop
|
|
self.setAcceptDrops(True)
|
|
|
|
@property
|
|
def selected_element(self):
|
|
"""For backward compatibility - returns first selected element or None"""
|
|
return next(iter(self.selected_elements)) if self.selected_elements else None
|
|
|
|
@selected_element.setter
|
|
def selected_element(self, value):
|
|
"""For backward compatibility - sets single element selection"""
|
|
if value is None:
|
|
self.selected_elements.clear()
|
|
else:
|
|
self.selected_elements = {value}
|
|
|
|
def initializeGL(self):
|
|
"""Initialize OpenGL resources"""
|
|
glClearColor(1.0, 1.0, 1.0, 1.0)
|
|
glEnable(GL_DEPTH_TEST)
|
|
|
|
def resizeGL(self, w, h):
|
|
"""Handle window resizing"""
|
|
glViewport(0, 0, w, h)
|
|
glMatrixMode(GL_PROJECTION)
|
|
glLoadIdentity()
|
|
glOrtho(0, w, h, 0, -1, 1)
|
|
glMatrixMode(GL_MODELVIEW)
|
|
self.update()
|
|
|
|
def paintGL(self):
|
|
"""Main rendering function - renders all pages vertically"""
|
|
from pyPhotoAlbum.page_renderer import PageRenderer
|
|
|
|
glClear(GL_COLOR_BUFFER_BIT | GL_DEPTH_BUFFER_BIT)
|
|
glLoadIdentity()
|
|
|
|
main_window = self.window()
|
|
if not hasattr(main_window, 'project') or not main_window.project.pages:
|
|
return
|
|
|
|
# Set initial zoom if not done yet
|
|
if not self.initial_zoom_set:
|
|
self.zoom_level = self._calculate_fit_to_screen_zoom()
|
|
self.initial_zoom_set = True
|
|
|
|
dpi = main_window.project.working_dpi
|
|
|
|
# Calculate page positions with ghosts
|
|
page_positions = self._get_page_positions()
|
|
|
|
# Store page renderers for later use (mouse interaction, text overlays, etc.)
|
|
self._page_renderers = []
|
|
|
|
# Left margin for page rendering
|
|
PAGE_MARGIN = 50
|
|
|
|
# Render all pages
|
|
for page_info in page_positions:
|
|
page_type, page_or_ghost, y_offset = page_info
|
|
|
|
if page_type == 'page':
|
|
page = page_or_ghost
|
|
page_width_mm, page_height_mm = page.layout.size
|
|
|
|
# y_offset is in world coordinates (pixels at working DPI)
|
|
# Convert to screen coordinates by applying zoom, then add pan
|
|
screen_x = PAGE_MARGIN + self.pan_offset[0]
|
|
screen_y = (y_offset * self.zoom_level) + self.pan_offset[1]
|
|
|
|
# Create page renderer
|
|
renderer = PageRenderer(
|
|
page_width_mm=page_width_mm,
|
|
page_height_mm=page_height_mm,
|
|
screen_x=screen_x,
|
|
screen_y=screen_y,
|
|
dpi=dpi,
|
|
zoom=self.zoom_level
|
|
)
|
|
|
|
# Store renderer with page reference
|
|
self._page_renderers.append((renderer, page))
|
|
|
|
# Render the page
|
|
renderer.begin_render()
|
|
page.layout.render(dpi=dpi)
|
|
renderer.end_render()
|
|
|
|
elif page_type == 'ghost':
|
|
# Render ghost page using PageRenderer
|
|
ghost = page_or_ghost
|
|
ghost_width_mm, ghost_height_mm = ghost.page_size
|
|
|
|
# y_offset is in world coordinates, convert to screen coordinates
|
|
screen_x = PAGE_MARGIN + self.pan_offset[0]
|
|
screen_y = (y_offset * self.zoom_level) + self.pan_offset[1]
|
|
|
|
# Create page renderer for ghost
|
|
renderer = PageRenderer(
|
|
page_width_mm=ghost_width_mm,
|
|
page_height_mm=ghost_height_mm,
|
|
screen_x=screen_x,
|
|
screen_y=screen_y,
|
|
dpi=dpi,
|
|
zoom=self.zoom_level
|
|
)
|
|
|
|
# Render the ghost page
|
|
self._render_ghost_page(ghost, renderer)
|
|
|
|
# Update PageRenderer references for selected elements
|
|
# This ensures bounding boxes update correctly when zooming
|
|
for element in self.selected_elements:
|
|
if hasattr(element, '_parent_page'):
|
|
# Find the updated renderer for this element's page
|
|
for renderer, page in self._page_renderers:
|
|
if page is element._parent_page:
|
|
element._page_renderer = renderer
|
|
break
|
|
|
|
# Draw selection handles if element is selected
|
|
if self.selected_element:
|
|
self._draw_selection_handles()
|
|
|
|
# Render text overlays using QPainter after OpenGL rendering
|
|
self._render_text_overlays()
|
|
|
|
def _draw_selection_handles(self):
|
|
"""Draw selection handles around the selected element"""
|
|
if not self.selected_element:
|
|
return
|
|
|
|
main_window = self.window()
|
|
if not hasattr(main_window, 'project') or not main_window.project.pages:
|
|
return
|
|
|
|
# Get the PageRenderer for this element (stored when element was selected)
|
|
if not hasattr(self.selected_element, '_page_renderer'):
|
|
return
|
|
|
|
renderer = self.selected_element._page_renderer
|
|
|
|
# Get element position and size in page-local coordinates
|
|
elem_x, elem_y = self.selected_element.position
|
|
elem_w, elem_h = self.selected_element.size
|
|
handle_size = 8
|
|
|
|
# Convert to screen coordinates using PageRenderer
|
|
x, y = renderer.page_to_screen(elem_x, elem_y)
|
|
w = elem_w * renderer.zoom
|
|
h = elem_h * renderer.zoom
|
|
|
|
# Calculate center point
|
|
center_x = x + w / 2
|
|
center_y = y + h / 2
|
|
|
|
# Draw selection border
|
|
if self.rotation_mode:
|
|
glColor3f(1.0, 0.5, 0.0) # Orange for rotation mode
|
|
else:
|
|
glColor3f(0.0, 0.5, 1.0) # Blue for normal mode
|
|
glLineWidth(2.0)
|
|
glBegin(GL_LINE_LOOP)
|
|
glVertex2f(x, y)
|
|
glVertex2f(x + w, y)
|
|
glVertex2f(x + w, y + h)
|
|
glVertex2f(x, y + h)
|
|
glEnd()
|
|
glLineWidth(1.0)
|
|
|
|
if self.rotation_mode:
|
|
# Draw rotation handles (circular handles at corners)
|
|
import math
|
|
handle_radius = 6
|
|
handles = [
|
|
(x, y), # NW
|
|
(x + w, y), # NE
|
|
(x, y + h), # SW
|
|
(x + w, y + h), # SE
|
|
]
|
|
|
|
# Draw center point
|
|
glColor3f(1.0, 0.5, 0.0) # Orange
|
|
glBegin(GL_TRIANGLE_FAN)
|
|
glVertex2f(center_x, center_y)
|
|
for angle in range(0, 361, 10):
|
|
rad = math.radians(angle)
|
|
hx = center_x + 3 * math.cos(rad)
|
|
hy = center_y + 3 * math.sin(rad)
|
|
glVertex2f(hx, hy)
|
|
glEnd()
|
|
|
|
# Draw rotation handles as circles
|
|
for hx, hy in handles:
|
|
glColor3f(1.0, 1.0, 1.0) # White fill
|
|
glBegin(GL_TRIANGLE_FAN)
|
|
glVertex2f(hx, hy)
|
|
for angle in range(0, 361, 30):
|
|
rad = math.radians(angle)
|
|
px = hx + handle_radius * math.cos(rad)
|
|
py = hy + handle_radius * math.sin(rad)
|
|
glVertex2f(px, py)
|
|
glEnd()
|
|
|
|
glColor3f(1.0, 0.5, 0.0) # Orange outline
|
|
glBegin(GL_LINE_LOOP)
|
|
for angle in range(0, 361, 30):
|
|
rad = math.radians(angle)
|
|
px = hx + handle_radius * math.cos(rad)
|
|
py = hy + handle_radius * math.sin(rad)
|
|
glVertex2f(px, py)
|
|
glEnd()
|
|
else:
|
|
# Draw resize handles (square handles at corners)
|
|
handles = [
|
|
(x - handle_size/2, y - handle_size/2), # NW
|
|
(x + w - handle_size/2, y - handle_size/2), # NE
|
|
(x - handle_size/2, y + h - handle_size/2), # SW
|
|
(x + w - handle_size/2, y + h - handle_size/2), # SE
|
|
]
|
|
|
|
glColor3f(1.0, 1.0, 1.0)
|
|
for hx, hy in handles:
|
|
glBegin(GL_QUADS)
|
|
glVertex2f(hx, hy)
|
|
glVertex2f(hx + handle_size, hy)
|
|
glVertex2f(hx + handle_size, hy + handle_size)
|
|
glVertex2f(hx, hy + handle_size)
|
|
glEnd()
|
|
|
|
glColor3f(0.0, 0.5, 1.0)
|
|
for hx, hy in handles:
|
|
glBegin(GL_LINE_LOOP)
|
|
glVertex2f(hx, hy)
|
|
glVertex2f(hx + handle_size, hy)
|
|
glVertex2f(hx + handle_size, hy + handle_size)
|
|
glVertex2f(hx, hy + handle_size)
|
|
glEnd()
|
|
|
|
def _render_text_overlays(self):
|
|
"""Render text content for TextBoxData elements using QPainter overlay"""
|
|
from PyQt6.QtGui import QPainter, QFont, QColor, QPen
|
|
from PyQt6.QtCore import Qt, QRectF
|
|
from pyPhotoAlbum.models import TextBoxData
|
|
|
|
if not hasattr(self, '_page_renderers') or not self._page_renderers:
|
|
return
|
|
|
|
# Create QPainter for overlay rendering
|
|
painter = QPainter(self)
|
|
painter.setRenderHint(QPainter.RenderHint.Antialiasing)
|
|
painter.setRenderHint(QPainter.RenderHint.TextAntialiasing)
|
|
|
|
try:
|
|
# Render text for all pages using their PageRenderer
|
|
for renderer, page in self._page_renderers:
|
|
# Get all TextBoxData elements
|
|
text_elements = [elem for elem in page.layout.elements if isinstance(elem, TextBoxData)]
|
|
|
|
for element in text_elements:
|
|
if not element.text_content:
|
|
continue
|
|
|
|
# Get element properties in page-local coordinates
|
|
x, y = element.position
|
|
w, h = element.size
|
|
|
|
# Convert to screen coordinates using PageRenderer
|
|
screen_x, screen_y = renderer.page_to_screen(x, y)
|
|
screen_w = w * renderer.zoom
|
|
screen_h = h * renderer.zoom
|
|
|
|
# Set up font
|
|
font_family = element.font_settings.get('family', 'Arial')
|
|
font_size = int(element.font_settings.get('size', 12) * renderer.zoom)
|
|
font = QFont(font_family, font_size)
|
|
painter.setFont(font)
|
|
|
|
# Set up text color
|
|
font_color = element.font_settings.get('color', (0, 0, 0))
|
|
if all(isinstance(c, int) and c > 1 for c in font_color):
|
|
# Convert from 0-255 to QColor
|
|
color = QColor(*font_color)
|
|
else:
|
|
# Convert from 0-1 to QColor
|
|
color = QColor(int(font_color[0] * 255), int(font_color[1] * 255), int(font_color[2] * 255))
|
|
painter.setPen(QPen(color))
|
|
|
|
# Apply rotation if needed
|
|
if element.rotation != 0:
|
|
painter.save()
|
|
# Rotate around element center
|
|
center_x = screen_x + screen_w / 2
|
|
center_y = screen_y + screen_h / 2
|
|
painter.translate(center_x, center_y)
|
|
painter.rotate(element.rotation)
|
|
painter.translate(-screen_w / 2, -screen_h / 2)
|
|
|
|
# Draw text in rotated coordinate system
|
|
rect = QRectF(0, 0, screen_w, screen_h)
|
|
else:
|
|
# No rotation - draw normally
|
|
rect = QRectF(screen_x, screen_y, screen_w, screen_h)
|
|
|
|
# Set text alignment
|
|
alignment = Qt.AlignmentFlag.AlignLeft | Qt.AlignmentFlag.AlignVCenter
|
|
if element.alignment == 'center':
|
|
alignment = Qt.AlignmentFlag.AlignCenter | Qt.AlignmentFlag.AlignVCenter
|
|
elif element.alignment == 'right':
|
|
alignment = Qt.AlignmentFlag.AlignRight | Qt.AlignmentFlag.AlignVCenter
|
|
|
|
# Draw the text
|
|
painter.drawText(rect, alignment, element.text_content)
|
|
|
|
# Restore painter state if we rotated
|
|
if element.rotation != 0:
|
|
painter.restore()
|
|
|
|
finally:
|
|
painter.end()
|
|
|
|
def mousePressEvent(self, event):
|
|
"""Handle mouse press events"""
|
|
if event.button() == Qt.MouseButton.LeftButton:
|
|
x, y = event.position().x(), event.position().y()
|
|
ctrl_pressed = event.modifiers() & Qt.KeyboardModifier.ControlModifier
|
|
|
|
# Check if clicking on ghost page button
|
|
if self._check_ghost_page_click(x, y):
|
|
return
|
|
|
|
# Update current_page_index based on where user clicked
|
|
page, page_index, renderer = self._get_page_at(x, y)
|
|
if page_index >= 0:
|
|
self.current_page_index = page_index
|
|
|
|
if len(self.selected_elements) == 1 and self.selected_element:
|
|
if self.rotation_mode:
|
|
# In rotation mode, start rotation
|
|
self.drag_start_pos = (x, y)
|
|
self.rotation_start_angle = self.selected_element.rotation
|
|
self.is_dragging = True
|
|
return
|
|
else:
|
|
# In normal mode, check for resize handles
|
|
handle = self._get_resize_handle_at(x, y)
|
|
if handle:
|
|
self.resize_handle = handle
|
|
self.drag_start_pos = (x, y)
|
|
self.resize_start_pos = self.selected_element.position
|
|
self.resize_start_size = self.selected_element.size
|
|
self.is_dragging = True
|
|
return
|
|
|
|
element = self._get_element_at(x, y)
|
|
if element:
|
|
if ctrl_pressed:
|
|
if element in self.selected_elements:
|
|
self.selected_elements.remove(element)
|
|
else:
|
|
self.selected_elements.add(element)
|
|
else:
|
|
self.selected_elements = {element}
|
|
self.drag_start_pos = (x, y)
|
|
self.drag_start_element_pos = element.position
|
|
if not self.rotation_mode:
|
|
self.is_dragging = True
|
|
else:
|
|
if not ctrl_pressed:
|
|
self.selected_elements.clear()
|
|
|
|
self.update()
|
|
|
|
elif event.button() == Qt.MouseButton.MiddleButton:
|
|
self.is_panning = True
|
|
self.drag_start_pos = (event.position().x(), event.position().y())
|
|
self.setCursor(Qt.CursorShape.ClosedHandCursor)
|
|
|
|
def mouseMoveEvent(self, event):
|
|
"""Handle mouse move events"""
|
|
x, y = event.position().x(), event.position().y()
|
|
|
|
# Update status bar with page information
|
|
self._update_page_status(x, y)
|
|
|
|
if self.is_panning and self.drag_start_pos:
|
|
dx = x - self.drag_start_pos[0]
|
|
dy = y - self.drag_start_pos[1]
|
|
|
|
self.pan_offset[0] += dx
|
|
self.pan_offset[1] += dy
|
|
|
|
self.drag_start_pos = (x, y)
|
|
self.update()
|
|
return
|
|
|
|
if not self.is_dragging or not self.drag_start_pos:
|
|
return
|
|
|
|
if self.selected_element:
|
|
if self.rotation_mode:
|
|
# Calculate rotation angle from mouse position relative to element center
|
|
import math
|
|
|
|
# Get the PageRenderer for this element
|
|
if not hasattr(self.selected_element, '_page_renderer'):
|
|
return
|
|
|
|
renderer = self.selected_element._page_renderer
|
|
|
|
# Get element center in page-local coordinates
|
|
elem_x, elem_y = self.selected_element.position
|
|
elem_w, elem_h = self.selected_element.size
|
|
|
|
# Convert center to screen coordinates using PageRenderer
|
|
center_page_x = elem_x + elem_w / 2
|
|
center_page_y = elem_y + elem_h / 2
|
|
screen_center_x, screen_center_y = renderer.page_to_screen(center_page_x, center_page_y)
|
|
|
|
# Calculate angle from center to mouse position
|
|
dx = x - screen_center_x
|
|
dy = y - screen_center_y
|
|
angle = math.degrees(math.atan2(dy, dx))
|
|
|
|
# Rotation snapping is always applied if enabled globally
|
|
# (We don't need page-specific snapping for rotation)
|
|
angle = round(angle / self.rotation_snap_angle) * self.rotation_snap_angle
|
|
|
|
# Normalize angle to 0-360
|
|
angle = angle % 360
|
|
|
|
# Update element rotation
|
|
self.selected_element.rotation = angle
|
|
|
|
# Show current angle in status bar
|
|
if hasattr(main_window, 'show_status'):
|
|
main_window.show_status(f"Rotation: {angle:.1f}°", 100)
|
|
|
|
elif self.resize_handle:
|
|
total_dx = (x - self.drag_start_pos[0]) / self.zoom_level
|
|
total_dy = (y - self.drag_start_pos[1]) / self.zoom_level
|
|
self._resize_element(total_dx, total_dy)
|
|
else:
|
|
# Check if mouse is over a different page (for cross-page dragging)
|
|
current_page, current_page_index, current_renderer = self._get_page_at(x, y)
|
|
|
|
if current_page and hasattr(self.selected_element, '_parent_page'):
|
|
source_page = self.selected_element._parent_page
|
|
|
|
# Check if we've crossed into a different page
|
|
if current_page is not source_page:
|
|
# Transfer element to the new page
|
|
self._transfer_element_to_page(self.selected_element, source_page, current_page, x, y, current_renderer)
|
|
else:
|
|
# Same page - just update position normally
|
|
total_dx = (x - self.drag_start_pos[0]) / self.zoom_level
|
|
total_dy = (y - self.drag_start_pos[1]) / self.zoom_level
|
|
|
|
new_x = self.drag_start_element_pos[0] + total_dx
|
|
new_y = self.drag_start_element_pos[1] + total_dy
|
|
|
|
self.selected_element.position = (new_x, new_y)
|
|
else:
|
|
# No page detected or no parent page - update normally
|
|
total_dx = (x - self.drag_start_pos[0]) / self.zoom_level
|
|
total_dy = (y - self.drag_start_pos[1]) / self.zoom_level
|
|
|
|
new_x = self.drag_start_element_pos[0] + total_dx
|
|
new_y = self.drag_start_element_pos[1] + total_dy
|
|
|
|
self.selected_element.position = (new_x, new_y)
|
|
|
|
self.update()
|
|
|
|
def mouseReleaseEvent(self, event):
|
|
"""Handle mouse release events"""
|
|
if event.button() == Qt.MouseButton.LeftButton:
|
|
# If we were rotating, create a RotateElementCommand for undo/redo
|
|
if self.rotation_mode and self.rotation_start_angle is not None and self.selected_element:
|
|
from pyPhotoAlbum.commands import RotateElementCommand
|
|
|
|
new_angle = self.selected_element.rotation
|
|
if abs(new_angle - self.rotation_start_angle) > 0.1: # Only create command if angle changed
|
|
main_window = self.window()
|
|
if hasattr(main_window, 'project'):
|
|
cmd = RotateElementCommand(
|
|
self.selected_element,
|
|
self.rotation_start_angle,
|
|
new_angle
|
|
)
|
|
main_window.project.history.execute(cmd)
|
|
print(f"Rotation command created: {self.rotation_start_angle:.1f}° → {new_angle:.1f}°")
|
|
|
|
self.rotation_start_angle = None
|
|
|
|
self.is_dragging = False
|
|
self.drag_start_pos = None
|
|
self.drag_start_element_pos = None
|
|
self.resize_handle = None
|
|
self.snap_state = {
|
|
'is_snapped': False,
|
|
'last_position': None,
|
|
'last_size': None
|
|
}
|
|
|
|
elif event.button() == Qt.MouseButton.MiddleButton:
|
|
self.is_panning = False
|
|
self.drag_start_pos = None
|
|
self.setCursor(Qt.CursorShape.ArrowCursor)
|
|
|
|
def wheelEvent(self, event):
|
|
"""Handle mouse wheel events for scrolling or zooming (with Ctrl)"""
|
|
delta = event.angleDelta().y()
|
|
ctrl_pressed = event.modifiers() & Qt.KeyboardModifier.ControlModifier
|
|
|
|
if ctrl_pressed:
|
|
# Ctrl + Wheel: Zoom centered on mouse position
|
|
mouse_x = event.position().x()
|
|
mouse_y = event.position().y()
|
|
|
|
# Calculate world position before zoom
|
|
world_x = (mouse_x - self.pan_offset[0]) / self.zoom_level
|
|
world_y = (mouse_y - self.pan_offset[1]) / self.zoom_level
|
|
|
|
# Apply zoom
|
|
zoom_factor = 1.1 if delta > 0 else 0.9
|
|
new_zoom = self.zoom_level * zoom_factor
|
|
|
|
if 0.1 <= new_zoom <= 5.0:
|
|
old_zoom = self.zoom_level
|
|
self.zoom_level = new_zoom
|
|
|
|
# Adjust pan to keep world position under mouse
|
|
self.pan_offset[0] = mouse_x - world_x * self.zoom_level
|
|
self.pan_offset[1] = mouse_y - world_y * self.zoom_level
|
|
|
|
self.update()
|
|
|
|
main_window = self.window()
|
|
if hasattr(main_window, 'status_bar'):
|
|
main_window.status_bar.showMessage(f"Zoom: {int(self.zoom_level * 100)}%", 2000)
|
|
else:
|
|
# Regular wheel: Vertical scroll
|
|
scroll_amount = delta * 0.5 # Adjust scroll sensitivity
|
|
self.pan_offset[1] += scroll_amount
|
|
self.update()
|
|
|
|
def _get_page_at(self, x, y):
|
|
"""
|
|
Get the page at the given screen coordinates.
|
|
|
|
Returns:
|
|
Tuple of (page, page_index, renderer) or (None, -1, None) if no page at coordinates
|
|
"""
|
|
if not hasattr(self, '_page_renderers') or not self._page_renderers:
|
|
return None, -1, None
|
|
|
|
main_window = self.window()
|
|
if not hasattr(main_window, 'project') or not main_window.project.pages:
|
|
return None, -1, None
|
|
|
|
# Check each page to find which one contains the coordinates
|
|
for renderer, page in self._page_renderers:
|
|
if renderer.is_point_in_page(x, y):
|
|
# Find the page index in the project's pages list
|
|
page_index = main_window.project.pages.index(page)
|
|
return page, page_index, renderer
|
|
|
|
return None, -1, None
|
|
|
|
def _get_element_at(self, x, y):
|
|
"""Get the element at the given position across all pages"""
|
|
if not hasattr(self, '_page_renderers') or not self._page_renderers:
|
|
return None
|
|
|
|
# Check each page from top to bottom (reverse z-order)
|
|
for renderer, page in reversed(self._page_renderers):
|
|
# Check if click is within this page bounds
|
|
if not renderer.is_point_in_page(x, y):
|
|
continue
|
|
|
|
# Convert screen coordinates to page-local coordinates
|
|
page_x, page_y = renderer.screen_to_page(x, y)
|
|
|
|
# Check elements in this page (highest z-index first)
|
|
elements = sorted(page.layout.elements, key=lambda e: e.z_index, reverse=True)
|
|
for element in elements:
|
|
ex, ey = element.position
|
|
ew, eh = element.size
|
|
|
|
# Check if click is within element bounds (in page-local coordinates)
|
|
if ex <= page_x <= ex + ew and ey <= page_y <= ey + eh:
|
|
# Store the renderer with the element for later use
|
|
element._page_renderer = renderer
|
|
element._parent_page = page
|
|
return element
|
|
|
|
return None
|
|
|
|
def _get_resize_handle_at(self, x, y):
|
|
"""Get the resize handle at the given position"""
|
|
if not self.selected_element:
|
|
return None
|
|
|
|
main_window = self.window()
|
|
if not hasattr(main_window, 'project') or not main_window.project.pages:
|
|
return None
|
|
|
|
# Get the PageRenderer for this element (stored when element was selected)
|
|
if not hasattr(self.selected_element, '_page_renderer'):
|
|
return None
|
|
|
|
renderer = self.selected_element._page_renderer
|
|
|
|
# Get element position and size in page-local coordinates
|
|
elem_x, elem_y = self.selected_element.position
|
|
elem_w, elem_h = self.selected_element.size
|
|
handle_size = 8
|
|
|
|
# Convert to screen coordinates using PageRenderer
|
|
ex, ey = renderer.page_to_screen(elem_x, elem_y)
|
|
ew = elem_w * renderer.zoom
|
|
eh = elem_h * renderer.zoom
|
|
|
|
handles = {
|
|
'nw': (ex - handle_size/2, ey - handle_size/2),
|
|
'ne': (ex + ew - handle_size/2, ey - handle_size/2),
|
|
'sw': (ex - handle_size/2, ey + eh - handle_size/2),
|
|
'se': (ex + ew - handle_size/2, ey + eh - handle_size/2),
|
|
}
|
|
|
|
for name, (hx, hy) in handles.items():
|
|
if hx <= x <= hx + handle_size and hy <= y <= hy + handle_size:
|
|
return name
|
|
|
|
return None
|
|
|
|
def _transfer_element_to_page(self, element, source_page, target_page, mouse_x, mouse_y, target_renderer):
|
|
"""
|
|
Transfer an element from one page to another during drag operation.
|
|
|
|
Args:
|
|
element: The element to transfer
|
|
source_page: Source page object
|
|
target_page: Target page object
|
|
mouse_x, mouse_y: Current mouse position in screen coordinates
|
|
target_renderer: PageRenderer for the target page
|
|
"""
|
|
# Convert mouse position to target page coordinates
|
|
new_page_x, new_page_y = target_renderer.screen_to_page(mouse_x, mouse_y)
|
|
|
|
# Get element size
|
|
elem_w, elem_h = element.size
|
|
|
|
# Center the element on the mouse position
|
|
new_x = new_page_x - elem_w / 2
|
|
new_y = new_page_y - elem_h / 2
|
|
|
|
# Remove element from source page
|
|
if element in source_page.layout.elements:
|
|
source_page.layout.elements.remove(element)
|
|
print(f"Removed element from page {source_page.page_number}")
|
|
|
|
# Update element position to new page coordinates
|
|
element.position = (new_x, new_y)
|
|
|
|
# Add element to target page
|
|
target_page.layout.add_element(element)
|
|
|
|
# Update element's parent page reference
|
|
element._parent_page = target_page
|
|
element._page_renderer = target_renderer
|
|
|
|
# Update drag start position and element position for continued dragging
|
|
self.drag_start_pos = (mouse_x, mouse_y)
|
|
self.drag_start_element_pos = element.position
|
|
|
|
print(f"Transferred element to page {target_page.page_number} at ({new_x:.1f}, {new_y:.1f})")
|
|
|
|
def _resize_element(self, dx, dy):
|
|
"""Resize the element based on the resize handle"""
|
|
if not self.selected_element or not self.resize_handle:
|
|
return
|
|
|
|
if not self.resize_start_pos or not self.resize_start_size:
|
|
return
|
|
|
|
# Use non-snap resize for now
|
|
# TODO: Implement page-aware snapping
|
|
self._resize_element_no_snap(dx, dy)
|
|
|
|
def _resize_element_no_snap(self, dx, dy):
|
|
"""Resize element without snapping"""
|
|
if not self.resize_start_pos or not self.resize_start_size:
|
|
return
|
|
|
|
start_x, start_y = self.resize_start_pos
|
|
start_w, start_h = self.resize_start_size
|
|
|
|
if self.resize_handle == 'nw':
|
|
self.selected_element.position = (start_x + dx, start_y + dy)
|
|
self.selected_element.size = (start_w - dx, start_h - dy)
|
|
elif self.resize_handle == 'ne':
|
|
self.selected_element.position = (start_x, start_y + dy)
|
|
self.selected_element.size = (start_w + dx, start_h - dy)
|
|
elif self.resize_handle == 'sw':
|
|
self.selected_element.position = (start_x + dx, start_y)
|
|
self.selected_element.size = (start_w - dx, start_h + dy)
|
|
elif self.resize_handle == 'se':
|
|
self.selected_element.size = (start_w + dx, start_h + dy)
|
|
|
|
min_size = 20
|
|
w, h = self.selected_element.size
|
|
if w < min_size:
|
|
self.selected_element.size = (min_size, h)
|
|
if h < min_size:
|
|
w, _ = self.selected_element.size
|
|
self.selected_element.size = (w, min_size)
|
|
|
|
def keyPressEvent(self, event):
|
|
"""Handle key press events"""
|
|
if event.key() == Qt.Key.Key_Delete or event.key() == Qt.Key.Key_Backspace:
|
|
if self.selected_element:
|
|
main_window = self.window()
|
|
if hasattr(main_window, 'delete_selected_element'):
|
|
main_window.delete_selected_element()
|
|
|
|
elif event.key() == Qt.Key.Key_Escape:
|
|
self.selected_element = None
|
|
self.rotation_mode = False
|
|
self.update()
|
|
|
|
elif event.key() == Qt.Key.Key_Tab:
|
|
# Toggle rotation mode when an element is selected
|
|
if self.selected_element:
|
|
self.rotation_mode = not self.rotation_mode
|
|
main_window = self.window()
|
|
if hasattr(main_window, 'show_status'):
|
|
mode_text = "Rotation Mode" if self.rotation_mode else "Move/Resize Mode"
|
|
main_window.show_status(f"Switched to {mode_text}", 2000)
|
|
print(f"Rotation mode: {self.rotation_mode}")
|
|
self.update()
|
|
event.accept()
|
|
else:
|
|
super().keyPressEvent(event)
|
|
|
|
else:
|
|
super().keyPressEvent(event)
|
|
|
|
def _calculate_fit_to_screen_zoom(self):
|
|
"""Calculate zoom level to fit first page to screen"""
|
|
main_window = self.window()
|
|
if not hasattr(main_window, 'project') or not main_window.project.pages:
|
|
return 1.0
|
|
|
|
window_width = self.width()
|
|
window_height = self.height()
|
|
|
|
# Get first page dimensions in mm
|
|
first_page = main_window.project.pages[0]
|
|
page_width_mm, page_height_mm = first_page.layout.size
|
|
|
|
# Convert to pixels
|
|
dpi = main_window.project.working_dpi
|
|
page_width_px = page_width_mm * dpi / 25.4
|
|
page_height_px = page_height_mm * dpi / 25.4
|
|
|
|
# Calculate zoom to fit with margins
|
|
margin = 100 # pixels
|
|
zoom_w = (window_width - margin * 2) / page_width_px
|
|
zoom_h = (window_height - margin * 2) / page_height_px
|
|
|
|
# Use the smaller zoom to ensure entire page fits
|
|
return min(zoom_w, zoom_h, 1.0) # Don't zoom in beyond 100%
|
|
|
|
def _get_page_positions(self):
|
|
"""
|
|
Calculate page positions including ghost pages.
|
|
|
|
Returns:
|
|
List of tuples (page_type, page_or_ghost_data, y_offset)
|
|
"""
|
|
main_window = self.window()
|
|
if not hasattr(main_window, 'project'):
|
|
return []
|
|
|
|
# Get page layout with ghosts from project
|
|
layout_with_ghosts = main_window.project.calculate_page_layout_with_ghosts()
|
|
|
|
dpi = main_window.project.working_dpi
|
|
|
|
# Use project's page_spacing_mm setting (default is 10mm = 1cm)
|
|
# Convert to pixels at working DPI
|
|
spacing_mm = main_window.project.page_spacing_mm
|
|
spacing_px = spacing_mm * dpi / 25.4
|
|
|
|
# Start with a small top margin (5mm)
|
|
top_margin_mm = 5.0
|
|
top_margin_px = top_margin_mm * dpi / 25.4
|
|
|
|
result = []
|
|
current_y = top_margin_px # Initial top offset in pixels (not screen pixels)
|
|
|
|
for page_type, page_obj, logical_pos in layout_with_ghosts:
|
|
if page_type == 'page':
|
|
# Regular page (single or double spread)
|
|
result.append((page_type, page_obj, current_y))
|
|
|
|
# Calculate page height in pixels
|
|
# For double spreads, layout.size already contains the doubled width
|
|
page_height_mm = page_obj.layout.size[1]
|
|
page_height_px = page_height_mm * dpi / 25.4
|
|
|
|
# Move to next position (add height + spacing)
|
|
current_y += page_height_px + spacing_px
|
|
|
|
elif page_type == 'ghost':
|
|
# Ghost page - use default page size
|
|
page_size_mm = main_window.project.page_size_mm
|
|
from pyPhotoAlbum.models import GhostPageData
|
|
|
|
# Create ghost page data with correct size
|
|
ghost = GhostPageData(page_size=page_size_mm)
|
|
result.append((page_type, ghost, current_y))
|
|
|
|
# Calculate ghost page height
|
|
page_height_px = page_size_mm[1] * dpi / 25.4
|
|
|
|
# Move to next position (add height + spacing)
|
|
current_y += page_height_px + spacing_px
|
|
|
|
return result
|
|
|
|
def _render_ghost_page(self, ghost_data, renderer):
|
|
"""Render a ghost page using PageRenderer"""
|
|
from PyQt6.QtGui import QPainter, QFont, QColor
|
|
from PyQt6.QtCore import Qt, QRectF
|
|
|
|
# Render using PageRenderer coordinate system
|
|
renderer.begin_render()
|
|
ghost_data.render()
|
|
renderer.end_render()
|
|
|
|
# Render "Click to Add Page" text overlay centered on entire page
|
|
painter = QPainter(self)
|
|
painter.setRenderHint(QPainter.RenderHint.Antialiasing)
|
|
painter.setRenderHint(QPainter.RenderHint.TextAntialiasing)
|
|
|
|
try:
|
|
# Get entire page rect in page-local coordinates
|
|
px, py, pw, ph = ghost_data.get_page_rect()
|
|
|
|
# Convert to screen coordinates using PageRenderer
|
|
screen_x, screen_y = renderer.page_to_screen(px, py)
|
|
screen_w = pw * renderer.zoom
|
|
screen_h = ph * renderer.zoom
|
|
|
|
# Draw "Click to Add Page" text centered on entire page
|
|
font = QFont("Arial", int(16 * renderer.zoom), QFont.Weight.Bold)
|
|
painter.setFont(font)
|
|
painter.setPen(QColor(120, 120, 120)) # Grey text
|
|
|
|
rect = QRectF(screen_x, screen_y, screen_w, screen_h)
|
|
painter.drawText(rect, Qt.AlignmentFlag.AlignCenter, "Click to Add Page")
|
|
|
|
finally:
|
|
painter.end()
|
|
|
|
def _check_ghost_page_click(self, x, y):
|
|
"""Check if click is on a ghost page (entire page is clickable) and handle it"""
|
|
if not hasattr(self, '_page_renderers'):
|
|
return False
|
|
|
|
main_window = self.window()
|
|
if not hasattr(main_window, 'project'):
|
|
return False
|
|
|
|
# Get full layout with ghosts to determine insertion position
|
|
layout_with_ghosts = main_window.project.calculate_page_layout_with_ghosts()
|
|
page_positions = self._get_page_positions()
|
|
|
|
# Track which index in the page list corresponds to each position
|
|
ghost_index = 0
|
|
for idx, ((page_type, page_obj_layout, logical_pos), (_, page_or_ghost, y_offset)) in enumerate(zip(layout_with_ghosts, page_positions)):
|
|
if page_type != 'ghost':
|
|
continue
|
|
|
|
ghost = page_or_ghost
|
|
dpi = main_window.project.working_dpi
|
|
|
|
# Calculate ghost page renderer
|
|
ghost_width_mm, ghost_height_mm = ghost.page_size
|
|
screen_x = 50 + self.pan_offset[0]
|
|
screen_y = (y_offset * self.zoom_level) + self.pan_offset[1]
|
|
|
|
from pyPhotoAlbum.page_renderer import PageRenderer
|
|
renderer = PageRenderer(
|
|
page_width_mm=ghost_width_mm,
|
|
page_height_mm=ghost_height_mm,
|
|
screen_x=screen_x,
|
|
screen_y=screen_y,
|
|
dpi=dpi,
|
|
zoom=self.zoom_level
|
|
)
|
|
|
|
# Check if click is anywhere on the ghost page (entire page is clickable)
|
|
if renderer.is_point_in_page(x, y):
|
|
# User clicked the ghost page!
|
|
# Calculate the insertion index (count real pages before this ghost)
|
|
insert_index = sum(1 for i, (pt, _, _) in enumerate(layout_with_ghosts) if i < idx and pt == 'page')
|
|
|
|
print(f"Ghost page clicked at index {insert_index} - inserting new page in place")
|
|
|
|
# Create a new page and insert it directly into the pages list
|
|
from pyPhotoAlbum.project import Page
|
|
from pyPhotoAlbum.page_layout import PageLayout
|
|
|
|
# Create new page with next page number
|
|
new_page_number = insert_index + 1
|
|
new_page = Page(
|
|
layout=PageLayout(
|
|
width=main_window.project.page_size_mm[0],
|
|
height=main_window.project.page_size_mm[1]
|
|
),
|
|
page_number=new_page_number
|
|
)
|
|
|
|
# Insert the page at the correct position
|
|
main_window.project.pages.insert(insert_index, new_page)
|
|
|
|
# Renumber all pages after this one
|
|
for i, page in enumerate(main_window.project.pages):
|
|
page.page_number = i + 1
|
|
|
|
print(f"Inserted page at index {insert_index}, renumbered pages")
|
|
self.update()
|
|
return True
|
|
|
|
return False
|
|
|
|
def _update_page_status(self, x, y):
|
|
"""Update status bar with current page and total page count"""
|
|
main_window = self.window()
|
|
if not hasattr(main_window, 'project') or not main_window.project.pages:
|
|
return
|
|
|
|
if not hasattr(self, '_page_renderers') or not self._page_renderers:
|
|
return
|
|
|
|
# Get total page count (accounting for double spreads = 2 pages each)
|
|
total_pages = sum(page.get_page_count() for page in main_window.project.pages)
|
|
|
|
# Find which page mouse is over
|
|
current_page_info = None
|
|
|
|
for renderer, page in self._page_renderers:
|
|
# Check if mouse is within this page bounds
|
|
if renderer.is_point_in_page(x, y):
|
|
# For facing page spreads, determine left or right
|
|
if page.is_double_spread:
|
|
side = renderer.get_sub_page_at(x, is_facing_page=True)
|
|
page_nums = page.get_page_numbers()
|
|
if side == 'left':
|
|
current_page_info = f"Page {page_nums[0]}"
|
|
else:
|
|
current_page_info = f"Page {page_nums[1]}"
|
|
else:
|
|
current_page_info = f"Page {page.page_number}"
|
|
break
|
|
|
|
# Update status bar
|
|
if hasattr(main_window, 'status_bar'):
|
|
if current_page_info:
|
|
main_window.status_bar.showMessage(f"{current_page_info} of {total_pages} | Zoom: {int(self.zoom_level * 100)}%")
|
|
else:
|
|
main_window.status_bar.showMessage(f"Total pages: {total_pages} | Zoom: {int(self.zoom_level * 100)}%")
|
|
|
|
def dragEnterEvent(self, event):
|
|
"""Handle drag enter events"""
|
|
if event.mimeData().hasUrls():
|
|
urls = event.mimeData().urls()
|
|
image_extensions = ['.jpg', '.jpeg', '.png', '.gif', '.bmp', '.tiff', '.webp']
|
|
for url in urls:
|
|
file_path = url.toLocalFile()
|
|
if any(file_path.lower().endswith(ext) for ext in image_extensions):
|
|
event.acceptProposedAction()
|
|
return
|
|
event.ignore()
|
|
|
|
def dragMoveEvent(self, event):
|
|
"""Handle drag move events"""
|
|
if event.mimeData().hasUrls():
|
|
event.acceptProposedAction()
|
|
else:
|
|
event.ignore()
|
|
|
|
def dropEvent(self, event):
|
|
"""Handle drop events"""
|
|
if not event.mimeData().hasUrls():
|
|
event.ignore()
|
|
return
|
|
|
|
image_extensions = ['.jpg', '.jpeg', '.png', '.gif', '.bmp', '.tiff', '.webp']
|
|
image_path = None
|
|
|
|
for url in event.mimeData().urls():
|
|
file_path = url.toLocalFile()
|
|
if any(file_path.lower().endswith(ext) for ext in image_extensions):
|
|
image_path = file_path
|
|
break
|
|
|
|
if not image_path:
|
|
event.ignore()
|
|
return
|
|
|
|
x, y = event.position().x(), event.position().y()
|
|
page_x = 50
|
|
page_y = 50
|
|
|
|
page_rel_x = (x / self.zoom_level) - (page_x / self.zoom_level)
|
|
page_rel_y = (y / self.zoom_level) - (page_y / self.zoom_level)
|
|
|
|
target_element = self._get_element_at(x, y)
|
|
|
|
if target_element and isinstance(target_element, (ImageData, PlaceholderData)):
|
|
if isinstance(target_element, PlaceholderData):
|
|
new_image = ImageData(
|
|
image_path=image_path,
|
|
x=target_element.position[0],
|
|
y=target_element.position[1],
|
|
width=target_element.size[0],
|
|
height=target_element.size[1],
|
|
z_index=target_element.z_index
|
|
)
|
|
main_window = self.window()
|
|
if hasattr(main_window, 'project') and main_window.project.pages:
|
|
for page in main_window.project.pages:
|
|
if target_element in page.layout.elements:
|
|
page.layout.elements.remove(target_element)
|
|
page.layout.add_element(new_image)
|
|
break
|
|
else:
|
|
target_element.image_path = image_path
|
|
|
|
print(f"Updated element with image: {image_path}")
|
|
else:
|
|
try:
|
|
from PIL import Image
|
|
img = Image.open(image_path)
|
|
img_width, img_height = img.size
|
|
|
|
max_size = 300
|
|
if img_width > max_size or img_height > max_size:
|
|
scale = min(max_size / img_width, max_size / img_height)
|
|
img_width = int(img_width * scale)
|
|
img_height = int(img_height * scale)
|
|
|
|
except Exception as e:
|
|
print(f"Error loading image dimensions: {e}")
|
|
img_width, img_height = 200, 150
|
|
|
|
main_window = self.window()
|
|
if hasattr(main_window, 'project') and main_window.project.pages:
|
|
# Detect which page the drop occurred on
|
|
target_page, page_index, page_renderer = self._get_page_at(x, y)
|
|
|
|
if target_page and page_renderer:
|
|
# Update current_page_index
|
|
if page_index >= 0:
|
|
self.current_page_index = page_index
|
|
|
|
# Convert screen coordinates to page-local coordinates
|
|
page_local_x, page_local_y = page_renderer.screen_to_page(x, y)
|
|
|
|
try:
|
|
asset_path = main_window.project.asset_manager.import_asset(image_path)
|
|
|
|
new_image = ImageData(
|
|
image_path=asset_path,
|
|
x=page_local_x,
|
|
y=page_local_y,
|
|
width=img_width,
|
|
height=img_height
|
|
)
|
|
|
|
cmd = AddElementCommand(
|
|
target_page.layout,
|
|
new_image,
|
|
asset_manager=main_window.project.asset_manager
|
|
)
|
|
main_window.project.history.execute(cmd)
|
|
|
|
print(f"Added new image to page {page_index + 1} at ({page_local_x:.1f}, {page_local_y:.1f}): {asset_path}")
|
|
except Exception as e:
|
|
print(f"Error adding dropped image: {e}")
|
|
else:
|
|
print("Drop location not on any page")
|
|
|
|
event.acceptProposedAction()
|
|
self.update()
|