Compare commits
21 Commits
Author | SHA1 | Date | |
---|---|---|---|
|
ba20665d57 | ||
|
939c5222af | ||
|
22beed7608 | ||
|
9a72d9d474 | ||
|
5202aba6a8 | ||
|
7e64be7d9d | ||
|
eeb4413678 | ||
|
f1cfe11c1a | ||
|
5a55a74670 | ||
|
27bcfc4c8e | ||
|
40b34213fb | ||
|
297838e895 | ||
|
15e3af4985 | ||
|
b12e5471a0 | ||
|
d93787e35a | ||
|
d04ca7a4b3 | ||
|
b6900b8b14 | ||
|
19779c6fb7 | ||
|
24b67d00e4 | ||
|
10feb74656 | ||
|
96f9a5cd90 |
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
|
||||
|
2
.gitignore
vendored
@@ -79,3 +79,5 @@ target/
|
||||
*/_version.py
|
||||
.vscode/settings.json
|
||||
screenshots
|
||||
|
||||
.mypy_cache
|
||||
|
@@ -1,23 +1,35 @@
|
||||
repos:
|
||||
- repo: https://github.com/pre-commit/pre-commit-hooks
|
||||
rev: v3.4.0
|
||||
rev: v4.0.1
|
||||
hooks:
|
||||
- id: end-of-file-fixer
|
||||
- id: trailing-whitespace
|
||||
- repo: https://github.com/asottile/setup-cfg-fmt
|
||||
rev: v1.17.0
|
||||
hooks:
|
||||
- id: setup-cfg-fmt
|
||||
- repo: https://github.com/PyCQA/flake8
|
||||
rev: 3.9.2
|
||||
hooks:
|
||||
- id: flake8
|
||||
additional_dependencies:
|
||||
[flake8-typing-imports==1.7.0]
|
||||
exclude: examples
|
||||
- repo: https://github.com/myint/autoflake
|
||||
rev: v1.4
|
||||
hooks:
|
||||
- id: autoflake
|
||||
args: ["--in-place", "--remove-all-unused-imports"]
|
||||
- repo: https://github.com/PyCQA/isort
|
||||
rev: 5.8.0
|
||||
hooks:
|
||||
- id: isort
|
||||
- repo: https://github.com/asottile/pyupgrade
|
||||
rev: v2.13.0
|
||||
rev: v2.19.1
|
||||
hooks:
|
||||
- id: pyupgrade
|
||||
args: [--py37-plus]
|
||||
- repo: https://github.com/psf/black
|
||||
rev: 21.4b0
|
||||
rev: 21.5b2
|
||||
hooks:
|
||||
- id: black
|
||||
- repo: https://github.com/PyCQA/flake8
|
||||
rev: 3.9.1
|
||||
hooks:
|
||||
- id: flake8
|
||||
pass_filenames: true
|
||||
|
54
CONTRIBUTING.md
Normal file
@@ -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.
|
||||
|
||||
|
271
README.md
@@ -1,262 +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>
|
||||
|
||||
------
|
||||
|
||||
## API
|
||||
- [Labeled Sliders](docs/sliders.md#labeled-sliders) (sliders with linked
|
||||
spinboxes)
|
||||
|
||||
To create a slider:
|
||||
<img src="https://raw.githubusercontent.com/napari/superqt/main/docs/images/labeled_qslider.png" alt="range sliders" width=680>
|
||||
|
||||
```python
|
||||
from qtrangeslider import QRangeSlider
|
||||
<img src="https://raw.githubusercontent.com/napari/superqt/main/docs/images/labeled_range.png" alt="range sliders" width=680>
|
||||
|
||||
# as usual:
|
||||
# you must create a QApplication before create a widget.
|
||||
range_slider = QRangeSlider()
|
||||
```
|
||||
- Unbound Integer SpinBox (backed by python `int`)
|
||||
|
||||
As `QRangeSlider` inherits from `QtWidgets.QSlider`, you can use all of the
|
||||
same methods available in the [QSlider API](https://doc.qt.io/qt-5/qslider.html). The major difference is that `value` and `sliderPosition` are reimplemented as `tuples` of `int` (where the length of the tuple is equal to the number of handles in the slider.)
|
||||
## Contributing
|
||||
|
||||
### `value: Tuple[int, ...]`
|
||||
We welcome contributions!
|
||||
|
||||
This property holds the current value of all handles in the slider.
|
||||
|
||||
The slider forces all values to be within the legal range:
|
||||
`minimum <= value <= maximum`.
|
||||
|
||||
Changing the value also changes the sliderPosition.
|
||||
|
||||
##### Access Functions:
|
||||
|
||||
```python
|
||||
range_slider.value() -> Tuple[int, ...]
|
||||
```
|
||||
|
||||
```python
|
||||
range_slider.setValue(val: Sequence[int]) -> None
|
||||
```
|
||||
|
||||
##### Notifier Signal:
|
||||
|
||||
```python
|
||||
valueChanged(Tuple[int, ...])
|
||||
```
|
||||
|
||||
### `sliderPosition: Tuple[int, ...]`
|
||||
|
||||
This property holds the current slider positions. It is a `tuple` with length equal to the number of handles.
|
||||
|
||||
If [tracking](https://doc.qt.io/qt-5/qabstractslider.html#tracking-prop) is enabled (the default), this is identical to [`value`](#value--tupleint-).
|
||||
|
||||
##### Access Functions:
|
||||
|
||||
```python
|
||||
range_slider.sliderPosition() -> Tuple[int, ...]
|
||||
```
|
||||
|
||||
```python
|
||||
range_slider.setSliderPosition(val: Sequence[int]) -> None
|
||||
```
|
||||
|
||||
##### Notifier Signal:
|
||||
|
||||
```python
|
||||
sliderMoved(Tuple[int, ...])
|
||||
```
|
||||
|
||||
### Additional properties
|
||||
|
||||
These options are in addition to the Qt QSlider API, and control the behavior of the bar between handles.
|
||||
|
||||
| getter | setter | type | default | description |
|
||||
| -------------------- | ------------------------------------------- | ------ | ------- | ------------------------------------------------------------------------------------------------ |
|
||||
| `barIsVisible` | `setBarIsVisible` <br>`hideBar` / `showBar` | `bool` | `True` | <small>Whether the bar between handles is visible.</small> |
|
||||
| `barMovesAllHandles` | `setBarMovesAllHandles` | `bool` | `True` | <small>Whether clicking on the bar moves all handles or just the nearest</small> |
|
||||
| `barIsRigid` | `setBarIsRigid` | `bool` | `True` | <small>Whether bar length is constant or "elastic" when dragging the bar beyond min/max.</small> |
|
||||
------
|
||||
|
||||
## Examples
|
||||
|
||||
These screenshots show `QRangeSlider` (multiple handles) next to the native `QSlider`
|
||||
(single handle). With no styles applied, `QRangeSlider` will match the native OS
|
||||
style of `QSlider` – with or without tick marks. When styles have been applied
|
||||
using [Qt Style Sheets](https://doc.qt.io/qt-5/stylesheet-reference.html), then
|
||||
`QRangeSlider` will inherit any styles applied to `QSlider` (since it inherits
|
||||
from QSlider). If you'd like to style `QRangeSlider` differently than `QSlider`,
|
||||
then you can also target it directly in your style sheet. The one "special"
|
||||
property for QRangeSlider is `qproperty-barColor`, which sets the color of the
|
||||
bar between the handles.
|
||||
|
||||
> The code for these example widgets is [here](examples/demo_widget.py)
|
||||
|
||||
<details>
|
||||
|
||||
<summary><em>See style sheet used for this example</em></summary>
|
||||
|
||||
```css
|
||||
/*
|
||||
Because QRangeSlider inherits from QSlider, it will also inherit styles
|
||||
*/
|
||||
QSlider {
|
||||
min-height: 20px;
|
||||
}
|
||||
|
||||
QSlider::groove:horizontal {
|
||||
border: 0px;
|
||||
background: qlineargradient(x1:0, y1:0, x2:1, y2:1,
|
||||
stop:0 #777, stop:1 #aaa);
|
||||
height: 20px;
|
||||
border-radius: 10px;
|
||||
}
|
||||
|
||||
QSlider::handle {
|
||||
background: qradialgradient(cx:0, cy:0, radius: 1.2, fx:0.5,
|
||||
fy:0.5, stop:0 #eef, stop:1 #000);
|
||||
height: 20px;
|
||||
width: 20px;
|
||||
border-radius: 10px;
|
||||
}
|
||||
|
||||
/*
|
||||
"QSlider::sub-page" is the one exception ...
|
||||
(it styles the area to the left of the QSlider handle)
|
||||
*/
|
||||
QSlider::sub-page:horizontal {
|
||||
background: #447;
|
||||
border-top-left-radius: 10px;
|
||||
border-bottom-left-radius: 10px;
|
||||
}
|
||||
|
||||
/*
|
||||
for QRangeSlider: use "qproperty-barColor". "sub-page" will not work.
|
||||
*/
|
||||
QRangeSlider {
|
||||
qproperty-barColor: #447;
|
||||
}
|
||||
```
|
||||
|
||||
</details>
|
||||
|
||||
### macOS
|
||||
|
||||
##### Catalina
|
||||

|
||||
|
||||
##### 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 qtrangeslider import QLabeledRangeSlider
|
||||
```
|
||||
|
||||
This has the same API as `QRangeSlider` with the following additional options:
|
||||
|
||||
#### `handleLabelPosition`/`setHandleLabelPosition`
|
||||
|
||||
Where/whether labels are shown adjacent to slider handles.
|
||||
|
||||
**type:** `QLabeledRangeSlider.LabelPosition`
|
||||
|
||||
**default:** `LabelPosition.LabelsAbove`
|
||||
|
||||
*options:*
|
||||
|
||||
- `LabelPosition.NoLabel` (no labels shown adjacent to handles)
|
||||
- `LabelPosition.LabelsAbove`
|
||||
- `LabelPosition.LabelsBelow`
|
||||
- `LabelPosition.LabelsRight` (alias for `LabelPosition.LabelsAbove`)
|
||||
- `LabelPosition.LabelsLeft` (alias for `LabelPosition.LabelsBelow`)
|
||||
|
||||
|
||||
#### `edgeLabelMode`/`setEdgeLabelMode`
|
||||
|
||||
**type:** `QLabeledRangeSlider.EdgeLabelMode`
|
||||
|
||||
**default:** `EdgeLabelMode.LabelsAbove`
|
||||
|
||||
*options:*
|
||||
|
||||
- `EdgeLabelMode.NoLabel`: no labels shown at slider extremes
|
||||
- `EdgeLabelMode.LabelIsRange`: edge labels shown the min/max values
|
||||
- `EdgeLabelMode.LabelIsValue`: edge labels shown the slider range
|
||||
|
||||
|
||||
#### fine tuning position of labels:
|
||||
|
||||
If you find that you need to fine tune the position of the handle labels:
|
||||
|
||||
- `QLabeledRangeSlider.label_shift_x`: adjust horizontal label position
|
||||
- `QLabeledRangeSlider.label_shift_y`: adjust vertical label position
|
||||
|
||||
### `QLabeledSlider`
|
||||
|
||||
|
||||

|
||||
|
||||
```python
|
||||
from qtrangeslider import QLabeledSlider
|
||||
```
|
||||
|
||||
(no additional options at this point)
|
||||
|
||||
## Issues
|
||||
|
||||
If you encounter any problems, please [file an issue] along with a detailed
|
||||
description.
|
||||
|
||||
[file an issue]: https://github.com/tlambert03/QtRangeSlider/issues
|
||||
Please see the [Contributing Guide](CONTRIBUTING.md)
|
||||
|
@@ -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
|
||||
|
63
docs/combobox.md
Normal file
@@ -0,0 +1,63 @@
|
||||
# ComboBox
|
||||
|
||||
|
||||
## Enum Combo Box
|
||||
|
||||
`QEnumComboBox` is a variant of [`QComboBox`](https://doc.qt.io/qt-5/qcombobox.html)
|
||||
that populates the items in the combobox based on a python `Enum` class. In addition to all
|
||||
the methods provided by `QComboBox`, this subclass adds the methods
|
||||
`enumClass`/`setEnumClass` to get/set the current `Enum` class represented by the combobox,
|
||||
and `currentEnum`/`setCurrentEnum` to get/set the current `Enum` member in the combobox.
|
||||
There is also a new signal `currentEnumChanged(enum)` analogous to `currentIndexChanged` and `currentTextChanged`.
|
||||
|
||||
Method like `insertItem` and `addItem` are blocked and try of its usage will end with `RuntimeError`
|
||||
|
||||
```python
|
||||
from enum import Enum
|
||||
|
||||
from superqt import QEnumComboBox
|
||||
|
||||
class SampleEnum(Enum):
|
||||
first = 1
|
||||
second = 2
|
||||
third = 3
|
||||
|
||||
# as usual:
|
||||
# you must create a QApplication before create a widget.
|
||||
|
||||
combo = QEnumComboBox()
|
||||
combo.setEnumClass(SampleEnum)
|
||||
```
|
||||
|
||||
other option is to use optional `enum_class` argument of constructor and change
|
||||
```python
|
||||
combo = QEnumComboBox()
|
||||
combo.setEnumClass(SampleEnum)
|
||||
```
|
||||
to
|
||||
```python
|
||||
combo = QEnumComboBox(enum_class=SampleEnum)
|
||||
```
|
||||
|
||||
|
||||
### Allow `None`
|
||||
`QEnumComboBox` allow using Optional type annotation:
|
||||
|
||||
```python
|
||||
from enum import Enum
|
||||
|
||||
from superqt import QEnumComboBox
|
||||
|
||||
class SampleEnum(Enum):
|
||||
first = 1
|
||||
second = 2
|
||||
third = 3
|
||||
|
||||
# as usual:
|
||||
# you must create a QApplication before create a widget.
|
||||
|
||||
combo = QEnumComboBox()
|
||||
combo.setEnumClass(SampleEnum, allow_none=True)
|
||||
```
|
||||
|
||||
In this case there is added option `----` and `currentEnum` will return `None` for it.
|
Before Width: | Height: | Size: 42 KiB After Width: | Height: | Size: 42 KiB |
Before Width: | Height: | Size: 45 KiB After Width: | Height: | Size: 45 KiB |
Before Width: | Height: | Size: 24 KiB After Width: | Height: | Size: 24 KiB |
Before Width: | Height: | Size: 9.0 KiB After Width: | Height: | Size: 9.0 KiB |
Before Width: | Height: | Size: 9.0 KiB After Width: | Height: | Size: 9.0 KiB |
Before Width: | Height: | Size: 12 KiB After Width: | Height: | Size: 12 KiB |
Before Width: | Height: | Size: 86 KiB After Width: | Height: | Size: 86 KiB |
237
docs/sliders.md
Normal file
@@ -0,0 +1,237 @@
|
||||
# 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,10 @@
|
||||
from qtrangeslider import QRangeSlider
|
||||
from qtrangeslider.qtcompat.QtCore import Qt
|
||||
from qtrangeslider.qtcompat.QtWidgets import QApplication
|
||||
from superqt import QRangeSlider
|
||||
from superqt.qtcompat.QtCore import Qt
|
||||
from superqt.qtcompat.QtWidgets import QApplication
|
||||
|
||||
app = QApplication([])
|
||||
|
||||
slider = QRangeSlider(Qt.Horizontal)
|
||||
slider = QRangeSlider(Qt.Horizontal)
|
||||
|
||||
slider.setValue((20, 80))
|
||||
|
12
examples/basic_float.py
Normal file
@@ -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,6 +1,6 @@
|
||||
from qtrangeslider import QRangeSlider
|
||||
from qtrangeslider.qtcompat import QtCore
|
||||
from qtrangeslider.qtcompat import QtWidgets as QtW
|
||||
from superqt import QRangeSlider
|
||||
from superqt.qtcompat import QtCore
|
||||
from superqt.qtcompat import QtWidgets as QtW
|
||||
|
||||
QSS = """
|
||||
QSlider {
|
||||
@@ -112,10 +112,10 @@ if __name__ == "__main__":
|
||||
app = QtW.QApplication([])
|
||||
demo = DemoWidget()
|
||||
|
||||
if "-x" in sys.argv:
|
||||
app.exec_()
|
||||
else:
|
||||
if "-snap" in sys.argv:
|
||||
import platform
|
||||
|
||||
QtW.QApplication.processEvents()
|
||||
demo.grab().save(str(dest / f"demo_{platform.system().lower()}.png"))
|
||||
else:
|
||||
app.exec_()
|
||||
|
12
examples/eliding_label.py
Normal file
@@ -0,0 +1,12 @@
|
||||
from superqt import QElidingLabel
|
||||
from superqt.qtcompat.QtWidgets import QApplication
|
||||
|
||||
app = QApplication([])
|
||||
|
||||
widget = QElidingLabel(
|
||||
"a skj skjfskfj sdlf sdfl sdlfk jsdf sdlkf jdsf dslfksdl sdlfk sdf sdl "
|
||||
"fjsdlf kjsdlfk laskdfsal as lsdfjdsl kfjdslf asfd dslkjfldskf sdlkfj"
|
||||
)
|
||||
widget.setWordWrap(True)
|
||||
widget.show()
|
||||
app.exec_()
|
@@ -1,7 +1,6 @@
|
||||
from qtrangeslider import QRangeSlider
|
||||
from qtrangeslider._float_slider import QDoubleRangeSlider, QDoubleSlider
|
||||
from qtrangeslider.qtcompat.QtCore import Qt
|
||||
from qtrangeslider.qtcompat.QtWidgets import QApplication, QVBoxLayout, QWidget
|
||||
from superqt import QDoubleRangeSlider, QDoubleSlider, QRangeSlider
|
||||
from superqt.qtcompat.QtCore import Qt
|
||||
from superqt.qtcompat.QtWidgets import QApplication, QVBoxLayout, QWidget
|
||||
|
||||
app = QApplication([])
|
||||
|
||||
|
12
examples/generic.py
Normal file
@@ -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_()
|
@@ -1,16 +1,11 @@
|
||||
from qtrangeslider._labeled import (
|
||||
from superqt import (
|
||||
QLabeledDoubleRangeSlider,
|
||||
QLabeledDoubleSlider,
|
||||
QLabeledRangeSlider,
|
||||
QLabeledSlider,
|
||||
)
|
||||
from qtrangeslider.qtcompat.QtCore import Qt
|
||||
from qtrangeslider.qtcompat.QtWidgets import (
|
||||
QApplication,
|
||||
QHBoxLayout,
|
||||
QVBoxLayout,
|
||||
QWidget,
|
||||
)
|
||||
from superqt.qtcompat.QtCore import Qt
|
||||
from superqt.qtcompat.QtWidgets import QApplication, QHBoxLayout, QVBoxLayout, QWidget
|
||||
|
||||
app = QApplication([])
|
||||
|
||||
@@ -30,12 +25,13 @@ qlds.setValue(0.5)
|
||||
qlds.setSingleStep(0.1)
|
||||
|
||||
qlrs = QLabeledRangeSlider(ORIENTATION)
|
||||
qlrs.valueChanged.connect(lambda e: print("qlrs valueChanged", e))
|
||||
qlrs.valueChanged.connect(lambda e: print("QLabeledRangeSlider valueChanged", e))
|
||||
qlrs.setValue((20, 60))
|
||||
|
||||
qldrs = QLabeledDoubleRangeSlider(ORIENTATION)
|
||||
qldrs.valueChanged.connect(lambda e: print("qlrs valueChanged", e))
|
||||
qldrs.setRange(0, 1)
|
||||
qldrs.setSingleStep(0.01)
|
||||
qldrs.setValue((0.2, 0.7))
|
||||
|
||||
|
||||
|
@@ -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([])
|
||||
|
||||
|
@@ -1,96 +0,0 @@
|
||||
import math
|
||||
from typing import Tuple
|
||||
|
||||
from ._hooked import _HookedSlider
|
||||
from ._qrangeslider import QRangeSlider
|
||||
from .qtcompat.QtCore import Signal
|
||||
|
||||
|
||||
class QDoubleSlider(_HookedSlider):
|
||||
|
||||
valueChanged = Signal(float)
|
||||
rangeChanged = Signal(float, float)
|
||||
sliderMoved = Signal(float)
|
||||
_multiplier = 1
|
||||
|
||||
def __init__(self, *args):
|
||||
super().__init__(*args)
|
||||
self._multiplier = 10 ** 2
|
||||
self.setMinimum(0)
|
||||
self.setMaximum(99)
|
||||
self.setSingleStep(1)
|
||||
self.setPageStep(10)
|
||||
super().sliderMoved.connect(
|
||||
lambda e: self.sliderMoved.emit(self._post_get_hook(e))
|
||||
)
|
||||
|
||||
def decimals(self) -> int:
|
||||
"""This property holds the precision of the slider, in decimals."""
|
||||
return int(math.log10(self._multiplier))
|
||||
|
||||
def setDecimals(self, prec: int):
|
||||
"""This property holds the precision of the slider, in decimals
|
||||
|
||||
Sets how many decimals the slider uses for displaying and interpreting doubles.
|
||||
"""
|
||||
previous = self._multiplier
|
||||
self._multiplier = 10 ** int(prec)
|
||||
ratio = self._multiplier / previous
|
||||
|
||||
if ratio != 1:
|
||||
self.blockSignals(True)
|
||||
try:
|
||||
newmin = self.minimum() * ratio
|
||||
newmax = self.maximum() * ratio
|
||||
newval = self._scale_value(ratio)
|
||||
newstep = self.singleStep() * ratio
|
||||
newpage = self.pageStep() * ratio
|
||||
self.setRange(newmin, newmax)
|
||||
self.setValue(newval)
|
||||
self.setSingleStep(newstep)
|
||||
self.setPageStep(newpage)
|
||||
except OverflowError as err:
|
||||
self._multiplier = previous
|
||||
raise OverflowError(
|
||||
f"Cannot use {prec} decimals with a range of {newmin}-"
|
||||
f"{newmax}. If you need this feature, please open a feature"
|
||||
" request at github."
|
||||
) from err
|
||||
self.blockSignals(False)
|
||||
|
||||
def _scale_value(self, p):
|
||||
# for subclasses
|
||||
return self.value() * p
|
||||
|
||||
def _post_get_hook(self, value: int) -> float:
|
||||
return value / self._multiplier
|
||||
|
||||
def _pre_set_hook(self, value: float) -> int:
|
||||
return int(value * self._multiplier)
|
||||
|
||||
def sliderChange(self, change) -> None:
|
||||
if change == self.SliderValueChange:
|
||||
self.valueChanged.emit(self.value())
|
||||
if change == self.SliderRangeChange:
|
||||
self.rangeChanged.emit(self.minimum(), self.maximum())
|
||||
return super().sliderChange(self.SliderChange(change))
|
||||
|
||||
|
||||
class QDoubleRangeSlider(QRangeSlider, QDoubleSlider):
|
||||
rangeChanged = Signal(float, float)
|
||||
|
||||
def value(self) -> Tuple[float, ...]:
|
||||
"""Get current value of the widget as a tuple of integers."""
|
||||
return tuple(float(i) for i in self._value)
|
||||
|
||||
def _min_max_bound(self, val: int) -> float:
|
||||
return round(super()._min_max_bound(val), self.decimals())
|
||||
|
||||
def _scale_value(self, p):
|
||||
# This function is called during setDecimals...
|
||||
# but because QRangeSlider has a private nonQt `_value`
|
||||
# we don't actually need to scale
|
||||
return self._value
|
||||
|
||||
def setDecimals(self, prec: int):
|
||||
return super().setDecimals(prec)
|
@@ -1,49 +0,0 @@
|
||||
from .qtcompat.QtWidgets import QSlider
|
||||
|
||||
|
||||
class _HookedSlider(QSlider):
|
||||
def _post_get_hook(self, value):
|
||||
return value
|
||||
|
||||
def _pre_set_hook(self, value):
|
||||
return value
|
||||
|
||||
def value(self) -> float: # type: ignore[override]
|
||||
return float(self._post_get_hook(super().value()))
|
||||
|
||||
def setValue(self, value: float) -> None:
|
||||
super().setValue(self._pre_set_hook(value))
|
||||
|
||||
def minimum(self) -> float: # type: ignore[override]
|
||||
return self._post_get_hook(super().minimum())
|
||||
|
||||
def setMinimum(self, minimum: float):
|
||||
super().setMinimum(self._pre_set_hook(minimum))
|
||||
|
||||
def maximum(self) -> float: # type: ignore[override]
|
||||
return self._post_get_hook(super().maximum())
|
||||
|
||||
def setMaximum(self, maximum: float):
|
||||
super().setMaximum(self._pre_set_hook(maximum))
|
||||
|
||||
def singleStep(self) -> float: # type: ignore[override]
|
||||
return self._post_get_hook(super().singleStep())
|
||||
|
||||
def setSingleStep(self, step: float):
|
||||
super().setSingleStep(self._pre_set_hook(step))
|
||||
|
||||
def pageStep(self) -> float: # type: ignore[override]
|
||||
return self._post_get_hook(super().pageStep())
|
||||
|
||||
def setPageStep(self, step: float) -> None:
|
||||
super().setPageStep(self._pre_set_hook(step))
|
||||
|
||||
def setRange(self, min: float, max: float) -> None:
|
||||
super().setRange(self._pre_set_hook(min), self._pre_set_hook(max))
|
||||
|
||||
# def sliderChange(self, change) -> None:
|
||||
# if change == QSlider.SliderValueChange:
|
||||
# self.valueChanged.emit(self.value())
|
||||
# if change == QSlider.SliderRangeChange:
|
||||
# self.rangeChanged.emit(self.minimum(), self.maximum())
|
||||
# return super().sliderChange(change)
|
@@ -1,577 +0,0 @@
|
||||
import textwrap
|
||||
from collections import abc
|
||||
from typing import List, Sequence, Tuple
|
||||
|
||||
from ._hooked import _HookedSlider
|
||||
from ._style import RangeSliderStyle, update_styles_from_stylesheet
|
||||
from .qtcompat import QtGui
|
||||
from .qtcompat.QtCore import (
|
||||
Property,
|
||||
QEvent,
|
||||
QPoint,
|
||||
QPointF,
|
||||
QRect,
|
||||
QRectF,
|
||||
Qt,
|
||||
Signal,
|
||||
)
|
||||
from .qtcompat.QtWidgets import (
|
||||
QApplication,
|
||||
QSlider,
|
||||
QStyle,
|
||||
QStyleOptionSlider,
|
||||
QStylePainter,
|
||||
)
|
||||
|
||||
ControlType = Tuple[str, int]
|
||||
|
||||
|
||||
class QRangeSlider(_HookedSlider, QSlider):
|
||||
"""MultiHandle Range Slider widget.
|
||||
|
||||
Same API as QSlider, but `value`, `setValue`, `sliderPosition`, and
|
||||
`setSliderPosition` are all sequences of integers.
|
||||
|
||||
The `valueChanged` and `sliderMoved` signals also both emit a tuple of
|
||||
integers.
|
||||
"""
|
||||
|
||||
# Emitted when the slider value has changed, with the new slider values
|
||||
valueChanged = Signal(tuple)
|
||||
|
||||
# Emitted when sliderDown is true and the slider moves
|
||||
# This usually happens when the user is dragging the slider
|
||||
# The value is the positions of *all* handles.
|
||||
sliderMoved = Signal(tuple)
|
||||
|
||||
_NULL_CTRL = ("None", -1)
|
||||
|
||||
def __init__(self, *args):
|
||||
super().__init__(*args)
|
||||
|
||||
# list of values
|
||||
self._value: List[int] = [20, 80]
|
||||
|
||||
# list of current positions of each handle. same length as _value
|
||||
# If tracking is enabled (the default) this will be identical to _value
|
||||
self._position: List[int] = [20, 80]
|
||||
self._pressedControl: ControlType = self._NULL_CTRL
|
||||
self._hoverControl: ControlType = self._NULL_CTRL
|
||||
|
||||
# whether bar length is constant when dragging the bar
|
||||
# if False, the bar can shorten when dragged beyond min/max
|
||||
self._bar_is_rigid = True
|
||||
# whether clicking on the bar moves all handles, or just the nearest handle
|
||||
self._bar_moves_all = True
|
||||
self._should_draw_bar = True
|
||||
|
||||
# for keyboard nav
|
||||
self._repeatMultiplier = 1 # TODO
|
||||
# for wheel nav
|
||||
self._offset_accum = 0
|
||||
|
||||
# color
|
||||
self._style = RangeSliderStyle()
|
||||
self.setStyleSheet("")
|
||||
update_styles_from_stylesheet(self)
|
||||
|
||||
# ############### Public API #######################
|
||||
|
||||
def setStyleSheet(self, styleSheet: str) -> None:
|
||||
# sub-page styles render on top of the lower sliders and don't work here.
|
||||
override = f"""
|
||||
\n{type(self).__name__}::sub-page:horizontal {{background: none}}
|
||||
\n{type(self).__name__}::sub-page:vertical {{background: none}}
|
||||
"""
|
||||
return super().setStyleSheet(styleSheet + override)
|
||||
|
||||
def value(self) -> Tuple[int, ...]:
|
||||
"""Get current value of the widget as a tuple of integers."""
|
||||
return tuple(self._value)
|
||||
|
||||
def setValue(self, val: Sequence[int]) -> None:
|
||||
"""Set current value of the widget with a sequence of integers.
|
||||
|
||||
The number of handles will be equal to the length of the sequence
|
||||
"""
|
||||
if not (isinstance(val, abc.Sequence) and len(val) >= 2):
|
||||
raise ValueError("value must be iterable of len >= 2")
|
||||
val = [self._min_max_bound(v) for v in val]
|
||||
if self._value == val and self._position == val:
|
||||
return
|
||||
self._value[:] = val[:]
|
||||
if self._position != val:
|
||||
self._position = val
|
||||
if self.isSliderDown():
|
||||
self.sliderMoved.emit(tuple(self._position))
|
||||
|
||||
self.sliderChange(QSlider.SliderValueChange)
|
||||
self.valueChanged.emit(self.value())
|
||||
|
||||
def sliderPosition(self) -> Tuple[int, ...]:
|
||||
"""Get current value of the widget as a tuple of integers.
|
||||
|
||||
If tracking is enabled (the default) this will be identical to value().
|
||||
"""
|
||||
return tuple(self._position)
|
||||
|
||||
def setSliderPosition(self, val: Sequence[int]) -> None:
|
||||
"""Set current position of the handles with a sequence of integers.
|
||||
|
||||
The sequence must have the same length as `value()`.
|
||||
"""
|
||||
if len(val) != len(self.value()):
|
||||
raise ValueError(
|
||||
f"'sliderPosition' must have length of 'value()' ({len(self.value())})"
|
||||
)
|
||||
|
||||
for i, v in enumerate(val):
|
||||
self._setSliderPositionAt(i, v, _update=False)
|
||||
self._updateSliderMove()
|
||||
|
||||
def barIsRigid(self) -> bool:
|
||||
"""Whether bar length is constant when dragging the bar.
|
||||
|
||||
If False, the bar can shorten when dragged beyond min/max. Default is True.
|
||||
"""
|
||||
return self._bar_is_rigid
|
||||
|
||||
def setBarIsRigid(self, val: bool = True) -> None:
|
||||
"""Whether bar length is constant when dragging the bar.
|
||||
|
||||
If False, the bar can shorten when dragged beyond min/max. Default is True.
|
||||
"""
|
||||
self._bar_is_rigid = bool(val)
|
||||
|
||||
def barMovesAllHandles(self) -> bool:
|
||||
"""Whether clicking on the bar moves all handles (default), or just the nearest."""
|
||||
return self._bar_moves_all
|
||||
|
||||
def setBarMovesAllHandles(self, val: bool = True) -> None:
|
||||
"""Whether clicking on the bar moves all handles (default), or just the nearest."""
|
||||
self._bar_moves_all = bool(val)
|
||||
|
||||
def barIsVisible(self) -> bool:
|
||||
"""Whether to show the bar between the first and last handle."""
|
||||
return self._should_draw_bar
|
||||
|
||||
def setBarVisible(self, val: bool = True) -> None:
|
||||
"""Whether to show the bar between the first and last handle."""
|
||||
self._should_draw_bar = bool(val)
|
||||
|
||||
def hideBar(self) -> None:
|
||||
self.setBarVisible(False)
|
||||
|
||||
def showBar(self) -> None:
|
||||
self.setBarVisible(True)
|
||||
|
||||
# ############### Implementation Details #######################
|
||||
|
||||
def _setSliderPositionAt(self, index: int, pos: int, _update=True) -> None:
|
||||
pos = self._min_max_bound(pos)
|
||||
# prevent sliders from moving beyond their neighbors
|
||||
pos = self._neighbor_bound(pos, index, self._position)
|
||||
if pos == self._position[index]:
|
||||
return
|
||||
|
||||
self._position[index] = pos
|
||||
if _update:
|
||||
self._updateSliderMove()
|
||||
|
||||
def _updateSliderMove(self):
|
||||
if not self.hasTracking():
|
||||
self.update()
|
||||
if self.isSliderDown():
|
||||
self.sliderMoved.emit(tuple(self._position))
|
||||
if self.hasTracking():
|
||||
self.triggerAction(QSlider.SliderMove)
|
||||
|
||||
def _offsetAllPositions(self, offset: int, ref=None) -> None:
|
||||
if ref is None:
|
||||
ref = self._position
|
||||
if self._bar_is_rigid:
|
||||
# NOTE: This assumes monotonically increasing slider positions
|
||||
if offset > 0 and ref[-1] + offset > self.maximum():
|
||||
offset = self.maximum() - ref[-1]
|
||||
elif ref[0] + offset < self.minimum():
|
||||
offset = self.minimum() - ref[0]
|
||||
self.setSliderPosition([i + offset for i in ref])
|
||||
|
||||
def _spreadAllPositions(self, shrink=False, gain=1.1, ref=None) -> None:
|
||||
if ref is None:
|
||||
ref = self._position
|
||||
# if self._bar_is_rigid: # TODO
|
||||
|
||||
if shrink:
|
||||
gain = 1 / gain
|
||||
center = abs(ref[-1] + ref[0]) / 2
|
||||
self.setSliderPosition([((i - center) * gain) + center for i in ref])
|
||||
|
||||
def _getStyleOption(self) -> QStyleOptionSlider:
|
||||
opt = QStyleOptionSlider()
|
||||
self.initStyleOption(opt)
|
||||
opt.sliderValue = 0
|
||||
opt.sliderPosition = 0
|
||||
return opt
|
||||
|
||||
def _getBarColor(self):
|
||||
return self._style.brush(self._getStyleOption())
|
||||
|
||||
def _setBarColor(self, color):
|
||||
self._style.brush_active = color
|
||||
|
||||
barColor = Property(QtGui.QBrush, _getBarColor, _setBarColor)
|
||||
|
||||
def _drawBar(self, painter: QStylePainter, opt: QStyleOptionSlider):
|
||||
|
||||
brush = self._style.brush(opt)
|
||||
|
||||
r_bar = self._barRect(opt)
|
||||
if isinstance(brush, QtGui.QGradient):
|
||||
brush.setStart(r_bar.topLeft())
|
||||
brush.setFinalStop(r_bar.bottomRight())
|
||||
|
||||
painter.setPen(self._style.pen(opt))
|
||||
painter.setBrush(brush)
|
||||
painter.drawRect(r_bar)
|
||||
|
||||
def paintEvent(self, a0: QtGui.QPaintEvent) -> None:
|
||||
"""Paint the slider."""
|
||||
# initialize painter and options
|
||||
painter = QStylePainter(self)
|
||||
opt = self._getStyleOption()
|
||||
|
||||
# draw groove and ticks
|
||||
opt.subControls = QStyle.SC_SliderGroove | QStyle.SC_SliderTickmarks
|
||||
painter.drawComplexControl(QStyle.CC_Slider, opt)
|
||||
|
||||
if self._should_draw_bar:
|
||||
self._drawBar(painter, opt)
|
||||
|
||||
# draw handles
|
||||
opt.subControls = QStyle.SC_SliderHandle
|
||||
hidx = -1
|
||||
pidx = -1
|
||||
if self._pressedControl[0] == "handle":
|
||||
pidx = self._pressedControl[1]
|
||||
elif self._hoverControl[0] == "handle":
|
||||
hidx = self._hoverControl[1]
|
||||
for idx, pos in enumerate(self._position):
|
||||
opt.sliderPosition = self._pre_set_hook(pos)
|
||||
|
||||
if idx == pidx: # make pressed handles appear sunken
|
||||
opt.state |= QStyle.State_Sunken
|
||||
else:
|
||||
opt.state = opt.state & ~QStyle.State_Sunken
|
||||
if idx == hidx:
|
||||
opt.activeSubControls = QStyle.SC_SliderHandle
|
||||
else:
|
||||
opt.activeSubControls = QStyle.SC_None
|
||||
painter.drawComplexControl(QStyle.CC_Slider, opt)
|
||||
|
||||
def event(self, ev: QEvent) -> bool:
|
||||
if ev.type() == QEvent.WindowActivate:
|
||||
self.update()
|
||||
if ev.type() == QEvent.StyleChange:
|
||||
update_styles_from_stylesheet(self)
|
||||
if ev.type() in (QEvent.HoverEnter, QEvent.HoverLeave, QEvent.HoverMove):
|
||||
old_hover = self._hoverControl
|
||||
self._hoverControl = self._getControlAtPos(ev.pos())
|
||||
if self._hoverControl != old_hover:
|
||||
self.update() # TODO: restrict to the rect of old_hover
|
||||
return super().event(ev)
|
||||
|
||||
def mousePressEvent(self, ev: QtGui.QMouseEvent) -> None:
|
||||
if self.minimum() == self.maximum() or ev.buttons() ^ ev.button():
|
||||
ev.ignore()
|
||||
return
|
||||
|
||||
ev.accept()
|
||||
# FIXME: why not working on other styles?
|
||||
# set_buttons = self.style().styleHint(QStyle.SH_Slider_AbsoluteSetButtons)
|
||||
set_buttons = Qt.LeftButton | Qt.MiddleButton
|
||||
|
||||
# If the mouse button used is allowed to set the value
|
||||
if ev.buttons() & set_buttons == ev.button():
|
||||
opt = self._getStyleOption()
|
||||
|
||||
self._pressedControl = self._getControlAtPos(ev.pos(), opt, True)
|
||||
|
||||
if self._pressedControl[0] == "handle":
|
||||
offset = self._handle_offset(opt)
|
||||
new_pos = self._pixelPosToRangeValue(self._pick(ev.pos() - offset))
|
||||
self._setSliderPositionAt(self._pressedControl[1], new_pos)
|
||||
self.triggerAction(QSlider.SliderMove)
|
||||
self.setRepeatAction(QSlider.SliderNoAction)
|
||||
self.update()
|
||||
|
||||
if self._pressedControl[0] == "handle":
|
||||
self.setRepeatAction(QSlider.SliderNoAction) # why again?
|
||||
sr = self._handleRects(opt, self._pressedControl[1])
|
||||
self._clickOffset = self._pick(ev.pos() - sr.topLeft())
|
||||
self.update()
|
||||
self.setSliderDown(True)
|
||||
elif self._pressedControl[0] == "bar":
|
||||
self.setRepeatAction(QSlider.SliderNoAction) # why again?
|
||||
self._clickOffset = self._pixelPosToRangeValue(self._pick(ev.pos()))
|
||||
self._sldPosAtPress = tuple(self._position)
|
||||
self.update()
|
||||
self.setSliderDown(True)
|
||||
|
||||
def mouseMoveEvent(self, ev: QtGui.QMouseEvent) -> None:
|
||||
# TODO: add pixelMetric(QStyle::PM_MaximumDragDistance, &opt, this);
|
||||
if self._pressedControl[0] == "handle":
|
||||
ev.accept()
|
||||
new = self._pixelPosToRangeValue(self._pick(ev.pos()) - self._clickOffset)
|
||||
self._setSliderPositionAt(self._pressedControl[1], new)
|
||||
elif self._pressedControl[0] == "bar":
|
||||
ev.accept()
|
||||
|
||||
delta = self._clickOffset - self._pixelPosToRangeValue(self._pick(ev.pos()))
|
||||
self._offsetAllPositions(-delta, self._sldPosAtPress)
|
||||
else:
|
||||
ev.ignore()
|
||||
return
|
||||
|
||||
def mouseReleaseEvent(self, ev: QtGui.QMouseEvent) -> None:
|
||||
if self._pressedControl[0] == "None" or ev.buttons():
|
||||
ev.ignore()
|
||||
return
|
||||
ev.accept()
|
||||
old_pressed = self._pressedControl
|
||||
self._pressedControl = self._NULL_CTRL
|
||||
self.setRepeatAction(QSlider.SliderNoAction)
|
||||
if old_pressed[0] in ("handle", "bar"):
|
||||
self.setSliderDown(False)
|
||||
self.update() # TODO: restrict to the rect of old_pressed
|
||||
|
||||
def triggerAction(self, action: QSlider.SliderAction) -> None:
|
||||
super().triggerAction(action) # TODO: probably need to override.
|
||||
self.setValue(self._position)
|
||||
|
||||
def setRange(self, min: int, max: int) -> None:
|
||||
super().setRange(min, max)
|
||||
self.setValue(self._value) # re-bound
|
||||
|
||||
def _handleRects(
|
||||
self, opt: QStyleOptionSlider = None, handle_index: int = None
|
||||
) -> QRect:
|
||||
"""Return the QRect for all handles."""
|
||||
if opt is None:
|
||||
opt = self._getStyleOption()
|
||||
|
||||
style = self.style().proxy()
|
||||
|
||||
if handle_index is not None: # get specific handle rect
|
||||
opt.sliderPosition = self._pre_set_hook(self._position[handle_index])
|
||||
return style.subControlRect(
|
||||
QStyle.CC_Slider, opt, QStyle.SC_SliderHandle, self
|
||||
)
|
||||
else:
|
||||
rects = []
|
||||
for p in self._position:
|
||||
opt.sliderPosition = self._pre_set_hook(p)
|
||||
r = style.subControlRect(
|
||||
QStyle.CC_Slider, opt, QStyle.SC_SliderHandle, self
|
||||
)
|
||||
rects.append(r)
|
||||
return rects
|
||||
|
||||
def _grooveRect(self, opt: QStyleOptionSlider) -> QRect:
|
||||
"""Return the QRect for the slider groove."""
|
||||
style = self.style().proxy()
|
||||
return style.subControlRect(QStyle.CC_Slider, opt, QStyle.SC_SliderGroove, self)
|
||||
|
||||
def _barRect(self, opt: QStyleOptionSlider, r_groove: QRect = None) -> QRect:
|
||||
"""Return the QRect for the bar between the outer handles."""
|
||||
if r_groove is None:
|
||||
r_groove = self._grooveRect(opt)
|
||||
r_bar = QRectF(r_groove)
|
||||
hdl_low, *_, hdl_high = self._handleRects(opt)
|
||||
|
||||
thickness = self._style.thickness(opt)
|
||||
offset = self._style.offset(opt)
|
||||
|
||||
if opt.orientation == Qt.Horizontal:
|
||||
r_bar.setTop(r_bar.center().y() - thickness / 2 + offset)
|
||||
r_bar.setHeight(thickness)
|
||||
r_bar.setLeft(hdl_low.center().x())
|
||||
r_bar.setRight(hdl_high.center().x())
|
||||
else:
|
||||
r_bar.setLeft(r_bar.center().x() - thickness / 2 + offset)
|
||||
r_bar.setWidth(thickness)
|
||||
r_bar.setBottom(hdl_low.center().y())
|
||||
r_bar.setTop(hdl_high.center().y())
|
||||
|
||||
return r_bar
|
||||
|
||||
def _getControlAtPos(
|
||||
self, pos: QPoint, opt: QStyleOptionSlider = None, closest_handle=False
|
||||
) -> ControlType:
|
||||
"""Update self._pressedControl based on ev.pos()."""
|
||||
if not opt:
|
||||
opt = self._getStyleOption()
|
||||
|
||||
event_position = self._pick(pos)
|
||||
bar_idx = 0
|
||||
hdl_idx = 0
|
||||
dist = float("inf")
|
||||
|
||||
if isinstance(pos, QPointF):
|
||||
pos = QPoint(pos.x(), pos.y())
|
||||
# TODO: this should be reversed, to prefer higher value handles
|
||||
for i, hdl in enumerate(self._handleRects(opt)):
|
||||
if hdl.contains(pos):
|
||||
return ("handle", i) # TODO: use enum for 'handle'
|
||||
hdl_center = self._pick(hdl.center())
|
||||
abs_dist = abs(event_position - hdl_center)
|
||||
if abs_dist < dist:
|
||||
dist = abs_dist
|
||||
hdl_idx = i
|
||||
if event_position > hdl_center:
|
||||
bar_idx += 1
|
||||
else:
|
||||
if closest_handle:
|
||||
if bar_idx == 0:
|
||||
# the click was below the minimum slider
|
||||
return ("handle", 0)
|
||||
elif bar_idx == len(self._position):
|
||||
# the click was above the maximum slider
|
||||
return ("handle", len(self._position) - 1)
|
||||
if self._bar_moves_all:
|
||||
# the click was in an internal segment
|
||||
return ("bar", bar_idx)
|
||||
elif closest_handle:
|
||||
return ("handle", hdl_idx)
|
||||
|
||||
return self._NULL_CTRL
|
||||
|
||||
def _handle_offset(self, opt: QStyleOptionSlider) -> QPoint:
|
||||
# to take half of the slider off for the setSliderPosition call we use the
|
||||
# center - topLeft
|
||||
handle_rect = self._handleRects(opt, 0)
|
||||
return handle_rect.center() - handle_rect.topLeft()
|
||||
|
||||
# from QSliderPrivate::pixelPosToRangeValue
|
||||
def _pixelPosToRangeValue(self, pos: int, opt: QStyleOptionSlider = None) -> int:
|
||||
if not opt:
|
||||
opt = self._getStyleOption()
|
||||
groove_rect = self._grooveRect(opt)
|
||||
handle_rect = self._handleRects(opt, 0)
|
||||
if self.orientation() == Qt.Horizontal:
|
||||
sliderLength = handle_rect.width()
|
||||
sliderMin = groove_rect.x()
|
||||
sliderMax = groove_rect.right() - sliderLength + 1
|
||||
else:
|
||||
sliderLength = handle_rect.height()
|
||||
sliderMin = groove_rect.y()
|
||||
sliderMax = groove_rect.bottom() - sliderLength + 1
|
||||
v = QStyle.sliderValueFromPosition(
|
||||
opt.minimum,
|
||||
opt.maximum,
|
||||
pos - sliderMin,
|
||||
sliderMax - sliderMin,
|
||||
opt.upsideDown,
|
||||
)
|
||||
return self._post_get_hook(v)
|
||||
|
||||
def _pick(self, pt: QPoint) -> int:
|
||||
return pt.x() if self.orientation() == Qt.Horizontal else pt.y()
|
||||
|
||||
def _min_max_bound(self, val: int) -> int:
|
||||
return _bound(self.minimum(), self.maximum(), val)
|
||||
|
||||
def _neighbor_bound(self, val: int, index: int, _lst: List[int]) -> int:
|
||||
# make sure we don't go lower than any preceding index:
|
||||
if index > 0:
|
||||
val = max(_lst[index - 1], val)
|
||||
# make sure we don't go higher than any following index:
|
||||
if index < len(_lst) - 1:
|
||||
val = min(_lst[index + 1], val)
|
||||
return val
|
||||
|
||||
def wheelEvent(self, e: QtGui.QWheelEvent) -> None:
|
||||
e.ignore()
|
||||
vertical = bool(e.angleDelta().y())
|
||||
delta = e.angleDelta().y() if vertical else e.angleDelta().x()
|
||||
if e.inverted():
|
||||
delta *= -1
|
||||
|
||||
orientation = Qt.Vertical if vertical else Qt.Horizontal
|
||||
if self._scrollByDelta(orientation, e.modifiers(), delta):
|
||||
e.accept()
|
||||
|
||||
def _scrollByDelta(
|
||||
self, orientation, modifiers: Qt.KeyboardModifiers, delta: int
|
||||
) -> bool:
|
||||
steps_to_scroll = 0
|
||||
pg_step = self.pageStep()
|
||||
|
||||
# in Qt scrolling to the right gives negative values.
|
||||
if orientation == Qt.Horizontal:
|
||||
delta *= -1
|
||||
offset = delta / 120
|
||||
if modifiers & Qt.ControlModifier or modifiers & Qt.ShiftModifier:
|
||||
# Scroll one page regardless of delta:
|
||||
steps_to_scroll = _bound(-pg_step, pg_step, int(offset * pg_step))
|
||||
self._offset_accum = 0
|
||||
else:
|
||||
# Calculate how many lines to scroll. Depending on what delta is (and
|
||||
# offset), we might end up with a fraction (e.g. scroll 1.3 lines). We can
|
||||
# only scroll whole lines, so we keep the reminder until next event.
|
||||
wheel_scroll_lines = QApplication.wheelScrollLines()
|
||||
steps_to_scrollF = wheel_scroll_lines * offset * self._effectiveSingleStep()
|
||||
|
||||
# Check if wheel changed direction since last event:
|
||||
if self._offset_accum != 0 and (offset / self._offset_accum) < 0:
|
||||
self._offset_accum = 0
|
||||
|
||||
self._offset_accum += steps_to_scrollF
|
||||
|
||||
# Don't scroll more than one page in any case:
|
||||
steps_to_scroll = _bound(-pg_step, pg_step, int(self._offset_accum))
|
||||
|
||||
self._offset_accum -= int(self._offset_accum)
|
||||
|
||||
if steps_to_scroll == 0:
|
||||
# We moved less than a line, but might still have accumulated partial
|
||||
# scroll, unless we already are at one of the ends.
|
||||
effective_offset = self._offset_accum
|
||||
if self.invertedControls():
|
||||
effective_offset *= -1
|
||||
if effective_offset > 0 and max(self._value) < self.maximum():
|
||||
return True
|
||||
if effective_offset < 0 and min(self._value) < self.minimum():
|
||||
return True
|
||||
self._offset_accum = 0
|
||||
return False
|
||||
|
||||
if self.invertedControls():
|
||||
steps_to_scroll *= -1
|
||||
|
||||
_prev_value = self.value()
|
||||
|
||||
if modifiers & Qt.AltModifier:
|
||||
self._spreadAllPositions(shrink=steps_to_scroll < 0)
|
||||
else:
|
||||
self._offsetAllPositions(self._post_get_hook(steps_to_scroll))
|
||||
self.triggerAction(QSlider.SliderMove)
|
||||
|
||||
if _prev_value == self.value():
|
||||
self._offset_accum = 0
|
||||
return False
|
||||
return True
|
||||
|
||||
def _effectiveSingleStep(self) -> int:
|
||||
return self.singleStep() * self._repeatMultiplier
|
||||
|
||||
def keyPressEvent(self, ev: QtGui.QKeyEvent) -> None:
|
||||
return # TODO
|
||||
|
||||
|
||||
def _bound(min_: int, max_: int, value: int) -> int:
|
||||
"""Return value bounded by min_ and max_."""
|
||||
return max(min_, min(max_, value))
|
||||
|
||||
|
||||
QRangeSlider.__doc__ += "\n" + textwrap.indent(QSlider.__doc__, " ")
|
@@ -1,115 +0,0 @@
|
||||
import os
|
||||
|
||||
import pytest
|
||||
|
||||
from qtrangeslider import QRangeSlider
|
||||
from qtrangeslider.qtcompat.QtCore import Qt
|
||||
|
||||
WINDOWS = os.name == "nt"
|
||||
|
||||
|
||||
@pytest.mark.parametrize("orientation", ["Horizontal", "Vertical"])
|
||||
def test_basic(qtbot, orientation):
|
||||
rs = QRangeSlider(getattr(Qt, orientation))
|
||||
qtbot.addWidget(rs)
|
||||
|
||||
|
||||
# @pytest.mark.skipif(WINDOWS, reason="QTest.mouseMove not working on windows")
|
||||
# def test_drag_handles(qtbot):
|
||||
# rs = QRangeSlider(Qt.Horizontal)
|
||||
# qtbot.addWidget(rs)
|
||||
# rs.setRange(0, 99)
|
||||
# rs.setValue((20, 80))
|
||||
# rs.setMouseTracking(True)
|
||||
# rs.show()
|
||||
|
||||
# # press the left handle
|
||||
# opt = rs._getStyleOption()
|
||||
# pos = rs._handleRects(opt, 0).center()
|
||||
# with qtbot.waitSignal(rs.sliderPressed):
|
||||
# qtbot.mousePress(rs, Qt.LeftButton, pos=pos)
|
||||
# assert rs._pressedControl == ("handle", 0)
|
||||
|
||||
# # drag the left handle
|
||||
# with qtbot.waitSignals([rs.sliderMoved] * 13): # couple less signals
|
||||
# for _ in range(15):
|
||||
# pos.setX(pos.x() + 2)
|
||||
# qtbot.mouseMove(rs, pos)
|
||||
|
||||
# with qtbot.waitSignal(rs.sliderReleased):
|
||||
# qtbot.mouseRelease(rs, Qt.LeftButton)
|
||||
|
||||
# # check the values
|
||||
# assert rs.value()[0] > 30
|
||||
# assert rs._pressedControl == rs._NULL_CTRL
|
||||
|
||||
# # press the right handle
|
||||
# pos = rs._handleRects(opt, 1).center()
|
||||
# with qtbot.waitSignal(rs.sliderPressed):
|
||||
# qtbot.mousePress(rs, Qt.LeftButton, pos=pos)
|
||||
# assert rs._pressedControl == ("handle", 1)
|
||||
|
||||
# # drag the right handle
|
||||
# with qtbot.waitSignals([rs.sliderMoved] * 13): # couple less signals
|
||||
# for _ in range(15):
|
||||
# pos.setX(pos.x() - 2)
|
||||
# qtbot.mouseMove(rs, pos)
|
||||
# with qtbot.waitSignal(rs.sliderReleased):
|
||||
# qtbot.mouseRelease(rs, Qt.LeftButton)
|
||||
|
||||
# # check the values
|
||||
# assert rs.value()[1] < 70
|
||||
# assert rs._pressedControl == rs._NULL_CTRL
|
||||
|
||||
|
||||
# @pytest.mark.skipif(WINDOWS, reason="QTest.mouseMove not working on windows")
|
||||
# def test_drag_handles_beyond_edge(qtbot):
|
||||
# rs = QRangeSlider(Qt.Horizontal)
|
||||
# qtbot.addWidget(rs)
|
||||
# rs.setRange(0, 99)
|
||||
# rs.setValue((20, 80))
|
||||
# rs.setMouseTracking(True)
|
||||
# rs.show()
|
||||
|
||||
# # press the right handle
|
||||
# opt = rs._getStyleOption()
|
||||
# pos = rs._handleRects(opt, 1).center()
|
||||
# with qtbot.waitSignal(rs.sliderPressed):
|
||||
# qtbot.mousePress(rs, Qt.LeftButton, pos=pos)
|
||||
# assert rs._pressedControl == ("handle", 1)
|
||||
|
||||
# # drag the handle off the right edge and make sure the value gets to the max
|
||||
# for _ in range(5):
|
||||
# pos.setX(pos.x() + 20)
|
||||
# qtbot.mouseMove(rs, pos)
|
||||
|
||||
# with qtbot.waitSignal(rs.sliderReleased):
|
||||
# qtbot.mouseRelease(rs, Qt.LeftButton)
|
||||
|
||||
# assert rs.value()[1] == 99
|
||||
|
||||
|
||||
# @pytest.mark.skipif(WINDOWS, reason="QTest.mouseMove not working on windows")
|
||||
# def test_bar_drag_beyond_edge(qtbot):
|
||||
# rs = QRangeSlider(Qt.Horizontal)
|
||||
# qtbot.addWidget(rs)
|
||||
# rs.setRange(0, 99)
|
||||
# rs.setValue((20, 80))
|
||||
# rs.setMouseTracking(True)
|
||||
# rs.show()
|
||||
|
||||
# # press the right handle
|
||||
# pos = rs.rect().center()
|
||||
# with qtbot.waitSignal(rs.sliderPressed):
|
||||
# qtbot.mousePress(rs, Qt.LeftButton, pos=pos)
|
||||
# assert rs._pressedControl == ("bar", 1)
|
||||
|
||||
# # drag the handle off the right edge and make sure the value gets to the max
|
||||
# for _ in range(15):
|
||||
# pos.setX(pos.x() + 10)
|
||||
# qtbot.mouseMove(rs, pos)
|
||||
|
||||
# with qtbot.waitSignal(rs.sliderReleased):
|
||||
# qtbot.mouseRelease(rs, Qt.LeftButton)
|
||||
|
||||
# assert rs.value()[1] == 99
|
75
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,49 +15,69 @@ classifiers =
|
||||
License :: OSI Approved :: BSD License
|
||||
Operating System :: OS Independent
|
||||
Programming Language :: Python
|
||||
Programming Language :: Python :: 3
|
||||
Programming Language :: Python :: 3 :: Only
|
||||
Programming Language :: Python :: 3.6
|
||||
Programming Language :: Python :: 3.7
|
||||
Programming Language :: Python :: 3.8
|
||||
Programming Language :: Python :: 3.9
|
||||
Programming Language :: Python :: Implementation :: CPython
|
||||
Topic :: Desktop Environment
|
||||
Topic :: Software Development
|
||||
Topic :: Software Development :: User Interfaces
|
||||
Topic :: Software Development :: Widget Sets
|
||||
keywords = qt, range slider, widget
|
||||
project_urls =
|
||||
Source = https://github.com/napari/superqt
|
||||
Tracker = https://github.com/napari/superqt/issues
|
||||
Changelog = https://github.com/napari/superqt/blob/master/CHANGELOG.md
|
||||
|
||||
[options]
|
||||
zip_safe = False
|
||||
packages = find:
|
||||
python_requires = >=3.6
|
||||
setup_requires = setuptools_scm
|
||||
python_requires = >=3.7
|
||||
setup_requires =
|
||||
setuptools_scm
|
||||
zip_safe = False
|
||||
|
||||
[options.extras_require]
|
||||
pyside2 = pyside2
|
||||
pyqt5 = pyqt5
|
||||
pyside6 = pyside6
|
||||
pyqt6 = pyqt6
|
||||
testing =
|
||||
tox
|
||||
tox-conda
|
||||
pytest
|
||||
pytest-qt
|
||||
pytest-cov
|
||||
dev =
|
||||
ipython
|
||||
jedi<0.18.0
|
||||
isort
|
||||
jedi<0.18.0
|
||||
mypy
|
||||
pre-commit
|
||||
%(testing)s
|
||||
%(pyqt5)s
|
||||
pyside2
|
||||
pytest
|
||||
pytest-cov
|
||||
pytest-qt
|
||||
tox
|
||||
tox-conda
|
||||
pyqt5 =
|
||||
pyqt5
|
||||
pyqt6 =
|
||||
pyqt6
|
||||
pyside2 =
|
||||
pyside2
|
||||
pyside6 =
|
||||
pyside6
|
||||
testing =
|
||||
pytest
|
||||
pytest-cov
|
||||
pytest-qt
|
||||
tox
|
||||
tox-conda
|
||||
|
||||
[flake8]
|
||||
exclude = _version.py,.eggs,examples
|
||||
docstring-convention = numpy
|
||||
ignore = E203,W503,E501,C901,F403,F405
|
||||
ignore = E203,W503,E501,C901,F403,F405,D100
|
||||
|
||||
[pydocstyle]
|
||||
convention = numpy
|
||||
add_select = D402,D415,D417
|
||||
ignore = D100
|
||||
|
||||
[isort]
|
||||
profile=black
|
||||
profile = black
|
||||
|
||||
[tool:pytest]
|
||||
addopts = -W error
|
||||
|
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"],
|
||||
)
|
||||
|
32
superqt/__init__.py
Normal file
@@ -0,0 +1,32 @@
|
||||
"""superqt is a collection of QtWidgets for python."""
|
||||
try:
|
||||
from ._version import version as __version__
|
||||
except ImportError:
|
||||
__version__ = "unknown"
|
||||
|
||||
|
||||
from ._eliding_label import QElidingLabel
|
||||
from .combobox import QEnumComboBox
|
||||
from .sliders import (
|
||||
QDoubleRangeSlider,
|
||||
QDoubleSlider,
|
||||
QLabeledDoubleRangeSlider,
|
||||
QLabeledDoubleSlider,
|
||||
QLabeledRangeSlider,
|
||||
QLabeledSlider,
|
||||
QRangeSlider,
|
||||
)
|
||||
from .spinbox import QLargeIntSpinBox
|
||||
|
||||
__all__ = [
|
||||
"QDoubleRangeSlider",
|
||||
"QDoubleSlider",
|
||||
"QElidingLabel",
|
||||
"QLabeledDoubleRangeSlider",
|
||||
"QLabeledDoubleSlider",
|
||||
"QLabeledRangeSlider",
|
||||
"QLabeledSlider",
|
||||
"QLargeIntSpinBox",
|
||||
"QRangeSlider",
|
||||
"QEnumComboBox",
|
||||
]
|
110
superqt/_eliding_label.py
Normal file
@@ -0,0 +1,110 @@
|
||||
from typing import List
|
||||
|
||||
from superqt.qtcompat.QtCore import QPoint, QRect, QSize, Qt
|
||||
from superqt.qtcompat.QtGui import QFont, QFontMetrics, QResizeEvent, QTextLayout
|
||||
from superqt.qtcompat.QtWidgets import QLabel
|
||||
|
||||
|
||||
class QElidingLabel(QLabel):
|
||||
"""A QLabel variant that will elide text (add '…') to fit width.
|
||||
|
||||
QElidingLabel()
|
||||
QElidingLabel(parent: Optional[QWidget], f: Qt.WindowFlags = ...)
|
||||
QElidingLabel(text: str, parent: Optional[QWidget] = None, f: Qt.WindowFlags = ...)
|
||||
|
||||
For a multiline eliding label, use `setWordWrap(True)`. In this case, text
|
||||
will wrap to fit the width, and only the last line will be elided.
|
||||
When `wordWrap()` is True, `sizeHint()` will return the size required to fit
|
||||
the full text.
|
||||
"""
|
||||
|
||||
def __init__(self, *args, **kwargs) -> None:
|
||||
self._elide_mode = Qt.TextElideMode.ElideRight
|
||||
super().__init__(*args, **kwargs)
|
||||
self.setText(args[0] if args and isinstance(args[0], str) else "")
|
||||
|
||||
# New Public methods
|
||||
|
||||
def elideMode(self) -> Qt.TextElideMode:
|
||||
"""The current Qt.TextElideMode."""
|
||||
return self._elide_mode
|
||||
|
||||
def setElideMode(self, mode: Qt.TextElideMode):
|
||||
"""Set the elide mode to a Qt.TextElideMode."""
|
||||
self._elide_mode = Qt.TextElideMode(mode)
|
||||
super().setText(self._elidedText())
|
||||
|
||||
@staticmethod
|
||||
def wrapText(text, width, font=None) -> List[str]:
|
||||
"""Returns `text`, split as it would be wrapped for `width`, given `font`.
|
||||
|
||||
Static method.
|
||||
"""
|
||||
tl = QTextLayout(text, font or QFont())
|
||||
tl.beginLayout()
|
||||
lines = []
|
||||
while True:
|
||||
ln = tl.createLine()
|
||||
if not ln.isValid():
|
||||
break
|
||||
ln.setLineWidth(width)
|
||||
start = ln.textStart()
|
||||
lines.append(text[start : start + ln.textLength()])
|
||||
tl.endLayout()
|
||||
return lines
|
||||
|
||||
# Reimplemented QT methods
|
||||
|
||||
def text(self) -> str:
|
||||
"""This property holds the label's text.
|
||||
|
||||
If no text has been set this will return an empty string.
|
||||
"""
|
||||
return self._text
|
||||
|
||||
def setText(self, txt: str):
|
||||
"""Set the label's text.
|
||||
|
||||
Setting the text clears any previous content.
|
||||
NOTE: we set the QLabel private text to the elided version
|
||||
"""
|
||||
self._text = txt
|
||||
super().setText(self._elidedText())
|
||||
|
||||
def resizeEvent(self, ev: QResizeEvent) -> None:
|
||||
ev.accept()
|
||||
super().setText(self._elidedText())
|
||||
|
||||
def setWordWrap(self, wrap: bool) -> None:
|
||||
super().setWordWrap(wrap)
|
||||
super().setText(self._elidedText())
|
||||
|
||||
def sizeHint(self) -> QSize:
|
||||
if not self.wordWrap():
|
||||
return super().sizeHint()
|
||||
fm = QFontMetrics(self.font())
|
||||
flags = self.alignment() | Qt.TextFlag.TextWordWrap
|
||||
r = fm.boundingRect(QRect(QPoint(0, 0), self.size()), flags, self._text)
|
||||
return QSize(self.width(), r.height())
|
||||
|
||||
# private implementation methods
|
||||
|
||||
def _elidedText(self) -> str:
|
||||
"""Return `self._text` elided to `width`"""
|
||||
fm = QFontMetrics(self.font())
|
||||
# the 2 is a magic number that prevents the ellipses from going missing
|
||||
# in certain cases (?)
|
||||
width = self.width() - 2
|
||||
if not self.wordWrap():
|
||||
return fm.elidedText(self._text, self._elide_mode, width)
|
||||
|
||||
# get number of lines we can fit without eliding
|
||||
nlines = self.height() // fm.height() - 1
|
||||
# get the last line (elided)
|
||||
text = self._wrappedText()
|
||||
last_line = fm.elidedText("".join(text[nlines:]), self._elide_mode, width)
|
||||
# join them
|
||||
return "".join(text[:nlines] + [last_line])
|
||||
|
||||
def _wrappedText(self) -> List[str]:
|
||||
return QElidingLabel.wrapText(self._text, self.width(), self.font())
|
68
superqt/_tests/test_eliding_label.py
Normal file
@@ -0,0 +1,68 @@
|
||||
from superqt import QElidingLabel
|
||||
from superqt.qtcompat.QtCore import QSize, Qt
|
||||
from superqt.qtcompat.QtGui import QResizeEvent
|
||||
|
||||
TEXT = (
|
||||
"Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do "
|
||||
"eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad "
|
||||
"minim ven iam, quis nostrud exercitation ullamco laborisnisi ut aliquip "
|
||||
"ex ea commodo consequat. Duis aute irure dolor inreprehenderit in voluptate "
|
||||
"velit esse cillum dolore eu fugiat nullapariatur."
|
||||
)
|
||||
ELLIPSIS = "…"
|
||||
|
||||
|
||||
def test_eliding_label(qtbot):
|
||||
wdg = QElidingLabel(TEXT)
|
||||
qtbot.addWidget(wdg)
|
||||
assert wdg._elidedText().endswith(ELLIPSIS)
|
||||
oldsize = wdg.size()
|
||||
newsize = QSize(200, 20)
|
||||
wdg.resize(newsize)
|
||||
wdg.resizeEvent(QResizeEvent(oldsize, newsize)) # for test coverage
|
||||
assert wdg.text() == TEXT
|
||||
|
||||
|
||||
def test_wrapped_eliding_label(qtbot):
|
||||
wdg = QElidingLabel(TEXT)
|
||||
qtbot.addWidget(wdg)
|
||||
assert not wdg.wordWrap()
|
||||
assert wdg.sizeHint() == QSize(633, 16)
|
||||
assert wdg._elidedText() == (
|
||||
"Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do "
|
||||
"eiusmod tempor incididunt ut labore et d…"
|
||||
)
|
||||
wdg.resize(QSize(200, 100))
|
||||
assert wdg.text() == TEXT
|
||||
assert wdg._elidedText() == "Lorem ipsum dolor sit amet, co…"
|
||||
wdg.setWordWrap(True)
|
||||
assert wdg.wordWrap()
|
||||
assert wdg.text() == TEXT
|
||||
assert wdg._elidedText() == (
|
||||
"Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do "
|
||||
"eiusmod tempor incididunt ut labore et dolore magna aliqua. "
|
||||
"Ut enim ad minim ven iam, quis nostrud exercitation ullamco la…"
|
||||
)
|
||||
assert wdg.sizeHint() == QSize(200, 176)
|
||||
wdg.resize(wdg.sizeHint())
|
||||
assert wdg._elidedText() == TEXT
|
||||
|
||||
|
||||
def test_shorter_eliding_label(qtbot):
|
||||
short = "asd a ads sd flksdf dsf lksfj sd lsdjf sd lsdfk sdlkfj s"
|
||||
wdg = QElidingLabel()
|
||||
qtbot.addWidget(wdg)
|
||||
wdg.setText(short)
|
||||
assert not wdg._elidedText().endswith(ELLIPSIS)
|
||||
wdg.resize(100, 20)
|
||||
assert wdg._elidedText().endswith(ELLIPSIS)
|
||||
wdg.setElideMode(Qt.TextElideMode.ElideLeft)
|
||||
assert wdg._elidedText().startswith(ELLIPSIS)
|
||||
assert wdg.elideMode() == Qt.TextElideMode.ElideLeft
|
||||
|
||||
|
||||
def test_wrap_text():
|
||||
wrap = QElidingLabel.wrapText(TEXT, 200)
|
||||
assert isinstance(wrap, list)
|
||||
assert all(isinstance(x, str) for x in wrap)
|
||||
assert len(wrap) == 11
|
3
superqt/combobox/__init__.py
Normal file
@@ -0,0 +1,3 @@
|
||||
from ._enum_combobox import QEnumComboBox
|
||||
|
||||
__all__ = ("QEnumComboBox",)
|
112
superqt/combobox/_enum_combobox.py
Normal file
@@ -0,0 +1,112 @@
|
||||
from enum import Enum, EnumMeta
|
||||
from typing import Optional, TypeVar
|
||||
|
||||
from ..qtcompat.QtCore import Signal
|
||||
from ..qtcompat.QtWidgets import QComboBox
|
||||
|
||||
EnumType = TypeVar("EnumType", bound=Enum)
|
||||
|
||||
|
||||
NONE_STRING = "----"
|
||||
|
||||
|
||||
def _get_name(enum_value: Enum):
|
||||
"""Create human readable name if user does not provide own implementation of __str__"""
|
||||
if enum_value.__str__.__module__ != "enum":
|
||||
# check if function was overloaded
|
||||
name = str(enum_value)
|
||||
else:
|
||||
name = enum_value.name.replace("_", " ")
|
||||
return name
|
||||
|
||||
|
||||
class QEnumComboBox(QComboBox):
|
||||
"""
|
||||
ComboBox presenting options from a python Enum.
|
||||
|
||||
If the Enum class does not implement `__str__` then a human readable name
|
||||
is created from the name of the enum member, replacing underscores with spaces.
|
||||
"""
|
||||
|
||||
currentEnumChanged = Signal(object)
|
||||
|
||||
def __init__(
|
||||
self, parent=None, enum_class: Optional[EnumMeta] = None, allow_none=False
|
||||
):
|
||||
super().__init__(parent)
|
||||
self._enum_class = None
|
||||
self._allow_none = False
|
||||
if enum_class is not None:
|
||||
self.setEnumClass(enum_class, allow_none)
|
||||
self.currentIndexChanged.connect(self._emit_signal)
|
||||
|
||||
def setEnumClass(self, enum: Optional[EnumMeta], allow_none=False):
|
||||
"""
|
||||
Set enum class from which members value should be selected
|
||||
"""
|
||||
self.clear()
|
||||
self._enum_class = enum
|
||||
self._allow_none = allow_none and enum is not None
|
||||
if allow_none:
|
||||
super().addItem(NONE_STRING)
|
||||
super().addItems(list(map(_get_name, self._enum_class.__members__.values())))
|
||||
|
||||
def enumClass(self) -> Optional[EnumMeta]:
|
||||
"""return current Enum class"""
|
||||
return self._enum_class
|
||||
|
||||
def isOptional(self) -> bool:
|
||||
"""return if current enum is with optional annotation"""
|
||||
return self._allow_none
|
||||
|
||||
def clear(self):
|
||||
self._enum_class = None
|
||||
self._allow_none = False
|
||||
super().clear()
|
||||
|
||||
def currentEnum(self) -> Optional[EnumType]:
|
||||
"""current value as Enum member"""
|
||||
if self._enum_class is not None:
|
||||
if self._allow_none:
|
||||
if self.currentText() == NONE_STRING:
|
||||
return None
|
||||
else:
|
||||
return list(self._enum_class.__members__.values())[
|
||||
self.currentIndex() - 1
|
||||
]
|
||||
return list(self._enum_class.__members__.values())[self.currentIndex()]
|
||||
return None
|
||||
|
||||
def setCurrentEnum(self, value: Optional[EnumType]) -> None:
|
||||
"""Set value with Enum."""
|
||||
if self._enum_class is None:
|
||||
raise RuntimeError(
|
||||
"Uninitialized enum class. Use `setEnumClass` before `setCurrentEnum`."
|
||||
)
|
||||
if value is None and self._allow_none:
|
||||
self.setCurrentIndex(0)
|
||||
return
|
||||
if not isinstance(value, self._enum_class):
|
||||
raise TypeError(
|
||||
f"setValue(self, Enum): argument 1 has unexpected type {type(value).__name__!r}"
|
||||
)
|
||||
self.setCurrentText(_get_name(value))
|
||||
|
||||
def _emit_signal(self):
|
||||
if self._enum_class is not None:
|
||||
self.currentEnumChanged.emit(self.currentEnum())
|
||||
|
||||
def insertItems(self, *_, **__):
|
||||
raise RuntimeError("EnumComboBox does not allow to insert items")
|
||||
|
||||
def insertItem(self, *_, **__):
|
||||
raise RuntimeError("EnumComboBox does not allow to insert item")
|
||||
|
||||
def addItems(self, *_, **__):
|
||||
raise RuntimeError("EnumComboBox does not allow to add items")
|
||||
|
||||
def addItem(self, *_, **__):
|
||||
raise RuntimeError("EnumComboBox does not allow to add item")
|
||||
|
||||
def setInsertPolicy(self, policy):
|
||||
raise RuntimeError("EnumComboBox does not allow to insert item")
|
129
superqt/combobox/_tests/test_enum_comb_box.py
Normal file
@@ -0,0 +1,129 @@
|
||||
from enum import Enum
|
||||
|
||||
import pytest
|
||||
|
||||
from superqt.combobox import QEnumComboBox
|
||||
from superqt.combobox._enum_combobox import NONE_STRING
|
||||
|
||||
|
||||
class Enum1(Enum):
|
||||
a = 1
|
||||
b = 2
|
||||
c = 3
|
||||
|
||||
|
||||
class Enum2(Enum):
|
||||
d = 1
|
||||
e = 2
|
||||
f = 3
|
||||
g = 4
|
||||
|
||||
|
||||
class Enum3(Enum):
|
||||
a = 1
|
||||
b = 2
|
||||
c = 3
|
||||
|
||||
def __str__(self):
|
||||
return self.name + "1"
|
||||
|
||||
|
||||
class Enum4(Enum):
|
||||
a_1 = 1
|
||||
b_2 = 2
|
||||
c_3 = 3
|
||||
|
||||
|
||||
def test_simple_create(qtbot):
|
||||
enum = QEnumComboBox(enum_class=Enum1)
|
||||
qtbot.addWidget(enum)
|
||||
assert enum.count() == 3
|
||||
assert [enum.itemText(i) for i in range(enum.count())] == ["a", "b", "c"]
|
||||
|
||||
|
||||
def test_simple_create2(qtbot):
|
||||
enum = QEnumComboBox()
|
||||
qtbot.addWidget(enum)
|
||||
assert enum.count() == 0
|
||||
enum.setEnumClass(Enum1)
|
||||
assert enum.count() == 3
|
||||
assert [enum.itemText(i) for i in range(enum.count())] == ["a", "b", "c"]
|
||||
|
||||
|
||||
def test_replace(qtbot):
|
||||
enum = QEnumComboBox(enum_class=Enum1)
|
||||
qtbot.addWidget(enum)
|
||||
assert enum.count() == 3
|
||||
assert enum.enumClass() == Enum1
|
||||
assert isinstance(enum.currentEnum(), Enum1)
|
||||
enum.setEnumClass(Enum2)
|
||||
assert enum.enumClass() == Enum2
|
||||
assert isinstance(enum.currentEnum(), Enum2)
|
||||
assert enum.count() == 4
|
||||
assert [enum.itemText(i) for i in range(enum.count())] == ["d", "e", "f", "g"]
|
||||
|
||||
|
||||
def test_str_replace(qtbot):
|
||||
enum = QEnumComboBox(enum_class=Enum3)
|
||||
qtbot.addWidget(enum)
|
||||
assert enum.count() == 3
|
||||
assert [enum.itemText(i) for i in range(enum.count())] == ["a1", "b1", "c1"]
|
||||
|
||||
|
||||
def test_underscore_replace(qtbot):
|
||||
enum = QEnumComboBox(enum_class=Enum4)
|
||||
qtbot.addWidget(enum)
|
||||
assert enum.count() == 3
|
||||
assert [enum.itemText(i) for i in range(enum.count())] == ["a 1", "b 2", "c 3"]
|
||||
|
||||
|
||||
def test_change_value(qtbot):
|
||||
enum = QEnumComboBox(enum_class=Enum1)
|
||||
qtbot.addWidget(enum)
|
||||
assert enum.currentEnum() == Enum1.a
|
||||
with qtbot.waitSignal(
|
||||
enum.currentEnumChanged, check_params_cb=lambda x: isinstance(x, Enum)
|
||||
):
|
||||
enum.setCurrentEnum(Enum1.c)
|
||||
assert enum.currentEnum() == Enum1.c
|
||||
|
||||
|
||||
def test_no_enum(qtbot):
|
||||
enum = QEnumComboBox()
|
||||
assert enum.enumClass() is None
|
||||
qtbot.addWidget(enum)
|
||||
assert enum.currentEnum() is None
|
||||
|
||||
|
||||
def test_prohibited_methods(qtbot):
|
||||
enum = QEnumComboBox(enum_class=Enum1)
|
||||
qtbot.addWidget(enum)
|
||||
with pytest.raises(RuntimeError):
|
||||
enum.addItem("aaa")
|
||||
with pytest.raises(RuntimeError):
|
||||
enum.addItems(["aaa", "bbb"])
|
||||
with pytest.raises(RuntimeError):
|
||||
enum.insertItem(0, "aaa")
|
||||
with pytest.raises(RuntimeError):
|
||||
enum.insertItems(0, ["aaa", "bbb"])
|
||||
assert enum.count() == 3
|
||||
|
||||
|
||||
def test_optional(qtbot):
|
||||
enum = QEnumComboBox(enum_class=Enum1, allow_none=True)
|
||||
qtbot.addWidget(enum)
|
||||
assert [enum.itemText(i) for i in range(enum.count())] == [
|
||||
NONE_STRING,
|
||||
"a",
|
||||
"b",
|
||||
"c",
|
||||
]
|
||||
assert enum.currentText() == NONE_STRING
|
||||
assert enum.currentEnum() is None
|
||||
enum.setCurrentEnum(Enum1.a)
|
||||
assert enum.currentText() == "a"
|
||||
assert enum.currentEnum() == Enum1.a
|
||||
assert enum.enumClass() is Enum1
|
||||
enum.setCurrentEnum(None)
|
||||
assert enum.currentText() == NONE_STRING
|
||||
assert enum.currentEnum() is None
|
@@ -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
|
@@ -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,
|
@@ -1,16 +1,10 @@
|
||||
try:
|
||||
from ._version import version as __version__
|
||||
except ImportError:
|
||||
__version__ = "unknown"
|
||||
|
||||
from ._float_slider import QDoubleRangeSlider, QDoubleSlider
|
||||
from ._labeled import (
|
||||
QLabeledDoubleRangeSlider,
|
||||
QLabeledDoubleSlider,
|
||||
QLabeledRangeSlider,
|
||||
QLabeledSlider,
|
||||
)
|
||||
from ._qrangeslider import QRangeSlider
|
||||
from ._sliders import QDoubleRangeSlider, QDoubleSlider, QRangeSlider
|
||||
|
||||
__all__ = [
|
||||
"QDoubleRangeSlider",
|
13
superqt/sliders/__init__.pyi
Normal file
@@ -0,0 +1,13 @@
|
||||
from PyQt5.QtWidgets import QSlider
|
||||
|
||||
from ._generic_range_slider import _GenericRangeSlider
|
||||
from ._generic_slider import _GenericSlider
|
||||
from .qtcompat.QtWidgets import QSlider
|
||||
|
||||
class QDoubleRangeSlider(_GenericRangeSlider): ...
|
||||
class QDoubleSlider(_GenericSlider): ...
|
||||
class QRangeSlider(_GenericRangeSlider): ...
|
||||
class QLabeledSlider(QSlider): ...
|
||||
class QLabeledDoubleSlider(QDoubleSlider): ...
|
||||
class QLabeledRangeSlider(QRangeSlider): ...
|
||||
class QLabeledDoubleRangeSlider(QDoubleRangeSlider): ...
|
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])
|
487
superqt/sliders/_generic_slider.py
Normal file
@@ -0,0 +1,487 @@
|
||||
"""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
|
||||
tmp = (max - min) * (position / span)
|
||||
return (max - tmp) if upsideDown else tmp + min
|
@@ -1,11 +1,10 @@
|
||||
from enum import IntEnum
|
||||
from functools import partial
|
||||
from typing import Any
|
||||
|
||||
from ._float_slider import QDoubleRangeSlider, QDoubleSlider
|
||||
from ._qrangeslider import QRangeSlider
|
||||
from .qtcompat.QtCore import QPoint, QSize, Qt, Signal
|
||||
from .qtcompat.QtGui import QFontMetrics, QValidator
|
||||
from .qtcompat.QtWidgets import (
|
||||
from ..qtcompat.QtCore import QPoint, QSize, Qt, Signal
|
||||
from ..qtcompat.QtGui import QFontMetrics, QValidator
|
||||
from ..qtcompat.QtWidgets import (
|
||||
QAbstractSlider,
|
||||
QApplication,
|
||||
QDoubleSpinBox,
|
||||
@@ -17,6 +16,7 @@ from .qtcompat.QtWidgets import (
|
||||
QVBoxLayout,
|
||||
QWidget,
|
||||
)
|
||||
from ._sliders import QDoubleRangeSlider, QDoubleSlider, QRangeSlider
|
||||
|
||||
|
||||
class LabelPosition(IntEnum):
|
||||
@@ -33,8 +33,8 @@ class EdgeLabelMode(IntEnum):
|
||||
LabelIsValue = 2
|
||||
|
||||
|
||||
class SliderProxy:
|
||||
_slider: QAbstractSlider
|
||||
class _SliderProxy:
|
||||
_slider: QSlider
|
||||
|
||||
def value(self):
|
||||
return self._slider.value()
|
||||
@@ -42,6 +42,12 @@ class SliderProxy:
|
||||
def setValue(self, value) -> None:
|
||||
self._slider.setValue(value)
|
||||
|
||||
def sliderPosition(self):
|
||||
return self._slider.sliderPosition()
|
||||
|
||||
def setSliderPosition(self, pos) -> None:
|
||||
self._slider.setSliderPosition(pos)
|
||||
|
||||
def minimum(self):
|
||||
return self._slider.minimum()
|
||||
|
||||
@@ -69,34 +75,63 @@ class SliderProxy:
|
||||
def setRange(self, min, max) -> None:
|
||||
self._slider.setRange(min, max)
|
||||
|
||||
def tickInterval(self):
|
||||
return self._slider.tickInterval()
|
||||
|
||||
class QLabeledSlider(SliderProxy, QAbstractSlider):
|
||||
def setTickInterval(self, interval) -> None:
|
||||
self._slider.setTickInterval(interval)
|
||||
|
||||
def tickPosition(self):
|
||||
return self._slider.tickPosition()
|
||||
|
||||
def setTickPosition(self, pos) -> None:
|
||||
self._slider.setTickPosition(pos)
|
||||
|
||||
def __getattr__(self, name) -> Any:
|
||||
return getattr(self._slider, name)
|
||||
|
||||
|
||||
def _handle_overloaded_slider_sig(args, kwargs):
|
||||
parent = None
|
||||
orientation = Qt.Vertical
|
||||
errmsg = (
|
||||
"TypeError: arguments did not match any overloaded call:\n"
|
||||
" QSlider(parent: QWidget = None)\n"
|
||||
" QSlider(Qt.Orientation, parent: QWidget = None)"
|
||||
)
|
||||
if len(args) > 2:
|
||||
raise TypeError(errmsg)
|
||||
elif len(args) == 2:
|
||||
if kwargs:
|
||||
raise TypeError(errmsg)
|
||||
orientation, parent = args
|
||||
elif args:
|
||||
if isinstance(args[0], QWidget):
|
||||
if kwargs:
|
||||
raise TypeError(errmsg)
|
||||
parent = args[0]
|
||||
else:
|
||||
orientation = args[0]
|
||||
parent = kwargs.get("parent", parent)
|
||||
return parent, orientation
|
||||
|
||||
|
||||
class QLabeledSlider(_SliderProxy, QAbstractSlider):
|
||||
_slider_class = QSlider
|
||||
_slider: QSlider
|
||||
|
||||
def __init__(self, *args) -> None:
|
||||
parent = None
|
||||
orientation = Qt.Horizontal
|
||||
if len(args) == 2:
|
||||
orientation, parent = args
|
||||
elif args:
|
||||
if isinstance(args[0], QWidget):
|
||||
parent = args[0]
|
||||
else:
|
||||
orientation = args[0]
|
||||
def __init__(self, *args, **kwargs) -> None:
|
||||
parent, orientation = _handle_overloaded_slider_sig(args, kwargs)
|
||||
|
||||
super().__init__(parent)
|
||||
|
||||
self._slider = self._slider_class()
|
||||
self._slider.valueChanged.connect(self.valueChanged.emit)
|
||||
self._label = SliderLabel(self._slider, connect=self._slider.setValue)
|
||||
|
||||
self._slider.rangeChanged.connect(self.rangeChanged.emit)
|
||||
self._label = SliderLabel(self._slider, connect=self.setValue)
|
||||
self._slider.valueChanged.connect(self.valueChanged.emit)
|
||||
self._slider.valueChanged.connect(self._label.setValue)
|
||||
|
||||
self.valueChanged.connect(self._label.setValue)
|
||||
self.valueChanged.connect(self._slider.setValue)
|
||||
self.rangeChanged.connect(self._slider.setRange)
|
||||
|
||||
self._slider.valueChanged.connect(self.setValue)
|
||||
self.setOrientation(orientation)
|
||||
|
||||
def setOrientation(self, orientation):
|
||||
@@ -113,7 +148,7 @@ class QLabeledSlider(SliderProxy, QAbstractSlider):
|
||||
layout.addWidget(self._slider)
|
||||
layout.addWidget(self._label)
|
||||
self._label.setAlignment(Qt.AlignRight)
|
||||
layout.setSpacing(10)
|
||||
layout.setSpacing(6)
|
||||
|
||||
old_layout = self.layout()
|
||||
if old_layout is not None:
|
||||
@@ -129,36 +164,26 @@ class QLabeledDoubleSlider(QLabeledSlider):
|
||||
valueChanged = Signal(float)
|
||||
rangeChanged = Signal(float, float)
|
||||
|
||||
def __init__(self, *args) -> None:
|
||||
super().__init__(*args)
|
||||
def __init__(self, *args, **kwargs) -> None:
|
||||
super().__init__(*args, **kwargs)
|
||||
self.setDecimals(2)
|
||||
|
||||
def decimals(self) -> int:
|
||||
return self._slider.decimals()
|
||||
return self._label.decimals()
|
||||
|
||||
def setDecimals(self, prec: int):
|
||||
self._slider.setDecimals(prec)
|
||||
self._label.setDecimals(prec)
|
||||
|
||||
|
||||
class QLabeledRangeSlider(SliderProxy, QAbstractSlider):
|
||||
class QLabeledRangeSlider(_SliderProxy, QAbstractSlider):
|
||||
valueChanged = Signal(tuple)
|
||||
LabelPosition = LabelPosition
|
||||
EdgeLabelMode = EdgeLabelMode
|
||||
_slider_class = QRangeSlider
|
||||
_slider: QRangeSlider
|
||||
|
||||
def __init__(self, *args) -> None:
|
||||
parent = None
|
||||
orientation = Qt.Horizontal
|
||||
if len(args) == 2:
|
||||
orientation, parent = args
|
||||
elif args:
|
||||
if isinstance(args[0], QWidget):
|
||||
parent = args[0]
|
||||
else:
|
||||
orientation = args[0]
|
||||
|
||||
def __init__(self, *args, **kwargs) -> None:
|
||||
parent, orientation = _handle_overloaded_slider_sig(args, kwargs)
|
||||
super().__init__(parent)
|
||||
self.setAttribute(Qt.WA_ShowWithoutActivating)
|
||||
self._handle_labels = []
|
||||
@@ -230,7 +255,9 @@ class QLabeledRangeSlider(SliderProxy, QAbstractSlider):
|
||||
horizontal = self.orientation() == Qt.Horizontal
|
||||
labels_above = self._handle_label_position == LabelPosition.LabelsAbove
|
||||
|
||||
for label, rect in zip(self._handle_labels, self._slider._handleRects()):
|
||||
last_edge = None
|
||||
for i, label in enumerate(self._handle_labels):
|
||||
rect = self._slider._handleRect(i)
|
||||
dx = -label.width() / 2
|
||||
dy = -label.height() / 2
|
||||
if labels_above:
|
||||
@@ -245,8 +272,16 @@ class QLabeledRangeSlider(SliderProxy, QAbstractSlider):
|
||||
dx *= 3
|
||||
pos = self._slider.mapToParent(rect.center())
|
||||
pos += QPoint(int(dx + self.label_shift_x), int(dy + self.label_shift_y))
|
||||
if last_edge is not None:
|
||||
# prevent label overlap
|
||||
if horizontal:
|
||||
pos.setX(int(max(pos.x(), last_edge.x() + label.width() / 2 + 12)))
|
||||
else:
|
||||
pos.setY(int(min(pos.y(), last_edge.y() - label.height() / 2 - 4)))
|
||||
label.move(pos)
|
||||
last_edge = pos
|
||||
label.clearFocus()
|
||||
self.update()
|
||||
|
||||
def _min_label_edited(self, val):
|
||||
if self._edge_label_mode == EdgeLabelMode.LabelIsRange:
|
||||
@@ -277,7 +312,7 @@ class QLabeledRangeSlider(SliderProxy, QAbstractSlider):
|
||||
lbl.deleteLater()
|
||||
self._handle_labels.clear()
|
||||
for n, val in enumerate(self._slider.value()):
|
||||
_cb = partial(self._slider._setSliderPositionAt, n)
|
||||
_cb = partial(self._slider.setSliderPosition, index=n)
|
||||
s = SliderLabel(self._slider, parent=self, connect=_cb)
|
||||
s.setValue(val)
|
||||
self._handle_labels.append(s)
|
||||
@@ -287,7 +322,8 @@ class QLabeledRangeSlider(SliderProxy, QAbstractSlider):
|
||||
self._reposition_labels()
|
||||
|
||||
def _on_range_changed(self, min, max):
|
||||
self._slider.setRange(min, max)
|
||||
if (min, max) != (self._slider.minimum(), self._slider.maximum()):
|
||||
self._slider.setRange(min, max)
|
||||
for lbl in self._handle_labels:
|
||||
lbl.setRange(min, max)
|
||||
if self._edge_label_mode == EdgeLabelMode.LabelIsRange:
|
||||
@@ -354,15 +390,14 @@ class QLabeledDoubleRangeSlider(QLabeledRangeSlider):
|
||||
_slider: QDoubleRangeSlider
|
||||
rangeChanged = Signal(float, float)
|
||||
|
||||
def __init__(self, *args) -> None:
|
||||
super().__init__(*args)
|
||||
def __init__(self, *args, **kwargs) -> None:
|
||||
super().__init__(*args, **kwargs)
|
||||
self.setDecimals(2)
|
||||
|
||||
def decimals(self) -> int:
|
||||
return self._slider.decimals()
|
||||
return self._min_label.decimals()
|
||||
|
||||
def setDecimals(self, prec: int):
|
||||
self._slider.setDecimals(prec)
|
||||
self._min_label.setDecimals(prec)
|
||||
self._max_label.setDecimals(prec)
|
||||
for lbl in self._handle_labels:
|
||||
@@ -393,7 +428,7 @@ class SliderLabel(QDoubleSpinBox):
|
||||
super().setDecimals(prec)
|
||||
self._update_size()
|
||||
|
||||
def _update_size(self):
|
||||
def _update_size(self, *_):
|
||||
# fontmetrics to measure the width of text
|
||||
fm = QFontMetrics(self.font())
|
||||
h = self.sizeHint().height()
|
||||
@@ -401,15 +436,14 @@ class SliderLabel(QDoubleSpinBox):
|
||||
|
||||
if self._mode == EdgeLabelMode.LabelIsValue:
|
||||
# determine width based on min/max/specialValue
|
||||
s = self.textFromValue(self.minimum())[:18] + fixed_content
|
||||
w = max(0, fm.horizontalAdvance(s))
|
||||
s = self.textFromValue(self.maximum())[:18] + fixed_content
|
||||
w = max(w, fm.horizontalAdvance(s))
|
||||
mintext = self.textFromValue(self.minimum())[:18] + fixed_content
|
||||
maxtext = self.textFromValue(self.maximum())[:18] + fixed_content
|
||||
w = max(0, _fm_width(fm, mintext))
|
||||
w = max(w, _fm_width(fm, maxtext))
|
||||
if self.specialValueText():
|
||||
w = max(w, fm.horizontalAdvance(self.specialValueText()))
|
||||
w = max(w, _fm_width(fm, self.specialValueText()))
|
||||
else:
|
||||
s = self.textFromValue(self.value())
|
||||
w = max(0, fm.horizontalAdvance(s)) + 3
|
||||
w = max(0, _fm_width(fm, self.textFromValue(self.value()))) + 3
|
||||
|
||||
w += 3 # cursor blinking space
|
||||
# get the final size hint
|
||||
@@ -455,3 +489,9 @@ class SliderLabel(QDoubleSpinBox):
|
||||
if "." in input and self.decimals() < 1:
|
||||
return QValidator.Invalid, input, len(input)
|
||||
return super().validate(input, pos)
|
||||
|
||||
|
||||
def _fm_width(fm, text):
|
||||
if hasattr(fm, "horizontalAdvance"):
|
||||
return fm.horizontalAdvance(text)
|
||||
return fm.width(text)
|
@@ -1,11 +1,13 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import platform
|
||||
import re
|
||||
from dataclasses import dataclass, replace
|
||||
from typing import TYPE_CHECKING, Union
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from .qtcompat import PYQT_VERSION
|
||||
from .qtcompat.QtCore import Qt
|
||||
from .qtcompat.QtGui import (
|
||||
from ..qtcompat import PYQT_VERSION
|
||||
from ..qtcompat.QtCore import Qt
|
||||
from ..qtcompat.QtGui import (
|
||||
QBrush,
|
||||
QColor,
|
||||
QGradient,
|
||||
@@ -13,26 +15,26 @@ from .qtcompat.QtGui import (
|
||||
QPalette,
|
||||
QRadialGradient,
|
||||
)
|
||||
from .qtcompat.QtWidgets import QApplication, QSlider, QStyleOptionSlider
|
||||
from ..qtcompat.QtWidgets import QApplication, QSlider, QStyleOptionSlider
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from ._qrangeslider import QRangeSlider
|
||||
from ._generic_range_slider import _GenericRangeSlider
|
||||
|
||||
|
||||
@dataclass
|
||||
class RangeSliderStyle:
|
||||
brush_active: str = None
|
||||
brush_inactive: str = None
|
||||
brush_disabled: str = None
|
||||
pen_active: str = None
|
||||
pen_inactive: str = None
|
||||
pen_disabled: str = None
|
||||
vertical_thickness: float = None
|
||||
horizontal_thickness: float = None
|
||||
tick_offset: float = None
|
||||
tick_bar_alpha: float = None
|
||||
v_offset: float = None
|
||||
h_offset: float = None
|
||||
brush_active: str | None = None
|
||||
brush_inactive: str | None = None
|
||||
brush_disabled: str | None = None
|
||||
pen_active: str | None = None
|
||||
pen_inactive: str | None = None
|
||||
pen_disabled: str | None = None
|
||||
vertical_thickness: float | None = None
|
||||
horizontal_thickness: float | None = None
|
||||
tick_offset: float | None = None
|
||||
tick_bar_alpha: float | None = None
|
||||
v_offset: float | None = None
|
||||
h_offset: float | None = None
|
||||
has_stylesheet: bool = False
|
||||
|
||||
def brush(self, opt: QStyleOptionSlider) -> QBrush:
|
||||
@@ -70,7 +72,7 @@ class RangeSliderStyle:
|
||||
|
||||
return QBrush(val)
|
||||
|
||||
def pen(self, opt: QStyleOptionSlider) -> Union[Qt.PenStyle, QColor]:
|
||||
def pen(self, opt: QStyleOptionSlider) -> Qt.PenStyle | QColor:
|
||||
cg = opt.palette.currentColorGroup()
|
||||
attr = {
|
||||
QPalette.Active: "pen_active", # 0
|
||||
@@ -226,7 +228,7 @@ rgba_pattern = re.compile(
|
||||
)
|
||||
|
||||
|
||||
def parse_color(color: str, default_attr) -> Union[QColor, QGradient]:
|
||||
def parse_color(color: str, default_attr) -> QColor | QGradient:
|
||||
qc = QColor(color)
|
||||
if qc.isValid():
|
||||
return qc
|
||||
@@ -239,7 +241,7 @@ def parse_color(color: str, default_attr) -> Union[QColor, QGradient]:
|
||||
# try linear gradient:
|
||||
match = qlineargrad_pattern.search(color)
|
||||
if match:
|
||||
grad = QLinearGradient(*[float(i) for i in match.groups()[:4]])
|
||||
grad = QLinearGradient(*(float(i) for i in match.groups()[:4]))
|
||||
grad.setColorAt(0, QColor(match.groupdict()["stop0"]))
|
||||
grad.setColorAt(1, QColor(match.groupdict()["stop1"]))
|
||||
return grad
|
||||
@@ -247,7 +249,7 @@ def parse_color(color: str, default_attr) -> Union[QColor, QGradient]:
|
||||
# try linear gradient:
|
||||
match = qradial_pattern.search(color)
|
||||
if match:
|
||||
grad = QRadialGradient(*[float(i) for i in match.groups()[:5]])
|
||||
grad = QRadialGradient(*(float(i) for i in match.groups()[:5]))
|
||||
grad.setColorAt(0, QColor(match.groupdict()["stop0"]))
|
||||
grad.setColorAt(1, QColor(match.groupdict()["stop1"]))
|
||||
return grad
|
||||
@@ -256,7 +258,7 @@ def parse_color(color: str, default_attr) -> Union[QColor, QGradient]:
|
||||
return QColor(getattr(SYSTEM_STYLE, default_attr))
|
||||
|
||||
|
||||
def update_styles_from_stylesheet(obj: "QRangeSlider"):
|
||||
def update_styles_from_stylesheet(obj: _GenericRangeSlider):
|
||||
qss = obj.styleSheet()
|
||||
|
||||
parent = obj.parent()
|
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
|
@@ -2,13 +2,13 @@ import os
|
||||
|
||||
import pytest
|
||||
|
||||
from qtrangeslider import (
|
||||
from superqt import (
|
||||
QDoubleRangeSlider,
|
||||
QDoubleSlider,
|
||||
QLabeledDoubleRangeSlider,
|
||||
QLabeledDoubleSlider,
|
||||
)
|
||||
from qtrangeslider.qtcompat import API_NAME
|
||||
from superqt.qtcompat import API_NAME
|
||||
|
||||
range_types = {QDoubleRangeSlider, QLabeledDoubleRangeSlider}
|
||||
|
||||
@@ -62,15 +62,13 @@ def test_double_sliders(ds):
|
||||
ds.assert_val_eq((20, 40))
|
||||
assert ds.singleStep() == 1
|
||||
|
||||
ds.setDecimals(2)
|
||||
ds.assert_val_eq((20, 40))
|
||||
ds.assert_val_type()
|
||||
|
||||
ds.setValue((20.23435, 40.2342))
|
||||
ds.assert_val_eq((20.23, 40.23)) # because of decimals
|
||||
ds.setValue((20.23, 40.23))
|
||||
ds.assert_val_eq((20.23, 40.23))
|
||||
ds.assert_val_type()
|
||||
|
||||
ds.setDecimals(4)
|
||||
assert ds.minimum() == 10
|
||||
assert ds.maximum() == 99
|
||||
assert ds.singleStep() == 1
|
||||
@@ -78,16 +76,11 @@ def test_double_sliders(ds):
|
||||
ds.setValue((20.2343, 40.2342))
|
||||
ds.assert_val_eq((20.2343, 40.2342))
|
||||
|
||||
ds.setDecimals(6)
|
||||
ds.assert_val_eq((20.2343, 40.2342))
|
||||
assert ds.minimum() == 10
|
||||
assert ds.maximum() == 99
|
||||
assert ds.singleStep() == 1
|
||||
|
||||
with pytest.raises(OverflowError) as err:
|
||||
ds.setDecimals(8)
|
||||
assert "open a feature request" in str(err)
|
||||
|
||||
ds.assert_val_eq((20.2343, 40.2342))
|
||||
assert ds.minimum() == 10
|
||||
assert ds.maximum() == 99
|
||||
@@ -96,7 +89,6 @@ def test_double_sliders(ds):
|
||||
|
||||
def test_double_sliders_small(ds):
|
||||
ds.setMaximum(1)
|
||||
ds.setDecimals(8)
|
||||
ds.setValue((0.5, 0.9))
|
||||
assert ds.minimum() == 0
|
||||
assert ds.maximum() == 1
|
||||
@@ -108,8 +100,6 @@ def test_double_sliders_small(ds):
|
||||
|
||||
def test_double_sliders_big(ds):
|
||||
ds.setValue((20, 80))
|
||||
ds.setDecimals(-6)
|
||||
assert ds.decimals() == -6
|
||||
ds.setMaximum(5e14)
|
||||
assert ds.minimum() == 0
|
||||
assert ds.maximum() == 5e14
|
182
superqt/sliders/_tests/test_generic_slider.py
Normal file
@@ -0,0 +1,182 @@
|
||||
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, _sliderValueFromPosition
|
||||
|
||||
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())
|
||||
|
||||
|
||||
# args are (min: float, max: float, position: int, span: int, upsideDown: bool)
|
||||
@pytest.mark.parametrize(
|
||||
"args, result",
|
||||
[
|
||||
# (min, max, pos, span[, inverted]), expectation
|
||||
# data range (1, 2)
|
||||
((1, 2, 50, 100), 1.5),
|
||||
((1, 2, 70, 100), 1.7),
|
||||
((1, 2, 70, 100, True), 1.3), # inverted appearance
|
||||
((1, 2, 170, 100), 2),
|
||||
((1, 2, 100, 100), 2),
|
||||
((1, 2, -30, 100), 1),
|
||||
# data range (-2, 2)
|
||||
((-2, 2, 50, 100), 0),
|
||||
((-2, 2, 75, 100), 1),
|
||||
((-2, 2, 75, 100, True), -1), # inverted appearance
|
||||
((-2, 2, 170, 100), 2),
|
||||
((-2, 2, 100, 100), 2),
|
||||
((-2, 2, -30, 100), -2),
|
||||
],
|
||||
)
|
||||
def test_slider_value_from_position(args, result):
|
||||
assert math.isclose(_sliderValueFromPosition(*args), result)
|
11
superqt/sliders/_tests/test_labeled_slider.py
Normal file
@@ -0,0 +1,11 @@
|
||||
from superqt import QLabeledRangeSlider
|
||||
|
||||
|
||||
def test_labeled_slider_api(qtbot):
|
||||
slider = QLabeledRangeSlider()
|
||||
qtbot.addWidget(slider)
|
||||
slider.hideBar()
|
||||
slider.showBar()
|
||||
slider.setBarVisible()
|
||||
slider.setBarMovesAllHandles()
|
||||
slider.setBarIsRigid()
|
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]
|
24
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,11 +46,14 @@ platform =
|
||||
passenv = CI GITHUB_ACTIONS DISPLAY XAUTHORITY
|
||||
deps =
|
||||
pytest-xvfb ; sys_platform == 'linux'
|
||||
pyqt511: pyqt5==5.11.*
|
||||
pyside511: pyside2==5.11.*
|
||||
extras =
|
||||
testing
|
||||
pyqt5: pyqt5
|
||||
pyside2: pyside2
|
||||
pyqt6: pyqt6
|
||||
pyside6: pyside6
|
||||
commands_pre = pip install -U pytest-qt@git+https://github.com/The-Compiler/pytest-qt.git@pyqt6
|
||||
commands = pytest -v --color=yes --cov=qtrangeslider --cov-report=xml
|
||||
commands_pre =
|
||||
pyqt6,pyside6: pip install -U pytest-qt@git+https://github.com/pytest-dev/pytest-qt.git
|
||||
commands = pytest --color=yes --cov=superqt --cov-report=xml --pyargs superqt {posargs}
|
||||
|