diff --git a/pyPhotoAlbum/alignment.py b/pyPhotoAlbum/alignment.py index 03a1487..15f0141 100644 --- a/pyPhotoAlbum/alignment.py +++ b/pyPhotoAlbum/alignment.py @@ -329,34 +329,118 @@ class AlignmentManager: def space_vertically(elements: List[BaseLayoutElement]) -> List[Tuple[BaseLayoutElement, Tuple[float, float]]]: """ Distribute elements with equal spacing between them vertically. - + Returns: List of (element, old_position) tuples for undo """ if len(elements) < 3: return [] - + # Sort by y position sorted_elements = sorted(elements, key=lambda e: e.position[1]) - + # Get topmost and bottommost boundaries min_y = sorted_elements[0].position[1] max_bottom = sorted_elements[-1].position[1] + sorted_elements[-1].size[1] - + # Calculate total height of all elements total_height = sum(elem.size[1] for elem in sorted_elements) - + # Calculate available space and spacing available_space = max_bottom - min_y - total_height spacing = available_space / (len(sorted_elements) - 1) - + changes = [] current_y = min_y - + for elem in sorted_elements: old_pos = elem.position elem.position = (elem.position[0], current_y) changes.append((elem, old_pos)) 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) diff --git a/pyPhotoAlbum/gl_widget.py b/pyPhotoAlbum/gl_widget.py index cc0e9d8..cbbc3da 100644 --- a/pyPhotoAlbum/gl_widget.py +++ b/pyPhotoAlbum/gl_widget.py @@ -177,31 +177,31 @@ 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() if not hasattr(main_window, 'project') or not main_window.project.pages: 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), diff --git a/pyPhotoAlbum/mixins/operations/size_ops.py b/pyPhotoAlbum/mixins/operations/size_ops.py index c406561..aa09c70 100644 --- a/pyPhotoAlbum/mixins/operations/size_ops.py +++ b/pyPhotoAlbum/mixins/operations/size_ops.py @@ -69,10 +69,104 @@ class SizeOperationsMixin: 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) + + @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) diff --git a/test_multiselect.py b/test_multiselect.py new file mode 100644 index 0000000..e3ee668 --- /dev/null +++ b/test_multiselect.py @@ -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)