Coverage for src / ezqt_widgets / widgets / misc / toggle_switch.py: 83.19%

111 statements  

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

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

2# TOGGLE_SWITCH - Toggle Switch Widget 

3# Project: ezqt_widgets 

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

5 

6""" 

7Toggle switch widget module. 

8 

9Provides a modern toggle switch widget with animated sliding circle for 

10PySide6 applications. 

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 ( 

23 Property, 

24 QEasingCurve, 

25 QPropertyAnimation, 

26 QRect, 

27 QSize, 

28 Qt, 

29 Signal, 

30) 

31from PySide6.QtGui import QBrush, QColor, QMouseEvent, QPainter, QPaintEvent, QPen 

32from PySide6.QtWidgets import QSizePolicy, QWidget 

33 

34# Local imports 

35from ...types import WidgetParent 

36 

37# /////////////////////////////////////////////////////////////// 

38# CLASSES 

39# /////////////////////////////////////////////////////////////// 

40 

41 

42class ToggleSwitch(QWidget): 

43 """Modern toggle switch widget with animated sliding circle. 

44 

45 Features: 

46 - Smooth animation when toggling 

47 - Customizable colors for on/off states 

48 - Configurable size and border radius 

49 - Click to toggle functionality 

50 - Property-based access to state 

51 - Signal emitted on state change 

52 

53 Args: 

54 parent: The parent widget (default: None). 

55 checked: Initial state of the toggle (default: False). 

56 width: Width of the toggle switch (default: 50). 

57 height: Height of the toggle switch (default: 24). 

58 animation: Whether to animate the toggle (default: True). 

59 *args: Additional arguments passed to QWidget. 

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

61 

62 Signals: 

63 toggled(bool): Emitted when the toggle state changes. 

64 

65 Example: 

66 >>> from ezqt_widgets import ToggleSwitch 

67 >>> switch = ToggleSwitch(checked=False, width=50, height=24) 

68 >>> switch.toggled.connect(lambda state: print(f"On: {state}")) 

69 >>> switch.checked = True 

70 >>> switch.show() 

71 """ 

72 

73 toggled = Signal(bool) 

74 

75 # /////////////////////////////////////////////////////////////// 

76 # INIT 

77 # /////////////////////////////////////////////////////////////// 

78 

79 def __init__( 

80 self, 

81 parent: WidgetParent = None, 

82 checked: bool = False, 

83 width: int = 50, 

84 height: int = 24, 

85 animation: bool = True, 

86 *args: Any, 

87 **kwargs: Any, 

88 ) -> None: 

89 """Initialize the toggle switch.""" 

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

91 

92 # Initialize properties 

93 self._checked: bool = checked 

94 self._width: int = width 

95 self._height: int = height 

96 self._animation: bool = animation 

97 self._circle_radius: int = (height - 4) // 2 # Circle radius with 2px margin 

98 self._animation_duration: int = 200 

99 

100 # Colors 

101 self._bg_color_off: QColor = QColor(44, 49, 58) # Default dark theme 

102 self._bg_color_on: QColor = QColor(150, 205, 50) # Default accent color 

103 self._circle_color: QColor = QColor(255, 255, 255) 

104 self._border_color: QColor = QColor(52, 59, 72) 

105 

106 # Initialize position 

107 self._circle_position: int = self._get_circle_position() 

108 

109 # Setup animation 

110 self._setup_animation() 

111 

112 # Setup widget 

113 self._setup_widget() 

114 

115 # ------------------------------------------------ 

116 # PRIVATE METHODS 

117 # ------------------------------------------------ 

118 

119 def _setup_animation(self) -> None: 

120 """Setup the animation system.""" 

121 self._animation_obj = QPropertyAnimation(self, b"circle_position") 

122 self._animation_obj.setDuration(self._animation_duration) 

123 self._animation_obj.setEasingCurve(QEasingCurve.Type.InOutQuart) 

124 

125 def _setup_widget(self) -> None: 

126 """Setup the widget properties.""" 

127 self.setSizePolicy(QSizePolicy.Policy.Fixed, QSizePolicy.Policy.Fixed) 

128 self.setFixedSize(self._width, self._height) 

129 self.setCursor(Qt.CursorShape.PointingHandCursor) 

130 

131 def _get_circle_position(self) -> int: 

132 """Calculate circle position based on state. 

133 

134 Returns: 

135 The circle position in pixels. 

136 """ 

137 if self._checked: 

138 return self._width - self._height + 2 # Right position 

139 else: 

140 return 2 # Left position 

141 

142 def _get_circle_position_property(self) -> int: 

143 """Property getter for animation. 

144 

145 Returns: 

146 The current circle position. 

147 """ 

148 return self._circle_position 

149 

150 def _set_circle_position_property(self, position: int) -> None: 

151 """Property setter for animation. 

152 

153 Args: 

154 position: The new circle position. 

155 """ 

156 self._circle_position = position 

157 self.update() 

158 

159 # Property for animation 

160 circle_position = Property( 

161 int, _get_circle_position_property, _set_circle_position_property 

162 ) 

163 

164 # /////////////////////////////////////////////////////////////// 

165 # PROPERTIES 

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

167 

168 @property 

169 def checked(self) -> bool: 

170 """Get the toggle state. 

171 

172 Returns: 

173 True if checked, False otherwise. 

174 """ 

175 return self._checked 

176 

177 @checked.setter 

178 def checked(self, value: bool) -> None: 

179 """Set the toggle state. 

180 

181 Args: 

182 value: The new toggle state. 

183 """ 

184 if value != self._checked: 184 ↛ exitline 184 didn't return from function 'checked' because the condition on line 184 was always true

185 self._checked = bool(value) 

186 if self._animation: 186 ↛ 189line 186 didn't jump to line 189 because the condition on line 186 was always true

187 self._animate_circle() 

188 else: 

189 self._circle_position = self._get_circle_position() 

190 self.update() 

191 self.toggled.emit(self._checked) 

192 

193 @property 

194 def width(self) -> int: 

195 """Get the width of the toggle. 

196 

197 Returns: 

198 The current width in pixels. 

199 """ 

200 return self._width 

201 

202 @width.setter 

203 def width(self, value: int) -> None: 

204 """Set the width of the toggle. 

205 

206 Args: 

207 value: The new width in pixels. 

208 """ 

209 self._width = max(20, int(value)) 

210 self._circle_radius = (self._height - 4) // 2 

211 self.setFixedSize(self._width, self._height) 

212 self._circle_position = self._get_circle_position() 

213 self.update() 

214 

215 @property 

216 def height(self) -> int: 

217 """Get the height of the toggle. 

218 

219 Returns: 

220 The current height in pixels. 

221 """ 

222 return self._height 

223 

224 @height.setter 

225 def height(self, value: int) -> None: 

226 """Set the height of the toggle. 

227 

228 Args: 

229 value: The new height in pixels. 

230 """ 

231 self._height = max(12, int(value)) 

232 self._circle_radius = (self._height - 4) // 2 

233 self.setFixedSize(self._width, self._height) 

234 self._circle_position = self._get_circle_position() 

235 self.update() 

236 

237 @property 

238 def animation(self) -> bool: 

239 """Get whether animation is enabled. 

240 

241 Returns: 

242 True if animation is enabled, False otherwise. 

243 """ 

244 return self._animation 

245 

246 @animation.setter 

247 def animation(self, value: bool) -> None: 

248 """Set whether animation is enabled. 

249 

250 Args: 

251 value: Whether to enable animation. 

252 """ 

253 self._animation = bool(value) 

254 

255 # /////////////////////////////////////////////////////////////// 

256 # PUBLIC METHODS 

257 # /////////////////////////////////////////////////////////////// 

258 

259 def toggle(self) -> None: 

260 """Toggle the switch state.""" 

261 self.checked = not self._checked 

262 

263 # ------------------------------------------------ 

264 # PRIVATE METHODS 

265 # ------------------------------------------------ 

266 

267 def _animate_circle(self) -> None: 

268 """Animate the circle movement.""" 

269 target_position = self._get_circle_position() 

270 self._animation_obj.setStartValue(self._circle_position) 

271 self._animation_obj.setEndValue(target_position) 

272 self._animation_obj.start() 

273 

274 # /////////////////////////////////////////////////////////////// 

275 # EVENT HANDLERS 

276 # /////////////////////////////////////////////////////////////// 

277 

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

279 """Handle mouse press events. 

280 

281 Args: 

282 event: The mouse event. 

283 """ 

284 if event.button() == Qt.MouseButton.LeftButton: 284 ↛ exitline 284 didn't return from function 'mousePressEvent' because the condition on line 284 was always true

285 self.toggle() 

286 

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

288 """Custom paint event to draw the toggle switch. 

289 

290 Args: 

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

292 """ 

293 painter = QPainter(self) 

294 painter.setRenderHint(QPainter.RenderHint.Antialiasing) 

295 

296 # Draw background 

297 bg_color = self._bg_color_on if self._checked else self._bg_color_off 

298 painter.setPen(QPen(self._border_color, 1)) 

299 painter.setBrush(QBrush(bg_color)) 

300 painter.drawRoundedRect( 

301 0, 

302 0, 

303 self._width, 

304 self._height, 

305 self._height // 2, 

306 self._height // 2, 

307 ) 

308 

309 # Draw circle 

310 circle_x = self._circle_position 

311 circle_y = (self._height - self._circle_radius * 2) // 2 

312 circle_rect = QRect( 

313 circle_x, circle_y, self._circle_radius * 2, self._circle_radius * 2 

314 ) 

315 

316 painter.setPen(Qt.PenStyle.NoPen) 

317 painter.setBrush(QBrush(self._circle_color)) 

318 painter.drawEllipse( 

319 circle_x, circle_y, circle_rect.width(), circle_rect.height() 

320 ) 

321 

322 # /////////////////////////////////////////////////////////////// 

323 # OVERRIDE METHODS 

324 # /////////////////////////////////////////////////////////////// 

325 

326 def sizeHint(self) -> QSize: 

327 """Return the recommended size for the widget. 

328 

329 Returns: 

330 The recommended size. 

331 """ 

332 return QSize(self._width, self._height) 

333 

334 def minimumSizeHint(self) -> QSize: 

335 """Return the minimum size for the widget. 

336 

337 Returns: 

338 The minimum size hint. 

339 """ 

340 return QSize(self._width, self._height) 

341 

342 # /////////////////////////////////////////////////////////////// 

343 # STYLE METHODS 

344 # /////////////////////////////////////////////////////////////// 

345 

346 def refreshStyle(self) -> None: 

347 """Refresh the widget style. 

348 

349 Useful after dynamic stylesheet changes. 

350 """ 

351 self.style().unpolish(self) 

352 self.style().polish(self) 

353 self.update() 

354 

355 

356# /////////////////////////////////////////////////////////////// 

357# PUBLIC API 

358# /////////////////////////////////////////////////////////////// 

359 

360__all__ = ["ToggleSwitch"]