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-03-31 10:03 +0000
« prev ^ index » next coverage.py v7.13.5, created at 2026-03-31 10:03 +0000
1# ///////////////////////////////////////////////////////////////
2# SPIN_BOX_INPUT - Spin Box Input Widget
3# Project: ezqt_widgets
4# ///////////////////////////////////////////////////////////////
6"""
7Spin box input widget module.
9Provides a fully custom numeric spin box widget with integrated decrement
10and increment buttons, mouse wheel support, and real-time validation.
11"""
13from __future__ import annotations
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)
29# Local imports
30from ...types import WidgetParent
32# ///////////////////////////////////////////////////////////////
33# CLASSES
34# ///////////////////////////////////////////////////////////////
37class SpinBoxInput(QWidget):
38 """Custom numeric spin box with integrated decrement and increment buttons.
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.
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
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: "").
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.
69 Signals:
70 valueChanged(int): Emitted when the value changes.
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 """
79 valueChanged = Signal(int)
81 # ///////////////////////////////////////////////////////////////
82 # INIT
83 # ///////////////////////////////////////////////////////////////
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")
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))
108 # Enable mouse wheel focus
109 self.setFocusPolicy(Qt.FocusPolicy.StrongFocus)
111 # Setup UI
112 self._setup_widget()
114 # ------------------------------------------------
115 # PRIVATE METHODS
116 # ------------------------------------------------
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)
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)
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)
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)
153 # Initial display
154 self._update_display()
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)
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)
175 def _on_text_changed(self, text: str) -> None:
176 """Attempt live parsing; silently ignore incomplete input.
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
192 # ///////////////////////////////////////////////////////////////
193 # PROPERTIES
194 # ///////////////////////////////////////////////////////////////
196 @property
197 def value(self) -> int:
198 """Get the current integer value.
200 Returns:
201 The current value.
202 """
203 return self._value
205 @value.setter
206 def value(self, val: int) -> None:
207 """Set the current value, clamped between minimum and maximum.
209 Args:
210 val: The new value.
211 """
212 self.setValue(val)
214 @property
215 def minimum(self) -> int:
216 """Get the minimum allowed value.
218 Returns:
219 The current minimum.
220 """
221 return self._minimum
223 @minimum.setter
224 def minimum(self, val: int) -> None:
225 """Set the minimum allowed value.
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)
234 @property
235 def maximum(self) -> int:
236 """Get the maximum allowed value.
238 Returns:
239 The current maximum.
240 """
241 return self._maximum
243 @maximum.setter
244 def maximum(self, val: int) -> None:
245 """Set the maximum allowed value.
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)
254 @property
255 def step(self) -> int:
256 """Get the step size for increment/decrement.
258 Returns:
259 The current step size.
260 """
261 return self._step
263 @step.setter
264 def step(self, val: int) -> None:
265 """Set the step size for increment/decrement.
267 Args:
268 val: The new step size (minimum 1).
269 """
270 self._step = max(1, int(val))
272 @property
273 def prefix(self) -> str:
274 """Get the display prefix.
276 Returns:
277 The current prefix string.
278 """
279 return self._prefix
281 @prefix.setter
282 def prefix(self, val: str) -> None:
283 """Set the display prefix.
285 Args:
286 val: The new prefix string.
287 """
288 self._prefix = str(val)
289 self._update_display()
291 @property
292 def suffix(self) -> str:
293 """Get the display suffix.
295 Returns:
296 The current suffix string.
297 """
298 return self._suffix
300 @suffix.setter
301 def suffix(self, val: str) -> None:
302 """Set the display suffix.
304 Args:
305 val: The new suffix string.
306 """
307 self._suffix = str(val)
308 self._update_display()
310 # ///////////////////////////////////////////////////////////////
311 # PUBLIC METHODS
312 # ///////////////////////////////////////////////////////////////
314 def setValue(self, value: int) -> None:
315 """Set the value, clamped between minimum and maximum.
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()
329 def stepUp(self) -> None:
330 """Increment the value by step, clamped at maximum."""
331 self.setValue(self._value + self._step)
333 def stepDown(self) -> None:
334 """Decrement the value by step, clamped at minimum."""
335 self.setValue(self._value - self._step)
337 # ///////////////////////////////////////////////////////////////
338 # EVENT HANDLERS
339 # ///////////////////////////////////////////////////////////////
341 def wheelEvent(self, event: QWheelEvent) -> None:
342 """Handle mouse wheel to increment or decrement by step.
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()
354 # ///////////////////////////////////////////////////////////////
355 # STYLE METHODS
356 # ///////////////////////////////////////////////////////////////
358 def refreshStyle(self) -> None:
359 """Refresh the widget style.
361 Useful after dynamic stylesheet changes.
362 """
363 self.style().unpolish(self)
364 self.style().polish(self)
365 self.update()
368# ///////////////////////////////////////////////////////////////
369# PUBLIC API
370# ///////////////////////////////////////////////////////////////
372__all__ = ["SpinBoxInput"]