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
« 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"""
5from typing import Any, TYPE_CHECKING, Optional, Set
7if TYPE_CHECKING:
8 from PyQt6.QtWidgets import QMainWindow
10from pyPhotoAlbum.models import BaseLayoutElement
13class ElementSelectionMixin:
14 # Type hints for expected attributes from mixing class
15 _page_renderers: list
17 def window(self) -> "QMainWindow": # type: ignore[empty-body]
18 """Expected from QWidget"""
19 ...
21 """
22 Mixin providing element selection and hit detection functionality.
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 """
28 def __init__(self, *args, **kwargs):
29 super().__init__(*args, **kwargs)
31 # Selection state - multi-select support
32 self.selected_elements: Set[BaseLayoutElement] = set()
34 @property
35 def selected_element(self) -> Optional[BaseLayoutElement]:
36 """
37 For backward compatibility - returns first selected element or None.
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
44 @selected_element.setter
45 def selected_element(self, value: Optional[BaseLayoutElement]):
46 """
47 For backward compatibility - sets single element selection.
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}
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.
61 Args:
62 x: Screen X coordinate
63 y: Screen Y coordinate
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
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)
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
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]
91 return None
93 def _get_resize_handle_at(self, x: float, y: float) -> Optional[str]:
94 """
95 Get the resize handle at the given screen position.
97 Only checks if there is a single selected element.
99 Args:
100 x: Screen X coordinate
101 y: Screen Y coordinate
103 Returns:
104 str or None: Handle name ('nw', 'ne', 'sw', 'se') or None
105 """
106 if not self.selected_element:
107 return None
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
113 # Get the PageRenderer for this element (stored when element was selected)
114 if not hasattr(self.selected_element, "_page_renderer"):
115 return None
117 renderer: Any = self.selected_element._page_renderer # type: ignore[attr-defined]
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
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
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 }
137 for name, (hx, hy) in handles.items():
138 if hx <= x <= hx + handle_size and hy <= y <= hy + handle_size:
139 return name
141 return None