mirror of
https://github.com/pyapp-kit/superqt.git
synced 2025-08-12 13:31:40 +02:00
Compare commits
41 Commits
Author | SHA1 | Date | |
---|---|---|---|
|
9f9dab6f3b | ||
|
97bb814451 | ||
|
d1c056886f | ||
|
a73e56bb83 | ||
|
6f71e46914 | ||
|
fbc67a745c | ||
|
77bd737e13 | ||
|
ba626e8786 | ||
|
04efa95511 | ||
|
f401d6d59c | ||
|
a3bd0d0edf | ||
|
e7e8dfc44c | ||
|
a556f16745 | ||
|
2864058974 | ||
|
463332f4fc | ||
|
f08e2d1720 | ||
|
39c10aa238 | ||
|
d5d40a35f3 | ||
|
5b92a19b82 | ||
|
a3b0f1b115 | ||
|
b1e6d55957 | ||
|
55535b7600 | ||
|
31c834053c | ||
|
69219c846d | ||
|
2edb3c287e | ||
|
218a7b4034 | ||
|
9ab24dbcf6 | ||
|
35acbbf5e6 | ||
|
0ae3350c57 | ||
|
c7f8780900 | ||
|
cc25733ce8 | ||
|
accb87021f | ||
|
ccad397838 | ||
|
68248c920c | ||
|
f8ac85aaf6 | ||
|
bd6fba96ad | ||
|
7d31812858 | ||
|
f27377ab1b | ||
|
2052fb8310 | ||
|
40d3e20bff | ||
|
f4d9881b0c |
22
.github/workflows/test_and_deploy.yml
vendored
22
.github/workflows/test_and_deploy.yml
vendored
@@ -70,9 +70,6 @@ jobs:
|
||||
- python-version: 3.8
|
||||
platform: ubuntu-18.04
|
||||
backend: pyside2
|
||||
- python-version: 3.6
|
||||
platform: windows-2016
|
||||
backend: pyqt5
|
||||
|
||||
# legacy Qt
|
||||
- python-version: 3.7
|
||||
@@ -162,32 +159,33 @@ jobs:
|
||||
name: napari tests
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v2
|
||||
- uses: actions/checkout@v3
|
||||
with:
|
||||
fetch-depth: 0
|
||||
path: superqt
|
||||
|
||||
- uses: actions/checkout@v2
|
||||
- uses: actions/checkout@v3
|
||||
with:
|
||||
repository: napari/napari
|
||||
path: napari
|
||||
path: napari-repo
|
||||
fetch-depth: 2
|
||||
|
||||
- uses: tlambert03/setup-qt-libs@v1
|
||||
- uses: actions/setup-python@v2
|
||||
- uses: actions/setup-python@v4
|
||||
with:
|
||||
python-version: '3.9'
|
||||
python-version: '3.10'
|
||||
|
||||
- name: install
|
||||
run: |
|
||||
python -m pip install -U pip
|
||||
python -m pip install -e ./napari[testing,pyqt5]
|
||||
python -m pip install -e ./superqt
|
||||
python -m pip install ./superqt
|
||||
python -m pip install ./napari-repo[testing,pyqt5]
|
||||
|
||||
- name: Test napari magicgui
|
||||
- name: Test napari
|
||||
uses: GabrielBB/xvfb-action@v1
|
||||
with:
|
||||
run: python -m pytest --color=yes napari/napari/_qt
|
||||
working-directory: napari-repo
|
||||
run: python -m pytest --color=yes napari/_qt
|
||||
|
||||
check_manifest:
|
||||
runs-on: ubuntu-latest
|
||||
|
1
.gitignore
vendored
1
.gitignore
vendored
@@ -82,3 +82,4 @@ src/superqt/_version.py
|
||||
screenshots
|
||||
|
||||
.mypy_cache
|
||||
docs/_auto_images/
|
||||
|
@@ -1,21 +1,22 @@
|
||||
repos:
|
||||
- repo: https://github.com/pre-commit/pre-commit-hooks
|
||||
rev: v4.1.0
|
||||
rev: v4.3.0
|
||||
hooks:
|
||||
- id: end-of-file-fixer
|
||||
- id: trailing-whitespace
|
||||
- repo: https://github.com/asottile/setup-cfg-fmt
|
||||
rev: v1.20.0
|
||||
rev: v2.0.0
|
||||
hooks:
|
||||
- id: setup-cfg-fmt
|
||||
args: ["--include-version-classifiers"]
|
||||
- repo: https://github.com/PyCQA/flake8
|
||||
rev: 4.0.1
|
||||
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"]
|
||||
@@ -24,16 +25,16 @@ repos:
|
||||
hooks:
|
||||
- id: isort
|
||||
- repo: https://github.com/asottile/pyupgrade
|
||||
rev: v2.31.0
|
||||
rev: v2.38.2
|
||||
hooks:
|
||||
- id: pyupgrade
|
||||
args: [--py37-plus, --keep-runtime-typing]
|
||||
- repo: https://github.com/psf/black
|
||||
rev: 22.1.0
|
||||
rev: 22.8.0
|
||||
hooks:
|
||||
- id: black
|
||||
- repo: https://github.com/pre-commit/mirrors-mypy
|
||||
rev: v0.931
|
||||
rev: v0.981
|
||||
hooks:
|
||||
- id: mypy
|
||||
exclude: examples
|
||||
|
81
CHANGELOG.md
81
CHANGELOG.md
@@ -1,5 +1,76 @@
|
||||
# Changelog
|
||||
|
||||
## [v0.3.6](https://github.com/napari/superqt/tree/v0.3.6) (2022-10-03)
|
||||
|
||||
[Full Changelog](https://github.com/napari/superqt/compare/v0.3.5...v0.3.6)
|
||||
|
||||
**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)
|
||||
|
||||
**Fixed bugs:**
|
||||
|
||||
- fix: relax runtime typing extensions requirement [\#101](https://github.com/napari/superqt/pull/101) ([tlambert03](https://github.com/tlambert03))
|
||||
- fix: catch qpixmap deprecation [\#99](https://github.com/napari/superqt/pull/99) ([tlambert03](https://github.com/tlambert03))
|
||||
|
||||
## [v0.3.3](https://github.com/napari/superqt/tree/v0.3.3) (2022-07-10)
|
||||
|
||||
[Full Changelog](https://github.com/napari/superqt/compare/v0.3.2...v0.3.3)
|
||||
|
||||
**Implemented enhancements:**
|
||||
|
||||
- Add code syntax highlight utils [\#88](https://github.com/napari/superqt/pull/88) ([Czaki](https://github.com/Czaki))
|
||||
|
||||
**Fixed bugs:**
|
||||
|
||||
- fix: fix deprecation warning on fonticon plugin discovery on python 3.10 [\#95](https://github.com/napari/superqt/pull/95) ([tlambert03](https://github.com/tlambert03))
|
||||
|
||||
## [v0.3.2](https://github.com/napari/superqt/tree/v0.3.2) (2022-05-03)
|
||||
|
||||
[Full Changelog](https://github.com/napari/superqt/compare/v0.3.1...v0.3.2)
|
||||
|
||||
**Implemented enhancements:**
|
||||
|
||||
- Add QSearchableListWidget and QSearchableComboBox widgets [\#80](https://github.com/napari/superqt/pull/80) ([Czaki](https://github.com/Czaki))
|
||||
|
||||
**Fixed bugs:**
|
||||
|
||||
- Fix crazy animation loop on Qcollapsible [\#84](https://github.com/napari/superqt/pull/84) ([tlambert03](https://github.com/tlambert03))
|
||||
- Reorder label update signal [\#83](https://github.com/napari/superqt/pull/83) ([tlambert03](https://github.com/tlambert03))
|
||||
- Fix height of expanded QCollapsible when child changes size [\#72](https://github.com/napari/superqt/pull/72) ([tlambert03](https://github.com/tlambert03))
|
||||
|
||||
**Tests & CI:**
|
||||
|
||||
- Fix deprecation warnings in tests [\#82](https://github.com/napari/superqt/pull/82) ([tlambert03](https://github.com/tlambert03))
|
||||
|
||||
**Merged pull requests:**
|
||||
|
||||
- Add changelog for v0.3.2 [\#86](https://github.com/napari/superqt/pull/86) ([tlambert03](https://github.com/tlambert03))
|
||||
|
||||
## [v0.3.1](https://github.com/napari/superqt/tree/v0.3.1) (2022-03-02)
|
||||
|
||||
[Full Changelog](https://github.com/napari/superqt/compare/v0.3.0...v0.3.1)
|
||||
@@ -12,6 +83,10 @@
|
||||
|
||||
- put SignalInstance in TYPE\_CHECKING clause, check min requirements [\#70](https://github.com/napari/superqt/pull/70) ([tlambert03](https://github.com/tlambert03))
|
||||
|
||||
**Merged pull requests:**
|
||||
|
||||
- Add changelog for v0.3.1 [\#71](https://github.com/napari/superqt/pull/71) ([tlambert03](https://github.com/tlambert03))
|
||||
|
||||
## [v0.3.0](https://github.com/napari/superqt/tree/v0.3.0) (2022-02-16)
|
||||
|
||||
[Full Changelog](https://github.com/napari/superqt/compare/v0.2.5-1...v0.3.0)
|
||||
@@ -117,13 +192,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.0rc0...v0.2.1)
|
||||
[Full Changelog](https://github.com/napari/superqt/compare/v0.2.0rc1...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.0rc1](https://github.com/napari/superqt/tree/v0.2.0rc1) (2021-06-26)
|
||||
|
||||
[Full Changelog](https://github.com/napari/superqt/compare/v0.2.0rc0...v0.2.0rc1)
|
||||
|
||||
## [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)
|
||||
|
24
README.md
24
README.md
@@ -1,6 +1,5 @@
|
||||
#  superqt!
|
||||
|
||||
|
||||
[](https://github.com/napari/superqt/raw/master/LICENSE)
|
||||
[](https://pypi.org/project/superqt)
|
||||
[
|
||||
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
144
docs/_macros.py
Normal 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"{{ 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
|
@@ -1,63 +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.
|
26
docs/faq.md
Normal file
26
docs/faq.md
Normal 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)
|
||||
```
|
29
docs/index.md
Normal file
29
docs/index.md
Normal file
@@ -0,0 +1,29 @@
|
||||
# superqt
|
||||
|
||||
##  "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.
|
237
docs/sliders.md
237
docs/sliders.md
@@ -1,237 +0,0 @@
|
||||
# Sliders
|
||||
|
||||
|
||||

|
||||
|
||||
- `QRangeSlider` inherits from [`QSlider`](https://doc.qt.io/qt-5/qslider.html)
|
||||
and attempts to match the Qt API as closely as possible
|
||||
- Uses platform-specific styles (for handle, groove, & ticks) but also supports
|
||||
QSS style sheets.
|
||||
- Supports mouse wheel and keypress (soon) events
|
||||
- Supports more than 2 handles (e.g. `slider.setValue([0, 10, 60, 80])`)
|
||||
|
||||
------
|
||||
|
||||
## Range Slider
|
||||
|
||||
```python
|
||||
from superqt import QRangeSlider
|
||||
|
||||
# as usual:
|
||||
# you must create a QApplication before create a widget.
|
||||
range_slider = QRangeSlider()
|
||||
```
|
||||
|
||||
As `QRangeSlider` inherits from `QtWidgets.QSlider`, you can use all of the
|
||||
same methods available in the [QSlider API](https://doc.qt.io/qt-5/qslider.html). The major difference is that `value` and `sliderPosition` are reimplemented as `tuples` of `int` (where the length of the tuple is equal to the number of handles in the slider.)
|
||||
|
||||
### `value: Tuple[int, ...]`
|
||||
|
||||
This property holds the current value of all handles in the slider.
|
||||
|
||||
The slider forces all values to be within the legal range:
|
||||
`minimum <= value <= maximum`.
|
||||
|
||||
Changing the value also changes the sliderPosition.
|
||||
|
||||
##### Access Functions:
|
||||
|
||||
```python
|
||||
range_slider.value() -> Tuple[int, ...]
|
||||
```
|
||||
|
||||
```python
|
||||
range_slider.setValue(val: Sequence[int]) -> None
|
||||
```
|
||||
|
||||
##### Notifier Signal:
|
||||
|
||||
```python
|
||||
valueChanged(Tuple[int, ...])
|
||||
```
|
||||
|
||||
### `sliderPosition: Tuple[int, ...]`
|
||||
|
||||
This property holds the current slider positions. It is a `tuple` with length equal to the number of handles.
|
||||
|
||||
If [tracking](https://doc.qt.io/qt-5/qabstractslider.html#tracking-prop) is enabled (the default), this is identical to [`value`](#value--tupleint-).
|
||||
|
||||
##### Access Functions:
|
||||
|
||||
```python
|
||||
range_slider.sliderPosition() -> Tuple[int, ...]
|
||||
```
|
||||
|
||||
```python
|
||||
range_slider.setSliderPosition(val: Sequence[int]) -> None
|
||||
```
|
||||
|
||||
##### Notifier Signal:
|
||||
|
||||
```python
|
||||
sliderMoved(Tuple[int, ...])
|
||||
```
|
||||
|
||||
### Additional properties
|
||||
|
||||
These options are in addition to the Qt QSlider API, and control the behavior of the bar between handles.
|
||||
|
||||
| getter | setter | type | default | description |
|
||||
| -------------------- | ------------------------------------------- | ------ | ------- | ------------------------------------------------------------------------------------------------ |
|
||||
| `barIsVisible` | `setBarIsVisible` <br>`hideBar` / `showBar` | `bool` | `True` | <small>Whether the bar between handles is visible.</small> |
|
||||
| `barMovesAllHandles` | `setBarMovesAllHandles` | `bool` | `True` | <small>Whether clicking on the bar moves all handles or just the nearest</small> |
|
||||
| `barIsRigid` | `setBarIsRigid` | `bool` | `True` | <small>Whether bar length is constant or "elastic" when dragging the bar beyond min/max.</small> |
|
||||
------
|
||||
|
||||
### Examples
|
||||
|
||||
These screenshots show `QRangeSlider` (multiple handles) next to the native `QSlider`
|
||||
(single handle). With no styles applied, `QRangeSlider` will match the native OS
|
||||
style of `QSlider` – with or without tick marks. When styles have been applied
|
||||
using [Qt Style Sheets](https://doc.qt.io/qt-5/stylesheet-reference.html), then
|
||||
`QRangeSlider` will inherit any styles applied to `QSlider` (since it inherits
|
||||
from QSlider). If you'd like to style `QRangeSlider` differently than `QSlider`,
|
||||
then you can also target it directly in your style sheet. The one "special"
|
||||
property for QRangeSlider is `qproperty-barColor`, which sets the color of the
|
||||
bar between the handles.
|
||||
|
||||
> The code for these example widgets is [here](../examples/demo_widget.py)
|
||||
<details>
|
||||
|
||||
<summary><em>See style sheet used for this example</em></summary>
|
||||
|
||||
```css
|
||||
/*
|
||||
Because QRangeSlider inherits from QSlider, it will also inherit styles
|
||||
*/
|
||||
QSlider {
|
||||
min-height: 20px;
|
||||
}
|
||||
|
||||
QSlider::groove:horizontal {
|
||||
border: 0px;
|
||||
background: qlineargradient(x1:0, y1:0, x2:1, y2:1,
|
||||
stop:0 #777, stop:1 #aaa);
|
||||
height: 20px;
|
||||
border-radius: 10px;
|
||||
}
|
||||
|
||||
QSlider::handle {
|
||||
background: qradialgradient(cx:0, cy:0, radius: 1.2, fx:0.5,
|
||||
fy:0.5, stop:0 #eef, stop:1 #000);
|
||||
height: 20px;
|
||||
width: 20px;
|
||||
border-radius: 10px;
|
||||
}
|
||||
|
||||
/*
|
||||
"QSlider::sub-page" is the one exception ...
|
||||
(it styles the area to the left of the QSlider handle)
|
||||
*/
|
||||
QSlider::sub-page:horizontal {
|
||||
background: #447;
|
||||
border-top-left-radius: 10px;
|
||||
border-bottom-left-radius: 10px;
|
||||
}
|
||||
|
||||
/*
|
||||
for QRangeSlider: use "qproperty-barColor". "sub-page" will not work.
|
||||
*/
|
||||
QRangeSlider {
|
||||
qproperty-barColor: #447;
|
||||
}
|
||||
```
|
||||
|
||||
</details>
|
||||
|
||||
#### macOS
|
||||
|
||||
##### Catalina
|
||||
|
||||

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

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

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

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

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

|
||||
|
||||
```python
|
||||
from superqt import QLabeledSlider
|
||||
```
|
||||
|
||||
(no additional options at this point)
|
||||
|
||||
## Issues
|
||||
|
||||
If you encounter any problems, please [file an issue] along with a detailed
|
||||
description.
|
||||
|
||||
[file an issue]: https://github.com/napari/superqt/issues
|
||||
|
||||
## Float Slider
|
||||
|
||||
just like QSlider, but supports float values
|
||||
|
||||
```python
|
||||
from superqt import QDoubleSlider
|
||||
```
|
52
docs/utilities/code_syntax_highlight.md
Normal file
52
docs/utilities/code_syntax_highlight.md
Normal 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
101
docs/utilities/fonticon.md
Normal 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
31
docs/utilities/index.md
Normal 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. |
|
8
docs/utilities/qmessagehandler.md
Normal file
8
docs/utilities/qmessagehandler.md
Normal file
@@ -0,0 +1,8 @@
|
||||
# QMessageHandler
|
||||
|
||||
::: superqt.utils.QMessageHandler
|
||||
options:
|
||||
heading_level: 3
|
||||
show_signature_annotations: True
|
||||
docstring_style: numpy
|
||||
show_bases: False
|
@@ -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
|
36
docs/utilities/threading.md
Normal file
36
docs/utilities/threading.md
Normal 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
|
46
docs/utilities/throttling.md
Normal file
46
docs/utilities/throttling.md
Normal 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
|
31
docs/widgets/index.md
Normal file
31
docs/widgets/index.md
Normal file
@@ -0,0 +1,31 @@
|
||||
# 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 |
|
||||
|
||||
## 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. |
|
24
docs/widgets/qcollapsible.md
Normal file
24
docs/widgets/qcollapsible.md
Normal 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') }}
|
23
docs/widgets/qdoublerangeslider.md
Normal file
23
docs/widgets/qdoublerangeslider.md
Normal 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') }}
|
23
docs/widgets/qdoubleslider.md
Normal file
23
docs/widgets/qdoubleslider.md
Normal 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') }}
|
26
docs/widgets/qelidinglabel.md
Normal file
26
docs/widgets/qelidinglabel.md
Normal 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') }}
|
72
docs/widgets/qenumcombobox.md
Normal file
72
docs/widgets/qenumcombobox.md
Normal 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') }}
|
23
docs/widgets/qlabeleddoublerangeslider.md
Normal file
23
docs/widgets/qlabeleddoublerangeslider.md
Normal 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') }}
|
24
docs/widgets/qlabeleddoubleslider.md
Normal file
24
docs/widgets/qlabeleddoubleslider.md
Normal 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') }}
|
29
docs/widgets/qlabeledrangeslider.md
Normal file
29
docs/widgets/qlabeledrangeslider.md
Normal 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
|
22
docs/widgets/qlabeledslider.md
Normal file
22
docs/widgets/qlabeledslider.md
Normal 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') }}
|
23
docs/widgets/qlargeintspinbox.md
Normal file
23
docs/widgets/qlargeintspinbox.md
Normal 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') }}
|
229
docs/widgets/qrangeslider.md
Normal file
229
docs/widgets/qrangeslider.md
Normal 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
|
||||
|
||||
{ width=580; }
|
||||
|
||||
##### Big Sur
|
||||
|
||||
{ width=580; }
|
||||
|
||||
#### Windows
|
||||
|
||||

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

|
||||
|
||||
|
||||
{{ 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, ...])
|
||||
```
|
25
docs/widgets/qsearchablecombobox.md
Normal file
25
docs/widgets/qsearchablecombobox.md
Normal 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') }}
|
28
docs/widgets/qsearchablelistwidget.md
Normal file
28
docs/widgets/qsearchablelistwidget.md
Normal 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') }}
|
32
examples/code_highlight.py
Normal file
32
examples/code_highlight.py
Normal file
@@ -0,0 +1,32 @@
|
||||
from PyQt5.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()
|
||||
|
||||
app.exec_()
|
@@ -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 {
|
||||
|
@@ -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_()
|
11
examples/searchable_combo_box.py
Normal file
11
examples/searchable_combo_box.py
Normal file
@@ -0,0 +1,11 @@
|
||||
from qtpy.QtWidgets import QApplication
|
||||
|
||||
from superqt import QSearchableComboBox
|
||||
|
||||
app = QApplication([])
|
||||
|
||||
slider = QSearchableComboBox()
|
||||
slider.addItems(["foo", "bar", "baz", "foobar", "foobaz", "barbaz"])
|
||||
slider.show()
|
||||
|
||||
app.exec_()
|
11
examples/searchable_list_widget.py
Normal file
11
examples/searchable_list_widget.py
Normal file
@@ -0,0 +1,11 @@
|
||||
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_()
|
55
mkdocs.yml
Normal file
55
mkdocs.yml
Normal 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 © 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
|
@@ -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"]
|
||||
|
@@ -35,8 +35,10 @@ project_urls =
|
||||
[options]
|
||||
packages = find:
|
||||
install_requires =
|
||||
packaging
|
||||
pygments>=2.4.0
|
||||
qtpy>=1.1.0
|
||||
typing-extensions>=3.10.0.0
|
||||
typing-extensions
|
||||
python_requires = >=3.7
|
||||
include_package_data = True
|
||||
package_dir =
|
||||
@@ -61,6 +63,10 @@ dev =
|
||||
pytest-qt
|
||||
tox
|
||||
tox-conda
|
||||
docs =
|
||||
mkdocs-macros-plugin
|
||||
mkdocs-material
|
||||
mkdocstrings[python]
|
||||
font_fa5 =
|
||||
fonticon-fontawesome5
|
||||
font_mi5 =
|
||||
|
@@ -7,7 +7,8 @@ except ImportError:
|
||||
|
||||
from ._eliding_label import QElidingLabel
|
||||
from .collapsible import QCollapsible
|
||||
from .combobox import QEnumComboBox
|
||||
from .combobox import QEnumComboBox, QSearchableComboBox
|
||||
from .selection import QSearchableListWidget
|
||||
from .sliders import (
|
||||
QDoubleRangeSlider,
|
||||
QDoubleSlider,
|
||||
@@ -26,13 +27,15 @@ __all__ = [
|
||||
"QDoubleRangeSlider",
|
||||
"QDoubleSlider",
|
||||
"QElidingLabel",
|
||||
"QEnumComboBox",
|
||||
"QLabeledDoubleRangeSlider",
|
||||
"QLabeledDoubleSlider",
|
||||
"QLabeledRangeSlider",
|
||||
"QLabeledSlider",
|
||||
"QLargeIntSpinBox",
|
||||
"QMessageHandler",
|
||||
"QSearchableComboBox",
|
||||
"QSearchableListWidget",
|
||||
"QRangeSlider",
|
||||
"QEnumComboBox",
|
||||
"QCollapsible",
|
||||
]
|
||||
|
@@ -1,20 +1,14 @@
|
||||
"""A collapsible widget to hide and unhide child widgets"""
|
||||
from typing import Optional
|
||||
|
||||
from qtpy.QtCore import (
|
||||
QAbstractAnimation,
|
||||
QEasingCurve,
|
||||
QMargins,
|
||||
QPropertyAnimation,
|
||||
Qt,
|
||||
)
|
||||
from qtpy.QtCore import QEasingCurve, QEvent, QMargins, QObject, QPropertyAnimation, Qt
|
||||
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 = "▼ "
|
||||
@@ -23,10 +17,11 @@ class QCollapsible(QFrame):
|
||||
def __init__(self, title: str = "", parent: Optional[QWidget] = None):
|
||||
super().__init__(parent)
|
||||
self._locked = False
|
||||
self._is_animating = False
|
||||
|
||||
self._toggle_btn = QPushButton(self._COLLAPSED + title)
|
||||
self._toggle_btn.setCheckable(True)
|
||||
self._toggle_btn.setStyleSheet("text-align: left; background: transparent;")
|
||||
self._toggle_btn.setStyleSheet("text-align: left; border: none; outline: none;")
|
||||
self._toggle_btn.toggled.connect(self._toggle)
|
||||
|
||||
# frame layout
|
||||
@@ -38,6 +33,7 @@ class QCollapsible(QFrame):
|
||||
self._animation = QPropertyAnimation(self)
|
||||
self._animation.setPropertyName(b"maximumHeight")
|
||||
self._animation.setStartValue(0)
|
||||
self._animation.finished.connect(self._on_animation_done)
|
||||
self.setDuration(300)
|
||||
self.setEasingCurve(QEasingCurve.Type.InOutCubic)
|
||||
|
||||
@@ -77,19 +73,21 @@ class QCollapsible(QFrame):
|
||||
|
||||
def addWidget(self, widget: QWidget):
|
||||
"""Add a widget to the central content widget's layout."""
|
||||
widget.installEventFilter(self)
|
||||
self._content.layout().addWidget(widget)
|
||||
|
||||
def removeWidget(self, widget: QWidget):
|
||||
"""Remove widget from the central content widget's layout."""
|
||||
self._content.layout().removeWidget(widget)
|
||||
widget.removeEventFilter(self)
|
||||
|
||||
def expand(self, animate: bool = True):
|
||||
"""Expand (show) the collapsible section"""
|
||||
self._expand_collapse(QAbstractAnimation.Direction.Forward, animate)
|
||||
self._expand_collapse(QPropertyAnimation.Direction.Forward, animate)
|
||||
|
||||
def collapse(self, animate: bool = True):
|
||||
"""Collapse (hide) the collapsible section"""
|
||||
self._expand_collapse(QAbstractAnimation.Direction.Backward, animate)
|
||||
self._expand_collapse(QPropertyAnimation.Direction.Backward, animate)
|
||||
|
||||
def isExpanded(self) -> bool:
|
||||
"""Return whether the collapsible section is visible"""
|
||||
@@ -105,12 +103,12 @@ class QCollapsible(QFrame):
|
||||
return self._locked
|
||||
|
||||
def _expand_collapse(
|
||||
self, direction: QAbstractAnimation.Direction, animate: bool = True
|
||||
self, direction: QPropertyAnimation.Direction, animate: bool = True
|
||||
):
|
||||
if self._locked:
|
||||
return
|
||||
|
||||
forward = direction == QAbstractAnimation.Direction.Forward
|
||||
forward = direction == QPropertyAnimation.Direction.Forward
|
||||
text = self._EXPANDED if forward else self._COLLAPSED
|
||||
|
||||
self._toggle_btn.setChecked(forward)
|
||||
@@ -120,9 +118,23 @@ class QCollapsible(QFrame):
|
||||
if animate:
|
||||
self._animation.setDirection(direction)
|
||||
self._animation.setEndValue(_content_height)
|
||||
self._is_animating = True
|
||||
self._animation.start()
|
||||
else:
|
||||
self._content.setMaximumHeight(_content_height if forward else 0)
|
||||
|
||||
def _toggle(self):
|
||||
self.expand() if self.isExpanded() else self.collapse()
|
||||
|
||||
def eventFilter(self, a0: QObject, a1: QEvent) -> bool:
|
||||
"""If a child widget resizes, we need to update our expanded height."""
|
||||
if (
|
||||
a1.type() == QEvent.Type.Resize
|
||||
and self.isExpanded()
|
||||
and not self._is_animating
|
||||
):
|
||||
self._expand_collapse(QPropertyAnimation.Direction.Forward, animate=False)
|
||||
return False
|
||||
|
||||
def _on_animation_done(self):
|
||||
self._is_animating = False
|
||||
|
@@ -1,3 +1,4 @@
|
||||
from ._enum_combobox import QEnumComboBox
|
||||
from ._searchable_combo_box import QSearchableComboBox
|
||||
|
||||
__all__ = ("QEnumComboBox",)
|
||||
__all__ = ("QEnumComboBox", "QSearchableComboBox")
|
||||
|
@@ -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:
|
||||
|
48
src/superqt/combobox/_searchable_combo_box.py
Normal file
48
src/superqt/combobox/_searchable_combo_box.py
Normal file
@@ -0,0 +1,48 @@
|
||||
from qtpy import QT_VERSION
|
||||
from qtpy.QtCore import Qt, Signal
|
||||
from qtpy.QtWidgets import QComboBox, QCompleter
|
||||
|
||||
try:
|
||||
is_qt_bellow_5_14 = tuple(int(x) for x in QT_VERSION.split(".")[:2]) < (5, 14)
|
||||
except ValueError:
|
||||
is_qt_bellow_5_14 = False
|
||||
|
||||
|
||||
class QSearchableComboBox(QComboBox):
|
||||
"""
|
||||
ComboCox with completer for fast search in multiple options
|
||||
"""
|
||||
|
||||
if is_qt_bellow_5_14:
|
||||
textActivated = Signal(str) # pragma: no cover
|
||||
|
||||
def __init__(self, parent=None):
|
||||
super().__init__(parent)
|
||||
self.setEditable(True)
|
||||
self.completer_object = QCompleter()
|
||||
self.completer_object.setCaseSensitivity(Qt.CaseInsensitive)
|
||||
self.completer_object.setCompletionMode(QCompleter.PopupCompletion)
|
||||
self.completer_object.setFilterMode(Qt.MatchContains)
|
||||
self.setCompleter(self.completer_object)
|
||||
self.setInsertPolicy(QComboBox.NoInsert)
|
||||
if is_qt_bellow_5_14: # pragma: no cover
|
||||
self.currentIndexChanged.connect(self._text_activated)
|
||||
|
||||
def _text_activated(self): # pragma: no cover
|
||||
self.textActivated.emit(self.currentText())
|
||||
|
||||
def addItem(self, *args):
|
||||
super().addItem(*args)
|
||||
self.completer_object.setModel(self.model())
|
||||
|
||||
def addItems(self, *args):
|
||||
super().addItems(*args)
|
||||
self.completer_object.setModel(self.model())
|
||||
|
||||
def insertItem(self, *args) -> None:
|
||||
super().insertItem(*args)
|
||||
self.completer_object.setModel(self.model())
|
||||
|
||||
def insertItems(self, *args) -> None:
|
||||
super().insertItems(*args)
|
||||
self.completer_object.setModel(self.model())
|
@@ -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
|
||||
----------
|
||||
|
@@ -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)
|
||||
|
@@ -17,7 +17,12 @@ class FontIconManager:
|
||||
|
||||
def _discover_fonts(self) -> None:
|
||||
self._PLUGINS.clear()
|
||||
for ep in entry_points().get(self.ENTRY_POINT, {}):
|
||||
entries = entry_points()
|
||||
if hasattr(entries, "select"): # python>3.10
|
||||
_entries = entries.select(group=self.ENTRY_POINT) # type: ignore
|
||||
else:
|
||||
_entries = entries.get(self.ENTRY_POINT, [])
|
||||
for ep in _entries:
|
||||
if ep not in self._BLOCKED:
|
||||
self._PLUGINS[ep.name] = ep
|
||||
|
||||
|
@@ -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
|
||||
@@ -243,7 +260,9 @@ class _QFontIconEngine(QIconEngine):
|
||||
def pixmap(self, size: QSize, mode: QIcon.Mode, state: QIcon.State) -> QPixmap:
|
||||
# first look in cache
|
||||
pmckey = self._pmcKey(size, mode, state)
|
||||
pm = QPixmapCache.find(pmckey) if pmckey else None
|
||||
with warnings.catch_warnings():
|
||||
warnings.filterwarnings("ignore", "QPixmapCache.find")
|
||||
pm = QPixmapCache.find(pmckey) if pmckey else None
|
||||
if pm:
|
||||
return pm
|
||||
pixmap = QPixmap(size)
|
||||
@@ -416,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
|
||||
----------
|
||||
@@ -507,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)
|
||||
|
3
src/superqt/selection/__init__.py
Normal file
3
src/superqt/selection/__init__.py
Normal file
@@ -0,0 +1,3 @@
|
||||
from ._searchable_list_widget import QSearchableListWidget
|
||||
|
||||
__all__ = ("QSearchableListWidget",)
|
46
src/superqt/selection/_searchable_list_widget.py
Normal file
46
src/superqt/selection/_searchable_list_widget.py
Normal file
@@ -0,0 +1,46 @@
|
||||
from qtpy.QtCore import Qt
|
||||
from qtpy.QtWidgets import QLineEdit, QListWidget, QVBoxLayout, QWidget
|
||||
|
||||
|
||||
class QSearchableListWidget(QWidget):
|
||||
def __init__(self, parent=None):
|
||||
super().__init__(parent)
|
||||
|
||||
self.list_widget = QListWidget()
|
||||
|
||||
self.filter_widget = QLineEdit()
|
||||
self.filter_widget.textChanged.connect(self.update_visible)
|
||||
|
||||
layout = QVBoxLayout()
|
||||
layout.addWidget(self.filter_widget)
|
||||
layout.addWidget(self.list_widget)
|
||||
self.setLayout(layout)
|
||||
|
||||
def __getattr__(self, item):
|
||||
if hasattr(self.list_widget, item):
|
||||
return getattr(self.list_widget, item)
|
||||
return super().__getattr__(item)
|
||||
|
||||
def update_visible(self, text):
|
||||
items_text = [
|
||||
x.text() for x in self.list_widget.findItems(text, Qt.MatchContains)
|
||||
]
|
||||
for index in range(self.list_widget.count()):
|
||||
item = self.item(index)
|
||||
item.setHidden(item.text() not in items_text)
|
||||
|
||||
def addItems(self, *args):
|
||||
self.list_widget.addItems(*args)
|
||||
self.update_visible(self.filter_widget.text())
|
||||
|
||||
def addItem(self, *args):
|
||||
self.list_widget.addItem(*args)
|
||||
self.update_visible(self.filter_widget.text())
|
||||
|
||||
def insertItems(self, *args):
|
||||
self.list_widget.insertItems(*args)
|
||||
self.update_visible(self.filter_widget.text())
|
||||
|
||||
def insertItem(self, *args):
|
||||
self.list_widget.insertItem(*args)
|
||||
self.update_visible(self.filter_widget.text())
|
@@ -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",
|
||||
]
|
||||
|
@@ -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:
|
||||
@@ -146,11 +169,17 @@ class _GenericRangeSlider(_GenericSlider[Tuple], Generic[_T]):
|
||||
def mouseMoveEvent(self, ev: QtGui.QMouseEvent) -> None:
|
||||
if self._pressedControl == SC_BAR:
|
||||
ev.accept()
|
||||
delta = self._clickOffset - self._pixelPosToRangeValue(self._pick(ev.pos()))
|
||||
delta = self._clickOffset - self._pixelPosToRangeValue(
|
||||
self._pick(self._event_position(ev))
|
||||
)
|
||||
self._offsetAllPositions(-delta, self._sldPosAtPress)
|
||||
else:
|
||||
super().mouseMoveEvent(ev)
|
||||
|
||||
def _event_position(self, event):
|
||||
# API changes between PyQt5 (.pos()) and PyQt6 (.position())
|
||||
return event.pos() if hasattr(event, "pos") else event.position()
|
||||
|
||||
# ############### Implementation Details #######################
|
||||
|
||||
def _setPosition(self, val):
|
||||
@@ -182,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:
|
||||
|
@@ -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 #######################
|
||||
|
@@ -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
|
||||
@@ -128,7 +131,7 @@ class QLabeledSlider(_SliderProxy, QAbstractSlider):
|
||||
super().__init__(parent)
|
||||
|
||||
self._slider = self._slider_class()
|
||||
self._label = SliderLabel(self._slider, connect=self._slider.setValue)
|
||||
self._label = SliderLabel(self._slider, connect=self._setValue)
|
||||
self._edge_label_mode: EdgeLabelMode = EdgeLabelMode.LabelIsValue
|
||||
|
||||
self._rename_signals()
|
||||
@@ -137,11 +140,19 @@ class QLabeledSlider(_SliderProxy, QAbstractSlider):
|
||||
self._slider.sliderMoved.connect(self.sliderMoved.emit)
|
||||
self._slider.sliderPressed.connect(self.sliderPressed.emit)
|
||||
self._slider.sliderReleased.connect(self.sliderReleased.emit)
|
||||
self._slider.valueChanged.connect(self.valueChanged.emit)
|
||||
self._slider.valueChanged.connect(self._label.setValue)
|
||||
self._slider.valueChanged.connect(self.valueChanged.emit)
|
||||
self._label.editingFinished.connect(self.editingFinished)
|
||||
|
||||
self.setOrientation(orientation)
|
||||
|
||||
def _setValue(self, value: float):
|
||||
"""
|
||||
Convert the value from float to int before
|
||||
setting the slider value
|
||||
"""
|
||||
self._slider.setValue(int(value))
|
||||
|
||||
def _rename_signals(self):
|
||||
# for subclasses
|
||||
pass
|
||||
@@ -174,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 "
|
||||
@@ -223,6 +236,8 @@ class QLabeledDoubleSlider(QLabeledSlider):
|
||||
|
||||
class QLabeledRangeSlider(_SliderProxy, QAbstractSlider):
|
||||
_valueChanged = Signal(tuple)
|
||||
editingFinished = Signal()
|
||||
|
||||
LabelPosition = LabelPosition
|
||||
EdgeLabelMode = EdgeLabelMode
|
||||
_slider_class = QRangeSlider
|
||||
@@ -255,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)
|
||||
@@ -268,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:
|
||||
@@ -280,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()
|
||||
@@ -304,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
|
||||
@@ -336,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):
|
||||
@@ -369,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:
|
||||
@@ -484,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()
|
||||
|
@@ -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()
|
||||
|
@@ -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
|
||||
|
@@ -1,4 +1,5 @@
|
||||
__all__ = (
|
||||
"CodeSyntaxHighlight",
|
||||
"create_worker",
|
||||
"ensure_main_thread",
|
||||
"ensure_object_thread",
|
||||
@@ -15,7 +16,7 @@ __all__ = (
|
||||
"WorkerBase",
|
||||
)
|
||||
|
||||
|
||||
from ._code_syntax_highlight import CodeSyntaxHighlight
|
||||
from ._ensure_thread import ensure_main_thread, ensure_object_thread
|
||||
from ._message_handler import QMessageHandler
|
||||
from ._misc import signals_blocked
|
||||
|
93
src/superqt/utils/_code_syntax_highlight.py
Normal file
93
src/superqt/utils/_code_syntax_highlight.py
Normal file
@@ -0,0 +1,93 @@
|
||||
from itertools import takewhile
|
||||
|
||||
from pygments import highlight
|
||||
from pygments.formatter import Formatter
|
||||
from pygments.lexers import find_lexer_class, get_lexer_by_name
|
||||
from pygments.util import ClassNotFound
|
||||
from qtpy import QtGui
|
||||
|
||||
# inspired by https://github.com/Vector35/snippets/blob/master/QCodeEditor.py (MIT license) and
|
||||
# https://pygments.org/docs/formatterdevelopment/#html-3-2-formatter
|
||||
|
||||
|
||||
def get_text_char_format(style):
|
||||
"""
|
||||
Return a QTextCharFormat with the given attributes.
|
||||
|
||||
https://pygments.org/docs/formatterdevelopment/#html-3-2-formatter
|
||||
"""
|
||||
|
||||
text_char_format = QtGui.QTextCharFormat()
|
||||
text_char_format.setFontFamily("monospace")
|
||||
if style.get("color"):
|
||||
text_char_format.setForeground(QtGui.QColor(f"#{style['color']}"))
|
||||
|
||||
if style.get("bgcolor"):
|
||||
text_char_format.setBackground(QtGui.QColor(style["bgcolor"]))
|
||||
|
||||
if style.get("bold"):
|
||||
text_char_format.setFontWeight(QtGui.QFont.Bold)
|
||||
if style.get("italic"):
|
||||
text_char_format.setFontItalic(True)
|
||||
if style.get("underline"):
|
||||
text_char_format.setFontUnderline(True)
|
||||
|
||||
# TODO find if it is possible to support border style.
|
||||
|
||||
return text_char_format
|
||||
|
||||
|
||||
class QFormatter(Formatter):
|
||||
def __init__(self, **kwargs):
|
||||
super().__init__(**kwargs)
|
||||
self.data = []
|
||||
self._style = {name: get_text_char_format(style) for name, style in self.style}
|
||||
|
||||
def format(self, tokensource, outfile):
|
||||
"""
|
||||
`outfile` is argument from parent class, but
|
||||
in Qt we do not produce string output, but QTextCharFormat, so it needs to be
|
||||
collected using `self.data`.
|
||||
"""
|
||||
self.data = []
|
||||
|
||||
for token, value in tokensource:
|
||||
self.data.extend(
|
||||
[
|
||||
self._style[token],
|
||||
]
|
||||
* len(value)
|
||||
)
|
||||
|
||||
|
||||
class CodeSyntaxHighlight(QtGui.QSyntaxHighlighter):
|
||||
def __init__(self, parent, lang, theme):
|
||||
super().__init__(parent)
|
||||
self.formatter = QFormatter(style=theme)
|
||||
try:
|
||||
self.lexer = get_lexer_by_name(lang)
|
||||
except ClassNotFound:
|
||||
self.lexer = find_lexer_class(lang)()
|
||||
|
||||
@property
|
||||
def background_color(self):
|
||||
return self.formatter.style.background_color
|
||||
|
||||
def highlightBlock(self, text):
|
||||
cb = self.currentBlock()
|
||||
p = cb.position()
|
||||
text_ = self.document().toPlainText() + "\n"
|
||||
highlight(text_, self.lexer, self.formatter)
|
||||
|
||||
enters = sum(1 for _ in takewhile(lambda x: x == "\n", text_))
|
||||
# pygments lexer ignore leading empty lines, so we need to do correction
|
||||
# here calculating the number of empty lines.
|
||||
|
||||
# dirty, dirty hack
|
||||
# The core problem is that pygemnts by default use string streams,
|
||||
# that will not handle QTextCharFormat, so wee need use `data` property to work around this.
|
||||
for i in range(len(text)):
|
||||
try:
|
||||
self.setFormat(i, 1, self.formatter.data[p + i - enters])
|
||||
except IndexError: # pragma: no cover
|
||||
pass
|
@@ -21,10 +21,8 @@ from typing import (
|
||||
)
|
||||
|
||||
from qtpy.QtCore import QObject, QRunnable, QThread, QThreadPool, QTimer, Signal
|
||||
from typing_extensions import Literal, ParamSpec
|
||||
|
||||
if TYPE_CHECKING:
|
||||
|
||||
_T = TypeVar("_T")
|
||||
|
||||
class SigInst(Generic[_T]):
|
||||
@@ -40,11 +38,21 @@ if TYPE_CHECKING:
|
||||
def emit(*args: _T) -> None:
|
||||
...
|
||||
|
||||
from typing_extensions import Literal, ParamSpec
|
||||
|
||||
_P = ParamSpec("_P")
|
||||
# maintain runtime compatibility with older typing_extensions
|
||||
else:
|
||||
try:
|
||||
from typing_extensions import ParamSpec
|
||||
|
||||
_P = ParamSpec("_P")
|
||||
except ImportError:
|
||||
_P = TypeVar("_P")
|
||||
|
||||
_Y = TypeVar("_Y")
|
||||
_S = TypeVar("_S")
|
||||
_R = TypeVar("_R")
|
||||
_P = ParamSpec("_P")
|
||||
|
||||
|
||||
def as_generator_function(
|
||||
@@ -84,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]
|
||||
@@ -105,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
|
||||
@@ -126,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
|
||||
|
||||
@@ -152,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
|
||||
@@ -200,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'
|
||||
@@ -230,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!")
|
||||
@@ -263,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
|
||||
@@ -318,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
|
||||
----------
|
||||
@@ -337,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):
|
||||
@@ -446,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
|
||||
|
||||
@@ -526,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
|
||||
@@ -565,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]
|
||||
|
||||
@@ -608,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):
|
||||
|
||||
@@ -664,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
|
||||
@@ -723,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):
|
||||
@@ -796,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
|
||||
----------
|
||||
@@ -835,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)
|
||||
@@ -855,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):
|
||||
|
@@ -33,10 +33,22 @@ from functools import wraps
|
||||
from typing import TYPE_CHECKING, Callable, Generic, Optional, TypeVar, Union, overload
|
||||
|
||||
from qtpy.QtCore import QObject, Qt, QTimer, Signal
|
||||
from typing_extensions import Literal, ParamSpec
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from qtpy.QtCore import SignalInstance
|
||||
from typing_extensions import Literal, ParamSpec
|
||||
|
||||
P = ParamSpec("P")
|
||||
# maintain runtime compatibility with older typing_extensions
|
||||
else:
|
||||
try:
|
||||
from typing_extensions import ParamSpec
|
||||
|
||||
P = ParamSpec("P")
|
||||
except ImportError:
|
||||
P = TypeVar("P")
|
||||
|
||||
R = TypeVar("R")
|
||||
|
||||
|
||||
class Kind(IntFlag):
|
||||
@@ -91,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:
|
||||
@@ -124,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:
|
||||
@@ -179,8 +191,6 @@ class QSignalDebouncer(GenericSignalThrottler):
|
||||
|
||||
# below here part is unique to superqt (not from KD)
|
||||
|
||||
P = ParamSpec("P")
|
||||
R = TypeVar("R")
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from typing_extensions import Protocol
|
||||
@@ -199,12 +209,12 @@ if TYPE_CHECKING:
|
||||
|
||||
if sys.version_info < (3, 9):
|
||||
|
||||
def __call__(self, *args: P.args, **kwargs: P.kwargs) -> Future:
|
||||
def __call__(self, *args: "P.args", **kwargs: "P.kwargs") -> Future:
|
||||
...
|
||||
|
||||
else:
|
||||
|
||||
def __call__(self, *args: P.args, **kwargs: P.kwargs) -> Future[R]:
|
||||
def __call__(self, *args: "P.args", **kwargs: "P.kwargs") -> Future[R]:
|
||||
...
|
||||
|
||||
|
||||
@@ -220,7 +230,7 @@ def qthrottled(
|
||||
|
||||
@overload
|
||||
def qthrottled(
|
||||
func: Literal[None] = None,
|
||||
func: "Literal[None]" = None,
|
||||
timeout: int = 100,
|
||||
leading: bool = True,
|
||||
timer_type: Qt.TimerType = Qt.TimerType.PreciseTimer,
|
||||
@@ -279,7 +289,7 @@ def qdebounced(
|
||||
|
||||
@overload
|
||||
def qdebounced(
|
||||
func: Literal[None] = None,
|
||||
func: "Literal[None]" = None,
|
||||
timeout: int = 100,
|
||||
leading: bool = False,
|
||||
timer_type: Qt.TimerType = Qt.TimerType.PreciseTimer,
|
||||
@@ -347,7 +357,7 @@ def _make_decorator(
|
||||
future: Optional[Future] = None
|
||||
|
||||
@wraps(func)
|
||||
def inner(*args: P.args, **kwargs: P.kwargs) -> Future:
|
||||
def inner(*args: "P.args", **kwargs: "P.kwargs") -> Future:
|
||||
nonlocal last_f
|
||||
nonlocal future
|
||||
if last_f is not None:
|
||||
|
19
tests/test_code_highlight.py
Normal file
19
tests/test_code_highlight.py
Normal file
@@ -0,0 +1,19 @@
|
||||
from qtpy.QtWidgets import QTextEdit
|
||||
|
||||
from superqt.utils import CodeSyntaxHighlight
|
||||
|
||||
|
||||
def test_code_highlight(qtbot):
|
||||
widget = QTextEdit()
|
||||
qtbot.addWidget(widget)
|
||||
code_highlight = CodeSyntaxHighlight(widget, "python", "default")
|
||||
assert code_highlight.background_color == "#f8f8f8"
|
||||
widget.setText("from argparse import ArgumentParser")
|
||||
|
||||
|
||||
def test_code_highlight_by_name(qtbot):
|
||||
widget = QTextEdit()
|
||||
qtbot.addWidget(widget)
|
||||
code_highlight = CodeSyntaxHighlight(widget, "Python Traceback", "monokai")
|
||||
assert code_highlight.background_color == "#272822"
|
||||
widget.setText("from argparse import ArgumentParser")
|
@@ -0,0 +1,3 @@
|
||||
Metadata-Version: 2.1
|
||||
Name: fake-plugin
|
||||
Version: 5.15.4
|
@@ -0,0 +1,2 @@
|
||||
[superqt.fonticon]
|
||||
ico = fake_plugin:ICO
|
@@ -0,0 +1 @@
|
||||
fake_plugin
|
6
tests/test_fonticon/fixtures/fake_plugin/__init__.py
Normal file
6
tests/test_fonticon/fixtures/fake_plugin/__init__.py
Normal file
@@ -0,0 +1,6 @@
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
class ICO:
|
||||
__font_file__ = str(Path(__file__).parent / "icontest.ttf")
|
||||
smiley = "ico.\ue900"
|
@@ -11,7 +11,7 @@ TEST_PREFIX = "ico"
|
||||
TEST_CHARNAME = "smiley"
|
||||
TEST_CHAR = "\ue900"
|
||||
TEST_GLYPHKEY = f"{TEST_PREFIX}.{TEST_CHARNAME}"
|
||||
FONT_FILE = Path(__file__).parent / "icontest.ttf"
|
||||
FONT_FILE = Path(__file__).parent / "fixtures" / "fake_plugin" / "icontest.ttf"
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
|
@@ -7,41 +7,15 @@ from qtpy.QtGui import QIcon, QPixmap
|
||||
from superqt.fonticon import _plugins, icon
|
||||
from superqt.fonticon._qfont_icon import QFontIconStore
|
||||
|
||||
try:
|
||||
from importlib.metadata import Distribution
|
||||
except ImportError:
|
||||
from importlib_metadata import Distribution # type: ignore
|
||||
|
||||
|
||||
class ICO:
|
||||
__font_file__ = str(Path(__file__).parent / "icontest.ttf")
|
||||
smiley = "ico.\ue900"
|
||||
FIXTURES = Path(__file__).parent / "fixtures"
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def plugin_store(qapp, monkeypatch):
|
||||
class MockEntryPoint:
|
||||
name = "ico"
|
||||
group = _plugins.FontIconManager.ENTRY_POINT
|
||||
value = "fake_plugin.ICO"
|
||||
|
||||
def load(self):
|
||||
return ICO
|
||||
|
||||
class MockFinder:
|
||||
def find_distributions(self, *a):
|
||||
class D(Distribution):
|
||||
name = "mock"
|
||||
|
||||
@property
|
||||
def entry_points(self):
|
||||
return [MockEntryPoint()]
|
||||
|
||||
return [D()]
|
||||
|
||||
_path = [str(FIXTURES)] + sys.path.copy()
|
||||
store = QFontIconStore().instance()
|
||||
with monkeypatch.context() as m:
|
||||
m.setattr(sys, "meta_path", [MockFinder()])
|
||||
m.setattr(sys, "path", _path)
|
||||
yield store
|
||||
store.clear()
|
||||
|
||||
|
35
tests/test_searchable_combobox.py
Normal file
35
tests/test_searchable_combobox.py
Normal file
@@ -0,0 +1,35 @@
|
||||
from superqt import QSearchableComboBox
|
||||
|
||||
|
||||
class TestSearchableComboBox:
|
||||
def test_constructor(self, qtbot):
|
||||
widget = QSearchableComboBox()
|
||||
qtbot.addWidget(widget)
|
||||
|
||||
def test_add_items(self, qtbot):
|
||||
widget = QSearchableComboBox()
|
||||
qtbot.addWidget(widget)
|
||||
widget.addItems(["foo", "bar"])
|
||||
assert widget.completer_object.model().rowCount() == 2
|
||||
widget.addItem("foobar")
|
||||
assert widget.completer_object.model().rowCount() == 3
|
||||
widget.insertItem(1, "baz")
|
||||
assert widget.completer_object.model().rowCount() == 4
|
||||
widget.insertItems(2, ["bazbar", "foobaz"])
|
||||
assert widget.completer_object.model().rowCount() == 6
|
||||
assert widget.itemText(0) == "foo"
|
||||
assert widget.itemText(1) == "baz"
|
||||
assert widget.itemText(2) == "bazbar"
|
||||
|
||||
def test_completion(self, qtbot):
|
||||
widget = QSearchableComboBox()
|
||||
qtbot.addWidget(widget)
|
||||
widget.addItems(["foo", "bar", "foobar", "baz", "bazbar", "foobaz"])
|
||||
|
||||
widget.completer_object.setCompletionPrefix("fo")
|
||||
assert widget.completer_object.completionCount() == 3
|
||||
assert widget.completer_object.currentCompletion() == "foo"
|
||||
widget.completer_object.setCurrentRow(1)
|
||||
assert widget.completer_object.currentCompletion() == "foobar"
|
||||
widget.completer_object.setCurrentRow(2)
|
||||
assert widget.completer_object.currentCompletion() == "foobaz"
|
34
tests/test_searchable_list.py
Normal file
34
tests/test_searchable_list.py
Normal file
@@ -0,0 +1,34 @@
|
||||
from superqt import QSearchableListWidget
|
||||
|
||||
|
||||
class TestSearchableListWidget:
|
||||
def test_create(self, qtbot):
|
||||
widget = QSearchableListWidget()
|
||||
qtbot.addWidget(widget)
|
||||
widget.addItem("aaa")
|
||||
assert widget.count() == 1
|
||||
|
||||
def test_add_items(self, qtbot):
|
||||
widget = QSearchableListWidget()
|
||||
qtbot.addWidget(widget)
|
||||
widget.addItems(["foo", "bar"])
|
||||
assert widget.count() == 2
|
||||
widget.insertItems(1, ["baz", "foobaz"])
|
||||
widget.insertItem(2, "foobar")
|
||||
assert widget.count() == 5
|
||||
assert widget.item(0).text() == "foo"
|
||||
assert widget.item(1).text() == "baz"
|
||||
assert widget.item(2).text() == "foobar"
|
||||
|
||||
def test_completion(self, qtbot):
|
||||
widget = QSearchableListWidget()
|
||||
qtbot.addWidget(widget)
|
||||
widget.show()
|
||||
widget.addItems(["foo", "bar", "foobar", "baz", "bazbar", "foobaz"])
|
||||
widget.filter_widget.setText("fo")
|
||||
assert widget.count() == 6
|
||||
for i in range(widget.count()):
|
||||
item = widget.item(i)
|
||||
assert item.isHidden() == ("fo" not in item.text())
|
||||
|
||||
widget.hide()
|
@@ -4,7 +4,7 @@ from platform import system
|
||||
import pytest
|
||||
from qtpy import QT_VERSION
|
||||
from qtpy.QtCore import QEvent, QPoint, QPointF, Qt
|
||||
from qtpy.QtGui import QMouseEvent, QWheelEvent
|
||||
from qtpy.QtGui import QHoverEvent, QMouseEvent, QWheelEvent
|
||||
|
||||
QT_VERSION = tuple(int(x) for x in QT_VERSION.split("."))
|
||||
|
||||
@@ -68,7 +68,18 @@ def _wheel_event(arc):
|
||||
)
|
||||
|
||||
|
||||
def _linspace(start, stop, n):
|
||||
def _hover_event(_type, position, old_position, widget=None):
|
||||
with suppress(TypeError):
|
||||
return QHoverEvent(
|
||||
_type,
|
||||
position,
|
||||
widget.mapToGlobal(position),
|
||||
old_position,
|
||||
)
|
||||
return QHoverEvent(_type, position, old_position)
|
||||
|
||||
|
||||
def _linspace(start: int, stop: int, n: int):
|
||||
h = (stop - start) / (n - 1)
|
||||
for i in range(n):
|
||||
yield start + h * i
|
||||
|
@@ -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())
|
||||
|
@@ -3,12 +3,11 @@ import platform
|
||||
|
||||
import pytest
|
||||
from qtpy.QtCore import QEvent, QPoint, QPointF, Qt
|
||||
from qtpy.QtGui import QHoverEvent
|
||||
from qtpy.QtWidgets import QStyle, QStyleOptionSlider
|
||||
|
||||
from superqt.sliders._generic_slider import _GenericSlider, _sliderValueFromPosition
|
||||
|
||||
from ._testutil import _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])
|
||||
@@ -118,6 +117,7 @@ def test_press_move_release(gslider: _GenericSlider, qtbot):
|
||||
@skip_on_linux_qt6
|
||||
def test_hover(gslider: _GenericSlider):
|
||||
|
||||
# stub
|
||||
opt = QStyleOptionSlider()
|
||||
gslider.initStyleOption(opt)
|
||||
style = gslider.style()
|
||||
@@ -128,11 +128,11 @@ def test_hover(gslider: _GenericSlider):
|
||||
|
||||
assert gslider._hoverControl == QStyle.SubControl.SC_None
|
||||
|
||||
gslider.event(QHoverEvent(QEvent.Type.HoverEnter, handle_pos, QPointF()))
|
||||
gslider.event(_hover_event(QEvent.Type.HoverEnter, handle_pos, QPointF(), gslider))
|
||||
assert gslider._hoverControl == QStyle.SubControl.SC_SliderHandle
|
||||
|
||||
gslider.event(
|
||||
QHoverEvent(QEvent.Type.HoverLeave, QPointF(-1000, -1000), handle_pos)
|
||||
_hover_event(QEvent.Type.HoverLeave, QPointF(-1000, -1000), handle_pos, gslider)
|
||||
)
|
||||
assert gslider._hoverControl == QStyle.SubControl.SC_None
|
||||
|
||||
@@ -163,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",
|
||||
|
@@ -1,4 +1,10 @@
|
||||
from superqt import QLabeledRangeSlider
|
||||
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):
|
||||
@@ -9,3 +15,64 @@ def test_labeled_slider_api(qtbot):
|
||||
slider.setBarVisible()
|
||||
slider.setBarMovesAllHandles()
|
||||
slider.setBarIsRigid()
|
||||
|
||||
|
||||
def test_slider_connect_works(qtbot):
|
||||
slider = QLabeledSlider()
|
||||
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()
|
||||
|
@@ -1,169 +1,257 @@
|
||||
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.QtGui import QHoverEvent
|
||||
from qtpy.QtWidgets import QStyle, QStyleOptionSlider
|
||||
|
||||
from superqt import QDoubleRangeSlider, QRangeSlider
|
||||
from superqt import QDoubleRangeSlider, QLabeledRangeSlider, QRangeSlider
|
||||
|
||||
from ._testutil import _linspace, _mouse_event, _wheel_event, skip_on_linux_qt6
|
||||
from ._testutil import (
|
||||
_hover_event,
|
||||
_linspace,
|
||||
_mouse_event,
|
||||
_wheel_event,
|
||||
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(QHoverEvent(QEvent.Type.HoverEnter, handle_pos, QPointF()))
|
||||
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(
|
||||
QHoverEvent(QEvent.Type.HoverLeave, QPointF(-1000, -1000), handle_pos)
|
||||
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_)
|
||||
|
@@ -4,7 +4,6 @@ from contextlib import suppress
|
||||
|
||||
import pytest
|
||||
from qtpy.QtCore import QEvent, QPoint, QPointF, Qt
|
||||
from qtpy.QtGui import QHoverEvent
|
||||
from qtpy.QtWidgets import QSlider, QStyle, QStyleOptionSlider
|
||||
|
||||
from superqt import QDoubleSlider, QLabeledDoubleSlider, QLabeledSlider
|
||||
@@ -12,6 +11,7 @@ from superqt.sliders._generic_slider import _GenericSlider
|
||||
|
||||
from ._testutil import (
|
||||
QT_VERSION,
|
||||
_hover_event,
|
||||
_linspace,
|
||||
_mouse_event,
|
||||
_wheel_event,
|
||||
@@ -167,12 +167,12 @@ def test_hover(sld: _GenericSlider):
|
||||
with suppress(AttributeError): # for QSlider
|
||||
assert _real_sld._hoverControl == QStyle.SubControl.SC_None
|
||||
|
||||
_real_sld.event(QHoverEvent(QEvent.Type.HoverEnter, handle_pos, QPointF()))
|
||||
_real_sld.event(_hover_event(QEvent.Type.HoverEnter, handle_pos, QPointF(), sld))
|
||||
with suppress(AttributeError): # for QSlider
|
||||
assert _real_sld._hoverControl == QStyle.SubControl.SC_SliderHandle
|
||||
|
||||
_real_sld.event(
|
||||
QHoverEvent(QEvent.Type.HoverLeave, QPointF(-1000, -1000), handle_pos)
|
||||
_hover_event(QEvent.Type.HoverLeave, QPointF(-1000, -1000), handle_pos, sld)
|
||||
)
|
||||
with suppress(AttributeError): # for QSlider
|
||||
assert _real_sld._hoverControl == QStyle.SubControl.SC_None
|
||||
|
Reference in New Issue
Block a user