Skip to content

Commit

Permalink
Merge pull request #3 from jnsebgosselin/add_range_widget
Browse files Browse the repository at this point in the history
PR: Add range spinbox and range widget classes
  • Loading branch information
jnsebgosselin committed Oct 10, 2023
2 parents 7be8c6e + 5ac6ca8 commit a27b45b
Show file tree
Hide file tree
Showing 4 changed files with 357 additions and 0 deletions.
10 changes: 10 additions & 0 deletions qtapputils/widgets/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
# -*- coding: utf-8 -*-
# -----------------------------------------------------------------------------
# Copyright © QtAppUtils Project Contributors
# https://github.com/jnsebgosselin/apputils
#
# This file is part of QtAppUtils.
# Licensed under the terms of the MIT License.
# -----------------------------------------------------------------------------

from .range import RangeSpinBox, RangeWidget
179 changes: 179 additions & 0 deletions qtapputils/widgets/range.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,179 @@
# -*- coding: utf-8 -*-
# -----------------------------------------------------------------------------
# Copyright © QtAppUtils Project Contributors
# https://github.com/jnsebgosselin/apputils
#
# This file is part of QtAppUtils.
# Licensed under the terms of the MIT License.
# -----------------------------------------------------------------------------

# ---- Third party imports
from qtpy.QtCore import Signal, QObject
from qtpy.QtGui import QValidator
from qtpy.QtWidgets import QDoubleSpinBox, QWidget


class RangeSpinBox(QDoubleSpinBox):
"""
A spinbox that allow to enter values that are lower or higher than the
minimum and maximum value of the spinbox.
When editing is finished, the value is corrected to the maximum if the
value entered was above the maximum and to the minimum if it was below
the minimum.
"""

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)
if minimum is not None:
self.setMinimum(minimum)
if maximum is not None:
self.setMaximum(maximum)
if singlestep is not None:
self.setSingleStep(singlestep)
if decimals is not None:
self.setDecimals(decimals)
if value is not None:
self.setValue(value)

def sizeHint(self):
"""
Override Qt method to add a buffer to the hint width of the spinbox,
so that there is enough space to hold the maximum value in the spinbox
when editing.
"""
qsize = super().sizeHint()
qsize.setWidth(qsize.width() + 8)
return qsize

def fixup(self, value):
"""Override Qt method."""
if value in ('-', '', '.', ','):
return super().fixup(value)
elif float(value) > self.maximum():
return '{value:0.{decimals}f}'.format(
value=self.maximum(), decimals=self.decimals())
else:
return '{value:0.{decimals}f}'.format(
value=self.minimum(), decimals=self.decimals())

def validate(self, value, pos):
"""Override Qt method."""
if value in ('-', '', '.', ','):
return QValidator.Intermediate, value, pos

try:
float(value)
except(ValueError):
return QValidator.Invalid, value, pos

if float(value) > self.maximum() or float(value) < self.minimum():
return QValidator.Intermediate, value, pos
else:
return QValidator.Acceptable, value, pos


class RangeWidget(QObject):
"""
A Qt object that link two double spinboxes that can be used to define
the start and end value of a range.
The RangeWidget does not come with a layout and both the spinbox_start
and spinbox_end must be added to a layout independently.
"""
sig_range_changed = Signal(float, float)

def __init__(self, parent: QWidget = None, maximum: float = 99.99,
minimum: float = 0, singlestep: float = 0.01,
decimals: int = 2, null_range_ok: bool = True):
super().__init__()
self.decimals = decimals
self.null_range_ok = null_range_ok

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(
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(
lambda: self._handle_value_changed())

self._update_spinbox_range()

def start(self):
"""Return the start value of the range."""
return self.spinbox_start.value()

def end(self):
"""Return the end value of the range."""
return self.spinbox_end.value()

def set_range(self, start: float, end: float):
"""Set the start and end value of the range."""
old_start = self.start()
old_end = self.end()

self._block_spinboxes_signals(True)
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():
self._handle_value_changed()

def set_minimum(self, minimum: float):
"""Set the minimum allowed value of the start of the range."""
old_start = self.start()

self._block_spinboxes_signals(True)
self.spinbox_start.setMinimum(minimum)
self._block_spinboxes_signals(False)

if old_start != self.start():
self._handle_value_changed()

def set_maximum(self, maximum: float):
"""Set the maximum allowed value of the end of the range."""
old_end = self.end()

self._block_spinboxes_signals(True)
self.spinbox_end.setMaximum(maximum)
self._block_spinboxes_signals(False)

if old_end != self.end():
self._handle_value_changed()

# ---- Private methods and handlers
def _block_spinboxes_signals(self, block: bool):
"""Block signals from the spinboxes."""
self.spinbox_start.blockSignals(block)
self.spinbox_end.blockSignals(block)

def _update_spinbox_range(self):
"""
Make sure lowcut and highcut spinbox ranges are
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):
"""
Handle when the value of the lowcut or highcut spinbox changed.
"""
self._update_spinbox_range()
self.sig_range_changed.emit(self.start(), self.end())
8 changes: 8 additions & 0 deletions qtapputils/widgets/tests/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
# -*- coding: utf-8 -*-
# -----------------------------------------------------------------------------
# Copyright © QtAppUtils Project Contributors
# https://github.com/jnsebgosselin/apputils
#
# This file is part of QtAppUtils.
# Licensed under the terms of the MIT License.
# -----------------------------------------------------------------------------
160 changes: 160 additions & 0 deletions qtapputils/widgets/tests/test_range.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,160 @@
# -*- coding: utf-8 -*-
# -----------------------------------------------------------------------------
# Copyright © QtAppUtils Project Contributors
# https://github.com/jnsebgosselin/apputils
#
# This file is part of QtAppUtils.
# Licensed under the terms of the MIT License.
# -----------------------------------------------------------------------------

"""Tests for the RangeSpinBox and RangeWidget."""

from qtpy.QtCore import Qt
from qtapputils.widgets import RangeSpinBox, RangeWidget
import pytest


# =============================================================================
# Fixtures
# =============================================================================
@pytest.fixture
def range_spinbox(qtbot):
spinbox = RangeSpinBox(
minimum=3, maximum=101, singlestep=0.1, decimals=2, value=74.21)
qtbot.addWidget(spinbox)
spinbox.show()

assert spinbox.maximum() == 101
assert spinbox.minimum() == 3
assert spinbox.decimals() == 2
assert spinbox.singleStep() == 0.1
assert spinbox.value() == 74.21

return spinbox


@pytest.fixture
def range_widget(qtbot):
widget = RangeWidget(null_range_ok=False)
qtbot.addWidget(widget.spinbox_start)
qtbot.addWidget(widget.spinbox_end)
widget.spinbox_start.show()
widget.spinbox_end.show()

assert widget.start() == 0
assert widget.end() == 99.99

assert widget.spinbox_start.minimum() == 0
assert widget.spinbox_start.maximum() == 99.98
assert widget.spinbox_start.decimals() == 2
assert widget.spinbox_start.singleStep() == 0.01
assert widget.spinbox_start.value() == 0

assert widget.spinbox_end.minimum() == 0.01
assert widget.spinbox_end.maximum() == 99.99
assert widget.spinbox_end.decimals() == 2
assert widget.spinbox_end.singleStep() == 0.01
assert widget.spinbox_end.value() == 99.99

return widget


# =============================================================================
# Tests
# =============================================================================
def test_range_spinbox(range_spinbox, qtbot):
"""
Test that the RangeSpinBox is working as expected.
"""
# 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

# 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

# Test entering a valid value.
range_spinbox.clear()
qtbot.keyClicks(range_spinbox, '45.3')
qtbot.keyClick(range_spinbox, Qt.Key_Enter)
assert range_spinbox.value() == 45.3

# Test entering an intermediate value.
range_spinbox.clear()
qtbot.keyClicks(range_spinbox, '-')
qtbot.keyClick(range_spinbox, Qt.Key_Enter)
assert range_spinbox.value() == 45.3

# 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


def test_range_widget(range_widget, qtbot):
"""
Test that the RangeWidget is working as expected.
"""
# Test set_minimum functionality.
range_widget.set_minimum(24)
assert range_widget.start() == 24
assert range_widget.end() == 99.99

assert range_widget.spinbox_start.minimum() == 24
assert range_widget.spinbox_start.maximum() == 99.98
assert range_widget.spinbox_start.value() == 24

assert range_widget.spinbox_end.minimum() == 24.01
assert range_widget.spinbox_end.maximum() == 99.99
assert range_widget.spinbox_end.value() == 99.99

# Test set_maximum functionality.
range_widget.set_maximum(74)
assert range_widget.start() == 24
assert range_widget.end() == 74

assert range_widget.spinbox_start.minimum() == 24
assert range_widget.spinbox_start.maximum() == 73.99
assert range_widget.spinbox_start.value() == 24

assert range_widget.spinbox_end.minimum() == 24.01
assert range_widget.spinbox_end.maximum() == 74.00
assert range_widget.spinbox_end.value() == 74.00

# Test set_range functionality.
range_widget.set_range(48, 62.3)
assert range_widget.start() == 48
assert range_widget.end() == 62.3

assert range_widget.spinbox_start.minimum() == 24
assert range_widget.spinbox_start.maximum() == 62.29
assert range_widget.spinbox_start.value() == 48

assert range_widget.spinbox_end.minimum() == 48.01
assert range_widget.spinbox_end.maximum() == 74.00
assert range_widget.spinbox_end.value() == 62.3

# Test entering values in spinbox.
range_widget.spinbox_start.clear()
qtbot.keyClicks(range_widget.spinbox_start, '43.51')
qtbot.keyClick(range_widget.spinbox_start, Qt.Key_Enter)
assert range_widget.start() == 43.51
assert range_widget.end() == 62.3

assert range_widget.spinbox_start.minimum() == 24
assert range_widget.spinbox_start.maximum() == 62.29
assert range_widget.spinbox_start.value() == 43.51

assert range_widget.spinbox_end.minimum() == 43.52
assert range_widget.spinbox_end.maximum() == 74.00
assert range_widget.spinbox_end.value() == 62.3


if __name__ == '__main__':
pytest.main(['-x', __file__, '-vv', '-rw'])

0 comments on commit a27b45b

Please sign in to comment.