diff --git a/docs/widgets/index.md b/docs/widgets/index.md index 6bc2412f..6e0d06a6 100644 --- a/docs/widgets/index.md +++ b/docs/widgets/index.md @@ -10,6 +10,7 @@ The following are QWidget subclasses: | [`QDoubleSlider`](./qdoubleslider.md) | Slider for float values | | [`QLabeledDoubleRangeSlider`](./qlabeleddoublerangeslider.md) | `QDoubleRangeSlider` variant with editable labels for each handle | | [`QLabeledDoubleSlider`](./qlabeleddoubleslider.md) | `QSlider` for float values with editable `QSpinBox` with the current value | +| [`QLabeledMappedSlider`](./qlabeledmappedslider.md) | `QLabeledDoubleSlider` variant with a log or power curve from slider position to value | | [`QLabeledRangeSlider`](./qlabeledrangeslider.md) | `QRangeSlider` variant, with editable labels for each handle | | [`QLabeledSlider`](./qlabeledslider.md) | `QSlider` with editable `QSpinBox` that shows the current value | | [`QLargeIntSpinBox`](./qlargeintspinbox.md) | `QSpinbox` that accepts arbitrarily large integers | diff --git a/docs/widgets/qlabeledmappedslider.md b/docs/widgets/qlabeledmappedslider.md new file mode 100644 index 00000000..0bd02fa0 --- /dev/null +++ b/docs/widgets/qlabeledmappedslider.md @@ -0,0 +1,24 @@ +# QLabeledMappedSlider + +[`QLabeledDoubleSlider`](./qlabeleddoubleslider.md) variant where the slider position is mapped to the value using a log or power curve. + +```python +from qtpy.QtCore import Qt +from qtpy.QtWidgets import QApplication + +from superqt import QLabeledMappedSlider + +app = QApplication([]) + +slider = QLabeledMappedSlider(Qt.Orientation.Horizontal) +slider.setMapping("log") +slider.setRange(1, 100) +slider.setValue(10) +slider.show() + +app.exec_() +``` + +{{ show_widget() }} + +{{ show_members('superqt.QLabeledMappedSlider') }} diff --git a/examples/mapped_slider.py b/examples/mapped_slider.py new file mode 100644 index 00000000..1a797900 --- /dev/null +++ b/examples/mapped_slider.py @@ -0,0 +1,34 @@ +from qtpy.QtCore import Qt +from qtpy.QtWidgets import QApplication, QFormLayout, QWidget + +from superqt import QLabeledDoubleSlider, QLabeledMappedSlider + +app = QApplication([]) + +linear = QLabeledDoubleSlider(Qt.Orientation.Horizontal) +linear.setRange(0, 0.5) +linear.setDecimals(3) +linear.valueChanged.connect(lambda v: print("linear", v)) + +log = QLabeledMappedSlider(Qt.Orientation.Horizontal) +log.setMapping("log") +log.setRange(0.001, 1) +log.setDecimals(4) +log.valueChanged.connect(lambda v: print("log", v)) + +power = QLabeledMappedSlider(Qt.Orientation.Horizontal) +power.setMapping("power") +power.setExponent(3) +power.setRange(0, 0.5) +power.setDecimals(4) +power.valueChanged.connect(lambda v: print("power", v)) + +w = QWidget() +layout = QFormLayout(w) +layout.addRow("linear (0 to 0.5)", linear) +layout.addRow("log (0.001 to 1)", log) +layout.addRow("power 3 (0 to 0.5)", power) +w.resize(500, 120) +w.show() + +app.exec() diff --git a/src/superqt/__init__.py b/src/superqt/__init__.py index 2ed2c2e1..1e04a14d 100644 --- a/src/superqt/__init__.py +++ b/src/superqt/__init__.py @@ -17,6 +17,7 @@ QDoubleSlider, QLabeledDoubleRangeSlider, QLabeledDoubleSlider, + QLabeledMappedSlider, QLabeledRangeSlider, QLabeledSlider, QRangeSlider, @@ -43,6 +44,7 @@ "QIconifyIcon", "QLabeledDoubleRangeSlider", "QLabeledDoubleSlider", + "QLabeledMappedSlider", "QLabeledRangeSlider", "QLabeledSlider", "QLargeIntSpinBox", diff --git a/src/superqt/sliders/__init__.py b/src/superqt/sliders/__init__.py index e42cedc0..38d85d41 100644 --- a/src/superqt/sliders/__init__.py +++ b/src/superqt/sliders/__init__.py @@ -4,6 +4,7 @@ QLabeledRangeSlider, QLabeledSlider, ) +from ._mapped import QLabeledMappedSlider from ._range_style import MONTEREY_SLIDER_STYLES_FIX from ._sliders import QDoubleRangeSlider, QDoubleSlider, QRangeSlider @@ -13,6 +14,7 @@ "QDoubleSlider", "QLabeledDoubleRangeSlider", "QLabeledDoubleSlider", + "QLabeledMappedSlider", "QLabeledRangeSlider", "QLabeledSlider", "QRangeSlider", diff --git a/src/superqt/sliders/_mapped.py b/src/superqt/sliders/_mapped.py new file mode 100644 index 00000000..0618a4b9 --- /dev/null +++ b/src/superqt/sliders/_mapped.py @@ -0,0 +1,159 @@ +from __future__ import annotations + +import math +from typing import TYPE_CHECKING, Any, Literal, overload + +from ._labeled import QLabeledDoubleSlider + +if TYPE_CHECKING: + from qtpy.QtCore import Qt + from qtpy.QtWidgets import QWidget + +# A string representing the slider position to value transfer function. +# Currently only "log" and "power" are supported. +Mapping = Literal["log", "power"] + + +def _check_mapping(mapping: str) -> None: + if mapping not in ("log", "power"): + raise ValueError(f"mapping must be 'log' or 'power', not {mapping!r}") + + +def _check_exponent(exponent: float) -> None: + if exponent <= 0: + raise ValueError("exponent must be greater than 0") + + +class QLabeledMappedSlider(QLabeledDoubleSlider): + """A labeled float slider whose value follows a non-linear curve. + + The handle moves evenly along the slider, while the value it represents + follows ``mapping``. Below, ``p`` is the handle position, from 0 at the + left to 1 at the right. + + ``"log"``: ``value = minimum * (maximum / minimum) ** p`` + + Each decade gets the same room on the slider. With a range of 1 to 100, + the middle of the slider is 10. The minimum must be greater than 0. + + ``"power"``: ``value = minimum + (maximum - minimum) * p ** exponent`` + + With a range of 0 to 1000, the middle of the slider is 500 for + ``exponent=1`` (a normal linear slider) and 250 for ``exponent=2``. + Larger exponents give finer control near the minimum, which can be 0. + """ + + # Set here, not in __init__: the parent's __init__ already calls our methods, + # and missing attributes would be looked up on the inner slider instead. + _mapping: Mapping = "log" + _exponent: float = 3.0 + _mapped_minimum: float = 0.01 + _mapped_maximum: float = 1.0 + _mapped_value: float = 0.01 + + @overload + def __init__(self, parent: QWidget | None = ...) -> None: ... + + @overload + def __init__( + self, orientation: Qt.Orientation, parent: QWidget | None = ... + ) -> None: ... + + def __init__(self, *args: Any, **kwargs: Any) -> None: + super().__init__(*args, **kwargs) + self._slider.setRange(0, 1) + self._slider.setSingleStep(0.01) + self._slider.setPageStep(0.1) + self.setRange(0.01, 1.0) + + def mapping(self) -> Mapping: + return self._mapping + + def setMapping(self, mapping: Mapping) -> None: + _check_mapping(mapping) + if mapping == "log" and self._mapped_minimum <= 0: + raise ValueError("with 'log' mapping the minimum must be greater than 0") + self._mapping = mapping + self._moveHandleToValue() + + def exponent(self) -> float: + return self._exponent + + def setExponent(self, exponent: float) -> None: + _check_exponent(exponent) + self._exponent = float(exponent) + self._moveHandleToValue() + + def minimum(self) -> float: # type: ignore[override] + return self._mapped_minimum + + def maximum(self) -> float: # type: ignore[override] + return self._mapped_maximum + + def setMinimum(self, minimum: float) -> None: + self.setRange(minimum, max(minimum, self._mapped_maximum)) + + def setMaximum(self, maximum: float) -> None: + self.setRange(min(self._mapped_minimum, maximum), maximum) + + def setRange(self, minimum: float, maximum: float) -> None: + if self._mapping == "log" and minimum <= 0: + raise ValueError("with 'log' mapping the minimum must be greater than 0") + self._mapped_minimum = float(minimum) + self._mapped_maximum = float(max(minimum, maximum)) + self._label.setRange(self._mapped_minimum, self._mapped_maximum) + old_value = self._mapped_value + self._mapped_value = self._clamp(old_value) + self._moveHandleToValue() + self.rangeChanged.emit(self._mapped_minimum, self._mapped_maximum) + if self._mapped_value != old_value: + self.valueChanged.emit(self._mapped_value) + + def value(self) -> float: + return self._mapped_value + + def setValue(self, value: float) -> None: + value = self._clamp(value) + if value == self._mapped_value: + return + self._mapped_value = value + self._moveHandleToValue() + self.valueChanged.emit(value) + + def _clamp(self, value: float) -> float: + return min(max(float(value), self._mapped_minimum), self._mapped_maximum) + + def _toValue(self, position: float) -> float: + lo, hi = self._mapped_minimum, self._mapped_maximum + if self._mapping == "log": + return float(lo * (hi / lo) ** position) + return float(lo + (hi - lo) * position**self._exponent) + + def _toPosition(self, value: float) -> float: + lo, hi = self._mapped_minimum, self._mapped_maximum + if hi == lo: + return 0.0 + if self._mapping == "log": + return math.log(value / lo) / math.log(hi / lo) + return float(((value - lo) / (hi - lo)) ** (1 / self._exponent)) + + def _moveHandleToValue(self) -> None: + self._label.setValue(self._mapped_value) + # Move the handle without triggering _on_slider_value_changed, which + # would replace the exact value with one recomputed from the position. + blocked = self._slider.blockSignals(True) + self._slider.setValue(self._toPosition(self._mapped_value)) + self._slider.blockSignals(blocked) + self._slider.update() + + def _on_slider_value_changed(self, position: Any) -> None: + self._mapped_value = self._toValue(position) + self._label.setValue(self._mapped_value) + self.valueChanged.emit(self._mapped_value) + + def _on_slider_range_changed(self, min_: float, max_: float) -> None: + # The inner slider's range is always 0 to 1; setRange emits the real one. + pass + + def _setValue(self, value: float) -> None: + self.setValue(value) diff --git a/tests/zz_test_sliders/test_mapped_slider.py b/tests/zz_test_sliders/test_mapped_slider.py new file mode 100644 index 00000000..e03c2cd6 --- /dev/null +++ b/tests/zz_test_sliders/test_mapped_slider.py @@ -0,0 +1,87 @@ +import pytest +from qtpy.QtCore import Qt +from qtpy.QtWidgets import QWidget + +from superqt import QLabeledMappedSlider + + +def test_constructor_overloads(qtbot): + parent = QWidget() + qtbot.addWidget(parent) + assert QLabeledMappedSlider(parent).parent() is parent + slider = QLabeledMappedSlider(Qt.Orientation.Vertical, parent) + assert slider.orientation() == Qt.Orientation.Vertical + + +def test_log_mapping(qtbot): + slider = QLabeledMappedSlider() + qtbot.addWidget(slider) + slider.setRange(1, 100) + + slider._slider.setValue(0.5) + assert slider.value() == pytest.approx(10) + + slider.setValue(100) + slider.setValue(10) + assert slider._slider.value() == pytest.approx(0.5) + + +def test_power_mapping(qtbot): + slider = QLabeledMappedSlider() + qtbot.addWidget(slider) + slider.setMapping("power") + slider.setExponent(1) + slider.setRange(0, 1000) + + slider._slider.setValue(0.5) + assert slider.value() == pytest.approx(500) + + slider.setExponent(2) + slider._slider.setValue(0.5) + assert slider.value() == pytest.approx(250) + + slider.setValue(1000) + slider.setValue(250) + assert slider._slider.value() == pytest.approx(0.5) + + +def test_set_value_is_exact_and_clamped(qtbot): + slider = QLabeledMappedSlider() + qtbot.addWidget(slider) + slider.setMapping("power") + slider.setRange(0, 0.5) + values = [] + slider.valueChanged.connect(values.append) + + slider.setValue(0.005) + assert slider.value() == 0.005 + + slider.setValue(0.005) + assert values == [0.005] + + slider.setValue(2) + assert slider.value() == 0.5 + + +def test_typing_in_label(qtbot): + slider = QLabeledMappedSlider() + qtbot.addWidget(slider) + slider.setMapping("power") + slider.setRange(0, 1000) + + slider._label.setText("250") + slider._label.editingFinished.emit() + assert slider.value() == 250 + assert slider._slider.value() == pytest.approx(0.25 ** (1 / 3)) + + +def test_bad_input_is_refused(qtbot): + slider = QLabeledMappedSlider() + qtbot.addWidget(slider) + + with pytest.raises(ValueError): + slider.setRange(0, 1) + with pytest.raises(ValueError): + slider.setMapping("sin") + with pytest.raises(ValueError): + slider.setExponent(0)