Compare commits

...

16 Commits

Author SHA1 Message Date
Talley Lambert
7e64be7d9d rename to superqt (#3) 2021-06-26 16:29:59 -04:00
Talley Lambert
eeb4413678 Update README.md 2021-06-03 07:31:02 -04:00
Talley Lambert
f1cfe11c1a Merge pull request #1 from tlambert-forks/intspin 2021-06-02 22:21:55 -04:00
Talley Lambert
5a55a74670 add spin 2021-06-02 21:11:42 -04:00
Talley Lambert
27bcfc4c8e update readme 2021-06-02 20:36:23 -04:00
Talley Lambert
40b34213fb Move to to qwidgets
commit 466fc7c19ace1343d23739e4058758cd21328511
Author: Talley Lambert <talley.lambert@gmail.com>
Date:   Wed Jun 2 20:22:38 2021 -0400

    add deploy cond

commit e9965e71490689935b61099225acc7f3bf5c2d48
Author: Talley Lambert <talley.lambert@gmail.com>
Date:   Wed Jun 2 20:20:45 2021 -0400

    more precommit

commit b39150b16d7d64a5530ec9a0e29e673e2b6ed0a4
Author: Talley Lambert <talley.lambert@gmail.com>
Date:   Wed Jun 2 19:52:42 2021 -0400

    updating precommit

commit d5018b38e7bc59f81cc161cca06fae829e493e3c
Author: Talley Lambert <talley.lambert@gmail.com>
Date:   Wed Jun 2 19:42:32 2021 -0400

    big reorg
2021-06-02 20:25:40 -04:00
Talley Lambert
297838e895 cov changes (#6)
* cov changes

* update yml

* undo test slider
2021-06-02 17:44:12 -04:00
Talley Lambert
15e3af4985 Generic slider (#14)
* good coverage

* merged classes

* working cross platform

* range slider tests working too

* many more fixes and unification

* type

* reorg

* working labels, better typing

* tests

* legacy compat

* update envlist

* skip mouse press not on mac

* fix getStyleOption

* fix again

* skip hover

* remove print

* add module docstring
2021-06-02 17:23:05 -04:00
pre-commit-ci[bot]
b12e5471a0 [pre-commit.ci] pre-commit autoupdate (#11)
updates:
- [github.com/pre-commit/pre-commit-hooks: v3.4.0 → v4.0.1](https://github.com/pre-commit/pre-commit-hooks/compare/v3.4.0...v4.0.1)
- [github.com/asottile/pyupgrade: v2.15.0 → v2.19.0](https://github.com/asottile/pyupgrade/compare/v2.15.0...v2.19.0)
- [github.com/psf/black: 21.5b1 → 21.5b2](https://github.com/psf/black/compare/21.5b1...21.5b2)

Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
2021-05-31 13:37:01 -04:00
Talley Lambert
d93787e35a Prevent handle and label overlap (#7)
* cov changes

* mostly good

* remove cov

* use int

* fix

* prevent label overlap
2021-05-17 09:38:08 -04:00
pre-commit-ci[bot]
d04ca7a4b3 [pre-commit.ci] pre-commit autoupdate (#8)
updates:
- [github.com/asottile/pyupgrade: v2.13.0 → v2.15.0](https://github.com/asottile/pyupgrade/compare/v2.13.0...v2.15.0)
- [github.com/psf/black: 21.4b0 → 21.5b1](https://github.com/psf/black/compare/21.4b0...21.5b1)
- [github.com/PyCQA/flake8: 3.9.1 → 3.9.2](https://github.com/PyCQA/flake8/compare/3.9.1...3.9.2)

Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
2021-05-17 09:35:54 -04:00
Talley Lambert
b6900b8b14 fix recursion (#10) 2021-05-17 09:35:45 -04:00
Talley Lambert
19779c6fb7 Update README.md 2021-05-17 09:32:42 -04:00
Talley Lambert
24b67d00e4 Fix slider signature overloads (#9)
* overload

* more sigs
2021-05-17 09:13:46 -04:00
Talley Lambert
10feb74656 remove annotation 2021-05-17 08:59:58 -04:00
Talley Lambert
96f9a5cd90 Mouse interaction tests (#5)
* tests

* skipmouse
2021-05-02 14:45:36 -04:00
49 changed files with 2482 additions and 1262 deletions

View File

@@ -66,6 +66,15 @@ jobs:
- python-version: 3.6
platform: windows-2016
backend: pyqt5
# legacy Qt
- python-version: 3.7
platform: ubuntu-latest
backend: pyqt511
- python-version: 3.7
platform: ubuntu-latest
backend: pyside511
steps:
- uses: actions/checkout@v2
@@ -80,6 +89,7 @@ jobs:
sudo apt-get install -y libdbus-1-3 libxkbcommon-x11-0 libxcb-icccm4 \
libxcb-image0 libxcb-keysyms1 libxcb-randr0 libxcb-render-util0 \
libxcb-xinerama0 libxcb-xfixes0
- name: Linux opengl
if: runner.os == 'Linux' && ( matrix.backend == 'pyside6' || matrix.backend == 'pyqt6' )
run: sudo apt-get install -y libopengl0 libegl1-mesa libxcb-xinput0
@@ -102,15 +112,15 @@ jobs:
if: matrix.screenshot
run: pip install . ${{ matrix.backend }}
- name: Screenshots
- name: Screenshots (Linux)
if: runner.os == 'Linux' && matrix.screenshot
uses: GabrielBB/xvfb-action@v1
with:
run: python examples/demo_widget.py
run: python examples/demo_widget.py -snap
- name: Screenshots
- name: Screenshots (macOS/Win)
if: runner.os != 'Linux' && matrix.screenshot
run: python examples/demo_widget.py
run: python examples/demo_widget.py -snap
- uses: actions/upload-artifact@v2
if: matrix.screenshot
@@ -124,8 +134,8 @@ jobs:
# and requires that you have put your twine API key in your
# github secrets (see readme for details)
needs: [test]
if: ${{ github.repository == 'napari/superqt' && contains(github.ref, 'tags') }}
runs-on: ubuntu-latest
if: contains(github.ref, 'tags')
steps:
- uses: actions/checkout@v2
- name: Set up Python

View File

@@ -1,23 +1,35 @@
repos:
- repo: https://github.com/pre-commit/pre-commit-hooks
rev: v3.4.0
rev: v4.0.1
hooks:
- id: end-of-file-fixer
- id: trailing-whitespace
- repo: https://github.com/asottile/setup-cfg-fmt
rev: v1.17.0
hooks:
- id: setup-cfg-fmt
- repo: https://github.com/PyCQA/flake8
rev: 3.9.2
hooks:
- id: flake8
additional_dependencies:
[flake8-typing-imports==1.7.0]
exclude: examples
- repo: https://github.com/myint/autoflake
rev: v1.4
hooks:
- id: autoflake
args: ["--in-place", "--remove-all-unused-imports"]
- repo: https://github.com/PyCQA/isort
rev: 5.8.0
hooks:
- id: isort
- repo: https://github.com/asottile/pyupgrade
rev: v2.13.0
rev: v2.19.1
hooks:
- id: pyupgrade
args: [--py37-plus]
- repo: https://github.com/psf/black
rev: 21.4b0
rev: 21.5b2
hooks:
- id: black
- repo: https://github.com/PyCQA/flake8
rev: 3.9.1
hooks:
- id: flake8
pass_filenames: true

54
CONTRIBUTING.md Normal file
View File

@@ -0,0 +1,54 @@
# Contributing to this repository
This repository seeks to accumulate Qt-based widgets for python (PyQt & PySide)
that are not provided in the native QtWidgets module.
## Clone
To get started fork this repository, and clone your fork:
```bash
# clone your fork
git clone https://github.com/<your_organization>/superqt
cd superqt
# install pre-commit hooks
pre-commit install
# install in editable mode
pip install -e .[dev]
# run tests & make sure everything is working!
pytest
```
## Targeted platforms
All widgets must be well-tested, and should work on:
- Python 3.7 and above
- PyQt5 (5.11 and above) & PyQt6
- PySide2 (5.11 and above) & PySide6
- macOS, Windows, & Linux
Until [qtpy](https://github.com/spyder-ide/qtpy) supports PyQt6/PySide6, imports
should use (and modify if necessary) `superqt.qtcompat`.
## Style Guide
All widgets should try to match the native Qt API as much as possible:
- Methods should use `camelCase` naming.
- Getters/setters use the `attribute()/setAttribute()` pattern.
- Private methods should use `_camelCaseNaming`.
- `__init__` methods should be like Qt constructors, meaning they often don't
include parameters for most of the widgets properties.
- When possible, widgets should inherit from the most similar native widget
available. It should strictly match the Qt API where it exists, and attempt to
cover as much of the native API as possible; this includes properties, public
functions, signals, and public slots.
## Testing
Tests can be run in the current environment with `pytest`. Or, to run tests
against all supported python & Qt versions, run `tox`.

View File

@@ -12,7 +12,7 @@ modification, are permitted provided that the following conditions are met:
this list of conditions and the following disclaimer in the documentation
and/or other materials provided with the distribution.
* Neither the name of QtRangeSlider nor the names of its
* Neither the name of superqt nor the names of its
contributors may be used to endorse or promote products derived from
this software without specific prior written permission.

271
README.md
View File

@@ -1,262 +1,47 @@
# QtRangeSlider
# ![tiny](https://user-images.githubusercontent.com/1609449/120636353-8c3f3800-c43b-11eb-8732-a14dec578897.png) superqt!
[![License](https://img.shields.io/pypi/l/QtRangeSlider.svg?color=green)](https://github.com/tlambert03/QtRangeSlider/raw/master/LICENSE)
[![PyPI](https://img.shields.io/pypi/v/QtRangeSlider.svg?color=green)](https://pypi.org/project/QtRangeSlider)
[![License](https://img.shields.io/pypi/l/superqt.svg?color=green)](https://github.com/napari/superqt/raw/master/LICENSE)
[![PyPI](https://img.shields.io/pypi/v/superqt.svg?color=green)](https://pypi.org/project/superqt)
[![Python
Version](https://img.shields.io/pypi/pyversions/QtRangeSlider.svg?color=green)](https://python.org)
[![Test](https://github.com/tlambert03/QtRangeSlider/actions/workflows/test_and_deploy.yml/badge.svg)](https://github.com/tlambert03/QtRangeSlider/actions/workflows/test_and_deploy.yml)
[![codecov](https://codecov.io/gh/tlambert03/QtRangeSlider/branch/master/graph/badge.svg)](https://codecov.io/gh/tlambert03/QtRangeSlider)
Version](https://img.shields.io/pypi/pyversions/superqt.svg?color=green)](https://python.org)
[![Test](https://github.com/napari/superqt/actions/workflows/test_and_deploy.yml/badge.svg)](https://github.com/napari/superqt/actions/workflows/test_and_deploy.yml)
[![codecov](https://codecov.io/gh/napari/superqt/branch/master/graph/badge.svg)](https://codecov.io/gh/napari/superqt)
**The missing multi-handle range slider widget for PyQt & PySide**
### "missing" widgets and components for PyQt/PySide
![slider](images/slider.png)
This repository aims to provide high-quality community-contributed Qt widgets and components for PyQt & PySide
that are not provided in the native QtWidgets module.
The goal of this package is to provide a Range Slider (a slider with 2 or more
handles) that feels as "native" as possible. Styles should match the OS by
default, and the slider should behave like a standard
[`QSlider`](https://doc.qt.io/qt-5/qslider.html)... but with multiple handles!
Components are tested on:
- `QRangeSlider` inherits from [`QSlider`](https://doc.qt.io/qt-5/qslider.html)
and attempts to match the Qt API as closely as possible
- Uses platform-specific styles (for handle, groove, & ticks) but also supports
QSS style sheets.
- Supports mouse wheel and keypress (soon) events
- Supports PyQt5, PyQt6, PySide2 and PySide6
- Supports more than 2 handles (e.g. `slider.setValue([0, 10, 60, 80])`)
- macOS, Windows, & Linux
- Python 3.7 and above
- PyQt5 (5.11 and above) & PyQt6
- PySide2 (5.11 and above) & PySide6
## Installation
## Widgets
You can install `QtRangeSlider` via pip:
Widgets include:
```sh
pip install qtrangeslider
- [Float Slider](docs/sliders.md#float-slider)
# NOTE: you must also install a Qt Backend.
# PyQt5, PySide2, PyQt6, and PySide6 are supported
# As a convenience you can install them as extras:
pip install qtrangeslider[pyqt5]
```
- [Range Slider](docs/sliders.md#range-slider) (multi-handle slider)
<img src="https://raw.githubusercontent.com/napari/superqt/main/docs/images/demo_darwin10.png" alt="range sliders" width=680>
------
## API
- [Labeled Sliders](docs/sliders.md#labeled-sliders) (sliders with linked
spinboxes)
To create a slider:
<img src="https://raw.githubusercontent.com/napari/superqt/main/docs/images/labeled_qslider.png" alt="range sliders" width=680>
```python
from qtrangeslider import QRangeSlider
<img src="https://raw.githubusercontent.com/napari/superqt/main/docs/images/labeled_range.png" alt="range sliders" width=680>
# as usual:
# you must create a QApplication before create a widget.
range_slider = QRangeSlider()
```
- Unbound Integer SpinBox (backed by python `int`)
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.)
## Contributing
### `value: Tuple[int, ...]`
We welcome contributions!
This property holds the current value of all handles in the slider.
The slider forces all values to be within the legal range:
`minimum <= value <= maximum`.
Changing the value also changes the sliderPosition.
##### Access Functions:
```python
range_slider.value() -> Tuple[int, ...]
```
```python
range_slider.setValue(val: Sequence[int]) -> None
```
##### Notifier Signal:
```python
valueChanged(Tuple[int, ...])
```
### `sliderPosition: Tuple[int, ...]`
This property holds the current slider positions. It is a `tuple` with length equal to the number of handles.
If [tracking](https://doc.qt.io/qt-5/qabstractslider.html#tracking-prop) is enabled (the default), this is identical to [`value`](#value--tupleint-).
##### Access Functions:
```python
range_slider.sliderPosition() -> Tuple[int, ...]
```
```python
range_slider.setSliderPosition(val: Sequence[int]) -> None
```
##### Notifier Signal:
```python
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> |
------
## Examples
These screenshots show `QRangeSlider` (multiple handles) next to the native `QSlider`
(single handle). With no styles applied, `QRangeSlider` will match the native OS
style of `QSlider` with or without tick marks. When styles have been applied
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. 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)
<details>
<summary><em>See style sheet used for this example</em></summary>
```css
/*
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 #777, stop:1 #aaa);
height: 20px;
border-radius: 10px;
}
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::sub-page" is the one exception ...
(it styles the area to the left of the QSlider handle)
*/
QSlider::sub-page:horizontal {
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;
}
```
</details>
### macOS
##### Catalina
![mac10](images/demo_darwin10.png)
##### Big Sur
![mac11](images/demo_darwin11.png)
### Windows
![window](images/demo_windows.png)
### Linux
![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
If you encounter any problems, please [file an issue] along with a detailed
description.
[file an issue]: https://github.com/tlambert03/QtRangeSlider/issues
Please see the [Contributing Guide](CONTRIBUTING.md)

View File

@@ -1,14 +1,16 @@
ignore:
- qtrangeslider/_version.py
- superqt/_version.py
- superqt/qtcompat/*
- '*_tests*'
coverage:
status:
project:
default:
target: auto
threshold: 1% # coverage can drop by up to 1% while still posting success
threshold: 1% # PR will fail if it drops coverage on the project by >1%
patch:
default:
target: auto
threshold: 40% # coverage can drop by up to 40% while still posting success
threshold: 40% # A given PR will fail if >40% is untested
comment:
require_changes: true # if true: only post the PR comment if coverage changes

View File

Before

Width:  |  Height:  |  Size: 42 KiB

After

Width:  |  Height:  |  Size: 42 KiB

View File

Before

Width:  |  Height:  |  Size: 45 KiB

After

Width:  |  Height:  |  Size: 45 KiB

View File

Before

Width:  |  Height:  |  Size: 24 KiB

After

Width:  |  Height:  |  Size: 24 KiB

View File

Before

Width:  |  Height:  |  Size: 9.0 KiB

After

Width:  |  Height:  |  Size: 9.0 KiB

View File

Before

Width:  |  Height:  |  Size: 9.0 KiB

After

Width:  |  Height:  |  Size: 9.0 KiB

View File

Before

Width:  |  Height:  |  Size: 12 KiB

After

Width:  |  Height:  |  Size: 12 KiB

View File

Before

Width:  |  Height:  |  Size: 86 KiB

After

Width:  |  Height:  |  Size: 86 KiB

238
docs/sliders.md Normal file
View File

@@ -0,0 +1,238 @@
# Sliders
![slider](images/slider.png)
- `QRangeSlider` inherits from [`QSlider`](https://doc.qt.io/qt-5/qslider.html)
and attempts to match the Qt API as closely as possible
- Uses platform-specific styles (for handle, groove, & ticks) but also supports
QSS style sheets.
- Supports mouse wheel and keypress (soon) events
- Supports more than 2 handles (e.g. `slider.setValue([0, 10, 60, 80])`)
------
## Range Slider
```python
from superqt import QRangeSlider
# as usual:
# you must create a QApplication before create a widget.
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, ...]`
This property holds the current value of all handles in the slider.
The slider forces all values to be within the legal range:
`minimum <= value <= maximum`.
Changing the value also changes the sliderPosition.
##### Access Functions:
```python
range_slider.value() -> Tuple[int, ...]
```
```python
range_slider.setValue(val: Sequence[int]) -> None
```
##### Notifier Signal:
```python
valueChanged(Tuple[int, ...])
```
### `sliderPosition: Tuple[int, ...]`
This property holds the current slider positions. It is a `tuple` with length equal to the number of handles.
If [tracking](https://doc.qt.io/qt-5/qabstractslider.html#tracking-prop) is enabled (the default), this is identical to [`value`](#value--tupleint-).
##### Access Functions:
```python
range_slider.sliderPosition() -> Tuple[int, ...]
```
```python
range_slider.setSliderPosition(val: Sequence[int]) -> None
```
##### Notifier Signal:
```python
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> |
------
### Examples
These screenshots show `QRangeSlider` (multiple handles) next to the native `QSlider`
(single handle). With no styles applied, `QRangeSlider` will match the native OS
style of `QSlider` with or without tick marks. When styles have been applied
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. 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)
<details>
<summary><em>See style sheet used for this example</em></summary>
```css
/*
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 #777, stop:1 #aaa);
height: 20px;
border-radius: 10px;
}
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::sub-page" is the one exception ...
(it styles the area to the left of the QSlider handle)
*/
QSlider::sub-page:horizontal {
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;
}
```
</details>
#### macOS
##### Catalina
![mac10](images/demo_darwin10.png)
##### Big Sur
![mac11](images/demo_darwin11.png)
#### Windows
![window](images/demo_windows.png)
#### Linux
![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 superqt 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.LabelIsRange`
*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 superqt import QLabeledSlider
```
(no additional options at this point)
## Issues
If you encounter any problems, please [file an issue] along with a detailed
description.
[file an issue]: https://github.com/napari/superqt/issues
## Float Slider
just like QSlider, but supports float values
```python
from superqt import QDoubleSlider
```

View File

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

12
examples/basic_float.py Normal file
View File

@@ -0,0 +1,12 @@
from superqt import QDoubleSlider
from superqt.qtcompat.QtCore import Qt
from superqt.qtcompat.QtWidgets import QApplication
app = QApplication([])
slider = QDoubleSlider(Qt.Horizontal)
slider.setRange(0, 1)
slider.setValue(0.5)
slider.show()
app.exec_()

View File

@@ -1,6 +1,6 @@
from qtrangeslider import QRangeSlider
from qtrangeslider.qtcompat import QtCore
from qtrangeslider.qtcompat import QtWidgets as QtW
from superqt import QRangeSlider
from superqt.qtcompat import QtCore
from superqt.qtcompat import QtWidgets as QtW
QSS = """
QSlider {
@@ -112,10 +112,10 @@ if __name__ == "__main__":
app = QtW.QApplication([])
demo = DemoWidget()
if "-x" in sys.argv:
app.exec_()
else:
if "-snap" in sys.argv:
import platform
QtW.QApplication.processEvents()
demo.grab().save(str(dest / f"demo_{platform.system().lower()}.png"))
else:
app.exec_()

View File

@@ -1,7 +1,6 @@
from qtrangeslider import QRangeSlider
from qtrangeslider._float_slider import QDoubleRangeSlider, QDoubleSlider
from qtrangeslider.qtcompat.QtCore import Qt
from qtrangeslider.qtcompat.QtWidgets import QApplication, QVBoxLayout, QWidget
from superqt import QDoubleRangeSlider, QDoubleSlider, QRangeSlider
from superqt.qtcompat.QtCore import Qt
from superqt.qtcompat.QtWidgets import QApplication, QVBoxLayout, QWidget
app = QApplication([])

12
examples/generic.py Normal file
View File

@@ -0,0 +1,12 @@
from superqt import QDoubleSlider
from superqt.qtcompat.QtCore import Qt
from superqt.qtcompat.QtWidgets import QApplication
app = QApplication([])
sld = QDoubleSlider(Qt.Horizontal)
sld.setRange(0, 1)
sld.setValue(0.5)
sld.show()
app.exec_()

View File

@@ -1,16 +1,11 @@
from qtrangeslider._labeled import (
from superqt import (
QLabeledDoubleRangeSlider,
QLabeledDoubleSlider,
QLabeledRangeSlider,
QLabeledSlider,
)
from qtrangeslider.qtcompat.QtCore import Qt
from qtrangeslider.qtcompat.QtWidgets import (
QApplication,
QHBoxLayout,
QVBoxLayout,
QWidget,
)
from superqt.qtcompat.QtCore import Qt
from superqt.qtcompat.QtWidgets import QApplication, QHBoxLayout, QVBoxLayout, QWidget
app = QApplication([])
@@ -30,12 +25,13 @@ qlds.setValue(0.5)
qlds.setSingleStep(0.1)
qlrs = QLabeledRangeSlider(ORIENTATION)
qlrs.valueChanged.connect(lambda e: print("qlrs valueChanged", e))
qlrs.valueChanged.connect(lambda e: print("QLabeledRangeSlider valueChanged", e))
qlrs.setValue((20, 60))
qldrs = QLabeledDoubleRangeSlider(ORIENTATION)
qldrs.valueChanged.connect(lambda e: print("qlrs valueChanged", e))
qldrs.setRange(0, 1)
qldrs.setSingleStep(0.01)
qldrs.setValue((0.2, 0.7))

View File

@@ -1,5 +1,5 @@
from qtrangeslider import QRangeSlider
from qtrangeslider.qtcompat.QtWidgets import QApplication
from superqt import QRangeSlider
from superqt.qtcompat.QtWidgets import QApplication
app = QApplication([])

View File

@@ -1,96 +0,0 @@
import math
from typing import Tuple
from ._hooked import _HookedSlider
from ._qrangeslider import QRangeSlider
from .qtcompat.QtCore import Signal
class QDoubleSlider(_HookedSlider):
valueChanged = Signal(float)
rangeChanged = Signal(float, float)
sliderMoved = Signal(float)
_multiplier = 1
def __init__(self, *args):
super().__init__(*args)
self._multiplier = 10 ** 2
self.setMinimum(0)
self.setMaximum(99)
self.setSingleStep(1)
self.setPageStep(10)
super().sliderMoved.connect(
lambda e: self.sliderMoved.emit(self._post_get_hook(e))
)
def decimals(self) -> int:
"""This property holds the precision of the slider, in decimals."""
return int(math.log10(self._multiplier))
def setDecimals(self, prec: int):
"""This property holds the precision of the slider, in decimals
Sets how many decimals the slider uses for displaying and interpreting doubles.
"""
previous = self._multiplier
self._multiplier = 10 ** int(prec)
ratio = self._multiplier / previous
if ratio != 1:
self.blockSignals(True)
try:
newmin = self.minimum() * ratio
newmax = self.maximum() * ratio
newval = self._scale_value(ratio)
newstep = self.singleStep() * ratio
newpage = self.pageStep() * ratio
self.setRange(newmin, newmax)
self.setValue(newval)
self.setSingleStep(newstep)
self.setPageStep(newpage)
except OverflowError as err:
self._multiplier = previous
raise OverflowError(
f"Cannot use {prec} decimals with a range of {newmin}-"
f"{newmax}. If you need this feature, please open a feature"
" request at github."
) from err
self.blockSignals(False)
def _scale_value(self, p):
# for subclasses
return self.value() * p
def _post_get_hook(self, value: int) -> float:
return value / self._multiplier
def _pre_set_hook(self, value: float) -> int:
return int(value * self._multiplier)
def sliderChange(self, change) -> None:
if change == self.SliderValueChange:
self.valueChanged.emit(self.value())
if change == self.SliderRangeChange:
self.rangeChanged.emit(self.minimum(), self.maximum())
return super().sliderChange(self.SliderChange(change))
class QDoubleRangeSlider(QRangeSlider, QDoubleSlider):
rangeChanged = Signal(float, float)
def value(self) -> Tuple[float, ...]:
"""Get current value of the widget as a tuple of integers."""
return tuple(float(i) for i in self._value)
def _min_max_bound(self, val: int) -> float:
return round(super()._min_max_bound(val), self.decimals())
def _scale_value(self, p):
# This function is called during setDecimals...
# but because QRangeSlider has a private nonQt `_value`
# we don't actually need to scale
return self._value
def setDecimals(self, prec: int):
return super().setDecimals(prec)

View File

@@ -1,49 +0,0 @@
from .qtcompat.QtWidgets import QSlider
class _HookedSlider(QSlider):
def _post_get_hook(self, value):
return value
def _pre_set_hook(self, value):
return value
def value(self) -> float: # type: ignore[override]
return float(self._post_get_hook(super().value()))
def setValue(self, value: float) -> None:
super().setValue(self._pre_set_hook(value))
def minimum(self) -> float: # type: ignore[override]
return self._post_get_hook(super().minimum())
def setMinimum(self, minimum: float):
super().setMinimum(self._pre_set_hook(minimum))
def maximum(self) -> float: # type: ignore[override]
return self._post_get_hook(super().maximum())
def setMaximum(self, maximum: float):
super().setMaximum(self._pre_set_hook(maximum))
def singleStep(self) -> float: # type: ignore[override]
return self._post_get_hook(super().singleStep())
def setSingleStep(self, step: float):
super().setSingleStep(self._pre_set_hook(step))
def pageStep(self) -> float: # type: ignore[override]
return self._post_get_hook(super().pageStep())
def setPageStep(self, step: float) -> None:
super().setPageStep(self._pre_set_hook(step))
def setRange(self, min: float, max: float) -> None:
super().setRange(self._pre_set_hook(min), self._pre_set_hook(max))
# def sliderChange(self, change) -> None:
# if change == QSlider.SliderValueChange:
# self.valueChanged.emit(self.value())
# if change == QSlider.SliderRangeChange:
# self.rangeChanged.emit(self.minimum(), self.maximum())
# return super().sliderChange(change)

View File

@@ -1,577 +0,0 @@
import textwrap
from collections import abc
from typing import List, Sequence, Tuple
from ._hooked import _HookedSlider
from ._style import RangeSliderStyle, update_styles_from_stylesheet
from .qtcompat import QtGui
from .qtcompat.QtCore import (
Property,
QEvent,
QPoint,
QPointF,
QRect,
QRectF,
Qt,
Signal,
)
from .qtcompat.QtWidgets import (
QApplication,
QSlider,
QStyle,
QStyleOptionSlider,
QStylePainter,
)
ControlType = Tuple[str, int]
class QRangeSlider(_HookedSlider, QSlider):
"""MultiHandle Range Slider widget.
Same API as QSlider, but `value`, `setValue`, `sliderPosition`, and
`setSliderPosition` are all sequences of integers.
The `valueChanged` and `sliderMoved` signals also both emit a tuple of
integers.
"""
# Emitted when the slider value has changed, with the new slider values
valueChanged = Signal(tuple)
# Emitted when sliderDown is true and the slider moves
# This usually happens when the user is dragging the slider
# The value is the positions of *all* handles.
sliderMoved = Signal(tuple)
_NULL_CTRL = ("None", -1)
def __init__(self, *args):
super().__init__(*args)
# list of values
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] = [20, 80]
self._pressedControl: ControlType = self._NULL_CTRL
self._hoverControl: ControlType = self._NULL_CTRL
# whether bar length is constant when dragging the bar
# if False, the bar can shorten when dragged beyond min/max
self._bar_is_rigid = True
# whether clicking on the bar moves all handles, or just the nearest handle
self._bar_moves_all = True
self._should_draw_bar = True
# for keyboard nav
self._repeatMultiplier = 1 # TODO
# for wheel nav
self._offset_accum = 0
# 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)
def setValue(self, val: Sequence[int]) -> None:
"""Set current value of the widget with a sequence of integers.
The number of handles will be equal to the length of the sequence
"""
if not (isinstance(val, abc.Sequence) and len(val) >= 2):
raise ValueError("value must be iterable of len >= 2")
val = [self._min_max_bound(v) for v in val]
if self._value == val and self._position == val:
return
self._value[:] = val[:]
if self._position != val:
self._position = val
if self.isSliderDown():
self.sliderMoved.emit(tuple(self._position))
self.sliderChange(QSlider.SliderValueChange)
self.valueChanged.emit(self.value())
def sliderPosition(self) -> Tuple[int, ...]:
"""Get current value of the widget as a tuple of integers.
If tracking is enabled (the default) this will be identical to value().
"""
return tuple(self._position)
def setSliderPosition(self, val: Sequence[int]) -> None:
"""Set current position of the handles with a sequence of integers.
The sequence must have the same length as `value()`.
"""
if len(val) != len(self.value()):
raise ValueError(
f"'sliderPosition' must have length of 'value()' ({len(self.value())})"
)
for i, v in enumerate(val):
self._setSliderPositionAt(i, v, _update=False)
self._updateSliderMove()
def barIsRigid(self) -> bool:
"""Whether bar length is constant when dragging the bar.
If False, the bar can shorten when dragged beyond min/max. Default is True.
"""
return self._bar_is_rigid
def setBarIsRigid(self, val: bool = True) -> None:
"""Whether bar length is constant when dragging the bar.
If False, the bar can shorten when dragged beyond min/max. Default is True.
"""
self._bar_is_rigid = bool(val)
def barMovesAllHandles(self) -> bool:
"""Whether clicking on the bar moves all handles (default), or just the nearest."""
return self._bar_moves_all
def setBarMovesAllHandles(self, val: bool = True) -> None:
"""Whether clicking on the bar moves all handles (default), or just the nearest."""
self._bar_moves_all = bool(val)
def barIsVisible(self) -> bool:
"""Whether to show the bar between the first and last handle."""
return self._should_draw_bar
def setBarVisible(self, val: bool = True) -> None:
"""Whether to show the bar between the first and last handle."""
self._should_draw_bar = bool(val)
def hideBar(self) -> None:
self.setBarVisible(False)
def showBar(self) -> None:
self.setBarVisible(True)
# ############### Implementation Details #######################
def _setSliderPositionAt(self, index: int, pos: int, _update=True) -> None:
pos = self._min_max_bound(pos)
# prevent sliders from moving beyond their neighbors
pos = self._neighbor_bound(pos, index, self._position)
if pos == self._position[index]:
return
self._position[index] = pos
if _update:
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
if self._bar_is_rigid:
# 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()
self.initStyleOption(opt)
opt.sliderValue = 0
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)
r_bar = self._barRect(opt)
if isinstance(brush, QtGui.QGradient):
brush.setStart(r_bar.topLeft())
brush.setFinalStop(r_bar.bottomRight())
painter.setPen(self._style.pen(opt))
painter.setBrush(brush)
painter.drawRect(r_bar)
def paintEvent(self, a0: QtGui.QPaintEvent) -> None:
"""Paint the slider."""
# initialize painter and options
painter = QStylePainter(self)
opt = self._getStyleOption()
# draw groove and ticks
opt.subControls = QStyle.SC_SliderGroove | QStyle.SC_SliderTickmarks
painter.drawComplexControl(QStyle.CC_Slider, opt)
if self._should_draw_bar:
self._drawBar(painter, opt)
# draw handles
opt.subControls = QStyle.SC_SliderHandle
hidx = -1
pidx = -1
if self._pressedControl[0] == "handle":
pidx = self._pressedControl[1]
elif self._hoverControl[0] == "handle":
hidx = self._hoverControl[1]
for idx, pos in enumerate(self._position):
opt.sliderPosition = self._pre_set_hook(pos)
if idx == pidx: # make pressed handles appear sunken
opt.state |= QStyle.State_Sunken
else:
opt.state = opt.state & ~QStyle.State_Sunken
if idx == hidx:
opt.activeSubControls = QStyle.SC_SliderHandle
else:
opt.activeSubControls = QStyle.SC_None
painter.drawComplexControl(QStyle.CC_Slider, opt)
def event(self, ev: QEvent) -> bool:
if ev.type() == QEvent.WindowActivate:
self.update()
if ev.type() == QEvent.StyleChange:
update_styles_from_stylesheet(self)
if ev.type() in (QEvent.HoverEnter, QEvent.HoverLeave, QEvent.HoverMove):
old_hover = self._hoverControl
self._hoverControl = self._getControlAtPos(ev.pos())
if self._hoverControl != old_hover:
self.update() # TODO: restrict to the rect of old_hover
return super().event(ev)
def mousePressEvent(self, ev: QtGui.QMouseEvent) -> None:
if self.minimum() == self.maximum() or ev.buttons() ^ ev.button():
ev.ignore()
return
ev.accept()
# FIXME: why not working on other styles?
# set_buttons = self.style().styleHint(QStyle.SH_Slider_AbsoluteSetButtons)
set_buttons = Qt.LeftButton | Qt.MiddleButton
# If the mouse button used is allowed to set the value
if ev.buttons() & set_buttons == ev.button():
opt = self._getStyleOption()
self._pressedControl = self._getControlAtPos(ev.pos(), opt, True)
if self._pressedControl[0] == "handle":
offset = self._handle_offset(opt)
new_pos = self._pixelPosToRangeValue(self._pick(ev.pos() - offset))
self._setSliderPositionAt(self._pressedControl[1], new_pos)
self.triggerAction(QSlider.SliderMove)
self.setRepeatAction(QSlider.SliderNoAction)
self.update()
if self._pressedControl[0] == "handle":
self.setRepeatAction(QSlider.SliderNoAction) # why again?
sr = self._handleRects(opt, self._pressedControl[1])
self._clickOffset = self._pick(ev.pos() - sr.topLeft())
self.update()
self.setSliderDown(True)
elif self._pressedControl[0] == "bar":
self.setRepeatAction(QSlider.SliderNoAction) # why again?
self._clickOffset = self._pixelPosToRangeValue(self._pick(ev.pos()))
self._sldPosAtPress = tuple(self._position)
self.update()
self.setSliderDown(True)
def mouseMoveEvent(self, ev: QtGui.QMouseEvent) -> None:
# TODO: add pixelMetric(QStyle::PM_MaximumDragDistance, &opt, this);
if self._pressedControl[0] == "handle":
ev.accept()
new = self._pixelPosToRangeValue(self._pick(ev.pos()) - self._clickOffset)
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)
else:
ev.ignore()
return
def mouseReleaseEvent(self, ev: QtGui.QMouseEvent) -> None:
if self._pressedControl[0] == "None" or ev.buttons():
ev.ignore()
return
ev.accept()
old_pressed = self._pressedControl
self._pressedControl = self._NULL_CTRL
self.setRepeatAction(QSlider.SliderNoAction)
if old_pressed[0] in ("handle", "bar"):
self.setSliderDown(False)
self.update() # TODO: restrict to the rect of old_pressed
def triggerAction(self, action: QSlider.SliderAction) -> None:
super().triggerAction(action) # TODO: probably need to override.
self.setValue(self._position)
def setRange(self, min: int, max: int) -> None:
super().setRange(min, max)
self.setValue(self._value) # re-bound
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
opt.sliderPosition = self._pre_set_hook(self._position[handle_index])
return style.subControlRect(
QStyle.CC_Slider, opt, QStyle.SC_SliderHandle, self
)
else:
rects = []
for p in self._position:
opt.sliderPosition = self._pre_set_hook(p)
r = style.subControlRect(
QStyle.CC_Slider, opt, QStyle.SC_SliderHandle, self
)
rects.append(r)
return rects
def _grooveRect(self, opt: QStyleOptionSlider) -> QRect:
"""Return the QRect for the slider groove."""
style = self.style().proxy()
return style.subControlRect(QStyle.CC_Slider, opt, QStyle.SC_SliderGroove, self)
def _barRect(self, opt: QStyleOptionSlider, r_groove: QRect = None) -> QRect:
"""Return the QRect for the bar between the outer handles."""
if r_groove is None:
r_groove = self._grooveRect(opt)
r_bar = QRectF(r_groove)
hdl_low, *_, hdl_high = self._handleRects(opt)
thickness = self._style.thickness(opt)
offset = self._style.offset(opt)
if opt.orientation == Qt.Horizontal:
r_bar.setTop(r_bar.center().y() - thickness / 2 + offset)
r_bar.setHeight(thickness)
r_bar.setLeft(hdl_low.center().x())
r_bar.setRight(hdl_high.center().x())
else:
r_bar.setLeft(r_bar.center().x() - thickness / 2 + offset)
r_bar.setWidth(thickness)
r_bar.setBottom(hdl_low.center().y())
r_bar.setTop(hdl_high.center().y())
return r_bar
def _getControlAtPos(
self, pos: QPoint, opt: QStyleOptionSlider = None, closest_handle=False
) -> ControlType:
"""Update self._pressedControl based on ev.pos()."""
if not opt:
opt = self._getStyleOption()
event_position = self._pick(pos)
bar_idx = 0
hdl_idx = 0
dist = float("inf")
if isinstance(pos, QPointF):
pos = QPoint(pos.x(), pos.y())
# TODO: this should be reversed, to prefer higher value handles
for i, hdl in enumerate(self._handleRects(opt)):
if hdl.contains(pos):
return ("handle", i) # TODO: use enum for 'handle'
hdl_center = self._pick(hdl.center())
abs_dist = abs(event_position - hdl_center)
if abs_dist < dist:
dist = abs_dist
hdl_idx = i
if event_position > hdl_center:
bar_idx += 1
else:
if closest_handle:
if bar_idx == 0:
# the click was below the minimum slider
return ("handle", 0)
elif bar_idx == len(self._position):
# the click was above the maximum slider
return ("handle", len(self._position) - 1)
if self._bar_moves_all:
# the click was in an internal segment
return ("bar", bar_idx)
elif closest_handle:
return ("handle", hdl_idx)
return self._NULL_CTRL
def _handle_offset(self, opt: QStyleOptionSlider) -> QPoint:
# to take half of the slider off for the setSliderPosition call we use the
# center - topLeft
handle_rect = self._handleRects(opt, 0)
return handle_rect.center() - handle_rect.topLeft()
# from QSliderPrivate::pixelPosToRangeValue
def _pixelPosToRangeValue(self, pos: int, opt: QStyleOptionSlider = None) -> int:
if not opt:
opt = self._getStyleOption()
groove_rect = self._grooveRect(opt)
handle_rect = self._handleRects(opt, 0)
if self.orientation() == Qt.Horizontal:
sliderLength = handle_rect.width()
sliderMin = groove_rect.x()
sliderMax = groove_rect.right() - sliderLength + 1
else:
sliderLength = handle_rect.height()
sliderMin = groove_rect.y()
sliderMax = groove_rect.bottom() - sliderLength + 1
v = QStyle.sliderValueFromPosition(
opt.minimum,
opt.maximum,
pos - sliderMin,
sliderMax - sliderMin,
opt.upsideDown,
)
return self._post_get_hook(v)
def _pick(self, pt: QPoint) -> int:
return pt.x() if self.orientation() == Qt.Horizontal else pt.y()
def _min_max_bound(self, val: int) -> int:
return _bound(self.minimum(), self.maximum(), val)
def _neighbor_bound(self, val: int, index: int, _lst: List[int]) -> int:
# make sure we don't go lower than any preceding index:
if index > 0:
val = max(_lst[index - 1], val)
# make sure we don't go higher than any following index:
if index < len(_lst) - 1:
val = min(_lst[index + 1], val)
return val
def wheelEvent(self, e: QtGui.QWheelEvent) -> None:
e.ignore()
vertical = bool(e.angleDelta().y())
delta = e.angleDelta().y() if vertical else e.angleDelta().x()
if e.inverted():
delta *= -1
orientation = Qt.Vertical if vertical else Qt.Horizontal
if self._scrollByDelta(orientation, e.modifiers(), delta):
e.accept()
def _scrollByDelta(
self, orientation, modifiers: Qt.KeyboardModifiers, delta: int
) -> bool:
steps_to_scroll = 0
pg_step = self.pageStep()
# in Qt scrolling to the right gives negative values.
if orientation == Qt.Horizontal:
delta *= -1
offset = delta / 120
if modifiers & Qt.ControlModifier or modifiers & Qt.ShiftModifier:
# Scroll one page regardless of delta:
steps_to_scroll = _bound(-pg_step, pg_step, int(offset * pg_step))
self._offset_accum = 0
else:
# Calculate how many lines to scroll. Depending on what delta is (and
# offset), we might end up with a fraction (e.g. scroll 1.3 lines). We can
# only scroll whole lines, so we keep the reminder until next event.
wheel_scroll_lines = QApplication.wheelScrollLines()
steps_to_scrollF = wheel_scroll_lines * offset * self._effectiveSingleStep()
# Check if wheel changed direction since last event:
if self._offset_accum != 0 and (offset / self._offset_accum) < 0:
self._offset_accum = 0
self._offset_accum += steps_to_scrollF
# Don't scroll more than one page in any case:
steps_to_scroll = _bound(-pg_step, pg_step, int(self._offset_accum))
self._offset_accum -= int(self._offset_accum)
if steps_to_scroll == 0:
# We moved less than a line, but might still have accumulated partial
# scroll, unless we already are at one of the ends.
effective_offset = self._offset_accum
if self.invertedControls():
effective_offset *= -1
if effective_offset > 0 and max(self._value) < self.maximum():
return True
if effective_offset < 0 and min(self._value) < self.minimum():
return True
self._offset_accum = 0
return False
if self.invertedControls():
steps_to_scroll *= -1
_prev_value = self.value()
if modifiers & Qt.AltModifier:
self._spreadAllPositions(shrink=steps_to_scroll < 0)
else:
self._offsetAllPositions(self._post_get_hook(steps_to_scroll))
self.triggerAction(QSlider.SliderMove)
if _prev_value == self.value():
self._offset_accum = 0
return False
return True
def _effectiveSingleStep(self) -> int:
return self.singleStep() * self._repeatMultiplier
def keyPressEvent(self, ev: QtGui.QKeyEvent) -> None:
return # TODO
def _bound(min_: int, max_: int, value: int) -> int:
"""Return value bounded by min_ and max_."""
return max(min_, min(max_, value))
QRangeSlider.__doc__ += "\n" + textwrap.indent(QSlider.__doc__, " ")

View File

@@ -1,115 +0,0 @@
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

@@ -1,18 +1,13 @@
[metadata]
name = QtRangeSlider
url = https://github.com/tlambert03/QtRangeSlider
license = BSD-3
license_file = LICENSE
description = Multi-handle range slider widget for PyQt/PySide
long_description = file: README.md, CHANGELOG.md
name = superqt
description = Missing widgets for PyQt/PySide
long_description = file: README.md
long_description_content_type = text/markdown
url = https://github.com/napari/superqt
author = Talley Lambert
author_email = talley.lambert@gmail.com
keywords = qt, range slider, widget
project_urls =
Source = https://github.com/tlambert03/QtRangeSlider
Tracker = https://github.com/tlambert03/QtRangeSlider/issues
Changelog = https://github.com/tlambert03/QtRangeSlider/blob/master/CHANGELOG.md
license = BSD-3-Clause
license_file = LICENSE
classifiers =
Development Status :: 4 - Beta
Environment :: X11 Applications :: Qt
@@ -20,49 +15,69 @@ classifiers =
License :: OSI Approved :: BSD License
Operating System :: OS Independent
Programming Language :: Python
Programming Language :: Python :: 3
Programming Language :: Python :: 3 :: Only
Programming Language :: Python :: 3.6
Programming Language :: Python :: 3.7
Programming Language :: Python :: 3.8
Programming Language :: Python :: 3.9
Programming Language :: Python :: Implementation :: CPython
Topic :: Desktop Environment
Topic :: Software Development
Topic :: Software Development :: User Interfaces
Topic :: Software Development :: Widget Sets
keywords = qt, range slider, widget
project_urls =
Source = https://github.com/napari/superqt
Tracker = https://github.com/napari/superqt/issues
Changelog = https://github.com/napari/superqt/blob/master/CHANGELOG.md
[options]
zip_safe = False
packages = find:
python_requires = >=3.6
setup_requires = setuptools_scm
python_requires = >=3.7
setup_requires =
setuptools_scm
zip_safe = False
[options.extras_require]
pyside2 = pyside2
pyqt5 = pyqt5
pyside6 = pyside6
pyqt6 = pyqt6
testing =
tox
tox-conda
pytest
pytest-qt
pytest-cov
dev =
ipython
jedi<0.18.0
isort
jedi<0.18.0
mypy
pre-commit
%(testing)s
%(pyqt5)s
pyside2
pytest
pytest-cov
pytest-qt
tox
tox-conda
pyqt5 =
pyqt5
pyqt6 =
pyqt6
pyside2 =
pyside2
pyside6 =
pyside6
testing =
pytest
pytest-cov
pytest-qt
tox
tox-conda
[flake8]
exclude = _version.py,.eggs,examples
docstring-convention = numpy
ignore = E203,W503,E501,C901,F403,F405
ignore = E203,W503,E501,C901,F403,F405,D100
[pydocstyle]
convention = numpy
add_select = D402,D415,D417
ignore = D100
[isort]
profile=black
profile = black
[tool:pytest]
addopts = -W error

View File

@@ -1,10 +1,6 @@
"""
PEP 517 doesnt support editable installs
so this file is currently here to support "pip install -e ."
"""
from setuptools import setup
setup(
use_scm_version={"write_to": "qtrangeslider/_version.py"},
use_scm_version={"write_to": "superqt/_version.py"},
setup_requires=["setuptools_scm"],
)

View File

@@ -1,16 +1,20 @@
"""superqt is a collection of QtWidgets for python."""
try:
from ._version import version as __version__
except ImportError:
__version__ = "unknown"
from ._float_slider import QDoubleRangeSlider, QDoubleSlider
from ._labeled import (
from .sliders import (
QDoubleRangeSlider,
QDoubleSlider,
QLabeledDoubleRangeSlider,
QLabeledDoubleSlider,
QLabeledRangeSlider,
QLabeledSlider,
QRangeSlider,
)
from ._qrangeslider import QRangeSlider
from .spinbox import QLargeIntSpinBox
__all__ = [
"QDoubleRangeSlider",
@@ -19,5 +23,6 @@ __all__ = [
"QLabeledDoubleSlider",
"QLabeledRangeSlider",
"QLabeledSlider",
"QLargeIntSpinBox",
"QRangeSlider",
]

View File

@@ -1,4 +1,3 @@
# -*- coding: utf-8 -*-
#
# Copyright © 2014-2015 Colin Duquesnoy
# Copyright © 2009- The Spyder Development Team

View File

@@ -1,4 +1,3 @@
# -*- coding: utf-8 -*-
#
# Copyright © 2014-2015 Colin Duquesnoy
# Copyright © 2009- The Spyder Development Team

View File

@@ -1,4 +1,3 @@
# -*- coding: utf-8 -*-
#
# Copyright © 2014-2015 Colin Duquesnoy
# Copyright © 2009- The Spyder Developmet Team

View File

@@ -1,4 +1,3 @@
# -*- coding: utf-8 -*-
#
# Copyright © 2009- The Spyder Development Team
# Copyright © 2014-2015 Colin Duquesnoy
@@ -147,7 +146,7 @@ if API in PYSIDE6_API:
if API is None:
raise PythonQtError(
"No Qt bindings could be found.\nYou must install one of the following packages "
"to use QtRangeSlider: PyQt5, PyQt6, PySide2, or PySide6"
"to use superqt: PyQt5, PyQt6, PySide2, or PySide6"
)
# If a correct API name is passed to QT_API and it could not be found,

View File

@@ -0,0 +1,17 @@
from ._labeled import (
QLabeledDoubleRangeSlider,
QLabeledDoubleSlider,
QLabeledRangeSlider,
QLabeledSlider,
)
from ._sliders import QDoubleRangeSlider, QDoubleSlider, QRangeSlider
__all__ = [
"QDoubleRangeSlider",
"QDoubleSlider",
"QLabeledDoubleRangeSlider",
"QLabeledDoubleSlider",
"QLabeledRangeSlider",
"QLabeledSlider",
"QRangeSlider",
]

View File

@@ -0,0 +1,336 @@
from typing import Generic, List, Sequence, Tuple, TypeVar, Union
from ..qtcompat import QtGui
from ..qtcompat.QtCore import (
Property,
QEvent,
QPoint,
QPointF,
QRect,
QRectF,
Qt,
Signal,
)
from ..qtcompat.QtWidgets import QSlider, QStyle, QStyleOptionSlider, QStylePainter
from ._generic_slider import CC_SLIDER, SC_GROOVE, SC_HANDLE, SC_NONE, _GenericSlider
from ._range_style import RangeSliderStyle, update_styles_from_stylesheet
_T = TypeVar("_T")
SC_BAR = QStyle.SubControl.SC_ScrollBarSubPage
class _GenericRangeSlider(_GenericSlider[Tuple], Generic[_T]):
"""MultiHandle Range Slider widget.
Same API as QSlider, but `value`, `setValue`, `sliderPosition`, and
`setSliderPosition` are all sequences of integers.
The `valueChanged` and `sliderMoved` signals also both emit a tuple of
integers.
"""
# Emitted when the slider value has changed, with the new slider values
valueChanged = Signal(tuple)
# Emitted when sliderDown is true and the slider moves
# This usually happens when the user is dragging the slider
# The value is the positions of *all* handles.
sliderMoved = Signal(tuple)
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
# list of values
self._value: List[_T] = [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[_T] = [20, 80]
# which handle is being pressed/hovered
self._pressedIndex = 0
self._hoverIndex = 0
# whether bar length is constant when dragging the bar
# if False, the bar can shorten when dragged beyond min/max
self._bar_is_rigid = True
# whether clicking on the bar moves all handles, or just the nearest handle
self._bar_moves_all = True
self._should_draw_bar = True
# color
self._style = RangeSliderStyle()
self.setStyleSheet("")
update_styles_from_stylesheet(self)
# ############### New Public API #######################
def barIsRigid(self) -> bool:
"""Whether bar length is constant when dragging the bar.
If False, the bar can shorten when dragged beyond min/max. Default is True.
"""
return self._bar_is_rigid
def setBarIsRigid(self, val: bool = True) -> None:
"""Whether bar length is constant when dragging the bar.
If False, the bar can shorten when dragged beyond min/max. Default is True.
"""
self._bar_is_rigid = bool(val)
def barMovesAllHandles(self) -> bool:
"""Whether clicking on the bar moves all handles (default), or just the nearest."""
return self._bar_moves_all
def setBarMovesAllHandles(self, val: bool = True) -> None:
"""Whether clicking on the bar moves all handles (default), or just the nearest."""
self._bar_moves_all = bool(val)
def barIsVisible(self) -> bool:
"""Whether to show the bar between the first and last handle."""
return self._should_draw_bar
def setBarVisible(self, val: bool = True) -> None:
"""Whether to show the bar between the first and last handle."""
self._should_draw_bar = bool(val)
def hideBar(self) -> None:
self.setBarVisible(False)
def showBar(self) -> None:
self.setBarVisible(True)
# ############### QtOverrides #######################
def value(self) -> Tuple[_T, ...]:
"""Get current value of the widget as a tuple of integers."""
return tuple(self._value)
def sliderPosition(self):
"""Get current value of the widget as a tuple of integers.
If tracking is enabled (the default) this will be identical to value().
"""
return tuple(float(i) for i in self._position)
def setSliderPosition(self, pos: Union[float, Sequence[float]], index=None) -> 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
"""
if isinstance(pos, (list, tuple)):
val_len = len(self.value())
if len(pos) != val_len:
msg = f"'sliderPosition' must have same length as 'value()' ({val_len})"
raise ValueError(msg)
pairs = list(enumerate(pos))
else:
pairs = [(self._pressedIndex if index is None else index, pos)]
for idx, position in pairs:
self._position[idx] = self._bound(position, idx)
self._doSliderMove()
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 event(self, ev: QEvent) -> bool:
if ev.type() == QEvent.StyleChange:
update_styles_from_stylesheet(self)
return super().event(ev)
def mouseMoveEvent(self, ev: QtGui.QMouseEvent) -> None:
if self._pressedControl == SC_BAR:
ev.accept()
delta = self._clickOffset - self._pixelPosToRangeValue(self._pick(ev.pos()))
self._offsetAllPositions(-delta, self._sldPosAtPress)
else:
super().mouseMoveEvent(ev)
# ############### Implementation Details #######################
def _setPosition(self, val):
self._position = list(val)
def _bound(self, value, index=None):
if isinstance(value, (list, tuple)):
return type(value)(self._bound(v) for v in value)
pos = super()._bound(value)
if index is not None:
pos = self._neighbor_bound(pos, index)
return self._type_cast(pos)
def _neighbor_bound(self, val, index):
# make sure we don't go lower than any preceding index:
min_dist = self.singleStep()
_lst = self._position
if index > 0:
val = max(_lst[index - 1] + min_dist, val)
# make sure we don't go higher than any following index:
if index < (len(_lst) - 1):
val = min(_lst[index + 1] - min_dist, val)
return val
def _getBarColor(self):
return self._style.brush(self._styleOption)
def _setBarColor(self, color):
self._style.brush_active = color
barColor = Property(QtGui.QBrush, _getBarColor, _setBarColor)
def _offsetAllPositions(self, offset: float, ref=None) -> None:
if ref is None:
ref = self._position
if self._bar_is_rigid:
# 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 _fixStyleOption(self, option):
pass
@property
def _optSliderPositions(self):
return [self._to_qinteger_space(p - self._minimum) for p in self._position]
# SubControl Positions
def _handleRect(self, handle_index: int, opt: QStyleOptionSlider = None) -> QRect:
"""Return the QRect for all handles."""
opt = opt or self._styleOption
opt.sliderPosition = self._optSliderPositions[handle_index]
return self.style().subControlRect(CC_SLIDER, opt, SC_HANDLE, self)
def _barRect(self, opt: QStyleOptionSlider) -> QRect:
"""Return the QRect for the bar between the outer handles."""
r_groove = self.style().subControlRect(CC_SLIDER, opt, SC_GROOVE, self)
r_bar = QRectF(r_groove)
hdl_low, hdl_high = self._handleRect(0, opt), self._handleRect(-1, opt)
thickness = self._style.thickness(opt)
offset = self._style.offset(opt)
if opt.orientation == Qt.Horizontal:
r_bar.setTop(r_bar.center().y() - thickness / 2 + offset)
r_bar.setHeight(thickness)
r_bar.setLeft(hdl_low.center().x())
r_bar.setRight(hdl_high.center().x())
else:
r_bar.setLeft(r_bar.center().x() - thickness / 2 + offset)
r_bar.setWidth(thickness)
r_bar.setBottom(hdl_low.center().y())
r_bar.setTop(hdl_high.center().y())
return r_bar
# Painting
def _drawBar(self, painter: QStylePainter, opt: QStyleOptionSlider):
brush = self._style.brush(opt)
r_bar = self._barRect(opt)
if isinstance(brush, QtGui.QGradient):
brush.setStart(r_bar.topLeft())
brush.setFinalStop(r_bar.bottomRight())
painter.setPen(self._style.pen(opt))
painter.setBrush(brush)
painter.drawRect(r_bar)
def _draw_handle(self, painter: QStylePainter, opt: QStyleOptionSlider):
if self._should_draw_bar:
self._drawBar(painter, opt)
opt.subControls = SC_HANDLE
pidx = self._pressedIndex if self._pressedControl == SC_HANDLE else -1
hidx = self._hoverIndex if self._hoverControl == SC_HANDLE else -1
for idx, pos in enumerate(self._optSliderPositions):
opt.sliderPosition = pos
# make pressed handles appear sunken
if idx == pidx:
opt.state |= QStyle.State_Sunken
else:
opt.state = opt.state & ~QStyle.State_Sunken
opt.activeSubControls = SC_HANDLE if idx == hidx else SC_NONE
painter.drawComplexControl(CC_SLIDER, opt)
def _updateHoverControl(self, pos):
old_hover = self._hoverControl, self._hoverIndex
self._hoverControl, self._hoverIndex = self._getControlAtPos(pos)
if (self._hoverControl, self._hoverIndex) != old_hover:
self.update()
def _updatePressedControl(self, pos):
opt = self._styleOption
self._pressedControl, self._pressedIndex = self._getControlAtPos(pos, opt)
def _setClickOffset(self, pos):
if self._pressedControl == SC_BAR:
self._clickOffset = self._pixelPosToRangeValue(self._pick(pos))
self._sldPosAtPress = tuple(self._position)
elif self._pressedControl == SC_HANDLE:
hr = self._handleRect(self._pressedIndex)
self._clickOffset = self._pick(pos - hr.topLeft())
# NOTE: this is very much tied to mousepress... not a generic "get control"
def _getControlAtPos(
self, pos: QPoint, opt: QStyleOptionSlider = None
) -> Tuple[QStyle.SubControl, int]:
"""Update self._pressedControl based on ev.pos()."""
opt = opt or self._styleOption
if isinstance(pos, QPointF):
pos = pos.toPoint()
for i in range(len(self._position)):
if self._handleRect(i, opt).contains(pos):
return (SC_HANDLE, i)
click_pos = self._pixelPosToRangeValue(self._pick(pos))
for i, p in enumerate(self._position):
if p > click_pos:
if i > 0:
# the click was in an internal segment
if self._bar_moves_all:
return (SC_BAR, i)
avg = (self._position[i - 1] + self._position[i]) / 2
return (SC_HANDLE, i - 1 if click_pos < avg else i)
# the click was below the minimum slider
return (SC_HANDLE, 0)
# the click was above the maximum slider
return (SC_HANDLE, len(self._position) - 1)
def _execute_scroll(self, steps_to_scroll, modifiers):
if modifiers & Qt.AltModifier:
self._spreadAllPositions(shrink=steps_to_scroll < 0)
else:
self._offsetAllPositions(steps_to_scroll)
self.triggerAction(QSlider.SliderMove)
def _has_scroll_space_left(self, offset):
return (offset > 0 and max(self._value) < self._maximum) or (
offset < 0 and min(self._value) < self._minimum
)
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])

View File

@@ -0,0 +1,488 @@
"""Generic Sliders with internal python-based models
This module reimplements most of the logic from qslider.cpp in python:
https://code.woboq.org/qt5/qtbase/src/widgets/widgets/qslider.cpp.html
This probably looks like tremendous overkill at first (and it may be!),
since a it's possible to acheive a very reasonable "float slider" by
scaling input float values to some internal integer range for the QSlider,
and converting back to float when getting `value()`. However, one still
runs into overflow limitations due to the internal integer model.
In order to circumvent them, one needs to reimplement more and more of
the attributes from QSliderPrivate in order to have the slider behave
like a native slider (with all of the proper signals and options).
So that's what `_GenericSlider` is below.
`_GenericRangeSlider` is a variant that expects `value()` and
`sliderPosition()` to be a sequence of scalars rather than a single
scalar (with one handle per item), and it forms the basis of
QRangeSlider.
"""
from typing import Generic, TypeVar
from ..qtcompat import QtGui
from ..qtcompat.QtCore import QEvent, QPoint, QPointF, QRect, Qt, Signal
from ..qtcompat.QtWidgets import (
QApplication,
QSlider,
QStyle,
QStyleOptionSlider,
QStylePainter,
)
_T = TypeVar("_T")
SC_NONE = QStyle.SubControl.SC_None
SC_HANDLE = QStyle.SubControl.SC_SliderHandle
SC_GROOVE = QStyle.SubControl.SC_SliderGroove
SC_TICKMARKS = QStyle.SubControl.SC_SliderTickmarks
CC_SLIDER = QStyle.ComplexControl.CC_Slider
QOVERFLOW = 2 ** 31 - 1
class _GenericSlider(QSlider, Generic[_T]):
valueChanged = Signal(float)
sliderMoved = Signal(float)
rangeChanged = Signal(float, float)
MAX_DISPLAY = 5000
def __init__(self, *args, **kwargs) -> None:
self._minimum = 0.0
self._maximum = 99.0
self._pageStep = 10.0
self._value: _T = 0.0 # type: ignore
self._position: _T = 0.0
self._singleStep = 1.0
self._offsetAccumulated = 0.0
self._blocktracking = False
self._tickInterval = 0.0
self._pressedControl = SC_NONE
self._hoverControl = SC_NONE
self._hoverRect = QRect()
self._clickOffset = 0.0
# for keyboard nav
self._repeatMultiplier = 1 # TODO
# for wheel nav
self._offset_accum = 0.0
# fraction of total range to scroll when holding Ctrl while scrolling
self._control_fraction = 0.04
super().__init__(*args, **kwargs)
self.setAttribute(Qt.WA_Hover)
# ############### QtOverrides #######################
def value(self) -> _T: # type: ignore
return self._value
def setValue(self, value: _T) -> None:
value = self._bound(value)
if self._value == value and self._position == value:
return
self._value = value
if self._position != value:
self._setPosition(value)
if self.isSliderDown():
self.sliderMoved.emit(self.sliderPosition())
self.sliderChange(self.SliderChange.SliderValueChange)
self.valueChanged.emit(self.value())
def sliderPosition(self) -> _T: # type: ignore
return self._position
def setSliderPosition(self, pos: _T) -> None:
position = self._bound(pos)
if position == self._position:
return
self._setPosition(position)
self._doSliderMove()
def singleStep(self) -> float: # type: ignore
return self._singleStep
def setSingleStep(self, step: float) -> None:
if step != self._singleStep:
self._setSteps(step, self._pageStep)
def pageStep(self) -> float: # type: ignore
return self._pageStep
def setPageStep(self, step: float) -> None:
if step != self._pageStep:
self._setSteps(self._singleStep, step)
def minimum(self) -> float: # type: ignore
return self._minimum
def setMinimum(self, min: float) -> None:
self.setRange(min, max(self._maximum, min))
def maximum(self) -> float: # type: ignore
return self._maximum
def setMaximum(self, max: float) -> None:
self.setRange(min(self._minimum, max), max)
def setRange(self, min: float, max_: float) -> None:
oldMin, self._minimum = self._minimum, float(min)
oldMax, self._maximum = self._maximum, float(max(min, max_))
if oldMin != self._minimum or oldMax != self._maximum:
self.sliderChange(self.SliderRangeChange)
self.rangeChanged.emit(self._minimum, self._maximum)
self.setValue(self._value) # re-bound
def tickInterval(self) -> float: # type: ignore
return self._tickInterval
def setTickInterval(self, ts: float) -> None:
self._tickInterval = max(0.0, ts)
self.update()
def triggerAction(self, action: QSlider.SliderAction) -> None:
self._blocktracking = True
# other actions here
# self.actionTriggered.emit(action) # FIXME: type not working for all Qt
self._blocktracking = False
self.setValue(self._position)
def initStyleOption(self, option: QStyleOptionSlider) -> None:
option.initFrom(self)
option.subControls = SC_NONE
option.activeSubControls = SC_NONE
option.orientation = self.orientation()
option.tickPosition = self.tickPosition()
option.upsideDown = (
self.invertedAppearance() != (option.direction == Qt.RightToLeft)
if self.orientation() == Qt.Horizontal
else not self.invertedAppearance()
)
option.direction = Qt.LeftToRight # we use the upsideDown option instead
# option.sliderValue = self._value # type: ignore
# option.singleStep = self._singleStep # type: ignore
if self.orientation() == Qt.Horizontal:
option.state |= QStyle.State_Horizontal
# scale style option to integer space
option.minimum = 0
option.maximum = self.MAX_DISPLAY
option.tickInterval = self._to_qinteger_space(self._tickInterval)
option.pageStep = self._to_qinteger_space(self._pageStep)
option.singleStep = self._to_qinteger_space(self._singleStep)
self._fixStyleOption(option)
def event(self, ev: QEvent) -> bool:
if ev.type() == QEvent.WindowActivate:
self.update()
elif ev.type() in (QEvent.HoverEnter, QEvent.HoverMove):
self._updateHoverControl(_event_position(ev))
elif ev.type() == QEvent.HoverLeave:
self._hoverControl = SC_NONE
lastHoverRect, self._hoverRect = self._hoverRect, QRect()
self.update(lastHoverRect)
return super().event(ev)
def mousePressEvent(self, ev: QtGui.QMouseEvent) -> None:
if self._minimum == self._maximum or ev.buttons() ^ ev.button():
ev.ignore()
return
ev.accept()
pos = _event_position(ev)
# If the mouse button used is allowed to set the value
if ev.button() in (Qt.LeftButton, Qt.MiddleButton):
self._updatePressedControl(pos)
if self._pressedControl == SC_HANDLE:
opt = self._styleOption
sr = self.style().subControlRect(CC_SLIDER, opt, SC_HANDLE, self)
offset = sr.center() - sr.topLeft()
new_pos = self._pixelPosToRangeValue(self._pick(pos - offset))
self.setSliderPosition(new_pos)
self.triggerAction(QSlider.SliderMove)
self.setRepeatAction(QSlider.SliderNoAction)
self.update()
# elif: deal with PageSetButtons
else:
ev.ignore()
if self._pressedControl != SC_NONE:
self.setRepeatAction(QSlider.SliderNoAction)
self._setClickOffset(pos)
self.update()
self.setSliderDown(True)
def mouseMoveEvent(self, ev: QtGui.QMouseEvent) -> None:
# TODO: add pixelMetric(QStyle::PM_MaximumDragDistance, &opt, this);
if self._pressedControl == SC_NONE:
ev.ignore()
return
ev.accept()
pos = self._pick(_event_position(ev))
newPosition = self._pixelPosToRangeValue(pos - self._clickOffset)
self.setSliderPosition(newPosition)
def mouseReleaseEvent(self, ev: QtGui.QMouseEvent) -> None:
if self._pressedControl == SC_NONE or ev.buttons():
ev.ignore()
return
ev.accept()
oldPressed = self._pressedControl
self._pressedControl = SC_NONE
self.setRepeatAction(QSlider.SliderNoAction)
if oldPressed != SC_NONE:
self.setSliderDown(False)
self.update()
def wheelEvent(self, e: QtGui.QWheelEvent) -> None:
e.ignore()
vertical = bool(e.angleDelta().y())
delta = e.angleDelta().y() if vertical else e.angleDelta().x()
if e.inverted():
delta *= -1
orientation = Qt.Vertical if vertical else Qt.Horizontal
if self._scrollByDelta(orientation, e.modifiers(), delta):
e.accept()
def paintEvent(self, ev: QtGui.QPaintEvent) -> None:
painter = QStylePainter(self)
opt = self._styleOption
# draw groove and ticks
opt.subControls = SC_GROOVE
if opt.tickPosition != QSlider.NoTicks:
opt.subControls |= SC_TICKMARKS
painter.drawComplexControl(CC_SLIDER, opt)
self._draw_handle(painter, opt)
# ############### Implementation Details #######################
def _type_cast(self, val):
return val
def _setPosition(self, val):
self._position = val
def _bound(self, value: _T) -> _T:
return self._type_cast(max(self._minimum, min(self._maximum, value)))
def _fixStyleOption(self, option):
option.sliderPosition = self._to_qinteger_space(self._position - self._minimum)
option.sliderValue = self._to_qinteger_space(self._value - self._minimum)
def _to_qinteger_space(self, val, _max=None):
_max = _max or self.MAX_DISPLAY
return int(min(QOVERFLOW, val / (self._maximum - self._minimum) * _max))
def _pick(self, pt: QPoint) -> int:
return pt.x() if self.orientation() == Qt.Horizontal else pt.y()
def _setSteps(self, single: float, page: float):
self._singleStep = single
self._pageStep = page
self.sliderChange(QSlider.SliderStepsChange)
def _doSliderMove(self):
if not self.hasTracking():
self.update()
if self.isSliderDown():
self.sliderMoved.emit(self.sliderPosition())
if self.hasTracking() and not self._blocktracking:
self.triggerAction(QSlider.SliderMove)
@property
def _styleOption(self):
opt = QStyleOptionSlider()
self.initStyleOption(opt)
return opt
def _updateHoverControl(self, pos: QPoint) -> bool:
lastHoverRect = self._hoverRect
lastHoverControl = self._hoverControl
doesHover = self.testAttribute(Qt.WA_Hover)
if lastHoverControl != self._newHoverControl(pos) and doesHover:
self.update(lastHoverRect)
self.update(self._hoverRect)
return True
return not doesHover
def _newHoverControl(self, pos: QPoint) -> QStyle.SubControl:
opt = self._styleOption
opt.subControls = QStyle.SubControl.SC_All
handleRect = self.style().subControlRect(CC_SLIDER, opt, SC_HANDLE, self)
grooveRect = self.style().subControlRect(CC_SLIDER, opt, SC_GROOVE, self)
tickmarksRect = self.style().subControlRect(CC_SLIDER, opt, SC_TICKMARKS, self)
if handleRect.contains(pos):
self._hoverRect = handleRect
self._hoverControl = SC_HANDLE
elif grooveRect.contains(pos):
self._hoverRect = grooveRect
self._hoverControl = SC_GROOVE
elif tickmarksRect.contains(pos):
self._hoverRect = tickmarksRect
self._hoverControl = SC_TICKMARKS
else:
self._hoverRect = QRect()
self._hoverControl = SC_NONE
return self._hoverControl
def _setClickOffset(self, pos: QPoint):
hr = self.style().subControlRect(CC_SLIDER, self._styleOption, SC_HANDLE, self)
self._clickOffset = self._pick(pos - hr.topLeft())
def _updatePressedControl(self, pos: QPoint):
self._pressedControl = SC_HANDLE
def _draw_handle(self, painter, opt):
opt.subControls = SC_HANDLE
if self._pressedControl:
opt.activeSubControls = self._pressedControl
opt.state |= QStyle.State_Sunken
else:
opt.activeSubControls = self._hoverControl
painter.drawComplexControl(CC_SLIDER, opt)
# from QSliderPrivate.pixelPosToRangeValue
def _pixelPosToRangeValue(self, pos: int) -> float:
opt = self._styleOption
gr = self.style().subControlRect(CC_SLIDER, opt, SC_GROOVE, self)
sr = self.style().subControlRect(CC_SLIDER, opt, SC_HANDLE, self)
if self.orientation() == Qt.Horizontal:
sliderLength = sr.width()
sliderMin = gr.x()
sliderMax = gr.right() - sliderLength + 1
else:
sliderLength = sr.height()
sliderMin = gr.y()
sliderMax = gr.bottom() - sliderLength + 1
return _sliderValueFromPosition(
self._minimum,
self._maximum,
pos - sliderMin,
sliderMax - sliderMin,
opt.upsideDown,
)
def _scrollByDelta(self, orientation, modifiers, delta: int) -> bool:
steps_to_scroll = 0.0
pg_step = self._pageStep
# in Qt scrolling to the right gives negative values.
if orientation == Qt.Horizontal:
delta *= -1
offset = delta / 120
if modifiers & Qt.ShiftModifier:
# Scroll one page regardless of delta:
steps_to_scroll = max(-pg_step, min(pg_step, offset * pg_step))
self._offset_accum = 0
elif modifiers & Qt.ControlModifier:
_range = self._maximum - self._minimum
steps_to_scroll = offset * _range * self._control_fraction
self._offset_accum = 0
else:
# Calculate how many lines to scroll. Depending on what delta is (and
# offset), we might end up with a fraction (e.g. scroll 1.3 lines). We can
# only scroll whole lines, so we keep the reminder until next event.
wheel_scroll_lines = QApplication.wheelScrollLines()
steps_to_scrollF = wheel_scroll_lines * offset * self._effectiveSingleStep()
# Check if wheel changed direction since last event:
if self._offset_accum != 0 and (offset / self._offset_accum) < 0:
self._offset_accum = 0
self._offset_accum += steps_to_scrollF
# Don't scroll more than one page in any case:
steps_to_scroll = max(-pg_step, min(pg_step, self._offset_accum))
self._offset_accum -= self._offset_accum
if steps_to_scroll == 0:
# We moved less than a line, but might still have accumulated partial
# scroll, unless we already are at one of the ends.
effective_offset = self._offset_accum
if self.invertedControls():
effective_offset *= -1
if self._has_scroll_space_left(effective_offset):
return True
self._offset_accum = 0
return False
if self.invertedControls():
steps_to_scroll *= -1
prevValue = self._value
self._execute_scroll(steps_to_scroll, modifiers)
if prevValue == self._value:
self._offset_accum = 0
return False
return True
def _has_scroll_space_left(self, offset):
return (offset > 0 and self._value < self._maximum) or (
offset < 0 and self._value < self._minimum
)
def _execute_scroll(self, steps_to_scroll, modifiers):
self._setPosition(self._bound(self._overflowSafeAdd(steps_to_scroll)))
self.triggerAction(QSlider.SliderMove)
def _effectiveSingleStep(self) -> float:
return self._singleStep * self._repeatMultiplier
def _overflowSafeAdd(self, add: float) -> float:
newValue = self._value + add
if add > 0 and newValue < self._value:
newValue = self._maximum
elif add < 0 and newValue > self._value:
newValue = self._minimum
return newValue
# def keyPressEvent(self, ev: QtGui.QKeyEvent) -> None:
# return # TODO
def _event_position(ev: QEvent) -> QPoint:
# safe for Qt6, Qt5, and hoverEvent
evp = getattr(ev, "position", getattr(ev, "pos", None))
pos = evp() if evp else QPoint()
if isinstance(pos, QPointF):
pos = pos.toPoint()
return pos
def _sliderValueFromPosition(
min: float, max: float, position: int, span: int, upsideDown: bool = False
) -> float:
"""Converts the given pixel `position` to a value.
0 maps to the `min` parameter, `span` maps to `max` and other values are
distributed evenly in-between.
By default, this function assumes that the maximum value is on the right
for horizontal items and on the bottom for vertical items. Set the
`upsideDown` parameter to True to reverse this behavior.
"""
if span <= 0 or position <= 0:
return max if upsideDown else min
if position >= span:
return min if upsideDown else max
range = max - min
tmp = min + position * range / span
return max - tmp if upsideDown else tmp + min

View File

@@ -1,11 +1,9 @@
from enum import IntEnum
from functools import partial
from ._float_slider import QDoubleRangeSlider, QDoubleSlider
from ._qrangeslider import QRangeSlider
from .qtcompat.QtCore import QPoint, QSize, Qt, Signal
from .qtcompat.QtGui import QFontMetrics, QValidator
from .qtcompat.QtWidgets import (
from ..qtcompat.QtCore import QPoint, QSize, Qt, Signal
from ..qtcompat.QtGui import QFontMetrics, QValidator
from ..qtcompat.QtWidgets import (
QAbstractSlider,
QApplication,
QDoubleSpinBox,
@@ -17,6 +15,7 @@ from .qtcompat.QtWidgets import (
QVBoxLayout,
QWidget,
)
from ._sliders import QDoubleRangeSlider, QDoubleSlider, QRangeSlider
class LabelPosition(IntEnum):
@@ -33,8 +32,8 @@ class EdgeLabelMode(IntEnum):
LabelIsValue = 2
class SliderProxy:
_slider: QAbstractSlider
class _SliderProxy:
_slider: QSlider
def value(self):
return self._slider.value()
@@ -42,6 +41,12 @@ class SliderProxy:
def setValue(self, value) -> None:
self._slider.setValue(value)
def sliderPosition(self):
return self._slider.sliderPosition()
def setSliderPosition(self, pos) -> None:
self._slider.setSliderPosition(pos)
def minimum(self):
return self._slider.minimum()
@@ -69,34 +74,60 @@ class SliderProxy:
def setRange(self, min, max) -> None:
self._slider.setRange(min, max)
def tickInterval(self):
return self._slider.tickInterval()
class QLabeledSlider(SliderProxy, QAbstractSlider):
def setTickInterval(self, interval) -> None:
self._slider.setTickInterval(interval)
def tickPosition(self):
return self._slider.tickPosition()
def setTickPosition(self, pos) -> None:
self._slider.setTickPosition(pos)
def _handle_overloaded_slider_sig(args, kwargs):
parent = None
orientation = Qt.Vertical
errmsg = (
"TypeError: arguments did not match any overloaded call:\n"
" QSlider(parent: QWidget = None)\n"
" QSlider(Qt.Orientation, parent: QWidget = None)"
)
if len(args) > 2:
raise TypeError(errmsg)
elif len(args) == 2:
if kwargs:
raise TypeError(errmsg)
orientation, parent = args
elif args:
if isinstance(args[0], QWidget):
if kwargs:
raise TypeError(errmsg)
parent = args[0]
else:
orientation = args[0]
parent = kwargs.get("parent", parent)
return parent, orientation
class QLabeledSlider(_SliderProxy, QAbstractSlider):
_slider_class = QSlider
_slider: QSlider
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]
def __init__(self, *args, **kwargs) -> None:
parent, orientation = _handle_overloaded_slider_sig(args, kwargs)
super().__init__(parent)
self._slider = self._slider_class()
self._slider.valueChanged.connect(self.valueChanged.emit)
self._label = SliderLabel(self._slider, connect=self._slider.setValue)
self._slider.rangeChanged.connect(self.rangeChanged.emit)
self._label = SliderLabel(self._slider, connect=self.setValue)
self._slider.valueChanged.connect(self.valueChanged.emit)
self._slider.valueChanged.connect(self._label.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):
@@ -113,7 +144,7 @@ class QLabeledSlider(SliderProxy, QAbstractSlider):
layout.addWidget(self._slider)
layout.addWidget(self._label)
self._label.setAlignment(Qt.AlignRight)
layout.setSpacing(10)
layout.setSpacing(6)
old_layout = self.layout()
if old_layout is not None:
@@ -129,36 +160,26 @@ class QLabeledDoubleSlider(QLabeledSlider):
valueChanged = Signal(float)
rangeChanged = Signal(float, float)
def __init__(self, *args) -> None:
super().__init__(*args)
def __init__(self, *args, **kwargs) -> None:
super().__init__(*args, **kwargs)
self.setDecimals(2)
def decimals(self) -> int:
return self._slider.decimals()
return self._label.decimals()
def setDecimals(self, prec: int):
self._slider.setDecimals(prec)
self._label.setDecimals(prec)
class QLabeledRangeSlider(SliderProxy, QAbstractSlider):
class QLabeledRangeSlider(_SliderProxy, QAbstractSlider):
valueChanged = Signal(tuple)
LabelPosition = LabelPosition
EdgeLabelMode = EdgeLabelMode
_slider_class = QRangeSlider
_slider: QRangeSlider
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]
def __init__(self, *args, **kwargs) -> None:
parent, orientation = _handle_overloaded_slider_sig(args, kwargs)
super().__init__(parent)
self.setAttribute(Qt.WA_ShowWithoutActivating)
self._handle_labels = []
@@ -230,7 +251,9 @@ class QLabeledRangeSlider(SliderProxy, QAbstractSlider):
horizontal = self.orientation() == Qt.Horizontal
labels_above = self._handle_label_position == LabelPosition.LabelsAbove
for label, rect in zip(self._handle_labels, self._slider._handleRects()):
last_edge = None
for i, label in enumerate(self._handle_labels):
rect = self._slider._handleRect(i)
dx = -label.width() / 2
dy = -label.height() / 2
if labels_above:
@@ -245,8 +268,16 @@ class QLabeledRangeSlider(SliderProxy, QAbstractSlider):
dx *= 3
pos = self._slider.mapToParent(rect.center())
pos += QPoint(int(dx + self.label_shift_x), int(dy + self.label_shift_y))
if last_edge is not None:
# prevent label overlap
if horizontal:
pos.setX(int(max(pos.x(), last_edge.x() + label.width() / 2 + 12)))
else:
pos.setY(int(min(pos.y(), last_edge.y() - label.height() / 2 - 4)))
label.move(pos)
last_edge = pos
label.clearFocus()
self.update()
def _min_label_edited(self, val):
if self._edge_label_mode == EdgeLabelMode.LabelIsRange:
@@ -277,7 +308,7 @@ class QLabeledRangeSlider(SliderProxy, QAbstractSlider):
lbl.deleteLater()
self._handle_labels.clear()
for n, val in enumerate(self._slider.value()):
_cb = partial(self._slider._setSliderPositionAt, n)
_cb = partial(self._slider.setSliderPosition, index=n)
s = SliderLabel(self._slider, parent=self, connect=_cb)
s.setValue(val)
self._handle_labels.append(s)
@@ -287,7 +318,8 @@ class QLabeledRangeSlider(SliderProxy, QAbstractSlider):
self._reposition_labels()
def _on_range_changed(self, min, max):
self._slider.setRange(min, max)
if (min, max) != (self._slider.minimum(), self._slider.maximum()):
self._slider.setRange(min, max)
for lbl in self._handle_labels:
lbl.setRange(min, max)
if self._edge_label_mode == EdgeLabelMode.LabelIsRange:
@@ -354,15 +386,14 @@ class QLabeledDoubleRangeSlider(QLabeledRangeSlider):
_slider: QDoubleRangeSlider
rangeChanged = Signal(float, float)
def __init__(self, *args) -> None:
super().__init__(*args)
def __init__(self, *args, **kwargs) -> None:
super().__init__(*args, **kwargs)
self.setDecimals(2)
def decimals(self) -> int:
return self._slider.decimals()
return self._min_label.decimals()
def setDecimals(self, prec: int):
self._slider.setDecimals(prec)
self._min_label.setDecimals(prec)
self._max_label.setDecimals(prec)
for lbl in self._handle_labels:
@@ -393,7 +424,7 @@ class SliderLabel(QDoubleSpinBox):
super().setDecimals(prec)
self._update_size()
def _update_size(self):
def _update_size(self, *_):
# fontmetrics to measure the width of text
fm = QFontMetrics(self.font())
h = self.sizeHint().height()
@@ -401,15 +432,14 @@ class SliderLabel(QDoubleSpinBox):
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))
mintext = self.textFromValue(self.minimum())[:18] + fixed_content
maxtext = self.textFromValue(self.maximum())[:18] + fixed_content
w = max(0, _fm_width(fm, mintext))
w = max(w, _fm_width(fm, maxtext))
if self.specialValueText():
w = max(w, fm.horizontalAdvance(self.specialValueText()))
w = max(w, _fm_width(fm, self.specialValueText()))
else:
s = self.textFromValue(self.value())
w = max(0, fm.horizontalAdvance(s)) + 3
w = max(0, _fm_width(fm, self.textFromValue(self.value()))) + 3
w += 3 # cursor blinking space
# get the final size hint
@@ -455,3 +485,9 @@ class SliderLabel(QDoubleSpinBox):
if "." in input and self.decimals() < 1:
return QValidator.Invalid, input, len(input)
return super().validate(input, pos)
def _fm_width(fm, text):
if hasattr(fm, "horizontalAdvance"):
return fm.horizontalAdvance(text)
return fm.width(text)

View File

@@ -1,11 +1,13 @@
from __future__ import annotations
import platform
import re
from dataclasses import dataclass, replace
from typing import TYPE_CHECKING, Union
from typing import TYPE_CHECKING
from .qtcompat import PYQT_VERSION
from .qtcompat.QtCore import Qt
from .qtcompat.QtGui import (
from ..qtcompat import PYQT_VERSION
from ..qtcompat.QtCore import Qt
from ..qtcompat.QtGui import (
QBrush,
QColor,
QGradient,
@@ -13,26 +15,26 @@ from .qtcompat.QtGui import (
QPalette,
QRadialGradient,
)
from .qtcompat.QtWidgets import QApplication, QSlider, QStyleOptionSlider
from ..qtcompat.QtWidgets import QApplication, QSlider, QStyleOptionSlider
if TYPE_CHECKING:
from ._qrangeslider import QRangeSlider
from ._generic_range_slider import _GenericRangeSlider
@dataclass
class RangeSliderStyle:
brush_active: str = None
brush_inactive: str = None
brush_disabled: str = None
pen_active: str = None
pen_inactive: str = None
pen_disabled: str = None
vertical_thickness: float = None
horizontal_thickness: float = None
tick_offset: float = None
tick_bar_alpha: float = None
v_offset: float = None
h_offset: float = None
brush_active: str | None = None
brush_inactive: str | None = None
brush_disabled: str | None = None
pen_active: str | None = None
pen_inactive: str | None = None
pen_disabled: str | None = None
vertical_thickness: float | None = None
horizontal_thickness: float | None = None
tick_offset: float | None = None
tick_bar_alpha: float | None = None
v_offset: float | None = None
h_offset: float | None = None
has_stylesheet: bool = False
def brush(self, opt: QStyleOptionSlider) -> QBrush:
@@ -70,7 +72,7 @@ class RangeSliderStyle:
return QBrush(val)
def pen(self, opt: QStyleOptionSlider) -> Union[Qt.PenStyle, QColor]:
def pen(self, opt: QStyleOptionSlider) -> Qt.PenStyle | QColor:
cg = opt.palette.currentColorGroup()
attr = {
QPalette.Active: "pen_active", # 0
@@ -226,7 +228,7 @@ rgba_pattern = re.compile(
)
def parse_color(color: str, default_attr) -> Union[QColor, QGradient]:
def parse_color(color: str, default_attr) -> QColor | QGradient:
qc = QColor(color)
if qc.isValid():
return qc
@@ -239,7 +241,7 @@ def parse_color(color: str, default_attr) -> Union[QColor, QGradient]:
# try linear gradient:
match = qlineargrad_pattern.search(color)
if match:
grad = QLinearGradient(*[float(i) for i in match.groups()[:4]])
grad = QLinearGradient(*(float(i) for i in match.groups()[:4]))
grad.setColorAt(0, QColor(match.groupdict()["stop0"]))
grad.setColorAt(1, QColor(match.groupdict()["stop1"]))
return grad
@@ -247,7 +249,7 @@ def parse_color(color: str, default_attr) -> Union[QColor, QGradient]:
# try linear gradient:
match = qradial_pattern.search(color)
if match:
grad = QRadialGradient(*[float(i) for i in match.groups()[:5]])
grad = QRadialGradient(*(float(i) for i in match.groups()[:5]))
grad.setColorAt(0, QColor(match.groupdict()["stop0"]))
grad.setColorAt(1, QColor(match.groupdict()["stop1"]))
return grad
@@ -256,7 +258,7 @@ def parse_color(color: str, default_attr) -> Union[QColor, QGradient]:
return QColor(getattr(SYSTEM_STYLE, default_attr))
def update_styles_from_stylesheet(obj: "QRangeSlider"):
def update_styles_from_stylesheet(obj: _GenericRangeSlider):
qss = obj.styleSheet()
parent = obj.parent()

View File

@@ -0,0 +1,42 @@
from ..qtcompat.QtCore import Signal
from ._generic_range_slider import _GenericRangeSlider
from ._generic_slider import _GenericSlider
class _IntMixin:
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self._singleStep = 1
def _type_cast(self, value) -> int:
return int(round(value))
class _FloatMixin:
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self._singleStep = 0.01
self._pageStep = 0.1
def _type_cast(self, value) -> float:
return float(value)
class QDoubleSlider(_FloatMixin, _GenericSlider[float]):
pass
class QIntSlider(_IntMixin, _GenericSlider[int]):
# mostly just an example... use QSlider instead.
valueChanged = Signal(int)
class QRangeSlider(_IntMixin, _GenericRangeSlider):
pass
class QDoubleRangeSlider(_FloatMixin, QRangeSlider):
pass
# QRangeSlider.__doc__ += "\n" + textwrap.indent(QSlider.__doc__, " ")

View File

View File

@@ -0,0 +1,70 @@
from contextlib import suppress
from distutils.version import LooseVersion
from platform import system
import pytest
from superqt.qtcompat import QT_VERSION
from superqt.qtcompat.QtCore import QEvent, QPoint, QPointF, Qt
from superqt.qtcompat.QtGui import QMouseEvent, QWheelEvent
QT_VERSION = LooseVersion(QT_VERSION)
SYS_DARWIN = system() == "Darwin"
skip_on_linux_qt6 = pytest.mark.skipif(
system() == "Linux" and QT_VERSION >= LooseVersion("6.0"),
reason="hover events not working on linux pyqt6",
)
def _mouse_event(pos=QPointF(), type_=QEvent.MouseMove):
"""Create a mouse event of `type_` at `pos`."""
return QMouseEvent(type_, QPointF(pos), Qt.LeftButton, Qt.LeftButton, Qt.NoModifier)
def _wheel_event(arc):
"""Create a wheel event with `arc`."""
with suppress(TypeError):
return QWheelEvent(
QPointF(),
QPointF(),
QPoint(arc, arc),
QPoint(arc, arc),
Qt.NoButton,
Qt.NoModifier,
Qt.ScrollBegin,
False,
Qt.MouseEventSynthesizedByQt,
)
with suppress(TypeError):
return QWheelEvent(
QPointF(),
QPointF(),
QPoint(-arc, -arc),
QPoint(-arc, -arc),
1,
Qt.Vertical,
Qt.NoButton,
Qt.NoModifier,
Qt.ScrollBegin,
False,
Qt.MouseEventSynthesizedByQt,
)
return QWheelEvent(
QPointF(),
QPointF(),
QPoint(arc, arc),
QPoint(arc, arc),
1,
Qt.Vertical,
Qt.NoButton,
Qt.NoModifier,
)
def _linspace(start, stop, n):
h = (stop - start) / (n - 1)
for i in range(n):
yield start + h * i

View File

@@ -2,13 +2,13 @@ import os
import pytest
from qtrangeslider import (
from superqt import (
QDoubleRangeSlider,
QDoubleSlider,
QLabeledDoubleRangeSlider,
QLabeledDoubleSlider,
)
from qtrangeslider.qtcompat import API_NAME
from superqt.qtcompat import API_NAME
range_types = {QDoubleRangeSlider, QLabeledDoubleRangeSlider}
@@ -62,15 +62,13 @@ def test_double_sliders(ds):
ds.assert_val_eq((20, 40))
assert ds.singleStep() == 1
ds.setDecimals(2)
ds.assert_val_eq((20, 40))
ds.assert_val_type()
ds.setValue((20.23435, 40.2342))
ds.assert_val_eq((20.23, 40.23)) # because of decimals
ds.setValue((20.23, 40.23))
ds.assert_val_eq((20.23, 40.23))
ds.assert_val_type()
ds.setDecimals(4)
assert ds.minimum() == 10
assert ds.maximum() == 99
assert ds.singleStep() == 1
@@ -78,16 +76,11 @@ def test_double_sliders(ds):
ds.setValue((20.2343, 40.2342))
ds.assert_val_eq((20.2343, 40.2342))
ds.setDecimals(6)
ds.assert_val_eq((20.2343, 40.2342))
assert ds.minimum() == 10
assert ds.maximum() == 99
assert ds.singleStep() == 1
with pytest.raises(OverflowError) as err:
ds.setDecimals(8)
assert "open a feature request" in str(err)
ds.assert_val_eq((20.2343, 40.2342))
assert ds.minimum() == 10
assert ds.maximum() == 99
@@ -96,7 +89,6 @@ def test_double_sliders(ds):
def test_double_sliders_small(ds):
ds.setMaximum(1)
ds.setDecimals(8)
ds.setValue((0.5, 0.9))
assert ds.minimum() == 0
assert ds.maximum() == 1
@@ -108,8 +100,6 @@ def test_double_sliders_small(ds):
def test_double_sliders_big(ds):
ds.setValue((20, 80))
ds.setDecimals(-6)
assert ds.decimals() == -6
ds.setMaximum(5e14)
assert ds.minimum() == 0
assert ds.maximum() == 5e14

View File

@@ -0,0 +1,157 @@
import math
import pytest
from superqt.qtcompat.QtCore import QEvent, QPoint, QPointF, Qt
from superqt.qtcompat.QtGui import QHoverEvent
from superqt.qtcompat.QtWidgets import QStyle, QStyleOptionSlider
from superqt.sliders._generic_slider import _GenericSlider
from ._testutil import _linspace, _mouse_event, _wheel_event, skip_on_linux_qt6
@pytest.fixture(params=[Qt.Horizontal, Qt.Vertical])
def gslider(qtbot, request):
slider = _GenericSlider(request.param)
qtbot.addWidget(slider)
assert slider.value() == 0
assert slider.minimum() == 0
assert slider.maximum() == 99
yield slider
slider.initStyleOption(QStyleOptionSlider())
def test_change_floatslider_range(gslider: _GenericSlider, qtbot):
with qtbot.waitSignals([gslider.rangeChanged, gslider.valueChanged]):
gslider.setMinimum(10)
assert gslider.value() == 10 == gslider.minimum()
assert gslider.maximum() == 99
with qtbot.waitSignal(gslider.rangeChanged):
gslider.setMaximum(90)
assert gslider.value() == 10 == gslider.minimum()
assert gslider.maximum() == 90
with qtbot.waitSignals([gslider.rangeChanged, gslider.valueChanged]):
gslider.setRange(20, 40)
assert gslider.value() == 20 == gslider.minimum()
assert gslider.maximum() == 40
with qtbot.waitSignal(gslider.valueChanged):
gslider.setValue(30)
assert gslider.value() == 30
with qtbot.waitSignals([gslider.rangeChanged, gslider.valueChanged]):
gslider.setMaximum(25)
assert gslider.value() == 25 == gslider.maximum()
assert gslider.minimum() == 20
def test_float_values(gslider: _GenericSlider, qtbot):
with qtbot.waitSignal(gslider.rangeChanged):
gslider.setRange(0.25, 0.75)
assert gslider.minimum() == 0.25
assert gslider.maximum() == 0.75
with qtbot.waitSignal(gslider.valueChanged):
gslider.setValue(0.55)
assert gslider.value() == 0.55
with qtbot.waitSignal(gslider.valueChanged):
gslider.setValue(1.55)
assert gslider.value() == 0.75 == gslider.maximum()
def test_ticks(gslider: _GenericSlider, qtbot):
gslider.setTickInterval(0.3)
assert gslider.tickInterval() == 0.3
gslider.setTickPosition(gslider.TicksAbove)
gslider.show()
def test_show(gslider, qtbot):
gslider.show()
def test_press_move_release(gslider: _GenericSlider, qtbot):
assert gslider._pressedControl == QStyle.SubControl.SC_None
opt = QStyleOptionSlider()
gslider.initStyleOption(opt)
style = gslider.style()
hrect = style.subControlRect(QStyle.CC_Slider, opt, QStyle.SC_SliderHandle)
handle_pos = gslider.mapToGlobal(hrect.center())
with qtbot.waitSignal(gslider.sliderPressed):
qtbot.mousePress(gslider, Qt.LeftButton, pos=handle_pos)
assert gslider._pressedControl == QStyle.SubControl.SC_SliderHandle
with qtbot.waitSignals([gslider.sliderMoved, gslider.valueChanged]):
shift = QPoint(0, -8) if gslider.orientation() == Qt.Vertical else QPoint(8, 0)
gslider.mouseMoveEvent(_mouse_event(handle_pos + shift))
with qtbot.waitSignal(gslider.sliderReleased):
qtbot.mouseRelease(gslider, Qt.LeftButton, pos=handle_pos)
assert gslider._pressedControl == QStyle.SubControl.SC_None
gslider.show()
with qtbot.waitSignal(gslider.sliderPressed):
qtbot.mousePress(gslider, Qt.LeftButton, pos=handle_pos)
@skip_on_linux_qt6
def test_hover(gslider: _GenericSlider):
opt = QStyleOptionSlider()
gslider.initStyleOption(opt)
style = gslider.style()
hrect = style.subControlRect(QStyle.CC_Slider, opt, QStyle.SC_SliderHandle)
handle_pos = QPointF(gslider.mapToGlobal(hrect.center()))
assert gslider._hoverControl == QStyle.SubControl.SC_None
gslider.event(QHoverEvent(QEvent.HoverEnter, handle_pos, QPointF()))
assert gslider._hoverControl == QStyle.SubControl.SC_SliderHandle
gslider.event(QHoverEvent(QEvent.HoverLeave, QPointF(-1000, -1000), handle_pos))
assert gslider._hoverControl == QStyle.SubControl.SC_None
def test_wheel(gslider: _GenericSlider, qtbot):
with qtbot.waitSignal(gslider.valueChanged):
gslider.wheelEvent(_wheel_event(120))
gslider.wheelEvent(_wheel_event(0))
def test_position(gslider: _GenericSlider, qtbot):
gslider.setSliderPosition(21.2)
assert gslider.sliderPosition() == 21.2
def test_steps(gslider: _GenericSlider, qtbot):
gslider.setSingleStep(0.1)
assert gslider.singleStep() == 0.1
gslider.setSingleStep(1.5e20)
assert gslider.singleStep() == 1.5e20
gslider.setPageStep(0.2)
assert gslider.pageStep() == 0.2
gslider.setPageStep(1.5e30)
assert gslider.pageStep() == 1.5e30
@pytest.mark.parametrize("mag", list(range(4, 37, 4)) + list(range(-4, -37, -4)))
def test_slider_extremes(gslider: _GenericSlider, mag, qtbot):
_mag = 10 ** mag
with qtbot.waitSignal(gslider.rangeChanged):
gslider.setRange(-_mag, _mag)
for i in _linspace(-_mag, _mag, 10):
gslider.setValue(i)
assert math.isclose(gslider.value(), i, rel_tol=1e-8)
gslider.initStyleOption(QStyleOptionSlider())

View File

@@ -0,0 +1,156 @@
import math
import pytest
from superqt import QDoubleRangeSlider, QRangeSlider
from superqt.qtcompat.QtCore import QEvent, QPoint, QPointF, Qt
from superqt.qtcompat.QtGui import QHoverEvent
from superqt.qtcompat.QtWidgets import QStyle, QStyleOptionSlider
from ._testutil import _linspace, _mouse_event, _wheel_event, skip_on_linux_qt6
@pytest.fixture(params=[Qt.Horizontal, Qt.Vertical])
def gslider(qtbot, request):
slider = QDoubleRangeSlider(request.param)
qtbot.addWidget(slider)
assert slider.value() == (20, 80)
assert slider.minimum() == 0
assert slider.maximum() == 99
yield slider
slider.initStyleOption(QStyleOptionSlider())
def test_change_floatslider_range(gslider: QRangeSlider, qtbot):
with qtbot.waitSignals([gslider.rangeChanged, gslider.valueChanged]):
gslider.setMinimum(30)
assert gslider.value()[0] == 30 == gslider.minimum()
assert gslider.maximum() == 99
with qtbot.waitSignal(gslider.rangeChanged):
gslider.setMaximum(70)
assert gslider.value()[0] == 30 == gslider.minimum()
assert gslider.value()[1] == 70 == gslider.maximum()
with qtbot.waitSignals([gslider.rangeChanged, gslider.valueChanged]):
gslider.setRange(40, 60)
assert gslider.value()[0] == 40 == gslider.minimum()
assert gslider.maximum() == 60
with qtbot.waitSignal(gslider.valueChanged):
gslider.setValue([40, 50])
assert gslider.value()[0] == 40 == gslider.minimum()
assert gslider.value()[1] == 50
with qtbot.waitSignals([gslider.rangeChanged, gslider.valueChanged]):
gslider.setMaximum(45)
assert gslider.value()[0] == 40 == gslider.minimum()
assert gslider.value()[1] == 45 == gslider.maximum()
def test_float_values(gslider: QRangeSlider, qtbot):
with qtbot.waitSignal(gslider.rangeChanged):
gslider.setRange(0.1, 0.9)
assert gslider.minimum() == 0.1
assert gslider.maximum() == 0.9
with qtbot.waitSignal(gslider.valueChanged):
gslider.setValue([0.4, 0.6])
assert gslider.value() == (0.4, 0.6)
with qtbot.waitSignal(gslider.valueChanged):
gslider.setValue([0, 1.9])
assert gslider.value()[0] == 0.1 == gslider.minimum()
assert gslider.value()[1] == 0.9 == gslider.maximum()
def test_position(gslider: QRangeSlider, qtbot):
gslider.setSliderPosition([10, 80])
assert gslider.sliderPosition() == (10, 80)
def test_steps(gslider: QRangeSlider, qtbot):
gslider.setSingleStep(0.1)
assert gslider.singleStep() == 0.1
gslider.setSingleStep(1.5e20)
assert gslider.singleStep() == 1.5e20
gslider.setPageStep(0.2)
assert gslider.pageStep() == 0.2
gslider.setPageStep(1.5e30)
assert gslider.pageStep() == 1.5e30
@pytest.mark.parametrize("mag", list(range(4, 37, 4)) + list(range(-4, -37, -4)))
def test_slider_extremes(gslider: QRangeSlider, mag, qtbot):
_mag = 10 ** mag
with qtbot.waitSignal(gslider.rangeChanged):
gslider.setRange(-_mag, _mag)
for i in _linspace(-_mag, _mag, 10):
gslider.setValue((i, _mag))
assert math.isclose(gslider.value()[0], i, rel_tol=1e-8)
gslider.initStyleOption(QStyleOptionSlider())
def test_ticks(gslider: QRangeSlider, qtbot):
gslider.setTickInterval(0.3)
assert gslider.tickInterval() == 0.3
gslider.setTickPosition(gslider.TicksAbove)
gslider.show()
def test_show(gslider, qtbot):
gslider.show()
def test_press_move_release(gslider: QRangeSlider, qtbot):
assert gslider._pressedControl == QStyle.SubControl.SC_None
opt = QStyleOptionSlider()
gslider.initStyleOption(opt)
style = gslider.style()
hrect = style.subControlRect(QStyle.CC_Slider, opt, QStyle.SC_SliderHandle)
handle_pos = gslider.mapToGlobal(hrect.center())
with qtbot.waitSignal(gslider.sliderPressed):
qtbot.mousePress(gslider, Qt.LeftButton, pos=handle_pos)
assert gslider._pressedControl == QStyle.SubControl.SC_SliderHandle
with qtbot.waitSignals([gslider.sliderMoved, gslider.valueChanged]):
shift = QPoint(0, -8) if gslider.orientation() == Qt.Vertical else QPoint(8, 0)
gslider.mouseMoveEvent(_mouse_event(handle_pos + shift))
with qtbot.waitSignal(gslider.sliderReleased):
qtbot.mouseRelease(gslider, Qt.LeftButton, pos=handle_pos)
assert gslider._pressedControl == QStyle.SubControl.SC_None
gslider.show()
with qtbot.waitSignal(gslider.sliderPressed):
qtbot.mousePress(gslider, Qt.LeftButton, pos=handle_pos)
@skip_on_linux_qt6
def test_hover(gslider: QRangeSlider):
hrect = gslider._handleRect(0)
handle_pos = QPointF(gslider.mapToGlobal(hrect.center()))
assert gslider._hoverControl == QStyle.SubControl.SC_None
gslider.event(QHoverEvent(QEvent.HoverEnter, handle_pos, QPointF()))
assert gslider._hoverControl == QStyle.SubControl.SC_SliderHandle
gslider.event(QHoverEvent(QEvent.HoverLeave, QPointF(-1000, -1000), handle_pos))
assert gslider._hoverControl == QStyle.SubControl.SC_None
def test_wheel(gslider: QRangeSlider, qtbot):
with qtbot.waitSignal(gslider.valueChanged):
gslider.wheelEvent(_wheel_event(120))
gslider.wheelEvent(_wheel_event(0))

View File

@@ -0,0 +1,222 @@
import math
from contextlib import suppress
from distutils.version import LooseVersion
import pytest
from superqt import QDoubleSlider, QLabeledDoubleSlider, QLabeledSlider
from superqt.qtcompat.QtCore import QEvent, QPoint, QPointF, Qt
from superqt.qtcompat.QtGui import QHoverEvent
from superqt.qtcompat.QtWidgets import QSlider, QStyle, QStyleOptionSlider
from superqt.sliders._generic_slider import _GenericSlider
from ._testutil import (
QT_VERSION,
SYS_DARWIN,
_linspace,
_mouse_event,
_wheel_event,
skip_on_linux_qt6,
)
@pytest.fixture(params=[Qt.Horizontal, Qt.Vertical], ids=["horizontal", "vertical"])
def orientation(request):
return request.param
START_MI_MAX_VAL = (0, 99, 0)
TEST_SLIDERS = [QDoubleSlider, QLabeledSlider, QLabeledDoubleSlider]
def _assert_value_in_range(sld):
val = sld.value()
if isinstance(val, (int, float)):
val = (val,)
assert all(sld.minimum() <= v <= sld.maximum() for v in val)
@pytest.fixture(params=TEST_SLIDERS)
def sld(request, qtbot, orientation):
Cls = request.param
slider = Cls(orientation)
slider.setRange(*START_MI_MAX_VAL[:2])
slider.setValue(START_MI_MAX_VAL[2])
qtbot.addWidget(slider)
assert (slider.minimum(), slider.maximum(), slider.value()) == START_MI_MAX_VAL
_assert_value_in_range(slider)
yield slider
_assert_value_in_range(slider)
with suppress(AttributeError):
slider.initStyleOption(QStyleOptionSlider())
def called_with(*expected_result):
"""Use in check_params_cbs to assert that a callback is called as expected.
e.g. `called_with(20, 50)` returns a callback that checks that the callback
is called with the arguments (20, 50)
"""
def check_emitted_values(*values):
return values == expected_result
return check_emitted_values
def test_change_floatslider_range(sld: _GenericSlider, qtbot):
BOTH = [sld.rangeChanged, sld.valueChanged]
for signals, checks, funcname, args in [
(BOTH, [called_with(10, 99), called_with(10)], "setMinimum", (10,)),
([sld.rangeChanged], [called_with(10, 90)], "setMaximum", (90,)),
(BOTH, [called_with(20, 40), called_with(20)], "setRange", (20, 40)),
([sld.valueChanged], [called_with(30)], "setValue", (30,)),
(BOTH, [called_with(20, 25), called_with(25)], "setMaximum", (25,)),
([sld.valueChanged], [called_with(23)], "setValue", (23,)),
]:
with qtbot.waitSignals(signals, check_params_cbs=checks, timeout=500):
getattr(sld, funcname)(*args)
_assert_value_in_range(sld)
def test_float_values(sld: _GenericSlider, qtbot):
if type(sld) is QLabeledSlider:
pytest.skip()
for signals, checks, funcname, args in [
(sld.rangeChanged, called_with(0.1, 0.9), "setRange", (0.1, 0.9)),
(sld.valueChanged, called_with(0.4), "setValue", (0.4,)),
(sld.valueChanged, called_with(0.1), "setValue", (0,)),
(sld.valueChanged, called_with(0.9), "setValue", (1.9,)),
]:
with qtbot.waitSignal(signals, check_params_cb=checks, timeout=400):
getattr(sld, funcname)(*args)
_assert_value_in_range(sld)
def test_ticks(sld: _GenericSlider, qtbot):
sld.setTickInterval(3)
assert sld.tickInterval() == 3
sld.setTickPosition(QSlider.TicksAbove)
sld.show()
# FIXME: this isn't testing labeled sliders as it needs to be ...
@pytest.mark.skipif(not SYS_DARWIN, reason="mousePress only working on mac")
def test_press_move_release(sld: _GenericSlider, qtbot):
_real_sld = getattr(sld, "_slider", sld)
with suppress(AttributeError): # for QSlider
assert _real_sld._pressedControl == QStyle.SubControl.SC_None
opt = QStyleOptionSlider()
_real_sld.initStyleOption(opt)
style = _real_sld.style()
hrect = style.subControlRect(QStyle.CC_Slider, opt, QStyle.SC_SliderHandle)
handle_pos = _real_sld.mapToGlobal(hrect.center())
with qtbot.waitSignal(_real_sld.sliderPressed, timeout=300):
qtbot.mousePress(_real_sld, Qt.LeftButton, pos=handle_pos)
with suppress(AttributeError):
assert sld._pressedControl == QStyle.SubControl.SC_SliderHandle
with qtbot.waitSignals(
[_real_sld.sliderMoved, _real_sld.valueChanged], timeout=300
):
shift = (
QPoint(0, -8) if _real_sld.orientation() == Qt.Vertical else QPoint(8, 0)
)
_real_sld.mouseMoveEvent(_mouse_event(handle_pos + shift))
with qtbot.waitSignal(_real_sld.sliderReleased, timeout=300):
qtbot.mouseRelease(_real_sld, Qt.LeftButton, pos=handle_pos)
with suppress(AttributeError):
assert _real_sld._pressedControl == QStyle.SubControl.SC_None
sld.show()
with qtbot.waitSignal(_real_sld.sliderPressed, timeout=300):
qtbot.mousePress(_real_sld, Qt.LeftButton, pos=handle_pos)
@skip_on_linux_qt6
def test_hover(sld: _GenericSlider):
_real_sld = getattr(sld, "_slider", sld)
opt = QStyleOptionSlider()
_real_sld.initStyleOption(opt)
hrect = _real_sld.style().subControlRect(
QStyle.CC_Slider, opt, QStyle.SC_SliderHandle
)
handle_pos = QPointF(sld.mapToGlobal(hrect.center()))
with suppress(AttributeError): # for QSlider
assert _real_sld._hoverControl == QStyle.SubControl.SC_None
_real_sld.event(QHoverEvent(QEvent.HoverEnter, handle_pos, QPointF()))
with suppress(AttributeError): # for QSlider
assert _real_sld._hoverControl == QStyle.SubControl.SC_SliderHandle
_real_sld.event(QHoverEvent(QEvent.HoverLeave, QPointF(-1000, -1000), handle_pos))
with suppress(AttributeError): # for QSlider
assert _real_sld._hoverControl == QStyle.SubControl.SC_None
def test_wheel(sld: _GenericSlider, qtbot):
if type(sld) is QLabeledSlider and QT_VERSION < LooseVersion("5.12"):
pytest.skip()
_real_sld = getattr(sld, "_slider", sld)
with qtbot.waitSignal(sld.valueChanged, timeout=400):
_real_sld.wheelEvent(_wheel_event(120))
_real_sld.wheelEvent(_wheel_event(0))
def test_position(sld: _GenericSlider, qtbot):
sld.setSliderPosition(21)
assert sld.sliderPosition() == 21
if type(sld) is not QLabeledSlider:
sld.setSliderPosition(21.5)
assert sld.sliderPosition() == 21.5
def test_steps(sld: _GenericSlider, qtbot):
sld.setSingleStep(11)
assert sld.singleStep() == 11
sld.setPageStep(16)
assert sld.pageStep() == 16
if type(sld) is not QLabeledSlider:
sld.setSingleStep(0.1)
assert sld.singleStep() == 0.1
sld.setSingleStep(1.5e20)
assert sld.singleStep() == 1.5e20
sld.setPageStep(0.2)
assert sld.pageStep() == 0.2
sld.setPageStep(1.5e30)
assert sld.pageStep() == 1.5e30
@pytest.mark.parametrize("mag", list(range(4, 37, 4)) + list(range(-4, -37, -4)))
def test_slider_extremes(sld: _GenericSlider, mag, qtbot):
if type(sld) is QLabeledSlider:
pytest.skip()
_mag = 10 ** mag
with qtbot.waitSignal(sld.rangeChanged, timeout=400):
sld.setRange(-_mag, _mag)
for i in _linspace(-_mag, _mag, 10):
sld.setValue(i)
assert math.isclose(sld.value(), i, rel_tol=1e-8)

View File

@@ -0,0 +1,142 @@
import platform
import pytest
from superqt import QRangeSlider
from superqt.qtcompat import API_NAME
from superqt.qtcompat.QtCore import Qt
from superqt.sliders._generic_range_slider import SC_BAR, SC_HANDLE, SC_NONE
NOT_LINUX = platform.system() != "Linux"
NOT_PYSIDE2 = API_NAME != "PySide2"
skipmouse = pytest.mark.skipif(NOT_LINUX or NOT_PYSIDE2, reason="mouse tests finicky")
@pytest.mark.parametrize("orientation", ["Horizontal", "Vertical"])
def test_basic(qtbot, orientation):
rs = QRangeSlider(getattr(Qt, orientation))
qtbot.addWidget(rs)
@pytest.mark.parametrize("orientation", ["Horizontal", "Vertical"])
def test_value(qtbot, orientation):
rs = QRangeSlider(getattr(Qt, orientation))
qtbot.addWidget(rs)
rs.setValue([10, 20])
assert rs.value() == (10, 20)
@pytest.mark.parametrize("orientation", ["Horizontal", "Vertical"])
def test_range(qtbot, orientation):
rs = QRangeSlider(getattr(Qt, orientation))
qtbot.addWidget(rs)
rs.setValue([10, 20])
assert rs.value() == (10, 20)
rs.setRange(15, 20)
assert rs.value() == (15, 20)
assert rs.minimum() == 15
assert rs.maximum() == 20
@skipmouse
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
pos = rs._handleRect(0).center()
with qtbot.waitSignal(rs.sliderPressed):
qtbot.mousePress(rs, Qt.LeftButton, pos=pos)
assert rs._pressedControl == SC_HANDLE
assert rs._pressedIndex == 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 == SC_NONE
# press the right handle
pos = rs._handleRect(1).center()
with qtbot.waitSignal(rs.sliderPressed):
qtbot.mousePress(rs, Qt.LeftButton, pos=pos)
assert rs._pressedControl == SC_HANDLE
assert rs._pressedIndex == 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 == SC_NONE
@skipmouse
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
pos = rs._handleRect(1).center()
with qtbot.waitSignal(rs.sliderPressed):
qtbot.mousePress(rs, Qt.LeftButton, pos=pos)
assert rs._pressedControl == SC_HANDLE
assert rs._pressedIndex == 1
# drag the handle off the right edge and make sure the value gets to the max
for _ in range(7):
pos.setX(pos.x() + 10)
qtbot.mouseMove(rs, pos)
with qtbot.waitSignal(rs.sliderReleased):
qtbot.mouseRelease(rs, Qt.LeftButton)
assert rs.value()[1] == 99
@skipmouse
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 == SC_BAR
assert rs._pressedIndex == 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

@@ -0,0 +1,3 @@
from ._intspin import QLargeIntSpinBox
__all__ = ["QLargeIntSpinBox"]

174
superqt/spinbox/_intspin.py Normal file
View File

@@ -0,0 +1,174 @@
from enum import Enum
from ..qtcompat.QtCore import QSize, Qt, Signal
from ..qtcompat.QtGui import QFontMetrics, QValidator
from ..qtcompat.QtWidgets import QAbstractSpinBox, QStyle, QStyleOptionSpinBox
class _EmitPolicy(Enum):
EmitIfChanged = 0
AlwaysEmit = 1
NeverEmit = 2
class _AnyIntValidator(QValidator):
def __init__(self, parent=None) -> None:
super().__init__(parent)
def validate(self, input: str, pos: int):
if not input.lstrip("-"):
return QValidator.State.Intermediate, input, len(input)
if input.lstrip("-").isnumeric():
return QValidator.State.Acceptable, input, len(input)
return QValidator.State.Invalid, input, len(input)
class QLargeIntSpinBox(QAbstractSpinBox):
"""An integer spinboxes backed by unbound python integer
Qt's built-in ``QSpinBox`` is backed by a signed 32-bit integer.
This could become limiting, particularly in large dense segmentations.
This class behaves like a ``QSpinBox`` backed by an unbound python int.
Does not yet support "prefix", "suffix" or "specialValue" like QSpinBox.
"""
textChanged = Signal(str)
valueChanged = Signal(object) # object instead of int for large ints
def __init__(self, parent=None) -> None:
super().__init__(parent)
self._value: int = 0
self._minimum: int = 0
self._maximum: int = 2 ** 64 - 1
self._single_step: int = 1
self._pending_emit = False
validator = _AnyIntValidator(self)
self.lineEdit().setValidator(validator)
self.lineEdit().textChanged.connect(self._editor_text_changed)
self.setValue(0)
# ############### Public Functions #######################
def value(self):
return self._value
def setValue(self, value):
self._setValue(value, _EmitPolicy.EmitIfChanged)
def minimum(self):
return self._minimum
def setMinimum(self, min):
self._minimum = int(min)
def maximum(self):
return self._maximum
def setMaximum(self, max):
self._maximum = int(max)
def setRange(self, minimum, maximum):
self.setMinimum(minimum)
self.setMaximum(maximum)
def singleStep(self):
return self._single_step
def setSingleStep(self, step):
self._single_step = int(step)
# TODO: add prefix/suffix/stepType
# ############### QtOverrides #######################
def focusOutEvent(self, e) -> None:
if self._pending_emit:
self._interpret(_EmitPolicy.EmitIfChanged)
return super().focusOutEvent(e)
def closeEvent(self, e) -> None:
if self._pending_emit:
self._interpret(_EmitPolicy.EmitIfChanged)
return super().closeEvent(e)
def keyPressEvent(self, e) -> None:
if e.key() in (Qt.Key_Enter, Qt.Key_Return):
self._interpret(
_EmitPolicy.AlwaysEmit
if self.keyboardTracking()
else _EmitPolicy.EmitIfChanged
)
return super().keyPressEvent(e)
def stepBy(self, steps: int) -> None:
step = self._single_step
old = self._value
e = _EmitPolicy.EmitIfChanged
if self._pending_emit:
self._interpret(_EmitPolicy.NeverEmit)
if self._value != old:
e = _EmitPolicy.AlwaysEmit
self._setValue(self._bound(self._value + (step * steps)), e)
def stepEnabled(self):
flags = QAbstractSpinBox.StepNone
if self.isReadOnly():
return flags
if self._value < self._maximum:
flags |= QAbstractSpinBox.StepUpEnabled
if self._value > self._minimum:
flags |= QAbstractSpinBox.StepDownEnabled
return flags
def sizeHint(self):
self.ensurePolished()
fm = QFontMetrics(self.font())
h = self.lineEdit().sizeHint().height()
if hasattr(fm, "horizontalAdvance"):
# Qt >= 5.11
w = fm.horizontalAdvance(str(self._value)) + 3
else:
w = fm.width(str(self._value)) + 3
w = max(36, w)
opt = QStyleOptionSpinBox()
self.initStyleOption(opt)
hint = QSize(w, h)
return self.style().sizeFromContents(QStyle.CT_SpinBox, opt, hint, self)
# ############### Implementation Details #######################
def _setValue(self, value, policy):
self._value, old = self._bound(int(value)), self._value
self._pending_emit = False
self._updateEdit()
self.update()
if policy is _EmitPolicy.AlwaysEmit or (
policy is _EmitPolicy.EmitIfChanged and self._value != old
):
self._pending_emit = False
self.textChanged.emit(self.lineEdit().displayText())
self.valueChanged.emit(self._value)
def _updateEdit(self):
new_text = str(self._value)
if self.lineEdit().text() == new_text:
return
self.lineEdit().setText(new_text)
def _interpret(self, policy):
text = self.lineEdit().displayText() or str(self._value)
v = int(text)
self._setValue(v, policy)
def _editor_text_changed(self, t):
if self.keyboardTracking():
self._setValue(int(t), _EmitPolicy.EmitIfChanged)
self.lineEdit().setFocus()
self._pending_emit = False
else:
self._pending_emit = True
def _bound(self, value):
return max(self._minimum, min(self._maximum, value))

View File

@@ -0,0 +1,73 @@
from superqt.qtcompat.QtCore import Qt
from superqt.spinbox import QLargeIntSpinBox
def test_large_spinbox(qtbot):
sb = QLargeIntSpinBox()
qtbot.addWidget(sb)
for e in range(2, 100, 2):
sb.setMaximum(10 ** e + 2)
with qtbot.waitSignal(sb.valueChanged) as sgnl:
sb.setValue(10 ** e)
assert sgnl.args == [10 ** e]
assert sb.value() == 10 ** e
sb.setMinimum(-(10 ** e) - 2)
with qtbot.waitSignal(sb.valueChanged) as sgnl:
sb.setValue(-(10 ** e))
assert sgnl.args == [-(10 ** e)]
assert sb.value() == -(10 ** e)
def test_large_spinbox_type(qtbot):
sb = QLargeIntSpinBox()
qtbot.addWidget(sb)
assert isinstance(sb.value(), int)
sb.setValue(1.1)
assert isinstance(sb.value(), int)
assert sb.value() == 1
sb.setValue(1.9)
assert isinstance(sb.value(), int)
assert sb.value() == 1
def test_large_spinbox_signals(qtbot):
sb = QLargeIntSpinBox()
qtbot.addWidget(sb)
with qtbot.waitSignal(sb.valueChanged) as sgnl:
sb.setValue(200)
assert sgnl.args == [200]
with qtbot.waitSignal(sb.textChanged) as sgnl:
sb.setValue(240)
assert sgnl.args == ["240"]
def test_keyboard_tracking(qtbot):
sb = QLargeIntSpinBox()
qtbot.addWidget(sb)
assert sb.value() == 0
sb.setKeyboardTracking(False)
with qtbot.assertNotEmitted(sb.valueChanged):
sb.lineEdit().setText("20")
assert sb.lineEdit().text() == "20"
assert sb.value() == 0
assert sb._pending_emit is True
with qtbot.waitSignal(sb.valueChanged) as sgnl:
qtbot.keyPress(sb, Qt.Key_Enter)
assert sgnl.args == [20]
assert sb._pending_emit is False
sb.setKeyboardTracking(True)
with qtbot.waitSignal(sb.valueChanged) as sgnl:
sb.lineEdit().setText("25")
assert sb._pending_emit is False
assert sgnl.args == [25]

24
tox.ini
View File

@@ -1,8 +1,19 @@
# For more information about tox, see https://tox.readthedocs.io/en/latest/
[tox]
envlist = py{37,38,39}-{linux,macos,windows}-{pyqt5,pyside2,pyqt6,pyside6}
envlist = py{37,38,39}-{linux,macos,windows}-{pyqt5,pyside2,pyqt6,pyside6},py37-{linux,macos,windows}-{pyqt511,pyside511}
toxworkdir=/tmp/.tox
[coverage:report]
exclude_lines =
pragma: no cover
if TYPE_CHECKING:
\.\.\.
except ImportError*
raise NotImplementedError()
omit =
superqt/_version.py
superqt/qtcompat/*
*_tests*
[gh-actions]
python =
3.6: py36
@@ -24,6 +35,8 @@ BACKEND =
pyside2: pyside2
pyqt6: pyqt6
pyside6: pyside6
pyqt511: pyqt511
pyside511: pyside511
[testenv]
platform =
@@ -33,11 +46,14 @@ platform =
passenv = CI GITHUB_ACTIONS DISPLAY XAUTHORITY
deps =
pytest-xvfb ; sys_platform == 'linux'
pyqt511: pyqt5==5.11.*
pyside511: pyside2==5.11.*
extras =
testing
pyqt5: pyqt5
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
commands_pre =
pyqt6,pyside6: pip install -U pytest-qt@git+https://github.com/pytest-dev/pytest-qt.git
commands = pytest --color=yes --cov=superqt --cov-report=xml {posargs}