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
« 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# ///////////////////////////////////////////////////////////////
6"""
7Circular timer widget module.
9Provides an animated circular timer widget for indicating progress or
10elapsed time in PySide6 applications.
11"""
13from __future__ import annotations
15# ///////////////////////////////////////////////////////////////
16# IMPORTS
17# ///////////////////////////////////////////////////////////////
18# Standard library imports
19import re
20from typing import Any, Literal
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
27# Local imports
28from ...types import ColorType, WidgetParent
30# ///////////////////////////////////////////////////////////////
31# FUNCTIONS
32# ///////////////////////////////////////////////////////////////
35def _parse_css_color(color_str: QColor | str) -> QColor:
36 """Parse CSS color strings to QColor.
38 Supports rgb, rgba, hex, and named colors.
40 Args:
41 color_str: CSS color string or QColor object.
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
49 color_str = str(color_str).strip()
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)
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))
63 return QColor(color_str)
66# ///////////////////////////////////////////////////////////////
67# CLASSES
68# ///////////////////////////////////////////////////////////////
71class CircularTimer(QWidget):
72 """Animated circular timer for indicating progress or elapsed time.
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
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.
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).
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 """
109 timerReset = Signal()
110 clicked = Signal()
111 cycleCompleted = Signal()
113 # ///////////////////////////////////////////////////////////////
114 # INIT
115 # ///////////////////////////////////////////////////////////////
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")
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
145 # Setup timer
146 self._timer = QTimer(self)
147 self._timer.timeout.connect(self._on_timer)
149 # ///////////////////////////////////////////////////////////////
150 # PROPERTIES
151 # ///////////////////////////////////////////////////////////////
153 @property
154 def duration(self) -> int:
155 """Get the total duration.
157 Returns:
158 The total duration in milliseconds.
159 """
160 return self._duration
162 @duration.setter
163 def duration(self, value: int) -> None:
164 """Set the total duration.
166 Args:
167 value: The new duration in milliseconds.
168 """
169 self._duration = int(value)
170 self.update()
172 @property
173 def elapsed(self) -> int:
174 """Get the elapsed time.
176 Returns:
177 The elapsed time in milliseconds.
178 """
179 return self._elapsed
181 @elapsed.setter
182 def elapsed(self, value: int) -> None:
183 """Set the elapsed time.
185 Args:
186 value: The new elapsed time in milliseconds.
187 """
188 self._elapsed = int(value)
189 self.update()
191 @property
192 def running(self) -> bool:
193 """Get whether the timer is running.
195 Returns:
196 True if running, False otherwise.
197 """
198 return self._running
200 @property
201 def ring_color(self) -> QColor:
202 """Get the ring color.
204 Returns:
205 The current ring color.
206 """
207 return self._ring_color
209 @ring_color.setter
210 def ring_color(self, value: ColorType) -> None:
211 """Set the ring color.
213 Args:
214 value: The new ring color (QColor or CSS string).
215 """
216 self._ring_color = _parse_css_color(value)
217 self.update()
219 @property
220 def node_color(self) -> QColor:
221 """Get the node color.
223 Returns:
224 The current node color.
225 """
226 return self._node_color
228 @node_color.setter
229 def node_color(self, value: ColorType) -> None:
230 """Set the node color.
232 Args:
233 value: The new node color (QColor or CSS string).
234 """
235 self._node_color = _parse_css_color(value)
236 self.update()
238 @property
239 def ring_width_mode(self) -> str:
240 """Get the ring width mode.
242 Returns:
243 The current ring width mode ("small", "medium", or "large").
244 """
245 return self._ring_width_mode
247 @ring_width_mode.setter
248 def ring_width_mode(self, value: str) -> None:
249 """Set the ring width mode.
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()
259 @property
260 def pen_width(self) -> float | None:
261 """Get the pen width.
263 Returns:
264 The pen width, or None if using ring_width_mode.
265 """
266 return self._pen_width
268 @pen_width.setter
269 def pen_width(self, value: int | float | None) -> None:
270 """Set the pen width.
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()
278 @property
279 def loop(self) -> bool:
280 """Get whether the timer loops.
282 Returns:
283 True if looping, False otherwise.
284 """
285 return self._loop
287 @loop.setter
288 def loop(self, value: bool) -> None:
289 """Set whether the timer loops.
291 Args:
292 value: Whether to loop the timer.
293 """
294 self._loop = bool(value)
296 # ///////////////////////////////////////////////////////////////
297 # EVENT HANDLERS
298 # ///////////////////////////////////////////////////////////////
300 def mousePressEvent(self, _event: QMouseEvent) -> None:
301 """Handle mouse press events.
303 Args:
304 _event: The mouse event (unused but required by signature).
305 """
306 self.clicked.emit()
308 # ///////////////////////////////////////////////////////////////
309 # PUBLIC METHODS
310 # ///////////////////////////////////////////////////////////////
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)
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()
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()
332 # ------------------------------------------------
333 # PRIVATE METHODS
334 # ------------------------------------------------
336 def _on_timer(self) -> None:
337 """Internal callback for smooth animation."""
338 import time
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()
359 # ///////////////////////////////////////////////////////////////
360 # OVERRIDE METHODS
361 # ///////////////////////////////////////////////////////////////
363 def minimumSizeHint(self) -> QSize:
364 """Get the recommended minimum size for the widget.
366 Returns:
367 The minimum size hint.
368 """
369 return QSize(24, 24)
371 def paintEvent(self, _event: QPaintEvent) -> None:
372 """Draw the animated circular timer.
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())
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))
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 )
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 )
424 # ///////////////////////////////////////////////////////////////
425 # STYLE METHODS
426 # ///////////////////////////////////////////////////////////////
428 def refreshStyle(self) -> None:
429 """Refresh the widget's style.
431 Useful after dynamic stylesheet changes.
432 """
433 self.style().unpolish(self)
434 self.style().polish(self)
435 self.update()
438# ///////////////////////////////////////////////////////////////
439# PUBLIC API
440# ///////////////////////////////////////////////////////////////
442__all__ = ["CircularTimer"]