Compare commits
35 Commits
Author | SHA1 | Date | |
---|---|---|---|
|
7e64be7d9d | ||
|
eeb4413678 | ||
|
f1cfe11c1a | ||
|
5a55a74670 | ||
|
27bcfc4c8e | ||
|
40b34213fb | ||
|
297838e895 | ||
|
15e3af4985 | ||
|
b12e5471a0 | ||
|
d93787e35a | ||
|
d04ca7a4b3 | ||
|
b6900b8b14 | ||
|
19779c6fb7 | ||
|
24b67d00e4 | ||
|
10feb74656 | ||
|
96f9a5cd90 | ||
|
f76cf6d126 | ||
|
a27b388f3e | ||
|
21523dee82 | ||
|
9471796fe5 | ||
|
a6b0518be5 | ||
|
592f0d75ba | ||
|
2897a18851 | ||
|
59c5dec044 | ||
|
1340bfa371 | ||
|
7d0ab56d54 | ||
|
4edcdf4941 | ||
|
b651e2b757 | ||
|
7ad87f9dc6 | ||
|
7d323240be | ||
|
e56d96fa5a | ||
|
69203f878f | ||
|
e8594d8b40 | ||
|
01f496bc18 | ||
|
75b29bc600 |
29
.github/ISSUE_TEMPLATE/bug_report.md
vendored
Normal 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
|
20
.github/workflows/test_and_deploy.yml
vendored
@@ -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
|
||||
|
@@ -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
@@ -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`.
|
2
LICENSE
@@ -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.
|
||||
|
||||
|
185
README.md
@@ -1,174 +1,47 @@
|
||||
# QtRangeSlider
|
||||
#  superqt!
|
||||
|
||||
[](https://github.com/tlambert03/QtRangeSlider/raw/master/LICENSE)
|
||||
[](https://pypi.org/project/QtRangeSlider)
|
||||
|
||||
[](https://github.com/napari/superqt/raw/master/LICENSE)
|
||||
[](https://pypi.org/project/superqt)
|
||||
[](https://python.org)
|
||||
[](https://github.com/tlambert03/QtRangeSlider/actions/workflows/test_and_deploy.yml)
|
||||
[](https://codecov.io/gh/tlambert03/QtRangeSlider)
|
||||
Version](https://img.shields.io/pypi/pyversions/superqt.svg?color=green)](https://python.org)
|
||||
[](https://github.com/napari/superqt/actions/workflows/test_and_deploy.yml)
|
||||
[](https://codecov.io/gh/napari/superqt)
|
||||
|
||||
**The missing multi-handle range slider widget for PyQt & PySide**
|
||||
### "missing" widgets and components for PyQt/PySide
|
||||
|
||||

|
||||
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, ...])
|
||||
```
|
||||
|
||||
------
|
||||
|
||||
## 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 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 QSlider, it will also inherit styles */
|
||||
QSlider::groove:horizontal {
|
||||
border: 0px;
|
||||
background: qlineargradient(x1:0, y1:0, x2:1, y2:1, stop:0 #FDE282, stop:1 #EB9A5D);
|
||||
height: 16px;
|
||||
border-radius: 2px;
|
||||
}
|
||||
|
||||
QSlider::handle:horizontal {
|
||||
background: #271848;
|
||||
border: 1px solid #583856;
|
||||
width: 18px;
|
||||
margin: -2px 0;
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
QSlider::handle:hover {
|
||||
background-color: #2F4F4F;
|
||||
}
|
||||
|
||||
/* "QSlider::sub-page" will style the "bar" area between the QRangeSlider handles */
|
||||
QSlider::sub-page:horizontal {
|
||||
background: #AF5A50;
|
||||
border-radius: 2px;
|
||||
}
|
||||
```
|
||||
|
||||
</details>
|
||||
|
||||
### macOS
|
||||
|
||||
##### Catalina
|
||||

|
||||
|
||||
##### Big Sur
|
||||

|
||||
|
||||
### Windows
|
||||
|
||||

|
||||
|
||||
### Linux
|
||||
|
||||

|
||||
|
||||
## 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)
|
||||
|
@@ -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
|
||||
|
BIN
docs/images/demo_darwin10.png
Normal file
After Width: | Height: | Size: 42 KiB |
BIN
docs/images/demo_darwin11.png
Normal file
After Width: | Height: | Size: 45 KiB |
BIN
docs/images/demo_linux.png
Normal file
After Width: | Height: | Size: 24 KiB |
BIN
docs/images/demo_windows.png
Normal file
After Width: | Height: | Size: 9.0 KiB |
BIN
docs/images/labeled_qslider.png
Normal file
After Width: | Height: | Size: 9.0 KiB |
BIN
docs/images/labeled_range.png
Normal file
After Width: | Height: | Size: 12 KiB |
Before Width: | Height: | Size: 86 KiB After Width: | Height: | Size: 86 KiB |
238
docs/sliders.md
Normal file
@@ -0,0 +1,238 @@
|
||||
# Sliders
|
||||
|
||||
|
||||

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

|
||||
|
||||
##### Big Sur
|
||||
|
||||

|
||||
|
||||
#### Windows
|
||||
|
||||

|
||||
|
||||
#### Linux
|
||||
|
||||

|
||||
|
||||
## Labeled Sliders
|
||||
|
||||
This package also includes two "labeled" slider variants. One for `QRangeSlider`, and one for the native `QSlider`:
|
||||
|
||||
### `QLabeledRangeSlider`
|
||||
|
||||

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

|
||||
|
||||
```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
|
||||
```
|
@@ -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
@@ -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_()
|
@@ -1,33 +1,36 @@
|
||||
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 {
|
||||
min-height: 20px;
|
||||
}
|
||||
|
||||
QSlider::groove:horizontal {
|
||||
border: 0px;
|
||||
background: qlineargradient(x1:0, y1:0, x2:1, y2:1, stop:0 #FDE282, stop:1 #EB9A5D);
|
||||
height: 16px;
|
||||
border-radius: 2px;
|
||||
border: 0px;
|
||||
background: qlineargradient(x1:0, y1:0, x2:1, y2:1, stop:0 #888, stop:1 #ddd);
|
||||
height: 20px;
|
||||
border-radius: 10px;
|
||||
}
|
||||
|
||||
QSlider::handle:horizontal {
|
||||
background: #271848;
|
||||
border: 1px solid #583856;
|
||||
width: 18px;
|
||||
margin: -2px 0;
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
QSlider::handle:hover {
|
||||
background-color: #2F4F4F;
|
||||
QSlider::handle {
|
||||
background: qradialgradient(cx:0, cy:0, radius: 1.2, fx:0.35,
|
||||
fy:0.3, stop:0 #eef, stop:1 #002);
|
||||
height: 20px;
|
||||
width: 20px;
|
||||
border-radius: 10px;
|
||||
}
|
||||
|
||||
QSlider::sub-page:horizontal {
|
||||
background: #AF5A50;
|
||||
border-radius: 2px;
|
||||
background: qlineargradient(x1:0, y1:0, x2:1, y2:1, stop:0 #227, stop:1 #77a);
|
||||
border-top-left-radius: 10px;
|
||||
border-bottom-left-radius: 10px;
|
||||
}
|
||||
|
||||
QRangeSlider {
|
||||
qproperty-barColor: qlineargradient(x1:0, y1:0, x2:1, y2:1, stop:0 #227, stop:1 #77a);
|
||||
}
|
||||
"""
|
||||
|
||||
|
||||
@@ -109,10 +112,10 @@ if __name__ == "__main__":
|
||||
app = QtW.QApplication([])
|
||||
demo = DemoWidget()
|
||||
|
||||
if "-x" in sys.argv:
|
||||
app.exec_()
|
||||
else:
|
||||
if "-snap" in sys.argv:
|
||||
import platform
|
||||
|
||||
QtW.QApplication.processEvents()
|
||||
demo.grab().save(str(dest / f"demo_{platform.system().lower()}.png"))
|
||||
else:
|
||||
app.exec_()
|
||||
|
27
examples/float.py
Normal 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
@@ -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
@@ -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_()
|
@@ -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([])
|
||||
|
||||
|
Before Width: | Height: | Size: 38 KiB |
Before Width: | Height: | Size: 41 KiB |
Before Width: | Height: | Size: 13 KiB |
Before Width: | Height: | Size: 34 KiB |
Before Width: | Height: | Size: 7.0 KiB |
3
pyproject.toml
Normal file
@@ -0,0 +1,3 @@
|
||||
# pyproject.toml
|
||||
[build-system]
|
||||
requires = ["setuptools>=45", "wheel", "setuptools_scm[toml]>=6.0"]
|
@@ -1,8 +0,0 @@
|
||||
try:
|
||||
from ._version import version as __version__
|
||||
except ImportError:
|
||||
__version__ = "unknown"
|
||||
|
||||
from ._qrangeslider import QRangeSlider
|
||||
|
||||
__all__ = ["QRangeSlider"]
|
@@ -1,526 +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 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)
|
||||
_DEFAULT_VALUE = (20, 80)
|
||||
|
||||
def __init__(self, *args):
|
||||
super().__init__(*args)
|
||||
|
||||
# list of values
|
||||
self._value: List[int] = self._DEFAULT_VALUE
|
||||
|
||||
# list of current positions of each handle. same length as _value
|
||||
# If tracking is enabled (the default) this will be identical to _value
|
||||
self._position: List[int] = self._DEFAULT_VALUE
|
||||
self._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()
|
||||
|
||||
# ############### Public API #######################
|
||||
|
||||
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 _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__, " ")
|
@@ -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)
|
79
setup.cfg
@@ -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,47 +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
|
||||
# https://github.com/pytest-dev/pytest-qt/pull/340
|
||||
pytest-qt @ git+https://github.com/The-Compiler/pytest-qt.git@pyqt6
|
||||
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
|
||||
|
6
setup.py
@@ -1,10 +1,6 @@
|
||||
"""
|
||||
PEP 517 doesn’t 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
@@ -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",
|
||||
]
|
@@ -1,4 +1,3 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
#
|
||||
# Copyright © 2014-2015 Colin Duquesnoy
|
||||
# Copyright © 2009- The Spyder Development Team
|
@@ -1,4 +1,3 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
#
|
||||
# Copyright © 2014-2015 Colin Duquesnoy
|
||||
# Copyright © 2009- The Spyder Development Team
|
@@ -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
|
@@ -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,
|
17
superqt/sliders/__init__.py
Normal 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",
|
||||
]
|
336
superqt/sliders/_generic_range_slider.py
Normal 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])
|
488
superqt/sliders/_generic_slider.py
Normal 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
@@ -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)
|
@@ -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",
|
||||
@@ -191,62 +217,57 @@ qradial_pattern = re.compile(
|
||||
re.X,
|
||||
)
|
||||
|
||||
rgba_pattern = re.compile(
|
||||
r"""
|
||||
rgba?\(
|
||||
(?P<r>\d+),\s*
|
||||
(?P<g>\d+),\s*
|
||||
(?P<b>\d+),?\s*(?P<a>\d+)?\)
|
||||
""",
|
||||
re.X,
|
||||
)
|
||||
|
||||
def parse_color(color: str) -> Union[str, QGradient]:
|
||||
|
||||
def parse_color(color: str, default_attr) -> QColor | QGradient:
|
||||
qc = QColor(color)
|
||||
if qc.isValid():
|
||||
return qc
|
||||
|
||||
# try linear gradient:
|
||||
match = qlineargrad_pattern.match(color)
|
||||
match = rgba_pattern.search(color)
|
||||
if match:
|
||||
grad = QLinearGradient(*[float(i) for i in match.groups()[:4]])
|
||||
rgba = [int(x) if x else 255 for x in match.groups()]
|
||||
return QColor(*rgba)
|
||||
|
||||
# try linear gradient:
|
||||
match = qlineargrad_pattern.search(color)
|
||||
if match:
|
||||
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
|
||||
|
||||
# try linear gradient:
|
||||
match = qradial_pattern.match(color)
|
||||
print("match", match.groupdict())
|
||||
match = qradial_pattern.search(color)
|
||||
if match:
|
||||
grad = QRadialGradient(*[float(i) for i in match.groups()[:5]])
|
||||
grad = 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()
|
||||
p = obj
|
||||
while p.parent():
|
||||
qss = p.styleSheet() + qss
|
||||
p = p.parent()
|
||||
|
||||
parent = obj.parent()
|
||||
while parent is not None:
|
||||
qss = parent.styleSheet() + qss
|
||||
parent = parent.parent()
|
||||
qss = QApplication.instance().styleSheet() + qss
|
||||
|
||||
obj._style.has_stylesheet = False
|
||||
|
||||
# Find bar color
|
||||
# TODO: optional horizontal or vertical
|
||||
match = re.search(r"Slider::sub-page:?([^{\s]*)?\s*{\s*([^}]+)}", qss, re.S)
|
||||
if match:
|
||||
orientation, content = match.groups()
|
||||
for line in reversed(content.splitlines()):
|
||||
bgrd = re.search(r"background(-color)?:\s*([^;]+)", line)
|
||||
if bgrd:
|
||||
color = parse_color(bgrd.groups()[-1])
|
||||
obj._style.brush_active = color
|
||||
# TODO: parse for inactive and disabled
|
||||
obj._style.brush_inactive = color
|
||||
obj._style.brush_disabled = color
|
||||
obj._style.has_stylesheet = True
|
||||
class_name = type(obj).__name__
|
||||
_ss = f"\n{class_name}::sub-page:{orientation}{{background: none}}"
|
||||
# TODO: block double event
|
||||
obj.setStyleSheet(qss + _ss)
|
||||
break
|
||||
if not qss:
|
||||
return
|
||||
|
||||
# Find bar height/width
|
||||
for orient, dim in (("horizontal", "height"), ("vertical", "width")):
|
42
superqt/sliders/_sliders.py
Normal 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__, " ")
|
0
superqt/sliders/_tests/__init__.py
Normal file
70
superqt/sliders/_tests/_testutil.py
Normal 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
|
124
superqt/sliders/_tests/test_float.py
Normal 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)
|
157
superqt/sliders/_tests/test_generic_slider.py
Normal 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())
|
156
superqt/sliders/_tests/test_range_slider.py
Normal 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))
|
222
superqt/sliders/_tests/test_single_value_sliders.py
Normal 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)
|
142
superqt/sliders/_tests/test_slider.py
Normal 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
|
3
superqt/spinbox/__init__.py
Normal file
@@ -0,0 +1,3 @@
|
||||
from ._intspin import QLargeIntSpinBox
|
||||
|
||||
__all__ = ["QLargeIntSpinBox"]
|
174
superqt/spinbox/_intspin.py
Normal 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))
|
73
superqt/spinbox/_tests/test_large_int_spinbox.py
Normal 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]
|
23
tox.ini
@@ -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,10 +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 = 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}
|
||||
|