343 lines
13 KiB
Python
343 lines
13 KiB
Python
"""
|
|
OpenGL widget for pyPhotoAlbum rendering - refactored with mixins
|
|
"""
|
|
|
|
from PyQt6.QtOpenGLWidgets import QOpenGLWidget
|
|
from PyQt6.QtCore import Qt
|
|
from pyPhotoAlbum.gl_imports import *
|
|
|
|
# Import all mixins
|
|
from pyPhotoAlbum.mixins.viewport import ViewportMixin
|
|
from pyPhotoAlbum.mixins.rendering import RenderingMixin
|
|
from pyPhotoAlbum.mixins.asset_path import AssetPathMixin
|
|
from pyPhotoAlbum.mixins.asset_drop import AssetDropMixin
|
|
from pyPhotoAlbum.mixins.page_navigation import PageNavigationMixin
|
|
from pyPhotoAlbum.mixins.image_pan import ImagePanMixin
|
|
from pyPhotoAlbum.mixins.element_manipulation import ElementManipulationMixin
|
|
from pyPhotoAlbum.mixins.element_selection import ElementSelectionMixin
|
|
from pyPhotoAlbum.mixins.mouse_interaction import MouseInteractionMixin
|
|
from pyPhotoAlbum.mixins.interaction_undo import UndoableInteractionMixin
|
|
from pyPhotoAlbum.mixins.async_loading import AsyncLoadingMixin
|
|
from pyPhotoAlbum.mixins.keyboard_navigation import KeyboardNavigationMixin
|
|
|
|
|
|
class GLWidget(
|
|
AsyncLoadingMixin,
|
|
ViewportMixin,
|
|
RenderingMixin,
|
|
AssetPathMixin,
|
|
AssetDropMixin,
|
|
PageNavigationMixin,
|
|
ImagePanMixin,
|
|
ElementManipulationMixin,
|
|
ElementSelectionMixin,
|
|
MouseInteractionMixin,
|
|
UndoableInteractionMixin,
|
|
KeyboardNavigationMixin,
|
|
QOpenGLWidget,
|
|
):
|
|
"""OpenGL widget for pyPhotoAlbum rendering and user interaction
|
|
|
|
This widget orchestrates multiple mixins to provide:
|
|
- Async image loading (non-blocking)
|
|
- Viewport control (zoom, pan)
|
|
- Page rendering (OpenGL)
|
|
- Element selection and manipulation
|
|
- Mouse interaction handling
|
|
- Drag-and-drop asset management
|
|
- Image panning within frames
|
|
- Page navigation and ghost pages
|
|
- Undo/redo integration
|
|
"""
|
|
|
|
def __init__(self, parent=None):
|
|
super().__init__(parent)
|
|
|
|
# Store reference to main window for accessing project
|
|
self._main_window = parent
|
|
|
|
# Initialize async loading system
|
|
self._init_async_loading()
|
|
|
|
# Set up OpenGL surface format with explicit double buffering
|
|
from PyQt6.QtGui import QSurfaceFormat
|
|
fmt = QSurfaceFormat()
|
|
fmt.setSwapBehavior(QSurfaceFormat.SwapBehavior.DoubleBuffer)
|
|
fmt.setSwapInterval(1) # Enable vsync
|
|
self.setFormat(fmt)
|
|
|
|
# Force full redraws to ensure viewport updates
|
|
self.setUpdateBehavior(QOpenGLWidget.UpdateBehavior.NoPartialUpdate)
|
|
|
|
# Enable mouse tracking and drag-drop
|
|
self.setMouseTracking(True)
|
|
self.setAcceptDrops(True)
|
|
|
|
# Enable keyboard focus
|
|
self.setFocusPolicy(Qt.FocusPolicy.StrongFocus)
|
|
self.setFocus()
|
|
|
|
# Enable gesture support for pinch-to-zoom
|
|
self.grabGesture(Qt.GestureType.PinchGesture)
|
|
|
|
# Track pinch gesture state
|
|
self._pinch_scale_factor = 1.0
|
|
|
|
def window(self):
|
|
"""Override window() to return stored main_window reference.
|
|
|
|
This fixes the Qt widget hierarchy issue where window() returns None
|
|
because the GL widget is nested in container widgets.
|
|
"""
|
|
return self._main_window if hasattr(self, '_main_window') else super().window()
|
|
|
|
def update(self):
|
|
"""Override update to force immediate repaint"""
|
|
super().update()
|
|
# Force immediate processing of paint events
|
|
self.repaint()
|
|
|
|
def closeEvent(self, event):
|
|
"""Handle widget close event."""
|
|
# Cleanup async loading
|
|
self._cleanup_async_loading()
|
|
super().closeEvent(event)
|
|
|
|
def _get_project_folder(self):
|
|
"""Override AssetPathMixin to access project via main window."""
|
|
main_window = self.window()
|
|
if hasattr(main_window, "project") and main_window.project:
|
|
return getattr(main_window.project, "folder_path", None)
|
|
return None
|
|
|
|
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)
|
|
|
|
elif event.key() == Qt.Key.Key_PageDown:
|
|
# Navigate to next page
|
|
self._navigate_to_next_page()
|
|
event.accept()
|
|
|
|
elif event.key() == Qt.Key.Key_PageUp:
|
|
# Navigate to previous page
|
|
self._navigate_to_previous_page()
|
|
event.accept()
|
|
|
|
elif event.key() in (Qt.Key.Key_Up, Qt.Key.Key_Down, Qt.Key.Key_Left, Qt.Key.Key_Right):
|
|
# Arrow key handling
|
|
if self.selected_elements:
|
|
# Move selected elements
|
|
self._move_selected_elements_with_arrow_keys(event.key())
|
|
event.accept()
|
|
else:
|
|
# Move viewport
|
|
self._move_viewport_with_arrow_keys(event.key())
|
|
event.accept()
|
|
|
|
else:
|
|
super().keyPressEvent(event)
|
|
|
|
def event(self, event):
|
|
"""Handle gesture events for pinch-to-zoom"""
|
|
from PyQt6.QtCore import QEvent, Qt as QtCore
|
|
from PyQt6.QtWidgets import QPinchGesture
|
|
from PyQt6.QtGui import QNativeGestureEvent
|
|
|
|
# Handle native touchpad gestures (Linux, macOS)
|
|
if event.type() == QEvent.Type.NativeGesture:
|
|
native_event = event
|
|
gesture_type = native_event.gestureType()
|
|
|
|
print(f"DEBUG: Native gesture detected - type: {gesture_type}")
|
|
|
|
# Check for zoom/pinch gesture
|
|
if gesture_type == QtCore.NativeGestureType.ZoomNativeGesture:
|
|
# Get zoom value (typically a delta around 0)
|
|
value = native_event.value()
|
|
print(f"DEBUG: Zoom value: {value}")
|
|
|
|
# Convert to scale factor (value is typically small, like -0.1 to 0.1)
|
|
# Positive value = zoom in, negative = zoom out
|
|
scale_factor = 1.0 + value
|
|
|
|
# Get the position of the gesture
|
|
pos = native_event.position()
|
|
mouse_x = pos.x()
|
|
mouse_y = pos.y()
|
|
|
|
self._apply_zoom_at_point(mouse_x, mouse_y, scale_factor)
|
|
return True
|
|
|
|
# Check for pan gesture (two-finger drag)
|
|
elif gesture_type == QtCore.NativeGestureType.PanNativeGesture:
|
|
# Get the pan delta
|
|
delta = native_event.delta()
|
|
dx = delta.x()
|
|
dy = delta.y()
|
|
|
|
print(f"DEBUG: Pan delta: dx={dx}, dy={dy}")
|
|
|
|
# Apply pan
|
|
self.pan_offset[0] += dx
|
|
self.pan_offset[1] += dy
|
|
|
|
# Clamp pan offset to content bounds
|
|
if hasattr(self, "clamp_pan_offset"):
|
|
self.clamp_pan_offset()
|
|
|
|
self.update()
|
|
|
|
# Update scrollbars if available
|
|
main_window = self.window()
|
|
if hasattr(main_window, "update_scrollbars"):
|
|
main_window.update_scrollbars()
|
|
|
|
return True
|
|
|
|
# Handle Qt gesture events (fallback for other platforms)
|
|
elif event.type() == QEvent.Type.Gesture:
|
|
print("DEBUG: Qt Gesture event detected")
|
|
gesture_event = event
|
|
pinch = gesture_event.gesture(Qt.GestureType.PinchGesture)
|
|
|
|
if pinch:
|
|
print(f"DEBUG: Pinch gesture detected - state: {pinch.state()}, scale: {pinch.totalScaleFactor()}")
|
|
self._handle_pinch_gesture(pinch)
|
|
return True
|
|
|
|
return super().event(event)
|
|
|
|
def _handle_pinch_gesture(self, pinch):
|
|
"""Handle pinch gesture for zooming"""
|
|
from PyQt6.QtCore import Qt as QtCore
|
|
|
|
# Check gesture state
|
|
state = pinch.state()
|
|
|
|
if state == QtCore.GestureState.GestureStarted:
|
|
# Reset scale factor at gesture start
|
|
self._pinch_scale_factor = 1.0
|
|
return
|
|
|
|
elif state == QtCore.GestureState.GestureUpdated:
|
|
# Get current total scale factor
|
|
current_scale = pinch.totalScaleFactor()
|
|
|
|
# Calculate incremental change from last update
|
|
if current_scale > 0:
|
|
scale_change = current_scale / self._pinch_scale_factor
|
|
self._pinch_scale_factor = current_scale
|
|
|
|
# Get the center point of the pinch gesture
|
|
center_point = pinch.centerPoint()
|
|
mouse_x = center_point.x()
|
|
mouse_y = center_point.y()
|
|
|
|
# Calculate world coordinates at the pinch center
|
|
world_x = (mouse_x - self.pan_offset[0]) / self.zoom_level
|
|
world_y = (mouse_y - self.pan_offset[1]) / self.zoom_level
|
|
|
|
# Apply incremental zoom change
|
|
new_zoom = self.zoom_level * scale_change
|
|
|
|
# Clamp zoom level to reasonable bounds
|
|
if 0.1 <= new_zoom <= 5.0:
|
|
old_pan_x = self.pan_offset[0]
|
|
old_pan_y = self.pan_offset[1]
|
|
|
|
self.zoom_level = new_zoom
|
|
|
|
# Adjust pan offset to keep the pinch center point fixed
|
|
self.pan_offset[0] = mouse_x - world_x * self.zoom_level
|
|
self.pan_offset[1] = mouse_y - world_y * self.zoom_level
|
|
|
|
# If dragging, adjust drag_start_pos to account for pan_offset change
|
|
if hasattr(self, 'is_dragging') and self.is_dragging and hasattr(self, 'drag_start_pos') and self.drag_start_pos:
|
|
pan_delta_x = self.pan_offset[0] - old_pan_x
|
|
pan_delta_y = self.pan_offset[1] - old_pan_y
|
|
self.drag_start_pos = (self.drag_start_pos[0] + pan_delta_x, self.drag_start_pos[1] + pan_delta_y)
|
|
|
|
# Clamp pan offset to content bounds
|
|
if hasattr(self, "clamp_pan_offset"):
|
|
self.clamp_pan_offset()
|
|
|
|
self.update()
|
|
|
|
# Update status bar
|
|
main_window = self.window()
|
|
if hasattr(main_window, "status_bar"):
|
|
main_window.status_bar.showMessage(f"Zoom: {int(self.zoom_level * 100)}%", 2000)
|
|
|
|
# Update scrollbars if available
|
|
if hasattr(main_window, "update_scrollbars"):
|
|
main_window.update_scrollbars()
|
|
|
|
elif state == QtCore.GestureState.GestureFinished or state == QtCore.GestureState.GestureCanceled:
|
|
# Reset on gesture end
|
|
self._pinch_scale_factor = 1.0
|
|
|
|
def _apply_zoom_at_point(self, mouse_x, mouse_y, scale_factor):
|
|
"""Apply zoom centered at a specific point"""
|
|
# Calculate world coordinates at the zoom center
|
|
world_x = (mouse_x - self.pan_offset[0]) / self.zoom_level
|
|
world_y = (mouse_y - self.pan_offset[1]) / self.zoom_level
|
|
|
|
# Apply zoom
|
|
new_zoom = self.zoom_level * scale_factor
|
|
|
|
# Clamp zoom level to reasonable bounds
|
|
if 0.1 <= new_zoom <= 5.0:
|
|
old_pan_x = self.pan_offset[0]
|
|
old_pan_y = self.pan_offset[1]
|
|
|
|
self.zoom_level = new_zoom
|
|
|
|
# Adjust pan offset to keep the zoom center point fixed
|
|
self.pan_offset[0] = mouse_x - world_x * self.zoom_level
|
|
self.pan_offset[1] = mouse_y - world_y * self.zoom_level
|
|
|
|
# If dragging, adjust drag_start_pos to account for pan_offset change
|
|
if hasattr(self, 'is_dragging') and self.is_dragging and hasattr(self, 'drag_start_pos') and self.drag_start_pos:
|
|
pan_delta_x = self.pan_offset[0] - old_pan_x
|
|
pan_delta_y = self.pan_offset[1] - old_pan_y
|
|
self.drag_start_pos = (self.drag_start_pos[0] + pan_delta_x, self.drag_start_pos[1] + pan_delta_y)
|
|
|
|
# Clamp pan offset to content bounds
|
|
if hasattr(self, "clamp_pan_offset"):
|
|
self.clamp_pan_offset()
|
|
|
|
self.update()
|
|
|
|
# Update status bar
|
|
main_window = self.window()
|
|
if hasattr(main_window, "status_bar"):
|
|
main_window.status_bar.showMessage(f"Zoom: {int(self.zoom_level * 100)}%", 2000)
|
|
|
|
# Update scrollbars if available
|
|
if hasattr(main_window, "update_scrollbars"):
|
|
main_window.update_scrollbars()
|