From ba495a5e720c434f04caaf84f59dcec1755b6b7f Mon Sep 17 00:00:00 2001 From: Talley Lambert Date: Mon, 6 May 2024 17:43:53 -0400 Subject: [PATCH] fix: fix a number of issues with Labeled and Range Sliders, add LabelsOnHandle mode. (#242) * fix: remove processEvents * merge in fixes * remove comment * fix hint * fix napari * change pyqt6 * fix: fix range slider styles --- .github/workflows/test_and_deploy.yml | 4 +- src/superqt/sliders/_generic_range_slider.py | 29 ++++++-- src/superqt/sliders/_generic_slider.py | 8 ++- src/superqt/sliders/_labeled.py | 69 +++++++++++++------- src/superqt/sliders/_range_style.py | 18 ++--- src/superqt/utils/_ensure_thread.py | 4 +- 6 files changed, 91 insertions(+), 41 deletions(-) diff --git a/.github/workflows/test_and_deploy.yml b/.github/workflows/test_and_deploy.yml index 0c96618..af86339 100644 --- a/.github/workflows/test_and_deploy.yml +++ b/.github/workflows/test_and_deploy.yml @@ -32,7 +32,7 @@ jobs: exclude: # Abort (core dumped) on linux pyqt6, unknown reason - platform: ubuntu-latest - backend: "'PyQt6<6.6'" + backend: pyqt6 # lack of wheels for pyside2/py3.11 - python-version: "3.11" backend: pyside2 @@ -52,7 +52,7 @@ jobs: backend: "'pyside6!=6.6.2'" - python-version: "3.12" platform: macos-latest - backend: "'PyQt6<6.6'" + backend: pyqt6 # legacy Qt - python-version: 3.8 platform: ubuntu-latest diff --git a/src/superqt/sliders/_generic_range_slider.py b/src/superqt/sliders/_generic_range_slider.py index 883571b..f1e9f06 100644 --- a/src/superqt/sliders/_generic_range_slider.py +++ b/src/superqt/sliders/_generic_range_slider.py @@ -103,7 +103,7 @@ class _GenericRangeSlider(_GenericSlider): """Show the bar between the first and last handle.""" self.setBarVisible(True) - def applyMacStylePatch(self) -> str: + def applyMacStylePatch(self) -> None: """Apply a QSS patch to fix sliders on macos>=12 with QT < 6. see [FAQ](../faq.md#sliders-not-dragging-properly-on-macos-12) for more details. @@ -124,11 +124,27 @@ class _GenericRangeSlider(_GenericSlider): """ return tuple(float(i) for i in self._position) - def setSliderPosition(self, pos: Union[float, Sequence[float]], index=None) -> None: + def setSliderPosition( # type: ignore + self, + pos: Union[float, Sequence[float]], + index: Optional[int] = None, + *, + reversed: bool = False, + ) -> None: """Set current position of the handles with a sequence of integers. - If `pos` is a sequence, it must have the same length as `value()`. - If it is a scalar, index will be + Parameters + ---------- + pos : Union[float, Sequence[float]] + The new position of the slider handle(s). If a sequence, it must have the + same length as `value()`. If it is a scalar, index will be used to set the + position of the handle at that index. + index : int | None + The index of the handle to set the position of. If None, the "pressedIndex" + will be used. + reversed : bool + Order in which to set the positions. Can be useful when setting multiple + positions, to avoid intermediate overlapping values. """ if isinstance(pos, (list, tuple)): val_len = len(self.value()) @@ -139,6 +155,9 @@ class _GenericRangeSlider(_GenericSlider): else: pairs = [(self._pressedIndex if index is None else index, pos)] + if reversed: + pairs = pairs[::-1] + for idx, position in pairs: self._position[idx] = self._bound(position, idx) @@ -222,7 +241,7 @@ class _GenericRangeSlider(_GenericSlider): offset = self.maximum() - ref[-1] elif ref[0] + offset < self.minimum(): offset = self.minimum() - ref[0] - self.setSliderPosition([i + offset for i in ref]) + self.setSliderPosition([i + offset for i in ref], reversed=offset > 0) def _fixStyleOption(self, option): pass diff --git a/src/superqt/sliders/_generic_slider.py b/src/superqt/sliders/_generic_slider.py index ddd7817..03fb243 100644 --- a/src/superqt/sliders/_generic_slider.py +++ b/src/superqt/sliders/_generic_slider.py @@ -99,7 +99,7 @@ class _GenericSlider(QSlider): if USE_MAC_SLIDER_PATCH: self.applyMacStylePatch() - def applyMacStylePatch(self) -> str: + def applyMacStylePatch(self) -> None: """Apply a QSS patch to fix sliders on macos>=12 with QT < 6. see [FAQ](../faq.md#sliders-not-dragging-properly-on-macos-12) for more details. @@ -342,8 +342,12 @@ class _GenericSlider(QSlider): option.sliderValue = self._to_qinteger_space(self._value - self._minimum) def _to_qinteger_space(self, val, _max=None): + """Converts a value to the internal integer space.""" _max = _max or self.MAX_DISPLAY - return int(min(QOVERFLOW, val / (self._maximum - self._minimum) * _max)) + range_ = self._maximum - self._minimum + if range_ == 0: + return self._minimum + return int(min(QOVERFLOW, val / range_ * _max)) def _pick(self, pt: QPoint) -> int: return pt.x() if self.orientation() == Qt.Orientation.Horizontal else pt.y() diff --git a/src/superqt/sliders/_labeled.py b/src/superqt/sliders/_labeled.py index 5b379cc..b5cc656 100644 --- a/src/superqt/sliders/_labeled.py +++ b/src/superqt/sliders/_labeled.py @@ -5,11 +5,11 @@ from enum import IntEnum, IntFlag, auto from functools import partial from typing import Any, Iterable, overload -from qtpy.QtCore import QPoint, QSize, Qt, Signal +from qtpy import QtGui +from qtpy.QtCore import Property, QPoint, QSize, Qt, Signal from qtpy.QtGui import QFontMetrics, QValidator from qtpy.QtWidgets import ( QAbstractSlider, - QApplication, QBoxLayout, QDoubleSpinBox, QHBoxLayout, @@ -32,6 +32,7 @@ class LabelPosition(IntEnum): LabelsBelow = auto() LabelsRight = LabelsAbove LabelsLeft = LabelsBelow + LabelsOnHandle = auto() class EdgeLabelMode(IntFlag): @@ -43,10 +44,10 @@ class EdgeLabelMode(IntFlag): class _SliderProxy: _slider: QSlider - def value(self) -> int: + def value(self) -> Any: return self._slider.value() - def setValue(self, value: int) -> None: + def setValue(self, value: Any) -> None: self._slider.setValue(value) def sliderPosition(self) -> int: @@ -158,6 +159,9 @@ def _handle_overloaded_slider_sig( class QLabeledSlider(_SliderProxy, QAbstractSlider): editingFinished = Signal() + _ivalueChanged = Signal(int) + _isliderMoved = Signal(int) + _irangeChanged = Signal(int, int) _slider_class = QSlider _slider: QSlider @@ -257,8 +261,6 @@ class QLabeledSlider(_SliderProxy, QAbstractSlider): self.layout().setContentsMargins(0, 0, 0, 0) self._on_slider_range_changed(self.minimum(), self.maximum()) - QApplication.processEvents() - # putting this after labelMode methods for the sake of mypy EdgeLabelMode = EdgeLabelMode @@ -279,8 +281,9 @@ class QLabeledSlider(_SliderProxy, QAbstractSlider): self._slider.setValue(int(value)) def _rename_signals(self) -> None: - # for subclasses - pass + self.valueChanged = self._ivalueChanged + self.sliderMoved = self._isliderMoved + self.rangeChanged = self._irangeChanged class QLabeledDoubleSlider(QLabeledSlider): @@ -386,10 +389,10 @@ class QLabeledRangeSlider(_SliderProxy, QAbstractSlider): """Set where/whether labels are shown adjacent to slider handles.""" self._handle_label_position = opt for lbl in self._handle_labels: - if not opt: - lbl.hide() - else: - lbl.show() + lbl.setVisible(bool(opt)) + trans = opt == LabelPosition.LabelsOnHandle + # TODO: make double clickable to edit + lbl.setAttribute(Qt.WidgetAttribute.WA_TransparentForMouseEvents, trans) self.setOrientation(self.orientation()) def edgeLabelMode(self) -> EdgeLabelMode: @@ -415,7 +418,6 @@ class QLabeledRangeSlider(_SliderProxy, QAbstractSlider): elif opt == EdgeLabelMode.LabelIsRange: self._min_label.setValue(self._slider.minimum()) self._max_label.setValue(self._slider.maximum()) - QApplication.processEvents() self._reposition_labels() def setRange(self, min: int, max: int) -> None: @@ -434,6 +436,7 @@ class QLabeledRangeSlider(_SliderProxy, QAbstractSlider): """Set orientation, value will be 'horizontal' or 'vertical'.""" self._slider.setOrientation(orientation) inverted = self._slider.invertedAppearance() + marg = (0, 0, 0, 0) if orientation == Qt.Orientation.Vertical: layout: QBoxLayout = QVBoxLayout() layout.setSpacing(1) @@ -441,9 +444,7 @@ class QLabeledRangeSlider(_SliderProxy, QAbstractSlider): # TODO: set margins based on label width if self._handle_label_position == LabelPosition.LabelsLeft: marg = (30, 0, 0, 0) - elif self._handle_label_position == LabelPosition.NoLabel: - marg = (0, 0, 0, 0) - else: + elif self._handle_label_position == LabelPosition.LabelsRight: marg = (0, 0, 20, 0) layout.setAlignment(Qt.AlignmentFlag.AlignCenter) else: @@ -451,9 +452,7 @@ class QLabeledRangeSlider(_SliderProxy, QAbstractSlider): layout.setSpacing(7) if self._handle_label_position == LabelPosition.LabelsBelow: marg = (0, 0, 0, 25) - elif self._handle_label_position == LabelPosition.NoLabel: - marg = (0, 0, 0, 0) - else: + elif self._handle_label_position == LabelPosition.LabelsAbove: marg = (0, 25, 0, 0) self._add_labels(layout, inverted=inverted) @@ -465,14 +464,13 @@ class QLabeledRangeSlider(_SliderProxy, QAbstractSlider): self.setLayout(layout) layout.setContentsMargins(*marg) super().setOrientation(orientation) - QApplication.processEvents() self._reposition_labels() def setInvertedAppearance(self, a0: bool) -> None: self._slider.setInvertedAppearance(a0) self.setOrientation(self._slider.orientation()) - def resizeEvent(self, a0) -> None: + def resizeEvent(self, a0: Any) -> None: super().resizeEvent(a0) self._reposition_labels() @@ -480,6 +478,15 @@ class QLabeledRangeSlider(_SliderProxy, QAbstractSlider): LabelPosition = LabelPosition EdgeLabelMode = EdgeLabelMode + def _getBarColor(self) -> QtGui.QBrush: + return self._slider._style.brush(self._slider._styleOption) + + def _setBarColor(self, color: str) -> None: + self._slider._style.brush_active = color + + barColor = Property(QtGui.QBrush, _getBarColor, _setBarColor) + """The color of the bar between the first and last handle.""" + # ------------- private methods ---------------- def _rename_signals(self) -> None: self.valueChanged = self._valueChanged @@ -495,6 +502,7 @@ class QLabeledRangeSlider(_SliderProxy, QAbstractSlider): horizontal = self.orientation() == Qt.Orientation.Horizontal labels_above = self._handle_label_position == LabelPosition.LabelsAbove + labels_on_handle = self._handle_label_position == LabelPosition.LabelsOnHandle last_edge = None labels: Iterable[tuple[int, SliderLabel]] = enumerate(self._handle_labels) @@ -502,13 +510,18 @@ class QLabeledRangeSlider(_SliderProxy, QAbstractSlider): labels = reversed(list(labels)) for i, label in labels: rect = self._slider._handleRect(i) - dx = -label.width() / 2 + dx = (-label.width() / 2) + 2 dy = -label.height() / 2 - if labels_above: + if labels_above: # or on the right if horizontal: dy *= 3 else: dx *= -1 + elif labels_on_handle: + if horizontal: + dy += 0.5 + else: + dx += 0.5 else: if horizontal: dy *= -1 @@ -525,6 +538,7 @@ class QLabeledRangeSlider(_SliderProxy, QAbstractSlider): label.move(pos) last_edge = pos label.clearFocus() + label.raise_() label.show() self.update() @@ -612,6 +626,15 @@ class QLabeledDoubleRangeSlider(QLabeledRangeSlider): for lbl in self._handle_labels: lbl.setDecimals(prec) + def _getBarColor(self) -> QtGui.QBrush: + return self._slider._style.brush(self._slider._styleOption) + + def _setBarColor(self, color: str) -> None: + self._slider._style.brush_active = color + + barColor = Property(QtGui.QBrush, _getBarColor, _setBarColor) + """The color of the bar between the first and last handle.""" + class SliderLabel(QDoubleSpinBox): def __init__( diff --git a/src/superqt/sliders/_range_style.py b/src/superqt/sliders/_range_style.py index c33a59a..3d501f1 100644 --- a/src/superqt/sliders/_range_style.py +++ b/src/superqt/sliders/_range_style.py @@ -5,7 +5,6 @@ import re from dataclasses import dataclass, replace from typing import TYPE_CHECKING -from qtpy import QT_VERSION from qtpy.QtCore import Qt from qtpy.QtGui import ( QBrush, @@ -140,8 +139,9 @@ CATALINA_STYLE = replace( tick_offset=4, ) -if QT_VERSION and int(QT_VERSION.split(".")[0]) == 6: - CATALINA_STYLE = replace(CATALINA_STYLE, tick_offset=2) +# I can no longer reproduce the cases in which this was necessary +# if QT_VERSION and int(QT_VERSION.split(".")[0]) == 6: +# CATALINA_STYLE = replace(CATALINA_STYLE, tick_offset=2) BIG_SUR_STYLE = replace( CATALINA_STYLE, @@ -155,8 +155,9 @@ BIG_SUR_STYLE = replace( tick_bar_alpha=0.2, ) -if QT_VERSION and int(QT_VERSION.split(".")[0]) == 6: - BIG_SUR_STYLE = replace(BIG_SUR_STYLE, tick_offset=-3) +# I can no longer reproduce the cases in which this was necessary +# if QT_VERSION and int(QT_VERSION.split(".")[0]) == 6: +# BIG_SUR_STYLE = replace(BIG_SUR_STYLE, tick_offset=-3) WINDOWS_STYLE = replace( BASE_STYLE, @@ -229,7 +230,7 @@ rgba_pattern = re.compile( ) -def parse_color(color: str, default_attr) -> QColor | QGradient: +def parse_color(color: str, default_attr: str) -> QColor | QGradient: qc = QColor(color) if qc.isValid(): return qc @@ -241,6 +242,7 @@ def parse_color(color: str, default_attr) -> QColor | QGradient: # try linear gradient: match = qlineargrad_pattern.search(color) + grad: QGradient if match: grad = QLinearGradient(*(float(i) for i in match.groups()[:4])) grad.setColorAt(0, QColor(match.groupdict()["stop0"])) @@ -259,11 +261,11 @@ def parse_color(color: str, default_attr) -> QColor | QGradient: return QColor(getattr(SYSTEM_STYLE, default_attr)) -def update_styles_from_stylesheet(obj: _GenericRangeSlider): +def update_styles_from_stylesheet(obj: _GenericRangeSlider) -> None: qss: str = obj.styleSheet() parent = obj.parent() - while parent is not None: + while parent and hasattr(parent, "styleSheet"): qss = parent.styleSheet() + qss parent = parent.parent() qss = QApplication.instance().styleSheet() + qss diff --git a/src/superqt/utils/_ensure_thread.py b/src/superqt/utils/_ensure_thread.py index d98bd5d..602c877 100644 --- a/src/superqt/utils/_ensure_thread.py +++ b/src/superqt/utils/_ensure_thread.py @@ -2,6 +2,7 @@ from __future__ import annotations from concurrent.futures import Future +from contextlib import suppress from functools import wraps from typing import TYPE_CHECKING, Any, Callable, ClassVar, overload @@ -41,7 +42,8 @@ class CallCallable(QObject): def call(self): CallCallable.instances.remove(self) res = self._callable(*self._args, **self._kwargs) - self.finished.emit(res) + with suppress(RuntimeError): + self.finished.emit(res) # fmt: off