Coverage for src / ezqt_widgets / widgets / misc / option_selector.py: 89.33%
189 statements
« prev ^ index » next coverage.py v7.13.5, created at 2026-03-31 10:03 +0000
« prev ^ index » next coverage.py v7.13.5, created at 2026-03-31 10:03 +0000
1# ///////////////////////////////////////////////////////////////
2# OPTION_SELECTOR - Option Selector Widget
3# Project: ezqt_widgets
4# ///////////////////////////////////////////////////////////////
6"""
7Option selector widget module.
9Provides an option selector widget with animated selector for PySide6
10applications.
11"""
13from __future__ import annotations
15# ///////////////////////////////////////////////////////////////
16# IMPORTS
17# ///////////////////////////////////////////////////////////////
18# Standard library imports
19from typing import Any
21# Third-party imports
22from PySide6.QtCore import QEasingCurve, QPropertyAnimation, QSize, Qt, Signal
23from PySide6.QtGui import QMouseEvent
24from PySide6.QtWidgets import QFrame, QGridLayout, QSizePolicy
26from ...types import WidgetParent
28# Local imports
29from ..label.framed_label import FramedLabel
31# ///////////////////////////////////////////////////////////////
32# UTILITY CLASSES
33# ///////////////////////////////////////////////////////////////
36class _SelectableOptionLabel(FramedLabel):
37 """Internal label class for selectable options."""
39 def __init__(
40 self,
41 text: str,
42 option_id: int,
43 selector: OptionSelector,
44 parent=None,
45 ) -> None:
46 """Initialize the selectable option label.
48 Args:
49 text: The option text.
50 option_id: The option ID.
51 selector: The parent OptionSelector instance.
52 parent: The parent widget.
53 """
54 super().__init__(text, parent)
55 self._option_id = option_id
56 self._selector = selector
58 def mousePressEvent(self, event: QMouseEvent) -> None:
59 """Handle mouse press events.
61 Args:
62 event: The mouse event.
63 """
64 self._selector.toggleSelection(self._option_id)
65 super().mousePressEvent(event)
68# ///////////////////////////////////////////////////////////////
69# CLASSES
70# ///////////////////////////////////////////////////////////////
73class OptionSelector(QFrame):
74 """Option selector widget with animated selector.
76 Features:
77 - Multiple selectable options displayed as labels
78 - Animated selector that moves between options
79 - Single selection mode (radio behavior)
80 - Configurable default selection by ID (index)
81 - Smooth animations with easing curves
82 - Click events for option selection
83 - Uses IDs internally for robust value handling
85 Args:
86 items: List of option texts to display.
87 default_id: Default selected option ID (index) (default: 0).
88 min_width: Minimum width constraint for the widget (default: None).
89 min_height: Minimum height constraint for the widget (default: None).
90 orientation: Layout orientation: "horizontal" or "vertical"
91 (default: "horizontal").
92 animation_duration: Duration of the selector animation in milliseconds
93 (default: 300).
94 parent: The parent widget (default: None).
95 *args: Additional arguments passed to QFrame.
96 **kwargs: Additional keyword arguments passed to QFrame.
98 Signals:
99 clicked(): Emitted when an option is clicked.
100 valueChanged(str): Emitted when the selected value changes.
101 valueIdChanged(int): Emitted when the selected value ID changes.
103 Example:
104 >>> from ezqt_widgets import OptionSelector
105 >>> selector = OptionSelector(items=["Day", "Week", "Month"], default_id=0)
106 >>> selector.valueChanged.connect(lambda v: print(f"Selected: {v}"))
107 >>> selector.show()
108 """
110 clicked = Signal()
111 valueChanged = Signal(str)
112 valueIdChanged = Signal(int)
114 # ///////////////////////////////////////////////////////////////
115 # INIT
116 # ///////////////////////////////////////////////////////////////
118 def __init__(
119 self,
120 items: list[str],
121 default_id: int = 0,
122 min_width: int | None = None,
123 min_height: int | None = None,
124 orientation: str = "horizontal",
125 animation_duration: int = 300,
126 parent: WidgetParent = None,
127 *args: Any,
128 **kwargs: Any,
129 ) -> None:
130 """Initialize the option selector."""
131 super().__init__(parent, *args, **kwargs)
132 self.setProperty("type", "OptionSelector")
133 self.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Expanding)
135 # Initialize variables
136 self._value_id = 0
137 self._options_list = items
138 self._default_id = default_id
139 self._options: dict[int, FramedLabel] = {}
140 self._selector_animation: QPropertyAnimation | None = None
141 self._min_width = min_width
142 self._min_height = min_height
143 self._orientation = orientation.lower()
144 self._animation_duration = animation_duration
146 # Setup grid layout
147 self._grid = QGridLayout(self)
148 self._grid.setObjectName("grid")
149 self._grid.setSpacing(4)
150 self._grid.setContentsMargins(4, 4, 4, 4)
151 self._grid.setAlignment(Qt.AlignmentFlag.AlignCenter)
153 # Create selector
154 self._selector = QFrame(self)
155 self._selector.setObjectName("selector")
156 self._selector.setProperty("type", "OptionSelector_Selector")
158 # Add options
159 for i, option_text in enumerate(self._options_list):
160 self.addOption(option_id=i, option_text=option_text)
162 # Initialize selector
163 if self._options_list: 163 ↛ exitline 163 didn't return from function '__init__' because the condition on line 163 was always true
164 self.initializeSelector(self._default_id)
166 # ///////////////////////////////////////////////////////////////
167 # PROPERTIES
168 # ///////////////////////////////////////////////////////////////
170 @property
171 def value(self) -> str:
172 """Get or set the currently selected option text.
174 Returns:
175 The currently selected option text, or empty string if none.
176 """
177 if 0 <= self._value_id < len(self._options_list): 177 ↛ 179line 177 didn't jump to line 179 because the condition on line 177 was always true
178 return self._options_list[self._value_id]
179 return ""
181 @value.setter
182 def value(self, new_value: str) -> None:
183 """Set the selected option by text.
185 Args:
186 new_value: The option text to select.
187 """
188 try:
189 new_id = self._options_list.index(new_value)
190 self.value_id = new_id
191 except ValueError:
192 pass # Value not found in list
194 @property
195 def value_id(self) -> int:
196 """Get or set the currently selected option ID.
198 Returns:
199 The currently selected option ID.
200 """
201 return self._value_id
203 @value_id.setter
204 def value_id(self, new_id: int) -> None:
205 """Set the selected option by ID.
207 Args:
208 new_id: The option ID to select.
209 """
210 if 0 <= new_id < len(self._options_list) and new_id != self._value_id: 210 ↛ exitline 210 didn't return from function 'value_id' because the condition on line 210 was always true
211 self._value_id = new_id
212 if new_id in self._options: 212 ↛ 214line 212 didn't jump to line 214 because the condition on line 212 was always true
213 self.moveSelector(self._options[new_id])
214 self.valueChanged.emit(self.value)
215 self.valueIdChanged.emit(new_id)
217 @property
218 def options(self) -> list[str]:
219 """Get the list of available options.
221 Returns:
222 A copy of the options list.
223 """
224 return self._options_list.copy()
226 @property
227 def default_id(self) -> int:
228 """Get or set the default option ID.
230 Returns:
231 The default option ID.
232 """
233 return self._default_id
235 @default_id.setter
236 def default_id(self, value: int) -> None:
237 """Set the default option ID.
239 Args:
240 value: The new default option ID.
241 """
242 if 0 <= value < len(self._options_list): 242 ↛ exitline 242 didn't return from function 'default_id' because the condition on line 242 was always true
243 self._default_id = value
244 if not self._value_id and self._options_list: 244 ↛ exitline 244 didn't return from function 'default_id' because the condition on line 244 was always true
245 self.value_id = value
247 @property
248 def selected_option(self) -> FramedLabel | None:
249 """Get the currently selected option widget.
251 Returns:
252 The selected option widget, or None if none selected.
253 """
254 if self._value_id in self._options: 254 ↛ 256line 254 didn't jump to line 256 because the condition on line 254 was always true
255 return self._options[self._value_id]
256 return None
258 @property
259 def orientation(self) -> str:
260 """Get or set the orientation of the selector.
262 Returns:
263 The current orientation ("horizontal" or "vertical").
264 """
265 return self._orientation
267 @orientation.setter
268 def orientation(self, value: str) -> None:
269 """Set the orientation of the selector.
271 Args:
272 value: The new orientation ("horizontal" or "vertical").
273 """
274 if value.lower() in ["horizontal", "vertical"]: 274 ↛ exitline 274 didn't return from function 'orientation' because the condition on line 274 was always true
275 self._orientation = value.lower()
276 self.updateGeometry()
278 @property
279 def min_width(self) -> int | None:
280 """Get or set the minimum width of the widget.
282 Returns:
283 The minimum width, or None if not set.
284 """
285 return self._min_width
287 @min_width.setter
288 def min_width(self, value: int | None) -> None:
289 """Set the minimum width of the widget.
291 Args:
292 value: The new minimum width, or None to auto-calculate.
293 """
294 self._min_width = value
295 self.updateGeometry()
297 @property
298 def min_height(self) -> int | None:
299 """Get or set the minimum height of the widget.
301 Returns:
302 The minimum height, or None if not set.
303 """
304 return self._min_height
306 @min_height.setter
307 def min_height(self, value: int | None) -> None:
308 """Set the minimum height of the widget.
310 Args:
311 value: The new minimum height, or None to auto-calculate.
312 """
313 self._min_height = value
314 self.updateGeometry()
316 @property
317 def animation_duration(self) -> int:
318 """Get or set the animation duration in milliseconds.
320 Returns:
321 The animation duration in milliseconds.
322 """
323 return self._animation_duration
325 @animation_duration.setter
326 def animation_duration(self, value: int) -> None:
327 """Set the animation duration in milliseconds.
329 Args:
330 value: The new animation duration in milliseconds.
331 """
332 self._animation_duration = value
334 # ///////////////////////////////////////////////////////////////
335 # PUBLIC METHODS
336 # ///////////////////////////////////////////////////////////////
338 def initializeSelector(self, default_id: int = 0) -> None:
339 """Initialize the selector with default position.
341 Args:
342 default_id: The default option ID to select.
343 """
344 if 0 <= default_id < len(self._options_list): 344 ↛ exitline 344 didn't return from function 'initializeSelector' because the condition on line 344 was always true
345 self._default_id = default_id
346 selected_option = self._options.get(default_id)
348 if selected_option: 348 ↛ exitline 348 didn't return from function 'initializeSelector' because the condition on line 348 was always true
349 self._value_id = default_id
351 default_pos = self._grid.indexOf(selected_option)
352 self._grid.addWidget(self._selector, 0, default_pos)
353 self._selector.lower() # Ensure selector stays below
354 self._selector.update() # Force refresh if needed
356 def addOption(self, option_id: int, option_text: str) -> None:
357 """Add a new option to the selector.
359 Args:
360 option_id: The ID for the option.
361 option_text: The text to display for the option.
362 """
363 # Create option label
364 option = _SelectableOptionLabel(option_text.capitalize(), option_id, self, self)
365 option.setObjectName(f"opt_{option_id}")
366 option.setFrameShape(QFrame.Shape.NoFrame)
367 option.setFrameShadow(QFrame.Shadow.Raised)
368 option.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Expanding)
369 option.setProperty("type", "OptionSelector_Option")
371 # Add to grid based on orientation
372 option_index = len(self._options.items())
373 if self._orientation == "horizontal":
374 self._grid.addWidget(option, 0, option_index)
375 else: # vertical
376 self._grid.addWidget(option, option_index, 0)
378 # Store option
379 self._options[option_id] = option
381 # Update options list
382 if option_id >= len(self._options_list):
383 # Add empty elements if necessary
384 while len(self._options_list) <= option_id:
385 self._options_list.append("")
386 self._options_list[option_id] = option_text
388 def toggleSelection(self, option_id: int) -> None:
389 """Handle option selection.
391 Args:
392 option_id: The ID of the option to select.
393 """
394 if option_id != self._value_id: 394 ↛ exitline 394 didn't return from function 'toggleSelection' because the condition on line 394 was always true
395 self._value_id = option_id
396 self.clicked.emit()
397 self.valueChanged.emit(self.value)
398 self.valueIdChanged.emit(option_id)
399 self.moveSelector(self._options[option_id])
401 def moveSelector(self, option: FramedLabel) -> None:
402 """Animate the selector to the selected option.
404 Args:
405 option: The option widget to move the selector to.
406 """
407 start_geometry = self._selector.geometry()
408 end_geometry = option.geometry()
410 # Create geometry animation
411 self._selector_animation = QPropertyAnimation(self._selector, b"geometry")
412 self._selector_animation.setDuration(self._animation_duration)
413 self._selector_animation.setStartValue(start_geometry)
414 self._selector_animation.setEndValue(end_geometry)
415 self._selector_animation.setEasingCurve(QEasingCurve.Type.OutCubic)
417 # Ensure selector stays below
418 self._selector.lower()
420 # Start animation
421 self._selector_animation.start()
423 # ///////////////////////////////////////////////////////////////
424 # OVERRIDE METHODS
425 # ///////////////////////////////////////////////////////////////
427 def sizeHint(self) -> QSize:
428 """Get the recommended size for the widget.
430 Returns:
431 The recommended size.
432 """
433 return QSize(200, 40)
435 def minimumSizeHint(self) -> QSize:
436 """Get the minimum size hint for the widget.
438 Returns:
439 The minimum size hint.
440 """
441 # Calculate options dimensions
442 max_option_width = 0
443 max_option_height = 0
445 for option_text in self._options_list:
446 # Estimate text width using font metrics
447 font_metrics = self.fontMetrics()
448 text_width = font_metrics.horizontalAdvance(option_text.capitalize())
450 # Add padding and margins
451 option_width = text_width + 16 # 8px padding on each side
452 option_height = max(font_metrics.height() + 8, 30) # 4px padding top/bottom
454 max_option_width = max(max_option_width, option_width)
455 max_option_height = max(max_option_height, option_height)
457 # Calculate total dimensions based on orientation
458 if self._orientation == "horizontal": 458 ↛ 470line 458 didn't jump to line 470 because the condition on line 458 was always true
459 # Horizontal: options side by side with individual widths
460 total_width = 0
461 for option_text in self._options_list:
462 font_metrics = self.fontMetrics()
463 text_width = font_metrics.horizontalAdvance(option_text.capitalize())
464 option_width = text_width + 16 # 8px padding on each side
465 total_width += option_width
466 total_width += (len(self._options_list) - 1) * self._grid.spacing()
467 total_height = max_option_height
468 else:
469 # Vertical: options stacked
470 total_width = max_option_width
471 total_height = max_option_height * len(self._options_list)
472 total_height += (len(self._options_list) - 1) * self._grid.spacing()
474 # Add grid margins
475 total_width += 8 # Grid margins (4px on each side)
476 total_height += 8 # Grid margins (4px on each side)
478 # Apply minimum constraints
479 min_width = self._min_width if self._min_width is not None else total_width
480 min_height = self._min_height if self._min_height is not None else total_height
482 return QSize(max(min_width, total_width), max(min_height, total_height))
484 # ///////////////////////////////////////////////////////////////
485 # STYLE METHODS
486 # ///////////////////////////////////////////////////////////////
488 def refreshStyle(self) -> None:
489 """Refresh the widget's style.
491 Useful after dynamic stylesheet changes.
492 """
493 self.style().unpolish(self)
494 self.style().polish(self)
495 self.update()
498# ///////////////////////////////////////////////////////////////
499# PUBLIC API
500# ///////////////////////////////////////////////////////////////
502__all__ = ["OptionSelector"]