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

@@ -106,14 +106,14 @@ sliderMoved(Tuple[int, ...])
These options are in addition to the Qt QSlider API, and control the behavior of the bar between handles. These options are in addition to the Qt QSlider API, and control the behavior of the bar between handles.
| getter | setter | type | default | description | | getter | setter | type | default | description |
| ------------- | ------------- |--------- | -------- | --------- | | -------------------- | ------------------------------------------- | ------ | ------- | ------------------------------------------------------------------------------------------------ |
| `barIsVisible` | `setBarIsVisible` <br>`hideBar` / `showBar` | `bool` | `True` | <small>Whether the bar between handles is visible.</small> | | `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> | | `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> | | `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` These screenshots show `QRangeSlider` (multiple handles) next to the native `QSlider`
(single handle). With no styles applied, `QRangeSlider` will match the native OS (single handle). With no styles applied, `QRangeSlider` will match the native OS
@@ -178,18 +178,81 @@ QRangeSlider {
### macOS ### macOS
##### Catalina ##### Catalina
![mac](images/demo_darwin10.png) ![mac10](images/demo_darwin10.png)
##### Big Sur ##### Big Sur
![mac](images/demo_darwin11.png) ![mac11](images/demo_darwin11.png)
### Windows ### Windows
![mac](images/demo_windows.png) ![window](images/demo_windows.png)
### Linux ### 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 ## 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: except ImportError:
__version__ = "unknown" __version__ = "unknown"
from ._labeled import QLabeledRangeSlider, QLabeledSlider
from ._qrangeslider import QRangeSlider 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] offset = self.minimum() - ref[0]
self.setSliderPosition([i + offset for i in ref]) 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: if ref is None:
ref = self._position ref = self._position
# if self._bar_is_rigid: # TODO # if self._bar_is_rigid: # TODO
@@ -351,8 +351,13 @@ class QRangeSlider(QSlider):
super().setRange(min, max) super().setRange(min, max)
self.setValue(self._value) # re-bound 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.""" """Return the QRect for all handles."""
if opt is None:
opt = self._getStyleOption()
style = self.style().proxy() style = self.style().proxy()
if handle_index is not None: # get specific handle rect if handle_index is not None: # get specific handle rect

View File

@@ -22,7 +22,7 @@ elif PYQT6:
# backwards compat with PyQt5 # backwards compat with PyQt5
# namespace moves: # namespace moves:
for cls in (QStyle, QSlider, QSizePolicy): for cls in (QStyle, QSlider, QSizePolicy, QSpinBox):
for attr in dir(cls): for attr in dir(cls):
if not attr[0].isupper(): if not attr[0].isupper():
continue continue