diff --git a/examples/labeled_sliders.py b/examples/labeled_sliders.py index d92e0ae3..ad1129ec 100644 --- a/examples/labeled_sliders.py +++ b/examples/labeled_sliders.py @@ -7,6 +7,7 @@ QLabeledRangeSlider, QLabeledSlider, ) +from superqt.sliders._labeled import QLabeledCategoricalSlider app = QApplication([]) @@ -36,6 +37,8 @@ qldrs.setSingleStep(0.01) qldrs.setValue((0.2, 0.7)) +qlcs = QLabeledCategoricalSlider() +qlcs.setCategories(["dog", "cat", "elephant", "bird", "fish"]) w.setLayout( QVBoxLayout() if ORIENTATION == Qt.Orientation.Horizontal else QHBoxLayout() @@ -44,6 +47,7 @@ w.layout().addWidget(qlds) w.layout().addWidget(qlrs) w.layout().addWidget(qldrs) +w.layout().addWidget(qlcs) w.show() w.resize(500, 150) app.exec_() diff --git a/src/superqt/sliders/__init__.py b/src/superqt/sliders/__init__.py index e0e89c5a..341a07c6 100644 --- a/src/superqt/sliders/__init__.py +++ b/src/superqt/sliders/__init__.py @@ -1,4 +1,5 @@ from ._labeled import ( + QLabeledCategoricalSlider, QLabeledDoubleRangeSlider, QLabeledDoubleSlider, QLabeledRangeSlider, @@ -10,6 +11,7 @@ __all__ = [ "QDoubleRangeSlider", "QDoubleSlider", + "QLabeledCategoricalSlider", "QLabeledDoubleRangeSlider", "QLabeledDoubleSlider", "QLabeledRangeSlider", diff --git a/src/superqt/sliders/_categorical_slider.py b/src/superqt/sliders/_categorical_slider.py new file mode 100644 index 00000000..4f7c33bd --- /dev/null +++ b/src/superqt/sliders/_categorical_slider.py @@ -0,0 +1,79 @@ +from typing import Generic, Iterable, Sequence, TypeVar, overload + +from qtpy.QtCore import Qt, Signal +from qtpy.QtWidgets import QSlider, QWidget + +T = TypeVar("T") + + +class QCategoricalSlider(QSlider, Generic[T]): + """A Slider that can only take on a finite number of values.""" + + categoryChanged = Signal(object) + categoriesChanged = Signal(tuple) + + @overload + def __init__( + self, + parent: QWidget | None = ..., + /, + *, + categories: Iterable[T] = ..., + ) -> None: ... + @overload + def __init__( + self, + orientation: Qt.Orientation, + parent: QWidget | None = ..., + /, + *, + categories: Iterable[T] = ..., + ) -> None: ... + + def __init__( + self, *args: Qt.Orientation | QWidget | None, categories: Iterable[T] = () + ) -> None: + # default to horizontal orientation + if len(args) == 0: + args = (Qt.Orientation.Horizontal, None) + elif len(args) == 1: + args = (Qt.Orientation.Horizontal, args[0]) + super().__init__(*args) # type: ignore [arg-type] + + self._categories: Sequence[T] = () + self.setCategories(categories) + self.setTickPosition(QSlider.TickPosition.TicksAbove) + self.setTickInterval(1) + self.valueChanged.connect(self._on_value_changed) + + def categories(self) -> Sequence[T]: + """Return the categories of the slider.""" + return self._categories + + def setCategories(self, categories: Iterable[T]) -> None: + """Set the categories of the slider.""" + self._categories = tuple(categories) + self.setRange(0, len(self._categories) - 1) + self.categoriesChanged.emit(self._categories) + + def category(self) -> T: + """Return the current categorical value of the slider.""" + try: + return self._categories[super().value()] + except IndexError: + return None + + def setCategory(self, value: T) -> None: + """Set the current categorical value of the slider.""" + try: + # we could consider indexing this up-front during setCategories + # to save .index() calls here + idx = self._categories.index(value) + except ValueError: + # the behavior of the original QSlider is to (quietly) pin to the nearest + # value when the value is out of range. Here we do nothing. + return None + super().setValue(idx) + + def _on_value_changed(self, value: int) -> None: + self.categoryChanged.emit(self._categories[value]) diff --git a/src/superqt/sliders/_labeled.py b/src/superqt/sliders/_labeled.py index cff7279e..e82b9f61 100644 --- a/src/superqt/sliders/_labeled.py +++ b/src/superqt/sliders/_labeled.py @@ -11,6 +11,7 @@ from qtpy.QtWidgets import ( QAbstractSlider, QBoxLayout, + QComboBox, QDoubleSpinBox, QHBoxLayout, QSlider, @@ -23,6 +24,7 @@ from superqt.utils import signals_blocked +from ._categorical_slider import QCategoricalSlider from ._sliders import QDoubleRangeSlider, QDoubleSlider, QRangeSlider @@ -129,6 +131,143 @@ def __getattr__(self, name: Any) -> Any: return getattr(self._slider, name) +class TextSliderLabel(QComboBox): + editingFinished = Signal() + + def __init__( + self, + slider: QSlider, + parent=None, + alignment=Qt.AlignmentFlag.AlignLeft, + connect=None, + ) -> None: + super().__init__(parent=parent) + self._slider = slider + if connect is not None: + self.currentIndexChanged.connect(lambda: connect(self.currentIndex())) + + def setValue(self, val: str) -> None: + self.setCurrentText(str(val)) + + def setAlignment(self, alignment: Qt.AlignmentFlag) -> None: + # self.lineEdit().setAlignment(alignment) + ... + + +class SliderLabel(QDoubleSpinBox): + def __init__( + self, + slider: QSlider, + parent=None, + alignment=Qt.AlignmentFlag.AlignCenter, + connect=None, + ) -> None: + super().__init__(parent=parent) + self._slider = slider + self.setFocusPolicy(Qt.FocusPolicy.ClickFocus) + self.setMode(EdgeLabelMode.LabelIsValue) + self.setDecimals(0) + + self.setRange(slider.minimum(), slider.maximum()) + slider.rangeChanged.connect(self._update_size) + self.setAlignment(alignment) + self.setButtonSymbols(QSpinBox.ButtonSymbols.NoButtons) + + le = self.lineEdit() + # make the lineedit transparent + palette = le.palette() + palette.setColor(palette.ColorRole.Base, Qt.GlobalColor.transparent) + le.setPalette(palette) + le.setFrame(False) # no border + + if connect is not None: + self.editingFinished.connect(lambda: connect(self.value())) + self.editingFinished.connect(self._silent_clear_focus) + self._update_size() + + def setDecimals(self, prec: int) -> None: + super().setDecimals(prec) + self._update_size() + + def setValue(self, val: Any) -> None: + super().setValue(val) + if self._mode == EdgeLabelMode.LabelIsRange: + self._update_size() + + def setMaximum(self, max: float) -> None: + super().setMaximum(max) + if self._mode == EdgeLabelMode.LabelIsValue: + self._update_size() + + def setMinimum(self, min: float) -> None: + super().setMinimum(min) + if self._mode == EdgeLabelMode.LabelIsValue: + self._update_size() + + def setMode(self, opt: EdgeLabelMode) -> None: + # when the edge labels are controlling slider range, + # we want them to have a big range, but not have a huge label + self._mode = opt + if opt == EdgeLabelMode.LabelIsRange: + self.setMinimum(-9999999) + self.setMaximum(9999999) + with contextlib.suppress(Exception): + self._slider.rangeChanged.disconnect(self.setRange) + else: + self.setMinimum(self._slider.minimum()) + self.setMaximum(self._slider.maximum()) + self._slider.rangeChanged.connect(self.setRange) + self._update_size() + + # --------------- private ---------------- + + def _silent_clear_focus(self) -> None: + with signals_blocked(self): + self.clearFocus() + + def _update_size(self, *_: Any) -> None: + # fontmetrics to measure the width of text + fm = QFontMetrics(self.font()) + h = self.sizeHint().height() + fixed_content = self.prefix() + self.suffix() + " " + + if self._mode & EdgeLabelMode.LabelIsValue: + # determine width based on min/max/specialValue + mintext = self.textFromValue(self.minimum())[:18] + maxtext = self.textFromValue(self.maximum())[:18] + w = max(0, _fm_width(fm, mintext + fixed_content)) + w = max(w, _fm_width(fm, maxtext + fixed_content)) + if self.specialValueText(): + w = max(w, _fm_width(fm, self.specialValueText())) + if self._mode & EdgeLabelMode.LabelIsRange: + w += 8 # it seems as thought suffix() is not enough + else: + w = max(0, _fm_width(fm, self.textFromValue(self.value()))) + 3 + + w += 3 # cursor blinking space + # get the final size hint + opt = QStyleOptionSpinBox() + self.initStyleOption(opt) + size = self.style().sizeFromContents( + QStyle.ContentsType.CT_SpinBox, opt, QSize(w, h), self + ) + self.setFixedSize(size) + + def validate( + self, input_: str | None, pos: int + ) -> tuple[QValidator.State, str, int]: + # fake like an integer spinbox + if input_ and "." in input_ and self.decimals() < 1: + return QValidator.State.Invalid, input_, len(input_) + return super().validate(input_, pos) + + +def _fm_width(fm: QFontMetrics, text: str) -> int: + if hasattr(fm, "horizontalAdvance"): + return fm.horizontalAdvance(text) + return fm.width(text) + + def _handle_overloaded_slider_sig( args: tuple, kwargs: dict ) -> tuple[QWidget | None, Qt.Orientation]: @@ -163,8 +302,9 @@ class QLabeledSlider(_SliderProxy, QAbstractSlider): _isliderMoved = Signal(int) _irangeChanged = Signal(int, int) - _slider_class = QSlider + _slider_class: type[QSlider] = QSlider _slider: QSlider + _label_class: type = SliderLabel @overload def __init__(self, parent: QWidget | None = ...) -> None: ... @@ -182,8 +322,10 @@ def __init__(self, *args: Any, **kwargs: Any) -> None: fp = self.style().styleHint(QStyle.StyleHint.SH_Button_FocusPolicy) self.setFocusPolicy(Qt.FocusPolicy(fp)) - self._slider = self._slider_class(parent=self) - self._label = SliderLabel(self._slider, connect=self._setValue, parent=self) + self._slider = self._slider_class(self) + self._label = self._label_class( + self._slider, connect=self._setValue, parent=self + ) self._edge_label_mode: EdgeLabelMode = EdgeLabelMode.LabelIsValue self._rename_signals() @@ -215,8 +357,8 @@ def setOrientation(self, orientation: Qt.Orientation) -> None: layout = QHBoxLayout() # type: ignore layout.addWidget(self._slider) - layout.addWidget(self._label) - self._label.setAlignment(Qt.AlignmentFlag.AlignRight) + layout.addWidget(self._label, alignment=Qt.AlignmentFlag.AlignVCenter) + self._label.setAlignment(Qt.AlignmentFlag.AlignHCenter) layout.setSpacing(6) old_layout = self.layout() @@ -286,6 +428,35 @@ def _rename_signals(self) -> None: self.rangeChanged = self._irangeChanged +class QLabeledCategoricalSlider(QLabeledSlider): + _slider_class = QCategoricalSlider + _slider: QCategoricalSlider + _label_class: type = TextSliderLabel + + categoryChanged = Signal(object) + + def __init__( + self, *args: Qt.Orientation | QWidget | None, categories: Iterable = () + ) -> None: + super().__init__(*args) + self._slider.categoryChanged.connect(self.categoryChanged) + if categories: + self._slider.setCategories(categories) + self._label.setValue(self._slider.category()) + self._slider.categoriesChanged.connect(self._on_categories_changed) + self._on_categories_changed(self._slider.categories()) + + def _on_slider_value_changed(self, v: Any) -> None: + self._label.setCurrentIndex(v) + self.categoryChanged.emit(self._slider.category()) + self.valueChanged.emit(v) + + def _on_categories_changed(self, categories: Iterable) -> None: + self._label.clear() + self._label.addItems([str(c) for c in categories]) + self._label.setValue(str(self._slider.category())) + + class QLabeledDoubleSlider(QLabeledSlider): _slider_class = QDoubleSlider _slider: QDoubleSlider @@ -634,117 +805,3 @@ def _setBarColor(self, color: str) -> None: barColor = Property(QtGui.QBrush, _getBarColor, _setBarColor) """The color of the bar between the first and last handle.""" - - -class SliderLabel(QDoubleSpinBox): - def __init__( - self, - slider: QSlider, - parent=None, - alignment=Qt.AlignmentFlag.AlignCenter, - connect=None, - ) -> None: - super().__init__(parent=parent) - self._slider = slider - self.setFocusPolicy(Qt.FocusPolicy.ClickFocus) - self.setMode(EdgeLabelMode.LabelIsValue) - self.setDecimals(0) - - self.setRange(slider.minimum(), slider.maximum()) - slider.rangeChanged.connect(self._update_size) - self.setAlignment(alignment) - self.setButtonSymbols(QSpinBox.ButtonSymbols.NoButtons) - - le = self.lineEdit() - # make the lineedit transparent - palette = le.palette() - palette.setColor(palette.ColorRole.Base, Qt.GlobalColor.transparent) - le.setPalette(palette) - le.setFrame(False) # no border - - if connect is not None: - self.editingFinished.connect(lambda: connect(self.value())) - self.editingFinished.connect(self._silent_clear_focus) - self._update_size() - - def setDecimals(self, prec: int) -> None: - super().setDecimals(prec) - self._update_size() - - def setValue(self, val: Any) -> None: - super().setValue(val) - if self._mode == EdgeLabelMode.LabelIsRange: - self._update_size() - - def setMaximum(self, max: float) -> None: - super().setMaximum(max) - if self._mode == EdgeLabelMode.LabelIsValue: - self._update_size() - - def setMinimum(self, min: float) -> None: - super().setMinimum(min) - if self._mode == EdgeLabelMode.LabelIsValue: - self._update_size() - - def setMode(self, opt: EdgeLabelMode) -> None: - # when the edge labels are controlling slider range, - # we want them to have a big range, but not have a huge label - self._mode = opt - if opt == EdgeLabelMode.LabelIsRange: - self.setMinimum(-9999999) - self.setMaximum(9999999) - with contextlib.suppress(Exception): - self._slider.rangeChanged.disconnect(self.setRange) - else: - self.setMinimum(self._slider.minimum()) - self.setMaximum(self._slider.maximum()) - self._slider.rangeChanged.connect(self.setRange) - self._update_size() - - # --------------- private ---------------- - - def _silent_clear_focus(self) -> None: - with signals_blocked(self): - self.clearFocus() - - def _update_size(self, *_: Any) -> None: - # fontmetrics to measure the width of text - fm = QFontMetrics(self.font()) - h = self.sizeHint().height() - fixed_content = self.prefix() + self.suffix() + " " - - if self._mode & EdgeLabelMode.LabelIsValue: - # determine width based on min/max/specialValue - mintext = self.textFromValue(self.minimum())[:18] - maxtext = self.textFromValue(self.maximum())[:18] - w = max(0, _fm_width(fm, mintext + fixed_content)) - w = max(w, _fm_width(fm, maxtext + fixed_content)) - if self.specialValueText(): - w = max(w, _fm_width(fm, self.specialValueText())) - if self._mode & EdgeLabelMode.LabelIsRange: - w += 8 # it seems as thought suffix() is not enough - else: - w = max(0, _fm_width(fm, self.textFromValue(self.value()))) + 3 - - w += 3 # cursor blinking space - # get the final size hint - opt = QStyleOptionSpinBox() - self.initStyleOption(opt) - size = self.style().sizeFromContents( - QStyle.ContentsType.CT_SpinBox, opt, QSize(w, h), self - ) - self.setFixedSize(size) - - def validate( - self, input_: str | None, pos: int - ) -> tuple[QValidator.State, str, int]: - # fake like an integer spinbox - if input_ and "." in input_ and self.decimals() < 1: - return QValidator.State.Invalid, input_, len(input_) - return super().validate(input_, pos) - - -def _fm_width(fm: QFontMetrics, text: str) -> int: - if hasattr(fm, "horizontalAdvance"): - return fm.horizontalAdvance(text) - return fm.width(text)