mirror of
https://github.com/pyapp-kit/superqt.git
synced 2025-07-21 12:11:07 +02:00
Compare commits
39 Commits
Author | SHA1 | Date | |
---|---|---|---|
|
ac4adf5234 | ||
|
5f68795a82 | ||
|
17ad1079a8 | ||
|
6bb050c499 | ||
|
1f4d9081b9 | ||
|
7b1aefd119 | ||
|
0ec5cd3a2f | ||
|
8f62b0b00d | ||
|
4a0aaca2e9 | ||
|
2d49e77c3d | ||
|
ba495a5e72 | ||
|
12f10be8da | ||
|
9ca0bbf858 | ||
|
0ab6758972 | ||
|
d2bc3d898c | ||
|
1bb1a58a73 | ||
|
1288250597 | ||
|
34a776e8d0 | ||
|
146644e105 | ||
|
e7873ad93d | ||
|
0396d465e2 | ||
|
4bf73c37f1 | ||
|
d407af2089 | ||
|
16f9ef9d3d | ||
|
56f65ff123 | ||
|
60188de52e | ||
|
b4d3a4f9b7 | ||
|
95b1178647 | ||
|
ef87685626 | ||
|
b927159f49 | ||
|
61e7409b1c | ||
|
c9103e3dd8 | ||
|
570c261368 | ||
|
bd6899133f | ||
|
3efafd7aa8 | ||
|
0fd25aa665 | ||
|
a5740f0109 | ||
|
65a4a6e17c | ||
|
6f74c6905e |
140
.github/workflows/test_and_deploy.yml
vendored
140
.github/workflows/test_and_deploy.yml
vendored
@@ -6,23 +6,27 @@ concurrency:
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
tags:
|
||||
- "v*" # Push events to matching v*, i.e. v1.0, v20.15.10
|
||||
branches: [main]
|
||||
tags: [v*]
|
||||
pull_request:
|
||||
branches:
|
||||
- main
|
||||
workflow_dispatch:
|
||||
schedule:
|
||||
- cron: "0 0 * * 0" # run weekly
|
||||
|
||||
jobs:
|
||||
test:
|
||||
name: ${{ matrix.platform }} py${{ matrix.python-version }} ${{ matrix.backend }}
|
||||
runs-on: ${{ matrix.platform }}
|
||||
name: Test
|
||||
uses: pyapp-kit/workflows/.github/workflows/test-pyrepo.yml@v2
|
||||
with:
|
||||
os: ${{ matrix.platform }}
|
||||
python-version: ${{ matrix.python-version }}
|
||||
qt: ${{ matrix.backend }}
|
||||
pip-install-pre-release: ${{ github.event_name == 'schedule' }}
|
||||
coverage-upload: artifact
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
platform: [ubuntu-latest, windows-latest, macos-latest]
|
||||
platform: [ubuntu-latest, windows-latest, macos-13]
|
||||
python-version: ["3.8", "3.9", "3.10", "3.11"]
|
||||
backend: [pyqt5, pyside2, pyqt6]
|
||||
exclude:
|
||||
@@ -32,7 +36,6 @@ jobs:
|
||||
# lack of wheels for pyside2/py3.11
|
||||
- python-version: "3.11"
|
||||
backend: pyside2
|
||||
|
||||
include:
|
||||
- python-version: "3.10"
|
||||
platform: macos-latest
|
||||
@@ -46,7 +49,9 @@ jobs:
|
||||
- python-version: "3.11"
|
||||
platform: windows-latest
|
||||
backend: pyside6
|
||||
|
||||
- python-version: "3.12"
|
||||
platform: macos-latest
|
||||
backend: pyqt6
|
||||
# legacy Qt
|
||||
- python-version: 3.8
|
||||
platform: ubuntu-latest
|
||||
@@ -58,96 +63,43 @@ jobs:
|
||||
platform: ubuntu-latest
|
||||
backend: "pyqt5==5.14.*"
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
test-qt-minreqs:
|
||||
uses: pyapp-kit/workflows/.github/workflows/test-pyrepo.yml@v2
|
||||
with:
|
||||
python-version: "3.8"
|
||||
qt: pyqt5
|
||||
pip-post-installs: "qtpy==1.1.0 typing-extensions==3.7.4.3"
|
||||
pip-install-flags: -e
|
||||
coverage-upload: artifact
|
||||
|
||||
- name: Set up Python ${{ matrix.python-version }}
|
||||
uses: actions/setup-python@v4
|
||||
with:
|
||||
python-version: ${{ matrix.python-version }}
|
||||
|
||||
- uses: tlambert03/setup-qt-libs@v1.4
|
||||
|
||||
- name: Linux opengl
|
||||
if: runner.os == 'Linux' && ( startsWith(matrix.backend, 'pyside6') || startsWith(matrix.backend, 'pyqt6') )
|
||||
run: sudo apt-get install -y libopengl0 libegl1-mesa libxcb-xinput0
|
||||
|
||||
- name: Install dependencies
|
||||
run: |
|
||||
python -m pip install --upgrade pip
|
||||
python -m pip install -e .[test]
|
||||
python -m pip install ${{ matrix.backend }}
|
||||
|
||||
- name: Test
|
||||
uses: aganders3/headless-gui@v1.2
|
||||
with:
|
||||
run: python -m pytest --color=yes --cov=superqt --cov-report=xml
|
||||
|
||||
- name: Coverage
|
||||
uses: codecov/codecov-action@v3
|
||||
|
||||
test_old_qtpy:
|
||||
name: qtpy minreq
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: tlambert03/setup-qt-libs@v1.4
|
||||
- uses: actions/setup-python@v4
|
||||
with:
|
||||
python-version: "3.8"
|
||||
|
||||
- name: install
|
||||
run: |
|
||||
python -m pip install -U pip
|
||||
python -m pip install -e .[test,pyqt5]
|
||||
python -m pip install qtpy==1.1.0 typing-extensions==3.7.4.3
|
||||
|
||||
- name: Test
|
||||
uses: aganders3/headless-gui@v1.2
|
||||
with:
|
||||
run: python -m pytest --color=yes
|
||||
upload_coverage:
|
||||
if: always()
|
||||
needs: [test, test-qt-minreqs]
|
||||
uses: pyapp-kit/workflows/.github/workflows/upload-coverage.yml@v2
|
||||
secrets: inherit
|
||||
|
||||
test_napari:
|
||||
name: napari tests
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
path: superqt
|
||||
|
||||
- uses: actions/checkout@v4
|
||||
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: aganders3/headless-gui@v1.2
|
||||
with:
|
||||
working-directory: napari-repo
|
||||
run: python -m pytest --color=yes napari/_qt
|
||||
uses: pyapp-kit/workflows/.github/workflows/test-dependents.yml@v2
|
||||
with:
|
||||
dependency-repo: napari/napari
|
||||
dependency-ref: ${{ matrix.napari-version }}
|
||||
dependency-extras: "testing"
|
||||
qt: ${{ matrix.qt }}
|
||||
pytest-args: 'napari/_qt -k "not async and not qt_dims_2 and not qt_viewer_console_focus and not keybinding_editor"'
|
||||
python-version: "3.10"
|
||||
post-install-cmd: "pip install lxml_html_clean"
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
napari-version: ["", "v0.4.19.post1"]
|
||||
qt: ["pyqt5", "pyside2"]
|
||||
|
||||
check-manifest:
|
||||
name: Check Manifest
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-python@v4
|
||||
with:
|
||||
python-version: "3.x"
|
||||
- run: pip install check-manifest && check-manifest
|
||||
- run: pipx run check-manifest
|
||||
|
||||
deploy:
|
||||
# this will run when you have tagged a commit, starting with "v*"
|
||||
@@ -161,7 +113,7 @@ jobs:
|
||||
with:
|
||||
fetch-depth: 0
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@v4
|
||||
uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: "3.x"
|
||||
- name: Install dependencies
|
||||
@@ -178,6 +130,6 @@ jobs:
|
||||
twine check dist/*
|
||||
twine upload dist/*
|
||||
|
||||
- uses: softprops/action-gh-release@v1
|
||||
- uses: softprops/action-gh-release@v2
|
||||
with:
|
||||
generate_release_notes: true
|
||||
|
@@ -4,31 +4,20 @@ ci:
|
||||
autoupdate_commit_msg: "ci: [pre-commit.ci] autoupdate"
|
||||
|
||||
repos:
|
||||
- repo: https://github.com/pre-commit/pre-commit-hooks
|
||||
rev: v4.4.0
|
||||
hooks:
|
||||
- id: check-docstring-first
|
||||
- id: end-of-file-fixer
|
||||
- id: trailing-whitespace
|
||||
|
||||
- repo: https://github.com/psf/black
|
||||
rev: 23.7.0
|
||||
hooks:
|
||||
- id: black
|
||||
|
||||
- repo: https://github.com/astral-sh/ruff-pre-commit
|
||||
rev: v0.0.287
|
||||
rev: v0.4.7
|
||||
hooks:
|
||||
- id: ruff
|
||||
args: ["--fix"]
|
||||
args: [--fix, --unsafe-fixes]
|
||||
- id: ruff-format
|
||||
|
||||
- repo: https://github.com/abravalheri/validate-pyproject
|
||||
rev: v0.14
|
||||
rev: v0.18
|
||||
hooks:
|
||||
- id: validate-pyproject
|
||||
|
||||
- repo: https://github.com/pre-commit/mirrors-mypy
|
||||
rev: v1.5.1
|
||||
rev: v1.10.0
|
||||
hooks:
|
||||
- id: mypy
|
||||
exclude: tests|examples
|
||||
|
102
CHANGELOG.md
102
CHANGELOG.md
@@ -1,5 +1,107 @@
|
||||
# Changelog
|
||||
|
||||
## [v0.6.8](https://github.com/pyapp-kit/superqt/tree/v0.6.8) (2024-06-15)
|
||||
|
||||
[Full Changelog](https://github.com/pyapp-kit/superqt/compare/v0.6.7...v0.6.8)
|
||||
|
||||
**Implemented enhancements:**
|
||||
|
||||
- feat: graceful offline fallback for qiconify [\#251](https://github.com/pyapp-kit/superqt/pull/251) ([tlambert03](https://github.com/tlambert03))
|
||||
|
||||
## [v0.6.7](https://github.com/pyapp-kit/superqt/tree/v0.6.7) (2024-06-07)
|
||||
|
||||
[Full Changelog](https://github.com/pyapp-kit/superqt/compare/v0.6.6...v0.6.7)
|
||||
|
||||
**Fixed bugs:**
|
||||
|
||||
- fix: prevent qthrottled and qdebounced from holding strong references with bound methods [\#247](https://github.com/pyapp-kit/superqt/pull/247) ([tlambert03](https://github.com/tlambert03))
|
||||
|
||||
**Merged pull requests:**
|
||||
|
||||
- Prevent computing full document content highlight per block and only compute current block content for performance [\#246](https://github.com/pyapp-kit/superqt/pull/246) ([dalthviz](https://github.com/dalthviz))
|
||||
|
||||
## [v0.6.6](https://github.com/pyapp-kit/superqt/tree/v0.6.6) (2024-05-12)
|
||||
|
||||
[Full Changelog](https://github.com/pyapp-kit/superqt/compare/v0.6.5...v0.6.6)
|
||||
|
||||
**Refactors:**
|
||||
|
||||
- perf: improve paint time for QColormapLineEdit [\#245](https://github.com/pyapp-kit/superqt/pull/245) ([tlambert03](https://github.com/tlambert03))
|
||||
|
||||
## [v0.6.5](https://github.com/pyapp-kit/superqt/tree/v0.6.5) (2024-05-06)
|
||||
|
||||
[Full Changelog](https://github.com/pyapp-kit/superqt/compare/v0.6.4...v0.6.5)
|
||||
|
||||
**Implemented enhancements:**
|
||||
|
||||
- fix: fix a number of issues with Labeled and Range Sliders, add LabelsOnHandle mode. [\#242](https://github.com/pyapp-kit/superqt/pull/242) ([tlambert03](https://github.com/tlambert03))
|
||||
|
||||
**Tests & CI:**
|
||||
|
||||
- ci: trying to fix tests on various platforms [\#243](https://github.com/pyapp-kit/superqt/pull/243) ([tlambert03](https://github.com/tlambert03))
|
||||
|
||||
## [v0.6.4](https://github.com/pyapp-kit/superqt/tree/v0.6.4) (2024-04-25)
|
||||
|
||||
[Full Changelog](https://github.com/pyapp-kit/superqt/compare/v0.6.3...v0.6.4)
|
||||
|
||||
**Fixed bugs:**
|
||||
|
||||
- fix: fix inverted appearance [\#240](https://github.com/pyapp-kit/superqt/pull/240) ([tlambert03](https://github.com/tlambert03))
|
||||
|
||||
**Merged pull requests:**
|
||||
|
||||
- ci: \[pre-commit.ci\] autoupdate [\#238](https://github.com/pyapp-kit/superqt/pull/238) ([pre-commit-ci[bot]](https://github.com/apps/pre-commit-ci))
|
||||
|
||||
## [v0.6.3](https://github.com/pyapp-kit/superqt/tree/v0.6.3) (2024-03-27)
|
||||
|
||||
[Full Changelog](https://github.com/pyapp-kit/superqt/compare/v0.6.2...v0.6.3)
|
||||
|
||||
**Fixed bugs:**
|
||||
|
||||
- fix: fix sliderReleased, sliderPressed signals, and setTracking [\#237](https://github.com/pyapp-kit/superqt/pull/237) ([tlambert03](https://github.com/tlambert03))
|
||||
|
||||
**Merged pull requests:**
|
||||
|
||||
- ci\(dependabot\): bump softprops/action-gh-release from 1 to 2 [\#236](https://github.com/pyapp-kit/superqt/pull/236) ([dependabot[bot]](https://github.com/apps/dependabot))
|
||||
|
||||
## [v0.6.2](https://github.com/pyapp-kit/superqt/tree/v0.6.2) (2024-03-06)
|
||||
|
||||
[Full Changelog](https://github.com/pyapp-kit/superqt/compare/v0.6.1...v0.6.2)
|
||||
|
||||
**Implemented enhancements:**
|
||||
|
||||
- feat: make toggle button public in QCollapsible [\#232](https://github.com/pyapp-kit/superqt/pull/232) ([tlambert03](https://github.com/tlambert03))
|
||||
- feat: add addKey method to QIconifyIcon [\#218](https://github.com/pyapp-kit/superqt/pull/218) ([tlambert03](https://github.com/tlambert03))
|
||||
- feat: Add QIconifyIcon.name\(\) method [\#213](https://github.com/pyapp-kit/superqt/pull/213) ([tlambert03](https://github.com/tlambert03))
|
||||
|
||||
**Fixed bugs:**
|
||||
|
||||
- fix: don't use AbstractContextManager for exceptions\_as\_dialog [\#234](https://github.com/pyapp-kit/superqt/pull/234) ([tlambert03](https://github.com/tlambert03))
|
||||
- fix: Check min max versus current value [\#221](https://github.com/pyapp-kit/superqt/pull/221) ([psobolewskiPhD](https://github.com/psobolewskiPhD))
|
||||
- fix: better default size policy for qcollapsible [\#217](https://github.com/pyapp-kit/superqt/pull/217) ([tlambert03](https://github.com/tlambert03))
|
||||
|
||||
**Merged pull requests:**
|
||||
|
||||
- style: use ruff format instead of black, update pre-commit, restrict pyside6 tests [\#235](https://github.com/pyapp-kit/superqt/pull/235) ([tlambert03](https://github.com/tlambert03))
|
||||
- ci: \[pre-commit.ci\] autoupdate [\#228](https://github.com/pyapp-kit/superqt/pull/228) ([pre-commit-ci[bot]](https://github.com/apps/pre-commit-ci))
|
||||
- ci\(dependabot\): bump actions/setup-python from 4 to 5 [\#225](https://github.com/pyapp-kit/superqt/pull/225) ([dependabot[bot]](https://github.com/apps/dependabot))
|
||||
- ci: \[pre-commit.ci\] autoupdate [\#223](https://github.com/pyapp-kit/superqt/pull/223) ([pre-commit-ci[bot]](https://github.com/apps/pre-commit-ci))
|
||||
- ci: \[pre-commit.ci\] autoupdate [\#216](https://github.com/pyapp-kit/superqt/pull/216) ([pre-commit-ci[bot]](https://github.com/apps/pre-commit-ci))
|
||||
- ci: use reusable test workflow [\#215](https://github.com/pyapp-kit/superqt/pull/215) ([tlambert03](https://github.com/tlambert03))
|
||||
- build: remove packaging dep [\#212](https://github.com/pyapp-kit/superqt/pull/212) ([tlambert03](https://github.com/tlambert03))
|
||||
|
||||
## [v0.6.1](https://github.com/pyapp-kit/superqt/tree/v0.6.1) (2023-10-10)
|
||||
|
||||
[Full Changelog](https://github.com/pyapp-kit/superqt/compare/v0.6.0...v0.6.1)
|
||||
|
||||
**Implemented enhancements:**
|
||||
|
||||
- feat: add QIcon backed by iconify [\#209](https://github.com/pyapp-kit/superqt/pull/209) ([tlambert03](https://github.com/tlambert03))
|
||||
|
||||
**Merged pull requests:**
|
||||
|
||||
- ci: test python 3.12 [\#181](https://github.com/pyapp-kit/superqt/pull/181) ([tlambert03](https://github.com/tlambert03))
|
||||
|
||||
## [v0.6.0](https://github.com/pyapp-kit/superqt/tree/v0.6.0) (2023-09-25)
|
||||
|
||||
[Full Changelog](https://github.com/pyapp-kit/superqt/compare/v0.5.4...v0.6.0)
|
||||
|
@@ -37,7 +37,7 @@ def define_env(env: "MacrosPlugin"):
|
||||
)
|
||||
src = src.replace("app.exec_()", "")
|
||||
|
||||
exec(src) # noqa: S102
|
||||
exec(src)
|
||||
_grab(dest, width)
|
||||
return (
|
||||
f""
|
||||
|
@@ -1,4 +1,4 @@
|
||||
from PyQt5.QtGui import QColor, QPalette
|
||||
from qtpy.QtGui import QColor, QPalette
|
||||
from qtpy.QtWidgets import QApplication, QTextEdit
|
||||
|
||||
from superqt.utils import CodeSyntaxHighlight
|
||||
|
@@ -88,7 +88,7 @@ class IconPreviewArea(QtWidgets.QWidget):
|
||||
self.updatePixmapLabels()
|
||||
|
||||
def createHeaderLabel(self, text):
|
||||
label = QtWidgets.QLabel("<b>%s</b>" % text)
|
||||
label = QtWidgets.QLabel(f"<b>{text}</b>")
|
||||
label.setAlignment(Qt.AlignmentFlag.AlignCenter)
|
||||
return label
|
||||
|
||||
|
14
examples/iconify.py
Normal file
14
examples/iconify.py
Normal file
@@ -0,0 +1,14 @@
|
||||
from qtpy.QtCore import QSize
|
||||
from qtpy.QtWidgets import QApplication, QPushButton
|
||||
|
||||
from superqt import QIconifyIcon
|
||||
|
||||
app = QApplication([])
|
||||
|
||||
btn = QPushButton()
|
||||
# search https://icon-sets.iconify.design for available icon keys
|
||||
btn.setIcon(QIconifyIcon("fluent-emoji-flat:alarm-clock"))
|
||||
btn.setIconSize(QSize(60, 60))
|
||||
btn.show()
|
||||
|
||||
app.exec()
|
@@ -1,4 +1,5 @@
|
||||
"""Example for QCollapsible."""
|
||||
|
||||
from qtpy.QtWidgets import QApplication, QLabel, QPushButton
|
||||
|
||||
from superqt import QCollapsible
|
||||
|
@@ -32,13 +32,13 @@ classifiers = [
|
||||
"Programming Language :: Python :: 3.9",
|
||||
"Programming Language :: Python :: 3.10",
|
||||
"Programming Language :: Python :: 3.11",
|
||||
"Programming Language :: Python :: 3.12",
|
||||
"Topic :: Desktop Environment",
|
||||
"Topic :: Software Development :: User Interfaces",
|
||||
"Topic :: Software Development :: Widget Sets",
|
||||
]
|
||||
dynamic = ["version"]
|
||||
dependencies = [
|
||||
"packaging",
|
||||
"pygments>=2.4.0",
|
||||
"qtpy>=1.1.0",
|
||||
"typing-extensions >=3.7.4.3,!=3.10.0.0",
|
||||
@@ -47,9 +47,8 @@ dependencies = [
|
||||
# extras
|
||||
# https://peps.python.org/pep-0621/#dependencies-optional-dependencies
|
||||
[project.optional-dependencies]
|
||||
test = ["pint", "pytest", "pytest-cov", "pytest-qt", "numpy", "cmap"]
|
||||
test = ["pint", "pytest", "pytest-cov", "pytest-qt", "numpy", "cmap", "pyconify"]
|
||||
dev = [
|
||||
"black",
|
||||
"ipython",
|
||||
"ruff",
|
||||
"mypy",
|
||||
@@ -66,13 +65,15 @@ pyside2 = ["pyside2"]
|
||||
# see issues surrounding usage of Generics in pyside6.5.x
|
||||
# https://github.com/pyapp-kit/superqt/pull/177
|
||||
# https://github.com/pyapp-kit/superqt/pull/164
|
||||
pyside6 = ["pyside6 !=6.5.0,!=6.5.1"]
|
||||
# https://bugreports.qt.io/browse/PYSIDE-2627
|
||||
pyside6 = ["pyside6 !=6.5.0,!=6.5.1,!=6.6.2"]
|
||||
pyqt5 = ["pyqt5"]
|
||||
pyqt6 = ["pyqt6"]
|
||||
pyqt6 = ["pyqt6<6.7"]
|
||||
font-fa5 = ["fonticon-fontawesome5"]
|
||||
font-fa6 = ["fonticon-fontawesome6"]
|
||||
font-mi6 = ["fonticon-materialdesignicons6"]
|
||||
font-mi7 = ["fonticon-materialdesignicons7"]
|
||||
iconify = ["pyconify >=0.1.4"]
|
||||
|
||||
[project.urls]
|
||||
Documentation = "https://pyapp-kit.github.io/superqt/"
|
||||
@@ -110,55 +111,51 @@ matrix.qt.extra-dependencies = [
|
||||
{value = "pyqt5==5.12", if = ["pyqt5.12"]},
|
||||
]
|
||||
|
||||
# https://pycqa.github.io/isort/docs/configuration/options.html
|
||||
[tool.isort]
|
||||
profile = "black"
|
||||
src_paths = ["src/superqt", "tests"]
|
||||
|
||||
# https://github.com/charliermarsh/ruff
|
||||
[tool.ruff]
|
||||
line-length = 88
|
||||
target-version = "py38"
|
||||
src = ["src", "tests"]
|
||||
|
||||
# https://docs.astral.sh/ruff/rules
|
||||
[tool.ruff.lint]
|
||||
pydocstyle = { convention = "numpy" }
|
||||
select = [
|
||||
"E", # style errors
|
||||
"W", # style warnings
|
||||
"F", # flakes
|
||||
"W", # flakes
|
||||
"D", # pydocstyle
|
||||
"D417", # Missing argument descriptions in Docstrings
|
||||
"I", # isort
|
||||
"UP", # pyupgrade
|
||||
"S", # bandit
|
||||
"C4", # flake8-comprehensions
|
||||
"B", # flake8-bugbear
|
||||
"A001", # flake8-builtins
|
||||
"RUF", # ruff-specific rules
|
||||
"TID", # tidy imports
|
||||
"TCH", # flake8-type-checking
|
||||
"TID", # flake8-tidy-imports
|
||||
]
|
||||
ignore = [
|
||||
"D100", # Missing docstring in public module
|
||||
"D101", # Missing docstring in public class
|
||||
"D104", # Missing docstring in public package
|
||||
"D107", # Missing docstring in __init__
|
||||
"D203", # 1 blank line required before class docstring
|
||||
"D212", # Multi-line docstring summary should start at the first line
|
||||
"D213", # Multi-line docstring summary should start at the second line
|
||||
"D401", # First line should be in imperative mood
|
||||
"D413", # Missing blank line after last section
|
||||
"D416", # Section name should end with a colon
|
||||
"D401", # First line should be in imperative mood (remove to opt in)
|
||||
]
|
||||
|
||||
|
||||
[tool.ruff.per-file-ignores]
|
||||
[tool.ruff.lint.per-file-ignores]
|
||||
"tests/*.py" = ["D", "S101"]
|
||||
"examples/demo_widget.py" = ["E501"]
|
||||
"examples/*.py" = ["B", "D"]
|
||||
|
||||
# https://docs.astral.sh/ruff/formatter/
|
||||
[tool.ruff.format]
|
||||
docstring-code-format = true
|
||||
|
||||
# https://docs.pytest.org/en/6.2.x/customize.html
|
||||
[tool.pytest.ini_options]
|
||||
minversion = "6.0"
|
||||
testpaths = ["tests"]
|
||||
filterwarnings = [
|
||||
"error",
|
||||
"ignore:Failed to disconnect::pytestqt",
|
||||
"ignore:QPixmapCache.find:DeprecationWarning:",
|
||||
"ignore:SelectableGroups dict interface:DeprecationWarning",
|
||||
"ignore:The distutils package is deprecated:DeprecationWarning",
|
||||
@@ -185,15 +182,17 @@ allow_redefinition = true
|
||||
|
||||
# https://coverage.readthedocs.io/en/6.4/config.html
|
||||
[tool.coverage.run]
|
||||
source = ["src/superqt"]
|
||||
source = ["superqt"]
|
||||
|
||||
[tool.coverage.report]
|
||||
show_missing = true
|
||||
exclude_lines = [
|
||||
"pragma: no cover",
|
||||
"if TYPE_CHECKING:",
|
||||
"@overload",
|
||||
"except ImportError",
|
||||
"\\.\\.\\."
|
||||
"\\.\\.\\.",
|
||||
"pass",
|
||||
]
|
||||
|
||||
# https://github.com/mgedmin/check-manifest#configuration
|
||||
|
@@ -1,4 +1,5 @@
|
||||
"""superqt is a collection of Qt components for python."""
|
||||
|
||||
from importlib.metadata import PackageNotFoundError, version
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
@@ -10,6 +11,7 @@ except PackageNotFoundError:
|
||||
from .collapsible import QCollapsible
|
||||
from .combobox import QColorComboBox, QEnumComboBox, QSearchableComboBox
|
||||
from .elidable import QElidingLabel, QElidingLineEdit
|
||||
from .iconify import QIconifyIcon
|
||||
from .selection import QSearchableListWidget, QSearchableTreeWidget
|
||||
from .sliders import (
|
||||
QDoubleRangeSlider,
|
||||
@@ -35,6 +37,7 @@ __all__ = [
|
||||
"QElidingLineEdit",
|
||||
"QEnumComboBox",
|
||||
"QLabeledDoubleRangeSlider",
|
||||
"QIconifyIcon",
|
||||
"QLabeledDoubleSlider",
|
||||
"QLabeledRangeSlider",
|
||||
"QLabeledSlider",
|
||||
@@ -48,8 +51,8 @@ __all__ = [
|
||||
]
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from .combobox import QColormapComboBox
|
||||
from .spinbox._quantity import QQuantity
|
||||
from .combobox import QColormapComboBox # noqa: TCH004
|
||||
from .spinbox._quantity import QQuantity # noqa: TCH004
|
||||
|
||||
|
||||
def __getattr__(name: str) -> Any:
|
||||
|
@@ -4,7 +4,6 @@ from typing import TYPE_CHECKING, Container
|
||||
|
||||
from cmap import Colormap
|
||||
from qtpy.QtCore import Qt, Signal
|
||||
from qtpy.QtGui import QKeyEvent
|
||||
from qtpy.QtWidgets import QComboBox, QCompleter, QWidget
|
||||
|
||||
from ._cmap_item_delegate import QColormapItemDelegate
|
||||
@@ -13,6 +12,7 @@ from ._cmap_utils import try_cast_colormap
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from cmap._catalog import Category, Interpolation
|
||||
from qtpy.QtGui import QKeyEvent
|
||||
|
||||
|
||||
class CmapCatalogComboBox(QComboBox):
|
||||
|
@@ -1,14 +1,16 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import cast
|
||||
from typing import TYPE_CHECKING, cast
|
||||
|
||||
from cmap import Colormap
|
||||
from qtpy.QtCore import QModelIndex, QObject, QPersistentModelIndex, QRect, QSize, Qt
|
||||
from qtpy.QtGui import QColor, QPainter
|
||||
from qtpy.QtWidgets import QStyle, QStyledItemDelegate, QStyleOptionViewItem
|
||||
|
||||
from ._cmap_utils import CMAP_ROLE, draw_colormap, pick_font_color, try_cast_colormap
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from cmap import Colormap
|
||||
|
||||
DEFAULT_SIZE = QSize(80, 22)
|
||||
DEFAULT_BORDER_COLOR = QColor(Qt.GlobalColor.transparent)
|
||||
|
||||
|
@@ -1,12 +1,16 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from cmap import Colormap
|
||||
from qtpy.QtCore import Qt
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
from qtpy.QtCore import QRect, Qt
|
||||
from qtpy.QtGui import QIcon, QPainter, QPaintEvent, QPalette
|
||||
from qtpy.QtWidgets import QApplication, QLineEdit, QStyle, QWidget
|
||||
|
||||
from ._cmap_utils import draw_colormap, pick_font_color, try_cast_colormap
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from cmap import Colormap
|
||||
|
||||
MISSING = QStyle.StandardPixmap.SP_TitleBarContextHelpButton
|
||||
|
||||
|
||||
@@ -99,6 +103,19 @@ class QColormapLineEdit(QLineEdit):
|
||||
def _cmap_is_full_width(self):
|
||||
return self._colormap_fraction >= 0.75
|
||||
|
||||
def _cmap_rect(self) -> QRect:
|
||||
cmap_rect = self.rect().adjusted(2, 0, 0, 0)
|
||||
cmap_rect.setWidth(int(cmap_rect.width() * self._colormap_fraction))
|
||||
return cmap_rect
|
||||
|
||||
def resizeEvent(self, e: Any) -> None:
|
||||
left_margin = 6
|
||||
if not self._cmap_is_full_width():
|
||||
# leave room for the colormap
|
||||
left_margin += self._cmap_rect().width()
|
||||
self.setTextMargins(left_margin, 2, 0, 0)
|
||||
super().resizeEvent(e)
|
||||
|
||||
def paintEvent(self, e: QPaintEvent) -> None:
|
||||
# don't draw the background
|
||||
# otherwise it will cover the colormap during super().paintEvent
|
||||
@@ -108,15 +125,7 @@ class QColormapLineEdit(QLineEdit):
|
||||
palette.setColor(palette.ColorRole.Base, Qt.GlobalColor.transparent)
|
||||
self.setPalette(palette)
|
||||
|
||||
cmap_rect = self.rect().adjusted(2, 0, 0, 0)
|
||||
cmap_rect.setWidth(int(cmap_rect.width() * self._colormap_fraction))
|
||||
|
||||
left_margin = 6
|
||||
if not self._cmap_is_full_width():
|
||||
# leave room for the colormap
|
||||
left_margin += cmap_rect.width()
|
||||
self.setTextMargins(left_margin, 2, 0, 0)
|
||||
|
||||
cmap_rect = self._cmap_rect()
|
||||
if self._cmap:
|
||||
draw_colormap(
|
||||
self, self._cmap, cmap_rect, checkerboard_size=self._checkerboard_size
|
||||
|
@@ -56,12 +56,14 @@ def draw_colormap(
|
||||
from qtpy.QtWidgets import QWidget
|
||||
from superqt.utils import draw_colormap
|
||||
|
||||
viridis = 'viridis' # or cmap.Colormap('viridis')
|
||||
viridis = "viridis" # or cmap.Colormap('viridis')
|
||||
|
||||
|
||||
class W(QWidget):
|
||||
def paintEvent(self, event) -> None:
|
||||
draw_colormap(self, viridis, event.rect())
|
||||
|
||||
|
||||
# or draw onto a QPixmap
|
||||
pm = QPixmap(200, 200)
|
||||
draw_colormap(pm, viridis)
|
||||
|
@@ -1,5 +1,6 @@
|
||||
"""A collapsible widget to hide and unhide child widgets."""
|
||||
from typing import Optional, Union
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from qtpy.QtCore import (
|
||||
QEasingCurve,
|
||||
@@ -12,7 +13,7 @@ from qtpy.QtCore import (
|
||||
Signal,
|
||||
)
|
||||
from qtpy.QtGui import QIcon, QPainter, QPalette, QPixmap
|
||||
from qtpy.QtWidgets import QFrame, QPushButton, QVBoxLayout, QWidget
|
||||
from qtpy.QtWidgets import QFrame, QPushButton, QSizePolicy, QVBoxLayout, QWidget
|
||||
|
||||
|
||||
class QCollapsible(QFrame):
|
||||
@@ -28,9 +29,9 @@ class QCollapsible(QFrame):
|
||||
def __init__(
|
||||
self,
|
||||
title: str = "",
|
||||
parent: Optional[QWidget] = None,
|
||||
expandedIcon: Optional[Union[QIcon, str]] = "▼",
|
||||
collapsedIcon: Optional[Union[QIcon, str]] = "▲",
|
||||
parent: QWidget | None = None,
|
||||
expandedIcon: QIcon | str | None = "▼",
|
||||
collapsedIcon: QIcon | str | None = "▲",
|
||||
):
|
||||
super().__init__(parent)
|
||||
self._locked = False
|
||||
@@ -41,13 +42,15 @@ class QCollapsible(QFrame):
|
||||
self._toggle_btn.setCheckable(True)
|
||||
self.setCollapsedIcon(icon=collapsedIcon)
|
||||
self.setExpandedIcon(icon=expandedIcon)
|
||||
self.setSizePolicy(QSizePolicy.Policy.Preferred, QSizePolicy.Policy.Maximum)
|
||||
|
||||
self._toggle_btn.setStyleSheet("text-align: left; border: none; outline: none;")
|
||||
self._toggle_btn.toggled.connect(self._toggle)
|
||||
|
||||
# frame layout
|
||||
self.setLayout(QVBoxLayout())
|
||||
self.layout().setAlignment(Qt.AlignmentFlag.AlignTop)
|
||||
self.layout().addWidget(self._toggle_btn)
|
||||
layout = QVBoxLayout(self)
|
||||
layout.setAlignment(Qt.AlignmentFlag.AlignTop)
|
||||
layout.addWidget(self._toggle_btn)
|
||||
|
||||
# Create animators
|
||||
self._animation = QPropertyAnimation(self)
|
||||
@@ -64,6 +67,10 @@ class QCollapsible(QFrame):
|
||||
_content.layout().setContentsMargins(QMargins(5, 0, 0, 0))
|
||||
self.setContent(_content)
|
||||
|
||||
def toggleButton(self) -> QPushButton:
|
||||
"""Return the toggle button."""
|
||||
return self._toggle_btn
|
||||
|
||||
def setText(self, text: str) -> None:
|
||||
"""Set the text of the toggle button."""
|
||||
self._toggle_btn.setText(text)
|
||||
@@ -98,7 +105,7 @@ class QCollapsible(QFrame):
|
||||
"""Returns the icon used when the widget is expanded."""
|
||||
return self._expanded_icon
|
||||
|
||||
def setExpandedIcon(self, icon: Optional[Union[QIcon, str]] = None) -> None:
|
||||
def setExpandedIcon(self, icon: QIcon | str | None = None) -> None:
|
||||
"""Set the icon on the toggle button when the widget is expanded."""
|
||||
if icon and isinstance(icon, QIcon):
|
||||
self._expanded_icon = icon
|
||||
@@ -112,7 +119,7 @@ class QCollapsible(QFrame):
|
||||
"""Returns the icon used when the widget is collapsed."""
|
||||
return self._collapsed_icon
|
||||
|
||||
def setCollapsedIcon(self, icon: Optional[Union[QIcon, str]] = None) -> None:
|
||||
def setCollapsedIcon(self, icon: QIcon | str | None = None) -> None:
|
||||
"""Set the icon on the toggle button when the widget is collapsed."""
|
||||
if icon and isinstance(icon, QIcon):
|
||||
self._collapsed_icon = icon
|
||||
@@ -126,7 +133,7 @@ class QCollapsible(QFrame):
|
||||
"""Set duration of the collapse/expand animation."""
|
||||
self._animation.setDuration(msecs)
|
||||
|
||||
def setEasingCurve(self, easing: QEasingCurve) -> None:
|
||||
def setEasingCurve(self, easing: QEasingCurve | QEasingCurve.Type) -> None:
|
||||
"""Set the easing curve for the collapse/expand animation."""
|
||||
self._animation.setEasingCurve(easing)
|
||||
|
||||
|
@@ -13,7 +13,7 @@ __all__ = (
|
||||
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from superqt.cmap import QColormapComboBox
|
||||
from superqt.cmap import QColormapComboBox # noqa: TCH004
|
||||
|
||||
|
||||
def __getattr__(name: str) -> Any: # pragma: no cover
|
||||
|
@@ -10,6 +10,7 @@ __all__ = [
|
||||
"IconFontMeta",
|
||||
"IconOpts",
|
||||
"pulse",
|
||||
"QIconifyIcon",
|
||||
"setTextIcon",
|
||||
"spin",
|
||||
]
|
||||
@@ -103,7 +104,7 @@ def icon(
|
||||
plugin is installed)
|
||||
|
||||
>>> btn = QPushButton()
|
||||
>>> btn.setIcon(icon('fa5s.smile'))
|
||||
>>> btn.setIcon(icon("fa5s.smile"))
|
||||
|
||||
can also directly import from fonticon_fa5
|
||||
>>> from fonticon_fa5 import FA5S
|
||||
@@ -129,7 +130,7 @@ def icon(
|
||||
... "disabled": {
|
||||
... "color": "green",
|
||||
... "scale_factor": 0.8,
|
||||
... "animation": spin(btn)
|
||||
... "animation": spin(btn),
|
||||
... },
|
||||
... },
|
||||
... )
|
||||
|
@@ -4,7 +4,7 @@ import warnings
|
||||
from collections import abc, defaultdict
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import ClassVar, DefaultDict, Sequence, Tuple, Union, cast
|
||||
from typing import TYPE_CHECKING, ClassVar, DefaultDict, Sequence, Tuple, Union, cast
|
||||
|
||||
from qtpy import QT_VERSION
|
||||
from qtpy.QtCore import QObject, QPoint, QRect, QSize, Qt
|
||||
@@ -25,7 +25,8 @@ from typing_extensions import TypedDict
|
||||
|
||||
from superqt.utils import QMessageHandler
|
||||
|
||||
from ._animations import Animation
|
||||
if TYPE_CHECKING:
|
||||
from ._animations import Animation
|
||||
|
||||
|
||||
class Unset:
|
||||
@@ -157,9 +158,9 @@ class _QFontIconEngine(QIconEngine):
|
||||
|
||||
def __init__(self, options: _IconOptions):
|
||||
super().__init__()
|
||||
self._opts: defaultdict[
|
||||
QIcon.State, dict[QIcon.Mode, _IconOptions | None]
|
||||
] = DefaultDict(dict)
|
||||
self._opts: defaultdict[QIcon.State, dict[QIcon.Mode, _IconOptions | None]] = (
|
||||
DefaultDict(dict)
|
||||
)
|
||||
self._opts[QIcon.State.Off][QIcon.Mode.Normal] = options
|
||||
self.update_hash()
|
||||
|
||||
|
148
src/superqt/iconify/__init__.py
Normal file
148
src/superqt/iconify/__init__.py
Normal file
@@ -0,0 +1,148 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import warnings
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from qtpy.QtCore import QSize, Qt
|
||||
from qtpy.QtGui import QIcon, QPainter, QPixmap
|
||||
from qtpy.QtWidgets import QApplication
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from typing import Literal
|
||||
|
||||
Flip = Literal["horizontal", "vertical", "horizontal,vertical"]
|
||||
Rotation = Literal["90", "180", "270", 90, 180, 270, "-90", 1, 2, 3]
|
||||
|
||||
try:
|
||||
from pyconify import svg_path
|
||||
except ModuleNotFoundError: # pragma: no cover
|
||||
svg_path = None
|
||||
|
||||
|
||||
class QIconifyIcon(QIcon):
|
||||
"""QIcon backed by an iconify icon.
|
||||
|
||||
Iconify includes 150,000+ icons from most major icon sets including Bootstrap,
|
||||
FontAwesome, Material Design, and many more.
|
||||
|
||||
Search availble icons at https://icon-sets.iconify.design
|
||||
Once you find one you like, use the key in the format `"prefix:name"` to create an
|
||||
icon: `QIconifyIcon("bi:bell")`.
|
||||
|
||||
This class is a thin wrapper around the
|
||||
[pyconify](https://github.com/pyapp-kit/pyconify) `svg_path` function. It pulls SVGs
|
||||
from iconify, creates a temporary SVG file and uses it as the source for a QIcon.
|
||||
SVGs are cached to disk, and persist across sessions (until `pyconify.clear_cache()`
|
||||
is called).
|
||||
|
||||
Parameters are the same as `QIconifyIcon.addKey`, which can be used to add
|
||||
additional icons for various modes and states to the same QIcon.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
*key: str
|
||||
Icon set prefix and name. May be passed as a single string in the format
|
||||
`"prefix:name"` or as two separate strings: `'prefix', 'name'`.
|
||||
color : str, optional
|
||||
Icon color. If not provided, the icon will appear black (the icon fill color
|
||||
will be set to the string "currentColor").
|
||||
flip : str, optional
|
||||
Flip icon. Must be one of "horizontal", "vertical", "horizontal,vertical"
|
||||
rotate : str | int, optional
|
||||
Rotate icon. Must be one of 0, 90, 180, 270,
|
||||
or 0, 1, 2, 3 (equivalent to 0, 90, 180, 270, respectively)
|
||||
dir : str, optional
|
||||
If 'dir' is not None, the file will be created in that directory, otherwise a
|
||||
default
|
||||
[directory](https://docs.python.org/3/library/tempfile.html#tempfile.mkstemp) is
|
||||
used.
|
||||
|
||||
Examples
|
||||
--------
|
||||
>>> from qtpy.QtWidgets import QPushButton
|
||||
>>> from superqt import QIconifyIcon
|
||||
>>> btn = QPushButton()
|
||||
>>> icon = QIconifyIcon("bi:alarm-fill", color="red", rotate=90)
|
||||
>>> btn.setIcon(icon)
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*key: str,
|
||||
color: str | None = None,
|
||||
flip: Flip | None = None,
|
||||
rotate: Rotation | None = None,
|
||||
dir: str | None = None,
|
||||
):
|
||||
if svg_path is None: # pragma: no cover
|
||||
raise ModuleNotFoundError(
|
||||
"pyconify is required to use QIconifyIcon. "
|
||||
"Please install it with `pip install pyconify` or use the "
|
||||
"`pip install superqt[iconify]` extra."
|
||||
)
|
||||
super().__init__()
|
||||
self.addKey(*key, color=color, flip=flip, rotate=rotate, dir=dir)
|
||||
|
||||
def addKey(
|
||||
self,
|
||||
*key: str,
|
||||
color: str | None = None,
|
||||
flip: Flip | None = None,
|
||||
rotate: Rotation | None = None,
|
||||
dir: str | None = None,
|
||||
size: QSize | None = None,
|
||||
mode: QIcon.Mode = QIcon.Mode.Normal,
|
||||
state: QIcon.State = QIcon.State.Off,
|
||||
) -> None:
|
||||
"""Add an icon to this QIcon.
|
||||
|
||||
This is a variant of `QIcon.addFile` that uses an iconify icon keys and
|
||||
arguments instead of a file path.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
*key: str
|
||||
Icon set prefix and name. May be passed as a single string in the format
|
||||
`"prefix:name"` or as two separate strings: `'prefix', 'name'`.
|
||||
color : str, optional
|
||||
Icon color. If not provided, the icon will appear black (the icon fill color
|
||||
will be set to the string "currentColor").
|
||||
flip : str, optional
|
||||
Flip icon. Must be one of "horizontal", "vertical", "horizontal,vertical"
|
||||
rotate : str | int, optional
|
||||
Rotate icon. Must be one of 0, 90, 180, 270, or 0, 1, 2, 3 (equivalent to 0,
|
||||
90, 180, 270, respectively)
|
||||
dir : str, optional
|
||||
If 'dir' is not None, the file will be created in that directory, otherwise
|
||||
a default
|
||||
[directory](https://docs.python.org/3/library/tempfile.html#tempfile.mkstemp)
|
||||
is used.
|
||||
size : QSize, optional
|
||||
Size specified for the icon, passed to `QIcon.addFile`.
|
||||
mode : QIcon.Mode, optional
|
||||
Mode specified for the icon, passed to `QIcon.addFile`.
|
||||
state : QIcon.State, optional
|
||||
State specified for the icon, passed to `QIcon.addFile`.
|
||||
"""
|
||||
try:
|
||||
path = svg_path(*key, color=color, flip=flip, rotate=rotate, dir=dir)
|
||||
except OSError:
|
||||
warnings.warn(
|
||||
f"Unable to connect to internet, and icon {key} not cached.",
|
||||
stacklevel=2,
|
||||
)
|
||||
self._draw_text_fallback(key)
|
||||
else:
|
||||
self.addFile(str(path), size or QSize(), mode, state)
|
||||
|
||||
def _draw_text_fallback(self, key: tuple[str, ...]) -> None:
|
||||
if style := QApplication.style():
|
||||
pixmap = style.standardPixmap(style.StandardPixmap.SP_MessageBoxQuestion)
|
||||
else:
|
||||
pixmap = QPixmap(18, 18)
|
||||
pixmap.fill(Qt.GlobalColor.transparent)
|
||||
painter = QPainter(pixmap)
|
||||
painter.drawText(pixmap.rect(), Qt.AlignmentFlag.AlignCenter, "?")
|
||||
painter.end()
|
||||
|
||||
self.addPixmap(pixmap)
|
@@ -13,6 +13,8 @@ warnings.warn(
|
||||
|
||||
# forward any requests for superqt.qtcompat.* to qtpy.*
|
||||
class SuperQtImporter(abc.MetaPathFinder):
|
||||
"""Pseudo-importer to forward superqt.qtcompat.* to qtpy.*."""
|
||||
|
||||
def find_spec(self, fullname: str, path, target=None): # type: ignore
|
||||
"""Forward any requests for superqt.qtcompat.* to qtpy.*."""
|
||||
if fullname.startswith(__name__):
|
||||
|
@@ -103,7 +103,7 @@ class _GenericRangeSlider(_GenericSlider):
|
||||
"""Show the bar between the first and last handle."""
|
||||
self.setBarVisible(True)
|
||||
|
||||
def applyMacStylePatch(self) -> str:
|
||||
def applyMacStylePatch(self) -> None:
|
||||
"""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.
|
||||
@@ -124,11 +124,27 @@ class _GenericRangeSlider(_GenericSlider):
|
||||
"""
|
||||
return tuple(float(i) for i in self._position)
|
||||
|
||||
def setSliderPosition(self, pos: Union[float, Sequence[float]], index=None) -> None:
|
||||
def setSliderPosition( # type: ignore
|
||||
self,
|
||||
pos: Union[float, Sequence[float]],
|
||||
index: Optional[int] = None,
|
||||
*,
|
||||
reversed: bool = False,
|
||||
) -> None:
|
||||
"""Set current position of the handles with a sequence of integers.
|
||||
|
||||
If `pos` is a sequence, it must have the same length as `value()`.
|
||||
If it is a scalar, index will be
|
||||
Parameters
|
||||
----------
|
||||
pos : Union[float, Sequence[float]]
|
||||
The new position of the slider handle(s). If a sequence, it must have the
|
||||
same length as `value()`. If it is a scalar, index will be used to set the
|
||||
position of the handle at that index.
|
||||
index : int | None
|
||||
The index of the handle to set the position of. If None, the "pressedIndex"
|
||||
will be used.
|
||||
reversed : bool
|
||||
Order in which to set the positions. Can be useful when setting multiple
|
||||
positions, to avoid intermediate overlapping values.
|
||||
"""
|
||||
if isinstance(pos, (list, tuple)):
|
||||
val_len = len(self.value())
|
||||
@@ -139,6 +155,9 @@ class _GenericRangeSlider(_GenericSlider):
|
||||
else:
|
||||
pairs = [(self._pressedIndex if index is None else index, pos)]
|
||||
|
||||
if reversed:
|
||||
pairs = pairs[::-1]
|
||||
|
||||
for idx, position in pairs:
|
||||
self._position[idx] = self._bound(position, idx)
|
||||
|
||||
@@ -222,7 +241,7 @@ class _GenericRangeSlider(_GenericSlider):
|
||||
offset = self.maximum() - ref[-1]
|
||||
elif ref[0] + offset < self.minimum():
|
||||
offset = self.minimum() - ref[0]
|
||||
self.setSliderPosition([i + offset for i in ref])
|
||||
self.setSliderPosition([i + offset for i in ref], reversed=offset > 0)
|
||||
|
||||
def _fixStyleOption(self, option):
|
||||
pass
|
||||
|
@@ -19,6 +19,7 @@ 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 TypeVar
|
||||
@@ -73,6 +74,7 @@ class _GenericSlider(QSlider):
|
||||
self._position: _T = 0.0
|
||||
self._singleStep = 1.0
|
||||
self._offsetAccumulated = 0.0
|
||||
self._inverted_appearance = False
|
||||
self._blocktracking = False
|
||||
self._tickInterval = 0.0
|
||||
self._pressedControl = SC_NONE
|
||||
@@ -97,7 +99,7 @@ class _GenericSlider(QSlider):
|
||||
if USE_MAC_SLIDER_PATCH:
|
||||
self.applyMacStylePatch()
|
||||
|
||||
def applyMacStylePatch(self) -> str:
|
||||
def applyMacStylePatch(self) -> None:
|
||||
"""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.
|
||||
@@ -173,6 +175,13 @@ class _GenericSlider(QSlider):
|
||||
self._tickInterval = max(0.0, ts)
|
||||
self.update()
|
||||
|
||||
def invertedAppearance(self) -> bool:
|
||||
return self._inverted_appearance
|
||||
|
||||
def setInvertedAppearance(self, inverted: bool) -> None:
|
||||
self._inverted_appearance = inverted
|
||||
self.update()
|
||||
|
||||
def triggerAction(self, action: QSlider.SliderAction) -> None:
|
||||
self._blocktracking = True
|
||||
# other actions here
|
||||
@@ -192,9 +201,8 @@ class _GenericSlider(QSlider):
|
||||
if self.orientation() == Qt.Orientation.Horizontal
|
||||
else not self.invertedAppearance()
|
||||
)
|
||||
option.direction = (
|
||||
Qt.LayoutDirection.LeftToRight
|
||||
) # we use the upsideDown option instead
|
||||
# we use the upsideDown option instead
|
||||
option.direction = Qt.LayoutDirection.LeftToRight
|
||||
# option.sliderValue = self._value # type: ignore
|
||||
# option.singleStep = self._singleStep # type: ignore
|
||||
if self.orientation() == Qt.Orientation.Horizontal:
|
||||
@@ -334,8 +342,12 @@ class _GenericSlider(QSlider):
|
||||
option.sliderValue = self._to_qinteger_space(self._value - self._minimum)
|
||||
|
||||
def _to_qinteger_space(self, val, _max=None):
|
||||
"""Converts a value to the internal integer space."""
|
||||
_max = _max or self.MAX_DISPLAY
|
||||
return int(min(QOVERFLOW, val / (self._maximum - self._minimum) * _max))
|
||||
range_ = self._maximum - self._minimum
|
||||
if range_ == 0:
|
||||
return self._minimum
|
||||
return int(min(QOVERFLOW, val / range_ * _max))
|
||||
|
||||
def _pick(self, pt: QPoint) -> int:
|
||||
return pt.x() if self.orientation() == Qt.Orientation.Horizontal else pt.y()
|
||||
|
@@ -3,13 +3,13 @@ from __future__ import annotations
|
||||
import contextlib
|
||||
from enum import IntEnum, IntFlag, auto
|
||||
from functools import partial
|
||||
from typing import Any, overload
|
||||
from typing import Any, Iterable, overload
|
||||
|
||||
from qtpy.QtCore import QPoint, QSize, Qt, Signal
|
||||
from qtpy import QtGui
|
||||
from qtpy.QtCore import Property, QPoint, QSize, Qt, Signal
|
||||
from qtpy.QtGui import QFontMetrics, QValidator
|
||||
from qtpy.QtWidgets import (
|
||||
QAbstractSlider,
|
||||
QApplication,
|
||||
QBoxLayout,
|
||||
QDoubleSpinBox,
|
||||
QHBoxLayout,
|
||||
@@ -32,6 +32,7 @@ class LabelPosition(IntEnum):
|
||||
LabelsBelow = auto()
|
||||
LabelsRight = LabelsAbove
|
||||
LabelsLeft = LabelsBelow
|
||||
LabelsOnHandle = auto()
|
||||
|
||||
|
||||
class EdgeLabelMode(IntFlag):
|
||||
@@ -43,10 +44,10 @@ class EdgeLabelMode(IntFlag):
|
||||
class _SliderProxy:
|
||||
_slider: QSlider
|
||||
|
||||
def value(self) -> int:
|
||||
def value(self) -> Any:
|
||||
return self._slider.value()
|
||||
|
||||
def setValue(self, value: int) -> None:
|
||||
def setValue(self, value: Any) -> None:
|
||||
self._slider.setValue(value)
|
||||
|
||||
def sliderPosition(self) -> int:
|
||||
@@ -94,6 +95,36 @@ class _SliderProxy:
|
||||
def setTickPosition(self, pos: QSlider.TickPosition) -> None:
|
||||
self._slider.setTickPosition(pos)
|
||||
|
||||
def triggerAction(self, action: QAbstractSlider.SliderAction) -> None:
|
||||
return self._slider.triggerAction(action)
|
||||
|
||||
def invertedControls(self) -> bool:
|
||||
return self._slider.invertedControls()
|
||||
|
||||
def setInvertedControls(self, a0: bool) -> None:
|
||||
return self._slider.setInvertedControls(a0)
|
||||
|
||||
def invertedAppearance(self) -> bool:
|
||||
return self._slider.invertedAppearance()
|
||||
|
||||
def setInvertedAppearance(self, a0: bool) -> None:
|
||||
return self._slider.setInvertedAppearance(a0)
|
||||
|
||||
def isSliderDown(self) -> bool:
|
||||
return self._slider.isSliderDown()
|
||||
|
||||
def setSliderDown(self, a0: bool) -> None:
|
||||
return self._slider.setSliderDown(a0)
|
||||
|
||||
def hasTracking(self) -> bool:
|
||||
return self._slider.hasTracking()
|
||||
|
||||
def setTracking(self, enable: bool) -> None:
|
||||
return self._slider.setTracking(enable)
|
||||
|
||||
def orientation(self) -> Qt.Orientation:
|
||||
return self._slider.orientation()
|
||||
|
||||
def __getattr__(self, name: Any) -> Any:
|
||||
return getattr(self._slider, name)
|
||||
|
||||
@@ -128,19 +159,20 @@ def _handle_overloaded_slider_sig(
|
||||
|
||||
class QLabeledSlider(_SliderProxy, QAbstractSlider):
|
||||
editingFinished = Signal()
|
||||
_ivalueChanged = Signal(int)
|
||||
_isliderMoved = Signal(int)
|
||||
_irangeChanged = Signal(int, int)
|
||||
|
||||
_slider_class = QSlider
|
||||
_slider: QSlider
|
||||
|
||||
@overload
|
||||
def __init__(self, parent: QWidget | None = ...) -> None:
|
||||
...
|
||||
def __init__(self, parent: QWidget | None = ...) -> None: ...
|
||||
|
||||
@overload
|
||||
def __init__(
|
||||
self, orientation: Qt.Orientation, parent: QWidget | None = ...
|
||||
) -> None:
|
||||
...
|
||||
) -> None: ...
|
||||
|
||||
def __init__(self, *args: Any, **kwargs: Any) -> None:
|
||||
parent, orientation = _handle_overloaded_slider_sig(args, kwargs)
|
||||
@@ -229,8 +261,6 @@ class QLabeledSlider(_SliderProxy, QAbstractSlider):
|
||||
self.layout().setContentsMargins(0, 0, 0, 0)
|
||||
self._on_slider_range_changed(self.minimum(), self.maximum())
|
||||
|
||||
QApplication.processEvents()
|
||||
|
||||
# putting this after labelMode methods for the sake of mypy
|
||||
EdgeLabelMode = EdgeLabelMode
|
||||
|
||||
@@ -251,8 +281,9 @@ class QLabeledSlider(_SliderProxy, QAbstractSlider):
|
||||
self._slider.setValue(int(value))
|
||||
|
||||
def _rename_signals(self) -> None:
|
||||
# for subclasses
|
||||
pass
|
||||
self.valueChanged = self._ivalueChanged
|
||||
self.sliderMoved = self._isliderMoved
|
||||
self.rangeChanged = self._irangeChanged
|
||||
|
||||
|
||||
class QLabeledDoubleSlider(QLabeledSlider):
|
||||
@@ -263,14 +294,12 @@ class QLabeledDoubleSlider(QLabeledSlider):
|
||||
_frangeChanged = Signal(float, float)
|
||||
|
||||
@overload
|
||||
def __init__(self, parent: QWidget | None = ...) -> None:
|
||||
...
|
||||
def __init__(self, parent: QWidget | None = ...) -> None: ...
|
||||
|
||||
@overload
|
||||
def __init__(
|
||||
self, orientation: Qt.Orientation, parent: QWidget | None = ...
|
||||
) -> None:
|
||||
...
|
||||
) -> None: ...
|
||||
|
||||
def __init__(self, *args: Any, **kwargs: Any) -> None:
|
||||
super().__init__(*args, **kwargs)
|
||||
@@ -294,20 +323,20 @@ class QLabeledDoubleSlider(QLabeledSlider):
|
||||
|
||||
class QLabeledRangeSlider(_SliderProxy, QAbstractSlider):
|
||||
_valueChanged = Signal(tuple)
|
||||
_sliderPressed = Signal()
|
||||
_sliderReleased = Signal()
|
||||
editingFinished = Signal()
|
||||
|
||||
_slider_class = QRangeSlider
|
||||
_slider: QRangeSlider
|
||||
|
||||
@overload
|
||||
def __init__(self, parent: QWidget | None = ...) -> None:
|
||||
...
|
||||
def __init__(self, parent: QWidget | None = ...) -> None: ...
|
||||
|
||||
@overload
|
||||
def __init__(
|
||||
self, orientation: Qt.Orientation, parent: QWidget | None = ...
|
||||
) -> None:
|
||||
...
|
||||
) -> None: ...
|
||||
|
||||
def __init__(self, *args: Any, **kwargs: Any) -> None:
|
||||
parent, orientation = _handle_overloaded_slider_sig(args, kwargs)
|
||||
@@ -315,7 +344,7 @@ class QLabeledRangeSlider(_SliderProxy, QAbstractSlider):
|
||||
self._rename_signals()
|
||||
|
||||
self.setAttribute(Qt.WidgetAttribute.WA_ShowWithoutActivating)
|
||||
self._handle_labels = []
|
||||
self._handle_labels: list[SliderLabel] = []
|
||||
self._handle_label_position: LabelPosition = LabelPosition.LabelsAbove
|
||||
|
||||
# for fine tuning label position
|
||||
@@ -324,6 +353,8 @@ class QLabeledRangeSlider(_SliderProxy, QAbstractSlider):
|
||||
|
||||
self._slider = self._slider_class()
|
||||
self._slider.valueChanged.connect(self.valueChanged.emit)
|
||||
self._slider.sliderPressed.connect(self.sliderPressed.emit)
|
||||
self._slider.sliderReleased.connect(self.sliderReleased.emit)
|
||||
self._slider.rangeChanged.connect(self.rangeChanged.emit)
|
||||
self.sliderMoved = self._slider._slidersMoved
|
||||
|
||||
@@ -358,10 +389,10 @@ class QLabeledRangeSlider(_SliderProxy, QAbstractSlider):
|
||||
"""Set where/whether labels are shown adjacent to slider handles."""
|
||||
self._handle_label_position = opt
|
||||
for lbl in self._handle_labels:
|
||||
if not opt:
|
||||
lbl.hide()
|
||||
else:
|
||||
lbl.show()
|
||||
lbl.setVisible(bool(opt))
|
||||
trans = opt == LabelPosition.LabelsOnHandle
|
||||
# TODO: make double clickable to edit
|
||||
lbl.setAttribute(Qt.WidgetAttribute.WA_TransparentForMouseEvents, trans)
|
||||
self.setOrientation(self.orientation())
|
||||
|
||||
def edgeLabelMode(self) -> EdgeLabelMode:
|
||||
@@ -387,27 +418,33 @@ class QLabeledRangeSlider(_SliderProxy, QAbstractSlider):
|
||||
elif opt == EdgeLabelMode.LabelIsRange:
|
||||
self._min_label.setValue(self._slider.minimum())
|
||||
self._max_label.setValue(self._slider.maximum())
|
||||
QApplication.processEvents()
|
||||
self._reposition_labels()
|
||||
|
||||
def setRange(self, min: int, max: int) -> None:
|
||||
self._on_range_changed(min, max)
|
||||
|
||||
def _add_labels(self, layout: QBoxLayout, inverted: bool = False) -> None:
|
||||
if inverted:
|
||||
first, second = self._max_label, self._min_label
|
||||
else:
|
||||
first, second = self._min_label, self._max_label
|
||||
layout.addWidget(first)
|
||||
layout.addWidget(self._slider)
|
||||
layout.addWidget(second)
|
||||
|
||||
def setOrientation(self, orientation: Qt.Orientation) -> None:
|
||||
"""Set orientation, value will be 'horizontal' or 'vertical'."""
|
||||
self._slider.setOrientation(orientation)
|
||||
inverted = self._slider.invertedAppearance()
|
||||
marg = (0, 0, 0, 0)
|
||||
if orientation == Qt.Orientation.Vertical:
|
||||
layout: QBoxLayout = QVBoxLayout()
|
||||
layout.setSpacing(1)
|
||||
layout.addWidget(self._max_label)
|
||||
layout.addWidget(self._slider)
|
||||
layout.addWidget(self._min_label)
|
||||
self._add_labels(layout, inverted=not inverted)
|
||||
# TODO: set margins based on label width
|
||||
if self._handle_label_position == LabelPosition.LabelsLeft:
|
||||
marg = (30, 0, 0, 0)
|
||||
elif self._handle_label_position == LabelPosition.NoLabel:
|
||||
marg = (0, 0, 0, 0)
|
||||
else:
|
||||
elif self._handle_label_position == LabelPosition.LabelsRight:
|
||||
marg = (0, 0, 20, 0)
|
||||
layout.setAlignment(Qt.AlignmentFlag.AlignCenter)
|
||||
else:
|
||||
@@ -415,13 +452,9 @@ class QLabeledRangeSlider(_SliderProxy, QAbstractSlider):
|
||||
layout.setSpacing(7)
|
||||
if self._handle_label_position == LabelPosition.LabelsBelow:
|
||||
marg = (0, 0, 0, 25)
|
||||
elif self._handle_label_position == LabelPosition.NoLabel:
|
||||
marg = (0, 0, 0, 0)
|
||||
else:
|
||||
elif self._handle_label_position == LabelPosition.LabelsAbove:
|
||||
marg = (0, 25, 0, 0)
|
||||
layout.addWidget(self._min_label)
|
||||
layout.addWidget(self._slider)
|
||||
layout.addWidget(self._max_label)
|
||||
self._add_labels(layout, inverted=inverted)
|
||||
|
||||
# remove old layout
|
||||
old_layout = self.layout()
|
||||
@@ -431,10 +464,13 @@ class QLabeledRangeSlider(_SliderProxy, QAbstractSlider):
|
||||
self.setLayout(layout)
|
||||
layout.setContentsMargins(*marg)
|
||||
super().setOrientation(orientation)
|
||||
QApplication.processEvents()
|
||||
self._reposition_labels()
|
||||
|
||||
def resizeEvent(self, a0) -> None:
|
||||
def setInvertedAppearance(self, a0: bool) -> None:
|
||||
self._slider.setInvertedAppearance(a0)
|
||||
self.setOrientation(self._slider.orientation())
|
||||
|
||||
def resizeEvent(self, a0: Any) -> None:
|
||||
super().resizeEvent(a0)
|
||||
self._reposition_labels()
|
||||
|
||||
@@ -442,9 +478,20 @@ class QLabeledRangeSlider(_SliderProxy, QAbstractSlider):
|
||||
LabelPosition = LabelPosition
|
||||
EdgeLabelMode = EdgeLabelMode
|
||||
|
||||
def _getBarColor(self) -> QtGui.QBrush:
|
||||
return self._slider._style.brush(self._slider._styleOption)
|
||||
|
||||
def _setBarColor(self, color: str) -> None:
|
||||
self._slider._style.brush_active = color
|
||||
|
||||
barColor = Property(QtGui.QBrush, _getBarColor, _setBarColor)
|
||||
"""The color of the bar between the first and last handle."""
|
||||
|
||||
# ------------- private methods ----------------
|
||||
def _rename_signals(self) -> None:
|
||||
self.valueChanged = self._valueChanged
|
||||
self.sliderReleased = self._sliderReleased
|
||||
self.sliderPressed = self._sliderPressed
|
||||
|
||||
def _reposition_labels(self) -> None:
|
||||
if (
|
||||
@@ -455,17 +502,26 @@ class QLabeledRangeSlider(_SliderProxy, QAbstractSlider):
|
||||
|
||||
horizontal = self.orientation() == Qt.Orientation.Horizontal
|
||||
labels_above = self._handle_label_position == LabelPosition.LabelsAbove
|
||||
labels_on_handle = self._handle_label_position == LabelPosition.LabelsOnHandle
|
||||
|
||||
last_edge = None
|
||||
for i, label in enumerate(self._handle_labels):
|
||||
labels: Iterable[tuple[int, SliderLabel]] = enumerate(self._handle_labels)
|
||||
if self._slider.invertedAppearance():
|
||||
labels = reversed(list(labels))
|
||||
for i, label in labels:
|
||||
rect = self._slider._handleRect(i)
|
||||
dx = -label.width() / 2
|
||||
dx = (-label.width() / 2) + 2
|
||||
dy = -label.height() / 2
|
||||
if labels_above:
|
||||
if labels_above: # or on the right
|
||||
if horizontal:
|
||||
dy *= 3
|
||||
else:
|
||||
dx *= -1
|
||||
elif labels_on_handle:
|
||||
if horizontal:
|
||||
dy += 0.5
|
||||
else:
|
||||
dx += 0.5
|
||||
else:
|
||||
if horizontal:
|
||||
dy *= -1
|
||||
@@ -482,6 +538,7 @@ class QLabeledRangeSlider(_SliderProxy, QAbstractSlider):
|
||||
label.move(pos)
|
||||
last_edge = pos
|
||||
label.clearFocus()
|
||||
label.raise_()
|
||||
label.show()
|
||||
self.update()
|
||||
|
||||
@@ -545,14 +602,12 @@ class QLabeledDoubleRangeSlider(QLabeledRangeSlider):
|
||||
_frangeChanged = Signal(float, float)
|
||||
|
||||
@overload
|
||||
def __init__(self, parent: QWidget | None = ...) -> None:
|
||||
...
|
||||
def __init__(self, parent: QWidget | None = ...) -> None: ...
|
||||
|
||||
@overload
|
||||
def __init__(
|
||||
self, orientation: Qt.Orientation, parent: QWidget | None = ...
|
||||
) -> None:
|
||||
...
|
||||
) -> None: ...
|
||||
|
||||
def __init__(self, *args: Any, **kwargs: Any) -> None:
|
||||
super().__init__(*args, **kwargs)
|
||||
@@ -571,6 +626,15 @@ class QLabeledDoubleRangeSlider(QLabeledRangeSlider):
|
||||
for lbl in self._handle_labels:
|
||||
lbl.setDecimals(prec)
|
||||
|
||||
def _getBarColor(self) -> QtGui.QBrush:
|
||||
return self._slider._style.brush(self._slider._styleOption)
|
||||
|
||||
def _setBarColor(self, color: str) -> None:
|
||||
self._slider._style.brush_active = color
|
||||
|
||||
barColor = Property(QtGui.QBrush, _getBarColor, _setBarColor)
|
||||
"""The color of the bar between the first and last handle."""
|
||||
|
||||
|
||||
class SliderLabel(QDoubleSpinBox):
|
||||
def __init__(
|
||||
|
@@ -5,7 +5,6 @@ import re
|
||||
from dataclasses import dataclass, replace
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from qtpy import QT_VERSION
|
||||
from qtpy.QtCore import Qt
|
||||
from qtpy.QtGui import (
|
||||
QBrush,
|
||||
@@ -140,8 +139,9 @@ CATALINA_STYLE = replace(
|
||||
tick_offset=4,
|
||||
)
|
||||
|
||||
if QT_VERSION and int(QT_VERSION.split(".")[0]) == 6:
|
||||
CATALINA_STYLE = replace(CATALINA_STYLE, tick_offset=2)
|
||||
# I can no longer reproduce the cases in which this was necessary
|
||||
# if QT_VERSION and int(QT_VERSION.split(".")[0]) == 6:
|
||||
# CATALINA_STYLE = replace(CATALINA_STYLE, tick_offset=2)
|
||||
|
||||
BIG_SUR_STYLE = replace(
|
||||
CATALINA_STYLE,
|
||||
@@ -155,8 +155,9 @@ BIG_SUR_STYLE = replace(
|
||||
tick_bar_alpha=0.2,
|
||||
)
|
||||
|
||||
if QT_VERSION and int(QT_VERSION.split(".")[0]) == 6:
|
||||
BIG_SUR_STYLE = replace(BIG_SUR_STYLE, tick_offset=-3)
|
||||
# I can no longer reproduce the cases in which this was necessary
|
||||
# if QT_VERSION and int(QT_VERSION.split(".")[0]) == 6:
|
||||
# BIG_SUR_STYLE = replace(BIG_SUR_STYLE, tick_offset=-3)
|
||||
|
||||
WINDOWS_STYLE = replace(
|
||||
BASE_STYLE,
|
||||
@@ -229,7 +230,7 @@ rgba_pattern = re.compile(
|
||||
)
|
||||
|
||||
|
||||
def parse_color(color: str, default_attr) -> QColor | QGradient:
|
||||
def parse_color(color: str, default_attr: str) -> QColor | QGradient:
|
||||
qc = QColor(color)
|
||||
if qc.isValid():
|
||||
return qc
|
||||
@@ -241,6 +242,7 @@ def parse_color(color: str, default_attr) -> QColor | QGradient:
|
||||
|
||||
# try linear gradient:
|
||||
match = qlineargrad_pattern.search(color)
|
||||
grad: QGradient
|
||||
if match:
|
||||
grad = QLinearGradient(*(float(i) for i in match.groups()[:4]))
|
||||
grad.setColorAt(0, QColor(match.groupdict()["stop0"]))
|
||||
@@ -259,11 +261,11 @@ def parse_color(color: str, default_attr) -> QColor | QGradient:
|
||||
return QColor(getattr(SYSTEM_STYLE, default_attr))
|
||||
|
||||
|
||||
def update_styles_from_stylesheet(obj: _GenericRangeSlider):
|
||||
def update_styles_from_stylesheet(obj: _GenericRangeSlider) -> None:
|
||||
qss: str = obj.styleSheet()
|
||||
|
||||
parent = obj.parent()
|
||||
while parent is not None:
|
||||
while parent and hasattr(parent, "styleSheet"):
|
||||
qss = parent.styleSheet() + qss
|
||||
parent = parent.parent()
|
||||
qss = QApplication.instance().styleSheet() + qss
|
||||
|
@@ -65,14 +65,20 @@ class QLargeIntSpinBox(QAbstractSpinBox):
|
||||
|
||||
def setMinimum(self, min):
|
||||
self._minimum = int(min)
|
||||
if self._minimum > self._value:
|
||||
self.setValue(self._minimum)
|
||||
|
||||
def maximum(self):
|
||||
return self._maximum
|
||||
|
||||
def setMaximum(self, max):
|
||||
self._maximum = int(max)
|
||||
if self._maximum < self._value:
|
||||
self.setValue(self._maximum)
|
||||
|
||||
def setRange(self, minimum, maximum):
|
||||
if maximum < minimum:
|
||||
maximum = minimum
|
||||
self.setMinimum(minimum)
|
||||
self.setMaximum(maximum)
|
||||
|
||||
|
@@ -1,7 +1,7 @@
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from superqt.cmap import draw_colormap
|
||||
from superqt.cmap import draw_colormap # noqa: TCH004
|
||||
|
||||
__all__ = (
|
||||
"CodeSyntaxHighlight",
|
||||
|
@@ -1,5 +1,3 @@
|
||||
from itertools import takewhile
|
||||
|
||||
from pygments import highlight
|
||||
from pygments.formatter import Formatter
|
||||
from pygments.lexers import find_lexer_class, get_lexer_by_name
|
||||
@@ -68,21 +66,10 @@ class CodeSyntaxHighlight(QtGui.QSyntaxHighlighter):
|
||||
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 we need use `data` property to
|
||||
# work around this.
|
||||
highlight(text, self.lexer, self.formatter)
|
||||
for i in range(len(text)):
|
||||
try:
|
||||
self.setFormat(i, 1, self.formatter.data[p + i - enters])
|
||||
except IndexError: # pragma: no cover
|
||||
pass
|
||||
self.setFormat(i, 1, self.formatter.data[i])
|
||||
|
@@ -2,6 +2,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from concurrent.futures import Future
|
||||
from contextlib import suppress
|
||||
from functools import wraps
|
||||
from typing import TYPE_CHECKING, Any, Callable, ClassVar, overload
|
||||
|
||||
@@ -41,7 +42,8 @@ class CallCallable(QObject):
|
||||
def call(self):
|
||||
CallCallable.instances.remove(self)
|
||||
res = self._callable(*self._args, **self._kwargs)
|
||||
self.finished.emit(res)
|
||||
with suppress(RuntimeError):
|
||||
self.finished.emit(res)
|
||||
|
||||
|
||||
# fmt: off
|
||||
@@ -68,8 +70,6 @@ def ensure_main_thread(
|
||||
timeout: int = 1000,
|
||||
) -> Callable[P, Future[R]]: ...
|
||||
# fmt: on
|
||||
|
||||
|
||||
def ensure_main_thread(
|
||||
func: Callable | None = None, await_return: bool = False, timeout: int = 1000
|
||||
):
|
||||
@@ -132,8 +132,6 @@ def ensure_object_thread(
|
||||
timeout: int = 1000,
|
||||
) -> Callable[P, Future[R]]: ...
|
||||
# fmt: on
|
||||
|
||||
|
||||
def ensure_object_thread(
|
||||
func: Callable | None = None, await_return: bool = False, timeout: int = 1000
|
||||
):
|
||||
@@ -187,5 +185,5 @@ def _run_in_thread(
|
||||
f = CallCallable(func, args, kwargs)
|
||||
f.moveToThread(thread)
|
||||
f.finished.connect(future.set_result, Qt.ConnectionType.DirectConnection)
|
||||
QMetaObject.invokeMethod(f, "call", Qt.ConnectionType.QueuedConnection) # type: ignore # noqa
|
||||
QMetaObject.invokeMethod(f, "call", Qt.ConnectionType.QueuedConnection) # type: ignore
|
||||
return future.result(timeout=timeout / 1000) if await_return else future
|
||||
|
@@ -1,7 +1,6 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import traceback
|
||||
from contextlib import AbstractContextManager
|
||||
from typing import TYPE_CHECKING, cast
|
||||
|
||||
from qtpy.QtCore import Qt
|
||||
@@ -14,7 +13,7 @@ if TYPE_CHECKING:
|
||||
_DEFAULT_FLAGS = Qt.WindowType.Dialog | Qt.WindowType.MSWindowsFixedSizeDialogHint
|
||||
|
||||
|
||||
class exceptions_as_dialog(AbstractContextManager):
|
||||
class exceptions_as_dialog:
|
||||
"""Context manager that shows a dialog when an exception is raised.
|
||||
|
||||
See examples below for common usage patterns.
|
||||
@@ -66,8 +65,8 @@ class exceptions_as_dialog(AbstractContextManager):
|
||||
exception : BaseException | None
|
||||
Will hold the exception instance if an exception was raised and caught.
|
||||
|
||||
Examplez
|
||||
-------
|
||||
Examples
|
||||
--------
|
||||
```python
|
||||
from qtpy.QtWidgets import QApplication
|
||||
from superqt.utils import exceptions_as_dialog
|
||||
|
@@ -38,7 +38,7 @@ class QMessageHandler:
|
||||
|
||||
>>> logger = logging.getLogger(__name__)
|
||||
>>> with QMessageHandler(logger): # re-reoute Qt messages to a python logger.
|
||||
... ...
|
||||
... ...
|
||||
"""
|
||||
|
||||
_qt2loggertype: ClassVar[dict[QtMsgType, int]] = {
|
||||
|
@@ -23,16 +23,13 @@ if TYPE_CHECKING:
|
||||
|
||||
class SigInst(Generic[_T]):
|
||||
@staticmethod
|
||||
def connect(slot: Callable[[_T], Any], type: type | None = ...) -> None:
|
||||
...
|
||||
def connect(slot: Callable[[_T], Any], type: type | None = ...) -> None: ...
|
||||
|
||||
@staticmethod
|
||||
def disconnect(slot: Callable[[_T], Any] = ...) -> None:
|
||||
...
|
||||
def disconnect(slot: Callable[[_T], Any] = ...) -> None: ...
|
||||
|
||||
@staticmethod
|
||||
def emit(*args: _T) -> None:
|
||||
...
|
||||
def emit(*args: _T) -> None: ...
|
||||
|
||||
from typing_extensions import Literal, ParamSpec
|
||||
|
||||
@@ -52,7 +49,7 @@ _R = TypeVar("_R")
|
||||
|
||||
|
||||
def as_generator_function(
|
||||
func: Callable[_P, _R]
|
||||
func: Callable[_P, _R],
|
||||
) -> Callable[_P, Generator[None, None, _R]]:
|
||||
"""Turns a regular function (single return) into a generator function."""
|
||||
|
||||
@@ -211,7 +208,6 @@ class WorkerBase(QRunnable, Generic[_R]):
|
||||
--------
|
||||
```python
|
||||
class MyWorker(WorkerBase):
|
||||
|
||||
def work(self):
|
||||
i = 0
|
||||
while True:
|
||||
@@ -499,8 +495,7 @@ def create_worker(
|
||||
_worker_class: type[GeneratorWorker] | type[FunctionWorker] | None = None,
|
||||
_ignore_errors: bool = False,
|
||||
**kwargs,
|
||||
) -> GeneratorWorker[_Y, _S, _R]:
|
||||
...
|
||||
) -> GeneratorWorker[_Y, _S, _R]: ...
|
||||
|
||||
|
||||
@overload
|
||||
@@ -512,8 +507,7 @@ def create_worker(
|
||||
_worker_class: type[GeneratorWorker] | type[FunctionWorker] | None = None,
|
||||
_ignore_errors: bool = False,
|
||||
**kwargs,
|
||||
) -> FunctionWorker[_R]:
|
||||
...
|
||||
) -> FunctionWorker[_R]: ...
|
||||
|
||||
|
||||
def create_worker(
|
||||
@@ -574,8 +568,10 @@ def create_worker(
|
||||
```python
|
||||
def long_function(duration):
|
||||
import time
|
||||
|
||||
time.sleep(duration)
|
||||
|
||||
|
||||
worker = create_worker(long_function, 10)
|
||||
```
|
||||
"""
|
||||
@@ -630,8 +626,7 @@ def thread_worker(
|
||||
connect: dict[str, Callable | Sequence[Callable]] | None = None,
|
||||
worker_class: type[WorkerBase] | None = None,
|
||||
ignore_errors: bool = False,
|
||||
) -> Callable[_P, GeneratorWorker[_Y, _S, _R]]:
|
||||
...
|
||||
) -> Callable[_P, GeneratorWorker[_Y, _S, _R]]: ...
|
||||
|
||||
|
||||
@overload
|
||||
@@ -641,8 +636,7 @@ def thread_worker(
|
||||
connect: dict[str, Callable | Sequence[Callable]] | None = None,
|
||||
worker_class: type[WorkerBase] | None = None,
|
||||
ignore_errors: bool = False,
|
||||
) -> Callable[_P, FunctionWorker[_R]]:
|
||||
...
|
||||
) -> Callable[_P, FunctionWorker[_R]]: ...
|
||||
|
||||
|
||||
@overload
|
||||
@@ -652,8 +646,7 @@ def thread_worker(
|
||||
connect: dict[str, Callable | Sequence[Callable]] | None = None,
|
||||
worker_class: type[WorkerBase] | None = None,
|
||||
ignore_errors: bool = False,
|
||||
) -> Callable[[Callable], Callable[_P, FunctionWorker | GeneratorWorker]]:
|
||||
...
|
||||
) -> Callable[[Callable], Callable[_P, FunctionWorker | GeneratorWorker]]: ...
|
||||
|
||||
|
||||
def thread_worker(
|
||||
@@ -737,7 +730,8 @@ def thread_worker(
|
||||
yield i
|
||||
|
||||
# do teardown
|
||||
return 'anything'
|
||||
return "anything"
|
||||
|
||||
|
||||
# call the function to start running in another thread.
|
||||
worker = long_function()
|
||||
@@ -790,8 +784,7 @@ if TYPE_CHECKING:
|
||||
class WorkerProtocol(QObject):
|
||||
finished: Signal
|
||||
|
||||
def work(self) -> None:
|
||||
...
|
||||
def work(self) -> None: ...
|
||||
|
||||
|
||||
def new_worker_qthread(
|
||||
@@ -846,9 +839,7 @@ def new_worker_qthread(
|
||||
Create some QObject that has a long-running work method:
|
||||
|
||||
```python
|
||||
|
||||
class Worker(QObject):
|
||||
|
||||
finished = Signal()
|
||||
increment = Signal(int)
|
||||
|
||||
@@ -860,16 +851,18 @@ def new_worker_qthread(
|
||||
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',
|
||||
"argument",
|
||||
_start_thread=True,
|
||||
_connect={'increment': print},
|
||||
_connect={"increment": print},
|
||||
)
|
||||
```
|
||||
"""
|
||||
|
@@ -26,13 +26,18 @@ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import warnings
|
||||
from concurrent.futures import Future
|
||||
from contextlib import suppress
|
||||
from enum import IntFlag, auto
|
||||
from functools import wraps
|
||||
from typing import TYPE_CHECKING, Callable, Generic, TypeVar, overload
|
||||
from weakref import WeakKeyDictionary
|
||||
from inspect import signature
|
||||
from types import MethodType
|
||||
from typing import TYPE_CHECKING, Any, Callable, Generic, TypeVar, overload
|
||||
from weakref import WeakKeyDictionary, WeakMethod
|
||||
|
||||
from qtpy.QtCore import QObject, Qt, QTimer, Signal
|
||||
|
||||
@@ -52,6 +57,12 @@ else:
|
||||
P = TypeVar("P")
|
||||
|
||||
R = TypeVar("R")
|
||||
REF_ERROR = (
|
||||
"To use qthrottled or qdebounced as a method decorator, "
|
||||
"objects must have `__dict__` or be weak referenceable. "
|
||||
"Please either add `__weakref__` to `__slots__` or use"
|
||||
"qthrottled/qdebounced as a function (not a decorator)."
|
||||
)
|
||||
|
||||
|
||||
class Kind(IntFlag):
|
||||
@@ -156,7 +167,7 @@ class GenericSignalThrottler(QObject):
|
||||
self.triggered.emit()
|
||||
self._timer.start()
|
||||
|
||||
def _maybeEmitTriggered(self, restart_timer=True) -> None:
|
||||
def _maybeEmitTriggered(self, restart_timer: bool = True) -> None:
|
||||
if self._hasPendingEmission:
|
||||
self._emitTriggered()
|
||||
if not restart_timer:
|
||||
@@ -202,6 +213,26 @@ class QSignalDebouncer(GenericSignalThrottler):
|
||||
# below here part is unique to superqt (not from KD)
|
||||
|
||||
|
||||
def _weak_func(func: Callable[P, R]) -> Callable[P, R]:
|
||||
if isinstance(func, MethodType):
|
||||
# this is a bound method, we need to avoid strong references
|
||||
try:
|
||||
weak_method = WeakMethod(func)
|
||||
except TypeError as e:
|
||||
raise TypeError(REF_ERROR) from e
|
||||
|
||||
def weak_func(*args, **kwargs):
|
||||
if method := weak_method():
|
||||
return method(*args, **kwargs)
|
||||
warnings.warn(
|
||||
"Method has been garbage collected", RuntimeWarning, stacklevel=2
|
||||
)
|
||||
|
||||
return weak_func
|
||||
|
||||
return func
|
||||
|
||||
|
||||
class ThrottledCallable(GenericSignalThrottler, Generic[P, R]):
|
||||
def __init__(
|
||||
self,
|
||||
@@ -213,26 +244,32 @@ class ThrottledCallable(GenericSignalThrottler, Generic[P, R]):
|
||||
super().__init__(kind, emissionPolicy, parent)
|
||||
|
||||
self._future: Future[R] = Future()
|
||||
if isinstance(func, staticmethod):
|
||||
self._func = func.__func__
|
||||
else:
|
||||
self._func = func
|
||||
|
||||
self.__wrapped__ = func
|
||||
self._is_static_method: bool = False
|
||||
if isinstance(func, staticmethod):
|
||||
self._is_static_method = True
|
||||
func = func.__func__
|
||||
|
||||
max_args = get_max_args(func)
|
||||
with suppress(TypeError, ValueError):
|
||||
self.__signature__ = signature(func)
|
||||
|
||||
self._func = _weak_func(func)
|
||||
self.__wrapped__ = self._func
|
||||
|
||||
self._args: tuple = ()
|
||||
self._kwargs: dict = {}
|
||||
self.triggered.connect(self._set_future_result)
|
||||
self._name = None
|
||||
|
||||
self._obj_dkt = WeakKeyDictionary()
|
||||
self._obj_dkt: WeakKeyDictionary[Any, ThrottledCallable] = WeakKeyDictionary()
|
||||
|
||||
# even if we were to compile __call__ with a signature matching that of func,
|
||||
# PySide wouldn't correctly inspect the signature of the ThrottledCallable
|
||||
# instance: https://bugreports.qt.io/browse/PYSIDE-2423
|
||||
# so we do it ourselfs and limit the number of positional arguments
|
||||
# that we pass to func
|
||||
self._max_args: int | None = get_max_args(self._func)
|
||||
self._max_args: int | None = max_args
|
||||
|
||||
def __call__(self, *args: P.args, **kwargs: P.kwargs) -> "Future[R]": # noqa
|
||||
if not self._future.done():
|
||||
@@ -250,12 +287,18 @@ class ThrottledCallable(GenericSignalThrottler, Generic[P, R]):
|
||||
self._future.set_result(result)
|
||||
|
||||
def __set_name__(self, owner, name):
|
||||
if not isinstance(self.__wrapped__, staticmethod):
|
||||
if not self._is_static_method:
|
||||
self._name = name
|
||||
|
||||
def _get_throttler(self, instance, owner, parent, obj):
|
||||
def _get_throttler(self, instance, owner, parent, obj, name):
|
||||
try:
|
||||
bound_method = self._func.__get__(instance, owner)
|
||||
except Exception as e: # pragma: no cover
|
||||
raise RuntimeError(
|
||||
f"Failed to bind function {self._func!r} to object {instance!r}"
|
||||
) from e
|
||||
throttler = ThrottledCallable(
|
||||
self.__wrapped__.__get__(instance, owner),
|
||||
bound_method,
|
||||
self._kind,
|
||||
self._emissionPolicy,
|
||||
parent=parent,
|
||||
@@ -263,21 +306,12 @@ class ThrottledCallable(GenericSignalThrottler, Generic[P, R]):
|
||||
throttler.setTimerType(self.timerType())
|
||||
throttler.setTimeout(self.timeout())
|
||||
try:
|
||||
setattr(
|
||||
obj,
|
||||
self._name,
|
||||
throttler,
|
||||
)
|
||||
setattr(obj, name, throttler)
|
||||
except AttributeError:
|
||||
try:
|
||||
self._obj_dkt[obj] = throttler
|
||||
except TypeError as e:
|
||||
raise TypeError(
|
||||
"To use qthrottled or qdebounced as a method decorator, "
|
||||
"objects must have `__dict__` or be weak referenceable. "
|
||||
"Please either add `__weakref__` to `__slots__` or use"
|
||||
"qthrottled/qdebounced as a function (not a decorator)."
|
||||
) from e
|
||||
raise TypeError(REF_ERROR) from e
|
||||
return throttler
|
||||
|
||||
def __get__(self, instance, owner):
|
||||
@@ -291,7 +325,7 @@ class ThrottledCallable(GenericSignalThrottler, Generic[P, R]):
|
||||
if parent is None and isinstance(instance, QObject):
|
||||
parent = instance
|
||||
|
||||
return self._get_throttler(instance, owner, parent, instance)
|
||||
return self._get_throttler(instance, owner, parent, instance, self._name)
|
||||
|
||||
|
||||
@overload
|
||||
@@ -301,8 +335,7 @@ def qthrottled(
|
||||
leading: bool = True,
|
||||
timer_type: Qt.TimerType = Qt.TimerType.PreciseTimer,
|
||||
parent: QObject | None = None,
|
||||
) -> ThrottledCallable[P, R]:
|
||||
...
|
||||
) -> ThrottledCallable[P, R]: ...
|
||||
|
||||
|
||||
@overload
|
||||
@@ -312,8 +345,7 @@ def qthrottled(
|
||||
leading: bool = True,
|
||||
timer_type: Qt.TimerType = Qt.TimerType.PreciseTimer,
|
||||
parent: QObject | None = None,
|
||||
) -> Callable[[Callable[P, R]], ThrottledCallable[P, R]]:
|
||||
...
|
||||
) -> Callable[[Callable[P, R]], ThrottledCallable[P, R]]: ...
|
||||
|
||||
|
||||
def qthrottled(
|
||||
@@ -364,8 +396,7 @@ def qdebounced(
|
||||
leading: bool = False,
|
||||
timer_type: Qt.TimerType = Qt.TimerType.PreciseTimer,
|
||||
parent: QObject | None = None,
|
||||
) -> ThrottledCallable[P, R]:
|
||||
...
|
||||
) -> ThrottledCallable[P, R]: ...
|
||||
|
||||
|
||||
@overload
|
||||
@@ -375,8 +406,7 @@ def qdebounced(
|
||||
leading: bool = False,
|
||||
timer_type: Qt.TimerType = Qt.TimerType.PreciseTimer,
|
||||
parent: QObject | None = None,
|
||||
) -> Callable[[Callable[P, R]], ThrottledCallable[P, R]]:
|
||||
...
|
||||
) -> Callable[[Callable[P, R]], ThrottledCallable[P, R]]: ...
|
||||
|
||||
|
||||
def qdebounced(
|
||||
@@ -441,6 +471,11 @@ def _make_decorator(
|
||||
obj = ThrottledCallable(func, kind, policy, parent=parent)
|
||||
obj.setTimerType(timer_type)
|
||||
obj.setTimeout(timeout)
|
||||
|
||||
if instance is not None:
|
||||
# this is a bound method, we need to avoid strong references,
|
||||
# and functools.wraps will prevent garbage collection on bound methods
|
||||
return obj
|
||||
return wraps(func)(obj)
|
||||
|
||||
return deco(func) if func is not None else deco
|
||||
|
23
tests/test_iconify.py
Normal file
23
tests/test_iconify.py
Normal file
@@ -0,0 +1,23 @@
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
import pytest
|
||||
from qtpy.QtGui import QIcon
|
||||
from qtpy.QtWidgets import QPushButton
|
||||
|
||||
from superqt import QIconifyIcon
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from pytestqt.qtbot import QtBot
|
||||
|
||||
|
||||
def test_qiconify(qtbot: "QtBot", monkeypatch: "pytest.MonkeyPatch") -> None:
|
||||
monkeypatch.setenv("PYCONIFY_CACHE", "0")
|
||||
pytest.importorskip("pyconify")
|
||||
|
||||
icon = QIconifyIcon("bi:alarm-fill", color="red", flip="vertical")
|
||||
icon.addKey("bi:alarm", color="blue", rotate=90, state=QIcon.State.On)
|
||||
|
||||
btn = QPushButton()
|
||||
qtbot.addWidget(btn)
|
||||
btn.setIcon(icon)
|
||||
btn.show()
|
@@ -22,6 +22,24 @@ def test_large_spinbox(qtbot):
|
||||
assert sb.value() == -(10**e)
|
||||
|
||||
|
||||
def test_large_spinbox_range(qtbot):
|
||||
sb = QLargeIntSpinBox()
|
||||
qtbot.addWidget(sb)
|
||||
sb.setRange(-100, 100)
|
||||
sb.setValue(50)
|
||||
|
||||
sb.setRange(-10, 10)
|
||||
assert sb.value() == 10
|
||||
|
||||
sb.setRange(100, 1000)
|
||||
assert sb.value() == 100
|
||||
|
||||
sb.setRange(50, 0)
|
||||
assert sb.minimum() == 50
|
||||
assert sb.maximum() == 50
|
||||
assert sb.value() == 50
|
||||
|
||||
|
||||
def test_large_spinbox_type(qtbot):
|
||||
sb = QLargeIntSpinBox()
|
||||
qtbot.addWidget(sb)
|
||||
|
@@ -1,3 +1,5 @@
|
||||
import gc
|
||||
import weakref
|
||||
from unittest.mock import Mock
|
||||
|
||||
import pytest
|
||||
@@ -116,7 +118,6 @@ def test_debouncer_method_definition(qtbot):
|
||||
A.call2(32)
|
||||
|
||||
qtbot.wait(5)
|
||||
|
||||
assert a.count == 1
|
||||
mock1.assert_called_once()
|
||||
mock2.assert_called_once()
|
||||
@@ -201,3 +202,36 @@ def test_ensure_throttled_sig_inspection(deco, qtbot):
|
||||
mock.assert_called_once_with(1, 2)
|
||||
assert func.__doc__ == "docstring"
|
||||
assert func.__name__ == "func"
|
||||
|
||||
|
||||
def test_qthrottled_does_not_prevent_gc(qtbot):
|
||||
mock = Mock()
|
||||
|
||||
class Thing:
|
||||
@qdebounced(timeout=1)
|
||||
def dmethod(self) -> None:
|
||||
mock()
|
||||
|
||||
@qthrottled(timeout=1)
|
||||
def tmethod(self, x: int = 1) -> None:
|
||||
mock()
|
||||
|
||||
thing = Thing()
|
||||
thing_ref = weakref.ref(thing)
|
||||
assert thing_ref() is not None
|
||||
thing.dmethod()
|
||||
qtbot.waitUntil(thing.dmethod._future.done, timeout=2000)
|
||||
assert mock.call_count == 1
|
||||
thing.tmethod()
|
||||
qtbot.waitUntil(thing.tmethod._future.done, timeout=2000)
|
||||
assert mock.call_count == 2
|
||||
|
||||
wm = thing.tmethod
|
||||
assert isinstance(wm, ThrottledCallable)
|
||||
del thing
|
||||
gc.collect()
|
||||
assert thing_ref() is None
|
||||
|
||||
with pytest.warns(RuntimeWarning, match="Method has been garbage collected"):
|
||||
wm()
|
||||
wm._set_future_result()
|
||||
|
Reference in New Issue
Block a user