pyPhotoAlbum/pyPhotoAlbum/mixins/mouse_interaction.py
Duncan Tourolle 7f32858baf
All checks were successful
Python CI / test (push) Successful in 1m7s
Lint / lint (push) Successful in 1m11s
Tests / test (3.10) (push) Successful in 50s
Tests / test (3.11) (push) Successful in 51s
Tests / test (3.9) (push) Successful in 47s
big refactor to use mixin architecture
2025-11-11 10:35:24 +01:00

299 lines
12 KiB
Python

"""
Mouse interaction mixin for GLWidget - coordinates all mouse events
"""
from PyQt6.QtCore import Qt
from pyPhotoAlbum.models import ImageData
class MouseInteractionMixin:
"""
Mixin providing mouse event handling and coordination.
This mixin routes mouse events to appropriate other mixins based on
the current interaction state.
"""
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
# Mouse interaction state
self.drag_start_pos = None
self.drag_start_element_pos = None
self.is_dragging = False
self.is_panning = False
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 tracking
self._begin_rotate(self.selected_element)
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._begin_resize(self.selected_element)
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:
# Check if Control is pressed and element is ImageData - enter image pan mode
if ctrl_pressed and isinstance(element, ImageData) and not self.rotation_mode:
# Enter image pan mode - pan image within frame
self.selected_elements = {element}
self.drag_start_pos = (x, y)
self.image_pan_mode = True
self.image_pan_start_crop = element.crop_info
self._begin_image_pan(element)
self.is_dragging = True
self.setCursor(Qt.CursorShape.SizeAllCursor)
print(f"Entered image pan mode for {element}")
elif ctrl_pressed:
# Multi-select mode
if element in self.selected_elements:
self.selected_elements.remove(element)
else:
self.selected_elements.add(element)
else:
# Normal drag mode
self.selected_elements = {element}
self.drag_start_pos = (x, y)
self.drag_start_element_pos = element.position
if not self.rotation_mode:
self._begin_move(element)
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.image_pan_mode:
# Image pan mode - delegate to ImagePanMixin
self._handle_image_pan_move(x, y, self.selected_element)
elif self.rotation_mode:
# Rotation mode
import math
if not hasattr(self.selected_element, '_page_renderer'):
return
renderer = self.selected_element._page_renderer
elem_x, elem_y = self.selected_element.position
elem_w, elem_h = self.selected_element.size
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)
dx = x - screen_center_x
dy = y - screen_center_y
angle = math.degrees(math.atan2(dy, dx))
angle = round(angle / self.rotation_snap_angle) * self.rotation_snap_angle
angle = angle % 360
self.selected_element.rotation = angle
main_window = self.window()
if hasattr(main_window, 'show_status'):
main_window.show_status(f"Rotation: {angle:.1f}°", 100)
elif self.resize_handle:
# Resize mode
screen_dx = x - self.drag_start_pos[0]
screen_dy = y - self.drag_start_pos[1]
total_dx = screen_dx / self.zoom_level
total_dy = screen_dy / self.zoom_level
if self.selected_element.rotation != 0:
import math
angle_rad = -math.radians(self.selected_element.rotation)
cos_a = math.cos(angle_rad)
sin_a = math.sin(angle_rad)
rotated_dx = total_dx * cos_a - total_dy * sin_a
rotated_dy = total_dx * sin_a + total_dy * cos_a
total_dx = rotated_dx
total_dy = rotated_dy
self._resize_element(total_dx, total_dy)
else:
# Move mode
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
if current_page is not source_page:
self._transfer_element_to_page(self.selected_element, source_page, current_page, x, y, current_renderer)
else:
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
main_window = self.window()
snap_sys = source_page.layout.snapping_system
page_size = source_page.layout.size
dpi = main_window.project.working_dpi
snapped_pos = snap_sys.snap_position(
position=(new_x, new_y),
size=self.selected_element.size,
page_size=page_size,
dpi=dpi
)
self.selected_element.position = snapped_pos
else:
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:
self._end_interaction()
self.is_dragging = False
self.drag_start_pos = None
self.drag_start_element_pos = None
self.resize_handle = None
self.rotation_start_angle = None
self.image_pan_mode = False
self.image_pan_start_crop = None
self.snap_state = {
'is_snapped': False,
'last_position': None,
'last_size': None
}
self.setCursor(Qt.CursorShape.ArrowCursor)
elif event.button() == Qt.MouseButton.MiddleButton:
self.is_panning = False
self.drag_start_pos = None
self.setCursor(Qt.CursorShape.ArrowCursor)
def mouseDoubleClickEvent(self, event):
"""Handle mouse double-click events"""
if event.button() == Qt.MouseButton.LeftButton:
x, y = event.position().x(), event.position().y()
element = self._get_element_at(x, y)
from pyPhotoAlbum.models import TextBoxData
if isinstance(element, TextBoxData):
self._edit_text_element(element)
return
super().mouseDoubleClickEvent(event)
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()
world_x = (mouse_x - self.pan_offset[0]) / self.zoom_level
world_y = (mouse_y - self.pan_offset[1]) / self.zoom_level
zoom_factor = 1.1 if delta > 0 else 0.9
new_zoom = self.zoom_level * zoom_factor
if 0.1 <= new_zoom <= 5.0:
self.zoom_level = new_zoom
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
self.pan_offset[1] += scroll_amount
self.update()
def _edit_text_element(self, text_element):
"""Open dialog to edit text element"""
from pyPhotoAlbum.text_edit_dialog import TextEditDialog
dialog = TextEditDialog(text_element, self)
if dialog.exec() == TextEditDialog.DialogCode.Accepted:
values = dialog.get_values()
text_element.text_content = values['text_content']
text_element.font_settings = values['font_settings']
text_element.alignment = values['alignment']
self.update()
print(f"Updated text element: {values['text_content'][:50]}...")