Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
63 changes: 42 additions & 21 deletions qtapputils/widgets/range.py
Original file line number Diff line number Diff line change
Expand Up @@ -73,43 +73,60 @@ def textFromValue(self, value: float):

class PreciseSpinBox(DoubleSpinBox):
"""
QDoubleSpinBox that preserves full float64 precision when values are set
programmatically, while still displaying only as many decimals as
QDoubleSpinBox that optionally preserves full float64 precision when values
are set programmatically, while still displaying only as many decimals as
configured.

Parameters
----------
precise : bool, optional
If True (default), the spinbox preserves full float64 precision for
programmatically set values. If False, it behaves like a standard
QDoubleSpinBox without storing an internal precise value.
"""
sig_value_changed = Signal(float)

def __init__(self, *args, **kwargs):
def __init__(self, *args, precise: bool = True, **kwargs):
super().__init__(*args, **kwargs)
self.setKeyboardTracking(False)
self._precise = precise
self._true_value = super().value()
self.valueChanged.connect(self._handle_user_change)

def _handle_user_change(self, value: float):
"""Update internal value when the user edits the widget."""
self._true_value = value
if self._precise:
self._true_value = value
self.sig_value_changed.emit(value)

# ---- QDoubleSpinBox Interface
def value(self) -> float:
"""Return the precise stored value."""
return self._true_value
"""
Return the precise stored value if enabled,
otherwise the standard UI value.
"""
if self._precise:
return self._true_value
return super().value()

def setValue(self, new_value: float):
"""Set the value without losing precision due to display rounding."""
old_value = self.value()
new_value = max(min(new_value, self.maximum()), self.minimum())
if new_value == self._true_value:
return
self._true_value = float(new_value)

was_blocked = self.signalsBlocked()
self.blockSignals(True)
super().setValue(new_value)
self.blockSignals(False)
self.blockSignals(was_blocked)

if self._precise:
self._true_value = float(new_value)

self.sig_value_changed.emit(self._true_value)
if old_value != self.value():
self.sig_value_changed.emit(self.value())


class RangeSpinBox(DoubleSpinBox):
class RangeSpinBox(PreciseSpinBox):
"""
A spinbox that allow to enter values that are lower or higher than the
minimum and maximum value of the spinbox.
Expand All @@ -121,9 +138,9 @@ class RangeSpinBox(DoubleSpinBox):

def __init__(self, parent: QWidget = None, maximum: float = None,
minimum: float = None, singlestep: float = None,
decimals: int = None, value: float = None):
super().__init__(parent)
self.setKeyboardTracking(False)
decimals: int = None, value: float = None,
precise: bool = False):
super().__init__(parent=parent, precise=precise)
if minimum is not None:
self.setMinimum(minimum)
if maximum is not None:
Expand Down Expand Up @@ -205,17 +222,15 @@ def __init__(self, parent: QWidget = None, maximum: float = 99.99,
self.spinbox_start = RangeSpinBox(
minimum=minimum, singlestep=singlestep, decimals=decimals,
value=minimum)
self.spinbox_start.valueChanged.connect(
lambda: self._handle_value_changed())
self.spinbox_start.editingFinished.connect(

self.spinbox_start.sig_value_changed.connect(
lambda: self._handle_value_changed())

self.spinbox_end = RangeSpinBox(
maximum=maximum, singlestep=singlestep, decimals=decimals,
value=maximum)
self.spinbox_end.valueChanged.connect(
lambda: self._handle_value_changed())
self.spinbox_end.editingFinished.connect(

self.spinbox_end.sig_value_changed.connect(
lambda: self._handle_value_changed())

self._update_spinbox_range()
Expand All @@ -235,10 +250,14 @@ def set_range(self, start: float, end: float):
step = 0 if self.null_range_ok else 10**-self.decimals

self._block_spinboxes_signals(True)

# Reset the spin boxes range.
self.spinbox_start.setMaximum(self.spinbox_end.maximum() - step)
self.spinbox_end.setMinimum(self.spinbox_start.minimum() + step)

self.spinbox_start.setValue(start)
self.spinbox_end.setValue(end)

self._block_spinboxes_signals(False)

if old_start != self.start() or old_end != self.end():
Expand Down Expand Up @@ -278,9 +297,11 @@ def _update_spinbox_range(self):
mutually exclusive
"""
self._block_spinboxes_signals(True)

step = 0 if self.null_range_ok else 10**-self.decimals
self.spinbox_start.setMaximum(self.spinbox_end.value() - step)
self.spinbox_end.setMinimum(self.spinbox_start.value() + step)

self._block_spinboxes_signals(False)

def _handle_value_changed(self, silent: bool = False):
Expand Down
44 changes: 32 additions & 12 deletions qtapputils/widgets/tests/test_range.py
Original file line number Diff line number Diff line change
Expand Up @@ -63,12 +63,13 @@ def range_widget(qtbot):
# =============================================================================
# Tests
# =============================================================================
def test_precise_dspinbox(qtbot):
@pytest.mark.parametrize("precise_mode", [True, False])
def test_precise_dspinbox(qtbot, precise_mode):
"""
Test that PreciseSpinBox preserves full precision internally
while displaying rounded values.
Test that PreciseSpinBox behaves correctly in both precise
and normal modes.
"""
spin = PreciseSpinBox()
spin = PreciseSpinBox(precise=precise_mode)
spin.setDecimals(2)
spin.setRange(-1e9, 1e9)
qtbot.addWidget(spin)
Expand All @@ -79,62 +80,81 @@ def test_precise_dspinbox(qtbot):
spin.sig_value_changed.connect(lambda v: emitted_values.append(v))

# Set a value with high precision
precise_value = 3.141592653589793
spin.setValue(precise_value)
spin.setValue(3.141592653589793)

# Internal value should match the full float64 precision.
assert spin.value() == precise_value
assert emitted_values == [precise_value]
# Internal value should match the full float64 precision in
# precise mode.
if precise_mode:
assert spin.value() == 3.141592653589793
assert emitted_values == [3.141592653589793]
else:
assert spin.value() == 3.14
assert emitted_values == [3.14]

# The displayed value in the UI should be rounded to 2 decimals
assert spin.text() == "3.14"

# Setting the same value again should NOT emit the signal
spin.setValue(precise_value)
assert emitted_values == [precise_value]
spin.setValue(3.141592653589793)
if precise_mode:
assert emitted_values == [3.141592653589793]
else:
assert emitted_values == [3.14]

# Changing the value from the GUI should update the internal value.
spin.clear()
qtbot.keyClicks(spin, '4.12345')
qtbot.keyClick(spin, Qt.Key_Enter)

assert spin.value() == 4.12
assert emitted_values == [precise_value, 4.12]
if precise_mode:
assert emitted_values == [3.141592653589793, 4.12]
else:
assert emitted_values == [3.14, 4.12]


def test_range_spinbox(range_spinbox, qtbot):
"""
Test that the RangeSpinBox is working as expected.
"""
# Connect a flag to check signal emission
emitted_values = []
range_spinbox.sig_value_changed.connect(lambda v: emitted_values.append(v))

# Test entering a value above the maximum.
range_spinbox.clear()
qtbot.keyClicks(range_spinbox, '120')
qtbot.keyClick(range_spinbox, Qt.Key_Enter)
assert range_spinbox.value() == 101
assert emitted_values == [101]

# Test entering a value below the minimum.
range_spinbox.clear()
qtbot.keyClicks(range_spinbox, '-12')
qtbot.keyClick(range_spinbox, Qt.Key_Enter)
assert range_spinbox.value() == 3
assert emitted_values == [101, 3]

# Test entering a valid value.
range_spinbox.clear()
qtbot.keyClicks(range_spinbox, '45.34823')
qtbot.keyClick(range_spinbox, Qt.Key_Enter)
assert range_spinbox.value() == 45.35
assert emitted_values == [101, 3, 45.35]

# Test entering an intermediate value.
range_spinbox.clear()
qtbot.keyClicks(range_spinbox, '-')
qtbot.keyClick(range_spinbox, Qt.Key_Enter)
assert range_spinbox.value() == 45.35
assert emitted_values == [101, 3, 45.35]

# Test entering invalid values.
range_spinbox.clear()
qtbot.keyClicks(range_spinbox, '23..a-45')
qtbot.keyClick(range_spinbox, Qt.Key_Enter)
assert range_spinbox.value() == 23.45
assert emitted_values == [101, 3, 45.35, 23.45]


def test_range_widget(range_widget, qtbot):
Expand Down