new alginment options and fix for multiselect
All checks were successful
Python CI / test (push) Successful in 1m17s
Lint / lint (push) Successful in 1m13s
Tests / test (3.10) (push) Successful in 46s
Tests / test (3.11) (push) Successful in 45s
Tests / test (3.9) (push) Successful in 44s

This commit is contained in:
Duncan Tourolle 2025-11-10 22:16:43 +01:00
parent 375e87ec84
commit 3805b6b913
4 changed files with 438 additions and 28 deletions

View File

@ -360,3 +360,87 @@ class AlignmentManager:
current_y += elem.size[1] + spacing
return changes
@staticmethod
def fit_to_page_width(element: BaseLayoutElement, page_width: float) -> Tuple[BaseLayoutElement, Tuple[float, float], Tuple[float, float]]:
"""
Resize element to fit page width while maintaining aspect ratio.
Args:
element: The element to resize
page_width: The page width in mm
Returns:
Tuple of (element, old_position, old_size) for undo
"""
old_pos = element.position
old_size = element.size
# Calculate aspect ratio
aspect_ratio = old_size[1] / old_size[0]
# Set new size
new_width = page_width
new_height = page_width * aspect_ratio
element.size = (new_width, new_height)
return (element, old_pos, old_size)
@staticmethod
def fit_to_page_height(element: BaseLayoutElement, page_height: float) -> Tuple[BaseLayoutElement, Tuple[float, float], Tuple[float, float]]:
"""
Resize element to fit page height while maintaining aspect ratio.
Args:
element: The element to resize
page_height: The page height in mm
Returns:
Tuple of (element, old_position, old_size) for undo
"""
old_pos = element.position
old_size = element.size
# Calculate aspect ratio
aspect_ratio = old_size[0] / old_size[1]
# Set new size
new_height = page_height
new_width = page_height * aspect_ratio
element.size = (new_width, new_height)
return (element, old_pos, old_size)
@staticmethod
def fit_to_page(element: BaseLayoutElement, page_width: float, page_height: float) -> Tuple[BaseLayoutElement, Tuple[float, float], Tuple[float, float]]:
"""
Resize element to fit within page dimensions while maintaining aspect ratio.
Args:
element: The element to resize
page_width: The page width in mm
page_height: The page height in mm
Returns:
Tuple of (element, old_position, old_size) for undo
"""
old_pos = element.position
old_size = element.size
# Calculate aspect ratios
element_aspect = old_size[0] / old_size[1]
page_aspect = page_width / page_height
# Determine which dimension to fit to
if element_aspect > page_aspect:
# Element is wider than page - fit to width
new_width = page_width
new_height = page_width / element_aspect
else:
# Element is taller than page - fit to height
new_height = page_height
new_width = page_height * element_aspect
element.size = (new_width, new_height)
return (element, old_pos, old_size)

View File

@ -177,16 +177,16 @@ class GLWidget(UndoableInteractionMixin, QOpenGLWidget):
element._page_renderer = renderer
break
# Draw selection handles if element is selected
if self.selected_element:
self._draw_selection_handles()
# Draw selection handles for all selected elements
for selected_elem in self.selected_elements:
self._draw_selection_handles(selected_elem)
# 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:
def _draw_selection_handles(self, element):
"""Draw selection handles around the given element"""
if not element:
return
main_window = self.window()
@ -194,14 +194,14 @@ class GLWidget(UndoableInteractionMixin, QOpenGLWidget):
return
# Get the PageRenderer for this element (stored when element was selected)
if not hasattr(self.selected_element, '_page_renderer'):
if not hasattr(element, '_page_renderer'):
return
renderer = self.selected_element._page_renderer
renderer = 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
elem_x, elem_y = element.position
elem_w, elem_h = element.size
handle_size = 8
# Convert to screen coordinates using PageRenderer
@ -215,10 +215,10 @@ class GLWidget(UndoableInteractionMixin, QOpenGLWidget):
# Apply rotation if element is rotated
from OpenGL.GL import glPushMatrix, glPopMatrix, glTranslatef, glRotatef
if self.selected_element.rotation != 0:
if element.rotation != 0:
glPushMatrix()
glTranslatef(center_x, center_y, 0)
glRotatef(self.selected_element.rotation, 0, 0, 1)
glRotatef(element.rotation, 0, 0, 1)
glTranslatef(-w / 2, -h / 2, 0)
# Now draw as if at origin
x, y = 0, 0
@ -307,7 +307,7 @@ class GLWidget(UndoableInteractionMixin, QOpenGLWidget):
glEnd()
# Restore matrix if we applied rotation
if self.selected_element.rotation != 0:
if element.rotation != 0:
glPopMatrix()
def _render_text_overlays(self):
@ -614,8 +614,28 @@ class GLWidget(UndoableInteractionMixin, QOpenGLWidget):
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
# Get mouse movement in screen pixels
screen_dx = x - self.drag_start_pos[0]
screen_dy = y - self.drag_start_pos[1]
# Convert to page-local coordinates
total_dx = screen_dx / self.zoom_level
total_dy = screen_dy / self.zoom_level
# If element is rotated, transform the deltas through inverse rotation
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)
# Rotate the delta vector
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:
# Check if mouse is over a different page (for cross-page dragging)
@ -800,6 +820,31 @@ class GLWidget(UndoableInteractionMixin, QOpenGLWidget):
ew = elem_w * renderer.zoom
eh = elem_h * renderer.zoom
# Calculate center point
center_x = ex + ew / 2
center_y = ey + eh / 2
# If element is rotated, transform mouse coordinates through inverse rotation
if self.selected_element.rotation != 0:
import math
# Translate mouse to origin (relative to center)
rel_x = x - center_x
rel_y = y - center_y
# Apply inverse rotation
angle_rad = -math.radians(self.selected_element.rotation)
cos_a = math.cos(angle_rad)
sin_a = math.sin(angle_rad)
# Rotate the point
rotated_x = rel_x * cos_a - rel_y * sin_a
rotated_y = rel_x * sin_a + rel_y * cos_a
# Translate back
x = center_x + rotated_x
y = center_y + rotated_y
# Now check handles in non-rotated coordinate system
handles = {
'nw': (ex - handle_size/2, ey - handle_size/2),
'ne': (ex + ew - handle_size/2, ey - handle_size/2),

View File

@ -76,3 +76,97 @@ class SizeOperationsMixin:
self.project.history.execute(cmd)
self.update_view()
self.show_status(f"Resized {len(elements)} elements to same height", 2000)
@ribbon_action(
label="Fit Width",
tooltip="Fit selected element to page width",
tab="Arrange",
group="Size",
requires_selection=True,
min_selection=1
)
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.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)
@ribbon_action(
label="Fit Height",
tooltip="Fit selected element to page height",
tab="Arrange",
group="Size",
requires_selection=True,
min_selection=1
)
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.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)
@ribbon_action(
label="Fit to Page",
tooltip="Fit selected element to page dimensions",
tab="Arrange",
group="Size",
requires_selection=True,
min_selection=1
)
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.size[0]
page_height = page.size[1]
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)

187
test_multiselect.py Normal file
View File

@ -0,0 +1,187 @@
#!/usr/bin/env python3
"""
Test script to verify multiselect visual feedback functionality
"""
import sys
from unittest.mock import Mock, patch, MagicMock
from PyQt6.QtWidgets import QApplication
from pyPhotoAlbum.gl_widget import GLWidget
from pyPhotoAlbum.models import ImageData
from pyPhotoAlbum.project import Project, Page
from pyPhotoAlbum.page_layout import PageLayout
def test_multiselect_visual_feedback():
"""Test that all selected elements get selection handles drawn"""
print("Testing multiselect visual feedback...")
# Create a project with a page
project = Project("Test Project")
page_layout = PageLayout(width=200, height=200)
page = Page(layout=page_layout, page_number=1)
project.add_page(page)
# Create GL widget
widget = GLWidget()
# Mock the main window to return our project
mock_window = Mock()
mock_window.project = project
widget.window = Mock(return_value=mock_window)
# Create test elements
element1 = ImageData(image_path="test1.jpg", x=10, y=10, width=50, height=50)
element2 = ImageData(image_path="test2.jpg", x=70, y=70, width=50, height=50)
element3 = ImageData(image_path="test3.jpg", x=130, y=130, width=50, height=50)
# Set up page renderer mock for each element
mock_renderer = Mock()
mock_renderer.page_to_screen = Mock(side_effect=lambda x, y: (x, y))
mock_renderer.zoom = 1.0
element1._parent_page = page
element2._parent_page = page
element3._parent_page = page
element1._page_renderer = mock_renderer
element2._page_renderer = mock_renderer
element3._page_renderer = mock_renderer
# Add elements to page
page.layout.add_element(element1)
page.layout.add_element(element2)
page.layout.add_element(element3)
print(f"Created 3 test elements")
# Test 1: Single selection
print("\nTest 1: Single selection")
widget.selected_elements = {element1}
with patch.object(widget, '_draw_selection_handles') as mock_draw:
# Simulate paintGL call (only the relevant part)
for selected_elem in widget.selected_elements:
widget._draw_selection_handles(selected_elem)
assert mock_draw.call_count == 1, f"Expected 1 call, got {mock_draw.call_count}"
assert mock_draw.call_args[0][0] == element1, "Wrong element passed"
print(f"✓ Single selection: _draw_selection_handles called 1 time with element1")
# Test 2: Multiple selection (2 elements)
print("\nTest 2: Multiple selection (2 elements)")
widget.selected_elements = {element1, element2}
with patch.object(widget, '_draw_selection_handles') as mock_draw:
for selected_elem in widget.selected_elements:
widget._draw_selection_handles(selected_elem)
assert mock_draw.call_count == 2, f"Expected 2 calls, got {mock_draw.call_count}"
called_elements = {call[0][0] for call in mock_draw.call_args_list}
assert called_elements == {element1, element2}, f"Wrong elements passed: {called_elements}"
print(f"✓ Multiple selection (2): _draw_selection_handles called 2 times with correct elements")
# Test 3: Multiple selection (3 elements)
print("\nTest 3: Multiple selection (3 elements)")
widget.selected_elements = {element1, element2, element3}
with patch.object(widget, '_draw_selection_handles') as mock_draw:
for selected_elem in widget.selected_elements:
widget._draw_selection_handles(selected_elem)
assert mock_draw.call_count == 3, f"Expected 3 calls, got {mock_draw.call_count}"
called_elements = {call[0][0] for call in mock_draw.call_args_list}
assert called_elements == {element1, element2, element3}, f"Wrong elements passed: {called_elements}"
print(f"✓ Multiple selection (3): _draw_selection_handles called 3 times with correct elements")
# Test 4: No selection
print("\nTest 4: No selection")
widget.selected_elements = set()
with patch.object(widget, '_draw_selection_handles') as mock_draw:
for selected_elem in widget.selected_elements:
widget._draw_selection_handles(selected_elem)
assert mock_draw.call_count == 0, f"Expected 0 calls, got {mock_draw.call_count}"
print(f"✓ No selection: _draw_selection_handles not called")
# Test 5: Verify _draw_selection_handles receives correct element parameter
print("\nTest 5: Verify _draw_selection_handles uses passed element")
widget.selected_elements = {element2}
# Mock OpenGL functions
with patch('pyPhotoAlbum.gl_widget.glColor3f'), \
patch('pyPhotoAlbum.gl_widget.glLineWidth'), \
patch('pyPhotoAlbum.gl_widget.glBegin'), \
patch('pyPhotoAlbum.gl_widget.glEnd'), \
patch('pyPhotoAlbum.gl_widget.glVertex2f'), \
patch('pyPhotoAlbum.gl_widget.glPushMatrix'), \
patch('pyPhotoAlbum.gl_widget.glPopMatrix'), \
patch('pyPhotoAlbum.gl_widget.glTranslatef'), \
patch('pyPhotoAlbum.gl_widget.glRotatef'):
# Call the actual method
widget._draw_selection_handles(element2)
# Verify it used element2's properties
assert element2._page_renderer.page_to_screen.called, "page_to_screen should be called"
print(f"✓ _draw_selection_handles correctly uses the passed element parameter")
print("\n✓ All multiselect visual feedback tests passed!")
def test_regression_old_code_bug():
"""
Regression test: Verify the old bug (only first element gets handles)
would have been caught by this test
"""
print("\nRegression test: Simulating old buggy behavior...")
widget = GLWidget()
# Create mock elements
element1 = Mock()
element2 = Mock()
element3 = Mock()
# Select multiple elements
widget.selected_elements = {element1, element2, element3}
# OLD BUGGY CODE (what we fixed):
# if self.selected_element: # This only returns first element!
# self._draw_selection_handles()
# Simulate old behavior
call_count_old = 0
if widget.selected_element: # This property returns only first element
call_count_old = 1
# NEW CORRECT CODE:
# for selected_elem in self.selected_elements:
# self._draw_selection_handles(selected_elem)
# Simulate new behavior
call_count_new = 0
for selected_elem in widget.selected_elements:
call_count_new += 1
print(f"Old buggy code: would call _draw_selection_handles {call_count_old} time(s)")
print(f"New fixed code: calls _draw_selection_handles {call_count_new} time(s)")
assert call_count_old == 1, "Old code should only handle 1 element"
assert call_count_new == 3, "New code should handle all 3 elements"
print("✓ Regression test confirms the bug would have been caught!")
if __name__ == "__main__":
# Initialize Qt application (needed for PyQt6 widgets)
app = QApplication(sys.argv)
test_multiselect_visual_feedback()
test_regression_old_code_bug()
print("\n" + "="*60)
print("All tests completed successfully!")
print("="*60)