From 4bfaa63aae074bbb510eafedd4153983be425e7c Mon Sep 17 00:00:00 2001 From: Duncan Tourolle Date: Wed, 29 Oct 2025 20:30:57 +0100 Subject: [PATCH] Fix rotation --- pyPhotoAlbum/commands.py | 45 +++++++++++++ pyPhotoAlbum/gl_widget.py | 89 ++++++++++++++++++++++++- pyPhotoAlbum/mixins/interaction_undo.py | 43 ++++++++++++ pyPhotoAlbum/models.py | 56 +++++++++++++++- 4 files changed, 228 insertions(+), 5 deletions(-) diff --git a/pyPhotoAlbum/commands.py b/pyPhotoAlbum/commands.py index a047f2f..d976387 100644 --- a/pyPhotoAlbum/commands.py +++ b/pyPhotoAlbum/commands.py @@ -348,6 +348,49 @@ class RotateElementCommand(Command): ) +class AdjustImageCropCommand(Command): + """Command for adjusting image crop/pan within frame""" + + def __init__(self, element: ImageData, old_crop_info: tuple, new_crop_info: tuple): + self.element = element + self.old_crop_info = old_crop_info + self.new_crop_info = new_crop_info + + def execute(self): + """Apply new crop info""" + self.element.crop_info = self.new_crop_info + + def undo(self): + """Restore old crop info""" + self.element.crop_info = self.old_crop_info + + def redo(self): + """Apply new crop info again""" + self.execute() + + def serialize(self) -> Dict[str, Any]: + """Serialize to dictionary""" + return { + "type": "adjust_image_crop", + "element": self.element.serialize(), + "old_crop_info": self.old_crop_info, + "new_crop_info": self.new_crop_info + } + + @staticmethod + def deserialize(data: Dict[str, Any], project) -> 'AdjustImageCropCommand': + """Deserialize from dictionary""" + elem_data = data["element"] + element = ImageData() + element.deserialize(elem_data) + + return AdjustImageCropCommand( + element, + tuple(data["old_crop_info"]), + tuple(data["new_crop_info"]) + ) + + class AlignElementsCommand(Command): """Command for aligning multiple elements""" @@ -717,6 +760,8 @@ class CommandHistory: return ResizeElementsCommand.deserialize(data, project) elif cmd_type == "change_zorder": return ChangeZOrderCommand.deserialize(data, project) + elif cmd_type == "adjust_image_crop": + return AdjustImageCropCommand.deserialize(data, project) else: print(f"Warning: Unknown command type: {cmd_type}") return None diff --git a/pyPhotoAlbum/gl_widget.py b/pyPhotoAlbum/gl_widget.py index 2e03994..25f188a 100644 --- a/pyPhotoAlbum/gl_widget.py +++ b/pyPhotoAlbum/gl_widget.py @@ -41,6 +41,10 @@ class GLWidget(UndoableInteractionMixin, QOpenGLWidget): self.rotation_start_angle = None # Starting rotation angle self.rotation_snap_angle = 15 # Default snap angle in degrees + # Image pan state (for panning image within frame with Control key) + self.image_pan_mode = False # True when Control+dragging an ImageData element + self.image_pan_start_crop = None # Starting crop_info when pan begins + # Zoom and pan state self.zoom_level = 1.0 self.pan_offset = [0, 0] @@ -209,6 +213,16 @@ class GLWidget(UndoableInteractionMixin, QOpenGLWidget): center_x = x + w / 2 center_y = y + h / 2 + # Apply rotation if element is rotated + from OpenGL.GL import glPushMatrix, glPopMatrix, glTranslatef, glRotatef + if self.selected_element.rotation != 0: + glPushMatrix() + glTranslatef(center_x, center_y, 0) + glRotatef(self.selected_element.rotation, 0, 0, 1) + glTranslatef(-w / 2, -h / 2, 0) + # Now draw as if at origin + x, y = 0, 0 + # Draw selection border if self.rotation_mode: glColor3f(1.0, 0.5, 0.0) # Orange for rotation mode @@ -291,6 +305,10 @@ class GLWidget(UndoableInteractionMixin, QOpenGLWidget): glVertex2f(hx + handle_size, hy + handle_size) glVertex2f(hx, hy + handle_size) glEnd() + + # Restore matrix if we applied rotation + if self.selected_element.rotation != 0: + glPopMatrix() def _render_text_overlays(self): """Render text content for TextBoxData elements using QPainter overlay""" @@ -448,12 +466,25 @@ class GLWidget(UndoableInteractionMixin, QOpenGLWidget): element = self._get_element_at(x, y) if element: - if ctrl_pressed: + # 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) # Show move cursor + print(f"Entered image pan mode for {element}") + elif ctrl_pressed: + # Multi-select mode (for non-ImageData or when not dragging) 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 @@ -493,7 +524,57 @@ class GLWidget(UndoableInteractionMixin, QOpenGLWidget): return if self.selected_element: - if self.rotation_mode: + if self.image_pan_mode: + # Image pan mode - adjust crop_info to pan image within frame + if not isinstance(self.selected_element, ImageData): + return + + # Calculate mouse movement in screen pixels + screen_dx = x - self.drag_start_pos[0] + screen_dy = y - self.drag_start_pos[1] + + # Get element size in page-local coordinates + elem_w, elem_h = self.selected_element.size + + # Convert screen movement to normalized crop coordinates + # Negative because moving mouse right should pan image left (show more of right side) + # Scale by zoom level and element size + crop_dx = -screen_dx / (elem_w * self.zoom_level) + crop_dy = -screen_dy / (elem_h * self.zoom_level) + + # Get starting crop info + start_crop = self.image_pan_start_crop + if not start_crop: + start_crop = (0, 0, 1, 1) + + # Calculate new crop_info + crop_width = start_crop[2] - start_crop[0] + crop_height = start_crop[3] - start_crop[1] + + new_x_min = start_crop[0] + crop_dx + new_y_min = start_crop[1] + crop_dy + new_x_max = new_x_min + crop_width + new_y_max = new_y_min + crop_height + + # Clamp to valid range (0-1) to prevent panning beyond image boundaries + if new_x_min < 0: + new_x_min = 0 + new_x_max = crop_width + if new_x_max > 1: + new_x_max = 1 + new_x_min = 1 - crop_width + + if new_y_min < 0: + new_y_min = 0 + new_y_max = crop_height + if new_y_max > 1: + new_y_max = 1 + new_y_min = 1 - crop_height + + # Update element's crop_info + self.selected_element.crop_info = (new_x_min, new_y_min, new_x_max, new_y_max) + + elif self.rotation_mode: # Calculate rotation angle from mouse position relative to element center import math @@ -528,6 +609,7 @@ class GLWidget(UndoableInteractionMixin, QOpenGLWidget): self.selected_element.rotation = angle # Show current angle in status bar + main_window = self.window() if hasattr(main_window, 'show_status'): main_window.show_status(f"Rotation: {angle:.1f}°", 100) @@ -591,11 +673,14 @@ class GLWidget(UndoableInteractionMixin, QOpenGLWidget): 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) # Reset cursor elif event.button() == Qt.MouseButton.MiddleButton: self.is_panning = False diff --git a/pyPhotoAlbum/mixins/interaction_undo.py b/pyPhotoAlbum/mixins/interaction_undo.py index aedbf3a..bd4fa96 100644 --- a/pyPhotoAlbum/mixins/interaction_undo.py +++ b/pyPhotoAlbum/mixins/interaction_undo.py @@ -64,6 +64,24 @@ class UndoableInteractionMixin: self._interaction_start_size = None self._interaction_start_rotation = element.rotation + def _begin_image_pan(self, element): + """ + Begin tracking an image pan operation. + + Args: + element: The ImageData element being panned + """ + from pyPhotoAlbum.models import ImageData + if not isinstance(element, ImageData): + return + + self._interaction_element = element + self._interaction_type = 'image_pan' + self._interaction_start_pos = None + self._interaction_start_size = None + self._interaction_start_rotation = None + self._interaction_start_crop_info = element.crop_info + def _end_interaction(self): """ End the current interaction and create appropriate undo/redo command. @@ -142,6 +160,29 @@ class UndoableInteractionMixin: ) print(f"Rotation command created: {self._interaction_start_rotation:.1f}° → {new_rotation:.1f}°") + elif self._interaction_type == 'image_pan': + # Check if crop_info actually changed + from pyPhotoAlbum.models import ImageData + if isinstance(element, ImageData): + new_crop_info = element.crop_info + if hasattr(self, '_interaction_start_crop_info') and self._interaction_start_crop_info is not None: + # Check if crop changed significantly (more than 0.001 in any coordinate) + if new_crop_info != self._interaction_start_crop_info: + old_crop = self._interaction_start_crop_info + significant_change = any( + abs(new_crop_info[i] - old_crop[i]) > 0.001 + for i in range(4) + ) + + if significant_change: + from pyPhotoAlbum.commands import AdjustImageCropCommand + command = AdjustImageCropCommand( + element, + self._interaction_start_crop_info, + new_crop_info + ) + print(f"Image pan command created: {self._interaction_start_crop_info} → {new_crop_info}") + # Execute the command through history if one was created if command: main_window.project.history.execute(command) @@ -156,6 +197,8 @@ class UndoableInteractionMixin: self._interaction_start_pos = None self._interaction_start_size = None self._interaction_start_rotation = None + if hasattr(self, '_interaction_start_crop_info'): + self._interaction_start_crop_info = None def _cancel_interaction(self): """Cancel the current interaction without creating a command""" diff --git a/pyPhotoAlbum/models.py b/pyPhotoAlbum/models.py index 81a50d2..6e649c7 100644 --- a/pyPhotoAlbum/models.py +++ b/pyPhotoAlbum/models.py @@ -44,13 +44,25 @@ class ImageData(BaseLayoutElement): glEnable, glDisable, GL_TEXTURE_2D, glBindTexture, glTexCoord2f, glGenTextures, glTexImage2D, GL_RGBA, GL_UNSIGNED_BYTE, glTexParameteri, GL_TEXTURE_MIN_FILTER, GL_TEXTURE_MAG_FILTER, GL_LINEAR, - glDeleteTextures) + glDeleteTextures, glPushMatrix, glPopMatrix, glTranslatef, glRotatef) from PIL import Image import os x, y = self.position w, h = self.size + # Apply rotation if needed + if self.rotation != 0: + glPushMatrix() + # Translate to center of element + center_x = x + w / 2 + center_y = y + h / 2 + glTranslatef(center_x, center_y, 0) + glRotatef(self.rotation, 0, 0, 1) + glTranslatef(-w / 2, -h / 2, 0) + # Now render at origin (rotation pivot is at element center) + x, y = 0, 0 + # Try to load and render the actual image texture_id = None @@ -191,6 +203,10 @@ class ImageData(BaseLayoutElement): glVertex2f(x + w, y + h) glVertex2f(x, y + h) glEnd() + + # Pop matrix if we pushed for rotation + if self.rotation != 0: + glPopMatrix() def serialize(self) -> Dict[str, Any]: """Serialize image data to dictionary""" @@ -223,11 +239,24 @@ class PlaceholderData(BaseLayoutElement): def render(self): """Render the placeholder using OpenGL""" - from OpenGL.GL import glBegin, glEnd, glVertex2f, glColor3f, GL_QUADS, GL_LINE_LOOP, glLineStipple, glEnable, glDisable, GL_LINE_STIPPLE + from OpenGL.GL import (glBegin, glEnd, glVertex2f, glColor3f, GL_QUADS, GL_LINE_LOOP, glLineStipple, + glEnable, glDisable, GL_LINE_STIPPLE, glPushMatrix, glPopMatrix, glTranslatef, glRotatef) x, y = self.position w, h = self.size + # Apply rotation if needed + if self.rotation != 0: + glPushMatrix() + # Translate to center of element + center_x = x + w / 2 + center_y = y + h / 2 + glTranslatef(center_x, center_y, 0) + glRotatef(self.rotation, 0, 0, 1) + glTranslatef(-w / 2, -h / 2, 0) + # Now render at origin (rotation pivot is at element center) + x, y = 0, 0 + # Draw a light gray rectangle as placeholder background glColor3f(0.9, 0.9, 0.9) # Light gray glBegin(GL_QUADS) @@ -248,6 +277,10 @@ class PlaceholderData(BaseLayoutElement): glVertex2f(x, y + h) glEnd() glDisable(GL_LINE_STIPPLE) + + # Pop matrix if we pushed for rotation + if self.rotation != 0: + glPopMatrix() def serialize(self) -> Dict[str, Any]: """Serialize placeholder data to dictionary""" @@ -282,11 +315,24 @@ class TextBoxData(BaseLayoutElement): def render(self): """Render the text box using OpenGL""" from OpenGL.GL import (glBegin, glEnd, glVertex2f, glColor3f, glColor4f, GL_QUADS, GL_LINE_LOOP, - glEnable, glDisable, GL_BLEND, glBlendFunc, GL_SRC_ALPHA, GL_ONE_MINUS_SRC_ALPHA) + glEnable, glDisable, GL_BLEND, glBlendFunc, GL_SRC_ALPHA, GL_ONE_MINUS_SRC_ALPHA, + glPushMatrix, glPopMatrix, glTranslatef, glRotatef) x, y = self.position w, h = self.size + # Apply rotation if needed + if self.rotation != 0: + glPushMatrix() + # Translate to center of element + center_x = x + w / 2 + center_y = y + h / 2 + glTranslatef(center_x, center_y, 0) + glRotatef(self.rotation, 0, 0, 1) + glTranslatef(-w / 2, -h / 2, 0) + # Now render at origin (rotation pivot is at element center) + x, y = 0, 0 + # Enable alpha blending for transparency glEnable(GL_BLEND) glBlendFunc(GL_SRC_ALPHA, GL_ONE_MINUS_SRC_ALPHA) @@ -311,6 +357,10 @@ class TextBoxData(BaseLayoutElement): glVertex2f(x, y + h) glEnd() + # Pop matrix if we pushed for rotation + if self.rotation != 0: + glPopMatrix() + # Note: Text content is rendered using QPainter overlay in GLWidget.paintGL() def serialize(self) -> Dict[str, Any]: