375 lines
14 KiB
Python
375 lines
14 KiB
Python
"""
|
|
Mouse interaction mixin for GLWidget - coordinates all mouse events
|
|
"""
|
|
|
|
import math
|
|
|
|
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 _handle_rotation_start(self, x: float, y: float):
|
|
"""Start rotation interaction for selected element."""
|
|
self._begin_rotate(self.selected_element)
|
|
self.drag_start_pos = (x, y)
|
|
self.rotation_start_angle = self.selected_element.rotation
|
|
self.is_dragging = True
|
|
|
|
def _handle_resize_start(self, x: float, y: float, handle):
|
|
"""Start resize interaction for selected element."""
|
|
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
|
|
|
|
def _handle_image_pan_start(self, x: float, y: float, element):
|
|
"""Start image pan mode for an ImageData element."""
|
|
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)
|
|
|
|
def _handle_multi_select(self, element):
|
|
"""Toggle element in multi-selection."""
|
|
if element in self.selected_elements:
|
|
self.selected_elements.remove(element)
|
|
else:
|
|
self.selected_elements.add(element)
|
|
|
|
def _handle_element_drag_start(self, x: float, y: float, element):
|
|
"""Start dragging an element."""
|
|
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
|
|
|
|
def mousePressEvent(self, event):
|
|
"""Handle mouse press events"""
|
|
self.setFocus()
|
|
|
|
if event.button() == Qt.MouseButton.LeftButton:
|
|
self._handle_left_click(event)
|
|
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 _handle_left_click(self, event):
|
|
"""Handle left mouse button click."""
|
|
x, y = event.position().x(), event.position().y()
|
|
ctrl_pressed = event.modifiers() & Qt.KeyboardModifier.ControlModifier
|
|
shift_pressed = event.modifiers() & Qt.KeyboardModifier.ShiftModifier
|
|
|
|
# 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
|
|
|
|
# Handle interaction with already-selected element
|
|
if len(self.selected_elements) == 1 and self.selected_element:
|
|
if self.rotation_mode:
|
|
self._handle_rotation_start(x, y)
|
|
return
|
|
else:
|
|
handle = self._get_resize_handle_at(x, y)
|
|
if handle:
|
|
self._handle_resize_start(x, y, handle)
|
|
return
|
|
|
|
# Handle click on element
|
|
element = self._get_element_at(x, y)
|
|
if element:
|
|
if ctrl_pressed and isinstance(element, ImageData) and not self.rotation_mode:
|
|
self._handle_image_pan_start(x, y, element)
|
|
elif ctrl_pressed or shift_pressed:
|
|
self._handle_multi_select(element)
|
|
else:
|
|
self._handle_element_drag_start(x, y, element)
|
|
else:
|
|
if not ctrl_pressed:
|
|
self.selected_elements.clear()
|
|
|
|
self.update()
|
|
|
|
def _handle_canvas_pan(self, x: float, y: float):
|
|
"""Handle canvas panning with middle mouse button."""
|
|
dx = x - self.drag_start_pos[0]
|
|
dy = y - self.drag_start_pos[1]
|
|
|
|
self.pan_offset[0] += dx
|
|
self.pan_offset[1] += dy
|
|
|
|
if hasattr(self, "clamp_pan_offset"):
|
|
self.clamp_pan_offset()
|
|
|
|
self.drag_start_pos = (x, y)
|
|
self.update()
|
|
|
|
main_window = self.window()
|
|
if hasattr(main_window, "update_scrollbars"):
|
|
main_window.update_scrollbars()
|
|
|
|
def _handle_rotation_move(self, x: float, y: float):
|
|
"""Handle element rotation during drag."""
|
|
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)
|
|
|
|
def _handle_resize_move(self, x: float, y: float):
|
|
"""Handle element resize during drag."""
|
|
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
|
|
|
|
self._resize_element(total_dx, total_dy)
|
|
|
|
def _handle_element_move(self, x: float, y: float):
|
|
"""Handle element movement during drag, including page transfer."""
|
|
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:
|
|
self._move_element_within_page(x, y, source_page)
|
|
else:
|
|
# No page context - simple move without snapping
|
|
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)
|
|
|
|
def _move_element_within_page(self, x: float, y: float, page):
|
|
"""Move element within its current page with snapping."""
|
|
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 = page.layout.snapping_system
|
|
page_size = 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,
|
|
project=main_window.project,
|
|
)
|
|
|
|
self.selected_element.position = snapped_pos
|
|
|
|
def mouseMoveEvent(self, event):
|
|
"""Handle mouse move events"""
|
|
x, y = event.position().x(), event.position().y()
|
|
|
|
self._update_page_status(x, y)
|
|
|
|
# Canvas panning (middle mouse button)
|
|
if self.is_panning and self.drag_start_pos:
|
|
self._handle_canvas_pan(x, y)
|
|
return
|
|
|
|
if not self.is_dragging or not self.drag_start_pos:
|
|
return
|
|
|
|
if not self.selected_element:
|
|
return
|
|
|
|
# Dispatch to appropriate handler based on interaction mode
|
|
if self.image_pan_mode:
|
|
self._handle_image_pan_move(x, y, self.selected_element)
|
|
elif self.rotation_mode:
|
|
self._handle_rotation_move(x, y)
|
|
elif self.resize_handle:
|
|
self._handle_resize_move(x, y)
|
|
else:
|
|
self._handle_element_move(x, 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:
|
|
old_zoom = self.zoom_level
|
|
old_pan_x = self.pan_offset[0]
|
|
old_pan_y = self.pan_offset[1]
|
|
|
|
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
|
|
|
|
# If dragging, adjust drag_start_pos to account for pan_offset change
|
|
if self.is_dragging 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()
|
|
|
|
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()
|
|
else:
|
|
# Regular wheel: Two-finger scroll (vertical and horizontal)
|
|
delta_x = event.angleDelta().x()
|
|
delta_y = event.angleDelta().y()
|
|
|
|
scroll_amount_x = delta_x * 0.5
|
|
scroll_amount_y = delta_y * 0.5
|
|
|
|
old_pan_x = self.pan_offset[0]
|
|
old_pan_y = self.pan_offset[1]
|
|
|
|
self.pan_offset[0] += scroll_amount_x
|
|
self.pan_offset[1] += scroll_amount_y
|
|
|
|
# Clamp pan offset to content bounds
|
|
if hasattr(self, "clamp_pan_offset"):
|
|
self.clamp_pan_offset()
|
|
|
|
# If dragging, adjust drag_start_pos to account for pan_offset change
|
|
if self.is_dragging 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)
|
|
|
|
self.update()
|
|
|
|
# Update scrollbars if available
|
|
main_window = self.window()
|
|
if hasattr(main_window, "update_scrollbars"):
|
|
main_window.update_scrollbars()
|
|
|
|
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]}...")
|