Coverage for pyPhotoAlbum/mixins/element_selection.py: 98%

51 statements  

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

1""" 

2Element selection mixin for GLWidget - handles element selection and hit detection 

3""" 

4 

5from typing import Any, TYPE_CHECKING, Optional, Set 

6 

7if TYPE_CHECKING: 

8 from PyQt6.QtWidgets import QMainWindow 

9 

10from pyPhotoAlbum.models import BaseLayoutElement 

11 

12 

13class ElementSelectionMixin: 

14 # Type hints for expected attributes from mixing class 

15 _page_renderers: list 

16 

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

18 """Expected from QWidget""" 

19 ... 

20 

21 """ 

22 Mixin providing element selection and hit detection functionality. 

23 

24 This mixin manages which elements are selected and provides methods to 

25 detect which element or resize handle is at a given screen position. 

26 """ 

27 

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

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

30 

31 # Selection state - multi-select support 

32 self.selected_elements: Set[BaseLayoutElement] = set() 

33 

34 @property 

35 def selected_element(self) -> Optional[BaseLayoutElement]: 

36 """ 

37 For backward compatibility - returns first selected element or None. 

38 

39 Returns: 

40 BaseLayoutElement or None: The first selected element, or None if no selection 

41 """ 

42 return next(iter(self.selected_elements)) if self.selected_elements else None 

43 

44 @selected_element.setter 

45 def selected_element(self, value: Optional[BaseLayoutElement]): 

46 """ 

47 For backward compatibility - sets single element selection. 

48 

49 Args: 

50 value: Element to select, or None to clear selection 

51 """ 

52 if value is None: 

53 self.selected_elements.clear() 

54 else: 

55 self.selected_elements = {value} 

56 

57 def _get_element_at(self, x: float, y: float) -> Optional[BaseLayoutElement]: 

58 """ 

59 Get the element at the given screen position across all pages. 

60 

61 Args: 

62 x: Screen X coordinate 

63 y: Screen Y coordinate 

64 

65 Returns: 

66 BaseLayoutElement or None: The topmost element at the position, or None 

67 """ 

68 if not hasattr(self, "_page_renderers") or not self._page_renderers: 

69 return None 

70 

71 # Check each page from top to bottom (reverse z-order) 

72 for renderer, page in reversed(self._page_renderers): 

73 # Convert screen coordinates to page-local coordinates 

74 # Do this for all pages, not just those where the click is within bounds 

75 # This allows selecting elements that have moved off the page 

76 page_x, page_y = renderer.screen_to_page(x, y) 

77 

78 # Check elements in this page (highest in list = on top, so check in reverse) 

79 for element in reversed(page.layout.elements): 

80 # Get element bounds 

81 ex, ey = element.position 

82 ew, eh = element.size 

83 

84 # Simple bounds check (no rotation transformation needed - images are already rotated) 

85 if ex <= page_x <= ex + ew and ey <= page_y <= ey + eh: 

86 # Store the renderer with the element for later use 

87 element._page_renderer = renderer # type: ignore[attr-defined] 

88 element._parent_page = page # type: ignore[attr-defined] 

89 return element # type: ignore[no-any-return] 

90 

91 return None 

92 

93 def _get_resize_handle_at(self, x: float, y: float) -> Optional[str]: 

94 """ 

95 Get the resize handle at the given screen position. 

96 

97 Only checks if there is a single selected element. 

98 

99 Args: 

100 x: Screen X coordinate 

101 y: Screen Y coordinate 

102 

103 Returns: 

104 str or None: Handle name ('nw', 'ne', 'sw', 'se') or None 

105 """ 

106 if not self.selected_element: 

107 return None 

108 

109 main_window = self.window() 

110 if not hasattr(main_window, "project") or not main_window.project or not main_window.project.pages: 

111 return None 

112 

113 # Get the PageRenderer for this element (stored when element was selected) 

114 if not hasattr(self.selected_element, "_page_renderer"): 

115 return None 

116 

117 renderer: Any = self.selected_element._page_renderer # type: ignore[attr-defined] 

118 

119 # Get element position and size in page-local coordinates 

120 elem_x, elem_y = self.selected_element.position 

121 elem_w, elem_h = self.selected_element.size 

122 handle_size = 8 

123 

124 # Convert to screen coordinates using PageRenderer 

125 ex, ey = renderer.page_to_screen(elem_x, elem_y) 

126 ew = elem_w * renderer.zoom 

127 eh = elem_h * renderer.zoom 

128 

129 # Check handles (no rotation transformation needed - images are already rotated) 

130 handles = { 

131 "nw": (ex - handle_size / 2, ey - handle_size / 2), 

132 "ne": (ex + ew - handle_size / 2, ey - handle_size / 2), 

133 "sw": (ex - handle_size / 2, ey + eh - handle_size / 2), 

134 "se": (ex + ew - handle_size / 2, ey + eh - handle_size / 2), 

135 } 

136 

137 for name, (hx, hy) in handles.items(): 

138 if hx <= x <= hx + handle_size and hy <= y <= hy + handle_size: 

139 return name 

140 

141 return None