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:
Talley Lambert
2021-04-27 21:33:45 -04:00
committed by GitHub
parent 21523dee82
commit a27b388f3e
8 changed files with 462 additions and 14 deletions

View File

@@ -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
![mac](images/demo_darwin10.png)
![mac10](images/demo_darwin10.png)
##### Big Sur
![mac](images/demo_darwin11.png)
![mac11](images/demo_darwin11.png)
### Windows
![mac](images/demo_windows.png)
![window](images/demo_windows.png)
### Linux
![mac](images/demo_linux.png)
![linux](images/demo_linux.png)
## Labeled Sliders
This package also includes two "labeled" slider variants. One for `QRangeSlider`, and one for the native `QSlider`:
### `QLabeledRangeSlider`
![labeled_range](images/labeled_range.png)
```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`
![labeled_range](images/labeled_qslider.png)
```python
from qtrangeslider import QLabeledSlider
```
(no additional options at this point)
## Issues

17
examples/labeled.py Normal file
View 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

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.0 KiB

BIN
images/labeled_range.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

View File

@@ -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
View 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()

View File

@@ -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

View File

@@ -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