Compare commits

...

51 Commits

Author SHA1 Message Date
Talley Lambert
03978cc37a chore: changelog v0.7.1 2025-01-05 16:34:27 -05:00
Hanjin Liu
048aaa45a7 Lazy-import pyconify (#270)
* lazy-import pyconify

* change import pattern

---------

Co-authored-by: Talley Lambert <talley.lambert@gmail.com>
2025-01-05 16:30:51 -05:00
Talley Lambert
3ff2d7ccce feat: add QFlowLayout, for variable width widgets (#271)
* feat: add QLayout

* add to docs
2025-01-05 16:17:27 -05:00
Talley Lambert
6a7a731c5d feat: Improve CodeSyntaxHighlight object (#268) 2024-12-25 07:57:13 -05:00
Talley Lambert
4da5ac262c feat: allow chaining of QIconifyIcon.addKey (#267) 2024-12-21 12:46:01 -05:00
Talley Lambert
e471031f19 fix: better warning for download error (#266) 2024-12-14 15:29:19 -05:00
Talley Lambert
34b9851b36 chore: changelog v0.7.0 2024-12-14 14:41:40 -05:00
Talley Lambert
8ede2a2f39 build: support py313 (#264)
* build: drop py38

* bump min typing ext

* add py313

* only use pyqt6

* fix ubunt
2024-12-14 12:37:26 -05:00
Hanjin Liu
df008464cc Fix KeyError in CodeSyntaxHighlight (#258)
* use dict.get

* typing

* Update src/superqt/utils/_code_syntax_highlight.py

Co-authored-by: Grzegorz Bokota <bokota+github@gmail.com>

---------

Co-authored-by: Talley Lambert <talley.lambert@gmail.com>
Co-authored-by: Grzegorz Bokota <bokota+github@gmail.com>
2024-12-14 12:37:17 -05:00
Talley Lambert
e99adaac03 Revert "remove stylesheet on sliderLabel (#254)" (#265)
This reverts commit 7e92b81711.
2024-12-14 12:36:48 -05:00
Talley Lambert
8a40170c89 build: drop py38 (#263)
* build: drop py38

* bump min typing ext

* ignore cleanup warning from pyside6

* change minreq

* bump min

* fix for pint again
2024-12-13 09:30:27 -05:00
Gabriel Selzer
2f3113f0f6 End painter when drawing colormap (#262)
* End painter when drawing colormap

* Only end painter if we created it
2024-12-12 19:27:54 -05:00
pre-commit-ci[bot]
c9528ff85a ci: [pre-commit.ci] autoupdate (#257)
* ci: [pre-commit.ci] autoupdate

updates:
- [github.com/astral-sh/ruff-pre-commit: v0.6.9 → v0.8.1](https://github.com/astral-sh/ruff-pre-commit/compare/v0.6.9...v0.8.1)
- [github.com/abravalheri/validate-pyproject: v0.20.2 → v0.23](https://github.com/abravalheri/validate-pyproject/compare/v0.20.2...v0.23)
- [github.com/pre-commit/mirrors-mypy: v1.11.2 → v1.13.0](https://github.com/pre-commit/mirrors-mypy/compare/v1.11.2...v1.13.0)

* style: [pre-commit.ci] auto fixes [...]

---------

Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
2024-12-03 11:03:25 -05:00
Gabriel Selzer
e7a87897f5 fix: minimum size hint for QElidingLabel (#260) 2024-11-26 16:53:12 -05:00
pre-commit-ci[bot]
952ac336bf ci: [pre-commit.ci] autoupdate (#253)
* ci: [pre-commit.ci] autoupdate

updates:
- [github.com/astral-sh/ruff-pre-commit: v0.4.7 → v0.6.3](https://github.com/astral-sh/ruff-pre-commit/compare/v0.4.7...v0.6.3)
- [github.com/abravalheri/validate-pyproject: v0.18 → v0.19](https://github.com/abravalheri/validate-pyproject/compare/v0.18...v0.19)
- [github.com/pre-commit/mirrors-mypy: v1.10.0 → v1.11.2](https://github.com/pre-commit/mirrors-mypy/compare/v1.10.0...v1.11.2)

* style: [pre-commit.ci] auto fixes [...]

* fix lint

* update

* no pyside 6.8

* update pins

* quotes

---------

Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
Co-authored-by: Talley Lambert <talley.lambert@gmail.com>
2024-10-12 12:35:17 -04:00
Talley Lambert
7e92b81711 remove stylesheet on sliderLabel (#254) 2024-10-12 12:01:32 -04:00
Talley Lambert
ac4adf5234 chore: changelog v0.6.8 2024-06-15 16:58:36 -04:00
Talley Lambert
5f68795a82 feat: graceful offline fallback for qiconify (#251) 2024-06-15 07:54:40 -04:00
Talley Lambert
17ad1079a8 chore: changelog v0.6.7 2024-06-07 16:39:41 -04:00
pre-commit-ci[bot]
6bb050c499 ci: [pre-commit.ci] autoupdate (#250)
updates:
- [github.com/astral-sh/ruff-pre-commit: v0.4.3 → v0.4.7](https://github.com/astral-sh/ruff-pre-commit/compare/v0.4.3...v0.4.7)
- [github.com/abravalheri/validate-pyproject: v0.16 → v0.18](https://github.com/abravalheri/validate-pyproject/compare/v0.16...v0.18)

Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
2024-06-07 16:36:52 -04:00
Talley Lambert
1f4d9081b9 fix: prevent qthrottled and qdebounced from holding strong references with bound methods (#247)
* finish

* linting

* done

* use weakmethod, add signature

* add test for warning
2024-06-03 10:24:03 -04:00
Daniel Althviz Moré
7b1aefd119 Prevent computing full document content highlight and only parse current block content for performance (#246) 2024-05-28 07:11:57 -04:00
Talley Lambert
0ec5cd3a2f chore: changelog v0.6.6 2024-05-12 11:11:56 -04:00
Talley Lambert
8f62b0b00d perf: improve paint time for QColormapLineEdit (#245) 2024-05-12 10:32:59 -04:00
pre-commit-ci[bot]
4a0aaca2e9 ci: [pre-commit.ci] autoupdate (#244)
* ci: [pre-commit.ci] autoupdate

updates:
- [github.com/astral-sh/ruff-pre-commit: v0.3.5 → v0.4.3](https://github.com/astral-sh/ruff-pre-commit/compare/v0.3.5...v0.4.3)
- [github.com/pre-commit/mirrors-mypy: v1.9.0 → v1.10.0](https://github.com/pre-commit/mirrors-mypy/compare/v1.9.0...v1.10.0)

* style: [pre-commit.ci] auto fixes [...]

---------

Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
2024-05-06 19:18:03 -04:00
Talley Lambert
2d49e77c3d chore: changelog v0.6.5 2024-05-06 17:45:31 -04:00
Talley Lambert
ba495a5e72 fix: fix a number of issues with Labeled and Range Sliders, add LabelsOnHandle mode. (#242)
* fix: remove processEvents

* merge in fixes

* remove comment

* fix hint

* fix napari

* change pyqt6

* fix: fix range slider styles
2024-05-06 17:43:53 -04:00
Talley Lambert
12f10be8da ci: trying to fix tests on various platforms (#243)
* ci: attempt1

* add lxml_html_clean

* fix napari version name

* breakout coverage

* use main

* cump

* bump again

* bump

* bump

* skip more napari tests

* add always

* back to v1

* try editabel

* use main again

* remove editable

* editable again

* bump

* bump

* bump

* use v2
2024-05-06 15:29:43 -04:00
Talley Lambert
9ca0bbf858 chore: changelog v0.6.4 2024-04-25 15:56:57 -04:00
Talley Lambert
0ab6758972 fix: fix inverted appearance (#240)
* fix: fix inverted appearance

* style: [pre-commit.ci] auto fixes [...]

* pass codecov token

* inherit secrets

* explicitly pass token

* pin "'PyQt6<6.7'"

* pin upper pyqt6

---------

Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
2024-04-25 15:55:02 -04:00
Talley Lambert
d2bc3d898c drop to macos-13 2024-04-25 13:54:44 -04:00
Talley Lambert
1bb1a58a73 inherit secret 2024-04-22 14:08:53 -04:00
Talley Lambert
1288250597 add secret 2024-04-22 13:51:54 -04:00
pre-commit-ci[bot]
34a776e8d0 ci: [pre-commit.ci] autoupdate (#238)
updates:
- [github.com/astral-sh/ruff-pre-commit: v0.3.0 → v0.3.5](https://github.com/astral-sh/ruff-pre-commit/compare/v0.3.0...v0.3.5)
- [github.com/pre-commit/mirrors-mypy: v1.8.0 → v1.9.0](https://github.com/pre-commit/mirrors-mypy/compare/v1.8.0...v1.9.0)

Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
2024-04-22 13:15:40 -04:00
Talley Lambert
146644e105 chore: changelog v0.6.3 2024-03-27 17:34:31 -04:00
Talley Lambert
e7873ad93d fix: fix sliderReleased, sliderPressed signals, and setTracking (#237) 2024-03-27 17:32:25 -04:00
dependabot[bot]
0396d465e2 ci(dependabot): bump softprops/action-gh-release from 1 to 2 (#236)
Bumps [softprops/action-gh-release](https://github.com/softprops/action-gh-release) from 1 to 2.
- [Release notes](https://github.com/softprops/action-gh-release/releases)
- [Changelog](https://github.com/softprops/action-gh-release/blob/master/CHANGELOG.md)
- [Commits](https://github.com/softprops/action-gh-release/compare/v1...v2)

---
updated-dependencies:
- dependency-name: softprops/action-gh-release
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-03-11 15:56:10 -04:00
Talley Lambert
4bf73c37f1 chore: changelog v0.6.2 2024-03-06 15:48:43 -05:00
Talley Lambert
d407af2089 fix: don't use AbstractContextManager for exceptions_as_dialog (#234)
* fix: don't use AbstractContextManager

* fix docs
2024-03-06 15:46:28 -05:00
Talley Lambert
16f9ef9d3d style: use ruff format instead of black, update pre-commit, restrict pyside6 tests (#235)
* style: use ruff format

* fix import

* disallow pyside 6.6.2

* pin in tests too

* pyside6.4 on windows

* fix greedy imports

* double quote

* run sliders last

* try 6.6.1 again
2024-03-06 15:42:51 -05:00
Talley Lambert
56f65ff123 feat: make toggle button public in QCollapsible (#232) 2024-03-06 15:27:56 -05:00
pre-commit-ci[bot]
60188de52e ci: [pre-commit.ci] autoupdate (#228)
updates:
- [github.com/psf/black: 23.11.0 → 23.12.1](https://github.com/psf/black/compare/23.11.0...23.12.1)
- [github.com/astral-sh/ruff-pre-commit: v0.1.6 → v0.1.9](https://github.com/astral-sh/ruff-pre-commit/compare/v0.1.6...v0.1.9)
- [github.com/pre-commit/mirrors-mypy: v1.7.1 → v1.8.0](https://github.com/pre-commit/mirrors-mypy/compare/v1.7.1...v1.8.0)

Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
2024-01-09 10:09:23 -05:00
pre-commit-ci[bot]
b4d3a4f9b7 ci: [pre-commit.ci] autoupdate (#223)
updates:
- [github.com/psf/black: 23.10.1 → 23.11.0](https://github.com/psf/black/compare/23.10.1...23.11.0)
- [github.com/astral-sh/ruff-pre-commit: v0.1.4 → v0.1.6](https://github.com/astral-sh/ruff-pre-commit/compare/v0.1.4...v0.1.6)
- [github.com/pre-commit/mirrors-mypy: v1.6.1 → v1.7.1](https://github.com/pre-commit/mirrors-mypy/compare/v1.6.1...v1.7.1)

Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
2023-12-28 12:03:21 -05:00
dependabot[bot]
95b1178647 ci(dependabot): bump actions/setup-python from 4 to 5 (#225)
Bumps [actions/setup-python](https://github.com/actions/setup-python) from 4 to 5.
- [Release notes](https://github.com/actions/setup-python/releases)
- [Commits](https://github.com/actions/setup-python/compare/v4...v5)

---
updated-dependencies:
- dependency-name: actions/setup-python
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2023-12-22 17:15:47 -05:00
Peter Sobolewski
ef87685626 Bugfix: Check min max versus current value (#221)
* Check min max vs value

* add test

* style: [pre-commit.ci] auto fixes [...]

* test min too

* check that max > min per Qt

* update test

---------

Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
2023-11-25 13:29:48 -05:00
Talley Lambert
b927159f49 feat: add addKey method to QIconifyIcon (#218)
* feat: addKey method to Iconify

* style: [pre-commit.ci] auto fixes [...]

* remove breakpoint

---------

Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
2023-11-08 12:52:41 -05:00
Talley Lambert
61e7409b1c fix: better default size policy for qcollapsible (#217)
* fix: better default size policy for qcollapsible

* fix: fix annotations
2023-11-07 07:44:19 -05:00
Talley Lambert
c9103e3dd8 ci: use reusable test workflow (#215)
* ci: try resuable

* style: [pre-commit.ci] auto fixes [...]

* remove x

* fix cov

* update

* update

---------

Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
2023-11-06 18:36:36 -05:00
pre-commit-ci[bot]
570c261368 ci: [pre-commit.ci] autoupdate (#216)
updates:
- [github.com/pre-commit/pre-commit-hooks: v4.4.0 → v4.5.0](https://github.com/pre-commit/pre-commit-hooks/compare/v4.4.0...v4.5.0)
- [github.com/psf/black: 23.9.1 → 23.10.1](https://github.com/psf/black/compare/23.9.1...23.10.1)
- [github.com/astral-sh/ruff-pre-commit: v0.0.292 → v0.1.4](https://github.com/astral-sh/ruff-pre-commit/compare/v0.0.292...v0.1.4)
- [github.com/abravalheri/validate-pyproject: v0.14 → v0.15](https://github.com/abravalheri/validate-pyproject/compare/v0.14...v0.15)
- [github.com/pre-commit/mirrors-mypy: v1.5.1 → v1.6.1](https://github.com/pre-commit/mirrors-mypy/compare/v1.5.1...v1.6.1)

Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
2023-11-06 16:36:08 -05:00
Talley Lambert
bd6899133f feat: icon.name() (#213) 2023-10-23 11:20:59 -04:00
Talley Lambert
3efafd7aa8 fix: remove old dep (#212) 2023-10-10 16:52:08 -04:00
66 changed files with 1350 additions and 476 deletions

View File

@@ -6,24 +6,28 @@ 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]
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
@@ -32,126 +36,79 @@ jobs:
# lack of wheels for pyside2/py3.11
- python-version: "3.11"
backend: pyside2
include:
- python-version: "3.10"
platform: macos-latest
backend: pyside6
- python-version: "3.11"
platform: macos-latest
backend: pyside6
- python-version: "3.10"
platform: windows-latest
backend: pyside6
- python-version: "3.11"
platform: windows-latest
backend: pyside6
- 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.*"
steps:
- uses: actions/checkout@v4
test-qt-minreqs:
uses: pyapp-kit/workflows/.github/workflows/test-pyrepo.yml@v2
with:
python-version: "3.9"
qt: pyqt5
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
- 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 and not preferences_dialog_not_dismissed"'
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*"
@@ -165,7 +122,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
@@ -182,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

View File

@@ -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.9.1
hooks:
- id: black
- repo: https://github.com/astral-sh/ruff-pre-commit
rev: v0.0.292
rev: v0.8.1
hooks:
- id: ruff
args: ["--fix"]
args: [--fix, --unsafe-fixes]
- id: ruff-format
- repo: https://github.com/abravalheri/validate-pyproject
rev: v0.14
rev: v0.23
hooks:
- id: validate-pyproject
- repo: https://github.com/pre-commit/mirrors-mypy
rev: v1.5.1
rev: v1.13.0
hooks:
- id: mypy
exclude: tests|examples

View File

@@ -1,5 +1,135 @@
# Changelog
## [v0.7.1](https://github.com/pyapp-kit/superqt/tree/v0.7.1) (2025-01-05)
[Full Changelog](https://github.com/pyapp-kit/superqt/compare/v0.7.0...v0.7.1)
**Implemented enhancements:**
- feat: add QFlowLayout, for variable width widgets [\#271](https://github.com/pyapp-kit/superqt/pull/271) ([tlambert03](https://github.com/tlambert03))
- feat: Improve CodeSyntaxHighlight object [\#268](https://github.com/pyapp-kit/superqt/pull/268) ([tlambert03](https://github.com/tlambert03))
- feat: allow chaining of QIconifyIcon.addKey [\#267](https://github.com/pyapp-kit/superqt/pull/267) ([tlambert03](https://github.com/tlambert03))
**Fixed bugs:**
- fix: better warning for download error [\#266](https://github.com/pyapp-kit/superqt/pull/266) ([tlambert03](https://github.com/tlambert03))
**Merged pull requests:**
- Lazy-import `pyconify` [\#270](https://github.com/pyapp-kit/superqt/pull/270) ([hanjinliu](https://github.com/hanjinliu))
## [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)
**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)
@@ -382,13 +512,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)

View File

@@ -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

View File

@@ -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

View File

@@ -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"![{page.title}](../{dest.parent.name}/{dest.name})"

View File

@@ -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

View File

@@ -33,3 +33,4 @@ The following are QWidget subclasses:
| Widget | Description |
| ----------- | --------------------- |
| [`QCollapsible`](./qcollapsible.md) | A collapsible widget to hide and unhide child widgets. |
| [`QFlowLayout`](./qflowlayout.md) | A layout that rearranges items based on parent width. |

View File

@@ -0,0 +1,29 @@
# QFlowLayout
QLayout that rearranges items based on parent width.
```python
from qtpy.QtWidgets import QApplication, QPushButton, QWidget
from superqt import QFlowLayout
app = QApplication([])
wdg = QWidget()
layout = QFlowLayout(wdg)
layout.addWidget(QPushButton("Short"))
layout.addWidget(QPushButton("Longer"))
layout.addWidget(QPushButton("Different text"))
layout.addWidget(QPushButton("More text"))
layout.addWidget(QPushButton("Even longer button text"))
wdg.setWindowTitle("Flow Layout")
wdg.show()
app.exec()
```
{{ show_widget(350) }}
{{ show_members('superqt.QFlowLayout') }}

View File

@@ -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

View File

@@ -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 {

19
examples/flow_layout.py Normal file
View File

@@ -0,0 +1,19 @@
from qtpy.QtWidgets import QApplication, QPushButton, QWidget
from superqt import QFlowLayout
app = QApplication([])
wdg = QWidget()
layout = QFlowLayout(wdg)
layout.addWidget(QPushButton("Short"))
layout.addWidget(QPushButton("Longer"))
layout.addWidget(QPushButton("Different text"))
layout.addWidget(QPushButton("More text"))
layout.addWidget(QPushButton("Even longer button text"))
wdg.setWindowTitle("Flow Layout")
wdg.show()
app.exec()

View File

@@ -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

View File

@@ -1,4 +1,5 @@
"""Example for QCollapsible."""
from qtpy.QtWidgets import QApplication, QLabel, QPushButton
from superqt import QCollapsible

View File

@@ -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)

View File

@@ -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,29 +28,35 @@ 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",
]
dynamic = ["version"]
dependencies = [
"packaging",
"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 = [
"black",
"ipython",
"ruff",
"mypy",
@@ -60,16 +66,23 @@ 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"]
# 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,<6.8"]
pyqt5 = ["pyqt5"]
pyqt6 = ["pyqt6"]
pyqt6 = ["pyqt6<6.7"]
font-fa5 = ["fonticon-fontawesome5"]
font-fa6 = ["fonticon-fontawesome6"]
font-mi6 = ["fonticon-materialdesignicons6"]
@@ -101,69 +114,75 @@ 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://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"
target-version = "py39"
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
"TC", # 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",
"ignore:.*Skipping callback call set_result",
]
# https://mypy.readthedocs.io/en/stable/config_file.html
@@ -187,15 +206,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

View File

@@ -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,7 +11,6 @@ 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,
@@ -22,11 +22,14 @@ from .sliders import (
QRangeSlider,
)
from .spinbox import QLargeIntSpinBox
from .utils import QMessageHandler, ensure_main_thread, ensure_object_thread
from .utils import (
QFlowLayout,
QMessageHandler,
ensure_main_thread,
ensure_object_thread,
)
__all__ = [
"ensure_main_thread",
"ensure_object_thread",
"QCollapsible",
"QColorComboBox",
"QColormapComboBox",
@@ -35,8 +38,9 @@ __all__ = [
"QElidingLabel",
"QElidingLineEdit",
"QEnumComboBox",
"QLabeledDoubleRangeSlider",
"QFlowLayout",
"QIconifyIcon",
"QLabeledDoubleRangeSlider",
"QLabeledDoubleSlider",
"QLabeledRangeSlider",
"QLabeledSlider",
@@ -47,20 +51,27 @@ __all__ = [
"QSearchableComboBox",
"QSearchableListWidget",
"QSearchableTreeWidget",
"ensure_main_thread",
"ensure_object_thread",
]
if TYPE_CHECKING:
from .combobox import QColormapComboBox
from .spinbox._quantity import QQuantity
from .combobox import QColormapComboBox # noqa: TC004
from .iconify import QIconifyIcon # noqa: TC004
from .spinbox._quantity import QQuantity # noqa: TC004
def __getattr__(name: str) -> Any:
if name == "QQuantity":
from .spinbox._quantity import QQuantity
return QQuantity
if name == "QColormapComboBox":
from .cmap import QColormapComboBox
return QColormapComboBox
if name == "QIconifyIcon":
from .iconify import QIconifyIcon
return QIconifyIcon
if name == "QQuantity":
from .spinbox._quantity import QQuantity
return QQuantity
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")

View File

@@ -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",
]

View File

@@ -1,10 +1,9 @@
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
from qtpy.QtGui import QKeyEvent
from qtpy.QtWidgets import QComboBox, QCompleter, QWidget
from ._cmap_item_delegate import QColormapItemDelegate
@@ -12,7 +11,10 @@ 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
class CmapCatalogComboBox(QComboBox):

View File

@@ -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

View File

@@ -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)

View File

@@ -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

View File

@@ -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)
@@ -119,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

View File

@@ -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)

View File

@@ -13,7 +13,7 @@ __all__ = (
if TYPE_CHECKING:
from superqt.cmap import QColormapComboBox
from superqt.cmap import QColormapComboBox # noqa: TC004
def __getattr__(name: str) -> Any: # pragma: no cover

View File

@@ -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

View File

@@ -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

View File

@@ -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())

View File

@@ -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())

View File

@@ -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",
]
@@ -104,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
@@ -130,7 +130,7 @@ def icon(
... "disabled": {
... "color": "green",
... "scale_factor": 0.8,
... "animation": spin(btn)
... "animation": spin(btn),
... },
... },
... )

View File

@@ -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):

View File

@@ -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():

View File

@@ -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 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
@@ -25,7 +26,8 @@ from typing_extensions import TypedDict
from superqt.utils import QMessageHandler
from ._animations import Animation
if TYPE_CHECKING:
from ._animations import Animation
class Unset:
@@ -46,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,
]
@@ -157,9 +159,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()

View File

@@ -1,8 +1,20 @@
from __future__ import annotations
import warnings
from typing import TYPE_CHECKING
from qtpy.QtGui import QIcon
from qtpy.QtCore import QSize, Qt
from qtpy.QtGui import QIcon, QPainter, QPixmap
from qtpy.QtWidgets import QApplication
try:
from pyconify import svg_path
except ModuleNotFoundError: # 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."
) from None
if TYPE_CHECKING:
from typing import Literal
@@ -10,6 +22,8 @@ if TYPE_CHECKING:
Flip = Literal["horizontal", "vertical", "horizontal,vertical"]
Rotation = Literal["90", "180", "270", 90, 180, 270, "-90", 1, 2, 3]
__all__ = ["QIconifyIcon"]
class QIconifyIcon(QIcon):
"""QIcon backed by an iconify icon.
@@ -27,6 +41,9 @@ class QIconifyIcon(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
@@ -63,13 +80,77 @@ class QIconifyIcon(QIcon):
rotate: Rotation | None = None,
dir: str | None = None,
):
super().__init__()
if key:
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,
) -> QIconifyIcon:
"""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`.
Returns
-------
QIconifyIcon
This QIconifyIcon instance, for chaining.
"""
try:
from pyconify import svg_path
except ModuleNotFoundError as e: # pragma: no cover
raise ImportError(
"pyconify is required to use QIconifyIcon. "
"Please install it with `pip install pyconify` or use the "
"`pip install superqt[iconify]` extra."
) from e
self.path = svg_path(*key, color=color, flip=flip, rotate=rotate, dir=dir)
super().__init__(str(self.path))
path = svg_path(*key, color=color, flip=flip, rotate=rotate, dir=dir)
except OSError as e:
warnings.warn(
f"Error fetching icon: {e}.\nIcon {key} not cached. Using fallback.",
stacklevel=2,
)
self._draw_text_fallback(key)
else:
self.addFile(str(path), size or QSize(), mode, state)
return self
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)

View File

@@ -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__):

View File

@@ -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

View File

@@ -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",
]

View File

@@ -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

View File

@@ -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()

View File

@@ -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,19 +162,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 +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
@@ -251,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):
@@ -263,14 +297,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 +326,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 +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
@@ -324,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
@@ -358,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:
@@ -387,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:
@@ -415,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()
@@ -431,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()
@@ -442,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 (
@@ -455,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
@@ -482,6 +541,7 @@ class QLabeledRangeSlider(_SliderProxy, QAbstractSlider):
label.move(pos)
last_edge = pos
label.clearFocus()
label.raise_()
label.show()
self.update()
@@ -545,14 +605,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 +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__(

View File

@@ -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

View File

@@ -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)

View File

@@ -1,32 +1,34 @@
from typing import TYPE_CHECKING, Any
if TYPE_CHECKING:
from superqt.cmap import draw_colormap
from superqt.cmap import draw_colormap # noqa: TC004
__all__ = (
"CodeSyntaxHighlight",
"FunctionWorker",
"GeneratorWorker",
"QFlowLayout",
"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
from ._ensure_thread import ensure_main_thread, ensure_object_thread
from ._errormsg_context import exceptions_as_dialog
from ._flow_layout import QFlowLayout
from ._img_utils import qimage_to_array
from ._message_handler import QMessageHandler
from ._misc import signals_blocked

View File

@@ -1,88 +1,268 @@
from itertools import takewhile
from __future__ import annotations
from typing import TYPE_CHECKING, Any, cast
from pygments import highlight
from pygments.formatter import Formatter
from pygments.lexers import find_lexer_class, get_lexer_by_name
from pygments.util import ClassNotFound
from qtpy import QtGui
from qtpy.QtGui import (
QColor,
QFont,
QPalette,
QSyntaxHighlighter,
QTextCharFormat,
QTextDocument,
)
if TYPE_CHECKING:
from collections.abc import Mapping, Sequence
from typing import Literal, TypeAlias, TypedDict, Unpack
import pygments.style
from pygments.style import _StyleDict
from pygments.token import _TokenType
from qtpy.QtCore import QObject
class SupportsDocumentAndPalette(QObject):
def document(self) -> QTextDocument | None: ...
def palette(self) -> QPalette: ...
def setPalette(self, palette: QPalette) -> None: ...
KnownStyle: TypeAlias = Literal[
"abap",
"algol",
"algol_nu",
"arduino",
"autumn",
"bw",
"borland",
"coffee",
"colorful",
"default",
"dracula",
"emacs",
"friendly_grayscale",
"friendly",
"fruity",
"github-dark",
"gruvbox-dark",
"gruvbox-light",
"igor",
"inkpot",
"lightbulb",
"lilypond",
"lovelace",
"manni",
"material",
"monokai",
"murphy",
"native",
"nord-darker",
"nord",
"one-dark",
"paraiso-dark",
"paraiso-light",
"pastie",
"perldoc",
"rainbow_dash",
"rrt",
"sas",
"solarized-dark",
"solarized-light",
"staroffice",
"stata-dark",
"stata-light",
"tango",
"trac",
"vim",
"vs",
"xcode",
"zenburn",
]
class FormatterKwargs(TypedDict, total=False):
style: KnownStyle | str
full: bool
title: str
encoding: str
outencoding: str
MONO_FAMILIES = [
"Menlo",
"Courier New",
"Courier",
"Monaco",
"Consolas",
"Andale Mono",
"Source Code Pro",
"Ubuntu Mono",
"monospace",
]
# inspired by https://github.com/Vector35/snippets/blob/master/QCodeEditor.py
# (MIT license) and
# https://pygments.org/docs/formatterdevelopment/#html-3-2-formatter
def get_text_char_format(style: _StyleDict) -> QTextCharFormat:
"""Return a QTextCharFormat object based on the given Pygments `_StyleDict`.
def get_text_char_format(style):
text_char_format = QtGui.QTextCharFormat()
if hasattr(text_char_format, "setFontFamilies"):
text_char_format.setFontFamilies(["monospace"])
else:
text_char_format.setFontFamily("monospace")
if style.get("color"):
text_char_format.setForeground(QtGui.QColor(f"#{style['color']}"))
if style.get("bgcolor"):
text_char_format.setBackground(QtGui.QColor(style["bgcolor"]))
style will likely have these keys:
- color: str | None
- bold: bool
- italic: bool
- underline: bool
- bgcolor: str | None
- border: str | None
- roman: bool | None
- sans: bool | None
- mono: bool | None
- ansicolor: str | None
- bgansicolor: str | None
"""
text_char_format = QTextCharFormat()
if style.get("mono"):
text_char_format.setFontFamilies(MONO_FAMILIES)
if color := style.get("color"):
text_char_format.setForeground(QColor(f"#{color}"))
if bgcolor := style.get("bgcolor"):
text_char_format.setBackground(QColor(f"#{bgcolor}"))
if style.get("bold"):
text_char_format.setFontWeight(QtGui.QFont.Bold)
text_char_format.setFontWeight(QFont.Weight.Bold)
if style.get("italic"):
text_char_format.setFontItalic(True)
if style.get("underline"):
text_char_format.setFontUnderline(True)
# TODO find if it is possible to support border style.
# if style.get("border"):
# ...
return text_char_format
class QFormatter(Formatter):
def __init__(self, **kwargs):
def __init__(self, **kwargs: Unpack[FormatterKwargs]) -> None:
super().__init__(**kwargs)
self.data = []
self._style = {name: get_text_char_format(style) for name, style in self.style}
self.data: list[QTextCharFormat] = []
style = cast("pygments.style.StyleMeta", self.style)
self._style: Mapping[_TokenType, QTextCharFormat]
self._style = {token: get_text_char_format(style) for token, style in style}
def format(self, tokensource, outfile):
def format(
self, tokensource: Sequence[tuple[_TokenType, str]], outfile: Any
) -> None:
"""Format the given token stream.
`outfile` is argument from parent class, but
in Qt we do not produce string output, but QTextCharFormat, so it needs to be
collected using `self.data`.
When Qt calls the highlightBlock method on a `CodeSyntaxHighlight` object,
`highlight(text, self.lexer, self.formatter)`, which trigger pygments to call
this method.
Normally, this method puts output into `outfile`, but in Qt we do not produce
string output; instead we collect QTextCharFormat objects in `self.data`, which
can be used to apply formatting in the `highlightBlock` method that triggered
this method.
"""
self.data = []
null = QTextCharFormat()
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, null)] * len(value))
class CodeSyntaxHighlight(QtGui.QSyntaxHighlighter):
def __init__(self, parent, lang, theme):
class CodeSyntaxHighlight(QSyntaxHighlighter):
"""A syntax highlighter for code using Pygments.
Parameters
----------
parent : QTextDocument | QObject | None
The parent object. Usually a QTextDocument. To use this class with a
QTextArea, pass in `text_area.document()`.
lang : str
The language of the code to highlight. This should be a string that
Pygments recognizes, e.g. 'python', 'pytb', 'cpp', 'java', etc.
theme : KnownStyle | str
The name of the Pygments style to use. For a complete list of available
styles, use `pygments.styles.get_all_styles()`.
Examples
--------
```python
from qtpy.QtWidgets import QTextEdit
from superqt.utils import CodeSyntaxHighlight
text_area = QTextEdit()
highlighter = CodeSyntaxHighlight(text_area.document(), "python", "monokai")
# then manually apply the background color to the text area.
palette = text_area.palette()
bgrd_color = QColor(self._highlight.background_color)
palette.setColor(QPalette.ColorRole.Base, bgrd_color)
text_area.setPalette(palette)
```
"""
def __init__(
self,
parent: SupportsDocumentAndPalette | QTextDocument | QObject | None,
lang: str,
theme: KnownStyle | str = "default",
) -> None:
self._doc_parent: SupportsDocumentAndPalette | None = None
if (
parent
and not isinstance(parent, QTextDocument)
and hasattr(parent, "document")
and callable(parent.document)
and isinstance(doc := parent.document(), QTextDocument)
):
if hasattr(parent, "palette") and hasattr(parent, "setPalette"):
self._doc_parent = cast("SupportsDocumentAndPalette", parent)
parent = doc
super().__init__(parent)
self.setLanguage(lang)
self.setTheme(theme)
def setTheme(self, theme: KnownStyle | str) -> None:
"""Set the theme for the syntax highlighting.
This should be a string that Pygments recognizes, e.g. 'monokai', 'solarized'.
Use `pygments.styles.get_all_styles()` to see a list of available styles.
"""
self.formatter = QFormatter(style=theme)
if self._doc_parent is not None:
palette = self._doc_parent.palette()
bgrd = QColor(self.background_color)
palette.setColor(QPalette.ColorRole.Base, bgrd)
self._doc_parent.setPalette(palette)
self.rehighlight()
def setLanguage(self, lang: str) -> None:
"""Set the language for the syntax highlighting.
This should be a string that Pygments recognizes, e.g. 'python', 'pytb', 'cpp',
'java', etc.
"""
try:
self.lexer = get_lexer_by_name(lang)
except ClassNotFound:
self.lexer = find_lexer_class(lang)()
except ClassNotFound as e:
if cls := find_lexer_class(lang):
self.lexer = cls()
else:
raise ValueError(f"Could not find lexer for language {lang!r}.") from e
@property
def background_color(self):
return self.formatter.style.background_color
def highlightBlock(self, text):
cb = self.currentBlock()
p = cb.position()
text_ = self.document().toPlainText() + "\n"
highlight(text_, self.lexer, self.formatter)
enters = sum(1 for _ in takewhile(lambda x: x == "\n", text_))
# pygments lexer ignore leading empty lines, so we need to do correction
# here calculating the number of empty lines.
def background_color(self) -> str:
style = cast("pygments.style.StyleMeta", self.formatter.style)
return style.background_color
def highlightBlock(self, text: str | None) -> None:
# dirty, dirty hack
# The core problem is that pygemnts by default use string streams,
# The core problem is that pygments by default use string streams,
# that will not handle QTextCharFormat, so we need use `data` property to
# work around this.
for i in range(len(text)):
try:
self.setFormat(i, 1, self.formatter.data[p + i - enters])
except IndexError: # pragma: no cover
pass
if text:
highlight(text, self.lexer, self.formatter)
for i in range(len(text)):
self.setFormat(i, 1, self.formatter.data[i])

View File

@@ -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
):

View File

@@ -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

View File

@@ -0,0 +1,183 @@
from __future__ import annotations
from qtpy.QtCore import QPoint, QRect, QSize, Qt
from qtpy.QtWidgets import QLayout, QLayoutItem, QSizePolicy, QStyle, QWidget
class QFlowLayout(QLayout):
"""Layout that handles different window sizes.
The widget placement changes depending on the width of the application window.
Code translated from C++ at:
<https://code.qt.io/cgit/qt/qtbase.git/tree/examples/widgets/layouts/flowlayout>
described at: <https://doc.qt.io/qt-6/qtwidgets-layouts-flowlayout-example.html>
See also: <https://doc.qt.io/qt-6/layout.html>
Parameters
----------
parent : QWidget, optional
The parent widget, by default None
"""
def __init__(self, parent: QWidget | None = None) -> None:
super().__init__(parent)
self._item_list: list[QLayoutItem] = []
self._h_space = -1
self._v_space = -1
def __del__(self) -> None:
while item := self.takeAt(0):
del item
def addItem(self, item: QLayoutItem | None) -> None:
"""Add an item to the layout."""
if item:
self._item_list.append(item)
def setHorizontalSpacing(self, space: int | None) -> None:
"""Set the horizontal spacing.
If None or -1, the spacing is set to the default value based on the style
of the parent widget.
"""
self._h_space = -1 if space is None else space
def horizontalSpacing(self) -> int:
"""Return the horizontal spacing."""
if self._h_space >= 0:
return self._h_space
return self._smartSpacing(QStyle.PixelMetric.PM_LayoutHorizontalSpacing)
def setVerticalSpacing(self, space: int | None) -> None:
"""Set the vertical spacing.
If None or -1, the spacing is set to the default value based on the style
of the parent widget.
"""
self._v_space = -1 if space is None else space
def verticalSpacing(self) -> int:
"""Return the vertical spacing."""
if self._v_space >= 0:
return self._v_space
return self._smartSpacing(QStyle.PixelMetric.PM_LayoutVerticalSpacing)
def expandingDirections(self) -> Qt.Orientation:
"""Return the expanding directions.
These are the Qt::Orientations in which the layout can make use of more space
than its sizeHint().
"""
return Qt.Orientation.Horizontal
def hasHeightForWidth(self) -> bool:
"""Return whether the layout handles height for width."""
return True
def heightForWidth(self, width: int) -> int:
"""Return the height for a given width.
`heightForWidth()` passes the width on to _doLayout() which in turn uses the
width as an argument for the layout rect, i.e., the bounds in which the items
are laid out. This rect does not include the layout margin().
"""
return self._doLayout(QRect(0, 0, width, 0), True)
def count(self) -> int:
"""Return the number of items in the layout."""
return len(self._item_list)
def itemAt(self, index: int) -> QLayoutItem | None:
"""Return the item at the given index, or None if the index is out of range."""
try:
return self._item_list[index]
except IndexError:
return None
def minimumSize(self) -> QSize:
"""Return the minimum size of the layout."""
size = QSize()
for item in self._item_list:
size = size.expandedTo(item.minimumSize())
margins = self.contentsMargins()
size += QSize(
margins.left() + margins.right(), margins.top() + margins.bottom()
)
return size
def setGeometry(self, rect: QRect) -> None:
"""Set the geometry of the layout.
This triggers a re-layout of the items.
"""
super().setGeometry(rect)
self._doLayout(rect)
def sizeHint(self) -> QSize:
"""Return the size hint of the layout."""
return self.minimumSize()
def takeAt(self, index: int) -> QLayoutItem | None:
"""Remove and return the item at the given index. Or return None."""
if 0 <= index < len(self._item_list):
return self._item_list.pop(index)
return None
def _doLayout(self, rect: QRect, test_only: bool = False) -> int:
"""Arrange the items in the layout.
If test_only is True, the items are not actually laid out, but the height
that the layout would have with the given width is returned.
"""
left, top, right, bottom = self.getContentsMargins()
effective_rect = rect.adjusted(left, top, -right, -bottom) # type: ignore
x = effective_rect.x()
y = effective_rect.y()
line_height = 0
for item in self._item_list:
if (wid := item.widget()) and (style := wid.style()):
space_x = self.horizontalSpacing()
space_y = self.verticalSpacing()
if space_x == -1:
space_x = style.layoutSpacing(
QSizePolicy.ControlType.PushButton,
QSizePolicy.ControlType.PushButton,
Qt.Orientation.Horizontal,
)
if space_y == -1:
space_y = style.layoutSpacing(
QSizePolicy.ControlType.PushButton,
QSizePolicy.ControlType.PushButton,
Qt.Orientation.Vertical,
)
# next_x is the x-coordinate of the right edge of the item
next_x = x + item.sizeHint().width() + space_x
# if the item is not the first one in a line, add the spacing
# to the left of it
if next_x - space_x > effective_rect.right() and line_height > 0:
x = effective_rect.x()
y = y + line_height + space_y
next_x = x + item.sizeHint().width() + space_x
line_height = 0
# if this is not a test run, move the item to its proper place
if not test_only:
item.setGeometry(QRect(QPoint(x, y), item.sizeHint()))
x = next_x
line_height = max(line_height, item.sizeHint().height())
return y + line_height - rect.y() + bottom
def _smartSpacing(self, pm: QStyle.PixelMetric) -> int:
"""Return the smart spacing based on the style of the parent widget."""
if isinstance(parent := self.parent(), QWidget) and (style := parent.style()):
return style.pixelMetric(pm, None, parent)
return -1

View File

@@ -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]] = {

View File

@@ -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

View File

@@ -9,9 +9,7 @@ from typing import (
Any,
Callable,
ClassVar,
Generator,
Generic,
Sequence,
TypeVar,
overload,
)
@@ -19,20 +17,19 @@ 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]):
@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},
)
```
"""

View File

@@ -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

View File

@@ -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()

View File

@@ -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"

27
tests/test_flow_layout.py Normal file
View File

@@ -0,0 +1,27 @@
from typing import Any
from qtpy.QtWidgets import QPushButton, QWidget
from superqt import QFlowLayout
def test_flow_layout(qtbot: Any) -> None:
wdg = QWidget()
qtbot.addWidget(wdg)
layout = QFlowLayout(wdg)
layout.addWidget(QPushButton("Short"))
layout.addWidget(QPushButton("Longer"))
layout.addWidget(QPushButton("Different text"))
layout.addWidget(QPushButton("More text"))
layout.addWidget(QPushButton("Even longer button text"))
wdg.setWindowTitle("Flow Layout")
wdg.show()
assert layout.expandingDirections()
assert layout.heightForWidth(200) > layout.heightForWidth(400)
assert layout.count() == 5
assert layout.itemAt(0).widget().text() == "Short"
layout.takeAt(0)
assert layout.count() == 4

View File

@@ -1,6 +1,7 @@
from typing import TYPE_CHECKING
import pytest
from qtpy.QtGui import QIcon
from qtpy.QtWidgets import QPushButton
from superqt import QIconifyIcon
@@ -13,8 +14,8 @@ def test_qiconify(qtbot: "QtBot", monkeypatch: "pytest.MonkeyPatch") -> None:
monkeypatch.setenv("PYCONIFY_CACHE", "0")
pytest.importorskip("pyconify")
icon = QIconifyIcon("bi:alarm-fill", color="red", rotate=90)
assert icon.path.name.endswith(".svg")
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)

View File

@@ -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)

View File

@@ -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()]

View File

@@ -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()

View File

@@ -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

View File

@@ -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