Compare commits

...

16 Commits

Author SHA1 Message Date
Talley Lambert
b79c8e95b7 chore: changelog v0.3.8 2022-10-10 15:37:16 -04:00
Kira Evans
b393c6d039 fix: allow submodule imports (#128)
* fix: allow submodule imports

* [pre-commit.ci] auto fixes from pre-commit.com hooks

for more information, see https://pre-commit.ci

Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
2022-10-10 15:35:53 -04:00
Talley Lambert
61b8ab30ab chore: changelog v0.3.7 2022-10-10 08:27:33 -04:00
Talley Lambert
abf544cf0e feat: add Quantity widget (using pint) (#126)
* wip

* simplified quantity widget

* fix example

* more docs

* add test

* update docs

* try to avoid overflow

* reduce again
2022-10-10 08:22:52 -04:00
Talley Lambert
9f9dab6f3b fix readme (#125) 2022-10-05 11:16:55 -04:00
Talley Lambert
97bb814451 Docs (#124)
* wip

* wip

* more wip

* progress

* more docs

* more changes

* add link

* more examples and improvements

* fix check-manifest

* sort members

* remove autogen images

* remove _images

* add font docs

* add link to utils
2022-10-05 08:59:27 -04:00
Talley Lambert
d1c056886f chore: changelog v0.3.6 2022-10-03 17:04:14 -04:00
Talley Lambert
a73e56bb83 fix: fix missing labels after setValue (#123) 2022-10-03 17:00:53 -04:00
Talley Lambert
6f71e46914 feat: add editing finished signal to LabeledSliders (#122)
* feat: add editing finished signal to LabeledSliders

* remove extra file
2022-10-03 17:00:29 -04:00
Talley Lambert
fbc67a745c fix: Offer patch for (unstyled) QSliders on macos 12 and Qt <6 (#117)
* wip

* opt-in patch

* finishes

* add patch to demo

* remove demo 2

* extend docs
2022-10-03 15:49:34 -04:00
Talley Lambert
77bd737e13 fix: Fix TypeError on slider rangeChanged signal (#121)
* fix: fix sliders type signals

* test: more tests

* fix for pyside2

* skip type check on py37
2022-10-03 15:10:40 -04:00
pre-commit-ci[bot]
ba626e8786 ci(pre-commit.ci): pre-commit autoupdate (#120)
updates:
- [github.com/pre-commit/mirrors-mypy: v0.971 → v0.981](https://github.com/pre-commit/mirrors-mypy/compare/v0.971...v0.981)

Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
2022-10-03 15:09:47 -04:00
Grzegorz Bokota
04efa95511 dummy fix (#119) 2022-10-03 08:24:45 -04:00
pre-commit-ci[bot]
f401d6d59c [pre-commit.ci] pre-commit autoupdate (#116)
updates:
- https://github.com/myint/autoflakehttps://github.com/PyCQA/autoflake
- [github.com/PyCQA/autoflake: v1.5.3 → v1.6.1](https://github.com/PyCQA/autoflake/compare/v1.5.3...v1.6.1)
- [github.com/asottile/pyupgrade: v2.37.3 → v2.38.2](https://github.com/asottile/pyupgrade/compare/v2.37.3...v2.38.2)

Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
2022-10-02 19:20:26 -04:00
pre-commit-ci[bot]
a3bd0d0edf [pre-commit.ci] pre-commit autoupdate (#114) 2022-09-05 16:06:28 -04:00
pre-commit-ci[bot]
e7e8dfc44c [pre-commit.ci] pre-commit autoupdate (#109)
updates:
- [github.com/PyCQA/flake8: 5.0.2 → 5.0.4](https://github.com/PyCQA/flake8/compare/5.0.2...5.0.4)

Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
2022-08-17 12:55:20 -04:00
62 changed files with 2195 additions and 667 deletions

1
.gitignore vendored
View File

@@ -82,3 +82,4 @@ src/superqt/_version.py
screenshots
.mypy_cache
docs/_auto_images/

View File

@@ -10,13 +10,13 @@ repos:
- id: setup-cfg-fmt
args: ["--include-version-classifiers"]
- repo: https://github.com/PyCQA/flake8
rev: 5.0.2
rev: 5.0.4
hooks:
- id: flake8
additional_dependencies: [flake8-typing-imports==1.7.0]
exclude: examples
- repo: https://github.com/myint/autoflake
rev: v1.4
- repo: https://github.com/PyCQA/autoflake
rev: v1.6.1
hooks:
- id: autoflake
args: ["--in-place", "--remove-all-unused-imports"]
@@ -25,16 +25,16 @@ repos:
hooks:
- id: isort
- repo: https://github.com/asottile/pyupgrade
rev: v2.37.3
rev: v2.38.2
hooks:
- id: pyupgrade
args: [--py37-plus, --keep-runtime-typing]
- repo: https://github.com/psf/black
rev: 22.6.0
rev: 22.8.0
hooks:
- id: black
- repo: https://github.com/pre-commit/mirrors-mypy
rev: v0.971
rev: v0.981
hooks:
- id: mypy
exclude: examples

View File

@@ -1,14 +1,58 @@
# Changelog
## [0.3.5](https://github.com/napari/superqt/tree/0.3.5) (2022-08-17)
## [0.3.8](https://github.com/napari/superqt/tree/0.3.8) (2022-10-10)
[Full Changelog](https://github.com/napari/superqt/compare/v0.3.4...0.3.5)
[Full Changelog](https://github.com/napari/superqt/compare/v0.3.7...0.3.8)
**Fixed bugs:**
- fix: allow submodule imports [\#128](https://github.com/napari/superqt/pull/128) ([kne42](https://github.com/kne42))
## [v0.3.7](https://github.com/napari/superqt/tree/v0.3.7) (2022-10-10)
[Full Changelog](https://github.com/napari/superqt/compare/v0.3.6...v0.3.7)
**Implemented enhancements:**
- feat: add Quantity widget \(using pint\) [\#126](https://github.com/napari/superqt/pull/126) ([tlambert03](https://github.com/tlambert03))
## [v0.3.6](https://github.com/napari/superqt/tree/v0.3.6) (2022-10-05)
[Full Changelog](https://github.com/napari/superqt/compare/v0.3.6rc0...v0.3.6)
**Documentation updates:**
- minor fix to readme [\#125](https://github.com/napari/superqt/pull/125) ([tlambert03](https://github.com/tlambert03))
- Docs [\#124](https://github.com/napari/superqt/pull/124) ([tlambert03](https://github.com/tlambert03))
## [v0.3.6rc0](https://github.com/napari/superqt/tree/v0.3.6rc0) (2022-10-03)
[Full Changelog](https://github.com/napari/superqt/compare/v0.3.5...v0.3.6rc0)
**Implemented enhancements:**
- feat: add editing finished signal to LabeledSliders [\#122](https://github.com/napari/superqt/pull/122) ([tlambert03](https://github.com/tlambert03))
**Fixed bugs:**
- fix: fix missing labels after setValue [\#123](https://github.com/napari/superqt/pull/123) ([tlambert03](https://github.com/tlambert03))
- fix: Fix TypeError on slider rangeChanged signal [\#121](https://github.com/napari/superqt/pull/121) ([tlambert03](https://github.com/tlambert03))
- Simple workaround for pyside 6 [\#119](https://github.com/napari/superqt/pull/119) ([Czaki](https://github.com/Czaki))
- fix: Offer patch for \(unstyled\) QSliders on macos 12 and Qt \<6 [\#117](https://github.com/napari/superqt/pull/117) ([tlambert03](https://github.com/tlambert03))
## [v0.3.5](https://github.com/napari/superqt/tree/v0.3.5) (2022-08-17)
[Full Changelog](https://github.com/napari/superqt/compare/v0.3.4...v0.3.5)
**Fixed bugs:**
- fix range slider drag crash on PyQt6 [\#108](https://github.com/napari/superqt/pull/108) ([sfhbarnett](https://github.com/sfhbarnett))
- Fix float value error in pyqt configuration [\#106](https://github.com/napari/superqt/pull/106) ([mstabrin](https://github.com/mstabrin))
**Merged pull requests:**
- chore: changelog v0.3.5 [\#110](https://github.com/napari/superqt/pull/110) ([tlambert03](https://github.com/tlambert03))
## [v0.3.4](https://github.com/napari/superqt/tree/v0.3.4) (2022-07-24)
[Full Changelog](https://github.com/napari/superqt/compare/v0.3.3...v0.3.4)
@@ -173,13 +217,17 @@
## [v0.2.1](https://github.com/napari/superqt/tree/v0.2.1) (2021-07-10)
[Full Changelog](https://github.com/napari/superqt/compare/v0.2.0...v0.2.1)
[Full Changelog](https://github.com/napari/superqt/compare/v0.2.0rc0...v0.2.1)
**Fixed bugs:**
- Fix QLabeledRangeSlider API \(fix slider proxy\) [\#10](https://github.com/napari/superqt/pull/10) ([tlambert03](https://github.com/tlambert03))
- Fix range slider with negative min range [\#9](https://github.com/napari/superqt/pull/9) ([tlambert03](https://github.com/tlambert03))
## [v0.2.0rc0](https://github.com/napari/superqt/tree/v0.2.0rc0) (2021-06-26)
[Full Changelog](https://github.com/napari/superqt/compare/v0.2.0...v0.2.0rc0)
\* *This Changelog was automatically generated by [github_changelog_generator](https://github.com/github-changelog-generator/github-changelog-generator)*

View File

@@ -1,6 +1,5 @@
# ![tiny](https://user-images.githubusercontent.com/1609449/120636353-8c3f3800-c43b-11eb-8732-a14dec578897.png) superqt!
[![License](https://img.shields.io/pypi/l/superqt.svg?color=green)](https://github.com/napari/superqt/raw/master/LICENSE)
[![PyPI](https://img.shields.io/pypi/v/superqt.svg?color=green)](https://pypi.org/project/superqt)
[![Python
@@ -20,25 +19,32 @@ Components are tested on:
- PyQt5 (5.11 and above) & PyQt6
- PySide2 (5.11 and above) & PySide6
## Documentation
Documentation is available at https://napari.org/superqt
## Widgets
Widgets include:
superqt provides a variety of widgets that are not included in the native QtWidgets module, including multihandle (range) sliders, comboboxes, and more.
- [Float Slider](docs/sliders.md#float-slider)
See the [widgets documentation](https://napari.org/superqt/widgets) for a full list of widgets.
- [Range Slider](docs/sliders.md#range-slider) (multi-handle slider)
- [Range Slider](https://napari.org/superqt/widgets/qrangeslider/) (multi-handle slider)
<img src="https://raw.githubusercontent.com/napari/superqt/main/docs/images/demo_darwin10.png" alt="range sliders" width=680>
- [Labeled Sliders](docs/sliders.md#labeled-sliders) (sliders with linked
spinboxes)
<img src="https://raw.githubusercontent.com/napari/superqt/main/docs/images/labeled_qslider.png" alt="range sliders" width=680>
<img src="https://raw.githubusercontent.com/napari/superqt/main/docs/images/labeled_range.png" alt="range sliders" width=680>
- Unbound Integer SpinBox (backed by python `int`)
## Utilities
superqt includes a number of utitlities for working with Qt, including:
- tools and decorators for working with threads in qt.
- `superqt.fonticon` for generating icons from font files (such as [Material Design Icons](https://materialdesignicons.com/) and [Font Awesome](https://fontawesome.com/))
See the [utilities documentation](https://napari.org/superqt/utilities/) for a full list of utilities.
## Contributing

144
docs/_macros.py Normal file
View File

@@ -0,0 +1,144 @@
import sys
from enum import EnumMeta
from importlib import import_module
from pathlib import Path
from textwrap import dedent
from typing import TYPE_CHECKING
from jinja2 import pass_context
from qtpy.QtCore import QObject, Signal
if TYPE_CHECKING:
from mkdocs_macros.plugin import MacrosPlugin
EXAMPLES = Path(__file__).parent.parent / "examples"
IMAGES = Path(__file__).parent / "_auto_images"
IMAGES.mkdir(exist_ok=True, parents=True)
def define_env(env: "MacrosPlugin"):
@env.macro
@pass_context
def show_widget(context, width: int = 500) -> list[Path]:
# extract all fenced code blocks starting with "python"
page = context["page"]
dest = IMAGES / f"{page.title}.png"
if "build" in sys.argv:
dest.unlink(missing_ok=True)
codeblocks = [
b[6:].strip()
for b in page.markdown.split("```")
if b.startswith("python")
]
src = codeblocks[0].strip()
src = src.replace(
"QApplication([])", "QApplication.instance() or QApplication([])"
)
src = src.replace("app.exec_()", "")
exec(src)
_grab(dest, width)
return f"![{page.title}](../{dest.parent.name}/{dest.name}){{ loading=lazy; width={width} }}\n\n"
@env.macro
def show_members(cls: str):
# import class
module, name = cls.rsplit(".", 1)
_cls = getattr(import_module(module), name)
first_q = next(
(
b.__name__
for b in _cls.__mro__
if issubclass(b, QObject) and ".Qt" in b.__module__
),
None,
)
inherited_members = set()
for base in _cls.__mro__:
if issubclass(base, QObject) and ".Qt" in base.__module__:
inherited_members.update(
{k for k in dir(base) if not k.startswith("_")}
)
new_signals = {
k
for k, v in vars(_cls).items()
if not k.startswith("_") and isinstance(v, Signal)
}
self_members = {
k
for k in dir(_cls)
if not k.startswith("_") and k not in inherited_members | new_signals
}
enums = []
for m in list(self_members):
if isinstance(getattr(_cls, m), EnumMeta):
self_members.remove(m)
enums.append(m)
out = ""
if first_q:
url = f"https://doc.qt.io/qt-6/{first_q.lower()}.html"
out += f"## Qt Class\n\n<a href='{url}'>`{first_q}`</a>\n\n"
out += ""
if new_signals:
out += "## Signals\n\n"
for sig in new_signals:
out += f"### `{sig}`\n\n"
if enums:
out += "## Enums\n\n"
for e in enums:
out += f"### `{_cls.__name__}.{e}`\n\n"
for m in getattr(_cls, e):
out += f"- `{m.name}`\n\n"
if self_members:
out += dedent(
f"""
## Methods
::: {cls}
options:
heading_level: 3
show_source: False
show_inherited_members: false
show_signature_annotations: True
members: {sorted(self_members)}
docstring_style: numpy
show_bases: False
show_root_toc_entry: False
show_root_heading: False
"""
)
return out
def _grab(dest: str | Path, width) -> list[Path]:
"""Grab the top widgets of the application."""
from qtpy.QtCore import QTimer
from qtpy.QtWidgets import QApplication
w = QApplication.topLevelWidgets()[-1]
w.setFixedWidth(width)
w.activateWindow()
w.setMinimumHeight(40)
w.grab().save(str(dest))
# hack to make sure the object is truly closed and deleted
while True:
QTimer.singleShot(10, w.deleteLater)
QApplication.processEvents()
try:
w.parent()
except RuntimeError:
return

View File

@@ -1,68 +0,0 @@
# 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.
## QSearchableComboBox
`QSearchableComboBox` is a variant of [`QComboBox`](https://doc.qt.io/qt-5/qcombobox.html) that
allow to filter list of options by enter part of text. It could be drop in replacement for `QComboBox`.

26
docs/faq.md Normal file
View File

@@ -0,0 +1,26 @@
# FAQ
## Sliders not dragging properly on MacOS 12+
??? details
On MacOS Monterey, with Qt5, there is a bug that causes all sliders
(including native Qt sliders) to not respond properly to drag events. See:
- [https://bugreports.qt.io/browse/QTBUG-98093](https://bugreports.qt.io/browse/QTBUG-98093)
- [https://github.com/napari/superqt/issues/74](https://github.com/napari/superqt/issues/74)
Superqt includes a workaround for this issue, but it is not perfect, and it requires using a custom stylesheet (which may interfere with your own styles). Note that you
may not see this issue if you're already using custom stylesheets.
To opt in to the workaround, do any of the following:
- set the environment variable `USE_MAC_SLIDER_PATCH=1` before importing superqt
(note: this is safe to use even if you're targeting more than just MacOS 12, it will only be applied when needed)
- call the `applyMacStylePatch()` method on any of the superqt slider subclasses (note, this will override your slider styles)
- apply the stylesheet manually:
```python
from superqt.sliders import MONTEREY_SLIDER_STYLES_FIX
slider.setStyleSheet(MONTEREY_SLIDER_STYLES_FIX)
```

View File

29
docs/index.md Normal file
View File

@@ -0,0 +1,29 @@
# superqt
## ![tiny](https://user-images.githubusercontent.com/1609449/120636353-8c3f3800-c43b-11eb-8732-a14dec578897.png) "missing" widgets and components for PyQt/PySide
This repository aims to provide high-quality community-contributed Qt widgets
and components for [PyQt](https://riverbankcomputing.com/software/pyqt/) &
[PySide](https://www.qt.io/qt-for-python) that are not provided in the native
QtWidgets module.
Components are tested on:
- macOS, Windows, & Linux
- Python 3.7 and above
- PyQt5 (5.11 and above) & PyQt6
- PySide2 (5.11 and above) & PySide6
## Installation
```bash
pip install superqt
```
```bash
conda install -c conda-forge superqt
```
## Usage
See the [Widgets](./widgets/) and [Utilities](./utilities/) pages for features offered by superqt.

View File

@@ -1,8 +0,0 @@
# ListWidget
## QSearchableListWidget
`QSearchableListWidget` is a variant of [`QListWidget`](https://doc.qt.io/qt-5/qlistwidget.html) that add text entry above list widget that allow to filter list
of available options.
Because of implementation it does not inherit directly from `QListWidget` but satisfy it all api. The only limitation is that it cannot be used as argument of `QListWidgetItem` constructor.

View File

@@ -1,237 +0,0 @@
# Sliders
![slider](images/slider.png)
- `QRangeSlider` inherits from [`QSlider`](https://doc.qt.io/qt-5/qslider.html)
and attempts to match the Qt API as closely as possible
- Uses platform-specific styles (for handle, groove, & ticks) but also supports
QSS style sheets.
- Supports mouse wheel and keypress (soon) events
- Supports more than 2 handles (e.g. `slider.setValue([0, 10, 60, 80])`)
------
## Range Slider
```python
from superqt import QRangeSlider
# as usual:
# you must create a QApplication before create a widget.
range_slider = QRangeSlider()
```
As `QRangeSlider` inherits from `QtWidgets.QSlider`, you can use all of the
same methods available in the [QSlider API](https://doc.qt.io/qt-5/qslider.html). The major difference is that `value` and `sliderPosition` are reimplemented as `tuples` of `int` (where the length of the tuple is equal to the number of handles in the slider.)
### `value: Tuple[int, ...]`
This property holds the current value of all handles in the slider.
The slider forces all values to be within the legal range:
`minimum <= value <= maximum`.
Changing the value also changes the sliderPosition.
##### Access Functions:
```python
range_slider.value() -> Tuple[int, ...]
```
```python
range_slider.setValue(val: Sequence[int]) -> None
```
##### Notifier Signal:
```python
valueChanged(Tuple[int, ...])
```
### `sliderPosition: Tuple[int, ...]`
This property holds the current slider positions. It is a `tuple` with length equal to the number of handles.
If [tracking](https://doc.qt.io/qt-5/qabstractslider.html#tracking-prop) is enabled (the default), this is identical to [`value`](#value--tupleint-).
##### Access Functions:
```python
range_slider.sliderPosition() -> Tuple[int, ...]
```
```python
range_slider.setSliderPosition(val: Sequence[int]) -> None
```
##### Notifier Signal:
```python
sliderMoved(Tuple[int, ...])
```
### Additional properties
These options are in addition to the Qt QSlider API, and control the behavior of the bar between handles.
| getter | setter | type | default | description |
| -------------------- | ------------------------------------------- | ------ | ------- | ------------------------------------------------------------------------------------------------ |
| `barIsVisible` | `setBarIsVisible` <br>`hideBar` / `showBar` | `bool` | `True` | <small>Whether the bar between handles is visible.</small> |
| `barMovesAllHandles` | `setBarMovesAllHandles` | `bool` | `True` | <small>Whether clicking on the bar moves all handles or just the nearest</small> |
| `barIsRigid` | `setBarIsRigid` | `bool` | `True` | <small>Whether bar length is constant or "elastic" when dragging the bar beyond min/max.</small> |
------
### Examples
These screenshots show `QRangeSlider` (multiple handles) next to the native `QSlider`
(single handle). With no styles applied, `QRangeSlider` will match the native OS
style of `QSlider` with or without tick marks. When styles have been applied
using [Qt Style Sheets](https://doc.qt.io/qt-5/stylesheet-reference.html), then
`QRangeSlider` will inherit any styles applied to `QSlider` (since it inherits
from QSlider). If you'd like to style `QRangeSlider` differently than `QSlider`,
then you can also target it directly in your style sheet. The one "special"
property for QRangeSlider is `qproperty-barColor`, which sets the color of the
bar between the handles.
> The code for these example widgets is [here](../examples/demo_widget.py)
<details>
<summary><em>See style sheet used for this example</em></summary>
```css
/*
Because QRangeSlider inherits from QSlider, it will also inherit styles
*/
QSlider {
min-height: 20px;
}
QSlider::groove:horizontal {
border: 0px;
background: qlineargradient(x1:0, y1:0, x2:1, y2:1,
stop:0 #777, stop:1 #aaa);
height: 20px;
border-radius: 10px;
}
QSlider::handle {
background: qradialgradient(cx:0, cy:0, radius: 1.2, fx:0.5,
fy:0.5, stop:0 #eef, stop:1 #000);
height: 20px;
width: 20px;
border-radius: 10px;
}
/*
"QSlider::sub-page" is the one exception ...
(it styles the area to the left of the QSlider handle)
*/
QSlider::sub-page:horizontal {
background: #447;
border-top-left-radius: 10px;
border-bottom-left-radius: 10px;
}
/*
for QRangeSlider: use "qproperty-barColor". "sub-page" will not work.
*/
QRangeSlider {
qproperty-barColor: #447;
}
```
</details>
#### macOS
##### Catalina
![mac10](images/demo_darwin10.png)
##### Big Sur
![mac11](images/demo_darwin11.png)
#### Windows
![window](images/demo_windows.png)
#### Linux
![linux](images/demo_linux.png)
## Labeled Sliders
This package also includes two "labeled" slider variants. One for `QRangeSlider`, and one for the native `QSlider`:
### `QLabeledRangeSlider`
![labeled_range](images/labeled_range.png)
```python
from superqt import QLabeledRangeSlider
```
This has the same API as `QRangeSlider` with the following additional options:
#### `handleLabelPosition`/`setHandleLabelPosition`
Where/whether labels are shown adjacent to slider handles.
**type:** `QLabeledRangeSlider.LabelPosition`
**default:** `LabelPosition.LabelsAbove`
*options:*
- `LabelPosition.NoLabel` (no labels shown adjacent to handles)
- `LabelPosition.LabelsAbove`
- `LabelPosition.LabelsBelow`
- `LabelPosition.LabelsRight` (alias for `LabelPosition.LabelsAbove`)
- `LabelPosition.LabelsLeft` (alias for `LabelPosition.LabelsBelow`)
#### `edgeLabelMode`/`setEdgeLabelMode`
**type:** `QLabeledRangeSlider.EdgeLabelMode`
**default:** `EdgeLabelMode.LabelIsRange`
*options:*
- `EdgeLabelMode.NoLabel`: no labels shown at slider extremes
- `EdgeLabelMode.LabelIsRange`: edge labels shown the min/max values
- `EdgeLabelMode.LabelIsValue`: edge labels shown the slider range
#### fine tuning position of labels:
If you find that you need to fine tune the position of the handle labels:
- `QLabeledRangeSlider.label_shift_x`: adjust horizontal label position
- `QLabeledRangeSlider.label_shift_y`: adjust vertical label position
### `QLabeledSlider`
![labeled_range](images/labeled_qslider.png)
```python
from superqt import QLabeledSlider
```
(no additional options at this point)
## Issues
If you encounter any problems, please [file an issue] along with a detailed
description.
[file an issue]: https://github.com/napari/superqt/issues
## Float Slider
just like QSlider, but supports float values
```python
from superqt import QDoubleSlider
```

View File

@@ -0,0 +1,52 @@
# CodeSyntaxHighlight
A code highlighter subclass of `QSyntaxHighlighter`
that can be used to highlight code in a QTextEdit.
Code lexer and available styles are from [`pygments`](https://pygments.org/) python library
List of available languages are available [here](https://pygments.org/languages/).
List of available styles are available [here](https://pygments.org/styles/).
## Example
```python
from qtpy.QtGui import QColor, QPalette
from qtpy.QtWidgets import QApplication, QTextEdit
from superqt.utils import CodeSyntaxHighlight
app = QApplication([])
text_area = QTextEdit()
highlight = CodeSyntaxHighlight(text_area.document(), "python", "monokai")
palette = text_area.palette()
palette.setColor(QPalette.Base, QColor(highlight.background_color))
text_area.setPalette(palette)
text_area.setText(
"""from argparse import ArgumentParser
def main():
parser = ArgumentParser()
parser.add_argument("name", help="Your name")
args = parser.parse_args()
print(f"Hello {args.name}")
if __name__ == "__main__":
main()
"""
)
text_area.show()
text_area.resize(400, 200)
app.exec_()
```
{{ show_widget() }}
{{ show_members('superqt.utils.CodeSyntaxHighlight') }}

101
docs/utilities/fonticon.md Normal file
View File

@@ -0,0 +1,101 @@
# Font icons
The `superqt.fonticon` module provides a set of utilities for working with font
icons such as [Font Awesome](https://fontawesome.com/) or [Material Design
Icons](https://materialdesignicons.com/).
## Basic Example
```python
from fonticon_fa5 import FA5S
from qtpy.QtCore import QSize
from qtpy.QtWidgets import QApplication, QPushButton
from superqt.fonticon import icon, pulse
app = QApplication([])
btn2 = QPushButton()
btn2.setIcon(icon(FA5S.smile, color="blue"))
btn2.setIconSize(QSize(225, 225))
btn2.show()
app.exec()
```
{{ show_widget(225) }}
## Font Icon plugins
Ready-made fonticon packs are available as plugins:
### [Font Awesome 5](https://fontawesome.com/v5/search)
```bash
pip install fonticon-fontawesome5
```
### [Font Awesome 6](https://fontawesome.com/v6/search)
```bash
pip install fonticon-fontawesome6
```
### [Material Design Icons](https://materialdesignicons.com/)
```bash
pip install fonticon-materialdesignicons6
```
### See also
- <https://github.com/tlambert03/fonticon-bootstrapicons>
- <https://github.com/tlambert03/fonticon-linearicons>
- <https://github.com/tlambert03/fonticon-feather>
`superqt.fonticon` is a pluggable system, and font icon packs may use the `"superqt.fonticon"`
entry point to register themselves with superqt. See [`fonticon-cookiecutter`](https://github.com/tlambert03/fonticon-cookiecutter) for a template, or look through the following repos for examples:
- <https://github.com/tlambert03/fonticon-fontawesome6>
- <https://github.com/tlambert03/fonticon-fontawesome5>
- <https://github.com/tlambert03/fonticon-materialdesignicons6>
## API
::: superqt.fonticon.icon
options:
heading_level: 3
::: superqt.fonticon.setTextIcon
options:
heading_level: 3
::: superqt.fonticon.font
options:
heading_level: 3
::: superqt.fonticon.IconOpts
options:
heading_level: 3
::: superqt.fonticon.addFont
options:
heading_level: 3
## Animations
the `animation` parameter to `icon()` accepts a subclass of
`Animation` that will be
::: superqt.fonticon.Animation
options:
heading_level: 3
::: superqt.fonticon.pulse
options:
heading_level: 3
::: superqt.fonticon.spin
options:
heading_level: 3

31
docs/utilities/index.md Normal file
View File

@@ -0,0 +1,31 @@
# Utilities
## Font Icons
| Object | Description |
| ----------- | --------------------- |
| [`addFont`](./fonticon.md#superqt.fonticon.addFont) | Add an `OTF/TTF` file at to the font registry. |
| [`font`](./fonticon.md#superqt.fonticon.font) | Create `QFont` for a given font-icon font family key |
| [`icon`](./fonticon.md#superqt.fonticon.icon) | Create a `QIcon` for font-con glyph key |
| [`setTextIcon`](./fonticon.md#superqt.fonticon.setTextIcon) | Set text on a `QWidget` to a specific font & glyph. |
| [`IconFont`](./fonticon.md#superqt.fonticon.IconFont) | Helper class that provides a standard way to create an `IconFont`. |
| [`IconOpts`](./fonticon.md#superqt.fonticon.IconOpts) | Options for rendering an icon |
| [`Animation`](./fonticon.md#superqt.fonticon.Animation) | Base class for adding animations to a font-icon. |
## Threading tools
| Object | Description |
| ----------- | --------------------- |
| [`ensure_main_thread`](./thread_decorators.md#ensure_main_thread) | Decorator that ensures a function is called in the main `QApplication` thread. |
| [`ensure_object_thread`](./thread_decorators.md#ensure_object_thread) | Decorator that ensures a `QObject` method is called in the object's thread. |
| [`FunctionWorker`](./threading.md#superqt.utils.FunctionWorker) | `QRunnable` with signals that wraps a simple long-running function. |
| [`GeneratorWorker`](./threading.md#superqt.utils.GeneratorWorker) | `QRunnable` with signals that wraps a long-running generator. |
| [`create_worker`](./threading.md#superqt.utils.create_worker) | Create a worker to run a target function in another thread. |
| [`thread_worker`](./threading.md#superqt.utils.thread_worker) | Decorator for `create_worker`, turn a function into a worker. |
## Miscellaneous
| Object | Description |
| ----------- | --------------------- |
| [`QMessageHandler`](./qmessagehandler.md) | A context manager to intercept messages from Qt. |
| [`CodeSyntaxHighlight`](./code_syntax_highlight.md) | A `QSyntaxHighlighter` for code syntax highlighting. |

View File

@@ -0,0 +1,8 @@
# QMessageHandler
::: superqt.utils.QMessageHandler
options:
heading_level: 3
show_signature_annotations: True
docstring_style: numpy
show_bases: False

View File

@@ -1,18 +1,24 @@
# Decorators
## Move to thread decorators
# Threading decorators
`superqt` provides two decorators that help to ensure that given function is
running in the desired thread:
* `ensure_main_thread` - ensures that the decorated function/method runs in the main thread
* `ensure_object_thread` - ensures that a decorated bound method of a `QObject` runs in the
thread in which the instance lives ([qt
documentation](https://doc.qt.io/qt-5/threads-qobject.html#accessing-qobject-subclasses-from-other-threads)).
## `ensure_main_thread`
`ensure_main_thread` ensures that the decorated function/method runs in the main thread
## `ensure_object_thread`
`ensure_object_thread` ensures that a decorated bound method of a `QObject` runs
in the thread in which the instance lives ([see qt documentation for
details](https://doc.qt.io/qt-5/threads-qobject.html#accessing-qobject-subclasses-from-other-threads)).
## Usage
By default, functions are executed asynchronously (they return immediately with
an instance of
[`concurrent.futures.Future`](https://docs.python.org/3/library/concurrent.futures.html#concurrent.futures.Future)).
To block and wait for the result, see [Synchronous mode](#synchronous-mode)
```python
@@ -57,12 +63,14 @@ As can be seen in this example these decorators can also be used for setters.
These decorators should not be used as replacement of Qt Signals but rather to
interact with Qt objects from non Qt code.
### Synchronous mode
## Synchronous mode
If you'd like for the program to block and wait for the result of your function
call, use the `await_return=True` parameter, and optionally specify a timeout.
> *Note: Using synchronous mode may significantly impact performance.*
!!! important
Using synchronous mode may significantly impact performance.
```python
from superqt import ensure_main_thread

View File

@@ -0,0 +1,36 @@
# Thread workers
The objects in this module provide utilities for running tasks in a separate
thread. In general (with the exception of `new_worker_qthread`), everything
here wraps Qt's [QRunnable API](https://doc.qt.io/qt-6/qrunnable.html).
The highest level object is the
[`@thread_worker`][superqt.utils.thread_worker] decorator. It was originally
written for `napari`, and was later extracted into `superqt`. You may also be
interested in reading the [napari
documentation](https://napari.org/stable/guides/threading.html#threading-in-napari-with-thread-worker) on this feature,
which provides a more in-depth/introductory usage guide.
For additional control, you can create your own
[`FunctionWorker`][superqt.utils.FunctionWorker] or
[`GeneratorWorker`][superqt.utils.GeneratorWorker] objects.
::: superqt.utils.WorkerBase
::: superqt.utils.FunctionWorker
::: superqt.utils.GeneratorWorker
## Convenience functions
::: superqt.utils.thread_worker
options:
heading_level: 3
::: superqt.utils.create_worker
options:
heading_level: 3
::: superqt.utils.new_worker_qthread
options:
heading_level: 3

View File

@@ -0,0 +1,46 @@
# Throttling & Debouncing
These utilities allow you to throttle or debounce a function. This is useful
when you have a function that is called multiple times in a short period of
time, and you want to make sure it is only "actually" called once (or at least
no more than a certain frequency).
For background on throttling and debouncing, see:
- <https://blog.openreplay.com/forever-functional-debouncing-and-throttling-for-performance>
- <https://css-tricks.com/debouncing-throttling-explained-examples/>
::: superqt.utils.qdebounced
options:
show_source: false
docstring_style: numpy
show_root_toc_entry: True
show_root_heading: True
::: superqt.utils.qthrottled
options:
show_source: false
docstring_style: numpy
show_root_toc_entry: True
show_root_heading: True
::: superqt.utils.QSignalDebouncer
options:
show_source: false
docstring_style: numpy
show_root_toc_entry: True
show_root_heading: True
::: superqt.utils.QSignalThrottler
options:
show_source: false
docstring_style: numpy
show_root_toc_entry: True
show_root_heading: True
::: superqt.utils._throttler.GenericSignalThrottler
options:
show_source: false
docstring_style: numpy
show_root_toc_entry: True
show_root_heading: True

View File

@@ -1,10 +0,0 @@
# Utils
## Code highlighting
`superqt` provides a code highlighter subclass of `QSyntaxHighlighter`
that can be used to highlight code in a QTextEdit.
Code lexer and available styles are from [`pygments`](https://pygments.org/) python library
List of available languages are available [here](https://pygments.org/languages/).
List of available styles are available [here](https://pygments.org/styles/).

32
docs/widgets/index.md Normal file
View File

@@ -0,0 +1,32 @@
# Widgets
The following are QWidget subclasses:
## Sliders and Numerical Inputs
| Widget | Description |
| ----------- | --------------------- |
| [`QDoubleRangeSlider`](./qdoublerangeslider.md) | Multi-handle slider for float values |
| [`QDoubleSlider`](./qdoubleslider.md) | Slider for float values |
| [`QLabeledDoubleRangeSlider`](./qlabeleddoublerangeslider.md) | `QDoubleRangeSlider` variant with editable labels for each handle |
| [`QLabeledDoubleSlider`](./qlabeleddoubleslider.md) | `QSlider` for float values with editable `QSpinBox` with the current value |
| [`QLabeledRangeSlider`](./qlabeledrangeslider.md) | `QRangeSlider` variant, with editable labels for each handle |
| [`QLabeledSlider`](./qlabeledslider.md) | `QSlider` with editable `QSpinBox` that shows the current value |
| [`QLargeIntSpinBox`](./qlargeintspinbox.md) | `QSpinbox` that accepts arbitrarily large integers |
| [`QRangeSlider`](./qrangeslider.md) | Multi-handle slider |
| [`QQuantity`](./qquantity.md) | Pint-backed quantity widget (magnitude combined with unit dropdown) |
## Labels and categorical inputs
| Widget | Description |
| ----------- | --------------------- |
| [`QElidingLabel`](./qelidinglabel.md) | A `QLabel` variant that will elide text (add `…`) to fit width. |
| [`QEnumComboBox`](./qenumcombobox.md) | `QComboBox` that populates the combobox from a python `Enum` |
| [`QSearchableComboBox`](./qsearchablecombobox.md) | `QComboBox` variant that filters available options based on text input |
| [`QSearchableListWidget`](./qsearchablelistwidget.md) | `QListWidget` variant with search field that filters available options |
## Frames and containers
| Widget | Description |
| ----------- | --------------------- |
| [`QCollapsible`](./qcollapsible.md) | A collapsible widget to hide and unhide child widgets. |

View File

@@ -0,0 +1,24 @@
# QCollapsible
Collapsible `QFrame` that can be expanded or collapsed by clicking on the header.
```python
from qtpy.QtWidgets import QApplication, QLabel, QPushButton
from superqt import QCollapsible
app = QApplication([])
collapsible = QCollapsible("Advanced analysis")
collapsible.addWidget(QLabel("This is the inside of the collapsible frame"))
for i in range(10):
collapsible.addWidget(QPushButton(f"Content button {i + 1}"))
collapsible.expand(animate=False)
collapsible.show()
app.exec_()
```
{{ show_widget(350) }}
{{ show_members('superqt.QCollapsible') }}

View File

@@ -0,0 +1,23 @@
# QDoubleRangeSlider
Float variant of [`QRangeSlider`](qrangeslider.md). (see that page for more details).
```python
from qtpy.QtCore import Qt
from qtpy.QtWidgets import QApplication
from superqt import QDoubleRangeSlider
app = QApplication([])
slider = QDoubleRangeSlider(Qt.Orientation.Horizontal)
slider.setRange(0, 1)
slider.setValue((0.2, 0.8))
slider.show()
app.exec_()
```
{{ show_widget() }}
{{ show_members('superqt.QDoubleRangeSlider') }}

View File

@@ -0,0 +1,23 @@
# QDoubleSlider
`QSlider` variant that accepts floating point values.
```python
from qtpy.QtCore import Qt
from qtpy.QtWidgets import QApplication
from superqt import QDoubleSlider
app = QApplication([])
slider = QDoubleSlider(Qt.Orientation.Horizontal)
slider.setRange(0, 1)
slider.setValue(0.5)
slider.show()
app.exec_()
```
{{ show_widget() }}
{{ show_members('superqt.QDoubleSlider') }}

View File

@@ -0,0 +1,26 @@
# QElidingLabel
`QLabel` variant that will elide text (i.e. add an ellipsis)
if it is too long to fit in the available space.
```python
from qtpy.QtWidgets import QApplication
from superqt import QElidingLabel
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.resize(300, 20)
widget.show()
app.exec_()
```
{{ show_widget(300) }}
{{ show_members('superqt.QElidingLabel') }}

View File

@@ -0,0 +1,72 @@
# QEnumComboBox
`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 qtpy.QtWidgets import QApplication
from superqt import QEnumComboBox
class SampleEnum(Enum):
first = 1
second = 2
third = 3
app = QApplication([])
combo = QEnumComboBox()
combo.setEnumClass(SampleEnum)
combo.show()
app.exec_()
```
{{ show_widget() }}
Another option is to use optional `enum_class` argument of constructor and change
```python
# option A:
combo = QEnumComboBox()
combo.setEnumClass(SampleEnum)
# option B:
combo = QEnumComboBox(enum_class=SampleEnum)
```
## Allow `None`
`QEnumComboBox` also allows 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 the `currentEnum()` method will
return `None` when it is selected.
{{ show_members('superqt.QEnumComboBox') }}

View File

@@ -0,0 +1,23 @@
# QLabeledDoubleRangeSlider
Labeled Float variant of [`QRangeSlider`](qrangeslider.md). (see that page for more details).
```python
from qtpy.QtCore import Qt
from qtpy.QtWidgets import QApplication
from superqt import QLabeledDoubleRangeSlider
app = QApplication([])
slider = QLabeledDoubleRangeSlider(Qt.Orientation.Horizontal)
slider.setRange(0, 1)
slider.setValue((0.2, 0.8))
slider.show()
app.exec_()
```
{{ show_widget() }}
{{ show_members('superqt.QLabeledDoubleRangeSlider') }}

View File

@@ -0,0 +1,24 @@
# QLabeledDoubleSlider
[`QDoubleSlider`](./qdoubleslider.md) variant that shows an editable (SpinBox) label next to the slider.
```python
from qtpy.QtCore import Qt
from qtpy.QtWidgets import QApplication
from superqt import QLabeledDoubleSlider
app = QApplication([])
slider = QLabeledDoubleSlider(Qt.Orientation.Horizontal)
slider.setRange(0, 2.5)
slider.setValue(1.3)
slider.show()
app.exec_()
```
{{ show_widget() }}
{{ show_members('superqt.QLabeledDoubleSlider') }}

View File

@@ -0,0 +1,29 @@
# QLabeledRangeSlider
Labeled variant of [`QRangeSlider`](qrangeslider.md). (see that page for more details).
```python
from qtpy.QtCore import Qt
from qtpy.QtWidgets import QApplication
from superqt import QLabeledRangeSlider
app = QApplication([])
slider = QLabeledRangeSlider(Qt.Orientation.Horizontal)
slider.setValue((20, 80))
slider.show()
app.exec_()
```
{{ show_widget() }}
{{ show_members('superqt.QLabeledRangeSlider') }}
----
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

View File

@@ -0,0 +1,22 @@
# QLabeledSlider
`QSlider` variant that shows an editable (SpinBox) label next to the slider.
```python
from qtpy.QtCore import Qt
from qtpy.QtWidgets import QApplication
from superqt import QLabeledSlider
app = QApplication([])
slider = QLabeledSlider(Qt.Orientation.Horizontal)
slider.setValue(42)
slider.show()
app.exec_()
```
{{ show_widget() }}
{{ show_members('superqt.QLabeledSlider') }}

View File

@@ -0,0 +1,23 @@
# QLargeIntSpinBox
`QSpinBox` variant that allows to enter large integers, without overflow.
```python
from qtpy.QtCore import Qt
from qtpy.QtWidgets import QApplication
from superqt import QLargeIntSpinBox
app = QApplication([])
slider = QLargeIntSpinBox()
slider.setRange(0, 4.53e8)
slider.setValue(4.53e8)
slider.show()
app.exec_()
```
{{ show_widget(150) }}
{{ show_members('superqt.QLargeIntSpinBox') }}

33
docs/widgets/qquantity.md Normal file
View File

@@ -0,0 +1,33 @@
# QQuantity
A widget that allows the user to edit a quantity (a magnitude associated with a unit).
!!! note
This widget requires [`pint`](https://pint.readthedocs.io):
```
pip install pint
```
or
```
pip install superqt[quantity]
```
```python
from qtpy.QtWidgets import QApplication
from superqt import QQuantity
app = QApplication([])
w = QQuantity("1m")
w.show()
app.exec()
```
{{ show_widget(150) }}
{{ show_members('superqt.QQuantity') }}

View File

@@ -0,0 +1,229 @@
# QRangeSlider
A multi-handle slider widget than can be used to
select a range of values.
```python
from qtpy.QtCore import Qt
from qtpy.QtWidgets import QApplication
from superqt import QRangeSlider
app = QApplication([])
slider = QRangeSlider(Qt.Orientation.Horizontal)
slider.setValue((20, 80))
slider.show()
app.exec_()
```
{{ show_widget() }}
- `QRangeSlider` inherits from [`QSlider`](https://doc.qt.io/qt-5/qslider.html)
and attempts to match the Qt API as closely as possible
- It uses platform-specific styles (for handle, groove, & ticks) but also supports
QSS style sheets.
- Supports mouse wheel events
- Supports more than 2 handles (e.g. `slider.setValue([0, 10, 60, 80])`)
As `QRangeSlider` inherits from
[`QtWidgets.QSlider`](https://doc.qt.io/qt-5/qslider.html), 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.)
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> |
### Screenshots
??? title "code that generates the images below"
```python
import os
from qtpy import QtCore
from qtpy import QtWidgets as QtW
# patch for Qt 5.15 on macos >= 12
os.environ["USE_MAC_SLIDER_PATCH"] = "1"
from superqt import QRangeSlider # noqa
QSS = """
QSlider {
min-height: 20px;
}
QSlider::groove:horizontal {
border: 0px;
background: qlineargradient(x1:0, y1:0, x2:1, y2:1, stop:0 #888, stop:1 #ddd);
height: 20px;
border-radius: 10px;
}
QSlider::handle {
background: qradialgradient(cx:0, cy:0, radius: 1.2, fx:0.35,
fy:0.3, stop:0 #eef, stop:1 #002);
height: 20px;
width: 20px;
border-radius: 10px;
}
QSlider::sub-page:horizontal {
background: qlineargradient(x1:0, y1:0, x2:1, y2:1, stop:0 #227, stop:1 #77a);
border-top-left-radius: 10px;
border-bottom-left-radius: 10px;
}
QRangeSlider {
qproperty-barColor: qlineargradient(x1:0, y1:0, x2:1, y2:1, stop:0 #227, stop:1 #77a);
}
"""
Horizontal = QtCore.Qt.Orientation.Horizontal
class DemoWidget(QtW.QWidget):
def __init__(self) -> None:
super().__init__()
reg_hslider = QtW.QSlider(Horizontal)
reg_hslider.setValue(50)
range_hslider = QRangeSlider(Horizontal)
range_hslider.setValue((20, 80))
multi_range_hslider = QRangeSlider(Horizontal)
multi_range_hslider.setValue((11, 33, 66, 88))
multi_range_hslider.setTickPosition(QtW.QSlider.TickPosition.TicksAbove)
styled_reg_hslider = QtW.QSlider(Horizontal)
styled_reg_hslider.setValue(50)
styled_reg_hslider.setStyleSheet(QSS)
styled_range_hslider = QRangeSlider(Horizontal)
styled_range_hslider.setValue((20, 80))
styled_range_hslider.setStyleSheet(QSS)
reg_vslider = QtW.QSlider(QtCore.Qt.Orientation.Vertical)
reg_vslider.setValue(50)
range_vslider = QRangeSlider(QtCore.Qt.Orientation.Vertical)
range_vslider.setValue((22, 77))
tick_vslider = QtW.QSlider(QtCore.Qt.Orientation.Vertical)
tick_vslider.setValue(55)
tick_vslider.setTickPosition(QtW.QSlider.TicksRight)
range_tick_vslider = QRangeSlider(QtCore.Qt.Orientation.Vertical)
range_tick_vslider.setValue((22, 77))
range_tick_vslider.setTickPosition(QtW.QSlider.TicksLeft)
szp = QtW.QSizePolicy.Maximum
left = QtW.QWidget()
left.setLayout(QtW.QVBoxLayout())
left.setContentsMargins(2, 2, 2, 2)
label1 = QtW.QLabel("Regular QSlider Unstyled")
label2 = QtW.QLabel("QRangeSliders Unstyled")
label3 = QtW.QLabel("Styled Sliders (using same stylesheet)")
label1.setSizePolicy(szp, szp)
label2.setSizePolicy(szp, szp)
label3.setSizePolicy(szp, szp)
left.layout().addWidget(label1)
left.layout().addWidget(reg_hslider)
left.layout().addWidget(label2)
left.layout().addWidget(range_hslider)
left.layout().addWidget(multi_range_hslider)
left.layout().addWidget(label3)
left.layout().addWidget(styled_reg_hslider)
left.layout().addWidget(styled_range_hslider)
right = QtW.QWidget()
right.setLayout(QtW.QHBoxLayout())
right.setContentsMargins(15, 5, 5, 0)
right.layout().setSpacing(30)
right.layout().addWidget(reg_vslider)
right.layout().addWidget(range_vslider)
right.layout().addWidget(tick_vslider)
right.layout().addWidget(range_tick_vslider)
self.setLayout(QtW.QHBoxLayout())
self.layout().addWidget(left)
self.layout().addWidget(right)
self.setGeometry(600, 300, 580, 300)
self.activateWindow()
self.show()
if __name__ == "__main__":
import sys
from pathlib import Path
dest = Path("screenshots")
dest.mkdir(exist_ok=True)
app = QtW.QApplication([])
demo = DemoWidget()
if "-snap" in sys.argv:
import platform
QtW.QApplication.processEvents()
demo.grab().save(str(dest / f"demo_{platform.system().lower()}.png"))
else:
app.exec_()
```
#### macOS
##### Catalina
![mac10](../images/demo_darwin10.png){ width=580; }
##### Big Sur
![mac11](../images/demo_darwin11.png){ width=580; }
#### Windows
![window](../images/demo_windows.png)
#### Linux
![linux](../images/demo_linux.png)
{{ show_members('superqt.sliders._sliders._GenericRangeSlider') }}
## Type changes
Note the following changes in types compared to the `QSlider` API:
```python
value() -> Tuple[int, ...]
```
```python
setValue(val: Sequence[int]) -> None
```
```python
# Signal
valueChanged(Tuple[int, ...])
```
```python
sliderPosition() -> Tuple[int, ...]
```
```python
setSliderPosition(val: Sequence[int]) -> None
```
```python
sliderMoved(Tuple[int, ...])
```

View File

@@ -0,0 +1,25 @@
# QSearchableComboBox
`QSearchableComboBox` is a variant of
[`QComboBox`](https://doc.qt.io/qt-5/qcombobox.html) that allow to filter list
of options by enter part of text. It could be drop in replacement for
`QComboBox`.
```python
from qtpy.QtWidgets import QApplication
from superqt import QSearchableComboBox
app = QApplication([])
combo = QSearchableComboBox()
combo.addItems(["foo", "bar", "baz", "foobar", "foobaz", "barbaz"])
combo.show()
app.exec_()
```
{{ show_widget() }}
{{ show_members('superqt.QSearchableComboBox') }}

View File

@@ -0,0 +1,28 @@
# QSearchableListWidget
`QSearchableListWidget` is a variant of
[`QListWidget`](https://doc.qt.io/qt-5/qlistwidget.html) that add text entry
above list widget that allow to filter list of available options.
Due to implementation details, this widget it does not inherit directly from
[`QListWidget`](https://doc.qt.io/qt-5/qlistwidget.html) but it does fully
satisfy its api. The only limitation is that it cannot be used as argument of
[`QListWidgetItem`](https://doc.qt.io/qt-5/qlistwidgetitem.html) constructor.
```python
from qtpy.QtWidgets import QApplication
from superqt import QSearchableListWidget
app = QApplication([])
slider = QSearchableListWidget()
slider.addItems(["foo", "bar", "baz", "foobar", "foobaz", "barbaz"])
slider.show()
app.exec_()
```
{{ show_widget() }}
{{ show_members('superqt.QSearchableListWidget') }}

View File

@@ -1,7 +1,12 @@
import os
from qtpy import QtCore
from qtpy import QtWidgets as QtW
from superqt import QRangeSlider
# patch for Qt 5.15 on macos >= 12
os.environ["USE_MAC_SLIDER_PATCH"] = "1"
from superqt import QRangeSlider # noqa
QSS = """
QSlider {

View File

@@ -8,6 +8,7 @@ app = QApplication([])
slider = QDoubleSlider(Qt.Orientation.Horizontal)
slider.setRange(0, 1)
slider.setValue(0.5)
slider.resize(500, 50)
slider.show()
app.exec_()

9
examples/quantity.py Normal file
View File

@@ -0,0 +1,9 @@
from qtpy.QtWidgets import QApplication
from superqt import QQuantity
app = QApplication([])
w = QQuantity("1m")
w.show()
app.exec()

55
mkdocs.yml Normal file
View File

@@ -0,0 +1,55 @@
site_name: superqt
site_url: https://github.com/napari/superqt
site_description: >-
missing widgets and components for PyQt/PySide
# Repository
repo_name: napari/superqt
repo_url: https://github.com/napari/superqt
# Copyright
copyright: Copyright &copy; 2021 - 2022 Talley Lambert
extra_css:
- stylesheets/extra.css
watch:
- src
theme:
name: material
features:
- navigation.instant
- navigation.indexes
- navigation.expand
# - navigation.tracking
# - navigation.tabs
- search.highlight
- search.suggest
markdown_extensions:
- admonition
- pymdownx.details
- pymdownx.superfences
- tables
- attr_list
- md_in_html
- pymdownx.emoji:
emoji_index: !!python/name:materialx.emoji.twemoji
emoji_generator: !!python/name:materialx.emoji.to_svg
plugins:
- search
- autorefs
- mkdocstrings
- macros:
module_name: docs/_macros
- mkdocstrings:
handlers:
python:
import:
- https://docs.python.org/3/objects.inv
options:
show_source: false
docstring_style: numpy
show_root_toc_entry: True
show_root_heading: True

View File

@@ -7,4 +7,4 @@ build-backend = "setuptools.build_meta"
write_to = "src/superqt/_version.py"
[tool.check-manifest]
ignore = ["src/superqt/_version.py"]
ignore = ["src/superqt/_version.py", "mkdocs.yml"]

View File

@@ -63,6 +63,10 @@ dev =
pytest-qt
tox
tox-conda
docs =
mkdocs-macros-plugin
mkdocs-material
mkdocstrings[python]
font_fa5 =
fonticon-fontawesome5
font_mi5 =
@@ -75,7 +79,10 @@ pyside2 =
pyside2
pyside6 =
pyside6
quantity =
pint
testing =
pint
pytest
pytest-cov
pytest-qt

View File

@@ -1,9 +1,13 @@
"""superqt is a collection of QtWidgets for python."""
"""superqt is a collection of Qt components for python."""
from typing import TYPE_CHECKING
try:
from ._version import version as __version__
except ImportError:
__version__ = "unknown"
if TYPE_CHECKING:
from .spinbox._quantity import QQuantity
from ._eliding_label import QElidingLabel
from .collapsible import QCollapsible
@@ -25,6 +29,7 @@ __all__ = [
"ensure_main_thread",
"ensure_object_thread",
"QDoubleRangeSlider",
"QCollapsible",
"QDoubleSlider",
"QElidingLabel",
"QEnumComboBox",
@@ -34,8 +39,16 @@ __all__ = [
"QLabeledSlider",
"QLargeIntSpinBox",
"QMessageHandler",
"QQuantity",
"QRangeSlider",
"QSearchableComboBox",
"QSearchableListWidget",
"QRangeSlider",
"QCollapsible",
]
def __getattr__(name):
if name == "QQuantity":
from .spinbox._quantity import QQuantity
return QQuantity
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")

View File

@@ -8,7 +8,7 @@ from qtpy.QtWidgets import QFrame, QPushButton, QVBoxLayout, QWidget
class QCollapsible(QFrame):
"""A collapsible widget to hide and unhide child widgets.
Based on https://stackoverflow.com/a/68141638
Based on [https://stackoverflow.com/a/68141638](https://stackoverflow.com/a/68141638)
"""
_EXPANDED = ""

View File

@@ -12,7 +12,10 @@ 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":
if (
enum_value.__str__.__module__ != "enum"
and not enum_value.__str__.__module__.startswith("shibokensupport")
):
# check if function was overloaded
name = str(enum_value)
else:

View File

@@ -2,14 +2,15 @@ from __future__ import annotations
__all__ = [
"addFont",
"Animation",
"ENTRY_POINT",
"font",
"icon",
"IconFont",
"IconFontMeta",
"IconOpts",
"Animation",
"pulse",
"setTextIcon",
"spin",
]
@@ -49,10 +50,11 @@ def icon(
The `glyph_key` (e.g. 'fa5s.smile') represents a Font-family & style, and a glpyh.
In most cases, the key should be provided by a plugin in the environment, like:
https://github.com/tlambert03/fonticon-fontawesome5 ('fa5s' & 'fa5r' prefixes)
https://github.com/tlambert03/fonticon-materialdesignicons6 ('mdi6' prefix)
...but fonts can also be added manually using :func:`addFont`.
- [fonticon-fontawesome5](https://pypi.org/project/fonticon-fontawesome5/) ('fa5s' & 'fa5r' prefixes)
- [fonticon-materialdesignicons6](https://pypi.org/project/fonticon-materialdesignicons6/) ('mdi6' prefix)
...but fonts can also be added manually using [`addFont`][superqt.fonticon.addFont].
Parameters
----------
@@ -96,19 +98,22 @@ def icon(
Examples
--------
# simple example (assumes the font-awesome5 plugin is installed)
simple example (using the string `'fa5s.smile'` assumes the `fonticon-fontawesome5`
plugin is installed)
>>> btn = QPushButton()
>>> btn.setIcon(icon('fa5s.smile'))
# can also directly import from fonticon_fa5
can also directly import from fonticon_fa5
>>> from fonticon_fa5 import FA5S
>>> btn.setIcon(icon(FA5S.smile))
# with animation
with animation
>>> btn2 = QPushButton()
>>> btn2.setIcon(icon(FA5S.spinner, animation=pulse(btn2)))
# complicated example
complicated example
>>> btn = QPushButton()
>>> btn.setIcon(
... icon(
@@ -152,7 +157,7 @@ def setTextIcon(widget: QWidget, glyph_key: str, size: Optional[float] = None) -
Parameters
----------
wdg : QWidget
widget : QWidget
A widget supporting a `setText` method.
glyph_key : str
String encapsulating a font-family, style, and glyph. e.g. 'fa5s.smile'.
@@ -190,10 +195,12 @@ def addFont(
to their unicode numbers. If a charmap is not provided, glyphs must be directly
accessed with their unicode as something like `key.\uffff`.
NOTE: in most cases, users will not need this.
Instead, they should install a font plugin, like:
https://github.com/tlambert03/fonticon-fontawesome5
https://github.com/tlambert03/fonticon-materialdesignicons6
!!! Note
in most cases, users will not need this. Instead, they should install a
font plugin, like:
- [fonticon-fontawesome5](https://pypi.org/project/fonticon-fontawesome5/)
- [fonticon-materialdesignicons6](https://pypi.org/project/fonticon-materialdesignicons6/)
Parameters
----------

View File

@@ -6,6 +6,8 @@ from qtpy.QtWidgets import QWidget
class Animation(ABC):
"""Base icon animation class."""
def __init__(self, parent_widget: QWidget, interval: int = 10, step: int = 1):
self.parent_widget = parent_widget
self.timer = QTimer()
@@ -25,6 +27,8 @@ class Animation(ABC):
class spin(Animation):
"""Animation that smoothly spins an icon."""
def animate(self, painter: QPainter):
if not self.timer.isActive():
self.timer.start()
@@ -36,5 +40,7 @@ class spin(Animation):
class pulse(spin):
"""Animation that spins an icon in slower, discrete steps."""
def __init__(self, parent_widget: QWidget = None):
super().__init__(parent_widget, interval=200, step=45)

View File

@@ -102,6 +102,23 @@ class IconOptionDict(TypedDict, total=False):
# IconOptions are.
@dataclass
class IconOpts:
"""Options for rendering an icon.
Parameters
----------
glyph_key : str, optional
The key of the glyph to use, e.g. `'fa5s.smile'`, by default `None`
scale_factor : float, optional
The scale factor to use, by default `None`
color : ValidColor, optional
The color to use, by default `None`. Colors may be specified as a string,
`QColor`, `Qt.GlobalColor`, or a 3 or 4-tuple of integers.
opacity : float, optional
The opacity to use, by default `None`
animation : Animation, optional
The animation to use, by default `None`
"""
glyph_key: Union[str, Unset] = _Unset
scale_factor: Union[float, Unset] = _Unset
color: Union[ValidColor, Unset] = _Unset
@@ -418,7 +435,7 @@ class QFontIconStore(QObject):
If you'd like to later use a fontkey in the form of `key.some-name`, then
`charmap` must be provided and provide a mapping for all of the glyph names
to their unicode numbers. If a charmap is not provided, glyphs must be directly
accessed with their unicode as something like `key.\uffff`.
accessed with their unicode as something like `key.\\uffff`.
Parameters
----------
@@ -509,7 +526,7 @@ class QFontIconStore(QObject):
) -> None:
"""Sets text on a widget to a specific font & glyph.
This is an alternative to setting a QIcon with a pixmap. It may
This is an alternative to setting a `QIcon` with a pixmap. It may
be easier to combine with dynamic stylesheets.
"""
setText = getattr(widget, "setText", None)

View File

@@ -4,6 +4,7 @@ from ._labeled import (
QLabeledRangeSlider,
QLabeledSlider,
)
from ._range_style import MONTEREY_SLIDER_STYLES_FIX
from ._sliders import QDoubleRangeSlider, QDoubleSlider, QRangeSlider
__all__ = [
@@ -14,4 +15,5 @@ __all__ = [
"QLabeledRangeSlider",
"QLabeledSlider",
"QRangeSlider",
"MONTEREY_SLIDER_STYLES_FIX",
]

View File

@@ -5,7 +5,11 @@ from qtpy.QtCore import Property, QEvent, QPoint, QPointF, QRect, QRectF, Qt, Si
from qtpy.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
from ._range_style import (
MONTEREY_SLIDER_STYLES_FIX,
RangeSliderStyle,
update_styles_from_stylesheet,
)
_T = TypeVar("_T")
@@ -32,6 +36,8 @@ class _GenericRangeSlider(_GenericSlider[Tuple], Generic[_T]):
_slidersMoved = Signal(tuple)
def __init__(self, *args, **kwargs):
self._style = RangeSliderStyle()
super().__init__(*args, **kwargs)
self.valueChanged = self._valuesChanged
self.sliderMoved = self._slidersMoved
@@ -55,23 +61,21 @@ class _GenericRangeSlider(_GenericSlider[Tuple], Generic[_T]):
# 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.
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.
If `False`, the bar can shorten when dragged beyond min/max. Default is `True`.
"""
self._bar_is_rigid = bool(val)
@@ -92,11 +96,21 @@ class _GenericRangeSlider(_GenericSlider[Tuple], Generic[_T]):
self._should_draw_bar = bool(val)
def hideBar(self) -> None:
"""Hide the bar between the first and last handle."""
self.setBarVisible(False)
def showBar(self) -> None:
"""Show the bar between the first and last handle."""
self.setBarVisible(True)
def applyMacStylePatch(self) -> str:
"""Apply a QSS patch to fix sliders on macos>=12 with QT < 6.
see [FAQ](../faq.md#sliders-not-dragging-properly-on-macos-12) for more details.
"""
super().applyMacStylePatch()
self._style._macpatch = True
# ############### QtOverrides #######################
def value(self) -> Tuple[_T, ...]:
@@ -131,12 +145,21 @@ class _GenericRangeSlider(_GenericSlider[Tuple], Generic[_T]):
self._doSliderMove()
def setStyleSheet(self, styleSheet: str) -> None:
return super().setStyleSheet(self._patch_style(styleSheet))
def _patch_style(self, style: str):
"""Override to patch style options before painting."""
# sub-page styles render on top of the lower sliders and don't work here.
if self._style._macpatch and not style:
style = MONTEREY_SLIDER_STYLES_FIX
override = f"""
\n{type(self).__name__}::sub-page:horizontal {{background: none}}
\n{type(self).__name__}::sub-page:vertical {{background: none}}
\n{type(self).__name__}::sub-page:horizontal
{{background: none; border: none}}
\n{type(self).__name__}::add-page:vertical
{{background: none; border: none}}
"""
return super().setStyleSheet(styleSheet + override)
return style + override
def event(self, ev: QEvent) -> bool:
if ev.type() == QEvent.Type.StyleChange:
@@ -188,6 +211,7 @@ class _GenericRangeSlider(_GenericSlider[Tuple], Generic[_T]):
self._style.brush_active = color
barColor = Property(QtGui.QBrush, _getBarColor, _setBarColor)
"""The color of the bar between the first and last handle."""
def _offsetAllPositions(self, offset: float, ref=None) -> None:
if ref is None:

View File

@@ -19,10 +19,11 @@ So that's what `_GenericSlider` is below.
scalar (with one handle per item), and it forms the basis of
QRangeSlider.
"""
import os
import platform
from typing import Generic, TypeVar
from qtpy import QtGui
from qtpy import QT_VERSION, QtGui
from qtpy.QtCore import QEvent, QPoint, QPointF, QRect, Qt, Signal
from qtpy.QtWidgets import (
QApplication,
@@ -32,6 +33,8 @@ from qtpy.QtWidgets import (
QStylePainter,
)
from ._range_style import MONTEREY_SLIDER_STYLES_FIX
_T = TypeVar("_T")
SC_NONE = QStyle.SubControl.SC_None
@@ -42,11 +45,23 @@ SC_TICKMARKS = QStyle.SubControl.SC_SliderTickmarks
CC_SLIDER = QStyle.ComplexControl.CC_Slider
QOVERFLOW = 2**31 - 1
# whether to use the MONTEREY_SLIDER_STYLES_FIX QSS hack
# for fixing sliders on macos>=12 with QT < 6
# https://bugreports.qt.io/browse/QTBUG-98093
# https://github.com/napari/superqt/issues/74
USE_MAC_SLIDER_PATCH = (
QT_VERSION
and int(QT_VERSION.split(".")[0]) < 6
and platform.system() == "Darwin"
and int(platform.mac_ver()[0].split(".", maxsplit=1)[0]) >= 12
and os.getenv("USE_MAC_SLIDER_PATCH", "0") not in ("0", "False", "false")
)
class _GenericSlider(QSlider, Generic[_T]):
_fvalueChanged = Signal(float)
_fsliderMoved = Signal(float)
_frangeChanged = Signal(float, float)
_fvalueChanged = Signal(int)
_fsliderMoved = Signal(int)
_frangeChanged = Signal(int, int)
MAX_DISPLAY = 5000
@@ -79,6 +94,16 @@ class _GenericSlider(QSlider, Generic[_T]):
self.rangeChanged = self._frangeChanged
self.setAttribute(Qt.WidgetAttribute.WA_Hover)
self.setStyleSheet("")
if USE_MAC_SLIDER_PATCH:
self.applyMacStylePatch()
def applyMacStylePatch(self) -> str:
"""Apply a QSS patch to fix sliders on macos>=12 with QT < 6.
see [FAQ](../faq.md#sliders-not-dragging-properly-on-macos-12) for more details.
"""
self.setStyleSheet(MONTEREY_SLIDER_STYLES_FIX)
# ############### QtOverrides #######################
@@ -134,8 +159,8 @@ class _GenericSlider(QSlider, Generic[_T]):
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_))
oldMin, self._minimum = self._minimum, self._type_cast(min)
oldMax, self._maximum = self._maximum, self._type_cast(max(min, max_))
if oldMin != self._minimum or oldMax != self._maximum:
self.sliderChange(self.SliderChange.SliderRangeChange)
@@ -272,6 +297,27 @@ class _GenericSlider(QSlider, Generic[_T]):
opt.subControls |= SC_TICKMARKS
painter.drawComplexControl(CC_SLIDER, opt)
if (
opt.tickPosition != QSlider.TickPosition.NoTicks
and "MONTEREY_SLIDER_STYLES_FIX" in self.styleSheet()
):
# draw tick marks manually because they are badly behaved with style sheets
interval = opt.tickInterval or int(self._pageStep)
_range = self._maximum - self._minimum
nticks = (_range + interval) // interval
painter.setPen(QtGui.QColor("#C7C7C7"))
half_height = 3
for i in range(int(nticks)):
if self.orientation() == Qt.Orientation.Vertical:
y = int((self.height() - 8) * i / (nticks - 1)) + 1
x = self.rect().center().x()
painter.drawRect(x - half_height, y, 6, 1)
else:
x = int((self.width() - 3) * i / (nticks - 1)) + 1
y = self.rect().center().y()
painter.drawRect(x, y - half_height, 1, 6)
self._draw_handle(painter, opt)
# ############### Implementation Details #######################

View File

@@ -17,6 +17,7 @@ from qtpy.QtWidgets import (
QWidget,
)
from ..utils import signals_blocked
from ._sliders import QDoubleRangeSlider, QDoubleSlider, QRangeSlider
@@ -118,6 +119,8 @@ def _handle_overloaded_slider_sig(args, kwargs):
class QLabeledSlider(_SliderProxy, QAbstractSlider):
editingFinished = Signal()
EdgeLabelMode = EdgeLabelMode
_slider_class = QSlider
_slider: QSlider
@@ -139,6 +142,7 @@ class QLabeledSlider(_SliderProxy, QAbstractSlider):
self._slider.sliderReleased.connect(self.sliderReleased.emit)
self._slider.valueChanged.connect(self._label.setValue)
self._slider.valueChanged.connect(self.valueChanged.emit)
self._label.editingFinished.connect(self.editingFinished)
self.setOrientation(orientation)
@@ -181,9 +185,11 @@ class QLabeledSlider(_SliderProxy, QAbstractSlider):
self.setLayout(layout)
def edgeLabelMode(self) -> EdgeLabelMode:
"""Return current `EdgeLabelMode`."""
return self._edge_label_mode
def setEdgeLabelMode(self, opt: EdgeLabelMode) -> None:
"""Set the `EdgeLabelMode`."""
if opt is EdgeLabelMode.LabelIsRange:
raise ValueError(
"mode must be one of 'EdgeLabelMode.NoLabel' or "
@@ -230,6 +236,8 @@ class QLabeledDoubleSlider(QLabeledSlider):
class QLabeledRangeSlider(_SliderProxy, QAbstractSlider):
_valueChanged = Signal(tuple)
editingFinished = Signal()
LabelPosition = LabelPosition
EdgeLabelMode = EdgeLabelMode
_slider_class = QRangeSlider
@@ -262,6 +270,8 @@ class QLabeledRangeSlider(_SliderProxy, QAbstractSlider):
alignment=Qt.AlignmentFlag.AlignRight,
connect=self._max_label_edited,
)
self._min_label.editingFinished.connect(self.editingFinished)
self._max_label.editingFinished.connect(self.editingFinished)
self.setEdgeLabelMode(EdgeLabelMode.LabelIsRange)
self._slider.valueChanged.connect(self._on_value_changed)
@@ -275,9 +285,11 @@ class QLabeledRangeSlider(_SliderProxy, QAbstractSlider):
self.valueChanged = self._valueChanged
def handleLabelPosition(self) -> LabelPosition:
"""Return where/whether labels are shown adjacent to slider handles."""
return self._handle_label_position
def setHandleLabelPosition(self, opt: LabelPosition) -> LabelPosition:
"""Set where/whether labels are shown adjacent to slider handles."""
self._handle_label_position = opt
for lbl in self._handle_labels:
if not opt:
@@ -287,9 +299,11 @@ class QLabeledRangeSlider(_SliderProxy, QAbstractSlider):
self.setOrientation(self.orientation())
def edgeLabelMode(self) -> EdgeLabelMode:
"""Return current `EdgeLabelMode`."""
return self._edge_label_mode
def setEdgeLabelMode(self, opt: EdgeLabelMode):
"""Set `EdgeLabelMode`, controls what is shown at the min/max labels."""
self._edge_label_mode = opt
if not self._edge_label_mode:
self._min_label.hide()
@@ -311,7 +325,10 @@ class QLabeledRangeSlider(_SliderProxy, QAbstractSlider):
self._reposition_labels()
def _reposition_labels(self):
if not self._handle_labels:
if (
not self._handle_labels
or self._handle_label_position == LabelPosition.NoLabel
):
return
horizontal = self.orientation() == Qt.Orientation.Horizontal
@@ -343,6 +360,7 @@ class QLabeledRangeSlider(_SliderProxy, QAbstractSlider):
label.move(pos)
last_edge = pos
label.clearFocus()
label.show()
self.update()
def _min_label_edited(self, val):
@@ -376,6 +394,7 @@ class QLabeledRangeSlider(_SliderProxy, QAbstractSlider):
for n, val in enumerate(self._slider.value()):
_cb = partial(self._slider.setSliderPosition, index=n)
s = SliderLabel(self._slider, parent=self, connect=_cb)
s.editingFinished.connect(self.editingFinished)
s.setValue(val)
self._handle_labels.append(s)
else:
@@ -491,9 +510,13 @@ class SliderLabel(QDoubleSpinBox):
self.setStyleSheet("background:transparent; border: 0;")
if connect is not None:
self.editingFinished.connect(lambda: connect(self.value()))
self.editingFinished.connect(self.clearFocus)
self.editingFinished.connect(self._silent_clear_focus)
self._update_size()
def _silent_clear_focus(self):
with signals_blocked(self):
self.clearFocus()
def setDecimals(self, prec: int) -> None:
super().setDecimals(prec)
self._update_size()

View File

@@ -36,6 +36,7 @@ class RangeSliderStyle:
v_offset: float | None = None
h_offset: float | None = None
has_stylesheet: bool = False
_macpatch: bool = False
def brush(self, opt: QStyleOptionSlider) -> QBrush:
cg = opt.palette.currentColorGroup()
@@ -86,15 +87,15 @@ class RangeSliderStyle:
val = QColor(val)
if opt.tickPosition != QSlider.TickPosition.NoTicks:
val.setAlphaF(self.tick_bar_alpha or SYSTEM_STYLE.tick_bar_alpha)
return val
def offset(self, opt: QStyleOptionSlider) -> int:
tp = opt.tickPosition
off = 0
if not self.has_stylesheet:
tp = opt.tickPosition
if opt.orientation == Qt.Orientation.Horizontal:
off += self.h_offset or SYSTEM_STYLE.h_offset or 0
if not self._macpatch:
off += self.h_offset or SYSTEM_STYLE.h_offset or 0
else:
off += self.v_offset or SYSTEM_STYLE.v_offset or 0
if tp == QSlider.TickPosition.TicksAbove:
@@ -259,7 +260,8 @@ def parse_color(color: str, default_attr) -> QColor | QGradient:
def update_styles_from_stylesheet(obj: _GenericRangeSlider):
qss = obj.styleSheet()
qss: str = obj.styleSheet()
parent = obj.parent()
while parent is not None:
@@ -268,6 +270,11 @@ def update_styles_from_stylesheet(obj: _GenericRangeSlider):
qss = QApplication.instance().styleSheet() + qss
if not qss:
return
if MONTEREY_SLIDER_STYLES_FIX in qss:
qss = qss.replace(MONTEREY_SLIDER_STYLES_FIX, "")
obj._style._macpatch = True
else:
obj._style._macpatch = False
# Find bar height/width
for orient, dim in (("horizontal", "height"), ("vertical", "width")):
@@ -279,3 +286,56 @@ def update_styles_from_stylesheet(obj: _GenericRangeSlider):
thickness = float(bgrd.groups()[-1])
setattr(obj._style, f"{orient}_thickness", thickness)
obj._style.has_stylesheet = True
# a fix for https://bugreports.qt.io/browse/QTBUG-98093
MONTEREY_SLIDER_STYLES_FIX = """
/* MONTEREY_SLIDER_STYLES_FIX */
QSlider::groove {
background: #DFDFDF;
border: 1px solid #DBDBDB;
border-radius: 2px;
}
QSlider::groove:horizontal {
height: 2px;
margin: 2px;
}
QSlider::groove:vertical {
width: 2px;
margin: 2px 0 6px 0;
}
QSlider::handle {
background: white;
border: 0.5px solid #DADADA;
width: 19.5px;
height: 19.5px;
border-radius: 10.5px;
}
QSlider::handle:horizontal {
margin: -10px -2px;
}
QSlider::handle:vertical {
margin: -2px -10px;
}
QSlider::handle:pressed {
background: #F0F0F0;
}
QSlider::sub-page:horizontal {
background: #0981FE;
border-radius: 2px;
margin: 2px;
height: 2px;
}
QSlider::add-page:vertical {
background: #0981FE;
border-radius: 2px;
margin: 2px 0 6px 0;
width: 2px;
}
""".strip()

View File

@@ -14,6 +14,10 @@ class _IntMixin:
class _FloatMixin:
_fvalueChanged = Signal(float)
_fsliderMoved = Signal(float)
_frangeChanged = Signal(float, float)
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self._singleStep = 0.01

View File

@@ -0,0 +1,226 @@
from typing import TYPE_CHECKING, Optional, Union
try:
from pint import Quantity, Unit, UnitRegistry
from pint.util import UnitsContainer
except ImportError as e:
raise ImportError(
"pint is required to use QQuantity. Install it with `pip install pint`"
) from e
from qtpy.QtCore import Signal
from qtpy.QtWidgets import QComboBox, QDoubleSpinBox, QHBoxLayout, QSizePolicy, QWidget
from ..utils import signals_blocked
if TYPE_CHECKING:
from decimal import Decimal
Number = Union[int, float, "Decimal"]
UREG = UnitRegistry()
NULL_OPTION = "-----"
QOVERFLOW = 2**30
SI_BASES = {
"[length]": "meter",
"[time]": "second",
"[current]": "ampere",
"[luminosity]": "candela",
"[mass]": "gram",
"[substance]": "mole",
"[temperature]": "kelvin",
}
DEFAULT_OPTIONS = {
"[length]": ["km", "m", "mm", "µm"],
"[time]": ["day", "hour", "min", "sec", "ms"],
"[current]": ["A", "mA", "µA"],
"[luminosity]": ["kcd", "cd", "mcd"],
"[mass]": ["kg", "g", "mg", "µg"],
"[substance]": ["mol", "mmol", "µmol"],
"[temperature]": ["°C", "°F", "°K"],
"radian": ["rad", "deg"],
}
class QQuantity(QWidget):
"""A combination QDoubleSpinBox and QComboBox for entering quantities.
For this widget, `value()` returns a `pint.Quantity` object, while `setValue()`
accepts either a number, `pint.Quantity`, a string that can be parsed by `pint`.
Parameters
----------
value : Union[str, pint.Quantity, Number]
The initial value to display. If a string, it will be parsed by `pint`.
units : Union[pint.util.UnitsContainer, str, pint.Quantity], optional
The units to use if `value` is a number. If a string, it will be parsed by
`pint`. If a `pint.Quantity`, the units will be extracted from it.
ureg : pint.UnitRegistry, optional
The unit registry to use. If not provided, the registry will be extracted
from `value` if it is a `pint.Quantity`, otherwise the default registry will
be used.
parent : QWidget, optional
The parent widget, by default None
"""
valueChanged = Signal(Quantity)
unitsChanged = Signal(Unit)
dimensionalityChanged = Signal(UnitsContainer)
def __init__(
self,
value: Union[str, Quantity, Number],
units: Union[UnitsContainer, str, Quantity] = None,
ureg: Optional[UnitRegistry] = None,
parent: Optional[QWidget] = None,
) -> None:
super().__init__(parent=parent)
if ureg is None:
ureg = value._REGISTRY if isinstance(value, Quantity) else UREG
else:
assert isinstance(ureg, UnitRegistry)
self._ureg = ureg
self._value: Quantity = self._ureg.Quantity(value, units=units)
# whether to preserve quantity equality when changing units or magnitude
self._preserve_quantity: bool = False
self._abbreviate_units: bool = True # TODO: implement
self._mag_spinbox = QDoubleSpinBox()
self._mag_spinbox.setDecimals(3)
self._mag_spinbox.setRange(-QOVERFLOW, QOVERFLOW - 1)
self._mag_spinbox.setValue(float(self._value.magnitude))
self._mag_spinbox.valueChanged.connect(self.setMagnitude)
self._units_combo = QComboBox()
self._units_combo.setSizePolicy(QSizePolicy.Fixed, QSizePolicy.Fixed)
self._units_combo.currentTextChanged.connect(self.setUnits)
self._update_units_combo_choices()
self.setLayout(QHBoxLayout())
self.layout().addWidget(self._mag_spinbox)
self.layout().addWidget(self._units_combo)
self.layout().setContentsMargins(6, 0, 0, 0)
def unitRegistry(self) -> UnitRegistry:
"""Return the pint UnitRegistry used by this widget."""
return self._ureg
def _update_units_combo_choices(self):
if self._value.dimensionless:
with signals_blocked(self._units_combo):
self._units_combo.clear()
self._units_combo.addItem(NULL_OPTION)
self._units_combo.addItems(
[self._format_units(x) for x in SI_BASES.values()]
)
self._units_combo.setCurrentText(NULL_OPTION)
return
units = self._value.units
dims, exp = next(iter(units.dimensionality.items()))
if exp != 1:
raise NotImplementedError("Inverse units not yet implemented")
options = [
self._format_units(self._ureg.Unit(u))
for u in DEFAULT_OPTIONS.get(dims, [])
]
current = self._format_units(units)
with signals_blocked(self._units_combo):
self._units_combo.clear()
self._units_combo.addItems(options)
if self._units_combo.findText(current) == -1:
self._units_combo.addItem(current)
self._units_combo.setCurrentText(current)
def value(self) -> Quantity:
"""Return the current value as a `pint.Quantity`."""
return self._value
def text(self) -> str:
return str(self._value)
def magnitude(self) -> Union[float, int]:
"""Return the magnitude of the current value."""
return self._value.magnitude
def units(self) -> Unit:
"""Return the current units."""
return self._value.units
def dimensionality(self) -> UnitsContainer:
"""Return the current dimensionality (cast to `str` for nice repr)."""
return self._value.dimensionality
def setDecimals(self, decimals: int) -> None:
"""Set the number of decimals to display in the spinbox."""
self._mag_spinbox.setDecimals(decimals)
if self._value is not None:
self._mag_spinbox.setValue(self._value.magnitude)
def setValue(
self,
value: Union[str, Quantity, Number],
units: Union[UnitsContainer, str, Quantity] = None,
) -> None:
"""Set the current value (will cast to a pint Quantity)."""
new_val = self._ureg.Quantity(value, units=units)
mag_change = new_val.magnitude != self._value.magnitude
units_change = new_val.units != self._value.units
dims_changed = new_val.dimensionality != self._value.dimensionality
self._value = new_val
if mag_change:
with signals_blocked(self._mag_spinbox):
self._mag_spinbox.setValue(float(self._value.magnitude))
if units_change:
with signals_blocked(self._units_combo):
self._units_combo.setCurrentText(self._format_units(self._value.units))
self.unitsChanged.emit(self._value.units)
if dims_changed:
self._update_units_combo_choices()
self.dimensionalityChanged.emit(self._value.dimensionality)
if mag_change or units_change:
self.valueChanged.emit(self._value)
def setMagnitude(self, magnitude: Number) -> None:
"""Set the magnitude of the current value."""
self.setValue(self._ureg.Quantity(magnitude, self._value.units))
def setUnits(self, units: Union[str, Unit, Quantity]) -> None:
"""Set the units of the current value.
If `units` is `None`, will convert to a dimensionless quantity.
Otherwise, units must be compatible with the current dimensionality.
"""
if units is None:
new_val = self._ureg.Quantity(self._value.magnitude)
elif self.isDimensionless():
new_val = self._ureg.Quantity(self._value.magnitude, units)
else:
new_val = self._value.to(units)
self.setValue(new_val)
def isDimensionless(self) -> bool:
"""Return `True` if the current value is dimensionless."""
return self._value.dimensionless
def magnitudeSpinBox(self) -> QDoubleSpinBox:
"""Return the `QSpinBox` widget used to edit the magnitude."""
return self._mag_spinbox
def unitsComboBox(self) -> QComboBox:
"""Return the `QCombBox` widget used to edit the units."""
return self._units_combo
def _format_units(self, u: Union[Unit, str]) -> str:
if isinstance(u, str):
return u
return f"{u:~}" if self._abbreviate_units else f"{u:}"

View File

@@ -92,7 +92,7 @@ class WorkerBase(QRunnable, Generic[_R]):
signal emitter object. To allow identify which worker thread emitted signal.
"""
#: A set of Workers. Add to set using :meth:`WorkerBase.start`
#: A set of Workers. Add to set using `WorkerBase.start`
_worker_set: Set[WorkerBase] = set()
returned: SigInst[_R]
errored: SigInst[Exception]
@@ -113,11 +113,11 @@ class WorkerBase(QRunnable, Generic[_R]):
def __getattr__(self, name: str) -> SigInst:
"""Pass through attr requests to signals to simplify connection API.
The goal is to enable ``worker.yielded.connect`` instead of
``worker.signals.yielded.connect``. Because multiple inheritance of Qt
The goal is to enable `worker.yielded.connect` instead of
`worker.signals.yielded.connect`. Because multiple inheritance of Qt
classes is not well supported in PyQt, we have to use composition here
(signals are provided by QObjects, and QRunnable is not a QObject). So
this passthrough allows us to connect to signals on the ``_signals``
this passthrough allows us to connect to signals on the `_signals`
object.
"""
# the Signal object is actually a class attribute
@@ -134,11 +134,10 @@ class WorkerBase(QRunnable, Generic[_R]):
def quit(self) -> None:
"""Send a request to abort the worker.
.. note::
!!! note
It is entirely up to subclasses to honor this method by checking
``self.abort_requested`` periodically in their ``worker.work``
method, and exiting if ``True``.
`self.abort_requested` periodically in their `worker.work`
method, and exiting if `True`.
"""
self._abort_requested = True
@@ -160,20 +159,20 @@ class WorkerBase(QRunnable, Generic[_R]):
The order of method calls when starting a worker is:
.. code-block:: none
```
calls QThreadPool.globalInstance().start(worker)
| triggered by the QThreadPool.start() method
| | called by worker.run
| | |
V V V
worker.start -> worker.run -> worker.work
```
**This** is the function that actually gets called when calling
:func:`QThreadPool.start(worker)`. It simply wraps the :meth:`work`
`QThreadPool.start(worker)`. It simply wraps the `work()`
method, and emits a few signals. Subclasses should NOT override this
method (except with good reason), and instead should implement
:meth:`work`.
`work()`.
"""
self.started.emit()
self._running = True
@@ -208,26 +207,26 @@ class WorkerBase(QRunnable, Generic[_R]):
The end-user should never need to call this function.
But subclasses must implement this method (See
:meth:`GeneratorFunction.work` for an example implementation).
Minimally, it should check ``self.abort_requested`` periodically and
[`GeneratorFunction.work`][superqt.utils._qthreading.GeneratorWorker.work] for an example implementation).
Minimally, it should check `self.abort_requested` periodically and
exit if True.
Examples
--------
.. code-block:: python
```python
class MyWorker(WorkerBase):
class MyWorker(WorkerBase):
def work(self):
i = 0
while True:
if self.abort_requested:
self.aborted.emit()
break
i += 1
if i > max_iters:
break
time.sleep(0.5)
def work(self):
i = 0
while True:
if self.abort_requested:
self.aborted.emit()
break
i += 1
if i > max_iters:
break
time.sleep(0.5)
```
"""
raise NotImplementedError(
f'"{self.__class__.__name__}" failed to define work() method'
@@ -238,14 +237,14 @@ class WorkerBase(QRunnable, Generic[_R]):
The order of method calls when starting a worker is:
.. code-block:: none
```
calls QThreadPool.globalInstance().start(worker)
| triggered by the QThreadPool.start() method
| | called by worker.run
| | |
V V V
worker.start -> worker.run -> worker.work
```
"""
if self in self._worker_set:
raise RuntimeError("This worker is already started!")
@@ -271,33 +270,33 @@ class WorkerBase(QRunnable, Generic[_R]):
def await_workers(cls, msecs: int = None) -> None:
"""Ask all workers to quit, and wait up to `msec` for quit.
Attempts to clean up all running workers by calling ``worker.quit()``
method. Any workers in the ``WorkerBase._worker_set`` set will have this
Attempts to clean up all running workers by calling `worker.quit()`
method. Any workers in the `WorkerBase._worker_set` set will have this
method.
By default, this function will block indefinitely, until worker threads
finish. If a timeout is provided, a ``RuntimeError`` will be raised if
finish. If a timeout is provided, a `RuntimeError` will be raised if
the workers do not gracefully exit in the time requests, but the threads
will NOT be killed. It is (currently) left to the user to use their OS
to force-quit rogue threads.
.. important::
!!! important
If the user does not put any yields in their function, and the function
is super long, it will just hang... For instance, there's no graceful
way to kill this thread in python:
.. code-block:: python
@thread_worker
def ZZZzzz():
time.sleep(10000000)
```python
@thread_worker
def ZZZzzz():
time.sleep(10000000)
```
This is why it's always advisable to use a generator that periodically
yields for long-running computations in another thread.
See `this stack-overflow post
<https://stackoverflow.com/questions/323972/is-there-any-way-to-kill-a-thread>`_
See [this stack-overflow
post](https://stackoverflow.com/questions/323972/is-there-any-way-to-kill-a-thread)
for a good discussion on the difficulty of killing a rogue python thread:
Parameters
@@ -326,12 +325,11 @@ class WorkerBase(QRunnable, Generic[_R]):
class FunctionWorker(WorkerBase[_R]):
"""QRunnable with signals that wraps a simple long-running function.
.. note::
``FunctionWorker`` does not provide a way to stop a very long-running
function (e.g. ``time.sleep(10000)``). So whenever possible, it is
better to implement your long running function as a generator that
yields periodically, and use the :class:`GeneratorWorker` instead.
!!! note
`FunctionWorker` does not provide a way to stop a very long-running
function (e.g. `time.sleep(10000)`). So whenever possible, it is better to
implement your long running function as a generator that yields periodically,
and use the [`GeneratorWorker`][superqt.utils.GeneratorWorker] instead.
Parameters
----------
@@ -345,7 +343,7 @@ class FunctionWorker(WorkerBase[_R]):
Raises
------
TypeError
If ``func`` is a generator function and not a regular function.
If `func` is a generator function and not a regular function.
"""
def __init__(self, func: Callable[_P, _R], *args, **kwargs):
@@ -454,7 +452,7 @@ class GeneratorWorker(WorkerBase, Generic[_Y, _S, _R]):
return exc.value
except RuntimeError as exc:
# The worker has probably been deleted. warning will be
# emitted in ``WorkerBase.run``
# emitted in `WorkerBase.run`
return exc
return None
@@ -534,38 +532,39 @@ def create_worker(
) -> Union[FunctionWorker, GeneratorWorker]:
"""Convenience function to start a function in another thread.
By default, uses :class:`Worker`, but a custom ``WorkerBase`` subclass may
be provided. If so, it must be a subclass of :class:`Worker`, which
defines a standard set of signals and a run method.
By default, uses `FunctionWorker` for functions and `GeneratorWorker` for
generators, but a custom `WorkerBase` subclass may be provided. If so, it must be a
subclass of `WorkerBase`, which defines a standard set of signals and a run method.
Parameters
----------
func : Callable
The function to call in another thread.
_start_thread : bool, optional
_start_thread : bool
Whether to immediaetly start the thread. If False, the returned worker
must be manually started with ``worker.start()``. by default it will be
``False`` if the ``_connect`` argument is ``None``, otherwise ``True``.
must be manually started with `worker.start()`. by default it will be
`False` if the `_connect` argument is `None`, otherwise `True`.
_connect : Dict[str, Union[Callable, Sequence]], optional
A mapping of ``"signal_name"`` -> ``callable`` or list of ``callable``:
A mapping of `"signal_name"` -> `callable` or list of `callable`:
callback functions to connect to the various signals offered by the
worker class. by default None
_worker_class : type of GeneratorWorker or FunctionWorker, optional
The :class`WorkerBase` to instantiate, by default
:class:`FunctionWorker` will be used if ``func`` is a regular function,
and :class:`GeneratorWorker` will be used if it is a generator.
_ignore_errors : bool, optional
If ``False`` (the default), errors raised in the other thread will be
worker class. by default `None`
_worker_class : type of `GeneratorWorker` or `FunctionWorker`, optional
The [`WorkerBase`][superqt.utils.WorkerBase] to instantiate, by default
[`FunctionWorker`][superqt.utils.FunctionWorker] will be used if `func` is a
regular function, and [`GeneratorWorker`][superqt.utils.GeneratorWorker] will be
used if it is a generator.
_ignore_errors : bool
If `False` (the default), errors raised in the other thread will be
reraised in the main thread (makes debugging significantly easier).
*args
will be passed to ``func``
will be passed to `func`
**kwargs
will be passed to ``func``
will be passed to `func`
Returns
-------
worker : WorkerBase
An instantiated worker. If ``_start_thread`` was ``False``, the worker
An instantiated worker. If `_start_thread` was `False`, the worker
will have a `.start()` method that can be used to start the thread.
Raises
@@ -573,18 +572,17 @@ def create_worker(
TypeError
If a worker_class is provided that is not a subclass of WorkerBase.
TypeError
If _connect is provided and is not a dict of ``{str: callable}``
If _connect is provided and is not a dict of `{str: callable}`
Examples
--------
.. code-block:: python
def long_function(duration):
import time
time.sleep(duration)
worker = create_worker(long_function, 10)
```python
def long_function(duration):
import time
time.sleep(duration)
worker = create_worker(long_function, 10)
```
"""
worker: Union[FunctionWorker, GeneratorWorker]
@@ -616,7 +614,7 @@ def create_worker(
getattr(worker, key).connect(v)
# if the user has not provided a default connection for the "errored"
# signal... and they have not explicitly set ``ignore_errors=True``
# signal... and they have not explicitly set `ignore_errors=True`
# Then rereaise any errors from the thread.
if not _ignore_errors and not (_connect or {}).get("errored", False):
@@ -672,55 +670,55 @@ def thread_worker(
):
"""Decorator that runs a function in a separate thread when called.
When called, the decorated function returns a :class:`WorkerBase`. See
:func:`create_worker` for additional keyword arguments that can be used
When called, the decorated function returns a [`WorkerBase`][superqt.utils.WorkerBase]. See
[`create_worker`][superqt.utils.create_worker] for additional keyword arguments that can be used
when calling the function.
The returned worker will have these signals:
- *started*: emitted when the work is started
- *finished*: emitted when the work is finished
- *returned*: emitted with return value
- *errored*: emitted with error object on Exception
- **started**: emitted when the work is started
- **finished**: emitted when the work is finished
- **returned**: emitted with return value
- **errored**: emitted with error object on Exception
It will also have a ``worker.start()`` method that can be used to start
It will also have a `worker.start()` method that can be used to start
execution of the function in another thread. (useful if you need to connect
callbacks to signals prior to execution)
If the decorated function is a generator, the returned worker will also
provide these signals:
- *yielded*: emitted with yielded values
- *paused*: emitted when a running job has successfully paused
- *resumed*: emitted when a paused job has successfully resumed
- *aborted*: emitted when a running job is successfully aborted
- **yielded**: emitted with yielded values
- **paused**: emitted when a running job has successfully paused
- **resumed**: emitted when a paused job has successfully resumed
- **aborted**: emitted when a running job is successfully aborted
And these methods:
- *quit*: ask the thread to quit
- *toggle_paused*: toggle the running state of the thread.
- *send*: send a value into the generator. (This requires that your
decorator function uses the ``value = yield`` syntax)
- **quit**: ask the thread to quit
- **toggle_paused**: toggle the running state of the thread.
- **send**: send a value into the generator. (This requires that your
decorator function uses the `value = yield` syntax)
Parameters
----------
function : callable
Function to call in another thread. For communication between threads
may be a generator function.
start_thread : bool, optional
start_thread : bool
Whether to immediaetly start the thread. If False, the returned worker
must be manually started with ``worker.start()``. by default it will be
``False`` if the ``_connect`` argument is ``None``, otherwise ``True``.
connect : Dict[str, Union[Callable, Sequence]], optional
A mapping of ``"signal_name"`` -> ``callable`` or list of ``callable``:
must be manually started with `worker.start()`. by default it will be
`False` if the `_connect` argument is `None`, otherwise `True`.
connect : Dict[str, Union[Callable, Sequence]]
A mapping of `"signal_name"` -> `callable` or list of `callable`:
callback functions to connect to the various signals offered by the
worker class. by default None
worker_class : Type[WorkerBase], optional
The :class`WorkerBase` to instantiate, by default
:class:`FunctionWorker` will be used if ``func`` is a regular function,
and :class:`GeneratorWorker` will be used if it is a generator.
ignore_errors : bool, optional
If ``False`` (the default), errors raised in the other thread will be
worker_class : Type[WorkerBase]
The [`WorkerBase`][superqt.utils.WorkerBase] to instantiate, by default
[`FunctionWorker`][superqt.utils.FunctionWorker] will be used if `func` is a regular function,
and [`GeneratorWorker`][superqt.utils.GeneratorWorker] will be used if it is a generator.
ignore_errors : bool
If `False` (the default), errors raised in the other thread will be
reraised in the main thread (makes debugging significantly easier).
Returns
@@ -731,25 +729,26 @@ def thread_worker(
Examples
--------
.. code-block:: python
```python
@thread_worker
def long_function(start, end):
# do work, periodically yielding
i = start
while i <= end:
time.sleep(0.1)
yield i
@thread_worker
def long_function(start, end):
# do work, periodically yielding
i = start
while i <= end:
time.sleep(0.1)
yield i
# do teardown
return 'anything'
# do teardown
return 'anything'
# call the function to start running in another thread.
worker = long_function()
# call the function to start running in another thread.
worker = long_function()
# connect signals here if desired... or they may be added using the
# `connect` argument in the `@thread_worker` decorator... in which
# case the worker will start immediately when long_function() is called
worker.start()
# connect signals here if desired... or they may be added using the
# `connect` argument in the `@thread_worker` decorator... in which
# case the worker will start immediately when long_function() is called
worker.start()
```
"""
def _inner(func):
@@ -804,36 +803,35 @@ def new_worker_qthread(
_connect: Dict[str, Callable] = None,
**kwargs,
):
"""This is a convenience function to start a worker in a Qthread.
"""This is a convenience function to start a worker in a `QThread`.
In most cases, the @thread_worker decorator is sufficient and preferable.
But this allows the user to completely customize the Worker object.
However, they must then maintain control over the thread and clean up
In most cases, the [thread_worker][superqt.utils.thread_worker] decorator is
sufficient and preferable. But this allows the user to completely customize the
Worker object. However, they must then maintain control over the thread and clean up
appropriately.
It follows the pattern described here:
https://www.qt.io/blog/2010/06/17/youre-doing-it-wrong
and
https://doc.qt.io/qt-5/qthread.html#details
It follows the pattern described
[here](https://www.qt.io/blog/2010/06/17/youre-doing-it-wrong) and in the [qt thread
docs](https://doc.qt.io/qt-5/qthread.html#details)
see also:
https://mayaposch.wordpress.com/2011/11/01/how-to-really-truly-use-qthreads-the-full-explanation/
A QThread object is not a thread! It should be thought of as a class to
*manage* a thread, not as the actual code or object that runs in that
A QThread object is not a thread! It should be thought of as a class to *manage* a
thread, not as the actual code or object that runs in that
thread. The QThread object is created on the main thread and lives there.
Worker objects which derive from QObject are the things that actually do
the work. They can be moved to a QThread as is done here.
.. note:: Mostly ignorable detail
??? "Mostly ignorable detail"
While the signals/slots syntax of the worker looks very similar to
standard "single-threaded" signals & slots, note that inter-thread
signals and slots (automatically) use an event-based QueuedConnection,
while intra-thread signals use a DirectConnection. See `Signals and
Slots Across Threads
<https://doc.qt.io/qt-5/threads-qobject.html#signals-and-slots-across-threads>`_
signals and slots (automatically) use an event-based QueuedConnection, while
intra-thread signals use a DirectConnection. See [Signals and Slots Across
Threads](https://doc.qt.io/qt-5/threads-qobject.html#signals-and-slots-across-threads>)
Parameters
----------
@@ -843,7 +841,7 @@ def new_worker_qthread(
_start_thread : bool
If True, thread will be started immediately, otherwise, thread must
be manually started with thread.start().
_connect : dict, optional
_connect : dict
Optional dictionary of {signal: function} to connect to the new worker.
for instance: _connect = {'incremented': myfunc} will result in:
worker.incremented.connect(myfunc)
@@ -863,33 +861,33 @@ def new_worker_qthread(
--------
Create some QObject that has a long-running work method:
.. code-block:: python
```python
class Worker(QObject):
class Worker(QObject):
finished = Signal()
increment = Signal(int)
finished = Signal()
increment = Signal(int)
def __init__(self, argument):
super().__init__()
self.argument = argument
def __init__(self, argument):
super().__init__()
self.argument = argument
@Slot()
def work(self):
# some long running task...
import time
for i in range(10):
time.sleep(1)
self.increment.emit(i)
self.finished.emit()
worker, thread = new_worker_qthread(
Worker,
'argument',
_start_thread=True,
_connect={'increment': print},
)
@Slot()
def work(self):
# some long running task...
import time
for i in range(10):
time.sleep(1)
self.increment.emit(i)
self.finished.emit()
worker, thread = new_worker_qthread(
Worker,
'argument',
_start_thread=True,
_connect={'increment': print},
)
```
"""
if _connect and not isinstance(_connect, dict):

View File

@@ -103,7 +103,7 @@ class GenericSignalThrottler(QObject):
self.timeoutChanged.emit(timeout)
def timerType(self) -> Qt.TimerType:
"""Return current Qt.TimerType."""
"""Return current `Qt.TimerType`."""
return self._timer.timerType()
def setTimerType(self, timerType: Qt.TimerType) -> None:
@@ -136,11 +136,11 @@ class GenericSignalThrottler(QObject):
assert self._timer.isActive()
def cancel(self) -> None:
""" "Cancel and pending emissions."""
"""Cancel any pending emissions."""
self._hasPendingEmission = False
def flush(self) -> None:
""" "Force emission of any pending emissions."""
"""Force emission of any pending emissions."""
self._maybeEmitTriggered()
def _emitTriggered(self) -> None:

31
tests/test_quantity.py Normal file
View File

@@ -0,0 +1,31 @@
from superqt import QQuantity
def test_qquantity(qtbot):
w = QQuantity(1, "m")
qtbot.addWidget(w)
assert w.value() == 1 * w.unitRegistry().meter
assert w.magnitude() == 1
assert w.units() == w.unitRegistry().meter
assert w.text() == "1 meter"
w.setUnits("cm")
assert w.value() == 100 * w.unitRegistry().centimeter
assert w.magnitude() == 100
assert w.units() == w.unitRegistry().centimeter
assert w.text() == "100.0 centimeter"
w.setMagnitude(10)
assert w.value() == 10 * w.unitRegistry().centimeter
assert w.magnitude() == 10
assert w.units() == w.unitRegistry().centimeter
assert w.text() == "10 centimeter"
w.setValue(1 * w.unitRegistry().meter)
assert w.value() == 1 * w.unitRegistry().meter
assert w.magnitude() == 1
assert w.units() == w.unitRegistry().meter
assert w.text() == "1 meter"
w.setUnits(None)
assert w.isDimensionless()
assert w.unitsComboBox().currentText() == "-----"
assert w.magnitude() == 1

View File

@@ -79,7 +79,7 @@ def _hover_event(_type, position, old_position, widget=None):
return QHoverEvent(_type, position, old_position)
def _linspace(start, stop, n):
def _linspace(start: int, stop: int, n: int):
h = (stop - start) / (n - 1)
for i in range(n):
yield start + h * i

View File

@@ -1,7 +1,9 @@
import math
import os
import pytest
from qtpy import API_NAME
from qtpy.QtWidgets import QStyleOptionSlider
from superqt import (
QDoubleRangeSlider,
@@ -10,6 +12,8 @@ from superqt import (
QLabeledDoubleSlider,
)
from ._testutil import _linspace
range_types = {QDoubleRangeSlider, QLabeledDoubleRangeSlider}
@@ -122,3 +126,15 @@ def test_signals(ds, qtbot):
with qtbot.waitSignal(ds.rangeChanged):
ds.setRange(1.2, 3.3)
@pytest.mark.parametrize("mag", list(range(4, 37, 4)) + list(range(-4, -37, -4)))
def test_slider_extremes(mag, qtbot):
sld = QDoubleSlider()
_mag = 10**mag
with qtbot.waitSignal(sld.rangeChanged):
sld.setRange(-_mag, _mag)
for i in _linspace(-_mag, _mag, 10):
sld.setValue(i)
assert math.isclose(sld.value(), i, rel_tol=1e-8)
sld.initStyleOption(QStyleOptionSlider())

View File

@@ -7,13 +7,7 @@ from qtpy.QtWidgets import QStyle, QStyleOptionSlider
from superqt.sliders._generic_slider import _GenericSlider, _sliderValueFromPosition
from ._testutil import (
_hover_event,
_linspace,
_mouse_event,
_wheel_event,
skip_on_linux_qt6,
)
from ._testutil import _hover_event, _mouse_event, _wheel_event, skip_on_linux_qt6
@pytest.fixture(params=[Qt.Orientation.Horizontal, Qt.Orientation.Vertical])
@@ -169,17 +163,6 @@ def test_steps(gslider: _GenericSlider, qtbot):
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",

View File

@@ -1,4 +1,10 @@
from superqt import QLabeledRangeSlider, QLabeledSlider
import sys
from typing import Any, Iterable
from unittest.mock import Mock
import pytest
from superqt import QLabeledDoubleSlider, QLabeledRangeSlider, QLabeledSlider
def test_labeled_slider_api(qtbot):
@@ -16,3 +22,57 @@ def test_slider_connect_works(qtbot):
qtbot.addWidget(slider)
slider._label.editingFinished.emit()
def _assert_types(args: Iterable[Any], type_: type):
# sourcery skip: comprehension-to-generator
if sys.version_info >= (3, 8):
assert all([isinstance(v, type_) for v in args]), "invalid type"
@pytest.mark.parametrize("cls", [QLabeledDoubleSlider, QLabeledSlider])
def test_labeled_signals(cls, qtbot):
gslider = cls()
qtbot.addWidget(gslider)
type_ = float if cls == QLabeledDoubleSlider else int
mock = Mock()
gslider.valueChanged.connect(mock)
with qtbot.waitSignal(gslider.valueChanged):
gslider.setValue(10)
mock.assert_called_once_with(10)
_assert_types(mock.call_args.args, type_)
mock = Mock()
gslider.rangeChanged.connect(mock)
with qtbot.waitSignal(gslider.rangeChanged):
gslider.setMinimum(3)
mock.assert_called_once_with(3, 99)
_assert_types(mock.call_args.args, type_)
mock.reset_mock()
with qtbot.waitSignal(gslider.rangeChanged):
gslider.setMaximum(15)
mock.assert_called_once_with(3, 15)
_assert_types(mock.call_args.args, type_)
mock.reset_mock()
with qtbot.waitSignal(gslider.rangeChanged):
gslider.setRange(1, 2)
mock.assert_called_once_with(1, 2)
_assert_types(mock.call_args.args, type_)
@pytest.mark.parametrize(
"cls", [QLabeledDoubleSlider, QLabeledRangeSlider, QLabeledSlider]
)
def test_editing_finished_signal(cls):
slider = cls()
mock = Mock()
slider.editingFinished.connect(mock)
if hasattr(slider, "_label"):
slider._label.editingFinished.emit()
else:
slider._min_label.editingFinished.emit()
mock.assert_called_once()

View File

@@ -1,10 +1,14 @@
import math
import sys
from itertools import product
from typing import Any, Iterable
from unittest.mock import Mock
import pytest
from qtpy.QtCore import QEvent, QPoint, QPointF, Qt
from qtpy.QtWidgets import QStyle, QStyleOptionSlider
from superqt import QDoubleRangeSlider, QRangeSlider
from superqt import QDoubleRangeSlider, QLabeledRangeSlider, QRangeSlider
from ._testutil import (
_hover_event,
@@ -14,161 +18,240 @@ from ._testutil import (
skip_on_linux_qt6,
)
ALL_SLIDER_COMBOS = list(
product(
[QDoubleRangeSlider, QRangeSlider, QLabeledRangeSlider],
[Qt.Orientation.Horizontal, Qt.Orientation.Vertical],
)
)
FLOAT_SLIDERS = [c for c in ALL_SLIDER_COMBOS if c[0] == QDoubleRangeSlider]
@pytest.fixture(params=[Qt.Orientation.Horizontal, Qt.Orientation.Vertical])
def gslider(qtbot, request):
slider = QDoubleRangeSlider(request.param)
qtbot.addWidget(slider)
@pytest.mark.parametrize("cls, orientation", ALL_SLIDER_COMBOS)
def test_slider_init(qtbot, cls, orientation):
slider = cls(orientation)
assert slider.value() == (20, 80)
assert slider.minimum() == 0
assert slider.maximum() == 99
yield slider
slider.initStyleOption(QStyleOptionSlider())
slider.show()
qtbot.addWidget(slider)
def test_change_floatslider_range(gslider: QRangeSlider, qtbot):
with qtbot.waitSignals([gslider.rangeChanged, gslider.valueChanged]):
gslider.setMinimum(30)
@pytest.mark.parametrize("cls, orientation", ALL_SLIDER_COMBOS)
def test_change_floatslider_range(cls, orientation, qtbot):
sld = cls(orientation)
qtbot.addWidget(sld)
assert gslider.value()[0] == 30 == gslider.minimum()
assert gslider.maximum() == 99
with qtbot.waitSignals([sld.rangeChanged, sld.valueChanged]):
sld.setMinimum(30)
with qtbot.waitSignal(gslider.rangeChanged):
gslider.setMaximum(70)
assert gslider.value()[0] == 30 == gslider.minimum()
assert gslider.value()[1] == 70 == gslider.maximum()
assert sld.value()[0] == 30 == sld.minimum()
assert sld.maximum() == 99
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(sld.rangeChanged):
sld.setMaximum(70)
assert sld.value()[0] == 30 == sld.minimum()
assert sld.value()[1] == 70 == sld.maximum()
with qtbot.waitSignal(gslider.valueChanged):
gslider.setValue([40, 50])
assert gslider.value()[0] == 40 == gslider.minimum()
assert gslider.value()[1] == 50
with qtbot.waitSignals([sld.rangeChanged, sld.valueChanged]):
sld.setRange(40, 60)
assert sld.value()[0] == 40 == sld.minimum()
assert sld.maximum() == 60
with qtbot.waitSignals([gslider.rangeChanged, gslider.valueChanged]):
gslider.setMaximum(45)
assert gslider.value()[0] == 40 == gslider.minimum()
assert gslider.value()[1] == 45 == gslider.maximum()
with qtbot.waitSignal(sld.valueChanged):
sld.setValue([40, 50])
assert sld.value()[0] == 40 == sld.minimum()
assert sld.value()[1] == 50
with qtbot.waitSignals([sld.rangeChanged, sld.valueChanged]):
sld.setMaximum(45)
assert sld.value()[0] == 40 == sld.minimum()
assert sld.value()[1] == 45 == sld.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
@pytest.mark.parametrize("cls, orientation", FLOAT_SLIDERS)
def test_float_values(cls, orientation, qtbot):
sld = cls(orientation)
qtbot.addWidget(sld)
with qtbot.waitSignal(gslider.valueChanged):
gslider.setValue([0.4, 0.6])
assert gslider.value() == (0.4, 0.6)
with qtbot.waitSignal(sld.rangeChanged):
sld.setRange(0.1, 0.9)
assert sld.minimum() == 0.1
assert sld.maximum() == 0.9
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()
with qtbot.waitSignal(sld.valueChanged):
sld.setValue([0.4, 0.6])
assert sld.value() == (0.4, 0.6)
with qtbot.waitSignal(sld.valueChanged):
sld.setValue([0, 1.9])
assert sld.value()[0] == 0.1 == sld.minimum()
assert sld.value()[1] == 0.9 == sld.maximum()
def test_position(gslider: QRangeSlider, qtbot):
gslider.setSliderPosition([10, 80])
assert gslider.sliderPosition() == (10, 80)
@pytest.mark.parametrize("cls, orientation", ALL_SLIDER_COMBOS)
def test_position(cls, orientation, qtbot):
sld = cls(orientation)
qtbot.addWidget(sld)
sld.setSliderPosition([10, 80])
assert sld.sliderPosition() == (10, 80)
def test_steps(gslider: QRangeSlider, qtbot):
gslider.setSingleStep(0.1)
assert gslider.singleStep() == 0.1
@pytest.mark.parametrize("cls, orientation", ALL_SLIDER_COMBOS)
def test_steps(cls, orientation, qtbot):
sld = cls(orientation)
qtbot.addWidget(sld)
gslider.setSingleStep(1.5e20)
assert gslider.singleStep() == 1.5e20
sld.setSingleStep(0.1)
assert sld.singleStep() == 0.1
gslider.setPageStep(0.2)
assert gslider.pageStep() == 0.2
sld.setSingleStep(1.5e20)
assert sld.singleStep() == 1.5e20
gslider.setPageStep(1.5e30)
assert gslider.pageStep() == 1.5e30
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(gslider: QRangeSlider, mag, qtbot):
@pytest.mark.parametrize("cls, orientation", FLOAT_SLIDERS)
def test_slider_extremes(cls, orientation, qtbot, mag):
sld = cls(orientation)
qtbot.addWidget(sld)
_mag = 10**mag
with qtbot.waitSignal(gslider.rangeChanged):
gslider.setRange(-_mag, _mag)
with qtbot.waitSignal(sld.rangeChanged):
sld.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())
sld.setValue((i, _mag))
assert math.isclose(sld.value()[0], i, rel_tol=0.0001)
sld.initStyleOption(QStyleOptionSlider())
def test_ticks(gslider: QRangeSlider, qtbot):
gslider.setTickInterval(0.3)
assert gslider.tickInterval() == 0.3
gslider.setTickPosition(gslider.TickPosition.TicksAbove)
gslider.show()
@pytest.mark.parametrize("cls, orientation", ALL_SLIDER_COMBOS)
def test_ticks(cls, orientation, qtbot):
sld = cls(orientation)
qtbot.addWidget(sld)
sld.setTickInterval(0.3)
assert sld.tickInterval() == 0.3
sld.setTickPosition(sld.TickPosition.TicksAbove)
sld.show()
def test_show(gslider, qtbot):
gslider.show()
@pytest.mark.parametrize("cls, orientation", FLOAT_SLIDERS)
def test_press_move_release(cls, orientation, qtbot):
sld = cls(orientation)
qtbot.addWidget(sld)
def test_press_move_release(gslider: QRangeSlider, qtbot):
# this fail on vertical came with pyside6.2 ... need to debug
# still works in practice, but test fails to catch signals
if gslider.orientation() == Qt.Orientation.Vertical:
if sld.orientation() == Qt.Orientation.Vertical:
pytest.xfail()
assert gslider._pressedControl == QStyle.SubControl.SC_None
assert sld._pressedControl == QStyle.SubControl.SC_None
opt = QStyleOptionSlider()
gslider.initStyleOption(opt)
style = gslider.style()
sld.initStyleOption(opt)
style = sld.style()
hrect = style.subControlRect(
QStyle.ComplexControl.CC_Slider, opt, QStyle.SubControl.SC_SliderHandle
)
handle_pos = gslider.mapToGlobal(hrect.center())
handle_pos = sld.mapToGlobal(hrect.center())
with qtbot.waitSignal(gslider.sliderPressed):
qtbot.mousePress(gslider, Qt.MouseButton.LeftButton, pos=handle_pos)
with qtbot.waitSignal(sld.sliderPressed):
qtbot.mousePress(sld, Qt.MouseButton.LeftButton, pos=handle_pos)
assert gslider._pressedControl == QStyle.SubControl.SC_SliderHandle
assert sld._pressedControl == QStyle.SubControl.SC_SliderHandle
with qtbot.waitSignals([gslider.sliderMoved, gslider.valueChanged]):
with qtbot.waitSignals([sld.sliderMoved, sld.valueChanged]):
shift = (
QPoint(0, -8)
if gslider.orientation() == Qt.Orientation.Vertical
if sld.orientation() == Qt.Orientation.Vertical
else QPoint(8, 0)
)
gslider.mouseMoveEvent(_mouse_event(handle_pos + shift))
sld.mouseMoveEvent(_mouse_event(handle_pos + shift))
with qtbot.waitSignal(gslider.sliderReleased):
qtbot.mouseRelease(gslider, Qt.MouseButton.LeftButton, pos=handle_pos)
with qtbot.waitSignal(sld.sliderReleased):
qtbot.mouseRelease(sld, Qt.MouseButton.LeftButton, pos=handle_pos)
assert gslider._pressedControl == QStyle.SubControl.SC_None
assert sld._pressedControl == QStyle.SubControl.SC_None
gslider.show()
with qtbot.waitSignal(gslider.sliderPressed):
qtbot.mousePress(gslider, Qt.MouseButton.LeftButton, pos=handle_pos)
sld.show()
with qtbot.waitSignal(sld.sliderPressed):
qtbot.mousePress(sld, Qt.MouseButton.LeftButton, pos=handle_pos)
@skip_on_linux_qt6
def test_hover(gslider: QRangeSlider):
@pytest.mark.parametrize("cls, orientation", FLOAT_SLIDERS)
def test_hover(cls, orientation, qtbot):
sld = cls(orientation)
qtbot.addWidget(sld)
hrect = gslider._handleRect(0)
handle_pos = QPointF(gslider.mapToGlobal(hrect.center()))
hrect = sld._handleRect(0)
handle_pos = QPointF(sld.mapToGlobal(hrect.center()))
assert gslider._hoverControl == QStyle.SubControl.SC_None
assert sld._hoverControl == QStyle.SubControl.SC_None
gslider.event(_hover_event(QEvent.Type.HoverEnter, handle_pos, QPointF(), gslider))
assert gslider._hoverControl == QStyle.SubControl.SC_SliderHandle
sld.event(_hover_event(QEvent.Type.HoverEnter, handle_pos, QPointF(), sld))
assert sld._hoverControl == QStyle.SubControl.SC_SliderHandle
gslider.event(
_hover_event(QEvent.Type.HoverLeave, QPointF(-1000, -1000), handle_pos, gslider)
sld.event(
_hover_event(QEvent.Type.HoverLeave, QPointF(-1000, -1000), handle_pos, sld)
)
assert gslider._hoverControl == QStyle.SubControl.SC_None
assert sld._hoverControl == QStyle.SubControl.SC_None
def test_wheel(gslider: QRangeSlider, qtbot):
with qtbot.waitSignal(gslider.valueChanged):
gslider.wheelEvent(_wheel_event(120))
@pytest.mark.parametrize("cls, orientation", FLOAT_SLIDERS)
def test_wheel(cls, orientation, qtbot):
sld = cls(orientation)
qtbot.addWidget(sld)
gslider.wheelEvent(_wheel_event(0))
with qtbot.waitSignal(sld.valueChanged):
sld.wheelEvent(_wheel_event(120))
sld.wheelEvent(_wheel_event(0))
def _assert_types(args: Iterable[Any], type_: type):
# sourcery skip: comprehension-to-generator
if sys.version_info >= (3, 8):
assert all([isinstance(v, type_) for v in args]), "invalid type"
@pytest.mark.parametrize("cls, orientation", ALL_SLIDER_COMBOS)
def test_rangeslider_signals(cls, orientation, qtbot):
sld = cls(orientation)
qtbot.addWidget(sld)
type_ = float if cls == QDoubleRangeSlider else int
mock = Mock()
sld.valueChanged.connect(mock)
with qtbot.waitSignal(sld.valueChanged):
sld.setValue((20, 40))
mock.assert_called_once_with((20, 40))
_assert_types(mock.call_args.args, tuple)
_assert_types(mock.call_args.args[0], type_)
mock = Mock()
sld.rangeChanged.connect(mock)
with qtbot.waitSignal(sld.rangeChanged):
sld.setMinimum(3)
mock.assert_called_once_with(3, 99)
_assert_types(mock.call_args.args, type_)
mock.reset_mock()
with qtbot.waitSignal(sld.rangeChanged):
sld.setMaximum(15)
mock.assert_called_once_with(3, 15)
_assert_types(mock.call_args.args, type_)
mock.reset_mock()
with qtbot.waitSignal(sld.rangeChanged):
sld.setRange(1, 2)
mock.assert_called_once_with(1, 2)
_assert_types(mock.call_args.args, type_)