mirror of
https://github.com/pyapp-kit/superqt.git
synced 2025-08-09 12:01:38 +02:00
Compare commits
31 Commits
Author | SHA1 | Date | |
---|---|---|---|
|
34b9851b36 | ||
|
8ede2a2f39 | ||
|
df008464cc | ||
|
e99adaac03 | ||
|
8a40170c89 | ||
|
2f3113f0f6 | ||
|
c9528ff85a | ||
|
e7a87897f5 | ||
|
952ac336bf | ||
|
7e92b81711 | ||
|
ac4adf5234 | ||
|
5f68795a82 | ||
|
17ad1079a8 | ||
|
6bb050c499 | ||
|
1f4d9081b9 | ||
|
7b1aefd119 | ||
|
0ec5cd3a2f | ||
|
8f62b0b00d | ||
|
4a0aaca2e9 | ||
|
2d49e77c3d | ||
|
ba495a5e72 | ||
|
12f10be8da | ||
|
9ca0bbf858 | ||
|
0ab6758972 | ||
|
d2bc3d898c | ||
|
1bb1a58a73 | ||
|
1288250597 | ||
|
34a776e8d0 | ||
|
146644e105 | ||
|
e7873ad93d | ||
|
0396d465e2 |
78
.github/workflows/test_and_deploy.yml
vendored
78
.github/workflows/test_and_deploy.yml
vendored
@@ -16,18 +16,18 @@ on:
|
||||
jobs:
|
||||
test:
|
||||
name: Test
|
||||
uses: pyapp-kit/workflows/.github/workflows/test-pyrepo.yml@v1
|
||||
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' }}
|
||||
report-failures: ${{ github.event_name == 'schedule' }}
|
||||
coverage-upload: artifact
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
platform: [ubuntu-latest, windows-latest, macos-latest]
|
||||
python-version: ["3.8", "3.9", "3.10", "3.11"]
|
||||
platform: [ubuntu-latest, windows-latest, macos-13]
|
||||
python-version: ["3.9", "3.10", "3.11", "3.12"]
|
||||
backend: [pyqt5, pyside2, pyqt6]
|
||||
exclude:
|
||||
# Abort (core dumped) on linux pyqt6, unknown reason
|
||||
@@ -36,57 +36,71 @@ jobs:
|
||||
# lack of wheels for pyside2/py3.11
|
||||
- python-version: "3.11"
|
||||
backend: pyside2
|
||||
|
||||
include:
|
||||
# https://bugreports.qt.io/browse/PYSIDE-2627
|
||||
- python-version: "3.10"
|
||||
platform: macos-latest
|
||||
backend: "'pyside6!=6.6.2'"
|
||||
- python-version: "3.11"
|
||||
platform: macos-latest
|
||||
backend: "'pyside6!=6.6.2'"
|
||||
- python-version: "3.10"
|
||||
platform: windows-latest
|
||||
backend: "'pyside6!=6.6.2'"
|
||||
- python-version: "3.11"
|
||||
platform: windows-latest
|
||||
backend: "'pyside6!=6.6.2'"
|
||||
|
||||
- python-version: "3.12"
|
||||
backend: pyside2
|
||||
- python-version: "3.12"
|
||||
backend: pyqt5
|
||||
include:
|
||||
- python-version: "3.13"
|
||||
platform: windows-latest
|
||||
backend: "pyqt6"
|
||||
- python-version: "3.13"
|
||||
platform: ubuntu-latest
|
||||
backend: "pyqt6"
|
||||
|
||||
- python-version: "3.10"
|
||||
platform: macos-latest
|
||||
backend: pyqt6
|
||||
backend: "'pyside6<6.8'"
|
||||
- python-version: "3.11"
|
||||
platform: macos-latest
|
||||
backend: "'pyside6<6.8'"
|
||||
- python-version: "3.10"
|
||||
platform: windows-latest
|
||||
backend: "'pyside6<6.8'"
|
||||
- python-version: "3.12"
|
||||
platform: windows-latest
|
||||
backend: "'pyside6<6.8'"
|
||||
|
||||
# legacy Qt
|
||||
- python-version: 3.8
|
||||
- python-version: 3.9
|
||||
platform: ubuntu-latest
|
||||
backend: "pyqt5==5.12.*"
|
||||
- python-version: 3.8
|
||||
- python-version: 3.9
|
||||
platform: ubuntu-latest
|
||||
backend: "pyqt5==5.13.*"
|
||||
- python-version: 3.8
|
||||
- python-version: 3.9
|
||||
platform: ubuntu-latest
|
||||
backend: "pyqt5==5.14.*"
|
||||
|
||||
test-qt-minreqs:
|
||||
uses: pyapp-kit/workflows/.github/workflows/test-pyrepo.yml@v1
|
||||
uses: pyapp-kit/workflows/.github/workflows/test-pyrepo.yml@v2
|
||||
with:
|
||||
python-version: "3.8"
|
||||
python-version: "3.9"
|
||||
qt: pyqt5
|
||||
pip-post-installs: 'qtpy==1.1.0 typing-extensions==3.7.4.3'
|
||||
pip-post-installs: "qtpy==1.1.0 typing-extensions==4.5.0" # 4.5.0 is just for pint
|
||||
pip-install-flags: -e
|
||||
coverage-upload: artifact
|
||||
|
||||
upload_coverage:
|
||||
if: always()
|
||||
needs: [test, test-qt-minreqs]
|
||||
uses: pyapp-kit/workflows/.github/workflows/upload-coverage.yml@v2
|
||||
secrets: inherit
|
||||
|
||||
test_napari:
|
||||
uses: pyapp-kit/workflows/.github/workflows/test-dependents.yml@v1
|
||||
uses: pyapp-kit/workflows/.github/workflows/test-dependents.yml@v2
|
||||
with:
|
||||
dependency-repo: napari/napari
|
||||
dependency-ref: ${{ matrix.napari-version }}
|
||||
dependency-extras: 'testing'
|
||||
dependency-extras: "testing"
|
||||
qt: ${{ matrix.qt }}
|
||||
pytest-args: 'napari/_qt -k "not async and not qt_dims_2"'
|
||||
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.18"]
|
||||
napari-version: ["", "v0.4.19.post1"]
|
||||
qt: ["pyqt5", "pyside2"]
|
||||
|
||||
check-manifest:
|
||||
@@ -125,6 +139,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
|
||||
|
@@ -5,19 +5,19 @@ ci:
|
||||
|
||||
repos:
|
||||
- repo: https://github.com/astral-sh/ruff-pre-commit
|
||||
rev: v0.3.0
|
||||
rev: v0.8.1
|
||||
hooks:
|
||||
- id: ruff
|
||||
args: [--fix, --unsafe-fixes]
|
||||
- id: ruff-format
|
||||
|
||||
- repo: https://github.com/abravalheri/validate-pyproject
|
||||
rev: v0.16
|
||||
rev: v0.23
|
||||
hooks:
|
||||
- id: validate-pyproject
|
||||
|
||||
- repo: https://github.com/pre-commit/mirrors-mypy
|
||||
rev: v1.8.0
|
||||
rev: v1.13.0
|
||||
hooks:
|
||||
- id: mypy
|
||||
exclude: tests|examples
|
||||
|
92
CHANGELOG.md
92
CHANGELOG.md
@@ -1,5 +1,91 @@
|
||||
# Changelog
|
||||
|
||||
## [v0.7.0](https://github.com/pyapp-kit/superqt/tree/v0.7.0) (2024-12-14)
|
||||
|
||||
[Full Changelog](https://github.com/pyapp-kit/superqt/compare/v0.6.8...v0.7.0)
|
||||
|
||||
**Fixed bugs:**
|
||||
|
||||
- fix: End painter when drawing colormap [\#262](https://github.com/pyapp-kit/superqt/pull/262) ([gselzer](https://github.com/gselzer))
|
||||
- fix: minimum size hint for QElidingLabel [\#260](https://github.com/pyapp-kit/superqt/pull/260) ([gselzer](https://github.com/gselzer))
|
||||
- fix: KeyError in CodeSyntaxHighlight [\#258](https://github.com/pyapp-kit/superqt/pull/258) ([hanjinliu](https://github.com/hanjinliu))
|
||||
|
||||
**Refactors:**
|
||||
|
||||
- chore: Revert "remove stylesheet on sliderLabel \(\#254\)" [\#265](https://github.com/pyapp-kit/superqt/pull/265) ([tlambert03](https://github.com/tlambert03))
|
||||
- refactor: remove stylesheet on sliderLabel [\#254](https://github.com/pyapp-kit/superqt/pull/254) ([tlambert03](https://github.com/tlambert03))
|
||||
|
||||
**Merged pull requests:**
|
||||
|
||||
- build: support py313 [\#264](https://github.com/pyapp-kit/superqt/pull/264) ([tlambert03](https://github.com/tlambert03))
|
||||
- build: drop py38 [\#263](https://github.com/pyapp-kit/superqt/pull/263) ([tlambert03](https://github.com/tlambert03))
|
||||
- ci: \[pre-commit.ci\] autoupdate [\#257](https://github.com/pyapp-kit/superqt/pull/257) ([pre-commit-ci[bot]](https://github.com/apps/pre-commit-ci))
|
||||
- ci: \[pre-commit.ci\] autoupdate [\#253](https://github.com/pyapp-kit/superqt/pull/253) ([pre-commit-ci[bot]](https://github.com/apps/pre-commit-ci))
|
||||
|
||||
## [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)
|
||||
@@ -408,13 +494,17 @@
|
||||
|
||||
## [v0.2.1](https://github.com/pyapp-kit/superqt/tree/v0.2.1) (2021-07-10)
|
||||
|
||||
[Full Changelog](https://github.com/pyapp-kit/superqt/compare/v0.2.0rc0...v0.2.1)
|
||||
[Full Changelog](https://github.com/pyapp-kit/superqt/compare/v0.2.0rc1...v0.2.1)
|
||||
|
||||
**Fixed bugs:**
|
||||
|
||||
- Fix QLabeledRangeSlider API \(fix slider proxy\) [\#10](https://github.com/pyapp-kit/superqt/pull/10) ([tlambert03](https://github.com/tlambert03))
|
||||
- Fix range slider with negative min range [\#9](https://github.com/pyapp-kit/superqt/pull/9) ([tlambert03](https://github.com/tlambert03))
|
||||
|
||||
## [v0.2.0rc1](https://github.com/pyapp-kit/superqt/tree/v0.2.0rc1) (2021-06-26)
|
||||
|
||||
[Full Changelog](https://github.com/pyapp-kit/superqt/compare/v0.2.0rc0...v0.2.0rc1)
|
||||
|
||||
## [v0.2.0rc0](https://github.com/pyapp-kit/superqt/tree/v0.2.0rc0) (2021-06-26)
|
||||
|
||||
[Full Changelog](https://github.com/pyapp-kit/superqt/compare/v0.2.0...v0.2.0rc0)
|
||||
|
@@ -26,7 +26,7 @@ pytest
|
||||
|
||||
All widgets must be well-tested, and should work on:
|
||||
|
||||
- Python 3.8 and above
|
||||
- Python 3.9 and above
|
||||
- PyQt5 (5.11 and above) & PyQt6
|
||||
- PySide2 (5.11 and above) & PySide6
|
||||
- macOS, Windows, & Linux
|
||||
|
@@ -15,7 +15,7 @@ that are not provided in the native QtWidgets module.
|
||||
Components are tested on:
|
||||
|
||||
- macOS, Windows, & Linux
|
||||
- Python 3.8 and above
|
||||
- Python 3.9 and above
|
||||
- PyQt5 (5.11 and above) & PyQt6
|
||||
- PySide2 (5.11 and above) & PySide6
|
||||
|
||||
|
@@ -10,7 +10,7 @@ QtWidgets module.
|
||||
Components are tested on:
|
||||
|
||||
- macOS, Windows, & Linux
|
||||
- Python 3.8 and above
|
||||
- Python 3.9 and above
|
||||
- PyQt5 (5.11 and above) & PyQt6
|
||||
- PySide2 (5.11 and above) & PySide6
|
||||
|
||||
|
@@ -6,7 +6,7 @@ from qtpy import QtWidgets as QtW
|
||||
# patch for Qt 5.15 on macos >= 12
|
||||
os.environ["USE_MAC_SLIDER_PATCH"] = "1"
|
||||
|
||||
from superqt import QRangeSlider # noqa
|
||||
from superqt import QRangeSlider
|
||||
|
||||
QSS = """
|
||||
QSlider {
|
||||
|
@@ -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
|
||||
|
||||
|
@@ -27,7 +27,7 @@ SOFTWARE.
|
||||
|
||||
"""
|
||||
|
||||
from typing import Deque
|
||||
from collections import deque
|
||||
|
||||
from qtpy.QtCore import QRect, QSize, Qt, QTimer, Signal
|
||||
from qtpy.QtGui import QPainter, QPen
|
||||
@@ -65,8 +65,8 @@ class DrawSignalsWidget(QWidget):
|
||||
self._scrollTimer.timeout.connect(self._scroll)
|
||||
self._scrollTimer.start()
|
||||
|
||||
self._signalActivations: Deque[int] = Deque()
|
||||
self._throttledSignalActivations: Deque[int] = Deque()
|
||||
self._signalActivations: deque[int] = deque()
|
||||
self._throttledSignalActivations: deque[int] = deque()
|
||||
|
||||
def sizeHint(self):
|
||||
return QSize(400, 200)
|
||||
@@ -84,7 +84,7 @@ class DrawSignalsWidget(QWidget):
|
||||
|
||||
self.update()
|
||||
|
||||
def scrollAndCut(self, v: Deque[int], cutoff: int):
|
||||
def scrollAndCut(self, v: deque[int], cutoff: int):
|
||||
L = len(v)
|
||||
for p in range(L):
|
||||
v[p] += 1
|
||||
@@ -121,7 +121,7 @@ class DrawSignalsWidget(QWidget):
|
||||
p.drawLine(0, h2, w, h2)
|
||||
p.restore()
|
||||
|
||||
def _drawSignals(self, p: QPainter, v: Deque[int], color, yStart, yEnd):
|
||||
def _drawSignals(self, p: QPainter, v: deque[int], color, yStart, yEnd):
|
||||
p.save()
|
||||
pen = QPen()
|
||||
pen.setWidthF(2.0)
|
||||
|
@@ -8,7 +8,7 @@ build-backend = "hatchling.build"
|
||||
name = "superqt"
|
||||
description = "Missing widgets and components for PyQt/PySide"
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.8"
|
||||
requires-python = ">=3.9"
|
||||
license = { text = "BSD 3-Clause License" }
|
||||
authors = [{ email = "talley.lambert@gmail.com", name = "Talley Lambert" }]
|
||||
keywords = [
|
||||
@@ -28,11 +28,11 @@ classifiers = [
|
||||
"Operating System :: OS Independent",
|
||||
"Programming Language :: Python :: 3",
|
||||
"Programming Language :: Python :: 3 :: Only",
|
||||
"Programming Language :: Python :: 3.8",
|
||||
"Programming Language :: Python :: 3.9",
|
||||
"Programming Language :: Python :: 3.10",
|
||||
"Programming Language :: Python :: 3.11",
|
||||
"Programming Language :: Python :: 3.12",
|
||||
"Programming Language :: Python :: 3.13",
|
||||
"Topic :: Desktop Environment",
|
||||
"Topic :: Software Development :: User Interfaces",
|
||||
"Topic :: Software Development :: Widget Sets",
|
||||
@@ -41,13 +41,21 @@ dynamic = ["version"]
|
||||
dependencies = [
|
||||
"pygments>=2.4.0",
|
||||
"qtpy>=1.1.0",
|
||||
"typing-extensions >=3.7.4.3,!=3.10.0.0",
|
||||
"typing-extensions >=3.7.4.3,!=3.10.0.0", # however, pint requires >4.5.0
|
||||
]
|
||||
|
||||
# extras
|
||||
# https://peps.python.org/pep-0621/#dependencies-optional-dependencies
|
||||
[project.optional-dependencies]
|
||||
test = ["pint", "pytest", "pytest-cov", "pytest-qt", "numpy", "cmap", "pyconify"]
|
||||
test = [
|
||||
"pint",
|
||||
"pytest",
|
||||
"pytest-cov",
|
||||
"pytest-qt",
|
||||
"numpy",
|
||||
"cmap",
|
||||
"pyconify",
|
||||
]
|
||||
dev = [
|
||||
"ipython",
|
||||
"ruff",
|
||||
@@ -58,7 +66,13 @@ dev = [
|
||||
"rich",
|
||||
"types-Pygments",
|
||||
]
|
||||
docs = ["mkdocs-macros-plugin", "mkdocs-material", "mkdocstrings[python]", "pint", "cmap"]
|
||||
docs = [
|
||||
"mkdocs-macros-plugin",
|
||||
"mkdocs-material",
|
||||
"mkdocstrings[python]",
|
||||
"pint",
|
||||
"cmap",
|
||||
]
|
||||
quantity = ["pint"]
|
||||
cmap = ["cmap >=0.1.1"]
|
||||
pyside2 = ["pyside2"]
|
||||
@@ -66,9 +80,9 @@ pyside2 = ["pyside2"]
|
||||
# https://github.com/pyapp-kit/superqt/pull/177
|
||||
# https://github.com/pyapp-kit/superqt/pull/164
|
||||
# https://bugreports.qt.io/browse/PYSIDE-2627
|
||||
pyside6 = ["pyside6 !=6.5.0,!=6.5.1,!=6.6.2"]
|
||||
pyside6 = ["pyside6 !=6.5.0,!=6.5.1,!=6.6.2,<6.8"]
|
||||
pyqt5 = ["pyqt5"]
|
||||
pyqt6 = ["pyqt6"]
|
||||
pyqt6 = ["pyqt6<6.7"]
|
||||
font-fa5 = ["fonticon-fontawesome5"]
|
||||
font-fa6 = ["fonticon-fontawesome6"]
|
||||
font-mi6 = ["fonticon-materialdesignicons6"]
|
||||
@@ -100,21 +114,30 @@ python = ["3.11"]
|
||||
|
||||
[[tool.hatch.envs.test.matrix]]
|
||||
qt = ["pyside2", "pyqt5", "pyqt5.12"]
|
||||
python = ["3.8"]
|
||||
python = ["3.9"]
|
||||
|
||||
[tool.hatch.envs.test.overrides]
|
||||
matrix.qt.extra-dependencies = [
|
||||
{value = "pyside2", if = ["pyside2"]},
|
||||
{value = "pyside6", if = ["pyside6"]},
|
||||
{value = "pyqt5", if = ["pyqt5"]},
|
||||
{value = "pyqt6", if = ["pyqt6"]},
|
||||
{value = "pyqt5==5.12", if = ["pyqt5.12"]},
|
||||
{ value = "pyside2", if = [
|
||||
"pyside2",
|
||||
] },
|
||||
{ value = "pyside6", if = [
|
||||
"pyside6",
|
||||
] },
|
||||
{ value = "pyqt5", if = [
|
||||
"pyqt5",
|
||||
] },
|
||||
{ value = "pyqt6", if = [
|
||||
"pyqt6",
|
||||
] },
|
||||
{ value = "pyqt5==5.12", if = [
|
||||
"pyqt5.12",
|
||||
] },
|
||||
]
|
||||
|
||||
# https://github.com/charliermarsh/ruff
|
||||
[tool.ruff]
|
||||
line-length = 88
|
||||
target-version = "py38"
|
||||
target-version = "py39"
|
||||
src = ["src", "tests"]
|
||||
|
||||
# https://docs.astral.sh/ruff/rules
|
||||
@@ -132,7 +155,7 @@ select = [
|
||||
"B", # flake8-bugbear
|
||||
"A001", # flake8-builtins
|
||||
"RUF", # ruff-specific rules
|
||||
"TCH", # flake8-type-checking
|
||||
"TC", # flake8-type-checking
|
||||
"TID", # flake8-tidy-imports
|
||||
]
|
||||
ignore = [
|
||||
@@ -155,9 +178,11 @@ 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",
|
||||
"ignore:.*Skipping callback call set_result",
|
||||
]
|
||||
|
||||
# https://mypy.readthedocs.io/en/stable/config_file.html
|
||||
@@ -191,7 +216,7 @@ exclude_lines = [
|
||||
"@overload",
|
||||
"except ImportError",
|
||||
"\\.\\.\\.",
|
||||
"pass"
|
||||
"pass",
|
||||
]
|
||||
|
||||
# https://github.com/mgedmin/check-manifest#configuration
|
||||
|
@@ -26,8 +26,6 @@ from .spinbox import QLargeIntSpinBox
|
||||
from .utils import QMessageHandler, ensure_main_thread, ensure_object_thread
|
||||
|
||||
__all__ = [
|
||||
"ensure_main_thread",
|
||||
"ensure_object_thread",
|
||||
"QCollapsible",
|
||||
"QColorComboBox",
|
||||
"QColormapComboBox",
|
||||
@@ -36,8 +34,8 @@ __all__ = [
|
||||
"QElidingLabel",
|
||||
"QElidingLineEdit",
|
||||
"QEnumComboBox",
|
||||
"QLabeledDoubleRangeSlider",
|
||||
"QIconifyIcon",
|
||||
"QLabeledDoubleRangeSlider",
|
||||
"QLabeledDoubleSlider",
|
||||
"QLabeledRangeSlider",
|
||||
"QLabeledSlider",
|
||||
@@ -48,11 +46,13 @@ __all__ = [
|
||||
"QSearchableComboBox",
|
||||
"QSearchableListWidget",
|
||||
"QSearchableTreeWidget",
|
||||
"ensure_main_thread",
|
||||
"ensure_object_thread",
|
||||
]
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from .combobox import QColormapComboBox # noqa: TCH004
|
||||
from .spinbox._quantity import QQuantity # noqa: TCH004
|
||||
from .combobox import QColormapComboBox # noqa: TC004
|
||||
from .spinbox._quantity import QQuantity # noqa: TC004
|
||||
|
||||
|
||||
def __getattr__(name: str) -> Any:
|
||||
|
@@ -15,9 +15,9 @@ from ._cmap_line_edit import QColormapLineEdit
|
||||
from ._cmap_utils import draw_colormap
|
||||
|
||||
__all__ = [
|
||||
"QColormapItemDelegate",
|
||||
"draw_colormap",
|
||||
"QColormapLineEdit",
|
||||
"CmapCatalogComboBox",
|
||||
"QColormapComboBox",
|
||||
"QColormapItemDelegate",
|
||||
"QColormapLineEdit",
|
||||
"draw_colormap",
|
||||
]
|
||||
|
@@ -1,6 +1,6 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING, Container
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from cmap import Colormap
|
||||
from qtpy.QtCore import Qt, Signal
|
||||
@@ -11,6 +11,8 @@ from ._cmap_line_edit import QColormapLineEdit
|
||||
from ._cmap_utils import try_cast_colormap
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from collections.abc import Container
|
||||
|
||||
from cmap._catalog import Category, Interpolation
|
||||
from qtpy.QtGui import QKeyEvent
|
||||
|
||||
|
@@ -1,6 +1,6 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING, Any, Sequence
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
from cmap import Colormap
|
||||
from qtpy.QtCore import Qt, Signal
|
||||
@@ -23,6 +23,8 @@ from ._cmap_line_edit import QColormapLineEdit
|
||||
from ._cmap_utils import try_cast_colormap
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from collections.abc import Sequence
|
||||
|
||||
from cmap._colormap import ColorStopsLike
|
||||
|
||||
|
||||
|
@@ -1,8 +1,8 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
from qtpy.QtCore import Qt
|
||||
from qtpy.QtCore import QRect, Qt
|
||||
from qtpy.QtGui import QIcon, QPainter, QPaintEvent, QPalette
|
||||
from qtpy.QtWidgets import QApplication, QLineEdit, QStyle, QWidget
|
||||
|
||||
@@ -103,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
|
||||
@@ -112,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
|
||||
|
@@ -121,6 +121,10 @@ def draw_colormap(
|
||||
painter.setBrush(gradient)
|
||||
painter.drawRect(rect)
|
||||
|
||||
# If we created a new Painter, free its resources
|
||||
if isinstance(painter_or_device, QPaintDevice):
|
||||
painter.end()
|
||||
|
||||
|
||||
def _draw_checkerboard(
|
||||
painter: QPainter, rect: QRect | QRectF, checker_size: int
|
||||
|
@@ -13,7 +13,7 @@ __all__ = (
|
||||
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from superqt.cmap import QColormapComboBox # noqa: TCH004
|
||||
from superqt.cmap import QColormapComboBox # noqa: TC004
|
||||
|
||||
|
||||
def __getattr__(name: str) -> Any: # pragma: no cover
|
||||
|
@@ -3,7 +3,7 @@ from __future__ import annotations
|
||||
import warnings
|
||||
from contextlib import suppress
|
||||
from enum import IntEnum, auto
|
||||
from typing import Any, Literal, Sequence, cast
|
||||
from typing import TYPE_CHECKING, Any, Literal, cast
|
||||
|
||||
from qtpy.QtCore import QModelIndex, QPersistentModelIndex, QRect, QSize, Qt, Signal
|
||||
from qtpy.QtGui import QColor, QPainter
|
||||
@@ -19,6 +19,9 @@ from qtpy.QtWidgets import (
|
||||
|
||||
from superqt.utils import signals_blocked
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from collections.abc import Sequence
|
||||
|
||||
_NAME_MAP = {QColor(x).name(): x for x in QColor.colorNames()}
|
||||
|
||||
COLOR_ROLE = Qt.ItemDataRole.BackgroundRole
|
||||
|
@@ -3,7 +3,7 @@ from enum import Enum, EnumMeta, Flag
|
||||
from functools import reduce
|
||||
from itertools import combinations
|
||||
from operator import or_
|
||||
from typing import Optional, Tuple, TypeVar
|
||||
from typing import Optional, TypeVar
|
||||
|
||||
from qtpy.QtCore import Signal
|
||||
from qtpy.QtWidgets import QComboBox
|
||||
@@ -47,7 +47,7 @@ def _get_name(enum_value: Enum):
|
||||
return name
|
||||
|
||||
|
||||
def _get_name_with_value(enum_value: Enum) -> Tuple[str, Enum]:
|
||||
def _get_name_with_value(enum_value: Enum) -> tuple[str, Enum]:
|
||||
return _get_name(enum_value), enum_value
|
||||
|
||||
|
||||
|
@@ -1,5 +1,3 @@
|
||||
from typing import List
|
||||
|
||||
from qtpy.QtCore import Qt
|
||||
from qtpy.QtGui import QFont, QFontMetrics, QTextLayout
|
||||
|
||||
@@ -36,7 +34,7 @@ class _GenericEliding:
|
||||
self._ellipses_width = width
|
||||
|
||||
@staticmethod
|
||||
def wrapText(text, width, font=None) -> List[str]:
|
||||
def wrapText(text, width, font=None) -> list[str]:
|
||||
"""Returns `text`, split as it would be wrapped for `width`, given `font`.
|
||||
|
||||
Static method.
|
||||
@@ -74,5 +72,5 @@ class _GenericEliding:
|
||||
# join them
|
||||
return "".join(text[:nlines] + [last_line])
|
||||
|
||||
def _wrappedText(self) -> List[str]:
|
||||
def _wrappedText(self) -> list[str]:
|
||||
return _GenericEliding.wrapText(self._text, self.width(), self.font())
|
||||
|
@@ -73,3 +73,10 @@ class QElidingLabel(_GenericEliding, QLabel):
|
||||
flags = int(self.alignment() | Qt.TextFlag.TextWordWrap)
|
||||
r = fm.boundingRect(QRect(QPoint(0, 0), self.size()), flags, self._text)
|
||||
return QSize(self.width(), r.height())
|
||||
|
||||
def minimumSizeHint(self) -> QSize:
|
||||
# The smallest that self._elidedText can be is just the ellipsis.
|
||||
fm = QFontMetrics(self.font())
|
||||
flags = int(self.alignment() | Qt.TextFlag.TextWordWrap)
|
||||
r = fm.boundingRect(QRect(QPoint(0, 0), self.size()), flags, "...")
|
||||
return QSize(r.width(), r.height())
|
||||
|
@@ -1,16 +1,16 @@
|
||||
from __future__ import annotations
|
||||
|
||||
__all__ = [
|
||||
"addFont",
|
||||
"Animation",
|
||||
"ENTRY_POINT",
|
||||
"font",
|
||||
"icon",
|
||||
"Animation",
|
||||
"IconFont",
|
||||
"IconFontMeta",
|
||||
"IconOpts",
|
||||
"pulse",
|
||||
"QIconifyIcon",
|
||||
"addFont",
|
||||
"font",
|
||||
"icon",
|
||||
"pulse",
|
||||
"setTextIcon",
|
||||
"spin",
|
||||
]
|
||||
|
@@ -1,4 +1,5 @@
|
||||
from typing import Mapping, Type, Union
|
||||
from collections.abc import Mapping
|
||||
from typing import Union
|
||||
|
||||
FONTFILE_ATTR = "__font_file__"
|
||||
|
||||
@@ -69,7 +70,7 @@ class IconFont(metaclass=IconFontMeta):
|
||||
__font_file__ = "..."
|
||||
|
||||
|
||||
def namespace2font(namespace: Union[Mapping, Type], name: str) -> Type[IconFont]:
|
||||
def namespace2font(namespace: Union[Mapping, type], name: str) -> type[IconFont]:
|
||||
"""Convenience to convert a namespace (class, module, dict) into an IconFont."""
|
||||
if isinstance(namespace, type):
|
||||
if not isinstance(getattr(namespace, FONTFILE_ATTR), str):
|
||||
|
@@ -1,5 +1,5 @@
|
||||
import contextlib
|
||||
from typing import ClassVar, Dict, List, Set, Tuple
|
||||
from typing import ClassVar
|
||||
|
||||
from ._iconfont import IconFontMeta, namespace2font
|
||||
|
||||
@@ -11,9 +11,9 @@ except ImportError:
|
||||
|
||||
class FontIconManager:
|
||||
ENTRY_POINT: ClassVar[str] = "superqt.fonticon"
|
||||
_PLUGINS: ClassVar[Dict[str, EntryPoint]] = {}
|
||||
_LOADED: ClassVar[Dict[str, IconFontMeta]] = {}
|
||||
_BLOCKED: ClassVar[Set[EntryPoint]] = set()
|
||||
_PLUGINS: ClassVar[dict[str, EntryPoint]] = {}
|
||||
_LOADED: ClassVar[dict[str, IconFontMeta]] = {}
|
||||
_BLOCKED: ClassVar[set[EntryPoint]] = set()
|
||||
|
||||
def _discover_fonts(self) -> None:
|
||||
self._PLUGINS.clear()
|
||||
@@ -86,15 +86,15 @@ _manager = FontIconManager()
|
||||
get_font_class = _manager._get_font_class
|
||||
|
||||
|
||||
def discover() -> Tuple[str]:
|
||||
def discover() -> tuple[str]:
|
||||
_manager._discover_fonts()
|
||||
|
||||
|
||||
def available() -> Tuple[str]:
|
||||
def available() -> tuple[str]:
|
||||
return tuple(_manager._PLUGINS)
|
||||
|
||||
|
||||
def loaded(load_all=False) -> Dict[str, List[str]]:
|
||||
def loaded(load_all=False) -> dict[str, list[str]]:
|
||||
if load_all:
|
||||
discover()
|
||||
for x in available():
|
||||
|
@@ -2,9 +2,10 @@ from __future__ import annotations
|
||||
|
||||
import warnings
|
||||
from collections import abc, defaultdict
|
||||
from collections.abc import Sequence
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import TYPE_CHECKING, ClassVar, DefaultDict, Sequence, Tuple, Union, cast
|
||||
from typing import TYPE_CHECKING, ClassVar, Union, cast
|
||||
|
||||
from qtpy import QT_VERSION
|
||||
from qtpy.QtCore import QObject, QPoint, QRect, QSize, Qt
|
||||
@@ -47,8 +48,8 @@ ValidColor = Union[
|
||||
int,
|
||||
str,
|
||||
Qt.GlobalColor,
|
||||
Tuple[int, int, int, int],
|
||||
Tuple[int, int, int],
|
||||
tuple[int, int, int, int],
|
||||
tuple[int, int, int],
|
||||
None,
|
||||
]
|
||||
|
||||
@@ -159,7 +160,7 @@ class _QFontIconEngine(QIconEngine):
|
||||
def __init__(self, options: _IconOptions):
|
||||
super().__init__()
|
||||
self._opts: defaultdict[QIcon.State, dict[QIcon.Mode, _IconOptions | None]] = (
|
||||
DefaultDict(dict)
|
||||
defaultdict(dict)
|
||||
)
|
||||
self._opts[QIcon.State.Off][QIcon.Mode.Normal] = options
|
||||
self.update_hash()
|
||||
|
@@ -1,9 +1,11 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import warnings
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from qtpy.QtCore import QSize
|
||||
from qtpy.QtGui import QIcon
|
||||
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
|
||||
@@ -122,5 +124,25 @@ class QIconifyIcon(QIcon):
|
||||
state : QIcon.State, optional
|
||||
State specified for the icon, passed to `QIcon.addFile`.
|
||||
"""
|
||||
path = svg_path(*key, color=color, flip=flip, rotate=rotate, dir=dir)
|
||||
self.addFile(str(path), size or QSize(), mode, state)
|
||||
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)
|
||||
|
@@ -1,5 +1,6 @@
|
||||
import logging
|
||||
from typing import Any, Iterable, Mapping
|
||||
from collections.abc import Iterable, Mapping
|
||||
from typing import Any
|
||||
|
||||
from qtpy.QtCore import QRegularExpression
|
||||
from qtpy.QtWidgets import QLineEdit, QTreeWidget, QTreeWidgetItem, QVBoxLayout, QWidget
|
||||
|
@@ -8,6 +8,7 @@ from ._range_style import MONTEREY_SLIDER_STYLES_FIX
|
||||
from ._sliders import QDoubleRangeSlider, QDoubleSlider, QRangeSlider
|
||||
|
||||
__all__ = [
|
||||
"MONTEREY_SLIDER_STYLES_FIX",
|
||||
"QDoubleRangeSlider",
|
||||
"QDoubleSlider",
|
||||
"QLabeledDoubleRangeSlider",
|
||||
@@ -15,5 +16,4 @@ __all__ = [
|
||||
"QLabeledRangeSlider",
|
||||
"QLabeledSlider",
|
||||
"QRangeSlider",
|
||||
"MONTEREY_SLIDER_STYLES_FIX",
|
||||
]
|
||||
|
@@ -1,4 +1,5 @@
|
||||
from typing import List, Optional, Sequence, Tuple, TypeVar, Union
|
||||
from collections.abc import Sequence
|
||||
from typing import Optional, TypeVar, Union
|
||||
|
||||
from qtpy import QtGui
|
||||
from qtpy.QtCore import Property, QEvent, QPoint, QPointF, QRect, QRectF, Qt, Signal
|
||||
@@ -42,11 +43,11 @@ class _GenericRangeSlider(_GenericSlider):
|
||||
self.valueChanged = self._valuesChanged
|
||||
self.sliderMoved = self._slidersMoved
|
||||
# list of values
|
||||
self._value: List[_T] = [20, 80]
|
||||
self._value: list[_T] = [20, 80]
|
||||
|
||||
# list of current positions of each handle. same length as _value
|
||||
# If tracking is enabled (the default) this will be identical to _value
|
||||
self._position: List[_T] = [20, 80]
|
||||
self._position: list[_T] = [20, 80]
|
||||
|
||||
# which handle is being pressed/hovered
|
||||
self._pressedIndex = 0
|
||||
@@ -103,7 +104,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.
|
||||
@@ -113,7 +114,7 @@ class _GenericRangeSlider(_GenericSlider):
|
||||
|
||||
# ############### QtOverrides #######################
|
||||
|
||||
def value(self) -> Tuple[_T, ...]:
|
||||
def value(self) -> tuple[_T, ...]:
|
||||
"""Get current value of the widget as a tuple of integers."""
|
||||
return tuple(self._value)
|
||||
|
||||
@@ -124,11 +125,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 +156,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 +242,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
|
||||
@@ -313,7 +333,7 @@ class _GenericRangeSlider(_GenericSlider):
|
||||
# NOTE: this is very much tied to mousepress... not a generic "get control"
|
||||
def _getControlAtPos(
|
||||
self, pos: QPoint, opt: Optional[QStyleOptionSlider] = None
|
||||
) -> Tuple[QStyle.SubControl, int]:
|
||||
) -> tuple[QStyle.SubControl, int]:
|
||||
"""Update self._pressedControl based on ev.pos()."""
|
||||
opt = opt or self._styleOption
|
||||
|
||||
|
@@ -74,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
|
||||
@@ -98,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.
|
||||
@@ -174,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
|
||||
@@ -193,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:
|
||||
@@ -335,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 TYPE_CHECKING, Any, 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,
|
||||
@@ -25,6 +25,9 @@ from superqt.utils import signals_blocked
|
||||
|
||||
from ._sliders import QDoubleRangeSlider, QDoubleSlider, QRangeSlider
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from collections.abc import Iterable
|
||||
|
||||
|
||||
class LabelPosition(IntEnum):
|
||||
NoLabel = 0
|
||||
@@ -32,6 +35,7 @@ class LabelPosition(IntEnum):
|
||||
LabelsBelow = auto()
|
||||
LabelsRight = LabelsAbove
|
||||
LabelsLeft = LabelsBelow
|
||||
LabelsOnHandle = auto()
|
||||
|
||||
|
||||
class EdgeLabelMode(IntFlag):
|
||||
@@ -43,10 +47,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 +98,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,6 +162,9 @@ 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
|
||||
@@ -227,8 +264,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
|
||||
|
||||
@@ -249,8 +284,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):
|
||||
@@ -290,6 +326,8 @@ class QLabeledDoubleSlider(QLabeledSlider):
|
||||
|
||||
class QLabeledRangeSlider(_SliderProxy, QAbstractSlider):
|
||||
_valueChanged = Signal(tuple)
|
||||
_sliderPressed = Signal()
|
||||
_sliderReleased = Signal()
|
||||
editingFinished = Signal()
|
||||
|
||||
_slider_class = QRangeSlider
|
||||
@@ -309,7 +347,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
|
||||
@@ -318,6 +356,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
|
||||
|
||||
@@ -352,10 +392,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:
|
||||
@@ -381,27 +421,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:
|
||||
@@ -409,13 +455,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()
|
||||
@@ -425,10 +467,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()
|
||||
|
||||
@@ -436,9 +481,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 (
|
||||
@@ -449,17 +505,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
|
||||
@@ -476,6 +541,7 @@ class QLabeledRangeSlider(_SliderProxy, QAbstractSlider):
|
||||
label.move(pos)
|
||||
last_edge = pos
|
||||
label.clearFocus()
|
||||
label.raise_()
|
||||
label.show()
|
||||
self.update()
|
||||
|
||||
@@ -563,6 +629,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
|
||||
|
@@ -1,27 +1,27 @@
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from superqt.cmap import draw_colormap # noqa: TCH004
|
||||
from superqt.cmap import draw_colormap # noqa: TC004
|
||||
|
||||
__all__ = (
|
||||
"CodeSyntaxHighlight",
|
||||
"FunctionWorker",
|
||||
"GeneratorWorker",
|
||||
"QMessageHandler",
|
||||
"QSignalDebouncer",
|
||||
"QSignalThrottler",
|
||||
"WorkerBase",
|
||||
"create_worker",
|
||||
"qimage_to_array",
|
||||
"draw_colormap",
|
||||
"ensure_main_thread",
|
||||
"ensure_object_thread",
|
||||
"exceptions_as_dialog",
|
||||
"FunctionWorker",
|
||||
"GeneratorWorker",
|
||||
"new_worker_qthread",
|
||||
"qdebounced",
|
||||
"QMessageHandler",
|
||||
"QSignalDebouncer",
|
||||
"QSignalThrottler",
|
||||
"qimage_to_array",
|
||||
"qthrottled",
|
||||
"signals_blocked",
|
||||
"thread_worker",
|
||||
"WorkerBase",
|
||||
)
|
||||
|
||||
from ._code_syntax_highlight import 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
|
||||
@@ -11,7 +9,9 @@ from qtpy import QtGui
|
||||
# https://pygments.org/docs/formatterdevelopment/#html-3-2-formatter
|
||||
|
||||
|
||||
def get_text_char_format(style):
|
||||
def get_text_char_format(
|
||||
style: dict[str, QtGui.QTextCharFormat],
|
||||
) -> QtGui.QTextCharFormat:
|
||||
text_char_format = QtGui.QTextCharFormat()
|
||||
if hasattr(text_char_format, "setFontFamilies"):
|
||||
text_char_format.setFontFamilies(["monospace"])
|
||||
@@ -38,7 +38,7 @@ def get_text_char_format(style):
|
||||
class QFormatter(Formatter):
|
||||
def __init__(self, **kwargs):
|
||||
super().__init__(**kwargs)
|
||||
self.data = []
|
||||
self.data: list[QtGui.QTextCharFormat] = []
|
||||
self._style = {name: get_text_char_format(style) for name, style in self.style}
|
||||
|
||||
def format(self, tokensource, outfile):
|
||||
@@ -51,7 +51,11 @@ class QFormatter(Formatter):
|
||||
self.data = []
|
||||
|
||||
for token, value in tokensource:
|
||||
self.data.extend([self._style[token]] * len(value))
|
||||
# using get method to workaround not defined style for plain token
|
||||
# https://github.com/pygments/pygments/issues/2149
|
||||
self.data.extend(
|
||||
[self._style.get(token, QtGui.QTextCharFormat())] * len(value)
|
||||
)
|
||||
|
||||
|
||||
class CodeSyntaxHighlight(QtGui.QSyntaxHighlighter):
|
||||
@@ -68,21 +72,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
|
||||
|
@@ -1,5 +1,6 @@
|
||||
from collections.abc import Iterator
|
||||
from contextlib import contextmanager
|
||||
from typing import TYPE_CHECKING, Iterator
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from qtpy.QtCore import QObject
|
||||
|
@@ -9,9 +9,7 @@ from typing import (
|
||||
Any,
|
||||
Callable,
|
||||
ClassVar,
|
||||
Generator,
|
||||
Generic,
|
||||
Sequence,
|
||||
TypeVar,
|
||||
overload,
|
||||
)
|
||||
@@ -19,6 +17,8 @@ from typing import (
|
||||
from qtpy.QtCore import QObject, QRunnable, QThread, QThreadPool, QTimer, Signal
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from collections.abc import Generator, Sequence
|
||||
|
||||
_T = TypeVar("_T")
|
||||
|
||||
class SigInst(Generic[_T]):
|
||||
|
@@ -29,11 +29,15 @@ 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
|
||||
|
||||
@@ -53,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):
|
||||
@@ -157,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:
|
||||
@@ -203,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,
|
||||
@@ -214,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():
|
||||
@@ -251,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,
|
||||
@@ -264,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):
|
||||
@@ -292,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
|
||||
@@ -438,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
|
||||
|
@@ -69,3 +69,14 @@ def test_wrap_text():
|
||||
assert isinstance(wrap, list)
|
||||
assert all(isinstance(x, str) for x in wrap)
|
||||
assert 9 <= len(wrap) <= 13
|
||||
|
||||
|
||||
def test_minimum_size_hint():
|
||||
# The hint should always just be the space needed for "..."
|
||||
wdg = QElidingLabel()
|
||||
size_hint = wdg.minimumSizeHint()
|
||||
# Regardless of what text is contained
|
||||
wdg.setText(TEXT)
|
||||
new_hint = wdg.minimumSizeHint()
|
||||
assert size_hint.width() == new_hint.width()
|
||||
assert size_hint.height() == new_hint.height()
|
||||
|
@@ -162,7 +162,7 @@ def test_names(qapp):
|
||||
signature = inspect.signature(ob.check_object_thread_return_future)
|
||||
assert len(signature.parameters) == 1
|
||||
assert next(iter(signature.parameters.values())).name == "a"
|
||||
assert next(iter(signature.parameters.values())).annotation == int
|
||||
assert next(iter(signature.parameters.values())).annotation is int
|
||||
assert ob.check_main_thread_return.__name__ == "check_main_thread_return"
|
||||
|
||||
|
||||
|
@@ -1,5 +1,3 @@
|
||||
from typing import List, Tuple
|
||||
|
||||
import pytest
|
||||
from pytestqt.qtbot import QtBot
|
||||
from qtpy.QtCore import Qt
|
||||
@@ -30,15 +28,15 @@ def widget(qtbot: QtBot, data: dict) -> QSearchableTreeWidget:
|
||||
return widget
|
||||
|
||||
|
||||
def columns(item: QTreeWidgetItem) -> Tuple[str, str]:
|
||||
def columns(item: QTreeWidgetItem) -> tuple[str, str]:
|
||||
return item.text(0), item.text(1)
|
||||
|
||||
|
||||
def all_items(tree: QTreeWidget) -> List[QTreeWidgetItem]:
|
||||
def all_items(tree: QTreeWidget) -> list[QTreeWidgetItem]:
|
||||
return tree.findItems("", Qt.MatchContains | Qt.MatchRecursive)
|
||||
|
||||
|
||||
def shown_items(tree: QTreeWidget) -> List[QTreeWidgetItem]:
|
||||
def shown_items(tree: QTreeWidget) -> list[QTreeWidgetItem]:
|
||||
items = all_items(tree)
|
||||
return [item for item in items if not item.isHidden()]
|
||||
|
||||
|
@@ -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()
|
||||
@@ -124,7 +125,7 @@ def test_debouncer_method_definition(qtbot):
|
||||
|
||||
def test_class_with_slots(qtbot):
|
||||
class A:
|
||||
__slots__ = ("count", "__weakref__")
|
||||
__slots__ = ("__weakref__", "count")
|
||||
|
||||
def __init__(self):
|
||||
self.count = 0
|
||||
@@ -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()
|
||||
|
@@ -1,4 +1,5 @@
|
||||
from typing import Any, Iterable
|
||||
from collections.abc import Iterable
|
||||
from typing import Any
|
||||
from unittest.mock import Mock
|
||||
|
||||
import pytest
|
||||
|
@@ -1,6 +1,7 @@
|
||||
import math
|
||||
from collections.abc import Iterable
|
||||
from itertools import product
|
||||
from typing import Any, Iterable
|
||||
from typing import Any
|
||||
from unittest.mock import Mock
|
||||
|
||||
import pytest
|
||||
|
Reference in New Issue
Block a user