refactor to reduce complexity of nesting
All checks were successful
Python CI / test (push) Successful in 1m41s
Lint / lint (push) Successful in 1m29s
Tests / test (3.11) (push) Successful in 1m46s
Tests / test (3.12) (push) Successful in 1m50s
Tests / test (3.13) (push) Successful in 1m43s
Tests / test (3.14) (push) Successful in 1m26s

This commit is contained in:
Duncan Tourolle 2026-01-01 18:42:58 +01:00
parent 293b568772
commit f0aa005d8c
7 changed files with 393 additions and 594 deletions

View File

@ -25,210 +25,227 @@ class MouseInteractionMixin:
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"""
# Ensure widget has focus for keyboard events
self.setFocus()
if event.button() == Qt.MouseButton.LeftButton:
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
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:
print(
f"DEBUG: Clicked on element: {element}, ctrl_pressed: {ctrl_pressed}, shift_pressed: {shift_pressed}"
)
# Check if Ctrl 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 (for non-ImageData elements or when Ctrl is pressed)
print(f"DEBUG: Multi-select mode triggered")
if element in self.selected_elements:
print(f"DEBUG: Removing element from selection")
self.selected_elements.remove(element)
else:
print(f"DEBUG: Adding element to selection. Current count: {len(self.selected_elements)}")
self.selected_elements.add(element)
print(f"DEBUG: Total selected elements: {len(self.selected_elements)}")
elif shift_pressed:
# Shift can be used for multi-select as well
if element in self.selected_elements:
self.selected_elements.remove(element)
else:
self.selected_elements.add(element)
else:
# Normal drag mode
print(f"DEBUG: Normal drag mode - single selection")
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()
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()
# Update status bar with page information
self._update_page_status(x, y)
# Canvas panning (middle mouse button)
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
# Clamp pan offset to content bounds
if hasattr(self, "clamp_pan_offset"):
self.clamp_pan_offset()
self.drag_start_pos = (x, y)
self.update()
# Update scrollbars if available
main_window = self.window()
if hasattr(main_window, "update_scrollbars"):
main_window.update_scrollbars()
self._handle_canvas_pan(x, y)
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)
if not self.selected_element:
return
elif self.rotation_mode:
# Rotation mode
if not hasattr(self.selected_element, "_page_renderer"):
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)
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
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,
project=main_window.project,
)
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()
self.update()
def mouseReleaseEvent(self, event):
"""Handle mouse release events"""

View File

@ -14,6 +14,25 @@ class AlignmentOperationsMixin:
"""Get list of selected elements for alignment operations"""
return list(self.gl_widget.selected_elements) if self.gl_widget.selected_elements else []
def _execute_alignment(self, alignment_func, status_msg: str):
"""
Execute an alignment operation with common boilerplate.
Args:
alignment_func: AlignmentManager method to call with elements
status_msg: Status message format string (will receive element count)
"""
elements = self._get_selected_elements_list()
if not self.require_selection(min_count=2):
return
changes = alignment_func(elements)
if changes:
cmd = AlignElementsCommand(changes)
self.project.history.execute(cmd)
self.update_view()
self.show_status(status_msg.format(len(elements)), 2000)
@ribbon_action(
label="Align Left",
tooltip="Align selected elements to the left",
@ -24,16 +43,7 @@ class AlignmentOperationsMixin:
)
def align_left(self):
"""Align selected elements to the left"""
elements = self._get_selected_elements_list()
if not self.require_selection(min_count=2):
return
changes = AlignmentManager.align_left(elements)
if changes:
cmd = AlignElementsCommand(changes)
self.project.history.execute(cmd)
self.update_view()
self.show_status(f"Aligned {len(elements)} elements to left", 2000)
self._execute_alignment(AlignmentManager.align_left, "Aligned {} elements to left")
@ribbon_action(
label="Align Right",
@ -45,16 +55,7 @@ class AlignmentOperationsMixin:
)
def align_right(self):
"""Align selected elements to the right"""
elements = self._get_selected_elements_list()
if not self.require_selection(min_count=2):
return
changes = AlignmentManager.align_right(elements)
if changes:
cmd = AlignElementsCommand(changes)
self.project.history.execute(cmd)
self.update_view()
self.show_status(f"Aligned {len(elements)} elements to right", 2000)
self._execute_alignment(AlignmentManager.align_right, "Aligned {} elements to right")
@ribbon_action(
label="Align Top",
@ -66,16 +67,7 @@ class AlignmentOperationsMixin:
)
def align_top(self):
"""Align selected elements to the top"""
elements = self._get_selected_elements_list()
if not self.require_selection(min_count=2):
return
changes = AlignmentManager.align_top(elements)
if changes:
cmd = AlignElementsCommand(changes)
self.project.history.execute(cmd)
self.update_view()
self.show_status(f"Aligned {len(elements)} elements to top", 2000)
self._execute_alignment(AlignmentManager.align_top, "Aligned {} elements to top")
@ribbon_action(
label="Align Bottom",
@ -87,16 +79,7 @@ class AlignmentOperationsMixin:
)
def align_bottom(self):
"""Align selected elements to the bottom"""
elements = self._get_selected_elements_list()
if not self.require_selection(min_count=2):
return
changes = AlignmentManager.align_bottom(elements)
if changes:
cmd = AlignElementsCommand(changes)
self.project.history.execute(cmd)
self.update_view()
self.show_status(f"Aligned {len(elements)} elements to bottom", 2000)
self._execute_alignment(AlignmentManager.align_bottom, "Aligned {} elements to bottom")
@ribbon_action(
label="Align H-Center",
@ -108,16 +91,7 @@ class AlignmentOperationsMixin:
)
def align_horizontal_center(self):
"""Align selected elements to horizontal center"""
elements = self._get_selected_elements_list()
if not self.require_selection(min_count=2):
return
changes = AlignmentManager.align_horizontal_center(elements)
if changes:
cmd = AlignElementsCommand(changes)
self.project.history.execute(cmd)
self.update_view()
self.show_status(f"Aligned {len(elements)} elements to horizontal center", 2000)
self._execute_alignment(AlignmentManager.align_horizontal_center, "Aligned {} elements to horizontal center")
@ribbon_action(
label="Align V-Center",
@ -129,16 +103,7 @@ class AlignmentOperationsMixin:
)
def align_vertical_center(self):
"""Align selected elements to vertical center"""
elements = self._get_selected_elements_list()
if not self.require_selection(min_count=2):
return
changes = AlignmentManager.align_vertical_center(elements)
if changes:
cmd = AlignElementsCommand(changes)
self.project.history.execute(cmd)
self.update_view()
self.show_status(f"Aligned {len(elements)} elements to vertical center", 2000)
self._execute_alignment(AlignmentManager.align_vertical_center, "Aligned {} elements to vertical center")
@ribbon_action(
label="Maximize Pattern",

View File

@ -14,6 +14,25 @@ class DistributionOperationsMixin:
"""Get list of selected elements for distribution operations"""
return list(self.gl_widget.selected_elements) if self.gl_widget.selected_elements else []
def _execute_distribution(self, distribution_func, status_msg: str):
"""
Execute a distribution operation with common boilerplate.
Args:
distribution_func: AlignmentManager method to call with elements
status_msg: Status message format string (will receive element count)
"""
elements = self._get_selected_elements_list()
if not self.require_selection(min_count=3):
return
changes = distribution_func(elements)
if changes:
cmd = AlignElementsCommand(changes)
self.project.history.execute(cmd)
self.update_view()
self.show_status(status_msg.format(len(elements)), 2000)
@ribbon_action(
label="Distribute H",
tooltip="Distribute selected elements evenly horizontally",
@ -24,16 +43,7 @@ class DistributionOperationsMixin:
)
def distribute_horizontally(self):
"""Distribute selected elements evenly horizontally"""
elements = self._get_selected_elements_list()
if not self.require_selection(min_count=3):
return
changes = AlignmentManager.distribute_horizontally(elements)
if changes:
cmd = AlignElementsCommand(changes)
self.project.history.execute(cmd)
self.update_view()
self.show_status(f"Distributed {len(elements)} elements horizontally", 2000)
self._execute_distribution(AlignmentManager.distribute_horizontally, "Distributed {} elements horizontally")
@ribbon_action(
label="Distribute V",
@ -45,16 +55,7 @@ class DistributionOperationsMixin:
)
def distribute_vertically(self):
"""Distribute selected elements evenly vertically"""
elements = self._get_selected_elements_list()
if not self.require_selection(min_count=3):
return
changes = AlignmentManager.distribute_vertically(elements)
if changes:
cmd = AlignElementsCommand(changes)
self.project.history.execute(cmd)
self.update_view()
self.show_status(f"Distributed {len(elements)} elements vertically", 2000)
self._execute_distribution(AlignmentManager.distribute_vertically, "Distributed {} elements vertically")
@ribbon_action(
label="Space H",
@ -66,16 +67,7 @@ class DistributionOperationsMixin:
)
def space_horizontally(self):
"""Space selected elements equally horizontally"""
elements = self._get_selected_elements_list()
if not self.require_selection(min_count=3):
return
changes = AlignmentManager.space_horizontally(elements)
if changes:
cmd = AlignElementsCommand(changes)
self.project.history.execute(cmd)
self.update_view()
self.show_status(f"Spaced {len(elements)} elements horizontally", 2000)
self._execute_distribution(AlignmentManager.space_horizontally, "Spaced {} elements horizontally")
@ribbon_action(
label="Space V",
@ -87,13 +79,4 @@ class DistributionOperationsMixin:
)
def space_vertically(self):
"""Space selected elements equally vertically"""
elements = self._get_selected_elements_list()
if not self.require_selection(min_count=3):
return
changes = AlignmentManager.space_vertically(elements)
if changes:
cmd = AlignElementsCommand(changes)
self.project.history.execute(cmd)
self.update_view()
self.show_status(f"Spaced {len(elements)} elements vertically", 2000)
self._execute_distribution(AlignmentManager.space_vertically, "Spaced {} elements vertically")

View File

@ -14,6 +14,50 @@ class SizeOperationsMixin:
"""Get list of selected elements for size operations"""
return list(self.gl_widget.selected_elements) if self.gl_widget.selected_elements else []
def _execute_resize(self, resize_func, status_msg: str):
"""
Execute a resize operation on multiple elements.
Args:
resize_func: AlignmentManager method to call with elements
status_msg: Status message format string (will receive element count)
"""
elements = self._get_selected_elements_list()
if not self.require_selection(min_count=2):
return
changes = resize_func(elements)
if changes:
cmd = ResizeElementsCommand(changes)
self.project.history.execute(cmd)
self.update_view()
self.show_status(status_msg.format(len(elements)), 2000)
def _execute_fit_to_page(self, fit_func, status_msg: str):
"""
Execute a fit-to-page operation on a single element.
Args:
fit_func: Function that takes (element, page) and returns a change tuple
status_msg: Status message to display on success
"""
if not self.require_selection(min_count=1):
return
page = self.get_current_page()
if not page:
self.show_warning("No Page", "Please create a page first.")
return
element = next(iter(self.gl_widget.selected_elements))
change = fit_func(element, page)
if change:
cmd = ResizeElementsCommand([change])
self.project.history.execute(cmd)
self.update_view()
self.show_status(status_msg, 2000)
@ribbon_action(
label="Same Size",
tooltip="Make all selected elements the same size",
@ -24,16 +68,7 @@ class SizeOperationsMixin:
)
def make_same_size(self):
"""Make all selected elements the same size"""
elements = self._get_selected_elements_list()
if not self.require_selection(min_count=2):
return
changes = AlignmentManager.make_same_size(elements)
if changes:
cmd = ResizeElementsCommand(changes)
self.project.history.execute(cmd)
self.update_view()
self.show_status(f"Resized {len(elements)} elements to same size", 2000)
self._execute_resize(AlignmentManager.make_same_size, "Resized {} elements to same size")
@ribbon_action(
label="Same Width",
@ -45,16 +80,7 @@ class SizeOperationsMixin:
)
def make_same_width(self):
"""Make all selected elements the same width"""
elements = self._get_selected_elements_list()
if not self.require_selection(min_count=2):
return
changes = AlignmentManager.make_same_width(elements)
if changes:
cmd = ResizeElementsCommand(changes)
self.project.history.execute(cmd)
self.update_view()
self.show_status(f"Resized {len(elements)} elements to same width", 2000)
self._execute_resize(AlignmentManager.make_same_width, "Resized {} elements to same width")
@ribbon_action(
label="Same Height",
@ -66,16 +92,7 @@ class SizeOperationsMixin:
)
def make_same_height(self):
"""Make all selected elements the same height"""
elements = self._get_selected_elements_list()
if not self.require_selection(min_count=2):
return
changes = AlignmentManager.make_same_height(elements)
if changes:
cmd = ResizeElementsCommand(changes)
self.project.history.execute(cmd)
self.update_view()
self.show_status(f"Resized {len(elements)} elements to same height", 2000)
self._execute_resize(AlignmentManager.make_same_height, "Resized {} elements to same height")
@ribbon_action(
label="Fit Width",
@ -87,26 +104,10 @@ class SizeOperationsMixin:
)
def fit_to_width(self):
"""Fit selected element to page width"""
if not self.require_selection(min_count=1):
return
page = self.get_current_page()
if not page:
self.show_warning("No Page", "Please create a page first.")
return
# Get the first selected element
element = next(iter(self.gl_widget.selected_elements))
# Fit to page width
page_width = page.layout.size[0]
change = AlignmentManager.fit_to_page_width(element, page_width)
if change:
cmd = ResizeElementsCommand([change])
self.project.history.execute(cmd)
self.update_view()
self.show_status("Fitted element to page width", 2000)
self._execute_fit_to_page(
lambda elem, page: AlignmentManager.fit_to_page_width(elem, page.layout.size[0]),
"Fitted element to page width",
)
@ribbon_action(
label="Fit Height",
@ -118,26 +119,10 @@ class SizeOperationsMixin:
)
def fit_to_height(self):
"""Fit selected element to page height"""
if not self.require_selection(min_count=1):
return
page = self.get_current_page()
if not page:
self.show_warning("No Page", "Please create a page first.")
return
# Get the first selected element
element = next(iter(self.gl_widget.selected_elements))
# Fit to page height
page_height = page.layout.size[1]
change = AlignmentManager.fit_to_page_height(element, page_height)
if change:
cmd = ResizeElementsCommand([change])
self.project.history.execute(cmd)
self.update_view()
self.show_status("Fitted element to page height", 2000)
self._execute_fit_to_page(
lambda elem, page: AlignmentManager.fit_to_page_height(elem, page.layout.size[1]),
"Fitted element to page height",
)
@ribbon_action(
label="Fit to Page",
@ -149,26 +134,10 @@ class SizeOperationsMixin:
)
def fit_to_page(self):
"""Fit selected element to page dimensions"""
if not self.require_selection(min_count=1):
return
page = self.get_current_page()
if not page:
self.show_warning("No Page", "Please create a page first.")
return
# Get the first selected element
element = next(iter(self.gl_widget.selected_elements))
# Fit to page
page_width, page_height = page.layout.size
change = AlignmentManager.fit_to_page(element, page_width, page_height)
if change:
cmd = ResizeElementsCommand([change])
self.project.history.execute(cmd)
self.update_view()
self.show_status("Fitted element to page", 2000)
self._execute_fit_to_page(
lambda elem, page: AlignmentManager.fit_to_page(elem, page.layout.size[0], page.layout.size[1]),
"Fitted element to page",
)
@ribbon_action(
label="Expand Image",

View File

@ -175,6 +175,84 @@ class ViewportMixin:
"height": total_height,
}
def _clamp_vertical_pan(self, viewport_height: float) -> float:
"""Clamp vertical pan offset and return the original value before clamping."""
bounds = self.get_content_bounds()
content_height = bounds["height"]
# Save original for page selection (prevents clamping from changing which page we target)
original_pan_y = self.pan_offset[1]
if content_height > viewport_height:
max_pan_up = 0 # Can't pan beyond top edge
min_pan_up = -(content_height - viewport_height) # Can't pan beyond bottom edge
self.pan_offset[1] = max(min_pan_up, min(max_pan_up, self.pan_offset[1]))
return original_pan_y
def _build_page_centerlines(self, pages, dpi: float) -> list:
"""Build list of (center_y, center_x, width) tuples for each page."""
PAGE_MARGIN = 50
PAGE_SPACING = 50
centerlines = []
current_y = PAGE_MARGIN
for page in pages:
page_width_mm, page_height_mm = page.layout.size
screen_page_width = page_width_mm * dpi / 25.4 * self.zoom_level
screen_page_height = page_height_mm * dpi / 25.4 * self.zoom_level
page_center_y = current_y + screen_page_height / 2
page_center_x = PAGE_MARGIN + screen_page_width / 2
centerlines.append((page_center_y, page_center_x, screen_page_width))
current_y += screen_page_height + PAGE_SPACING
return centerlines
def _interpolate_target_centerline(self, centerlines: list, viewport_center_y: float) -> tuple:
"""Find target centerline by interpolating between pages based on viewport position."""
if not centerlines:
return 0, 0
# Find the page index we're at or past
page_idx = self._find_page_at_viewport_y(centerlines, viewport_center_y)
if page_idx == 0:
return centerlines[0][1], centerlines[0][2]
# Interpolate between previous and current page
prev_y, prev_x, prev_w = centerlines[page_idx - 1]
curr_y, curr_x, curr_w = centerlines[page_idx]
if curr_y == prev_y:
return curr_x, curr_w
t = max(0, min(1, (viewport_center_y - prev_y) / (curr_y - prev_y)))
return prev_x + t * (curr_x - prev_x), prev_w + t * (curr_w - prev_w)
def _find_page_at_viewport_y(self, centerlines: list, viewport_center_y: float) -> int:
"""Find index of page at or after viewport Y position."""
for i, (page_y, _, _) in enumerate(centerlines):
if viewport_center_y <= page_y:
return i
return len(centerlines) - 1 # Below all pages - use last
def _clamp_horizontal_pan(self, viewport_width: float, target_centerline_x: float, target_page_width: float):
"""Clamp horizontal pan to keep viewport centered on target page."""
ideal_pan_x = viewport_width / 2 - target_centerline_x
if target_page_width > viewport_width:
max_deviation = (target_page_width / 2) + (viewport_width / 4)
else:
max_deviation = 100 # Small margin to avoid jitter
min_pan_x = ideal_pan_x - max_deviation
max_pan_x = ideal_pan_x + max_deviation
self.pan_offset[0] = max(min_pan_x, min(max_pan_x, self.pan_offset[0]))
def clamp_pan_offset(self):
"""
Clamp pan offset to prevent scrolling beyond content bounds.
@ -194,118 +272,18 @@ class ViewportMixin:
viewport_width = self.width()
viewport_height = self.height()
# Vertical clamping (returns original pan_y for page selection)
original_pan_y = self._clamp_vertical_pan(viewport_height)
# Build page centerline data
dpi = main_window.project.working_dpi
PAGE_MARGIN = 50
PAGE_SPACING = 50
# Vertical clamping
bounds = self.get_content_bounds()
content_height = bounds["height"]
# Save original pan_offset[1] BEFORE clamping for page selection
# This prevents clamping from changing which page we think we're on
original_pan_y = self.pan_offset[1]
if content_height > viewport_height:
# Content is taller than viewport - restrict panning
max_pan_up = 0 # Can't pan beyond top edge
min_pan_up = -(content_height - viewport_height) # Can't pan beyond bottom edge
self.pan_offset[1] = max(min_pan_up, min(max_pan_up, self.pan_offset[1]))
# Don't force centering when content fits - preserve scroll position
# This prevents jumping when zooming in/out across the content_height == viewport_height boundary
# Horizontal clamping - centerline-based approach
# Calculate the centerline position for each page and interpolate
# Build list of page centerlines and their Y positions
page_centerlines = []
current_y = PAGE_MARGIN
for page in main_window.project.pages:
page_width_mm, page_height_mm = page.layout.size
page_width_px = page_width_mm * dpi / 25.4
page_height_px = page_height_mm * dpi / 25.4
screen_page_width = page_width_px * self.zoom_level
screen_page_height = page_height_px * self.zoom_level
# Calculate page center Y position (in world coordinates)
page_center_y = current_y + screen_page_height / 2
# Calculate the centerline X position (center of the page)
# Pages are left-aligned at PAGE_MARGIN, so center is at PAGE_MARGIN + width/2
page_center_x = PAGE_MARGIN + screen_page_width / 2
page_centerlines.append((page_center_y, page_center_x, screen_page_width))
current_y += screen_page_height + PAGE_SPACING
if not page_centerlines:
centerlines = self._build_page_centerlines(main_window.project.pages, dpi)
if not centerlines:
return
# Determine current viewport center Y in world coordinates using ORIGINAL pan_y
# This prevents vertical clamping from changing which page we're targeting
# viewport_center_y in screen coords = viewport_height / 2
# Convert to world coords: world_y = (screen_y - pan_offset[1]) / zoom_level
# But we want screen position, so we use pan_offset directly
viewport_center_y_world = -original_pan_y + viewport_height / 2
# Find target centerline by interpolating based on viewport position
viewport_center_y = -original_pan_y + viewport_height / 2
target_x, target_width = self._interpolate_target_centerline(centerlines, viewport_center_y)
# Find which pages we're between and interpolate
target_centerline_x = page_centerlines[0][1] # Default to first page
target_page_width = page_centerlines[0][2]
selected_page_index = 0
for i in range(len(page_centerlines)):
page_y, page_x, page_w = page_centerlines[i]
if viewport_center_y_world <= page_y:
# We're above or at this page's center
if i == 0:
# First page
target_centerline_x = page_x
target_page_width = page_w
selected_page_index = 0
else:
# Interpolate between previous and current page
prev_y, prev_x, prev_w = page_centerlines[i - 1]
# Linear interpolation factor
if page_y != prev_y:
t = (viewport_center_y_world - prev_y) / (page_y - prev_y)
t = max(0, min(1, t)) # Clamp to [0, 1]
target_centerline_x = prev_x + t * (page_x - prev_x)
target_page_width = prev_w + t * (page_w - prev_w)
selected_page_index = i - 1 if t < 0.5 else i
else:
target_centerline_x = page_x
target_page_width = page_w
selected_page_index = i
break
else:
# We're below all pages - use last page
target_centerline_x = page_centerlines[-1][1]
target_page_width = page_centerlines[-1][2]
selected_page_index = len(page_centerlines) - 1
# Horizontal clamping to keep viewport reasonably centered on the page
# The centerline should ideally be at viewport_width / 2
ideal_pan_x = viewport_width / 2 - target_centerline_x
# Calculate how far we need to allow panning to see the full width of the page
# If page is wider than viewport, allow panning to see left and right edges
# If page is narrower, keep it centered
if target_page_width > viewport_width:
# Page wider than viewport - allow panning to see edges plus some margin
# Allow user to pan to see any part of the page, with reasonable overshoot
max_deviation = (target_page_width / 2) + (viewport_width / 4)
else:
# Page narrower than viewport - keep centered with small margin for stability
max_deviation = 100 # Small margin to avoid jitter
# Calculate bounds
min_pan_x = ideal_pan_x - max_deviation
max_pan_x = ideal_pan_x + max_deviation
old_pan_x = self.pan_offset[0]
self.pan_offset[0] = max(min_pan_x, min(max_pan_x, self.pan_offset[0]))
# Horizontal clamping
self._clamp_horizontal_pan(viewport_width, target_x, target_width)

View File

@ -370,74 +370,6 @@ class SnappingSystem:
return best_snap
def _snap_axis(
self, position: float, size: float, page_size_mm: float, dpi: int, snap_threshold_px: float, orientation: str
) -> float:
"""
Snap along a single axis
Args:
position: Current position along axis in pixels
size: Element size along axis in pixels
page_size_mm: Page size along axis in mm
dpi: DPI for conversion
snap_threshold_px: Snap threshold in pixels
orientation: 'vertical' for x-axis, 'horizontal' for y-axis
Returns:
Snapped position in pixels
"""
snap_candidates: List[Tuple[float, float]] = []
# 1. Page edge snapping
if self.snap_to_edges:
# Snap to start edge (0)
snap_candidates.append((0.0, abs(position - 0)))
# Snap to end edge
page_size_px = page_size_mm * dpi / 25.4
snap_candidates.append((page_size_px - size, abs(position - (page_size_px - size))))
# Also snap element's far edge to page edge
snap_candidates.append((page_size_px - size, abs((position + size) - page_size_px)))
# 2. Grid snapping
if self.snap_to_grid:
grid_size_px = self.grid_size_mm * dpi / 25.4
# Snap to nearest grid line
nearest_grid = round(position / grid_size_px) * grid_size_px
snap_candidates.append((nearest_grid, abs(position - nearest_grid)))
# Also try snapping element's far edge to grid
element_end = position + size
nearest_grid_end = round(element_end / grid_size_px) * grid_size_px
snap_candidates.append((nearest_grid_end - size, abs(element_end - nearest_grid_end)))
# 3. Guide snapping
if self.snap_to_guides:
for guide in self.guides:
if guide.orientation == orientation:
guide_pos_px = guide.position * dpi / 25.4
# Snap start edge to guide
snap_candidates.append((guide_pos_px, abs(position - guide_pos_px)))
# Snap end edge to guide
element_end = position + size
snap_candidates.append((guide_pos_px - size, abs(element_end - guide_pos_px)))
# Find the best snap candidate within threshold
best_snap = None
best_distance = snap_threshold_px
for snap_pos, distance in snap_candidates:
if distance < best_distance:
best_snap = snap_pos
best_distance = distance
return best_snap if best_snap is not None else position
def get_snap_lines(self, page_size: Tuple[float, float], dpi: int = 300) -> dict:
"""
Get all snap lines for visualization

View File

@ -462,51 +462,6 @@ class TestSnapEdgeToTargets:
assert result is None
class TestSnapAxis:
"""Tests for _snap_axis method"""
def test_snap_axis_to_start(self):
"""Test snapping axis to start edge"""
snap = SnappingSystem()
snap.snap_to_edges = True
snap.snap_to_grid = False
snap.snap_to_guides = False
dpi = 300
threshold_px = 50
result = snap._snap_axis(
position=10, size=50, page_size_mm=210, dpi=dpi, snap_threshold_px=threshold_px, orientation="vertical"
)
assert result == 0
def test_snap_axis_to_end(self):
"""Test snapping axis so element end aligns with page end"""
snap = SnappingSystem()
snap.snap_to_edges = True
snap.snap_to_grid = False
snap.snap_to_guides = False
dpi = 300
page_size_mm = 210
page_size_px = page_size_mm * dpi / 25.4
element_size = 50
threshold_px = 50
result = snap._snap_axis(
position=page_size_px - element_size - 10,
size=element_size,
page_size_mm=page_size_mm,
dpi=dpi,
snap_threshold_px=threshold_px,
orientation="vertical",
)
expected = page_size_px - element_size
assert abs(result - expected) < 1
class TestGetSnapLines:
"""Tests for get_snap_lines method"""