1 """

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

3 """

4

5 from typing import Any, TYPE_CHECKING, Optional, Set

6

7 if TYPE_CHECKING:

8 from PyQt6.QtWidgets import QMainWindow

9

10 from pyPhotoAlbum.models import BaseLayoutElement

11

12

13 class 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