1 """

2 Element manipulation mixin for GLWidget - handles element transformations

3 """

4

5 from typing import TYPE_CHECKING, Any, Optional, Tuple

6

7 if TYPE_CHECKING:

8 from pyPhotoAlbum.models import BaseLayoutElement

9 from PyQt6.QtWidgets import QMainWindow

10

11

12 class ElementManipulationMixin:

13 # Type hints for expected attributes from mixing class

14 selected_element: Optional["BaseLayoutElement"]

15 drag_start_pos: Optional[Tuple[float, float]]

16 drag_start_element_pos: Optional[Tuple[float, float]]

17

18 def window(self) -> "QMainWindow": # type: ignore[empty-body]

19 """Expected from QWidget"""

20 ...

21

22 def __init__(self, *args, **kwargs):

23 """

24 Initialize element manipulation mixin.

25

26 This mixin provides element transformation functionality including

27 resizing, rotating, moving elements, snapping support and cross-page

28 element transfers.

29 """

30 super().__init__(*args, **kwargs)

31

32 # Resize state

33 self.resize_handle: Optional[str] = None # 'nw', 'ne', 'sw', 'se'

34 self.resize_start_pos: Optional[Tuple[float, float]] = None

35 self.resize_start_size: Optional[Tuple[float, float]] = None

36

37 # Rotation state

38 self.rotation_mode: bool = False # Toggle between move/resize and rotation modes

39 self.rotation_start_angle: Optional[float] = None

40 self.rotation_snap_angle: int = 15 # Default snap angle in degrees

41

42 # Snap state tracking

43 self.snap_state = {"is_snapped": False, "last_position": None, "last_size": None}

44

45 def _resize_element(self, dx: float, dy: float):

46 """

47 Resize the element based on the resize handle.

48

49 Args:

50 dx: Delta X in page-local coordinates

51 dy: Delta Y in page-local coordinates

52 """

53 if not self.selected_element or not self.resize_handle:

54 return

55

56 if not self.resize_start_pos or not self.resize_start_size:

57 return

58

59 # Get the snapping system from the element's parent page

60 main_window = self.window()

61 if not hasattr(self.selected_element, "_parent_page"):

62 self._resize_element_no_snap(dx, dy)

63 return

64

65 parent_page = self.selected_element._parent_page # type: ignore[attr-defined]

66 snap_sys = parent_page.layout.snapping_system

67

68 # Get page size

69 page_size = parent_page.layout.size

70 dpi = main_window.project.working_dpi # type: ignore[attr-defined]

71

72 # Apply snapping to resize

73 from pyPhotoAlbum.snapping import SnapResizeParams

74

75 params = SnapResizeParams(

76 position=self.resize_start_pos,

77 size=self.resize_start_size,

78 dx=dx,

79 dy=dy,

80 resize_handle=self.resize_handle,

81 page_size=page_size,

82 dpi=dpi,

83 project=main_window.project, # type: ignore[attr-defined]

84 )

85 new_pos, new_size = snap_sys.snap_resize(params)

86

87 # Apply the snapped values

88 self.selected_element.position = new_pos

89 self.selected_element.size = new_size

90

91 # Ensure minimum size

92 min_size = 20

93 w, h = self.selected_element.size

94 if w < min_size or h < min_size:

95 w = max(w, min_size)

96 h = max(h, min_size)

97 self.selected_element.size = (w, h)

98

99 def _resize_element_no_snap(self, dx: float, dy: float):

100 """

101 Resize element without snapping.

102

103 Args:

104 dx: Delta X in page-local coordinates

105 dy: Delta Y in page-local coordinates

106 """

107 if not self.resize_start_pos or not self.resize_start_size:

108 return

109 if self.selected_element is None:

110 return

111

112 start_x, start_y = self.resize_start_pos

113 start_w, start_h = self.resize_start_size

114

115 if self.resize_handle == "nw":

116 self.selected_element.position = (start_x + dx, start_y + dy)

117 self.selected_element.size = (start_w - dx, start_h - dy)

118 elif self.resize_handle == "ne":

119 self.selected_element.position = (start_x, start_y + dy)

120 self.selected_element.size = (start_w + dx, start_h - dy)

121 elif self.resize_handle == "sw":

122 self.selected_element.position = (start_x + dx, start_y)

123 self.selected_element.size = (start_w - dx, start_h + dy)

124 elif self.resize_handle == "se":

125 self.selected_element.size = (start_w + dx, start_h + dy)

126

127 # Ensure minimum size

128 min_size = 20

129 w, h = self.selected_element.size

130 if w < min_size:

131 self.selected_element.size = (min_size, h)

132 if h < min_size:

133 w, _ = self.selected_element.size

134 self.selected_element.size = (w, min_size)

135

136 def _transfer_element_to_page(

137 self, element, source_page, target_page, mouse_x: float, mouse_y: float, target_renderer

138 ):

139 """

140 Transfer an element from one page to another during drag operation.

141

142 Args:

143 element: The element to transfer

144 source_page: Source page object

145 target_page: Target page object

146 mouse_x: Current mouse X position in screen coordinates

147 mouse_y: Current mouse Y position in screen coordinates

148 target_renderer: PageRenderer for the target page

149 """

150 # Convert mouse position to target page coordinates

151 new_page_x, new_page_y = target_renderer.screen_to_page(mouse_x, mouse_y)

152

153 # Get element size

154 elem_w, elem_h = element.size

155

156 # Center the element on the mouse position

157 new_x = new_page_x - elem_w / 2

158 new_y = new_page_y - elem_h / 2

159

160 # Remove element from source page

161 if element in source_page.layout.elements:

162 source_page.layout.elements.remove(element)

163 print(f"Removed element from page {source_page.page_number}")

164

165 # Update element position to new page coordinates

166 element.position = (new_x, new_y)

167

168 # Add element to target page

169 target_page.layout.add_element(element)

170

171 # Update element's parent page reference

172 element._parent_page = target_page

173 element._page_renderer = target_renderer

174

175 # Update drag start position and element position for continued dragging

176 self.drag_start_pos = (mouse_x, mouse_y)

177 self.drag_start_element_pos = element.position

178

179 print(f"Transferred element to page {target_page.page_number} at ({new_x:.1f}, {new_y:.1f})")