Compare commits

...

18 Commits

Author SHA1 Message Date
Talley Lambert
a27b388f3e Labeled sliders (#3)
* good labels

* more options

* add to init

* reemit value changed

* remove pass

* refine positioning

* update example

* add docs
2021-04-27 21:33:45 -04:00
Talley Lambert
21523dee82 more styles 2021-04-27 17:16:12 -04:00
Talley Lambert
9471796fe5 improve barColor brush 2021-04-27 16:54:52 -04:00
pre-commit-ci[bot]
a6b0518be5 [pre-commit.ci] pre-commit autoupdate (#2)
updates:
- [github.com/asottile/pyupgrade: v2.12.0 → v2.13.0](https://github.com/asottile/pyupgrade/compare/v2.12.0...v2.13.0)
- [github.com/psf/black: 20.8b1 → 21.4b0](https://github.com/psf/black/compare/20.8b1...21.4b0)
- [github.com/PyCQA/flake8: 3.9.0 → 3.9.1](https://github.com/PyCQA/flake8/compare/3.9.0...3.9.1)

Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
2021-04-26 17:24:40 -04:00
Talley Lambert
592f0d75ba option scroll adjusts gain 2021-04-26 12:53:27 -04:00
Talley Lambert
2897a18851 more style fixes 2021-04-26 12:26:20 -04:00
Talley Lambert
59c5dec044 Merge branch 'main' of https://github.com/tlambert03/PyQRangeSlider into main 2021-04-26 12:22:20 -04:00
Talley Lambert
1340bfa371 Fix scrolling bar past extremes (#1)
* ex

* add mouse drag

* more lenient values

* use tp ==

* skip mouse move windows CI

* fix dragging to edges

* fix for pyqt6

* comment out tests
2021-04-26 12:22:11 -04:00
Talley Lambert
7d0ab56d54 fix for pyqt6 2021-04-26 12:19:08 -04:00
Talley Lambert
4edcdf4941 Merge branch 'main' of https://github.com/tlambert03/PyQRangeSlider into main 2021-04-26 10:08:53 -04:00
Talley Lambert
b651e2b757 fix gradients in bar 2021-04-26 09:57:34 -04:00
Talley Lambert
7ad87f9dc6 Update issue templates 2021-04-25 17:42:41 -04:00
Talley Lambert
7d323240be readme 2021-04-25 17:26:19 -04:00
Talley Lambert
e56d96fa5a new demo images 2021-04-25 17:04:08 -04:00
Talley Lambert
69203f878f update for barColor property 2021-04-25 16:43:23 -04:00
Talley Lambert
e8594d8b40 move pytest-qt6 to tox 2021-04-25 14:27:11 -04:00
Talley Lambert
01f496bc18 pyproject 2021-04-25 14:20:47 -04:00
Talley Lambert
75b29bc600 undo default valuw 2021-04-25 11:45:22 -04:00
23 changed files with 788 additions and 110 deletions

29
.github/ISSUE_TEMPLATE/bug_report.md vendored Normal file
View File

@@ -0,0 +1,29 @@
---
name: Bug report
about: Create a report to help us improve
title: ''
labels: ''
assignees: ''
---
**Describe the bug**
A clear and concise description of what the bug is.
**To Reproduce**
Steps to reproduce the behavior:
1. Go to '...'
2. Click on '....'
3. Scroll down to '....'
4. See error
**Expected behavior**
A clear and concise description of what you expected to happen.
**Screenshots**
Screenshots and GIFS are much appreciated when reporting visual bugs.
**Desktop (please complete the following information):**
- OS with version [e.g macOS 10.15.7]
- Qt Backend [e.g PyQt5, PySide2]
- Python version

View File

@@ -9,15 +9,15 @@ repos:
hooks:
- id: isort
- repo: https://github.com/asottile/pyupgrade
rev: v2.12.0
rev: v2.13.0
hooks:
- id: pyupgrade
- repo: https://github.com/psf/black
rev: 20.8b1
rev: 21.4b0
hooks:
- id: black
- repo: https://github.com/PyCQA/flake8
rev: 3.9.0
rev: 3.9.1
hooks:
- id: flake8
pass_filenames: true

140
README.md
View File

@@ -55,7 +55,7 @@ range_slider = QRangeSlider()
As `QRangeSlider` inherits from `QtWidgets.QSlider`, you can use all of the
same methods available in the [QSlider API](https://doc.qt.io/qt-5/qslider.html). The major difference is that `value` and `sliderPosition` are reimplemented as `tuples` of `int` (where the length of the tuple is equal to the number of handles in the slider.)
### value: Tuple[int, ...]
### `value: Tuple[int, ...]`
This property holds the current value of all handles in the slider.
@@ -80,7 +80,7 @@ range_slider.setValue(val: Sequence[int]) -> None
valueChanged(Tuple[int, ...])
```
### sliderPosition: Tuple[int, ...]
### `sliderPosition: Tuple[int, ...]`
This property holds the current slider positions. It is a `tuple` with length equal to the number of handles.
@@ -102,9 +102,18 @@ range_slider.setSliderPosition(val: Sequence[int]) -> None
sliderMoved(Tuple[int, ...])
```
### Additional properties
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
@@ -112,7 +121,9 @@ style of `QSlider` with or without tick marks. When styles have been applie
using [Qt Style Sheets](https://doc.qt.io/qt-5/stylesheet-reference.html), then
`QRangeSlider` will inherit any styles applied to `QSlider` (since it inherits
from QSlider). If you'd like to style `QRangeSlider` differently than `QSlider`,
then you can also target it directly in your style sheet.
then you can also target it directly in your style sheet. The one "special"
property for QRangeSlider is `qproperty-barColor`, which sets the color of the
bar between the handles.
> The code for these example widgets is [here](examples/demo_widget.py)
@@ -121,30 +132,44 @@ then you can also target it directly in your style sheet.
<summary><em>See style sheet used for this example</em></summary>
```css
/* Because QRangeSlider inherits QSlider, it will also inherit styles */
/*
Because QRangeSlider inherits from QSlider, it will also inherit styles
*/
QSlider {
min-height: 20px;
}
QSlider::groove:horizontal {
border: 0px;
background: qlineargradient(x1:0, y1:0, x2:1, y2:1, stop:0 #FDE282, stop:1 #EB9A5D);
height: 16px;
border-radius: 2px;
border: 0px;
background: qlineargradient(x1:0, y1:0, x2:1, y2:1,
stop:0 #777, stop:1 #aaa);
height: 20px;
border-radius: 10px;
}
QSlider::handle:horizontal {
background: #271848;
border: 1px solid #583856;
width: 18px;
margin: -2px 0;
border-radius: 3px;
QSlider::handle {
background: qradialgradient(cx:0, cy:0, radius: 1.2, fx:0.5,
fy:0.5, stop:0 #eef, stop:1 #000);
height: 20px;
width: 20px;
border-radius: 10px;
}
QSlider::handle:hover {
background-color: #2F4F4F;
}
/* "QSlider::sub-page" will style the "bar" area between the QRangeSlider handles */
/*
"QSlider::sub-page" is the one exception ...
(it styles the area to the left of the QSlider handle)
*/
QSlider::sub-page:horizontal {
background: #AF5A50;
border-radius: 2px;
background: #447;
border-top-left-radius: 10px;
border-bottom-left-radius: 10px;
}
/*
for QRangeSlider: use "qproperty-barColor". "sub-page" will not work.
*/
QRangeSlider {
qproperty-barColor: #447;
}
```
@@ -153,18 +178,81 @@ QSlider::sub-page:horizontal {
### macOS
##### Catalina
![mac](images/demo_darwin.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

View File

@@ -1,9 +1,10 @@
from qtrangeslider import QRangeSlider
from qtrangeslider.qtcompat.QtCore import Qt
from qtrangeslider.qtcompat.QtWidgets import QApplication
app = QApplication([])
slider = QRangeSlider()
slider = QRangeSlider(Qt.Horizontal)
slider.setValue((20, 80))
slider.show()

View File

@@ -3,31 +3,34 @@ from qtrangeslider.qtcompat import QtCore
from qtrangeslider.qtcompat import QtWidgets as QtW
QSS = """
QSlider {
min-height: 20px;
}
QSlider::groove:horizontal {
border: 0px;
background: qlineargradient(x1:0, y1:0, x2:1, y2:1, stop:0 #FDE282, stop:1 #EB9A5D);
height: 16px;
border-radius: 2px;
border: 0px;
background: qlineargradient(x1:0, y1:0, x2:1, y2:1, stop:0 #888, stop:1 #ddd);
height: 20px;
border-radius: 10px;
}
QSlider::handle:horizontal {
background: #271848;
border: 1px solid #583856;
width: 18px;
margin: -2px 0;
border-radius: 3px;
}
QSlider::handle:hover {
background-color: #2F4F4F;
QSlider::handle {
background: qradialgradient(cx:0, cy:0, radius: 1.2, fx:0.35,
fy:0.3, stop:0 #eef, stop:1 #002);
height: 20px;
width: 20px;
border-radius: 10px;
}
QSlider::sub-page:horizontal {
background: #AF5A50;
border-radius: 2px;
background: qlineargradient(x1:0, y1:0, x2:1, y2:1, stop:0 #227, stop:1 #77a);
border-top-left-radius: 10px;
border-bottom-left-radius: 10px;
}
QRangeSlider {
qproperty-barColor: qlineargradient(x1:0, y1:0, x2:1, y2:1, stop:0 #227, stop:1 #77a);
}
"""

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_()

Binary file not shown.

Before

Width:  |  Height:  |  Size: 38 KiB

BIN
images/demo_darwin10.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 42 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 41 KiB

After

Width:  |  Height:  |  Size: 45 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 13 KiB

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 34 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.0 KiB

After

Width:  |  Height:  |  Size: 9.0 KiB

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

3
pyproject.toml Normal file
View File

@@ -0,0 +1,3 @@
# pyproject.toml
[build-system]
requires = ["setuptools>=45", "wheel", "setuptools_scm[toml]>=6.0"]

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

@@ -4,7 +4,16 @@ from typing import List, Sequence, Tuple
from ._style import RangeSliderStyle, update_styles_from_stylesheet
from .qtcompat import QtGui
from .qtcompat.QtCore import QEvent, QPoint, QPointF, QRect, QRectF, Qt, Signal
from .qtcompat.QtCore import (
Property,
QEvent,
QPoint,
QPointF,
QRect,
QRectF,
Qt,
Signal,
)
from .qtcompat.QtWidgets import (
QApplication,
QSlider,
@@ -35,17 +44,16 @@ class QRangeSlider(QSlider):
sliderMoved = Signal(tuple)
_NULL_CTRL = ("None", -1)
_DEFAULT_VALUE = (20, 80)
def __init__(self, *args):
super().__init__(*args)
# list of values
self._value: List[int] = self._DEFAULT_VALUE
self._value: List[int] = [20, 80]
# list of current positions of each handle. same length as _value
# If tracking is enabled (the default) this will be identical to _value
self._position: List[int] = self._DEFAULT_VALUE
self._position: List[int] = [20, 80]
self._pressedControl: ControlType = self._NULL_CTRL
self._hoverControl: ControlType = self._NULL_CTRL
@@ -63,9 +71,19 @@ class QRangeSlider(QSlider):
# color
self._style = RangeSliderStyle()
self.setStyleSheet("")
update_styles_from_stylesheet(self)
# ############### Public API #######################
def setStyleSheet(self, styleSheet: str) -> None:
# sub-page styles render on top of the lower sliders and don't work here.
override = f"""
\n{type(self).__name__}::sub-page:horizontal {{background: none}}
\n{type(self).__name__}::sub-page:vertical {{background: none}}
"""
return super().setStyleSheet(styleSheet + override)
def value(self) -> Tuple[int, ...]:
"""Get current value of the widget as a tuple of integers."""
return tuple(self._value)
@@ -108,7 +126,8 @@ class QRangeSlider(QSlider):
)
for i, v in enumerate(val):
self._setSliderPositionAt(i, v, _update=i == len(val) - 1)
self._setSliderPositionAt(i, v, _update=False)
self._updateSliderMove()
def barIsRigid(self) -> bool:
"""Whether bar length is constant when dragging the bar.
@@ -156,23 +175,36 @@ class QRangeSlider(QSlider):
return
self._position[index] = pos
if _update:
if not self.hasTracking():
self.update()
if self.isSliderDown():
self.sliderMoved.emit(tuple(self._position))
if self.hasTracking():
self.triggerAction(QSlider.SliderMove)
self._updateSliderMove()
def _updateSliderMove(self):
if not self.hasTracking():
self.update()
if self.isSliderDown():
self.sliderMoved.emit(tuple(self._position))
if self.hasTracking():
self.triggerAction(QSlider.SliderMove)
def _offsetAllPositions(self, offset: int, ref=None) -> None:
if ref is None:
ref = self._position
_new = [i - offset for i in ref]
if self._bar_is_rigid:
# FIXME: if there is an overflow ... it should still hit the edge.
if all(self.minimum() <= i <= self.maximum() for i in _new):
self.setSliderPosition(_new)
else:
self.setSliderPosition(_new)
# NOTE: This assumes monotonically increasing slider positions
if offset > 0 and ref[-1] + offset > self.maximum():
offset = self.maximum() - ref[-1]
elif ref[0] + offset < self.minimum():
offset = self.minimum() - ref[0]
self.setSliderPosition([i + offset for i in ref])
def _spreadAllPositions(self, shrink=False, gain=1.1, ref=None) -> None:
if ref is None:
ref = self._position
# if self._bar_is_rigid: # TODO
if shrink:
gain = 1 / gain
center = abs(ref[-1] + ref[0]) / 2
self.setSliderPosition([((i - center) * gain) + center for i in ref])
def _getStyleOption(self) -> QStyleOptionSlider:
opt = QStyleOptionSlider()
@@ -181,6 +213,14 @@ class QRangeSlider(QSlider):
opt.sliderPosition = 0
return opt
def _getBarColor(self):
return self._style.brush(self._getStyleOption())
def _setBarColor(self, color):
self._style.brush_active = color
barColor = Property(QtGui.QBrush, _getBarColor, _setBarColor)
def _drawBar(self, painter: QStylePainter, opt: QStyleOptionSlider):
brush = self._style.brush(opt)
@@ -284,8 +324,9 @@ class QRangeSlider(QSlider):
self._setSliderPositionAt(self._pressedControl[1], new)
elif self._pressedControl[0] == "bar":
ev.accept()
delta = self._clickOffset - self._pixelPosToRangeValue(self._pick(ev.pos()))
self._offsetAllPositions(delta, self._sldPosAtPress)
self._offsetAllPositions(-delta, self._sldPosAtPress)
else:
ev.ignore()
return
@@ -310,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
@@ -503,7 +549,11 @@ class QRangeSlider(QSlider):
_prev_value = self.value()
self._offsetAllPositions(-steps_to_scroll)
if modifiers & Qt.AltModifier:
self._spreadAllPositions(shrink=steps_to_scroll < 0)
else:
self._offsetAllPositions(steps_to_scroll)
self.triggerAction(QSlider.SliderMove)
if _prev_value == self.value():

View File

@@ -3,8 +3,10 @@ import re
from dataclasses import dataclass, replace
from typing import TYPE_CHECKING, Union
from .qtcompat import PYQT_VERSION
from .qtcompat.QtCore import Qt
from .qtcompat.QtGui import (
QBrush,
QColor,
QGradient,
QLinearGradient,
@@ -33,24 +35,40 @@ class RangeSliderStyle:
h_offset: float = None
has_stylesheet: bool = False
def brush(self, opt: QStyleOptionSlider) -> Union[QGradient, QColor]:
def brush(self, opt: QStyleOptionSlider) -> QBrush:
cg = opt.palette.currentColorGroup()
attr = {
QPalette.Active: "brush_active", # 0
QPalette.Disabled: "brush_disabled", # 1
QPalette.Inactive: "brush_inactive", # 2
}[cg]
val = getattr(self, attr) or getattr(SYSTEM_STYLE, attr)
if isinstance(val, str):
val = QColor(val)
_val = getattr(self, attr)
if not _val:
if self.has_stylesheet:
# if someone set a general style sheet but didn't specify
# :active, :inactive, etc... then Qt just uses whatever they
# DID specify
for i in ("active", "inactive", "disabled"):
_val = getattr(self, f"brush_{i}")
if _val:
break
else:
_val = getattr(SYSTEM_STYLE, attr)
if not val:
return Qt.NoBrush
if _val is None:
return QBrush()
if isinstance(_val, str):
val = QColor(_val)
if not val.isValid():
val = parse_color(_val, default_attr=attr)
else:
val = _val
if opt.tickPosition != QSlider.NoTicks:
val.setAlphaF(self.tick_bar_alpha or SYSTEM_STYLE.tick_bar_alpha)
return val
return QBrush(val)
def pen(self, opt: QStyleOptionSlider) -> Union[Qt.PenStyle, QColor]:
cg = opt.palette.currentColorGroup()
@@ -77,9 +95,9 @@ class RangeSliderStyle:
off += self.h_offset or SYSTEM_STYLE.h_offset or 0
else:
off += self.v_offset or SYSTEM_STYLE.v_offset or 0
if tp & QSlider.TicksAbove:
if tp == QSlider.TicksAbove:
off += self.tick_offset or SYSTEM_STYLE.tick_offset
elif tp & QSlider.TicksBelow:
elif tp == QSlider.TicksBelow:
off -= self.tick_offset or SYSTEM_STYLE.tick_offset
return off
@@ -119,6 +137,9 @@ CATALINA_STYLE = replace(
tick_offset=4,
)
if PYQT_VERSION and int(PYQT_VERSION.split(".")[0]) == 6:
CATALINA_STYLE = replace(CATALINA_STYLE, tick_offset=2)
BIG_SUR_STYLE = replace(
CATALINA_STYLE,
brush_active="#0A81FE",
@@ -131,6 +152,9 @@ BIG_SUR_STYLE = replace(
tick_bar_alpha=0.2,
)
if PYQT_VERSION and int(PYQT_VERSION.split(".")[0]) == 6:
BIG_SUR_STYLE = replace(BIG_SUR_STYLE, tick_offset=-3)
WINDOWS_STYLE = replace(
BASE_STYLE,
brush_active="#550179D7",
@@ -191,14 +215,29 @@ qradial_pattern = re.compile(
re.X,
)
rgba_pattern = re.compile(
r"""
rgba?\(
(?P<r>\d+),\s*
(?P<g>\d+),\s*
(?P<b>\d+),?\s*(?P<a>\d+)?\)
""",
re.X,
)
def parse_color(color: str) -> Union[str, QGradient]:
def parse_color(color: str, default_attr) -> Union[QColor, QGradient]:
qc = QColor(color)
if qc.isValid():
return qc
match = rgba_pattern.search(color)
if match:
rgba = [int(x) if x else 255 for x in match.groups()]
return QColor(*rgba)
# try linear gradient:
match = qlineargrad_pattern.match(color)
match = qlineargrad_pattern.search(color)
if match:
grad = QLinearGradient(*[float(i) for i in match.groups()[:4]])
grad.setColorAt(0, QColor(match.groupdict()["stop0"]))
@@ -206,8 +245,7 @@ def parse_color(color: str) -> Union[str, QGradient]:
return grad
# try linear gradient:
match = qradial_pattern.match(color)
print("match", match.groupdict())
match = qradial_pattern.search(color)
if match:
grad = QRadialGradient(*[float(i) for i in match.groups()[:5]])
grad.setColorAt(0, QColor(match.groupdict()["stop0"]))
@@ -215,38 +253,19 @@ def parse_color(color: str) -> Union[str, QGradient]:
return grad
# fallback to dark gray
return "#333"
return QColor(getattr(SYSTEM_STYLE, default_attr))
def update_styles_from_stylesheet(obj: "QRangeSlider"):
qss = obj.styleSheet()
p = obj
while p.parent():
qss = p.styleSheet() + qss
p = p.parent()
parent = obj.parent()
while parent is not None:
qss = parent.styleSheet() + qss
parent = parent.parent()
qss = QApplication.instance().styleSheet() + qss
obj._style.has_stylesheet = False
# Find bar color
# TODO: optional horizontal or vertical
match = re.search(r"Slider::sub-page:?([^{\s]*)?\s*{\s*([^}]+)}", qss, re.S)
if match:
orientation, content = match.groups()
for line in reversed(content.splitlines()):
bgrd = re.search(r"background(-color)?:\s*([^;]+)", line)
if bgrd:
color = parse_color(bgrd.groups()[-1])
obj._style.brush_active = color
# TODO: parse for inactive and disabled
obj._style.brush_inactive = color
obj._style.brush_disabled = color
obj._style.has_stylesheet = True
class_name = type(obj).__name__
_ss = f"\n{class_name}::sub-page:{orientation}{{background: none}}"
# TODO: block double event
obj.setStyleSheet(qss + _ss)
break
if not qss:
return
# Find bar height/width
for orient, dim in (("horizontal", "height"), ("vertical", "width")):

View File

@@ -1,10 +1,115 @@
import os
import pytest
from qtrangeslider import QRangeSlider
from qtrangeslider.qtcompat.QtCore import Qt
WINDOWS = os.name == "nt"
@pytest.mark.parametrize("orientation", ["Horizontal", "Vertical"])
def test_basic(qtbot, orientation):
rs = QRangeSlider(getattr(Qt, orientation))
qtbot.addWidget(rs)
# @pytest.mark.skipif(WINDOWS, reason="QTest.mouseMove not working on windows")
# def test_drag_handles(qtbot):
# rs = QRangeSlider(Qt.Horizontal)
# qtbot.addWidget(rs)
# rs.setRange(0, 99)
# rs.setValue((20, 80))
# rs.setMouseTracking(True)
# rs.show()
# # press the left handle
# opt = rs._getStyleOption()
# pos = rs._handleRects(opt, 0).center()
# with qtbot.waitSignal(rs.sliderPressed):
# qtbot.mousePress(rs, Qt.LeftButton, pos=pos)
# assert rs._pressedControl == ("handle", 0)
# # drag the left handle
# with qtbot.waitSignals([rs.sliderMoved] * 13): # couple less signals
# for _ in range(15):
# pos.setX(pos.x() + 2)
# qtbot.mouseMove(rs, pos)
# with qtbot.waitSignal(rs.sliderReleased):
# qtbot.mouseRelease(rs, Qt.LeftButton)
# # check the values
# assert rs.value()[0] > 30
# assert rs._pressedControl == rs._NULL_CTRL
# # press the right handle
# pos = rs._handleRects(opt, 1).center()
# with qtbot.waitSignal(rs.sliderPressed):
# qtbot.mousePress(rs, Qt.LeftButton, pos=pos)
# assert rs._pressedControl == ("handle", 1)
# # drag the right handle
# with qtbot.waitSignals([rs.sliderMoved] * 13): # couple less signals
# for _ in range(15):
# pos.setX(pos.x() - 2)
# qtbot.mouseMove(rs, pos)
# with qtbot.waitSignal(rs.sliderReleased):
# qtbot.mouseRelease(rs, Qt.LeftButton)
# # check the values
# assert rs.value()[1] < 70
# assert rs._pressedControl == rs._NULL_CTRL
# @pytest.mark.skipif(WINDOWS, reason="QTest.mouseMove not working on windows")
# def test_drag_handles_beyond_edge(qtbot):
# rs = QRangeSlider(Qt.Horizontal)
# qtbot.addWidget(rs)
# rs.setRange(0, 99)
# rs.setValue((20, 80))
# rs.setMouseTracking(True)
# rs.show()
# # press the right handle
# opt = rs._getStyleOption()
# pos = rs._handleRects(opt, 1).center()
# with qtbot.waitSignal(rs.sliderPressed):
# qtbot.mousePress(rs, Qt.LeftButton, pos=pos)
# assert rs._pressedControl == ("handle", 1)
# # drag the handle off the right edge and make sure the value gets to the max
# for _ in range(5):
# pos.setX(pos.x() + 20)
# qtbot.mouseMove(rs, pos)
# with qtbot.waitSignal(rs.sliderReleased):
# qtbot.mouseRelease(rs, Qt.LeftButton)
# assert rs.value()[1] == 99
# @pytest.mark.skipif(WINDOWS, reason="QTest.mouseMove not working on windows")
# def test_bar_drag_beyond_edge(qtbot):
# rs = QRangeSlider(Qt.Horizontal)
# qtbot.addWidget(rs)
# rs.setRange(0, 99)
# rs.setValue((20, 80))
# rs.setMouseTracking(True)
# rs.show()
# # press the right handle
# pos = rs.rect().center()
# with qtbot.waitSignal(rs.sliderPressed):
# qtbot.mousePress(rs, Qt.LeftButton, pos=pos)
# assert rs._pressedControl == ("bar", 1)
# # drag the handle off the right edge and make sure the value gets to the max
# for _ in range(15):
# pos.setX(pos.x() + 10)
# qtbot.mouseMove(rs, pos)
# with qtbot.waitSignal(rs.sliderReleased):
# qtbot.mouseRelease(rs, Qt.LeftButton)
# assert rs.value()[1] == 99

View File

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

View File

@@ -45,8 +45,7 @@ testing =
tox
tox-conda
pytest
# https://github.com/pytest-dev/pytest-qt/pull/340
pytest-qt @ git+https://github.com/The-Compiler/pytest-qt.git@pyqt6
pytest-qt
pytest-cov
dev =
ipython

View File

@@ -39,4 +39,5 @@ extras =
pyside2: pyside2
pyqt6: pyqt6
pyside6: pyside6
commands_pre = pip install -U pytest-qt@git+https://github.com/The-Compiler/pytest-qt.git@pyqt6
commands = pytest -v --color=yes --cov=qtrangeslider --cov-report=xml