Coverage for src / ezqt_widgets / widgets / input / spin_box_input.py: 85.61%

131 statements  

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

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

2# SPIN_BOX_INPUT - Spin Box Input Widget 

3# Project: ezqt_widgets 

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

5 

6""" 

7Spin box input widget module. 

8 

9Provides a fully custom numeric spin box widget with integrated decrement 

10and increment buttons, mouse wheel support, and real-time validation. 

11""" 

12 

13from __future__ import annotations 

14 

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

16# IMPORTS 

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

18# Third-party imports 

19from PySide6.QtCore import Qt, Signal 

20from PySide6.QtGui import QIntValidator, QWheelEvent 

21from PySide6.QtWidgets import ( 

22 QHBoxLayout, 

23 QLineEdit, 

24 QSizePolicy, 

25 QToolButton, 

26 QWidget, 

27) 

28 

29# Local imports 

30from ...types import WidgetParent 

31 

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

33# CLASSES 

34# /////////////////////////////////////////////////////////////// 

35 

36 

37class SpinBoxInput(QWidget): 

38 """Custom numeric spin box with integrated decrement and increment buttons. 

39 

40 Provides a fully stylable numeric input with − and + buttons flanking 

41 a central QLineEdit. Supports mouse wheel increments and real-time 

42 QIntValidator clamping. 

43 

44 Features: 

45 - Decrement (−) and increment (+) QToolButtons 

46 - Central QLineEdit with QIntValidator 

47 - Mouse wheel increments/decrements by step 

48 - Value clamped between minimum and maximum at all times 

49 - Optional prefix and suffix labels 

50 - Signal emitted only when value changes 

51 

52 Args: 

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

54 value: Initial value (default: 0). 

55 minimum: Minimum allowed value (default: 0). 

56 maximum: Maximum allowed value (default: 100). 

57 step: Step size for increment/decrement (default: 1). 

58 prefix: String prepended to the displayed value (default: ""). 

59 suffix: String appended to the displayed value (default: ""). 

60 

61 Properties: 

62 value: Get or set the current integer value. 

63 minimum: Get or set the minimum allowed value. 

64 maximum: Get or set the maximum allowed value. 

65 step: Get or set the step size. 

66 prefix: Get or set the display prefix. 

67 suffix: Get or set the display suffix. 

68 

69 Signals: 

70 valueChanged(int): Emitted when the value changes. 

71 

72 Example: 

73 >>> from ezqt_widgets import SpinBoxInput 

74 >>> spin = SpinBoxInput(value=10, minimum=0, maximum=100, step=5) 

75 >>> spin.valueChanged.connect(lambda v: print(f"Value: {v}")) 

76 >>> spin.show() 

77 """ 

78 

79 valueChanged = Signal(int) 

80 

81 # /////////////////////////////////////////////////////////////// 

82 # INIT 

83 # /////////////////////////////////////////////////////////////// 

84 

85 def __init__( 

86 self, 

87 parent: WidgetParent = None, 

88 *, 

89 value: int = 0, 

90 minimum: int = 0, 

91 maximum: int = 100, 

92 step: int = 1, 

93 prefix: str = "", 

94 suffix: str = "", 

95 ) -> None: 

96 """Initialize the spin box input.""" 

97 super().__init__(parent) 

98 self.setProperty("type", "SpinBoxInput") 

99 

100 # Initialize private state 

101 self._minimum: int = minimum 

102 self._maximum: int = maximum 

103 self._step: int = max(1, step) 

104 self._prefix: str = prefix 

105 self._suffix: str = suffix 

106 self._value: int = max(minimum, min(maximum, value)) 

107 

108 # Enable mouse wheel focus 

109 self.setFocusPolicy(Qt.FocusPolicy.StrongFocus) 

110 

111 # Setup UI 

112 self._setup_widget() 

113 

114 # ------------------------------------------------ 

115 # PRIVATE METHODS 

116 # ------------------------------------------------ 

117 

118 def _setup_widget(self) -> None: 

119 """Setup the widget layout and child components.""" 

120 # Decrement button 

121 self._btn_dec = QToolButton() 

122 self._btn_dec.setText("−") 

123 self._btn_dec.setFocusPolicy(Qt.FocusPolicy.NoFocus) 

124 self._btn_dec.setCursor(Qt.CursorShape.PointingHandCursor) 

125 self._btn_dec.clicked.connect(self.stepDown) 

126 

127 # Central QLineEdit 

128 self._line_edit = QLineEdit() 

129 self._line_edit.setAlignment(Qt.AlignmentFlag.AlignCenter) 

130 self._line_edit.setSizePolicy( 

131 QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed 

132 ) 

133 self._validator = QIntValidator(self._minimum, self._maximum) 

134 self._line_edit.setValidator(self._validator) 

135 self._line_edit.editingFinished.connect(self._on_editing_finished) 

136 self._line_edit.textChanged.connect(self._on_text_changed) 

137 

138 # Increment button 

139 self._btn_inc = QToolButton() 

140 self._btn_inc.setText("+") 

141 self._btn_inc.setFocusPolicy(Qt.FocusPolicy.NoFocus) 

142 self._btn_inc.setCursor(Qt.CursorShape.PointingHandCursor) 

143 self._btn_inc.clicked.connect(self.stepUp) 

144 

145 # Layout 

146 layout = QHBoxLayout(self) 

147 layout.setContentsMargins(0, 0, 0, 0) 

148 layout.setSpacing(2) 

149 layout.addWidget(self._btn_dec) 

150 layout.addWidget(self._line_edit) 

151 layout.addWidget(self._btn_inc) 

152 

153 # Initial display 

154 self._update_display() 

155 

156 def _update_display(self) -> None: 

157 """Refresh the QLineEdit text with prefix, value, and suffix.""" 

158 # Block signals to avoid re-entrant _on_text_changed 

159 self._line_edit.blockSignals(True) 

160 self._line_edit.setText(f"{self._prefix}{self._value}{self._suffix}") 

161 self._line_edit.blockSignals(False) 

162 

163 def _on_editing_finished(self) -> None: 

164 """Commit the text value on editing finished.""" 

165 raw = self._line_edit.text() 

166 # Strip prefix/suffix before parsing 

167 raw = raw.removeprefix(self._prefix).removesuffix(self._suffix) 

168 try: 

169 parsed = int(raw) 

170 except ValueError: 

171 self._update_display() 

172 return 

173 self.setValue(parsed) 

174 

175 def _on_text_changed(self, text: str) -> None: 

176 """Attempt live parsing; silently ignore incomplete input. 

177 

178 Args: 

179 text: The current text in the QLineEdit. 

180 """ 

181 raw = text.removeprefix(self._prefix).removesuffix(self._suffix) 

182 try: 

183 parsed = int(raw) 

184 clamped = max(self._minimum, min(self._maximum, parsed)) 

185 if clamped != self._value: 

186 # Do not call setValue to avoid display loop; update state only 

187 self._value = clamped 

188 self.valueChanged.emit(self._value) 

189 except ValueError: 

190 pass 

191 

192 # /////////////////////////////////////////////////////////////// 

193 # PROPERTIES 

194 # /////////////////////////////////////////////////////////////// 

195 

196 @property 

197 def value(self) -> int: 

198 """Get the current integer value. 

199 

200 Returns: 

201 The current value. 

202 """ 

203 return self._value 

204 

205 @value.setter 

206 def value(self, val: int) -> None: 

207 """Set the current value, clamped between minimum and maximum. 

208 

209 Args: 

210 val: The new value. 

211 """ 

212 self.setValue(val) 

213 

214 @property 

215 def minimum(self) -> int: 

216 """Get the minimum allowed value. 

217 

218 Returns: 

219 The current minimum. 

220 """ 

221 return self._minimum 

222 

223 @minimum.setter 

224 def minimum(self, val: int) -> None: 

225 """Set the minimum allowed value. 

226 

227 Args: 

228 val: The new minimum value. 

229 """ 

230 self._minimum = int(val) 

231 self._validator.setBottom(self._minimum) 

232 self.setValue(self._value) 

233 

234 @property 

235 def maximum(self) -> int: 

236 """Get the maximum allowed value. 

237 

238 Returns: 

239 The current maximum. 

240 """ 

241 return self._maximum 

242 

243 @maximum.setter 

244 def maximum(self, val: int) -> None: 

245 """Set the maximum allowed value. 

246 

247 Args: 

248 val: The new maximum value. 

249 """ 

250 self._maximum = int(val) 

251 self._validator.setTop(self._maximum) 

252 self.setValue(self._value) 

253 

254 @property 

255 def step(self) -> int: 

256 """Get the step size for increment/decrement. 

257 

258 Returns: 

259 The current step size. 

260 """ 

261 return self._step 

262 

263 @step.setter 

264 def step(self, val: int) -> None: 

265 """Set the step size for increment/decrement. 

266 

267 Args: 

268 val: The new step size (minimum 1). 

269 """ 

270 self._step = max(1, int(val)) 

271 

272 @property 

273 def prefix(self) -> str: 

274 """Get the display prefix. 

275 

276 Returns: 

277 The current prefix string. 

278 """ 

279 return self._prefix 

280 

281 @prefix.setter 

282 def prefix(self, val: str) -> None: 

283 """Set the display prefix. 

284 

285 Args: 

286 val: The new prefix string. 

287 """ 

288 self._prefix = str(val) 

289 self._update_display() 

290 

291 @property 

292 def suffix(self) -> str: 

293 """Get the display suffix. 

294 

295 Returns: 

296 The current suffix string. 

297 """ 

298 return self._suffix 

299 

300 @suffix.setter 

301 def suffix(self, val: str) -> None: 

302 """Set the display suffix. 

303 

304 Args: 

305 val: The new suffix string. 

306 """ 

307 self._suffix = str(val) 

308 self._update_display() 

309 

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

311 # PUBLIC METHODS 

312 # /////////////////////////////////////////////////////////////// 

313 

314 def setValue(self, value: int) -> None: 

315 """Set the value, clamped between minimum and maximum. 

316 

317 Args: 

318 value: The new value to set. 

319 """ 

320 clamped = max(self._minimum, min(self._maximum, int(value))) 

321 if clamped != self._value: 

322 self._value = clamped 

323 self._update_display() 

324 self.valueChanged.emit(self._value) 

325 else: 

326 # Always refresh display to show prefix/suffix 

327 self._update_display() 

328 

329 def stepUp(self) -> None: 

330 """Increment the value by step, clamped at maximum.""" 

331 self.setValue(self._value + self._step) 

332 

333 def stepDown(self) -> None: 

334 """Decrement the value by step, clamped at minimum.""" 

335 self.setValue(self._value - self._step) 

336 

337 # /////////////////////////////////////////////////////////////// 

338 # EVENT HANDLERS 

339 # /////////////////////////////////////////////////////////////// 

340 

341 def wheelEvent(self, event: QWheelEvent) -> None: 

342 """Handle mouse wheel to increment or decrement by step. 

343 

344 Args: 

345 event: The wheel event. 

346 """ 

347 delta = event.angleDelta().y() 

348 if delta > 0: 

349 self.stepUp() 

350 elif delta < 0: 350 ↛ 352line 350 didn't jump to line 352 because the condition on line 350 was always true

351 self.stepDown() 

352 event.accept() 

353 

354 # /////////////////////////////////////////////////////////////// 

355 # STYLE METHODS 

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

357 

358 def refreshStyle(self) -> None: 

359 """Refresh the widget style. 

360 

361 Useful after dynamic stylesheet changes. 

362 """ 

363 self.style().unpolish(self) 

364 self.style().polish(self) 

365 self.update() 

366 

367 

368# /////////////////////////////////////////////////////////////// 

369# PUBLIC API 

370# /////////////////////////////////////////////////////////////// 

371 

372__all__ = ["SpinBoxInput"]