Compare commits

...

29 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
Talley Lambert
f76cf6d126 FloatSlider (#4)
* decent point

* QLabeledDoubleRangeSlider

* update float add tests

* ugly but working

* fix validator

* flexible orientation

* horiz

* warnings are errors

* try convert

* fix signals

* skip signals test on windows pyqt6
2021-05-02 14:30:55 -04:00
Talley Lambert
a27b388f3e Labeled sliders (#3)
* good labels

* more options

* add to init

* reemit value changed

* remove pass

* refine positioning

* update example

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

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

* add mouse drag

* more lenient values

* use tp ==

* skip mouse move windows CI

* fix dragging to edges

* fix for pyqt6

* comment out tests
2021-04-26 12:22:11 -04:00
Talley Lambert
7d0ab56d54 fix for pyqt6 2021-04-26 12:19:08 -04:00
Talley Lambert
4edcdf4941 Merge branch 'main' of https://github.com/tlambert03/PyQRangeSlider into main 2021-04-26 10:08:53 -04:00
Talley Lambert
b651e2b757 fix gradients in bar 2021-04-26 09:57:34 -04:00
Talley Lambert
7ad87f9dc6 Update issue templates 2021-04-25 17:42:41 -04:00
49 changed files with 3161 additions and 881 deletions

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

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

View File

@@ -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.12.0
rev: v2.19.1
hooks:
- id: pyupgrade
args: [--py37-plus]
- repo: https://github.com/psf/black
rev: 20.8b1
rev: 21.5b2
hooks:
- id: black
- repo: https://github.com/PyCQA/flake8
rev: 3.9.0
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.

210
README.md
View File

@@ -1,199 +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>
------
- [Labeled Sliders](docs/sliders.md#labeled-sliders) (sliders with linked
spinboxes)
## API
<img src="https://raw.githubusercontent.com/napari/superqt/main/docs/images/labeled_qslider.png" alt="range sliders" width=680>
To create a slider:
<img src="https://raw.githubusercontent.com/napari/superqt/main/docs/images/labeled_range.png" alt="range sliders" width=680>
```python
from qtrangeslider import QRangeSlider
- Unbound Integer SpinBox (backed by python `int`)
# as usual:
# you must create a QApplication before create a widget.
range_slider = QRangeSlider()
```
## Contributing
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.)
We welcome contributions!
### `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> |
------
## Example
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
![mac](images/demo_darwin10.png)
##### Big Sur
![mac](images/demo_darwin11.png)
### Windows
![mac](images/demo_windows.png)
### Linux
![mac](images/demo_linux.png)
## 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

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.0 KiB

Binary file not shown.

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,11 @@
from qtrangeslider import QRangeSlider
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()
slider = QRangeSlider(Qt.Horizontal)
slider = QRangeSlider(Qt.Horizontal)
slider.setValue((20, 80))
slider.show()

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 {
@@ -9,27 +9,27 @@ QSlider {
QSlider::groove:horizontal {
border: 0px;
background: qlineargradient(x1:0, y1:0, x2:1, y2:1, stop:0 #777, stop:1 #aaa);
background: qlineargradient(x1:0, y1:0, x2:1, y2:1, stop:0 #888, stop:1 #ddd);
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);
background: qradialgradient(cx:0, cy:0, radius: 1.2, fx:0.35,
fy:0.3, stop:0 #eef, stop:1 #002);
height: 20px;
width: 20px;
border-radius: 10px;
}
QSlider::sub-page:horizontal {
background: #447;
background: qlineargradient(x1:0, y1:0, x2:1, y2:1, stop:0 #227, stop:1 #77a);
border-top-left-radius: 10px;
border-bottom-left-radius: 10px;
}
QRangeSlider {
qproperty-barColor: #447;
qproperty-barColor: qlineargradient(x1:0, y1:0, x2:1, y2:1, stop:0 #227, stop:1 #77a);
}
"""
@@ -106,18 +106,16 @@ if __name__ == "__main__":
import sys
from pathlib import Path
QtW.QApplication.setAttribute(QtCore.Qt.AA_EnableHighDpiScaling)
dest = Path("screenshots")
dest.mkdir(exist_ok=True)
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_()

27
examples/float.py Normal file
View File

@@ -0,0 +1,27 @@
from superqt import QDoubleRangeSlider, QDoubleSlider, QRangeSlider
from superqt.qtcompat.QtCore import Qt
from superqt.qtcompat.QtWidgets import QApplication, QVBoxLayout, QWidget
app = QApplication([])
w = QWidget()
sld1 = QDoubleSlider(Qt.Horizontal)
sld2 = QDoubleRangeSlider(Qt.Horizontal)
rs = QRangeSlider(Qt.Horizontal)
sld1.valueChanged.connect(lambda e: print("doubslider valuechanged", e))
sld2.setMaximum(1)
sld2.setValue((0.2, 0.8))
sld2.valueChanged.connect(lambda e: print("valueChanged", e))
sld2.sliderMoved.connect(lambda e: print("sliderMoved", e))
sld2.rangeChanged.connect(lambda e, f: print("rangeChanged", (e, f)))
w.setLayout(QVBoxLayout())
w.layout().addWidget(sld1)
w.layout().addWidget(sld2)
w.layout().addWidget(rs)
w.show()
w.resize(500, 150)
app.exec_()

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

45
examples/labeled.py Normal file
View File

@@ -0,0 +1,45 @@
from superqt import (
QLabeledDoubleRangeSlider,
QLabeledDoubleSlider,
QLabeledRangeSlider,
QLabeledSlider,
)
from superqt.qtcompat.QtCore import Qt
from superqt.qtcompat.QtWidgets import QApplication, QHBoxLayout, QVBoxLayout, QWidget
app = QApplication([])
ORIENTATION = Qt.Horizontal
w = QWidget()
qls = QLabeledSlider(ORIENTATION)
qls.valueChanged.connect(lambda e: print("qls valueChanged", e))
qls.setRange(0, 500)
qls.setValue(300)
qlds = QLabeledDoubleSlider(ORIENTATION)
qlds.valueChanged.connect(lambda e: print("qlds valueChanged", e))
qlds.setRange(0, 1)
qlds.setValue(0.5)
qlds.setSingleStep(0.1)
qlrs = QLabeledRangeSlider(ORIENTATION)
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))
w.setLayout(QVBoxLayout() if ORIENTATION == Qt.Horizontal else QHBoxLayout())
w.layout().addWidget(qls)
w.layout().addWidget(qlds)
w.layout().addWidget(qlrs)
w.layout().addWidget(qldrs)
w.show()
w.resize(500, 150)
app.exec_()

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,8 +0,0 @@
try:
from ._version import version as __version__
except ImportError:
__version__ = "unknown"
from ._qrangeslider import QRangeSlider
__all__ = ["QRangeSlider"]

View File

@@ -1,551 +0,0 @@
import textwrap
from collections import abc
from typing import List, Sequence, Tuple
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(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("")
# ############### 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(tuple(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=i == len(val) - 1)
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:
if not self.hasTracking():
self.update()
if self.isSliderDown():
self.sliderMoved.emit(tuple(self._position))
if self.hasTracking():
self.triggerAction(QSlider.SliderMove)
def _offsetAllPositions(self, offset: int, ref=None) -> None:
if ref is None:
ref = self._position
_new = [i - offset for i in ref]
if self._bar_is_rigid:
# FIXME: if there is an overflow ... it should still hit the edge.
if all(self.minimum() <= i <= self.maximum() for i in _new):
self.setSliderPosition(_new)
else:
self.setSliderPosition(_new)
def _getStyleOption(self) -> QStyleOptionSlider:
opt = QStyleOptionSlider()
self.initStyleOption(opt)
opt.sliderValue = 0
opt.sliderPosition = 0
return opt
def _getBarColor(self):
return self._style.brush_active or QtGui.QColor()
def _setBarColor(self, color):
self._style.brush_active = color
barColor = Property(QtGui.QColor, _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 = 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, handle_index: int = None) -> QRect:
"""Return the QRect for all handles."""
style = self.style().proxy()
if handle_index is not None: # get specific handle rect
opt.sliderPosition = self._position[handle_index]
return style.subControlRect(
QStyle.CC_Slider, opt, QStyle.SC_SliderHandle, self
)
else:
rects = []
for p in self._position:
opt.sliderPosition = 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
return QStyle.sliderValueFromPosition(
self.minimum(),
self.maximum(),
pos - sliderMin,
sliderMax - sliderMin,
opt.upsideDown,
)
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()
self._offsetAllPositions(-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,10 +0,0 @@
import pytest
from qtrangeslider import QRangeSlider
from qtrangeslider.qtcompat.QtCore import Qt
@pytest.mark.parametrize("orientation", ["Horizontal", "Vertical"])
def test_basic(qtbot, orientation):
rs = QRangeSlider(getattr(Qt, orientation))
qtbot.addWidget(rs)

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,46 +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"],
)

28
superqt/__init__.py Normal file
View File

@@ -0,0 +1,28 @@
"""superqt is a collection of QtWidgets for python."""
try:
from ._version import version as __version__
except ImportError:
__version__ = "unknown"
from .sliders import (
QDoubleRangeSlider,
QDoubleSlider,
QLabeledDoubleRangeSlider,
QLabeledDoubleSlider,
QLabeledRangeSlider,
QLabeledSlider,
QRangeSlider,
)
from .spinbox import QLargeIntSpinBox
__all__ = [
"QDoubleRangeSlider",
"QDoubleSlider",
"QLabeledDoubleRangeSlider",
"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
@@ -22,7 +21,7 @@ elif PYQT6:
# backwards compat with PyQt5
# namespace moves:
for cls in (QStyle, QSlider):
for cls in (QStyle, QSlider, QSizePolicy, QSpinBox):
for attr in dir(cls):
if not attr[0].isupper():
continue

View File

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

493
superqt/sliders/_labeled.py Normal file
View File

@@ -0,0 +1,493 @@
from enum import IntEnum
from functools import partial
from ..qtcompat.QtCore import QPoint, QSize, Qt, Signal
from ..qtcompat.QtGui import QFontMetrics, QValidator
from ..qtcompat.QtWidgets import (
QAbstractSlider,
QApplication,
QDoubleSpinBox,
QHBoxLayout,
QSlider,
QSpinBox,
QStyle,
QStyleOptionSpinBox,
QVBoxLayout,
QWidget,
)
from ._sliders import QDoubleRangeSlider, QDoubleSlider, QRangeSlider
class LabelPosition(IntEnum):
NoLabel = 0
LabelsAbove = 1
LabelsBelow = 2
LabelsRight = 1
LabelsLeft = 2
class EdgeLabelMode(IntEnum):
NoLabel = 0
LabelIsRange = 1
LabelIsValue = 2
class _SliderProxy:
_slider: QSlider
def value(self):
return self._slider.value()
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()
def setMinimum(self, minimum):
self._slider.setMinimum(minimum)
def maximum(self):
return self._slider.maximum()
def setMaximum(self, maximum):
self._slider.setMaximum(maximum)
def singleStep(self):
return self._slider.singleStep()
def setSingleStep(self, step):
self._slider.setSingleStep(step)
def pageStep(self):
return self._slider.pageStep()
def setPageStep(self, step) -> None:
self._slider.setPageStep(step)
def setRange(self, min, max) -> None:
self._slider.setRange(min, max)
def tickInterval(self):
return self._slider.tickInterval()
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, **kwargs) -> None:
parent, orientation = _handle_overloaded_slider_sig(args, kwargs)
super().__init__(parent)
self._slider = self._slider_class()
self._label = SliderLabel(self._slider, connect=self._slider.setValue)
self._slider.rangeChanged.connect(self.rangeChanged.emit)
self._slider.valueChanged.connect(self.valueChanged.emit)
self._slider.valueChanged.connect(self._label.setValue)
self.setOrientation(orientation)
def setOrientation(self, orientation):
"""Set orientation, value will be 'horizontal' or 'vertical'."""
self._slider.setOrientation(orientation)
if orientation == Qt.Vertical:
layout = QVBoxLayout()
layout.addWidget(self._slider, alignment=Qt.AlignHCenter)
layout.addWidget(self._label, alignment=Qt.AlignHCenter)
self._label.setAlignment(Qt.AlignCenter)
layout.setSpacing(1)
else:
layout = QHBoxLayout()
layout.addWidget(self._slider)
layout.addWidget(self._label)
self._label.setAlignment(Qt.AlignRight)
layout.setSpacing(6)
old_layout = self.layout()
if old_layout is not None:
QWidget().setLayout(old_layout)
layout.setContentsMargins(0, 0, 0, 0)
self.setLayout(layout)
class QLabeledDoubleSlider(QLabeledSlider):
_slider_class = QDoubleSlider
_slider: QDoubleSlider
valueChanged = Signal(float)
rangeChanged = Signal(float, float)
def __init__(self, *args, **kwargs) -> None:
super().__init__(*args, **kwargs)
self.setDecimals(2)
def decimals(self) -> int:
return self._label.decimals()
def setDecimals(self, prec: int):
self._label.setDecimals(prec)
class QLabeledRangeSlider(_SliderProxy, QAbstractSlider):
valueChanged = Signal(tuple)
LabelPosition = LabelPosition
EdgeLabelMode = EdgeLabelMode
_slider_class = QRangeSlider
_slider: QRangeSlider
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 = []
self._handle_label_position: LabelPosition = LabelPosition.LabelsAbove
# for fine tuning label position
self.label_shift_x = 0
self.label_shift_y = 0
self._slider = self._slider_class()
self._slider.valueChanged.connect(self.valueChanged.emit)
self._slider.rangeChanged.connect(self.rangeChanged.emit)
self._min_label = SliderLabel(
self._slider, alignment=Qt.AlignLeft, connect=self._min_label_edited
)
self._max_label = SliderLabel(
self._slider, alignment=Qt.AlignRight, connect=self._max_label_edited
)
self.setEdgeLabelMode(EdgeLabelMode.LabelIsRange)
self._slider.valueChanged.connect(self._on_value_changed)
self._slider.rangeChanged.connect(self._on_range_changed)
self._on_value_changed(self._slider.value())
self._on_range_changed(self._slider.minimum(), self._slider.maximum())
self.setOrientation(orientation)
def handleLabelPosition(self) -> LabelPosition:
return self._handle_label_position
def setHandleLabelPosition(self, opt: LabelPosition) -> LabelPosition:
self._handle_label_position = opt
for lbl in self._handle_labels:
if not opt:
lbl.hide()
else:
lbl.show()
self.setOrientation(self.orientation())
def edgeLabelMode(self) -> EdgeLabelMode:
return self._edge_label_mode
def setEdgeLabelMode(self, opt: EdgeLabelMode):
self._edge_label_mode = opt
if not self._edge_label_mode:
self._min_label.hide()
self._max_label.hide()
else:
if self.isVisible():
self._min_label.show()
self._max_label.show()
self._min_label.setMode(opt)
self._max_label.setMode(opt)
if opt == EdgeLabelMode.LabelIsValue:
v0, *_, v1 = self._slider.value()
self._min_label.setValue(v0)
self._max_label.setValue(v1)
elif opt == EdgeLabelMode.LabelIsRange:
self._min_label.setValue(self._slider.minimum())
self._max_label.setValue(self._slider.maximum())
QApplication.processEvents()
self._reposition_labels()
def _reposition_labels(self):
if not self._handle_labels:
return
horizontal = self.orientation() == Qt.Horizontal
labels_above = self._handle_label_position == LabelPosition.LabelsAbove
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:
if horizontal:
dy *= 3
else:
dx *= -1
else:
if horizontal:
dy *= -1
else:
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:
self.setMinimum(val)
else:
v = list(self._slider.value())
v[0] = val
self.setValue(v)
self._reposition_labels()
def _max_label_edited(self, val):
if self._edge_label_mode == EdgeLabelMode.LabelIsRange:
self.setMaximum(val)
else:
v = list(self._slider.value())
v[-1] = val
self.setValue(v)
self._reposition_labels()
def _on_value_changed(self, v):
if self._edge_label_mode == EdgeLabelMode.LabelIsValue:
self._min_label.setValue(v[0])
self._max_label.setValue(v[-1])
if len(v) != len(self._handle_labels):
for lbl in self._handle_labels:
lbl.setParent(None)
lbl.deleteLater()
self._handle_labels.clear()
for n, val in enumerate(self._slider.value()):
_cb = partial(self._slider.setSliderPosition, index=n)
s = SliderLabel(self._slider, parent=self, connect=_cb)
s.setValue(val)
self._handle_labels.append(s)
else:
for val, label in zip(v, self._handle_labels):
label.setValue(val)
self._reposition_labels()
def _on_range_changed(self, min, max):
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:
self._min_label.setValue(min)
self._max_label.setValue(max)
self._reposition_labels()
# def setValue(self, value) -> None:
# super().setValue(value)
# self.sliderChange(QSlider.SliderValueChange)
def setRange(self, min, max) -> None:
self._on_range_changed(min, max)
def setOrientation(self, orientation):
"""Set orientation, value will be 'horizontal' or 'vertical'."""
self._slider.setOrientation(orientation)
if orientation == Qt.Vertical:
layout = QVBoxLayout()
layout.setSpacing(1)
layout.addWidget(self._max_label)
layout.addWidget(self._slider)
layout.addWidget(self._min_label)
# TODO: set margins based on label width
if self._handle_label_position == LabelPosition.LabelsLeft:
marg = (30, 0, 0, 0)
elif self._handle_label_position == LabelPosition.NoLabel:
marg = (0, 0, 0, 0)
else:
marg = (0, 0, 20, 0)
layout.setAlignment(Qt.AlignCenter)
else:
layout = QHBoxLayout()
layout.setSpacing(7)
if self._handle_label_position == LabelPosition.LabelsBelow:
marg = (0, 0, 0, 25)
elif self._handle_label_position == LabelPosition.NoLabel:
marg = (0, 0, 0, 0)
else:
marg = (0, 25, 0, 0)
layout.addWidget(self._min_label)
layout.addWidget(self._slider)
layout.addWidget(self._max_label)
# remove old layout
old_layout = self.layout()
if old_layout is not None:
QWidget().setLayout(old_layout)
self.setLayout(layout)
layout.setContentsMargins(*marg)
super().setOrientation(orientation)
QApplication.processEvents()
self._reposition_labels()
def resizeEvent(self, a0) -> None:
super().resizeEvent(a0)
self._reposition_labels()
class QLabeledDoubleRangeSlider(QLabeledRangeSlider):
_slider_class = QDoubleRangeSlider
_slider: QDoubleRangeSlider
rangeChanged = Signal(float, float)
def __init__(self, *args, **kwargs) -> None:
super().__init__(*args, **kwargs)
self.setDecimals(2)
def decimals(self) -> int:
return self._min_label.decimals()
def setDecimals(self, prec: int):
self._min_label.setDecimals(prec)
self._max_label.setDecimals(prec)
for lbl in self._handle_labels:
lbl.setDecimals(prec)
class SliderLabel(QDoubleSpinBox):
def __init__(
self, slider: QSlider, parent=None, alignment=Qt.AlignCenter, connect=None
) -> None:
super().__init__(parent=parent)
self._slider = slider
self.setFocusPolicy(Qt.ClickFocus)
self.setMode(EdgeLabelMode.LabelIsValue)
self.setDecimals(0)
self.setRange(slider.minimum(), slider.maximum())
slider.rangeChanged.connect(self._update_size)
self.setAlignment(alignment)
self.setButtonSymbols(QSpinBox.NoButtons)
self.setStyleSheet("background:transparent; border: 0;")
if connect is not None:
self.editingFinished.connect(lambda: connect(self.value()))
self.editingFinished.connect(self.clearFocus)
self._update_size()
def setDecimals(self, prec: int) -> None:
super().setDecimals(prec)
self._update_size()
def _update_size(self, *_):
# fontmetrics to measure the width of text
fm = QFontMetrics(self.font())
h = self.sizeHint().height()
fixed_content = self.prefix() + self.suffix() + " "
if self._mode == EdgeLabelMode.LabelIsValue:
# determine width based on min/max/specialValue
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_width(fm, self.specialValueText()))
else:
w = max(0, _fm_width(fm, self.textFromValue(self.value()))) + 3
w += 3 # cursor blinking space
# get the final size hint
opt = QStyleOptionSpinBox()
self.initStyleOption(opt)
size = self.style().sizeFromContents(QStyle.CT_SpinBox, opt, QSize(w, h), self)
self.setFixedSize(size)
def setValue(self, val):
super().setValue(val)
if self._mode == EdgeLabelMode.LabelIsRange:
self._update_size()
def setMaximum(self, max: int) -> None:
super().setMaximum(max)
if self._mode == EdgeLabelMode.LabelIsValue:
self._update_size()
def setMinimum(self, min: int) -> None:
super().setMinimum(min)
if self._mode == EdgeLabelMode.LabelIsValue:
self._update_size()
def setMode(self, opt: EdgeLabelMode):
# when the edge labels are controlling slider range,
# we want them to have a big range, but not have a huge label
self._mode = opt
if opt == EdgeLabelMode.LabelIsRange:
self.setMinimum(-9999999)
self.setMaximum(9999999)
try:
self._slider.rangeChanged.disconnect(self.setRange)
except Exception:
pass
else:
self.setMinimum(self._slider.minimum())
self.setMaximum(self._slider.maximum())
self._slider.rangeChanged.connect(self.setRange)
self._update_size()
def validate(self, input: str, pos: int):
# fake like an integer spinbox
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,58 +1,78 @@
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.QtCore import Qt
from .qtcompat.QtGui import (
from ..qtcompat import PYQT_VERSION
from ..qtcompat.QtCore import Qt
from ..qtcompat.QtGui import (
QBrush,
QColor,
QGradient,
QLinearGradient,
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) -> Union[QGradient, QColor]:
def brush(self, opt: QStyleOptionSlider) -> QBrush:
cg = opt.palette.currentColorGroup()
attr = {
QPalette.Active: "brush_active", # 0
QPalette.Disabled: "brush_disabled", # 1
QPalette.Inactive: "brush_inactive", # 2
}[cg]
val = getattr(self, attr) or getattr(SYSTEM_STYLE, attr)
if isinstance(val, str):
val = QColor(val)
_val = getattr(self, attr)
if not _val:
if self.has_stylesheet:
# if someone set a general style sheet but didn't specify
# :active, :inactive, etc... then Qt just uses whatever they
# DID specify
for i in ("active", "inactive", "disabled"):
_val = getattr(self, f"brush_{i}")
if _val:
break
else:
_val = getattr(SYSTEM_STYLE, attr)
if not val:
return Qt.NoBrush
if _val is None:
return QBrush()
if isinstance(_val, str):
val = QColor(_val)
if not val.isValid():
val = parse_color(_val, default_attr=attr)
else:
val = _val
if opt.tickPosition != QSlider.NoTicks:
val.setAlphaF(self.tick_bar_alpha or SYSTEM_STYLE.tick_bar_alpha)
return val
return QBrush(val)
def pen(self, opt: QStyleOptionSlider) -> Union[Qt.PenStyle, QColor]:
def pen(self, opt: QStyleOptionSlider) -> Qt.PenStyle | QColor:
cg = opt.palette.currentColorGroup()
attr = {
QPalette.Active: "pen_active", # 0
@@ -77,9 +97,9 @@ class RangeSliderStyle:
off += self.h_offset or SYSTEM_STYLE.h_offset or 0
else:
off += self.v_offset or SYSTEM_STYLE.v_offset or 0
if tp & QSlider.TicksAbove:
if tp == QSlider.TicksAbove:
off += self.tick_offset or SYSTEM_STYLE.tick_offset
elif tp & QSlider.TicksBelow:
elif tp == QSlider.TicksBelow:
off -= self.tick_offset or SYSTEM_STYLE.tick_offset
return off
@@ -119,6 +139,9 @@ CATALINA_STYLE = replace(
tick_offset=4,
)
if PYQT_VERSION and int(PYQT_VERSION.split(".")[0]) == 6:
CATALINA_STYLE = replace(CATALINA_STYLE, tick_offset=2)
BIG_SUR_STYLE = replace(
CATALINA_STYLE,
brush_active="#0A81FE",
@@ -131,6 +154,9 @@ BIG_SUR_STYLE = replace(
tick_bar_alpha=0.2,
)
if PYQT_VERSION and int(PYQT_VERSION.split(".")[0]) == 6:
BIG_SUR_STYLE = replace(BIG_SUR_STYLE, tick_offset=-3)
WINDOWS_STYLE = replace(
BASE_STYLE,
brush_active="#550179D7",
@@ -202,7 +228,7 @@ rgba_pattern = re.compile(
)
def parse_color(color: str) -> Union[str, QGradient]:
def parse_color(color: str, default_attr) -> QColor | QGradient:
qc = QColor(color)
if qc.isValid():
return qc
@@ -215,7 +241,7 @@ def parse_color(color: str) -> Union[str, 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
@@ -223,16 +249,16 @@ def parse_color(color: str) -> Union[str, 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
# fallback to dark gray
return "#333"
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()
@@ -240,25 +266,8 @@ def update_styles_from_stylesheet(obj: "QRangeSlider"):
qss = parent.styleSheet() + qss
parent = parent.parent()
qss = QApplication.instance().styleSheet() + qss
# obj._style.has_stylesheet = False
# # Find bar color
# # TODO: optional horizontal or vertical
# match = re.search(r"Slider::sub-page:?([^{\s]*)?\s*{\s*([^}]+)}", qss, re.S)
# if match:
# orientation, content = match.groups()
# for line in reversed(content.splitlines()):
# bgrd = re.search(r"background(-color)?:\s*([^;]+)", line)
# if bgrd:
# color = parse_color(bgrd.groups()[-1])
# obj._style.brush_active = color
# # TODO: parse for inactive and disabled
# obj._style.brush_inactive = color
# obj._style.brush_disabled = color
# obj._style.has_stylesheet = True
# obj.update()
# break
if not qss:
return
# Find bar height/width
for orient, dim in (("horizontal", "height"), ("vertical", "width")):

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

@@ -0,0 +1,124 @@
import os
import pytest
from superqt import (
QDoubleRangeSlider,
QDoubleSlider,
QLabeledDoubleRangeSlider,
QLabeledDoubleSlider,
)
from superqt.qtcompat import API_NAME
range_types = {QDoubleRangeSlider, QLabeledDoubleRangeSlider}
@pytest.fixture(
params=[
QDoubleSlider,
QLabeledDoubleSlider,
QDoubleRangeSlider,
QLabeledDoubleRangeSlider,
]
)
def ds(qtbot, request):
# convenience fixture that converts value() and setValue()
# to let us use setValue((a, b)) for both range and non-range sliders
cls = request.param
wdg = cls()
qtbot.addWidget(wdg)
def assert_val_type():
type_ = float
if cls in range_types:
assert all([isinstance(i, type_) for i in wdg.value()]) # sourcery skip
else:
assert isinstance(wdg.value(), type_)
def assert_val_eq(val):
assert wdg.value() == val if cls is QDoubleRangeSlider else val[0]
wdg.assert_val_type = assert_val_type
wdg.assert_val_eq = assert_val_eq
if cls not in range_types:
superset = wdg.setValue
def _safe_set(val):
superset(val[0] if isinstance(val, tuple) else val)
wdg.setValue = _safe_set
return wdg
def test_double_sliders(ds):
ds.setMinimum(10)
ds.setMaximum(99)
ds.setValue((20, 40))
ds.setSingleStep(1)
assert ds.minimum() == 10
assert ds.maximum() == 99
ds.assert_val_eq((20, 40))
assert ds.singleStep() == 1
ds.assert_val_eq((20, 40))
ds.assert_val_type()
ds.setValue((20.23, 40.23))
ds.assert_val_eq((20.23, 40.23))
ds.assert_val_type()
assert ds.minimum() == 10
assert ds.maximum() == 99
assert ds.singleStep() == 1
ds.assert_val_eq((20.23, 40.23))
ds.setValue((20.2343, 40.2342))
ds.assert_val_eq((20.2343, 40.2342))
ds.assert_val_eq((20.2343, 40.2342))
assert ds.minimum() == 10
assert ds.maximum() == 99
assert ds.singleStep() == 1
ds.assert_val_eq((20.2343, 40.2342))
assert ds.minimum() == 10
assert ds.maximum() == 99
assert ds.singleStep() == 1
def test_double_sliders_small(ds):
ds.setMaximum(1)
ds.setValue((0.5, 0.9))
assert ds.minimum() == 0
assert ds.maximum() == 1
ds.assert_val_eq((0.5, 0.9))
ds.setValue((0.122233, 0.72644353))
ds.assert_val_eq((0.122233, 0.72644353))
def test_double_sliders_big(ds):
ds.setValue((20, 80))
ds.setMaximum(5e14)
assert ds.minimum() == 0
assert ds.maximum() == 5e14
ds.setValue((1.74e9, 1.432e10))
ds.assert_val_eq((1.74e9, 1.432e10))
@pytest.mark.skipif(
os.name == "nt" and API_NAME == "PyQt6", reason="Not ready for pyqt6"
)
def test_signals(ds, qtbot):
with qtbot.waitSignal(ds.valueChanged):
ds.setValue((10, 20))
with qtbot.waitSignal(ds.rangeChanged):
ds.setMinimum(0.5)
with qtbot.waitSignal(ds.rangeChanged):
ds.setMaximum(3.7)
with qtbot.waitSignal(ds.rangeChanged):
ds.setRange(1.2, 3.3)

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}