mirror of
https://github.com/pyapp-kit/superqt.git
synced 2026-01-04 19:31:24 +01:00
Labeled sliders (#3)
* good labels * more options * add to init * reemit value changed * remove pass * refine positioning * update example * add docs
This commit is contained in:
75
README.md
75
README.md
@@ -107,13 +107,13 @@ sliderMoved(Tuple[int, ...])
|
||||
These options are in addition to the Qt QSlider API, and control the behavior of the bar between handles.
|
||||
|
||||
| getter | setter | type | default | description |
|
||||
| ------------- | ------------- |--------- | -------- | --------- |
|
||||
| -------------------- | ------------------------------------------- | ------ | ------- | ------------------------------------------------------------------------------------------------ |
|
||||
| `barIsVisible` | `setBarIsVisible` <br>`hideBar` / `showBar` | `bool` | `True` | <small>Whether the bar between handles is visible.</small> |
|
||||
| `barMovesAllHandles` | `setBarMovesAllHandles` | `bool` | `True` | <small>Whether clicking on the bar moves all handles or just the nearest</small> |
|
||||
| `barIsRigid` | `setBarIsRigid` | `bool` | `True` | <small>Whether bar length is constant or "elastic" when dragging the bar beyond min/max.</small> |
|
||||
------
|
||||
|
||||
## Example
|
||||
## Examples
|
||||
|
||||
These screenshots show `QRangeSlider` (multiple handles) next to the native `QSlider`
|
||||
(single handle). With no styles applied, `QRangeSlider` will match the native OS
|
||||
@@ -178,18 +178,81 @@ QRangeSlider {
|
||||
### macOS
|
||||
|
||||
##### Catalina
|
||||

|
||||

|
||||
|
||||
##### Big Sur
|
||||

|
||||

|
||||
|
||||
### Windows
|
||||
|
||||

|
||||

|
||||
|
||||
### Linux
|
||||
|
||||

|
||||

|
||||
|
||||
|
||||
## Labeled Sliders
|
||||
|
||||
This package also includes two "labeled" slider variants. One for `QRangeSlider`, and one for the native `QSlider`:
|
||||
|
||||
### `QLabeledRangeSlider`
|
||||
|
||||

|
||||
|
||||
```python
|
||||
from qtrangeslider import QLabeledRangeSlider
|
||||
```
|
||||
|
||||
This has the same API as `QRangeSlider` with the following additional options:
|
||||
|
||||
#### `handleLabelPosition`/`setHandleLabelPosition`
|
||||
|
||||
Where/whether labels are shown adjacent to slider handles.
|
||||
|
||||
**type:** `QLabeledRangeSlider.LabelPosition`
|
||||
|
||||
**default:** `LabelPosition.LabelsAbove`
|
||||
|
||||
*options:*
|
||||
|
||||
- `LabelPosition.NoLabel` (no labels shown adjacent to handles)
|
||||
- `LabelPosition.LabelsAbove`
|
||||
- `LabelPosition.LabelsBelow`
|
||||
- `LabelPosition.LabelsRight` (alias for `LabelPosition.LabelsAbove`)
|
||||
- `LabelPosition.LabelsLeft` (alias for `LabelPosition.LabelsBelow`)
|
||||
|
||||
|
||||
#### `edgeLabelMode`/`setEdgeLabelMode`
|
||||
|
||||
**type:** `QLabeledRangeSlider.EdgeLabelMode`
|
||||
|
||||
**default:** `EdgeLabelMode.LabelsAbove`
|
||||
|
||||
*options:*
|
||||
|
||||
- `EdgeLabelMode.NoLabel`: no labels shown at slider extremes
|
||||
- `EdgeLabelMode.LabelIsRange`: edge labels shown the min/max values
|
||||
- `EdgeLabelMode.LabelIsValue`: edge labels shown the slider range
|
||||
|
||||
|
||||
#### fine tuning position of labels:
|
||||
|
||||
If you find that you need to fine tune the position of the handle labels:
|
||||
|
||||
- `QLabeledRangeSlider.label_shift_x`: adjust horizontal label position
|
||||
- `QLabeledRangeSlider.label_shift_y`: adjust vertical label position
|
||||
|
||||
### `QLabeledSlider`
|
||||
|
||||
|
||||

|
||||
|
||||
```python
|
||||
from qtrangeslider import QLabeledSlider
|
||||
```
|
||||
|
||||
(no additional options at this point)
|
||||
|
||||
## Issues
|
||||
|
||||
|
||||
17
examples/labeled.py
Normal file
17
examples/labeled.py
Normal file
@@ -0,0 +1,17 @@
|
||||
from qtrangeslider._labeled import QLabeledRangeSlider, QLabeledSlider
|
||||
from qtrangeslider.qtcompat.QtCore import Qt
|
||||
from qtrangeslider.qtcompat.QtWidgets import QApplication, QVBoxLayout, QWidget
|
||||
|
||||
app = QApplication([])
|
||||
|
||||
w = QWidget()
|
||||
sld = QLabeledRangeSlider()
|
||||
|
||||
sld.setRange(0, 500)
|
||||
sld.setValue((100, 400))
|
||||
w.setLayout(QVBoxLayout())
|
||||
w.layout().addWidget(sld)
|
||||
w.layout().addWidget(QLabeledSlider(Qt.Horizontal))
|
||||
w.show()
|
||||
w.resize(500, 150)
|
||||
app.exec_()
|
||||
BIN
images/labeled_qslider.png
Normal file
BIN
images/labeled_qslider.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 9.0 KiB |
BIN
images/labeled_range.png
Normal file
BIN
images/labeled_range.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 12 KiB |
@@ -3,6 +3,7 @@ try:
|
||||
except ImportError:
|
||||
__version__ = "unknown"
|
||||
|
||||
from ._labeled import QLabeledRangeSlider, QLabeledSlider
|
||||
from ._qrangeslider import QRangeSlider
|
||||
|
||||
__all__ = ["QRangeSlider"]
|
||||
__all__ = ["QRangeSlider", "QLabeledRangeSlider", "QLabeledSlider"]
|
||||
|
||||
362
qtrangeslider/_labeled.py
Normal file
362
qtrangeslider/_labeled.py
Normal file
@@ -0,0 +1,362 @@
|
||||
from enum import IntEnum
|
||||
from functools import partial
|
||||
|
||||
from ._qrangeslider import QRangeSlider
|
||||
from .qtcompat.QtCore import QPoint, QSize, Qt, Signal
|
||||
from .qtcompat.QtGui import QFontMetrics
|
||||
from .qtcompat.QtWidgets import (
|
||||
QAbstractSlider,
|
||||
QApplication,
|
||||
QHBoxLayout,
|
||||
QSlider,
|
||||
QSpinBox,
|
||||
QStyle,
|
||||
QStyleOptionSpinBox,
|
||||
QVBoxLayout,
|
||||
QWidget,
|
||||
)
|
||||
|
||||
|
||||
class LabelPosition(IntEnum):
|
||||
NoLabel = 0
|
||||
LabelsAbove = 1
|
||||
LabelsBelow = 2
|
||||
LabelsRight = 1
|
||||
LabelsLeft = 2
|
||||
|
||||
|
||||
class EdgeLabelMode(IntEnum):
|
||||
NoLabel = 0
|
||||
LabelIsRange = 1
|
||||
LabelIsValue = 2
|
||||
|
||||
|
||||
class QLabeledSlider(QAbstractSlider):
|
||||
def __init__(self, *args) -> None:
|
||||
parent = None
|
||||
orientation = Qt.Horizontal
|
||||
if len(args) == 2:
|
||||
orientation, parent = args
|
||||
elif args:
|
||||
if isinstance(args[0], QWidget):
|
||||
parent = args[0]
|
||||
else:
|
||||
orientation = args[0]
|
||||
|
||||
super().__init__(parent)
|
||||
|
||||
self._slider = QSlider()
|
||||
self._slider.valueChanged.connect(self.valueChanged.emit)
|
||||
self._label = SliderLabel(self._slider, connect=self.setValue)
|
||||
|
||||
self.valueChanged.connect(self._label.setValue)
|
||||
self.valueChanged.connect(self._slider.setValue)
|
||||
self.rangeChanged.connect(self._slider.setRange)
|
||||
|
||||
self._slider.valueChanged.connect(self.setValue)
|
||||
self.setOrientation(orientation)
|
||||
|
||||
def setOrientation(self, orientation):
|
||||
"""Set orientation, value will be 'horizontal' or 'vertical'."""
|
||||
self._slider.setOrientation(orientation)
|
||||
if orientation == Qt.Vertical:
|
||||
layout = QVBoxLayout()
|
||||
layout.addWidget(self._slider, alignment=Qt.AlignHCenter)
|
||||
layout.addWidget(self._label, alignment=Qt.AlignHCenter)
|
||||
self._label.setAlignment(Qt.AlignCenter)
|
||||
layout.setSpacing(1)
|
||||
else:
|
||||
layout = QHBoxLayout()
|
||||
layout.addWidget(self._slider)
|
||||
layout.addWidget(self._label)
|
||||
self._label.setAlignment(Qt.AlignRight)
|
||||
layout.setSpacing(10)
|
||||
|
||||
old_layout = self.layout()
|
||||
if old_layout is not None:
|
||||
QWidget().setLayout(old_layout)
|
||||
|
||||
layout.setContentsMargins(0, 0, 0, 0)
|
||||
self.setLayout(layout)
|
||||
|
||||
|
||||
class QLabeledRangeSlider(QAbstractSlider):
|
||||
valueChanged = Signal(tuple)
|
||||
LabelPosition = LabelPosition
|
||||
EdgeLabelMode = EdgeLabelMode
|
||||
|
||||
def __init__(self, *args) -> None:
|
||||
parent = None
|
||||
orientation = Qt.Horizontal
|
||||
if len(args) == 2:
|
||||
orientation, parent = args
|
||||
elif args:
|
||||
if isinstance(args[0], QWidget):
|
||||
parent = args[0]
|
||||
else:
|
||||
orientation = args[0]
|
||||
|
||||
super().__init__(parent)
|
||||
self.setAttribute(Qt.WA_ShowWithoutActivating)
|
||||
self._handle_labels = []
|
||||
self._handle_label_position: LabelPosition = LabelPosition.LabelsAbove
|
||||
|
||||
# for fine tuning label position
|
||||
self.label_shift_x = 0
|
||||
self.label_shift_y = 0
|
||||
|
||||
self._slider = QRangeSlider()
|
||||
self._slider.valueChanged.connect(self.valueChanged.emit)
|
||||
|
||||
self._min_label = SliderLabel(
|
||||
self._slider, alignment=Qt.AlignLeft, connect=self._min_label_edited
|
||||
)
|
||||
self._max_label = SliderLabel(
|
||||
self._slider, alignment=Qt.AlignRight, connect=self._max_label_edited
|
||||
)
|
||||
self.setEdgeLabelMode(EdgeLabelMode.LabelIsRange)
|
||||
|
||||
self._slider.valueChanged.connect(self._on_value_changed)
|
||||
self.rangeChanged.connect(self._on_range_changed)
|
||||
|
||||
self._on_value_changed(self._slider.value())
|
||||
self._on_range_changed(self._slider.minimum(), self._slider.maximum())
|
||||
self.setOrientation(orientation)
|
||||
|
||||
def handleLabelPosition(self) -> LabelPosition:
|
||||
return self._handle_label_position
|
||||
|
||||
def setHandleLabelPosition(self, opt: LabelPosition) -> LabelPosition:
|
||||
self._handle_label_position = opt
|
||||
for lbl in self._handle_labels:
|
||||
if not opt:
|
||||
lbl.hide()
|
||||
else:
|
||||
lbl.show()
|
||||
self.setOrientation(self.orientation())
|
||||
|
||||
def edgeLabelMode(self) -> EdgeLabelMode:
|
||||
return self._edge_label_mode
|
||||
|
||||
def setEdgeLabelMode(self, opt: EdgeLabelMode):
|
||||
self._edge_label_mode = opt
|
||||
if not self._edge_label_mode:
|
||||
self._min_label.hide()
|
||||
self._max_label.hide()
|
||||
else:
|
||||
if self.isVisible():
|
||||
self._min_label.show()
|
||||
self._max_label.show()
|
||||
self._min_label.setMode(opt)
|
||||
self._max_label.setMode(opt)
|
||||
if opt == EdgeLabelMode.LabelIsValue:
|
||||
v0, *_, v1 = self._slider.value()
|
||||
self._min_label.setValue(v0)
|
||||
self._max_label.setValue(v1)
|
||||
elif opt == EdgeLabelMode.LabelIsRange:
|
||||
self._min_label.setValue(self._slider.minimum())
|
||||
self._max_label.setValue(self._slider.maximum())
|
||||
QApplication.processEvents()
|
||||
self._reposition_labels()
|
||||
|
||||
def _reposition_labels(self):
|
||||
if not self._handle_labels:
|
||||
return
|
||||
|
||||
horizontal = self.orientation() == Qt.Horizontal
|
||||
labels_above = self._handle_label_position == LabelPosition.LabelsAbove
|
||||
|
||||
for label, rect in zip(self._handle_labels, self._slider._handleRects()):
|
||||
dx = -label.width() / 2
|
||||
dy = -label.height() / 2
|
||||
if labels_above:
|
||||
if horizontal:
|
||||
dy *= 3
|
||||
else:
|
||||
dx *= -1
|
||||
else:
|
||||
if horizontal:
|
||||
dy *= -1
|
||||
else:
|
||||
dx *= 3
|
||||
pos = self._slider.mapToParent(rect.center())
|
||||
pos += QPoint(dx + self.label_shift_x, dy + self.label_shift_y)
|
||||
label.move(pos)
|
||||
label.clearFocus()
|
||||
|
||||
def _min_label_edited(self, val):
|
||||
if self._edge_label_mode == EdgeLabelMode.LabelIsRange:
|
||||
self.setMinimum(val)
|
||||
else:
|
||||
v = list(self._slider.value())
|
||||
v[0] = val
|
||||
self.setValue(v)
|
||||
self._reposition_labels()
|
||||
|
||||
def _max_label_edited(self, val):
|
||||
if self._edge_label_mode == EdgeLabelMode.LabelIsRange:
|
||||
self.setMaximum(val)
|
||||
else:
|
||||
v = list(self._slider.value())
|
||||
v[-1] = val
|
||||
self.setValue(v)
|
||||
self._reposition_labels()
|
||||
|
||||
def _on_value_changed(self, v):
|
||||
if self._edge_label_mode == EdgeLabelMode.LabelIsValue:
|
||||
self._min_label.setValue(v[0])
|
||||
self._max_label.setValue(v[-1])
|
||||
|
||||
if len(v) != len(self._handle_labels):
|
||||
for lbl in self._handle_labels:
|
||||
lbl.setParent(None)
|
||||
lbl.deleteLater()
|
||||
self._handle_labels.clear()
|
||||
for n, val in enumerate(self._slider.value()):
|
||||
_cb = partial(self._slider._setSliderPositionAt, n)
|
||||
s = SliderLabel(self._slider, parent=self, connect=_cb)
|
||||
s.setValue(val)
|
||||
self._handle_labels.append(s)
|
||||
else:
|
||||
for val, label in zip(v, self._handle_labels):
|
||||
label.setValue(val)
|
||||
self._reposition_labels()
|
||||
|
||||
def _on_range_changed(self, min, max):
|
||||
self._slider.setRange(min, max)
|
||||
for lbl in self._handle_labels:
|
||||
lbl.setRange(min, max)
|
||||
if self._edge_label_mode == EdgeLabelMode.LabelIsRange:
|
||||
self._min_label.setValue(min)
|
||||
self._max_label.setValue(max)
|
||||
self._reposition_labels()
|
||||
|
||||
def value(self):
|
||||
return self._slider.value()
|
||||
|
||||
def setValue(self, v: int) -> None:
|
||||
self._slider.setValue(v)
|
||||
self.sliderChange(QSlider.SliderValueChange)
|
||||
|
||||
def setOrientation(self, orientation):
|
||||
"""Set orientation, value will be 'horizontal' or 'vertical'."""
|
||||
|
||||
self._slider.setOrientation(orientation)
|
||||
if orientation == Qt.Vertical:
|
||||
layout = QVBoxLayout()
|
||||
layout.setSpacing(1)
|
||||
layout.addWidget(self._max_label)
|
||||
layout.addWidget(self._slider)
|
||||
layout.addWidget(self._min_label)
|
||||
# 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:
|
||||
marg = (0, 0, 20, 0)
|
||||
layout.setAlignment(Qt.AlignCenter)
|
||||
else:
|
||||
layout = QHBoxLayout()
|
||||
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:
|
||||
marg = (0, 25, 0, 0)
|
||||
layout.addWidget(self._min_label)
|
||||
layout.addWidget(self._slider)
|
||||
layout.addWidget(self._max_label)
|
||||
|
||||
# remove old layout
|
||||
old_layout = self.layout()
|
||||
if old_layout is not None:
|
||||
QWidget().setLayout(old_layout)
|
||||
|
||||
self.setLayout(layout)
|
||||
layout.setContentsMargins(*marg)
|
||||
super().setOrientation(orientation)
|
||||
QApplication.processEvents()
|
||||
self._reposition_labels()
|
||||
|
||||
def resizeEvent(self, a0) -> None:
|
||||
super().resizeEvent(a0)
|
||||
self._reposition_labels()
|
||||
|
||||
|
||||
class SliderLabel(QSpinBox):
|
||||
def __init__(
|
||||
self, slider: QSlider, parent=None, alignment=Qt.AlignCenter, connect=None
|
||||
) -> None:
|
||||
super().__init__(parent=parent)
|
||||
self._slider = slider
|
||||
self.setFocusPolicy(Qt.ClickFocus)
|
||||
self.setMode(EdgeLabelMode.LabelIsValue)
|
||||
|
||||
self.setRange(slider.minimum(), slider.maximum())
|
||||
slider.rangeChanged.connect(self._update_size)
|
||||
self.setAlignment(alignment)
|
||||
self.setButtonSymbols(QSpinBox.NoButtons)
|
||||
self.setStyleSheet("background:transparent; border: 0;")
|
||||
if connect is not None:
|
||||
self.editingFinished.connect(lambda: connect(self.value()))
|
||||
self.editingFinished.connect(self.clearFocus)
|
||||
self._update_size()
|
||||
|
||||
def _update_size(self):
|
||||
# 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
|
||||
s = self.textFromValue(self.minimum())[:18] + fixed_content
|
||||
w = max(0, fm.horizontalAdvance(s))
|
||||
s = self.textFromValue(self.maximum())[:18] + fixed_content
|
||||
w = max(w, fm.horizontalAdvance(s))
|
||||
if self.specialValueText():
|
||||
w = max(w, fm.horizontalAdvance(self.specialValueText()))
|
||||
else:
|
||||
s = self.textFromValue(self.value())
|
||||
w = max(0, fm.horizontalAdvance(s)) + 3
|
||||
|
||||
w += 3 # cursor blinking space
|
||||
# get the final size hint
|
||||
opt = QStyleOptionSpinBox()
|
||||
self.initStyleOption(opt)
|
||||
size = self.style().sizeFromContents(QStyle.CT_SpinBox, opt, QSize(w, h), self)
|
||||
self.setFixedSize(size)
|
||||
|
||||
def setValue(self, val):
|
||||
super().setValue(val)
|
||||
if self._mode == EdgeLabelMode.LabelIsRange:
|
||||
self._update_size()
|
||||
|
||||
def setMaximum(self, max: int) -> None:
|
||||
super().setMaximum(max)
|
||||
if self._mode == EdgeLabelMode.LabelIsValue:
|
||||
self._update_size()
|
||||
|
||||
def setMinimum(self, min: int) -> None:
|
||||
super().setMinimum(min)
|
||||
if self._mode == EdgeLabelMode.LabelIsValue:
|
||||
self._update_size()
|
||||
|
||||
def setMode(self, opt: EdgeLabelMode):
|
||||
# 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)
|
||||
try:
|
||||
self._slider.rangeChanged.disconnect(self.setRange)
|
||||
except Exception:
|
||||
pass
|
||||
else:
|
||||
self.setMinimum(self._slider.minimum())
|
||||
self.setMaximum(self._slider.maximum())
|
||||
self._slider.rangeChanged.connect(self.setRange)
|
||||
self._update_size()
|
||||
@@ -196,7 +196,7 @@ class QRangeSlider(QSlider):
|
||||
offset = self.minimum() - ref[0]
|
||||
self.setSliderPosition([i + offset for i in ref])
|
||||
|
||||
def _spreadAllPositions(self, shrink=False, gain=1.2, ref=None) -> None:
|
||||
def _spreadAllPositions(self, shrink=False, gain=1.1, ref=None) -> None:
|
||||
if ref is None:
|
||||
ref = self._position
|
||||
# if self._bar_is_rigid: # TODO
|
||||
@@ -351,8 +351,13 @@ class QRangeSlider(QSlider):
|
||||
super().setRange(min, max)
|
||||
self.setValue(self._value) # re-bound
|
||||
|
||||
def _handleRects(self, opt: QStyleOptionSlider, handle_index: int = None) -> QRect:
|
||||
def _handleRects(
|
||||
self, opt: QStyleOptionSlider = None, handle_index: int = None
|
||||
) -> QRect:
|
||||
"""Return the QRect for all handles."""
|
||||
if opt is None:
|
||||
opt = self._getStyleOption()
|
||||
|
||||
style = self.style().proxy()
|
||||
|
||||
if handle_index is not None: # get specific handle rect
|
||||
|
||||
@@ -22,7 +22,7 @@ elif PYQT6:
|
||||
|
||||
# backwards compat with PyQt5
|
||||
# namespace moves:
|
||||
for cls in (QStyle, QSlider, QSizePolicy):
|
||||
for cls in (QStyle, QSlider, QSizePolicy, QSpinBox):
|
||||
for attr in dir(cls):
|
||||
if not attr[0].isupper():
|
||||
continue
|
||||
|
||||
Reference in New Issue
Block a user