mirror of
https://github.com/pyapp-kit/superqt.git
synced 2025-07-21 12:11:07 +02:00
Compare commits
42 Commits
Author | SHA1 | Date | |
---|---|---|---|
|
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 | ||
|
ba1ae92bcc | ||
|
8217a1cc71 | ||
|
96de1a261a |
58
.github/workflows/test_and_deploy.yml
vendored
58
.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
|
||||
@@ -136,36 +133,59 @@ jobs:
|
||||
name: screenshots ${{ runner.os }}
|
||||
path: screenshots
|
||||
|
||||
test_napari:
|
||||
name: napari tests
|
||||
test_old_qtpy:
|
||||
name: qtpy minreq
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v2
|
||||
with:
|
||||
fetch-depth: 0
|
||||
path: superqt
|
||||
|
||||
- uses: actions/checkout@v2
|
||||
with:
|
||||
repository: napari/napari
|
||||
path: napari
|
||||
fetch-depth: 2
|
||||
|
||||
- uses: tlambert03/setup-qt-libs@v1
|
||||
- uses: actions/setup-python@v2
|
||||
with:
|
||||
python-version: '3.9'
|
||||
python-version: '3.8'
|
||||
|
||||
- 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 -e .[testing,pyqt5]
|
||||
python -m pip install qtpy==1.1.0 typing-extensions==3.10.0.0
|
||||
|
||||
- name: Test napari magicgui
|
||||
uses: GabrielBB/xvfb-action@v1
|
||||
with:
|
||||
run: python -m pytest --color=yes napari/napari/_qt
|
||||
run: python -m pytest --color=yes
|
||||
|
||||
|
||||
test_napari:
|
||||
name: napari tests
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
with:
|
||||
fetch-depth: 0
|
||||
path: superqt
|
||||
|
||||
- uses: actions/checkout@v3
|
||||
with:
|
||||
repository: napari/napari
|
||||
path: napari-repo
|
||||
fetch-depth: 2
|
||||
|
||||
- uses: tlambert03/setup-qt-libs@v1
|
||||
- uses: actions/setup-python@v4
|
||||
with:
|
||||
python-version: '3.10'
|
||||
|
||||
- name: install
|
||||
run: |
|
||||
python -m pip install -U pip
|
||||
python -m pip install ./superqt
|
||||
python -m pip install ./napari-repo[testing,pyqt5]
|
||||
|
||||
- name: Test napari
|
||||
uses: GabrielBB/xvfb-action@v1
|
||||
with:
|
||||
working-directory: napari-repo
|
||||
run: python -m pytest --color=yes napari/_qt
|
||||
|
||||
check_manifest:
|
||||
runs-on: ubuntu-latest
|
||||
|
@@ -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
|
||||
|
97
CHANGELOG.md
97
CHANGELOG.md
@@ -1,5 +1,92 @@
|
||||
# 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)
|
||||
|
||||
**Implemented enhancements:**
|
||||
|
||||
- Add `signals_blocked` util [\#69](https://github.com/napari/superqt/pull/69) ([tlambert03](https://github.com/tlambert03))
|
||||
|
||||
**Fixed bugs:**
|
||||
|
||||
- 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)
|
||||
@@ -25,6 +112,10 @@
|
||||
|
||||
- Use qtpy, deprecate superqt.qtcompat, drop support for Qt \<5.12 [\#39](https://github.com/napari/superqt/pull/39) ([tlambert03](https://github.com/tlambert03))
|
||||
|
||||
**Merged pull requests:**
|
||||
|
||||
- Add changelog for v0.3.0 [\#68](https://github.com/napari/superqt/pull/68) ([tlambert03](https://github.com/tlambert03))
|
||||
|
||||
## [v0.2.5-1](https://github.com/napari/superqt/tree/v0.2.5-1) (2021-11-23)
|
||||
|
||||
[Full Changelog](https://github.com/napari/superqt/compare/v0.2.5...v0.2.5-1)
|
||||
@@ -101,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)
|
||||
|
@@ -61,3 +61,8 @@ 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`.
|
||||
|
8
docs/listwidgets.md
Normal file
8
docs/listwidgets.md
Normal file
@@ -0,0 +1,8 @@
|
||||
# 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.
|
@@ -10,7 +10,7 @@
|
||||
- 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
|
||||
|
||||
@@ -221,12 +221,6 @@ 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
|
||||
|
||||
@@ -235,3 +229,29 @@ 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)
|
||||
```
|
||||
|
10
docs/utils.md
Normal file
10
docs/utils.md
Normal file
@@ -0,0 +1,10 @@
|
||||
# Utils
|
||||
|
||||
## Code highlighting
|
||||
|
||||
`superqt` provides a code highlighter subclass of `QSyntaxHighlighter`
|
||||
that can be used to highlight code in a QTextEdit.
|
||||
|
||||
Code lexer and available styles are from [`pygments`](https://pygments.org/) python library
|
||||
List of available languages are available [here](https://pygments.org/languages/).
|
||||
List of available styles are available [here](https://pygments.org/styles/).
|
32
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 {
|
||||
|
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_()
|
@@ -35,8 +35,10 @@ project_urls =
|
||||
[options]
|
||||
packages = find:
|
||||
install_requires =
|
||||
qtpy
|
||||
typing-extensions>=3.10.0.0
|
||||
packaging
|
||||
pygments>=2.4.0
|
||||
qtpy>=1.1.0
|
||||
typing-extensions
|
||||
python_requires = >=3.7
|
||||
include_package_data = True
|
||||
package_dir =
|
||||
|
@@ -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,13 +1,7 @@
|
||||
"""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
|
||||
|
||||
|
||||
@@ -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())
|
@@ -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
|
||||
|
||||
|
@@ -243,7 +243,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)
|
||||
|
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,9 +61,7 @@ class _GenericRangeSlider(_GenericSlider[Tuple], Generic[_T]):
|
||||
|
||||
# color
|
||||
|
||||
self._style = RangeSliderStyle()
|
||||
self.setStyleSheet("")
|
||||
update_styles_from_stylesheet(self)
|
||||
|
||||
# ############### New Public API #######################
|
||||
|
||||
@@ -97,6 +101,10 @@ class _GenericRangeSlider(_GenericSlider[Tuple], Generic[_T]):
|
||||
def showBar(self) -> None:
|
||||
self.setBarVisible(True)
|
||||
|
||||
def applyMacStylePatch(self) -> str:
|
||||
super().applyMacStylePatch()
|
||||
self._style._macpatch = True
|
||||
|
||||
# ############### QtOverrides #######################
|
||||
|
||||
def value(self) -> Tuple[_T, ...]:
|
||||
@@ -131,12 +139,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 +163,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):
|
||||
|
@@ -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,12 @@ 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:
|
||||
self.setStyleSheet(MONTEREY_SLIDER_STYLES_FIX)
|
||||
|
||||
# ############### QtOverrides #######################
|
||||
|
||||
@@ -134,8 +155,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 +293,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
|
||||
@@ -223,6 +234,8 @@ class QLabeledDoubleSlider(QLabeledSlider):
|
||||
|
||||
class QLabeledRangeSlider(_SliderProxy, QAbstractSlider):
|
||||
_valueChanged = Signal(tuple)
|
||||
editingFinished = Signal()
|
||||
|
||||
LabelPosition = LabelPosition
|
||||
EdgeLabelMode = EdgeLabelMode
|
||||
_slider_class = QRangeSlider
|
||||
@@ -255,6 +268,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)
|
||||
@@ -304,7 +319,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 +354,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 +388,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 +504,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,21 +1,25 @@
|
||||
__all__ = (
|
||||
"CodeSyntaxHighlight",
|
||||
"create_worker",
|
||||
"ensure_main_thread",
|
||||
"ensure_object_thread",
|
||||
"FunctionWorker",
|
||||
"GeneratorWorker",
|
||||
"new_worker_qthread",
|
||||
"QMessageHandler",
|
||||
"thread_worker",
|
||||
"WorkerBase",
|
||||
"qthrottled",
|
||||
"qdebounced",
|
||||
"QMessageHandler",
|
||||
"QSignalDebouncer",
|
||||
"QSignalThrottler",
|
||||
"qthrottled",
|
||||
"signals_blocked",
|
||||
"thread_worker",
|
||||
"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
|
||||
from ._qthreading import (
|
||||
FunctionWorker,
|
||||
GeneratorWorker,
|
||||
|
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
|
15
src/superqt/utils/_misc.py
Normal file
15
src/superqt/utils/_misc.py
Normal file
@@ -0,0 +1,15 @@
|
||||
from contextlib import contextmanager
|
||||
from typing import TYPE_CHECKING, Iterator
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from qtpy.QtCore import QObject
|
||||
|
||||
|
||||
@contextmanager
|
||||
def signals_blocked(obj: "QObject") -> Iterator[None]:
|
||||
"""Context manager to temporarily block signals emitted by QObject: `obj`."""
|
||||
previous = obj.blockSignals(True)
|
||||
try:
|
||||
yield
|
||||
finally:
|
||||
obj.blockSignals(previous)
|
@@ -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(
|
||||
|
@@ -32,8 +32,23 @@ from enum import IntFlag, auto
|
||||
from functools import wraps
|
||||
from typing import TYPE_CHECKING, Callable, Generic, Optional, TypeVar, Union, overload
|
||||
|
||||
from qtpy.QtCore import QObject, Qt, QTimer, Signal, SignalInstance
|
||||
from typing_extensions import Literal, ParamSpec
|
||||
from qtpy.QtCore import QObject, Qt, QTimer, Signal
|
||||
|
||||
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):
|
||||
@@ -176,14 +191,12 @@ 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
|
||||
|
||||
class ThrottledCallable(Generic[P, R], Protocol):
|
||||
triggered: SignalInstance
|
||||
triggered: "SignalInstance"
|
||||
|
||||
def cancel(self) -> None:
|
||||
...
|
||||
@@ -196,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]:
|
||||
...
|
||||
|
||||
|
||||
@@ -217,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,
|
||||
@@ -276,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,
|
||||
@@ -344,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
|
||||
|
29
tests/test_utils.py
Normal file
29
tests/test_utils.py
Normal file
@@ -0,0 +1,29 @@
|
||||
from unittest.mock import Mock
|
||||
|
||||
from qtpy.QtCore import QObject, Signal
|
||||
|
||||
from superqt.utils import signals_blocked
|
||||
|
||||
|
||||
def test_signal_blocker(qtbot):
|
||||
"""make sure context manager signal blocker works"""
|
||||
|
||||
class Emitter(QObject):
|
||||
sig = Signal()
|
||||
|
||||
obj = Emitter()
|
||||
receiver = Mock()
|
||||
obj.sig.connect(receiver)
|
||||
|
||||
# make sure signal works
|
||||
with qtbot.waitSignal(obj.sig):
|
||||
obj.sig.emit()
|
||||
|
||||
receiver.assert_called_once()
|
||||
receiver.reset_mock()
|
||||
|
||||
with signals_blocked(obj):
|
||||
obj.sig.emit()
|
||||
qtbot.wait(10)
|
||||
|
||||
receiver.assert_not_called()
|
Reference in New Issue
Block a user