From 97bb814451e79afd66352a3358c93602ec6d604c Mon Sep 17 00:00:00 2001 From: Talley Lambert Date: Wed, 5 Oct 2022 08:59:27 -0400 Subject: [PATCH] Docs (#124) * wip * wip * more wip * progress * more docs * more changes * add link * more examples and improvements * fix check-manifest * sort members * remove autogen images * remove _images * add font docs * add link to utils --- .gitignore | 1 + README.md | 21 +- docs/_macros.py | 144 ++++++++ docs/combobox.md | 68 ---- docs/faq.md | 26 ++ docs/fonticon.md | 0 docs/index.md | 29 ++ docs/listwidgets.md | 8 - docs/sliders.md | 257 -------------- docs/utilities/code_syntax_highlight.md | 52 +++ docs/utilities/fonticon.md | 101 ++++++ docs/utilities/index.md | 31 ++ docs/utilities/qmessagehandler.md | 8 + .../thread_decorators.md} | 26 +- docs/utilities/threading.md | 36 ++ docs/utilities/throttling.md | 46 +++ docs/utils.md | 10 - docs/widgets/index.md | 31 ++ docs/widgets/qcollapsible.md | 24 ++ docs/widgets/qdoublerangeslider.md | 23 ++ docs/widgets/qdoubleslider.md | 23 ++ docs/widgets/qelidinglabel.md | 26 ++ docs/widgets/qenumcombobox.md | 72 ++++ docs/widgets/qlabeleddoublerangeslider.md | 23 ++ docs/widgets/qlabeleddoubleslider.md | 24 ++ docs/widgets/qlabeledrangeslider.md | 29 ++ docs/widgets/qlabeledslider.md | 22 ++ docs/widgets/qlargeintspinbox.md | 23 ++ docs/widgets/qrangeslider.md | 229 +++++++++++++ docs/widgets/qsearchablecombobox.md | 25 ++ docs/widgets/qsearchablelistwidget.md | 28 ++ examples/{basic_float.py => double_slider.py} | 1 + examples/{collapsible.py => qcollapsible.py} | 0 mkdocs.yml | 55 +++ pyproject.toml | 2 +- setup.cfg | 4 + src/superqt/collapsible/_collapsible.py | 2 +- src/superqt/fonticon/__init__.py | 33 +- src/superqt/fonticon/_animations.py | 6 + src/superqt/fonticon/_qfont_icon.py | 21 +- src/superqt/sliders/_generic_range_slider.py | 11 +- src/superqt/sliders/_generic_slider.py | 4 + src/superqt/sliders/_labeled.py | 6 + src/superqt/utils/_qthreading.py | 316 +++++++++--------- src/superqt/utils/_throttler.py | 6 +- 45 files changed, 1393 insertions(+), 540 deletions(-) create mode 100644 docs/_macros.py delete mode 100644 docs/combobox.md create mode 100644 docs/faq.md delete mode 100644 docs/fonticon.md create mode 100644 docs/index.md delete mode 100644 docs/listwidgets.md delete mode 100644 docs/sliders.md create mode 100644 docs/utilities/code_syntax_highlight.md create mode 100644 docs/utilities/fonticon.md create mode 100644 docs/utilities/index.md create mode 100644 docs/utilities/qmessagehandler.md rename docs/{decorators.md => utilities/thread_decorators.md} (79%) create mode 100644 docs/utilities/threading.md create mode 100644 docs/utilities/throttling.md delete mode 100644 docs/utils.md create mode 100644 docs/widgets/index.md create mode 100644 docs/widgets/qcollapsible.md create mode 100644 docs/widgets/qdoublerangeslider.md create mode 100644 docs/widgets/qdoubleslider.md create mode 100644 docs/widgets/qelidinglabel.md create mode 100644 docs/widgets/qenumcombobox.md create mode 100644 docs/widgets/qlabeleddoublerangeslider.md create mode 100644 docs/widgets/qlabeleddoubleslider.md create mode 100644 docs/widgets/qlabeledrangeslider.md create mode 100644 docs/widgets/qlabeledslider.md create mode 100644 docs/widgets/qlargeintspinbox.md create mode 100644 docs/widgets/qrangeslider.md create mode 100644 docs/widgets/qsearchablecombobox.md create mode 100644 docs/widgets/qsearchablelistwidget.md rename examples/{basic_float.py => double_slider.py} (91%) rename examples/{collapsible.py => qcollapsible.py} (100%) create mode 100644 mkdocs.yml diff --git a/.gitignore b/.gitignore index bc8255af..4ffe4467 100644 --- a/.gitignore +++ b/.gitignore @@ -82,3 +82,4 @@ src/superqt/_version.py screenshots .mypy_cache +docs/_auto_images/ diff --git a/README.md b/README.md index 5d001999..6867d381 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,5 @@ # ![tiny](https://user-images.githubusercontent.com/1609449/120636353-8c3f3800-c43b-11eb-8732-a14dec578897.png) superqt! - [![License](https://img.shields.io/pypi/l/superqt.svg?color=green)](https://github.com/napari/superqt/raw/master/LICENSE) [![PyPI](https://img.shields.io/pypi/v/superqt.svg?color=green)](https://pypi.org/project/superqt) [![Python @@ -20,25 +19,33 @@ Components are tested on: - PyQt5 (5.11 and above) & PyQt6 - PySide2 (5.11 and above) & PySide6 +## Documentation + +Documentation is available at https://napari.org/superqt + ## Widgets -Widgets include: +superqt provides a variety of widgets that are not included in the native QtWidgets module, including multihandle (range) sliders, comboboxes, and more. -- [Float Slider](docs/sliders.md#float-slider) +See the [widgets documentation](https://napari.org/superqt/widgets) for a full list of widgets. - [Range Slider](docs/sliders.md#range-slider) (multi-handle slider) range sliders -- [Labeled Sliders](docs/sliders.md#labeled-sliders) (sliders with linked - spinboxes) - range sliders range sliders -- 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 widgets. ## Contributing diff --git a/docs/_macros.py b/docs/_macros.py new file mode 100644 index 00000000..9d2694c9 --- /dev/null +++ b/docs/_macros.py @@ -0,0 +1,144 @@ +import sys +from enum import EnumMeta +from importlib import import_module +from pathlib import Path +from textwrap import dedent +from typing import TYPE_CHECKING + +from jinja2 import pass_context +from qtpy.QtCore import QObject, Signal + +if TYPE_CHECKING: + from mkdocs_macros.plugin import MacrosPlugin + +EXAMPLES = Path(__file__).parent.parent / "examples" +IMAGES = Path(__file__).parent / "_auto_images" +IMAGES.mkdir(exist_ok=True, parents=True) + + +def define_env(env: "MacrosPlugin"): + @env.macro + @pass_context + def show_widget(context, width: int = 500) -> list[Path]: + # extract all fenced code blocks starting with "python" + page = context["page"] + dest = IMAGES / f"{page.title}.png" + if "build" in sys.argv: + dest.unlink(missing_ok=True) + + codeblocks = [ + b[6:].strip() + for b in page.markdown.split("```") + if b.startswith("python") + ] + src = codeblocks[0].strip() + src = src.replace( + "QApplication([])", "QApplication.instance() or QApplication([])" + ) + src = src.replace("app.exec_()", "") + + exec(src) + _grab(dest, width) + return f"![{page.title}](../{dest.parent.name}/{dest.name}){{ loading=lazy; width={width} }}\n\n" + + @env.macro + def show_members(cls: str): + # import class + module, name = cls.rsplit(".", 1) + _cls = getattr(import_module(module), name) + + first_q = next( + ( + b.__name__ + for b in _cls.__mro__ + if issubclass(b, QObject) and ".Qt" in b.__module__ + ), + None, + ) + + inherited_members = set() + for base in _cls.__mro__: + if issubclass(base, QObject) and ".Qt" in base.__module__: + inherited_members.update( + {k for k in dir(base) if not k.startswith("_")} + ) + + new_signals = { + k + for k, v in vars(_cls).items() + if not k.startswith("_") and isinstance(v, Signal) + } + + self_members = { + k + for k in dir(_cls) + if not k.startswith("_") and k not in inherited_members | new_signals + } + + enums = [] + for m in list(self_members): + if isinstance(getattr(_cls, m), EnumMeta): + self_members.remove(m) + enums.append(m) + + out = "" + if first_q: + url = f"https://doc.qt.io/qt-6/{first_q.lower()}.html" + out += f"## Qt Class\n\n`{first_q}`\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 diff --git a/docs/combobox.md b/docs/combobox.md deleted file mode 100644 index b7b88bf0..00000000 --- a/docs/combobox.md +++ /dev/null @@ -1,68 +0,0 @@ -# ComboBox - - -## Enum Combo Box - -`QEnumComboBox` is a variant of [`QComboBox`](https://doc.qt.io/qt-5/qcombobox.html) -that populates the items in the combobox based on a python `Enum` class. In addition to all -the methods provided by `QComboBox`, this subclass adds the methods -`enumClass`/`setEnumClass` to get/set the current `Enum` class represented by the combobox, -and `currentEnum`/`setCurrentEnum` to get/set the current `Enum` member in the combobox. -There is also a new signal `currentEnumChanged(enum)` analogous to `currentIndexChanged` and `currentTextChanged`. - -Method like `insertItem` and `addItem` are blocked and try of its usage will end with `RuntimeError` - -```python -from enum import Enum - -from superqt import QEnumComboBox - -class SampleEnum(Enum): - first = 1 - second = 2 - third = 3 - -# as usual: -# you must create a QApplication before create a widget. - -combo = QEnumComboBox() -combo.setEnumClass(SampleEnum) -``` - -other option is to use optional `enum_class` argument of constructor and change -```python -combo = QEnumComboBox() -combo.setEnumClass(SampleEnum) -``` -to -```python -combo = QEnumComboBox(enum_class=SampleEnum) -``` - - -### Allow `None` -`QEnumComboBox` allow using Optional type annotation: - -```python -from enum import Enum - -from superqt import QEnumComboBox - -class SampleEnum(Enum): - first = 1 - second = 2 - third = 3 - -# as usual: -# you must create a QApplication before create a widget. - -combo = QEnumComboBox() -combo.setEnumClass(SampleEnum, allow_none=True) -``` - -In this case there is added option `----` and `currentEnum` will return `None` for it. - -## QSearchableComboBox - -`QSearchableComboBox` is a variant of [`QComboBox`](https://doc.qt.io/qt-5/qcombobox.html) that -allow to filter list of options by enter part of text. It could be drop in replacement for `QComboBox`. diff --git a/docs/faq.md b/docs/faq.md new file mode 100644 index 00000000..558e37b6 --- /dev/null +++ b/docs/faq.md @@ -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) + ``` diff --git a/docs/fonticon.md b/docs/fonticon.md deleted file mode 100644 index e69de29b..00000000 diff --git a/docs/index.md b/docs/index.md new file mode 100644 index 00000000..3d9b8608 --- /dev/null +++ b/docs/index.md @@ -0,0 +1,29 @@ +# superqt + +## ![tiny](https://user-images.githubusercontent.com/1609449/120636353-8c3f3800-c43b-11eb-8732-a14dec578897.png) "missing" widgets and components for PyQt/PySide + +This repository aims to provide high-quality community-contributed Qt widgets +and components for [PyQt](https://riverbankcomputing.com/software/pyqt/) & +[PySide](https://www.qt.io/qt-for-python) that are not provided in the native +QtWidgets module. + +Components are tested on: + +- macOS, Windows, & Linux +- Python 3.7 and above +- PyQt5 (5.11 and above) & PyQt6 +- PySide2 (5.11 and above) & PySide6 + +## Installation + +```bash +pip install superqt +``` + +```bash +conda install -c conda-forge superqt +``` + +## Usage + +See the [Widgets](./widgets/) and [Utilities](./utilities/) pages for features offered by superqt. diff --git a/docs/listwidgets.md b/docs/listwidgets.md deleted file mode 100644 index d8696341..00000000 --- a/docs/listwidgets.md +++ /dev/null @@ -1,8 +0,0 @@ -# ListWidget - -## QSearchableListWidget - -`QSearchableListWidget` is a variant of [`QListWidget`](https://doc.qt.io/qt-5/qlistwidget.html) that add text entry above list widget that allow to filter list -of available options. - -Because of implementation it does not inherit directly from `QListWidget` but satisfy it all api. The only limitation is that it cannot be used as argument of `QListWidgetItem` constructor. diff --git a/docs/sliders.md b/docs/sliders.md deleted file mode 100644 index 17cc0fed..00000000 --- a/docs/sliders.md +++ /dev/null @@ -1,257 +0,0 @@ -# Sliders - - -![slider](images/slider.png) - -- `QRangeSlider` inherits from [`QSlider`](https://doc.qt.io/qt-5/qslider.html) - and attempts to match the Qt API as closely as possible -- Uses platform-specific styles (for handle, groove, & ticks) but also supports - QSS style sheets. -- Supports mouse wheel and keypress (soon) events -- Supports more than 2 handles (e.g. `slider.setValue([0, 10, 60, 80])`) - -*Note: There is a Qt5 Bug that affects sliders in MacOS 12+, see fix at bottom of page.* - -## 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`
`hideBar` / `showBar` | `bool` | `True` | Whether the bar between handles is visible. | -| `barMovesAllHandles` | `setBarMovesAllHandles` | `bool` | `True` | Whether clicking on the bar moves all handles or just the nearest | -| `barIsRigid` | `setBarIsRigid` | `bool` | `True` | Whether bar length is constant or "elastic" when dragging the bar beyond min/max. | ------- - -### 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) -
- -See style sheet used for this example - -```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; -} -``` - -
- -#### macOS - -##### Catalina - -![mac10](images/demo_darwin10.png) - -##### Big Sur - -![mac11](images/demo_darwin11.png) - -#### Windows - -![window](images/demo_windows.png) - -#### Linux - -![linux](images/demo_linux.png) - -## Labeled Sliders - -This package also includes two "labeled" slider variants. One for `QRangeSlider`, and one for the native `QSlider`: - -### `QLabeledRangeSlider` - -![labeled_range](images/labeled_range.png) - -```python -from superqt import QLabeledRangeSlider -``` - -This has the same API as `QRangeSlider` with the following additional options: - -#### `handleLabelPosition`/`setHandleLabelPosition` - -Where/whether labels are shown adjacent to slider handles. - -**type:** `QLabeledRangeSlider.LabelPosition` - -**default:** `LabelPosition.LabelsAbove` - -*options:* - -- `LabelPosition.NoLabel` (no labels shown adjacent to handles) -- `LabelPosition.LabelsAbove` -- `LabelPosition.LabelsBelow` -- `LabelPosition.LabelsRight` (alias for `LabelPosition.LabelsAbove`) -- `LabelPosition.LabelsLeft` (alias for `LabelPosition.LabelsBelow`) - -#### `edgeLabelMode`/`setEdgeLabelMode` - -**type:** `QLabeledRangeSlider.EdgeLabelMode` - -**default:** `EdgeLabelMode.LabelIsRange` - -*options:* - -- `EdgeLabelMode.NoLabel`: no labels shown at slider extremes -- `EdgeLabelMode.LabelIsRange`: edge labels shown the min/max values -- `EdgeLabelMode.LabelIsValue`: edge labels shown the slider range - -#### fine tuning position of labels: - -If you find that you need to fine tune the position of the handle labels: - -- `QLabeledRangeSlider.label_shift_x`: adjust horizontal label position -- `QLabeledRangeSlider.label_shift_y`: adjust vertical label position - -### `QLabeledSlider` - -![labeled_range](images/labeled_qslider.png) - -```python -from superqt import QLabeledSlider -``` - -(no additional options at this point) - - -## Float Slider - -just like QSlider, but supports float values - -```python -from superqt import QDoubleSlider -``` - -## Issues - -### MacOS Monterey Slider issue - -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://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) -``` diff --git a/docs/utilities/code_syntax_highlight.md b/docs/utilities/code_syntax_highlight.md new file mode 100644 index 00000000..225a3eba --- /dev/null +++ b/docs/utilities/code_syntax_highlight.md @@ -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') }} diff --git a/docs/utilities/fonticon.md b/docs/utilities/fonticon.md new file mode 100644 index 00000000..aeb0ec19 --- /dev/null +++ b/docs/utilities/fonticon.md @@ -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 + +- +- +- + +`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: + +- +- +- + +## 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 diff --git a/docs/utilities/index.md b/docs/utilities/index.md new file mode 100644 index 00000000..dbe6c356 --- /dev/null +++ b/docs/utilities/index.md @@ -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. | diff --git a/docs/utilities/qmessagehandler.md b/docs/utilities/qmessagehandler.md new file mode 100644 index 00000000..eeb928e8 --- /dev/null +++ b/docs/utilities/qmessagehandler.md @@ -0,0 +1,8 @@ +# QMessageHandler + +::: superqt.utils.QMessageHandler + options: + heading_level: 3 + show_signature_annotations: True + docstring_style: numpy + show_bases: False diff --git a/docs/decorators.md b/docs/utilities/thread_decorators.md similarity index 79% rename from docs/decorators.md rename to docs/utilities/thread_decorators.md index 083d969f..7ad4afd2 100644 --- a/docs/decorators.md +++ b/docs/utilities/thread_decorators.md @@ -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 diff --git a/docs/utilities/threading.md b/docs/utilities/threading.md new file mode 100644 index 00000000..efa100d3 --- /dev/null +++ b/docs/utilities/threading.md @@ -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 diff --git a/docs/utilities/throttling.md b/docs/utilities/throttling.md new file mode 100644 index 00000000..8b2283a1 --- /dev/null +++ b/docs/utilities/throttling.md @@ -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: + +- +- + +::: 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 diff --git a/docs/utils.md b/docs/utils.md deleted file mode 100644 index c43efdc7..00000000 --- a/docs/utils.md +++ /dev/null @@ -1,10 +0,0 @@ -# Utils - -## Code highlighting - -`superqt` provides a code highlighter subclass of `QSyntaxHighlighter` -that can be used to highlight code in a QTextEdit. - -Code lexer and available styles are from [`pygments`](https://pygments.org/) python library -List of available languages are available [here](https://pygments.org/languages/). -List of available styles are available [here](https://pygments.org/styles/). diff --git a/docs/widgets/index.md b/docs/widgets/index.md new file mode 100644 index 00000000..2e5534f6 --- /dev/null +++ b/docs/widgets/index.md @@ -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. | diff --git a/docs/widgets/qcollapsible.md b/docs/widgets/qcollapsible.md new file mode 100644 index 00000000..9e52fc25 --- /dev/null +++ b/docs/widgets/qcollapsible.md @@ -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') }} diff --git a/docs/widgets/qdoublerangeslider.md b/docs/widgets/qdoublerangeslider.md new file mode 100644 index 00000000..128ee67c --- /dev/null +++ b/docs/widgets/qdoublerangeslider.md @@ -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') }} diff --git a/docs/widgets/qdoubleslider.md b/docs/widgets/qdoubleslider.md new file mode 100644 index 00000000..137a3389 --- /dev/null +++ b/docs/widgets/qdoubleslider.md @@ -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') }} diff --git a/docs/widgets/qelidinglabel.md b/docs/widgets/qelidinglabel.md new file mode 100644 index 00000000..0bc42e46 --- /dev/null +++ b/docs/widgets/qelidinglabel.md @@ -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') }} diff --git a/docs/widgets/qenumcombobox.md b/docs/widgets/qenumcombobox.md new file mode 100644 index 00000000..466d9523 --- /dev/null +++ b/docs/widgets/qenumcombobox.md @@ -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') }} diff --git a/docs/widgets/qlabeleddoublerangeslider.md b/docs/widgets/qlabeleddoublerangeslider.md new file mode 100644 index 00000000..0829c527 --- /dev/null +++ b/docs/widgets/qlabeleddoublerangeslider.md @@ -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') }} diff --git a/docs/widgets/qlabeleddoubleslider.md b/docs/widgets/qlabeleddoubleslider.md new file mode 100644 index 00000000..29a17ce8 --- /dev/null +++ b/docs/widgets/qlabeleddoubleslider.md @@ -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') }} diff --git a/docs/widgets/qlabeledrangeslider.md b/docs/widgets/qlabeledrangeslider.md new file mode 100644 index 00000000..10012b2d --- /dev/null +++ b/docs/widgets/qlabeledrangeslider.md @@ -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 diff --git a/docs/widgets/qlabeledslider.md b/docs/widgets/qlabeledslider.md new file mode 100644 index 00000000..66cb2de3 --- /dev/null +++ b/docs/widgets/qlabeledslider.md @@ -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') }} diff --git a/docs/widgets/qlargeintspinbox.md b/docs/widgets/qlargeintspinbox.md new file mode 100644 index 00000000..69f0ee4f --- /dev/null +++ b/docs/widgets/qlargeintspinbox.md @@ -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') }} diff --git a/docs/widgets/qrangeslider.md b/docs/widgets/qrangeslider.md new file mode 100644 index 00000000..237f87b7 --- /dev/null +++ b/docs/widgets/qrangeslider.md @@ -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`
`hideBar` / `showBar` | `bool` | `True` | Whether the bar between handles is visible. | +| `barMovesAllHandles` | `setBarMovesAllHandles` | `bool` | `True` | Whether clicking on the bar moves all handles or just the nearest | +| `barIsRigid` | `setBarIsRigid` | `bool` | `True` | Whether bar length is constant or "elastic" when dragging the bar beyond min/max. | + +### Screenshots + +??? title "code that generates the images below" + + ```python + import os + + from qtpy import QtCore + from qtpy import QtWidgets as QtW + + # patch for Qt 5.15 on macos >= 12 + os.environ["USE_MAC_SLIDER_PATCH"] = "1" + + from superqt import QRangeSlider # noqa + + QSS = """ + QSlider { + min-height: 20px; + } + + QSlider::groove:horizontal { + border: 0px; + background: qlineargradient(x1:0, y1:0, x2:1, y2:1, stop:0 #888, stop:1 #ddd); + height: 20px; + border-radius: 10px; + } + + QSlider::handle { + background: qradialgradient(cx:0, cy:0, radius: 1.2, fx:0.35, + fy:0.3, stop:0 #eef, stop:1 #002); + height: 20px; + width: 20px; + border-radius: 10px; + } + + QSlider::sub-page:horizontal { + background: qlineargradient(x1:0, y1:0, x2:1, y2:1, stop:0 #227, stop:1 #77a); + border-top-left-radius: 10px; + border-bottom-left-radius: 10px; + } + + QRangeSlider { + qproperty-barColor: qlineargradient(x1:0, y1:0, x2:1, y2:1, stop:0 #227, stop:1 #77a); + } + """ + + Horizontal = QtCore.Qt.Orientation.Horizontal + + + class DemoWidget(QtW.QWidget): + def __init__(self) -> None: + super().__init__() + + reg_hslider = QtW.QSlider(Horizontal) + reg_hslider.setValue(50) + range_hslider = QRangeSlider(Horizontal) + range_hslider.setValue((20, 80)) + multi_range_hslider = QRangeSlider(Horizontal) + multi_range_hslider.setValue((11, 33, 66, 88)) + multi_range_hslider.setTickPosition(QtW.QSlider.TickPosition.TicksAbove) + + styled_reg_hslider = QtW.QSlider(Horizontal) + styled_reg_hslider.setValue(50) + styled_reg_hslider.setStyleSheet(QSS) + styled_range_hslider = QRangeSlider(Horizontal) + styled_range_hslider.setValue((20, 80)) + styled_range_hslider.setStyleSheet(QSS) + + reg_vslider = QtW.QSlider(QtCore.Qt.Orientation.Vertical) + reg_vslider.setValue(50) + range_vslider = QRangeSlider(QtCore.Qt.Orientation.Vertical) + range_vslider.setValue((22, 77)) + + tick_vslider = QtW.QSlider(QtCore.Qt.Orientation.Vertical) + tick_vslider.setValue(55) + tick_vslider.setTickPosition(QtW.QSlider.TicksRight) + range_tick_vslider = QRangeSlider(QtCore.Qt.Orientation.Vertical) + range_tick_vslider.setValue((22, 77)) + range_tick_vslider.setTickPosition(QtW.QSlider.TicksLeft) + + szp = QtW.QSizePolicy.Maximum + left = QtW.QWidget() + left.setLayout(QtW.QVBoxLayout()) + left.setContentsMargins(2, 2, 2, 2) + label1 = QtW.QLabel("Regular QSlider Unstyled") + label2 = QtW.QLabel("QRangeSliders Unstyled") + label3 = QtW.QLabel("Styled Sliders (using same stylesheet)") + label1.setSizePolicy(szp, szp) + label2.setSizePolicy(szp, szp) + label3.setSizePolicy(szp, szp) + left.layout().addWidget(label1) + left.layout().addWidget(reg_hslider) + left.layout().addWidget(label2) + left.layout().addWidget(range_hslider) + left.layout().addWidget(multi_range_hslider) + left.layout().addWidget(label3) + left.layout().addWidget(styled_reg_hslider) + left.layout().addWidget(styled_range_hslider) + + right = QtW.QWidget() + right.setLayout(QtW.QHBoxLayout()) + right.setContentsMargins(15, 5, 5, 0) + right.layout().setSpacing(30) + right.layout().addWidget(reg_vslider) + right.layout().addWidget(range_vslider) + right.layout().addWidget(tick_vslider) + right.layout().addWidget(range_tick_vslider) + + self.setLayout(QtW.QHBoxLayout()) + self.layout().addWidget(left) + self.layout().addWidget(right) + self.setGeometry(600, 300, 580, 300) + self.activateWindow() + self.show() + + + if __name__ == "__main__": + + import sys + from pathlib import Path + + dest = Path("screenshots") + dest.mkdir(exist_ok=True) + + app = QtW.QApplication([]) + demo = DemoWidget() + + if "-snap" in sys.argv: + import platform + + QtW.QApplication.processEvents() + demo.grab().save(str(dest / f"demo_{platform.system().lower()}.png")) + else: + app.exec_() + ``` + +#### macOS + +##### Catalina + +![mac10](../images/demo_darwin10.png){ width=580; } + +##### Big Sur + +![mac11](../images/demo_darwin11.png){ width=580; } + +#### Windows + +![window](../images/demo_windows.png) + +#### Linux + +![linux](../images/demo_linux.png) + + +{{ show_members('superqt.sliders._sliders._GenericRangeSlider') }} + +## Type changes + +Note the following changes in types compared to the `QSlider` API: + +```python +value() -> Tuple[int, ...] +``` + +```python +setValue(val: Sequence[int]) -> None +``` + +```python +# Signal +valueChanged(Tuple[int, ...]) +``` + +```python +sliderPosition() -> Tuple[int, ...] +``` + +```python +setSliderPosition(val: Sequence[int]) -> None +``` + +```python +sliderMoved(Tuple[int, ...]) +``` diff --git a/docs/widgets/qsearchablecombobox.md b/docs/widgets/qsearchablecombobox.md new file mode 100644 index 00000000..f5dea868 --- /dev/null +++ b/docs/widgets/qsearchablecombobox.md @@ -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') }} diff --git a/docs/widgets/qsearchablelistwidget.md b/docs/widgets/qsearchablelistwidget.md new file mode 100644 index 00000000..ba905793 --- /dev/null +++ b/docs/widgets/qsearchablelistwidget.md @@ -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') }} diff --git a/examples/basic_float.py b/examples/double_slider.py similarity index 91% rename from examples/basic_float.py rename to examples/double_slider.py index 82cc6a52..eb0351f7 100644 --- a/examples/basic_float.py +++ b/examples/double_slider.py @@ -8,6 +8,7 @@ slider = QDoubleSlider(Qt.Orientation.Horizontal) slider.setRange(0, 1) slider.setValue(0.5) +slider.resize(500, 50) slider.show() app.exec_() diff --git a/examples/collapsible.py b/examples/qcollapsible.py similarity index 100% rename from examples/collapsible.py rename to examples/qcollapsible.py diff --git a/mkdocs.yml b/mkdocs.yml new file mode 100644 index 00000000..f753ede3 --- /dev/null +++ b/mkdocs.yml @@ -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 diff --git a/pyproject.toml b/pyproject.toml index 38ea7626..4654cea6 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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"] diff --git a/setup.cfg b/setup.cfg index b5fa5db7..0f204495 100644 --- a/setup.cfg +++ b/setup.cfg @@ -63,6 +63,10 @@ dev = pytest-qt tox tox-conda +docs = + mkdocs-macros-plugin + mkdocs-material + mkdocstrings[python] font_fa5 = fonticon-fontawesome5 font_mi5 = diff --git a/src/superqt/collapsible/_collapsible.py b/src/superqt/collapsible/_collapsible.py index 37b47b97..f5325dc0 100644 --- a/src/superqt/collapsible/_collapsible.py +++ b/src/superqt/collapsible/_collapsible.py @@ -8,7 +8,7 @@ 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 = "▼ " diff --git a/src/superqt/fonticon/__init__.py b/src/superqt/fonticon/__init__.py index 7b4538ce..6062ab07 100644 --- a/src/superqt/fonticon/__init__.py +++ b/src/superqt/fonticon/__init__.py @@ -2,14 +2,15 @@ __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 ---------- diff --git a/src/superqt/fonticon/_animations.py b/src/superqt/fonticon/_animations.py index c86bab30..e01c4ab8 100644 --- a/src/superqt/fonticon/_animations.py +++ b/src/superqt/fonticon/_animations.py @@ -6,6 +6,8 @@ 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 @@ def animate(self, painter: QPainter): 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 @@ def animate(self, painter: QPainter): 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) diff --git a/src/superqt/fonticon/_qfont_icon.py b/src/superqt/fonticon/_qfont_icon.py index 60f21092..44916602 100644 --- a/src/superqt/fonticon/_qfont_icon.py +++ b/src/superqt/fonticon/_qfont_icon.py @@ -102,6 +102,23 @@ class IconOptionDict(TypedDict, total=False): # IconOptions are. @dataclass class IconOpts: + """Options for rendering an icon. + + Parameters + ---------- + glyph_key : str, optional + The key of the glyph to use, e.g. `'fa5s.smile'`, by default `None` + scale_factor : float, optional + The scale factor to use, by default `None` + color : ValidColor, optional + The color to use, by default `None`. Colors may be specified as a string, + `QColor`, `Qt.GlobalColor`, or a 3 or 4-tuple of integers. + opacity : float, optional + The opacity to use, by default `None` + animation : Animation, optional + The animation to use, by default `None` + """ + glyph_key: Union[str, Unset] = _Unset scale_factor: Union[float, Unset] = _Unset color: Union[ValidColor, Unset] = _Unset @@ -418,7 +435,7 @@ def addFont( If you'd like to later use a fontkey in the form of `key.some-name`, then `charmap` must be provided and provide a mapping for all of the glyph names to their unicode numbers. If a charmap is not provided, glyphs must be directly - accessed with their unicode as something like `key.\uffff`. + accessed with their unicode as something like `key.\\uffff`. Parameters ---------- @@ -509,7 +526,7 @@ def setTextIcon( ) -> 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) diff --git a/src/superqt/sliders/_generic_range_slider.py b/src/superqt/sliders/_generic_range_slider.py index 02a41366..60877474 100644 --- a/src/superqt/sliders/_generic_range_slider.py +++ b/src/superqt/sliders/_generic_range_slider.py @@ -68,14 +68,14 @@ def __init__(self, *args, **kwargs): 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) @@ -96,12 +96,18 @@ def setBarVisible(self, val: bool = True) -> None: 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 @@ -205,6 +211,7 @@ def _setBarColor(self, color): 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: diff --git a/src/superqt/sliders/_generic_slider.py b/src/superqt/sliders/_generic_slider.py index c748b47e..6d5bdcbf 100644 --- a/src/superqt/sliders/_generic_slider.py +++ b/src/superqt/sliders/_generic_slider.py @@ -99,6 +99,10 @@ def __init__(self, *args, **kwargs) -> None: 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 ####################### diff --git a/src/superqt/sliders/_labeled.py b/src/superqt/sliders/_labeled.py index b587e986..1bf50ed3 100644 --- a/src/superqt/sliders/_labeled.py +++ b/src/superqt/sliders/_labeled.py @@ -185,9 +185,11 @@ def setOrientation(self, orientation): 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 " @@ -283,9 +285,11 @@ def _rename_signals(self): 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: @@ -295,9 +299,11 @@ def setHandleLabelPosition(self, opt: LabelPosition) -> LabelPosition: 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() diff --git a/src/superqt/utils/_qthreading.py b/src/superqt/utils/_qthreading.py index 2494b464..eb8eab8a 100644 --- a/src/superqt/utils/_qthreading.py +++ b/src/superqt/utils/_qthreading.py @@ -92,7 +92,7 @@ class WorkerBase(QRunnable, Generic[_R]): signal emitter object. To allow identify which worker thread emitted signal. """ - #: A set of Workers. Add to set using :meth:`WorkerBase.start` + #: A set of Workers. Add to set using `WorkerBase.start` _worker_set: Set[WorkerBase] = set() returned: SigInst[_R] errored: SigInst[Exception] @@ -113,11 +113,11 @@ def __init__( def __getattr__(self, name: str) -> SigInst: """Pass through attr requests to signals to simplify connection API. - The goal is to enable ``worker.yielded.connect`` instead of - ``worker.signals.yielded.connect``. Because multiple inheritance of Qt + The goal is to enable `worker.yielded.connect` instead of + `worker.signals.yielded.connect`. Because multiple inheritance of Qt classes is not well supported in PyQt, we have to use composition here (signals are provided by QObjects, and QRunnable is not a QObject). So - this passthrough allows us to connect to signals on the ``_signals`` + this passthrough allows us to connect to signals on the `_signals` object. """ # the Signal object is actually a class attribute @@ -134,11 +134,10 @@ def __getattr__(self, name: str) -> SigInst: def quit(self) -> None: """Send a request to abort the worker. - .. note:: - + !!! note It is entirely up to subclasses to honor this method by checking - ``self.abort_requested`` periodically in their ``worker.work`` - method, and exiting if ``True``. + `self.abort_requested` periodically in their `worker.work` + method, and exiting if `True`. """ self._abort_requested = True @@ -160,20 +159,20 @@ def run(self) -> None: The order of method calls when starting a worker is: - .. code-block:: none - + ``` calls QThreadPool.globalInstance().start(worker) | triggered by the QThreadPool.start() method | | called by worker.run | | | V V V worker.start -> worker.run -> worker.work + ``` **This** is the function that actually gets called when calling - :func:`QThreadPool.start(worker)`. It simply wraps the :meth:`work` + `QThreadPool.start(worker)`. It simply wraps the `work()` method, and emits a few signals. Subclasses should NOT override this method (except with good reason), and instead should implement - :meth:`work`. + `work()`. """ self.started.emit() self._running = True @@ -208,26 +207,26 @@ def work(self) -> Union[Exception, _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 - - 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) + ```python + 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) + ``` """ raise NotImplementedError( f'"{self.__class__.__name__}" failed to define work() method' @@ -238,14 +237,14 @@ def start(self) -> None: The order of method calls when starting a worker is: - .. code-block:: none - + ``` calls QThreadPool.globalInstance().start(worker) | triggered by the QThreadPool.start() method | | called by worker.run | | | V V V worker.start -> worker.run -> worker.work + ``` """ if self in self._worker_set: raise RuntimeError("This worker is already started!") @@ -271,33 +270,33 @@ def _set_discard(cls, obj: WorkerBase) -> None: 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 - `_ + See [this stack-overflow + post](https://stackoverflow.com/questions/323972/is-there-any-way-to-kill-a-thread) for a good discussion on the difficulty of killing a rogue python thread: Parameters @@ -326,12 +325,11 @@ def ZZZzzz(): class FunctionWorker(WorkerBase[_R]): """QRunnable with signals that wraps a simple long-running function. - .. note:: - - ``FunctionWorker`` does not provide a way to stop a very long-running - function (e.g. ``time.sleep(10000)``). So whenever possible, it is - better to implement your long running function as a generator that - yields periodically, and use the :class:`GeneratorWorker` instead. + !!! note + `FunctionWorker` does not provide a way to stop a very long-running + function (e.g. `time.sleep(10000)`). So whenever possible, it is better to + implement your long running function as a generator that yields periodically, + and use the [`GeneratorWorker`][superqt.utils.GeneratorWorker] instead. Parameters ---------- @@ -345,7 +343,7 @@ class FunctionWorker(WorkerBase[_R]): Raises ------ TypeError - If ``func`` is a generator function and not a regular function. + If `func` is a generator function and not a regular function. """ def __init__(self, func: Callable[_P, _R], *args, **kwargs): @@ -454,7 +452,7 @@ def work(self) -> Union[Optional[_R], Exception]: return exc.value except RuntimeError as exc: # The worker has probably been deleted. warning will be - # emitted in ``WorkerBase.run`` + # emitted in `WorkerBase.run` return exc return None @@ -534,38 +532,39 @@ def create_worker( ) -> Union[FunctionWorker, GeneratorWorker]: """Convenience function to start a function in another thread. - By default, uses :class:`Worker`, but a custom ``WorkerBase`` subclass may - be provided. If so, it must be a subclass of :class:`Worker`, which - defines a standard set of signals and a run method. + By default, uses `FunctionWorker` for functions and `GeneratorWorker` for + generators, but a custom `WorkerBase` subclass may be provided. If so, it must be a + subclass of `WorkerBase`, which defines a standard set of signals and a run method. Parameters ---------- func : Callable The function to call in another thread. - _start_thread : bool, optional + _start_thread : bool Whether to immediaetly start the thread. If False, the returned worker - must be manually started with ``worker.start()``. by default it will be - ``False`` if the ``_connect`` argument is ``None``, otherwise ``True``. + must be manually started with `worker.start()`. by default it will be + `False` if the `_connect` argument is `None`, otherwise `True`. _connect : Dict[str, Union[Callable, Sequence]], optional - A mapping of ``"signal_name"`` -> ``callable`` or list of ``callable``: + A mapping of `"signal_name"` -> `callable` or list of `callable`: callback functions to connect to the various signals offered by the - worker class. by default None - _worker_class : type of GeneratorWorker or FunctionWorker, optional - The :class`WorkerBase` to instantiate, by default - :class:`FunctionWorker` will be used if ``func`` is a regular function, - and :class:`GeneratorWorker` will be used if it is a generator. - _ignore_errors : bool, optional - If ``False`` (the default), errors raised in the other thread will be + worker class. by default `None` + _worker_class : type of `GeneratorWorker` or `FunctionWorker`, optional + The [`WorkerBase`][superqt.utils.WorkerBase] to instantiate, by default + [`FunctionWorker`][superqt.utils.FunctionWorker] will be used if `func` is a + regular function, and [`GeneratorWorker`][superqt.utils.GeneratorWorker] will be + used if it is a generator. + _ignore_errors : bool + If `False` (the default), errors raised in the other thread will be reraised in the main thread (makes debugging significantly easier). *args - will be passed to ``func`` + will be passed to `func` **kwargs - will be passed to ``func`` + will be passed to `func` Returns ------- worker : WorkerBase - An instantiated worker. If ``_start_thread`` was ``False``, the worker + An instantiated worker. If `_start_thread` was `False`, the worker will have a `.start()` method that can be used to start the thread. Raises @@ -573,18 +572,17 @@ def create_worker( TypeError If a worker_class is provided that is not a subclass of WorkerBase. TypeError - If _connect is provided and is not a dict of ``{str: callable}`` + If _connect is provided and is not a dict of `{str: callable}` Examples -------- - .. code-block:: python - - def long_function(duration): - import time - time.sleep(duration) - - worker = create_worker(long_function, 10) + ```python + def long_function(duration): + import time + time.sleep(duration) + worker = create_worker(long_function, 10) + ``` """ worker: Union[FunctionWorker, GeneratorWorker] @@ -616,7 +614,7 @@ def long_function(duration): getattr(worker, key).connect(v) # if the user has not provided a default connection for the "errored" - # signal... and they have not explicitly set ``ignore_errors=True`` + # signal... and they have not explicitly set `ignore_errors=True` # Then rereaise any errors from the thread. if not _ignore_errors and not (_connect or {}).get("errored", False): @@ -672,55 +670,55 @@ def thread_worker( ): """Decorator that runs a function in a separate thread when called. - When called, the decorated function returns a :class:`WorkerBase`. See - :func:`create_worker` for additional keyword arguments that can be used + When called, the decorated function returns a [`WorkerBase`][superqt.utils.WorkerBase]. See + [`create_worker`][superqt.utils.create_worker] for additional keyword arguments that can be used when calling the function. The returned worker will have these signals: - - *started*: emitted when the work is started - - *finished*: emitted when the work is finished - - *returned*: emitted with return value - - *errored*: emitted with error object on Exception + - **started**: emitted when the work is started + - **finished**: emitted when the work is finished + - **returned**: emitted with return value + - **errored**: emitted with error object on Exception - It will also have a ``worker.start()`` method that can be used to start + It will also have a `worker.start()` method that can be used to start execution of the function in another thread. (useful if you need to connect callbacks to signals prior to execution) If the decorated function is a generator, the returned worker will also provide these signals: - - *yielded*: emitted with yielded values - - *paused*: emitted when a running job has successfully paused - - *resumed*: emitted when a paused job has successfully resumed - - *aborted*: emitted when a running job is successfully aborted + - **yielded**: emitted with yielded values + - **paused**: emitted when a running job has successfully paused + - **resumed**: emitted when a paused job has successfully resumed + - **aborted**: emitted when a running job is successfully aborted And these methods: - - *quit*: ask the thread to quit - - *toggle_paused*: toggle the running state of the thread. - - *send*: send a value into the generator. (This requires that your - decorator function uses the ``value = yield`` syntax) + - **quit**: ask the thread to quit + - **toggle_paused**: toggle the running state of the thread. + - **send**: send a value into the generator. (This requires that your + decorator function uses the `value = yield` syntax) Parameters ---------- function : callable Function to call in another thread. For communication between threads may be a generator function. - start_thread : bool, optional + start_thread : bool Whether to immediaetly start the thread. If False, the returned worker - must be manually started with ``worker.start()``. by default it will be - ``False`` if the ``_connect`` argument is ``None``, otherwise ``True``. - connect : Dict[str, Union[Callable, Sequence]], optional - A mapping of ``"signal_name"`` -> ``callable`` or list of ``callable``: + must be manually started with `worker.start()`. by default it will be + `False` if the `_connect` argument is `None`, otherwise `True`. + connect : Dict[str, Union[Callable, Sequence]] + A mapping of `"signal_name"` -> `callable` or list of `callable`: callback functions to connect to the various signals offered by the worker class. by default None - worker_class : Type[WorkerBase], optional - The :class`WorkerBase` to instantiate, by default - :class:`FunctionWorker` will be used if ``func`` is a regular function, - and :class:`GeneratorWorker` will be used if it is a generator. - ignore_errors : bool, optional - If ``False`` (the default), errors raised in the other thread will be + worker_class : Type[WorkerBase] + The [`WorkerBase`][superqt.utils.WorkerBase] to instantiate, by default + [`FunctionWorker`][superqt.utils.FunctionWorker] will be used if `func` is a regular function, + and [`GeneratorWorker`][superqt.utils.GeneratorWorker] will be used if it is a generator. + ignore_errors : bool + If `False` (the default), errors raised in the other thread will be reraised in the main thread (makes debugging significantly easier). Returns @@ -731,25 +729,26 @@ def thread_worker( Examples -------- - .. code-block:: python - - @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' - - # 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() + ```python + @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' + + # 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() + ``` """ def _inner(func): @@ -804,36 +803,35 @@ def new_worker_qthread( _connect: Dict[str, Callable] = None, **kwargs, ): - """This is a convenience function to start a worker in a Qthread. + """This is a convenience function to start a worker in a `QThread`. - In most cases, the @thread_worker decorator is sufficient and preferable. - But this allows the user to completely customize the Worker object. - However, they must then maintain control over the thread and clean up + In most cases, the [thread_worker][superqt.utils.thread_worker] decorator is + sufficient and preferable. But this allows the user to completely customize the + Worker object. However, they must then maintain control over the thread and clean up appropriately. - It follows the pattern described here: - https://www.qt.io/blog/2010/06/17/youre-doing-it-wrong - and - https://doc.qt.io/qt-5/qthread.html#details + It follows the pattern described + [here](https://www.qt.io/blog/2010/06/17/youre-doing-it-wrong) and in the [qt thread + docs](https://doc.qt.io/qt-5/qthread.html#details) see also: + https://mayaposch.wordpress.com/2011/11/01/how-to-really-truly-use-qthreads-the-full-explanation/ - A QThread object is not a thread! It should be thought of as a class to - *manage* a thread, not as the actual code or object that runs in that + A QThread object is not a thread! It should be thought of as a class to *manage* a + thread, not as the actual code or object that runs in that thread. The QThread object is created on the main thread and lives there. Worker objects which derive from QObject are the things that actually do the work. They can be moved to a QThread as is done here. - .. note:: Mostly ignorable detail + ??? "Mostly ignorable detail" While the signals/slots syntax of the worker looks very similar to standard "single-threaded" signals & slots, note that inter-thread - signals and slots (automatically) use an event-based QueuedConnection, - while intra-thread signals use a DirectConnection. See `Signals and - Slots Across Threads - `_ + signals and slots (automatically) use an event-based QueuedConnection, while + intra-thread signals use a DirectConnection. See [Signals and Slots Across + Threads](https://doc.qt.io/qt-5/threads-qobject.html#signals-and-slots-across-threads>) Parameters ---------- @@ -843,7 +841,7 @@ def new_worker_qthread( _start_thread : bool If True, thread will be started immediately, otherwise, thread must be manually started with thread.start(). - _connect : dict, optional + _connect : dict Optional dictionary of {signal: function} to connect to the new worker. for instance: _connect = {'incremented': myfunc} will result in: worker.incremented.connect(myfunc) @@ -863,33 +861,33 @@ def new_worker_qthread( -------- Create some QObject that has a long-running work method: - .. code-block:: python + ```python - class Worker(QObject): + class Worker(QObject): - finished = Signal() - increment = Signal(int) + finished = Signal() + increment = Signal(int) - def __init__(self, argument): - super().__init__() - self.argument = argument - - @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}, - ) + 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}, + ) + ``` """ if _connect and not isinstance(_connect, dict): diff --git a/src/superqt/utils/_throttler.py b/src/superqt/utils/_throttler.py index fbb51766..97563a20 100644 --- a/src/superqt/utils/_throttler.py +++ b/src/superqt/utils/_throttler.py @@ -103,7 +103,7 @@ def setTimeout(self, timeout: int) -> None: self.timeoutChanged.emit(timeout) def timerType(self) -> Qt.TimerType: - """Return current Qt.TimerType.""" + """Return current `Qt.TimerType`.""" return self._timer.timerType() def setTimerType(self, timerType: Qt.TimerType) -> None: @@ -136,11 +136,11 @@ def throttle(self) -> None: 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: