Coverage for pyPhotoAlbum/mixins/element_manipulation.py: 96%

80 statements  

« prev     ^ index     » next       coverage.py v7.14.1, created at 2026-06-20 12:55 +0000

1""" 

2Element manipulation mixin for GLWidget - handles element transformations 

3""" 

4 

5from typing import TYPE_CHECKING, Any, Optional, Tuple 

6 

7if TYPE_CHECKING: 

8 from pyPhotoAlbum.models import BaseLayoutElement 

9 from PyQt6.QtWidgets import QMainWindow 

10 

11 

12class 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})")