Skip to content
Closed
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
1 change: 1 addition & 0 deletions docs/widgets/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand Down
24 changes: 24 additions & 0 deletions docs/widgets/qlabeledmappedslider.md
Original file line number Diff line number Diff line change
@@ -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') }}
34 changes: 34 additions & 0 deletions examples/mapped_slider.py
Original file line number Diff line number Diff line change
@@ -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()
2 changes: 2 additions & 0 deletions src/superqt/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@
QDoubleSlider,
QLabeledDoubleRangeSlider,
QLabeledDoubleSlider,
QLabeledMappedSlider,
QLabeledRangeSlider,
QLabeledSlider,
QRangeSlider,
Expand All @@ -43,6 +44,7 @@
"QIconifyIcon",
"QLabeledDoubleRangeSlider",
"QLabeledDoubleSlider",
"QLabeledMappedSlider",
"QLabeledRangeSlider",
"QLabeledSlider",
"QLargeIntSpinBox",
Expand Down
2 changes: 2 additions & 0 deletions src/superqt/sliders/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
QLabeledRangeSlider,
QLabeledSlider,
)
from ._mapped import QLabeledMappedSlider
from ._range_style import MONTEREY_SLIDER_STYLES_FIX
from ._sliders import QDoubleRangeSlider, QDoubleSlider, QRangeSlider

Expand All @@ -13,6 +14,7 @@
"QDoubleSlider",
"QLabeledDoubleRangeSlider",
"QLabeledDoubleSlider",
"QLabeledMappedSlider",
"QLabeledRangeSlider",
"QLabeledSlider",
"QRangeSlider",
Expand Down
159 changes: 159 additions & 0 deletions src/superqt/sliders/_mapped.py
Original file line number Diff line number Diff line change
@@ -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)
87 changes: 87 additions & 0 deletions tests/zz_test_sliders/test_mapped_slider.py
Original file line number Diff line number Diff line change
@@ -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)
Loading