Coverage for src / ezqt_widgets / widgets / misc / option_selector.py: 87.82%

200 statements  

« prev     ^ index     » next       coverage.py v7.13.5, created at 2026-04-01 22:46 +0000

1# /////////////////////////////////////////////////////////////// 

2# OPTION_SELECTOR - Option Selector Widget 

3# Project: ezqt_widgets 

4# /////////////////////////////////////////////////////////////// 

5 

6""" 

7Option selector widget module. 

8 

9Provides an option selector widget with animated selector for PySide6 

10applications. 

11""" 

12 

13from __future__ import annotations 

14 

15# /////////////////////////////////////////////////////////////// 

16# IMPORTS 

17# /////////////////////////////////////////////////////////////// 

18# Standard library imports 

19from typing import Any 

20 

21# Third-party imports 

22from PySide6.QtCore import QEasingCurve, QPropertyAnimation, QRect, QSize, Qt, Signal 

23from PySide6.QtGui import QMouseEvent, QResizeEvent 

24from PySide6.QtWidgets import QFrame, QGridLayout, QSizePolicy 

25 

26from ...types import WidgetParent 

27 

28# Local imports 

29from ..label.framed_label import FramedLabel 

30 

31# /////////////////////////////////////////////////////////////// 

32# UTILITY CLASSES 

33# /////////////////////////////////////////////////////////////// 

34 

35 

36class _SelectableOptionLabel(FramedLabel): 

37 """Internal label class for selectable options.""" 

38 

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. 

47 

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 

57 

58 def mousePressEvent(self, event: QMouseEvent) -> None: 

59 """Handle mouse press events. 

60 

61 Args: 

62 event: The mouse event. 

63 """ 

64 self._selector.toggleSelection(self._option_id) 

65 super().mousePressEvent(event) 

66 

67 

68# /////////////////////////////////////////////////////////////// 

69# CLASSES 

70# /////////////////////////////////////////////////////////////// 

71 

72 

73class OptionSelector(QFrame): 

74 """Option selector widget with animated selector. 

75 

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 

84 

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. 

97 

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. 

102 

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 """ 

109 

110 clicked = Signal() 

111 valueChanged = Signal(str) 

112 valueIdChanged = Signal(int) 

113 

114 # /////////////////////////////////////////////////////////////// 

115 # INIT 

116 # /////////////////////////////////////////////////////////////// 

117 

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) 

134 

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 

145 

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) 

152 

153 # Create selector 

154 self._selector = QFrame(self) 

155 self._selector.setObjectName("selector") 

156 self._selector.setProperty("type", "OptionSelector_Selector") 

157 

158 # Add options 

159 for i, option_text in enumerate(self._options_list): 

160 self.addOption(option_id=i, option_text=option_text) 

161 

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) 

165 

166 # /////////////////////////////////////////////////////////////// 

167 # PROPERTIES 

168 # /////////////////////////////////////////////////////////////// 

169 

170 @property 

171 def value(self) -> str: 

172 """Get or set the currently selected option text. 

173 

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 "" 

180 

181 @value.setter 

182 def value(self, new_value: str) -> None: 

183 """Set the selected option by text. 

184 

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 

193 

194 @property 

195 def value_id(self) -> int: 

196 """Get or set the currently selected option ID. 

197 

198 Returns: 

199 The currently selected option ID. 

200 """ 

201 return self._value_id 

202 

203 @value_id.setter 

204 def value_id(self, new_id: int) -> None: 

205 """Set the selected option by ID. 

206 

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) 

216 

217 @property 

218 def options(self) -> list[str]: 

219 """Get the list of available options. 

220 

221 Returns: 

222 A copy of the options list. 

223 """ 

224 return self._options_list.copy() 

225 

226 @property 

227 def default_id(self) -> int: 

228 """Get or set the default option ID. 

229 

230 Returns: 

231 The default option ID. 

232 """ 

233 return self._default_id 

234 

235 @default_id.setter 

236 def default_id(self, value: int) -> None: 

237 """Set the default option ID. 

238 

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 

246 

247 @property 

248 def selected_option(self) -> FramedLabel | None: 

249 """Get the currently selected option widget. 

250 

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 

257 

258 @property 

259 def orientation(self) -> str: 

260 """Get or set the orientation of the selector. 

261 

262 Returns: 

263 The current orientation ("horizontal" or "vertical"). 

264 """ 

265 return self._orientation 

266 

267 @orientation.setter 

268 def orientation(self, value: str) -> None: 

269 """Set the orientation of the selector. 

270 

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() 

277 

278 @property 

279 def min_width(self) -> int | None: 

280 """Get or set the minimum width of the widget. 

281 

282 Returns: 

283 The minimum width, or None if not set. 

284 """ 

285 return self._min_width 

286 

287 @min_width.setter 

288 def min_width(self, value: int | None) -> None: 

289 """Set the minimum width of the widget. 

290 

291 Args: 

292 value: The new minimum width, or None to auto-calculate. 

293 """ 

294 self._min_width = value 

295 self.updateGeometry() 

296 

297 @property 

298 def min_height(self) -> int | None: 

299 """Get or set the minimum height of the widget. 

300 

301 Returns: 

302 The minimum height, or None if not set. 

303 """ 

304 return self._min_height 

305 

306 @min_height.setter 

307 def min_height(self, value: int | None) -> None: 

308 """Set the minimum height of the widget. 

309 

310 Args: 

311 value: The new minimum height, or None to auto-calculate. 

312 """ 

313 self._min_height = value 

314 self.updateGeometry() 

315 

316 @property 

317 def animation_duration(self) -> int: 

318 """Get or set the animation duration in milliseconds. 

319 

320 Returns: 

321 The animation duration in milliseconds. 

322 """ 

323 return self._animation_duration 

324 

325 @animation_duration.setter 

326 def animation_duration(self, value: int) -> None: 

327 """Set the animation duration in milliseconds. 

328 

329 Args: 

330 value: The new animation duration in milliseconds. 

331 """ 

332 self._animation_duration = value 

333 

334 # /////////////////////////////////////////////////////////////// 

335 # PUBLIC METHODS 

336 # /////////////////////////////////////////////////////////////// 

337 

338 def initializeSelector(self, default_id: int = 0) -> None: 

339 """Initialize the selector with default position. 

340 

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) 

347 

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 

350 self._set_selector_geometry(selected_option) 

351 self._selector.lower() # Ensure selector stays below 

352 self._selector.update() # Force refresh if needed 

353 

354 def addOption(self, option_id: int, option_text: str) -> None: 

355 """Add a new option to the selector. 

356 

357 Args: 

358 option_id: The ID for the option. 

359 option_text: The text to display for the option. 

360 """ 

361 # Create option label 

362 option = _SelectableOptionLabel(option_text.capitalize(), option_id, self, self) 

363 option.setObjectName(f"opt_{option_id}") 

364 option.setFrameShape(QFrame.Shape.NoFrame) 

365 option.setFrameShadow(QFrame.Shadow.Raised) 

366 option.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Expanding) 

367 option.setProperty("type", "OptionSelector_Option") 

368 

369 # Add to grid based on orientation 

370 option_index = len(self._options.items()) 

371 if self._orientation == "horizontal": 

372 self._grid.addWidget(option, 0, option_index) 

373 else: # vertical 

374 self._grid.addWidget(option, option_index, 0) 

375 

376 # Store option 

377 self._options[option_id] = option 

378 

379 # Update options list 

380 if option_id >= len(self._options_list): 

381 # Add empty elements if necessary 

382 while len(self._options_list) <= option_id: 

383 self._options_list.append("") 

384 self._options_list[option_id] = option_text 

385 

386 def toggleSelection(self, option_id: int) -> None: 

387 """Handle option selection. 

388 

389 Args: 

390 option_id: The ID of the option to select. 

391 """ 

392 if option_id != self._value_id: 392 ↛ exitline 392 didn't return from function 'toggleSelection' because the condition on line 392 was always true

393 self._value_id = option_id 

394 self.clicked.emit() 

395 self.valueChanged.emit(self.value) 

396 self.valueIdChanged.emit(option_id) 

397 self.moveSelector(self._options[option_id]) 

398 

399 def moveSelector(self, option: FramedLabel) -> None: 

400 """Animate the selector to the selected option. 

401 

402 Args: 

403 option: The option widget to move the selector to. 

404 """ 

405 start_geometry = self._selector.geometry() 

406 end_geometry = self._selector_geometry_for_option(option) 

407 

408 # Create geometry animation 

409 self._selector_animation = QPropertyAnimation(self._selector, b"geometry") 

410 self._selector_animation.setDuration(self._animation_duration) 

411 self._selector_animation.setStartValue(start_geometry) 

412 self._selector_animation.setEndValue(end_geometry) 

413 self._selector_animation.setEasingCurve(QEasingCurve.Type.OutCubic) 

414 

415 # Ensure selector stays below 

416 self._selector.lower() 

417 

418 # Start animation 

419 self._selector_animation.start() 

420 

421 def _selector_geometry_for_option(self, option: FramedLabel) -> QRect: 

422 padding = 2 

423 geometry = option.geometry() 

424 geometry.adjust(-padding, -padding, padding, padding) 

425 return geometry 

426 

427 def _set_selector_geometry(self, option: FramedLabel) -> None: 

428 geometry = self._selector_geometry_for_option(option) 

429 self._selector.setGeometry(geometry) 

430 

431 # /////////////////////////////////////////////////////////////// 

432 # OVERRIDE METHODS 

433 # /////////////////////////////////////////////////////////////// 

434 

435 def resizeEvent(self, event: QResizeEvent) -> None: 

436 """Handle resize events to keep selector aligned.""" 

437 if self._value_id in self._options: 

438 self._set_selector_geometry(self._options[self._value_id]) 

439 super().resizeEvent(event) 

440 

441 def sizeHint(self) -> QSize: 

442 """Get the recommended size for the widget. 

443 

444 Returns: 

445 The recommended size. 

446 """ 

447 return QSize(200, 40) 

448 

449 def minimumSizeHint(self) -> QSize: 

450 """Get the minimum size hint for the widget. 

451 

452 Returns: 

453 The minimum size hint. 

454 """ 

455 # Calculate options dimensions 

456 max_option_width = 0 

457 max_option_height = 0 

458 

459 for option_text in self._options_list: 

460 # Estimate text width using font metrics 

461 font_metrics = self.fontMetrics() 

462 text_width = font_metrics.horizontalAdvance(option_text.capitalize()) 

463 

464 # Add padding and margins 

465 option_width = text_width + 16 # 8px padding on each side 

466 option_height = max(font_metrics.height() + 8, 30) # 4px padding top/bottom 

467 

468 max_option_width = max(max_option_width, option_width) 

469 max_option_height = max(max_option_height, option_height) 

470 

471 # Calculate total dimensions based on orientation 

472 if self._orientation == "horizontal": 472 ↛ 484line 472 didn't jump to line 484 because the condition on line 472 was always true

473 # Horizontal: options side by side with individual widths 

474 total_width = 0 

475 for option_text in self._options_list: 

476 font_metrics = self.fontMetrics() 

477 text_width = font_metrics.horizontalAdvance(option_text.capitalize()) 

478 option_width = text_width + 16 # 8px padding on each side 

479 total_width += option_width 

480 total_width += (len(self._options_list) - 1) * self._grid.spacing() 

481 total_height = max_option_height 

482 else: 

483 # Vertical: options stacked 

484 total_width = max_option_width 

485 total_height = max_option_height * len(self._options_list) 

486 total_height += (len(self._options_list) - 1) * self._grid.spacing() 

487 

488 # Add grid margins 

489 total_width += 8 # Grid margins (4px on each side) 

490 total_height += 8 # Grid margins (4px on each side) 

491 

492 # Apply minimum constraints 

493 min_width = self._min_width if self._min_width is not None else total_width 

494 min_height = self._min_height if self._min_height is not None else total_height 

495 

496 return QSize(max(min_width, total_width), max(min_height, total_height)) 

497 

498 # /////////////////////////////////////////////////////////////// 

499 # STYLE METHODS 

500 # /////////////////////////////////////////////////////////////// 

501 

502 def refreshStyle(self) -> None: 

503 """Refresh the widget's style. 

504 

505 Useful after dynamic stylesheet changes. 

506 """ 

507 self.style().unpolish(self) 

508 self.style().polish(self) 

509 self.update() 

510 

511 

512# /////////////////////////////////////////////////////////////// 

513# PUBLIC API 

514# /////////////////////////////////////////////////////////////// 

515 

516__all__ = ["OptionSelector"]