Coverage for src / ezqt_widgets / widgets / misc / circular_timer.py: 74.01%

155 statements  

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

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

2# CIRCULAR_TIMER - Circular Timer Widget 

3# Project: ezqt_widgets 

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

5 

6""" 

7Circular timer widget module. 

8 

9Provides an animated circular timer widget for indicating progress or 

10elapsed time in PySide6 applications. 

11""" 

12 

13from __future__ import annotations 

14 

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

16# IMPORTS 

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

18# Standard library imports 

19import re 

20from typing import Any, Literal 

21 

22# Third-party imports 

23from PySide6.QtCore import QSize, Qt, QTimer, Signal 

24from PySide6.QtGui import QColor, QMouseEvent, QPainter, QPaintEvent, QPen 

25from PySide6.QtWidgets import QWidget 

26 

27# Local imports 

28from ...types import ColorType, WidgetParent 

29 

30# /////////////////////////////////////////////////////////////// 

31# FUNCTIONS 

32# /////////////////////////////////////////////////////////////// 

33 

34 

35def _parse_css_color(color_str: QColor | str) -> QColor: 

36 """Parse CSS color strings to QColor. 

37 

38 Supports rgb, rgba, hex, and named colors. 

39 

40 Args: 

41 color_str: CSS color string or QColor object. 

42 

43 Returns: 

44 QColor object. 

45 """ 

46 if isinstance(color_str, QColor): 46 ↛ 47line 46 didn't jump to line 47 because the condition on line 46 was never true

47 return color_str 

48 

49 color_str = str(color_str).strip() 

50 

51 rgb_match = re.match(r"rgb\((\d+),\s*(\d+),\s*(\d+)\)", color_str) 

52 if rgb_match: 52 ↛ 53line 52 didn't jump to line 53 because the condition on line 52 was never true

53 r, g, b = map(int, rgb_match.groups()) 

54 return QColor(r, g, b) 

55 

56 rgba_match = re.match(r"rgba\((\d+),\s*(\d+),\s*(\d+),\s*([\d.]+)\)", color_str) 

57 if rgba_match: 57 ↛ 58line 57 didn't jump to line 58 because the condition on line 57 was never true

58 r_str, g_str, b_str, a_str = rgba_match.groups() 

59 r, g, b = int(r_str), int(g_str), int(b_str) 

60 a = float(a_str) * 255 

61 return QColor(r, g, b, int(a)) 

62 

63 return QColor(color_str) 

64 

65 

66# /////////////////////////////////////////////////////////////// 

67# CLASSES 

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

69 

70 

71class CircularTimer(QWidget): 

72 """Animated circular timer for indicating progress or elapsed time. 

73 

74 Features: 

75 - Animated circular progress indicator 

76 - Customizable colors for ring and center 

77 - Configurable duration and loop mode 

78 - Click events for interaction 

79 - Smooth animation with configurable frame rate 

80 

81 Args: 

82 parent: Parent widget (default: None). 

83 duration: Total animation duration in milliseconds (default: 5000). 

84 ring_color: Color of the progress arc (default: "#0078d4"). 

85 Supports: hex (#ff0000), rgb(255,0,0), rgba(255,0,0,0.5), names (red). 

86 node_color: Color of the center (default: "#2d2d2d"). 

87 Supports: hex (#ffffff), rgb(255,255,255), rgba(255,255,255,0.8), names (white). 

88 ring_width_mode: "small", "medium" (default), or "large". 

89 Controls the dynamic thickness of the arc. 

90 pen_width: Thickness of the arc (takes priority over ring_width_mode if set). 

91 loop: If True, the timer loops automatically at each cycle (default: False). 

92 *args: Additional arguments passed to QWidget. 

93 **kwargs: Additional keyword arguments passed to QWidget. 

94 

95 Signals: 

96 timerReset(): Emitted when the timer is reset. 

97 clicked(): Emitted when the widget is clicked. 

98 cycleCompleted(): Emitted at each end of cycle (even if loop=False). 

99 

100 Example: 

101 >>> from ezqt_widgets import CircularTimer 

102 >>> timer = CircularTimer(duration=10000, ring_color="#0078d4", loop=True) 

103 >>> timer.cycleCompleted.connect(lambda: print("cycle done")) 

104 >>> timer.clicked.connect(timer.reset) 

105 >>> timer.start() 

106 >>> timer.show() 

107 """ 

108 

109 timerReset = Signal() 

110 clicked = Signal() 

111 cycleCompleted = Signal() 

112 

113 # /////////////////////////////////////////////////////////////// 

114 # INIT 

115 # /////////////////////////////////////////////////////////////// 

116 

117 def __init__( 

118 self, 

119 parent: WidgetParent = None, 

120 duration: int = 5000, 

121 ring_color: ColorType = "#0078d4", 

122 node_color: ColorType = "#2d2d2d", 

123 ring_width_mode: Literal["small", "medium", "large"] = "medium", 

124 pen_width: int | float | None = None, 

125 loop: bool = False, 

126 *args: Any, 

127 **kwargs: Any, 

128 ) -> None: 

129 """Initialize the circular timer.""" 

130 super().__init__(parent, *args, **kwargs) 

131 self.setProperty("type", "CircularTimer") 

132 

133 # Initialize properties 

134 self._duration: int = duration 

135 self._elapsed: int = 0 

136 self._running: bool = False 

137 self._ring_color: QColor = _parse_css_color(ring_color) 

138 self._node_color: QColor = _parse_css_color(node_color) 

139 self._ring_width_mode: str = ring_width_mode 

140 self._pen_width: float | None = pen_width 

141 self._loop: bool = bool(loop) 

142 self._last_update: float | None = None 

143 self._interval: int = 16 # ~60 FPS 

144 

145 # Setup timer 

146 self._timer = QTimer(self) 

147 self._timer.timeout.connect(self._on_timer) 

148 

149 # /////////////////////////////////////////////////////////////// 

150 # PROPERTIES 

151 # /////////////////////////////////////////////////////////////// 

152 

153 @property 

154 def duration(self) -> int: 

155 """Get the total duration. 

156 

157 Returns: 

158 The total duration in milliseconds. 

159 """ 

160 return self._duration 

161 

162 @duration.setter 

163 def duration(self, value: int) -> None: 

164 """Set the total duration. 

165 

166 Args: 

167 value: The new duration in milliseconds. 

168 """ 

169 self._duration = int(value) 

170 self.update() 

171 

172 @property 

173 def elapsed(self) -> int: 

174 """Get the elapsed time. 

175 

176 Returns: 

177 The elapsed time in milliseconds. 

178 """ 

179 return self._elapsed 

180 

181 @elapsed.setter 

182 def elapsed(self, value: int) -> None: 

183 """Set the elapsed time. 

184 

185 Args: 

186 value: The new elapsed time in milliseconds. 

187 """ 

188 self._elapsed = int(value) 

189 self.update() 

190 

191 @property 

192 def running(self) -> bool: 

193 """Get whether the timer is running. 

194 

195 Returns: 

196 True if running, False otherwise. 

197 """ 

198 return self._running 

199 

200 @property 

201 def ring_color(self) -> QColor: 

202 """Get the ring color. 

203 

204 Returns: 

205 The current ring color. 

206 """ 

207 return self._ring_color 

208 

209 @ring_color.setter 

210 def ring_color(self, value: ColorType) -> None: 

211 """Set the ring color. 

212 

213 Args: 

214 value: The new ring color (QColor or CSS string). 

215 """ 

216 self._ring_color = _parse_css_color(value) 

217 self.update() 

218 

219 @property 

220 def node_color(self) -> QColor: 

221 """Get the node color. 

222 

223 Returns: 

224 The current node color. 

225 """ 

226 return self._node_color 

227 

228 @node_color.setter 

229 def node_color(self, value: ColorType) -> None: 

230 """Set the node color. 

231 

232 Args: 

233 value: The new node color (QColor or CSS string). 

234 """ 

235 self._node_color = _parse_css_color(value) 

236 self.update() 

237 

238 @property 

239 def ring_width_mode(self) -> str: 

240 """Get the ring width mode. 

241 

242 Returns: 

243 The current ring width mode ("small", "medium", or "large"). 

244 """ 

245 return self._ring_width_mode 

246 

247 @ring_width_mode.setter 

248 def ring_width_mode(self, value: str) -> None: 

249 """Set the ring width mode. 

250 

251 Args: 

252 value: The new ring width mode ("small", "medium", or "large"). 

253 """ 

254 if value not in ("small", "medium", "large"): 254 ↛ 255line 254 didn't jump to line 255 because the condition on line 254 was never true

255 value = "medium" 

256 self._ring_width_mode = value 

257 self.update() 

258 

259 @property 

260 def pen_width(self) -> float | None: 

261 """Get the pen width. 

262 

263 Returns: 

264 The pen width, or None if using ring_width_mode. 

265 """ 

266 return self._pen_width 

267 

268 @pen_width.setter 

269 def pen_width(self, value: int | float | None) -> None: 

270 """Set the pen width. 

271 

272 Args: 

273 value: The new pen width, or None to use ring_width_mode. 

274 """ 

275 self._pen_width = float(value) if value is not None else None 

276 self.update() 

277 

278 @property 

279 def loop(self) -> bool: 

280 """Get whether the timer loops. 

281 

282 Returns: 

283 True if looping, False otherwise. 

284 """ 

285 return self._loop 

286 

287 @loop.setter 

288 def loop(self, value: bool) -> None: 

289 """Set whether the timer loops. 

290 

291 Args: 

292 value: Whether to loop the timer. 

293 """ 

294 self._loop = bool(value) 

295 

296 # /////////////////////////////////////////////////////////////// 

297 # EVENT HANDLERS 

298 # /////////////////////////////////////////////////////////////// 

299 

300 def mousePressEvent(self, _event: QMouseEvent) -> None: 

301 """Handle mouse press events. 

302 

303 Args: 

304 _event: The mouse event (unused but required by signature). 

305 """ 

306 self.clicked.emit() 

307 

308 # /////////////////////////////////////////////////////////////// 

309 # PUBLIC METHODS 

310 # /////////////////////////////////////////////////////////////// 

311 

312 def start(self) -> None: 

313 """Start the circular timer.""" 

314 self.stop() # Always stop before starting 

315 self._running = True 

316 self._last_update = None 

317 self._timer.start(self._interval) 

318 

319 def stop(self) -> None: 

320 """Stop the circular timer.""" 

321 self.reset() # Always reset to zero 

322 self._running = False 

323 self._timer.stop() 

324 

325 def reset(self) -> None: 

326 """Reset the circular timer.""" 

327 self._elapsed = 0 

328 self._last_update = None 

329 self.timerReset.emit() 

330 self.update() 

331 

332 # ------------------------------------------------ 

333 # PRIVATE METHODS 

334 # ------------------------------------------------ 

335 

336 def _on_timer(self) -> None: 

337 """Internal callback for smooth animation.""" 

338 import time 

339 

340 now = time.monotonic() * 1000 # ms 

341 if self._last_update is None: 

342 self._last_update = now 

343 return 

344 delta = now - self._last_update 

345 self._last_update = now 

346 self._elapsed += int(delta) 

347 if self._elapsed > self._duration: 

348 self.cycleCompleted.emit() 

349 if self._loop: 

350 self.reset() 

351 self._running = True 

352 self._last_update = now 

353 # Timer continues (no stop) 

354 else: 

355 self.reset() 

356 self.stop() 

357 self.update() 

358 

359 # /////////////////////////////////////////////////////////////// 

360 # OVERRIDE METHODS 

361 # /////////////////////////////////////////////////////////////// 

362 

363 def minimumSizeHint(self) -> QSize: 

364 """Get the recommended minimum size for the widget. 

365 

366 Returns: 

367 The minimum size hint. 

368 """ 

369 return QSize(24, 24) 

370 

371 def paintEvent(self, _event: QPaintEvent) -> None: 

372 """Draw the animated circular timer. 

373 

374 Args: 

375 _event: The paint event (unused but required by signature). 

376 """ 

377 painter = QPainter(self) 

378 painter.setRenderHint(QPainter.RenderHint.Antialiasing) 

379 size = min(self.width(), self.height()) 

380 

381 # Pen width (dynamic mode or fixed value) 

382 if self._pen_width is not None: 382 ↛ 383line 382 didn't jump to line 383 because the condition on line 382 was never true

383 pen_width = int(self._pen_width) 

384 else: 

385 if self._ring_width_mode == "small": 385 ↛ 386line 385 didn't jump to line 386 because the condition on line 385 was never true

386 pen_width = int(max(size * 0.12, 3)) 

387 elif self._ring_width_mode == "large": 387 ↛ 388line 387 didn't jump to line 388 because the condition on line 387 was never true

388 pen_width = int(max(size * 0.28, 3)) 

389 else: # medium 

390 pen_width = int(max(size * 0.18, 3)) 

391 

392 # Node circle (precise centering) 

393 center = size / 2 

394 node_radius = (size - 2 * pen_width) / 2 - pen_width / 2 

395 if node_radius > 0: 395 ↛ 406line 395 didn't jump to line 406 because the condition on line 395 was always true

396 painter.setPen(Qt.PenStyle.NoPen) 

397 painter.setBrush(self._node_color) 

398 painter.drawEllipse( 

399 int(center - node_radius), 

400 int(center - node_radius), 

401 int(2 * node_radius), 

402 int(2 * node_radius), 

403 ) 

404 

405 # Ring arc (clockwise, starting at 12 o'clock) 

406 painter.setPen( 

407 QPen( 

408 self._ring_color, 

409 pen_width, 

410 Qt.PenStyle.SolidLine, 

411 Qt.PenCapStyle.RoundCap, 

412 ) 

413 ) 

414 angle = int((self._elapsed / self._duration) * 360 * 16) 

415 painter.drawArc( 

416 pen_width, 

417 pen_width, 

418 int(size - 2 * pen_width), 

419 int(size - 2 * pen_width), 

420 90 * 16, 

421 -angle, # clockwise 

422 ) 

423 

424 # /////////////////////////////////////////////////////////////// 

425 # STYLE METHODS 

426 # /////////////////////////////////////////////////////////////// 

427 

428 def refreshStyle(self) -> None: 

429 """Refresh the widget's style. 

430 

431 Useful after dynamic stylesheet changes. 

432 """ 

433 self.style().unpolish(self) 

434 self.style().polish(self) 

435 self.update() 

436 

437 

438# /////////////////////////////////////////////////////////////// 

439# PUBLIC API 

440# /////////////////////////////////////////////////////////////// 

441 

442__all__ = ["CircularTimer"]