Compare commits

...

40 Commits

Author SHA1 Message Date
Talley Lambert
dd9af3bfed chore: changelog v0.5.0 2023-08-06 09:03:14 -04:00
Talley Lambert
7b964beb89 feat: add stepType to largeInt spinbox (#179) 2023-08-06 08:57:22 -04:00
Talley Lambert
0407fdc4bd build: unpin pyside6.5 (#178) 2023-08-05 19:01:25 -04:00
Daniel Althviz Moré
9119336de5 Add QElidingLineEdit class for elidable QLineEdits (#154)
* Add QElidingLineEdit class for elidable QLineEdits

* Fix QElidingLineEdit tests on Linux and MacOS

* Testing

---------

Co-authored-by: Talley Lambert <talley.lambert@gmail.com>
2023-08-03 11:08:12 -04:00
pre-commit-ci[bot]
6318675a8c ci: [pre-commit.ci] autoupdate (#173)
* ci: [pre-commit.ci] autoupdate

updates:
- [github.com/psf/black: 23.3.0 → 23.7.0](https://github.com/psf/black/compare/23.3.0...23.7.0)
- https://github.com/charliermarsh/ruff-pre-commithttps://github.com/astral-sh/ruff-pre-commit
- [github.com/astral-sh/ruff-pre-commit: v0.0.270 → v0.0.281](https://github.com/astral-sh/ruff-pre-commit/compare/v0.0.270...v0.0.281)
- [github.com/pre-commit/mirrors-mypy: v1.3.0 → v1.4.1](https://github.com/pre-commit/mirrors-mypy/compare/v1.3.0...v1.4.1)

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

* fix: fix precommit

* typing

---------

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>
2023-08-03 09:30:36 -04:00
Talley Lambert
efa2757111 fix: focus events on QLabeledSlider (#175)
* fix: focus events on QLabeledSlider

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

---------

Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
2023-07-10 11:11:28 -04:00
Grzegorz Bokota
402d237bc4 set parent of timer (#171) 2023-06-08 21:01:08 -04:00
pre-commit-ci[bot]
dc255bdeac ci: [pre-commit.ci] autoupdate (#170) 2023-06-06 07:40:44 -04:00
Talley Lambert
ae186df2ae build: pin pyside (#169) 2023-05-30 14:14:24 -04:00
Talley Lambert
0002d5ee37 fix: fix double slider label editing (#168) 2023-05-30 13:24:36 -04:00
Talley Lambert
f990fea78c test: add qtbot to test to fix windows segfault (#165)
* test: fix windows test

* test on windows

* try ubuntu

* remove ubuntu
2023-05-20 15:53:45 -04:00
Talley Lambert
1fb46854d4 test: fixing tests [wip] (#164) 2023-05-19 20:43:52 -04:00
pre-commit-ci[bot]
ca4a1ecb20 ci: [pre-commit.ci] autoupdate (#162)
* ci: [pre-commit.ci] autoupdate

updates:
- [github.com/charliermarsh/ruff-pre-commit: v0.0.260 → v0.0.263](https://github.com/charliermarsh/ruff-pre-commit/compare/v0.0.260...v0.0.263)
- [github.com/pre-commit/mirrors-mypy: v1.1.1 → v1.2.0](https://github.com/pre-commit/mirrors-mypy/compare/v1.1.1...v1.2.0)

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

---------

Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
2023-05-16 21:24:34 -04:00
Talley Lambert
c22b7d6f07 pin pyside6 (#160) 2023-04-20 19:16:43 -04:00
Andy Sweet
bb43cd7fad Searchable tree widget from a mapping (#158)
* Crude searchable tree widget with example

* Add logging and fix hiding bug

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

* Add factory method

* Use regular expression instead

* Reduce API

* Make setData public

* Clear filter when setting data

* Visible instead of hidden

* Show item when parent is visible

* Add docs

* Empty commit to [skip ci]

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

* Empty commit to [skip ci]

* Add test coverage

* Improve readability of tests

* Use python not json names

* Simplify example

* Some optimizations

* Clean up tests

* Fix visible siblings

* Modify test to cover visible sibling

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

* fix lint

* Update src/superqt/selection/_searchable_tree_widget.py

Co-authored-by: Talley Lambert <talley.lambert@gmail.com>

* Search by value too

* Remove optimizations

* Clean up formatting

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

---------

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>
2023-04-20 19:15:26 -04:00
pre-commit-ci[bot]
09c76a0bfa ci: [pre-commit.ci] autoupdate (#156)
* ci: [pre-commit.ci] autoupdate

updates:
- [github.com/psf/black: 23.1.0 → 23.3.0](https://github.com/psf/black/compare/23.1.0...23.3.0)
- [github.com/charliermarsh/ruff-pre-commit: v0.0.254 → v0.0.260](https://github.com/charliermarsh/ruff-pre-commit/compare/v0.0.254...v0.0.260)
- [github.com/abravalheri/validate-pyproject: v0.12.1 → v0.12.2](https://github.com/abravalheri/validate-pyproject/compare/v0.12.1...v0.12.2)

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

* fix: fix precommit

---------

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>
2023-04-06 19:51:39 -04:00
Talley Lambert
183899c4e7 update pre-commit (#151) 2023-03-27 12:57:58 -04:00
Kian-Meng Ang
a39b467563 Fix typos (#147)
* Fix typos and add codespell pre-commit hook

* Update .pre-commit-config.yaml

---------

Co-authored-by: Talley Lambert <talley.lambert@gmail.com>
2023-03-12 00:01:42 -05:00
pre-commit-ci[bot]
6ce87d44a6 ci: [pre-commit.ci] autoupdate (#146)
* ci: [pre-commit.ci] autoupdate

updates:
- [github.com/charliermarsh/ruff-pre-commit: v0.0.149 → v0.0.161](https://github.com/charliermarsh/ruff-pre-commit/compare/v0.0.149...v0.0.161)

* fix: fix linting

* style: add docstyle

* style: formatting

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>
2022-12-06 12:02:27 -05:00
Talley Lambert
2cebc868a8 chore: changelog v0.4.1 2022-12-01 08:25:36 -05:00
Talley Lambert
6abd3a21a6 build: use hatch for build backend, and use ruff for linting (#139)
* style: ruff fixes

* style: no implicit optional

* keep mypy manual

* build: add fake setup.py

* build: use hatch

* update ruff

* update ruff settings

* chore: merge

* smaller sdist

* fix: fix qfont typing

* fix types again

* add toc permalink

* ignore setup.py from sdist
2022-12-01 08:21:03 -05:00
Pam
7b2d8bfb2d Change icon used in Collapsible widget (#140)
* add ability to change icon

* fix icon setting so it will load properly on start up

* remove check on icon length.  not necessary anymore

* fix test

* reduce duplicate code.  expose _COLLAPSED and _EXPANDED to user on creation of QCollapsible widget

* add ability to set icon with string or icon.

* add tests for adding, setting icons

* fix test.

* fix test for icons

* move file

* fix test

* remove hardcoded size.  Use font size

* add test docstring

* fix test.  chnage expanded/collapsed names

* remove unnecessary strings

* update example.  add getter functions.  remove lines.  change function name

* put default string in init.  add getter tests

* update test

* cleanup typing and fix set setCollapsedIcon

Co-authored-by: Talley Lambert <talley.lambert@gmail.com>
2022-11-30 20:45:07 -05:00
Pam
ad2f05d908 Move QCollapsible toggle signal emit (#144)
* Emit toggle signal when animate is True.

* add flag to emit signal

* add docstring
2022-11-30 17:50:01 -05:00
Pam
3df7f49706 Add signal to QCollapsible (#142)
* add signal when toggle button is clicked

* emit signal when expand/collapse are called. emit bool. add to test.

* fix signal emission

Co-authored-by: Talley Lambert <talley.lambert@gmail.com>
2022-11-27 20:43:05 -05:00
pre-commit-ci[bot]
e98936e8d8 [pre-commit.ci] pre-commit autoupdate (#141)
updates:
- [github.com/asottile/pyupgrade: v2.34.0 → v3.2.2](https://github.com/asottile/pyupgrade/compare/v2.34.0...v3.2.2)
- [github.com/psf/black: 22.3.0 → 22.10.0](https://github.com/psf/black/compare/22.3.0...22.10.0)
- [github.com/PyCQA/flake8: 4.0.1 → 5.0.4](https://github.com/PyCQA/flake8/compare/4.0.1...5.0.4)
- [github.com/asottile/pyupgrade: v3.2.0 → v3.2.2](https://github.com/asottile/pyupgrade/compare/v3.2.0...v3.2.2)
- [github.com/pre-commit/mirrors-mypy: v0.982 → v0.991](https://github.com/pre-commit/mirrors-mypy/compare/v0.982...v0.991)

Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
2022-11-24 13:34:05 -05:00
Talley Lambert
532d3bf89c chore: rename napari org to pyapp-kit (#137) 2022-11-11 08:39:22 -05:00
Talley Lambert
16b383e783 chore: changelog v0.4.0 (#136) 2022-11-09 06:58:20 -05:00
dependabot[bot]
38d15d1b3b ci(dependabot): bump codecov/codecov-action from 2 to 3 (#134)
Bumps [codecov/codecov-action](https://github.com/codecov/codecov-action) from 2 to 3.
- [Release notes](https://github.com/codecov/codecov-action/releases)
- [Changelog](https://github.com/codecov/codecov-action/blob/main/CHANGELOG.md)
- [Commits](https://github.com/codecov/codecov-action/compare/v2...v3)

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

Signed-off-by: dependabot[bot] <support@github.com>

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2022-11-08 20:33:45 -05:00
dependabot[bot]
8f09c38074 ci(dependabot): bump actions/upload-artifact from 2 to 3 (#135)
Bumps [actions/upload-artifact](https://github.com/actions/upload-artifact) from 2 to 3.
- [Release notes](https://github.com/actions/upload-artifact/releases)
- [Commits](https://github.com/actions/upload-artifact/compare/v2...v3)

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

Signed-off-by: dependabot[bot] <support@github.com>

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2022-11-08 20:33:33 -05:00
Talley Lambert
3c8b5bcf98 refactor: update pyproject and ci, add py3.11 test (#132)
* refactor: reorg repo

* fix: include pyi in manifest

* remove extra

* changes

* why no trigger

* fix needs

* include python 3.11

* remove cache

* add back license

* bump versions

* fix py37

* fix napari test

* remove timeout

* fix py37 test

* test: fix py311 tests

* change windows test
2022-11-08 20:32:47 -05:00
Talley Lambert
3ece7a27b1 build: unpin pyside6 (#133)
* build: unpin pyside6

* [pre-commit.ci] auto fixes from pre-commit.com hooks

for more information, see https://pre-commit.ci

Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
2022-11-08 18:54:00 -05:00
Talley Lambert
e0bb2ea871 Revert "fix extras"
This reverts commit 78997fe155.
2022-11-01 17:01:13 -04:00
Talley Lambert
78997fe155 fix extras 2022-11-01 16:39:08 -04:00
Talley Lambert
021f164419 fix: fix quantity set value and add test (#131)
* fix: fix quantity set value and add test

* pin pyside6

* fix: try fix screenshot
2022-11-01 14:46:29 -04:00
pre-commit-ci[bot]
7f50e69e28 [pre-commit.ci] pre-commit autoupdate (#130)
* [pre-commit.ci] pre-commit autoupdate

updates:
- [github.com/asottile/setup-cfg-fmt: v2.0.0 → v2.2.0](https://github.com/asottile/setup-cfg-fmt/compare/v2.0.0...v2.2.0)
- [github.com/PyCQA/autoflake: v1.7.1 → v1.7.7](https://github.com/PyCQA/autoflake/compare/v1.7.1...v1.7.7)
- [github.com/asottile/pyupgrade: v3.0.0 → v3.2.0](https://github.com/asottile/pyupgrade/compare/v3.0.0...v3.2.0)

* [pre-commit.ci] auto fixes from pre-commit.com hooks

for more information, see https://pre-commit.ci

Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
2022-10-31 16:24:53 -04:00
pre-commit-ci[bot]
2c747c5a4f [pre-commit.ci] pre-commit autoupdate (#127)
updates:
- [github.com/PyCQA/autoflake: v1.6.1 → v1.7.1](https://github.com/PyCQA/autoflake/compare/v1.6.1...v1.7.1)
- [github.com/asottile/pyupgrade: v2.38.2 → v3.0.0](https://github.com/asottile/pyupgrade/compare/v2.38.2...v3.0.0)
- [github.com/psf/black: 22.8.0 → 22.10.0](https://github.com/psf/black/compare/22.8.0...22.10.0)
- [github.com/pre-commit/mirrors-mypy: v0.981 → v0.982](https://github.com/pre-commit/mirrors-mypy/compare/v0.981...v0.982)

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>
2022-10-17 10:13:59 -04:00
Talley Lambert
b79c8e95b7 chore: changelog v0.3.8 2022-10-10 15:37:16 -04:00
Kira Evans
b393c6d039 fix: allow submodule imports (#128)
* fix: allow submodule imports

* [pre-commit.ci] auto fixes from pre-commit.com hooks

for more information, see https://pre-commit.ci

Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
2022-10-10 15:35:53 -04:00
Talley Lambert
61b8ab30ab chore: changelog v0.3.7 2022-10-10 08:27:33 -04:00
Talley Lambert
abf544cf0e feat: add Quantity widget (using pint) (#126)
* wip

* simplified quantity widget

* fix example

* more docs

* add test

* update docs

* try to avoid overflow

* reduce again
2022-10-10 08:22:52 -04:00
72 changed files with 2106 additions and 1088 deletions

10
.github/dependabot.yml vendored Normal file
View File

@@ -0,0 +1,10 @@
# https://docs.github.com/github/administering-a-repository/configuration-options-for-dependency-updates
version: 2
updates:
- package-ecosystem: "github-actions"
directory: "/"
schedule:
interval: "weekly"
commit-message:
prefix: "ci(dependabot):"

View File

@@ -3,13 +3,11 @@ name: Test
on:
push:
branches:
- master
- main
tags:
- "v*" # Push events to matching v*, i.e. v1.0, v20.15.10
pull_request:
branches:
- master
- main
workflow_dispatch:
@@ -17,144 +15,107 @@ jobs:
test:
name: ${{ matrix.platform }} py${{ matrix.python-version }} ${{ matrix.backend }}
runs-on: ${{ matrix.platform }}
timeout-minutes: 10
strategy:
fail-fast: false
matrix:
platform: [ubuntu-latest, windows-latest, macos-latest]
python-version: [3.7, 3.8, 3.9]
backend: [pyqt5, pyside2]
include:
# pyqt6 and pyside6 on latest platforms
- python-version: 3.9
platform: ubuntu-latest
backend: pyside6
screenshot: 1
- python-version: 3.9
platform: windows-latest
backend: pyside6
screenshot: 1
- python-version: 3.9
platform: macos-11.0
backend: pyside6
screenshot: 1
- python-version: 3.9
platform: ubuntu-latest
python-version: ["3.8", "3.9", "3.10", "3.11"]
backend: [pyqt5, pyside2, pyqt6]
exclude:
# Abort (core dumped) on linux pyqt6, unknown reason
- platform: ubuntu-latest
backend: pyqt6
- python-version: 3.9
platform: windows-latest
backend: pyqt6
- python-version: 3.9
platform: macos-11.0
backend: pyqt6
# py3.10
- python-version: "3.10"
platform: ubuntu-latest
backend: pyside6
- python-version: "3.10"
platform: ubuntu-latest
backend: pyqt5
- python-version: "3.10"
platform: ubuntu-latest
backend: pyqt6
# big sur, 3.9
- python-version: 3.9
platform: macos-11.0
# lack of wheels for pyside2/py3.11
- python-version: "3.11"
backend: pyside2
- python-version: 3.9
platform: macos-11.0
backend: pyqt5
# legacy OS
- python-version: 3.8
platform: ubuntu-18.04
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 3.7
- python-version: 3.7
platform: macos-latest
backend: pyqt5
- python-version: 3.7
platform: windows-latest
backend: pyside2
# legacy Qt
- python-version: 3.7
- python-version: 3.8
platform: ubuntu-latest
backend: pyqt512
- python-version: 3.7
backend: "pyqt5==5.12.*"
- python-version: 3.8
platform: ubuntu-latest
backend: pyqt513
- python-version: 3.7
backend: "pyqt5==5.13.*"
- python-version: 3.8
platform: ubuntu-latest
backend: pyqt514
backend: "pyqt5==5.14.*"
steps:
- uses: actions/checkout@v2
- name: Cancel Previous Runs
uses: styfle/cancel-workflow-action@0.11.0
with:
access_token: ${{ github.token }}
- uses: actions/checkout@v3
- name: Set up Python ${{ matrix.python-version }}
uses: actions/setup-python@v2
uses: actions/setup-python@v4
with:
python-version: ${{ matrix.python-version }}
- uses: tlambert03/setup-qt-libs@v1
- uses: tlambert03/setup-qt-libs@v1.4
- name: Linux opengl
if: runner.os == 'Linux' && ( matrix.backend == 'pyside6' || matrix.backend == 'pyqt6' )
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 setuptools tox tox-gh-actions
python -m pip install -e .[test]
python -m pip install ${{ matrix.backend }}
- name: Test with tox
uses: GabrielBB/xvfb-action@v1
timeout-minutes: 3
- name: Test
uses: aganders3/headless-gui@v1.2
with:
run: python -m tox
env:
PLATFORM: ${{ matrix.platform }}
BACKEND: ${{ matrix.backend }}
run: python -m pytest --color=yes --cov=superqt --cov-report=xml
- name: Coverage
uses: codecov/codecov-action@v1
- name: Install for screenshots
if: matrix.screenshot
run: pip install . ${{ matrix.backend }}
- name: Screenshots (Linux)
if: runner.os == 'Linux' && matrix.screenshot
uses: GabrielBB/xvfb-action@v1
with:
run: python examples/demo_widget.py -snap
- name: Screenshots (macOS/Win)
if: runner.os != 'Linux' && matrix.screenshot
run: python examples/demo_widget.py -snap
- uses: actions/upload-artifact@v2
if: matrix.screenshot
with:
name: screenshots ${{ runner.os }}
path: screenshots
uses: codecov/codecov-action@v3
test_old_qtpy:
name: qtpy minreq
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v2
- uses: tlambert03/setup-qt-libs@v1
- uses: actions/setup-python@v2
- uses: actions/checkout@v3
- uses: tlambert03/setup-qt-libs@v1.4
- uses: actions/setup-python@v4
with:
python-version: '3.8'
python-version: "3.8"
- name: install
run: |
python -m pip install -U pip
python -m pip install -e .[testing,pyqt5]
python -m pip install -e .[test,pyqt5]
python -m pip install qtpy==1.1.0 typing-extensions==3.10.0.0
- name: Test napari magicgui
uses: GabrielBB/xvfb-action@v1
- name: Test
uses: aganders3/headless-gui@v1.2
with:
run: python -m pytest --color=yes
test_napari:
name: napari tests
runs-on: ubuntu-latest
@@ -173,7 +134,7 @@ jobs:
- uses: tlambert03/setup-qt-libs@v1
- uses: actions/setup-python@v4
with:
python-version: '3.10'
python-version: "3.10"
- name: install
run: |
@@ -182,35 +143,32 @@ jobs:
python -m pip install ./napari-repo[testing,pyqt5]
- name: Test napari
uses: GabrielBB/xvfb-action@v1
uses: aganders3/headless-gui@v1.2
with:
working-directory: napari-repo
run: python -m pytest --color=yes napari/_qt
check_manifest:
check-manifest:
name: Check Manifest
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v2
- uses: actions/setup-python@v2
- uses: actions/checkout@v3
- uses: actions/setup-python@v4
with:
python-version: "3.x"
- name: Check manifest
run: |
python -m pip install --upgrade pip
pip install check-manifest
check-manifest
- run: pip install check-manifest && check-manifest
deploy:
# this will run when you have tagged a commit, starting with "v*"
# and requires that you have put your twine API key in your
# github secrets (see readme for details)
needs: [test, check_manifest]
if: ${{ github.repository == 'napari/superqt' && contains(github.ref, 'tags') }}
needs: [test, check-manifest]
if: ${{ github.repository == 'pyapp-kit/superqt' && contains(github.ref, 'tags') }}
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v2
- uses: actions/checkout@v3
- name: Set up Python
uses: actions/setup-python@v2
uses: actions/setup-python@v4
with:
python-version: "3.x"
- name: Install dependencies

View File

@@ -1,7 +1,7 @@
# run this with:
# export CHANGELOG_GITHUB_TOKEN=......
# github_changelog_generator --future-release vX.Y.Z
user=napari
user=pyapp-kit
project=superqt
issues=false
since-tag=v0.2.0

1
.gitignore vendored
View File

@@ -45,7 +45,6 @@ nosetests.xml
coverage.xml
*,cover
.hypothesis/
.napari_cache
# Translations
*.mo

View File

@@ -1,41 +1,38 @@
ci:
autoupdate_schedule: monthly
autofix_commit_msg: "style: [pre-commit.ci] auto fixes [...]"
autoupdate_commit_msg: "ci: [pre-commit.ci] autoupdate"
repos:
- repo: https://github.com/pre-commit/pre-commit-hooks
rev: v4.3.0
rev: v4.4.0
hooks:
- id: check-docstring-first
- id: end-of-file-fixer
- id: trailing-whitespace
- repo: https://github.com/asottile/setup-cfg-fmt
rev: v2.0.0
hooks:
- id: setup-cfg-fmt
args: ["--include-version-classifiers"]
- repo: https://github.com/PyCQA/flake8
rev: 5.0.4
hooks:
- id: flake8
additional_dependencies: [flake8-typing-imports==1.7.0]
exclude: examples
- repo: https://github.com/PyCQA/autoflake
rev: v1.6.1
hooks:
- id: autoflake
args: ["--in-place", "--remove-all-unused-imports"]
- repo: https://github.com/PyCQA/isort
rev: 5.10.1
hooks:
- id: isort
- repo: https://github.com/asottile/pyupgrade
rev: v2.38.2
hooks:
- id: pyupgrade
args: [--py37-plus, --keep-runtime-typing]
- repo: https://github.com/psf/black
rev: 22.8.0
rev: 23.7.0
hooks:
- id: black
- repo: https://github.com/astral-sh/ruff-pre-commit
rev: v0.0.281
hooks:
- id: ruff
args: ["--fix"]
- repo: https://github.com/abravalheri/validate-pyproject
rev: v0.13
hooks:
- id: validate-pyproject
- repo: https://github.com/pre-commit/mirrors-mypy
rev: v0.981
rev: v1.4.1
hooks:
- id: mypy
exclude: examples
stages: [manual]
exclude: tests|examples
additional_dependencies:
- types-Pygments
stages:
- manual

View File

@@ -1,211 +1,297 @@
# Changelog
## [v0.3.6](https://github.com/napari/superqt/tree/v0.3.6) (2022-10-03)
## [v0.5.0](https://github.com/pyapp-kit/superqt/tree/v0.5.0) (2023-08-06)
[Full Changelog](https://github.com/napari/superqt/compare/v0.3.5...v0.3.6)
[Full Changelog](https://github.com/pyapp-kit/superqt/compare/v0.4.1...v0.5.0)
**Implemented enhancements:**
- feat: add editing finished signal to LabeledSliders [\#122](https://github.com/napari/superqt/pull/122) ([tlambert03](https://github.com/tlambert03))
- feat: add stepType to largeInt spinbox [\#179](https://github.com/pyapp-kit/superqt/pull/179) ([tlambert03](https://github.com/tlambert03))
- Searchable tree widget from a mapping [\#158](https://github.com/pyapp-kit/superqt/pull/158) ([andy-sweet](https://github.com/andy-sweet))
- Add `QElidingLineEdit` class for elidable `QLineEdit`s [\#154](https://github.com/pyapp-kit/superqt/pull/154) ([dalthviz](https://github.com/dalthviz))
**Fixed bugs:**
- fix: fix missing labels after setValue [\#123](https://github.com/napari/superqt/pull/123) ([tlambert03](https://github.com/tlambert03))
- fix: Fix TypeError on slider rangeChanged signal [\#121](https://github.com/napari/superqt/pull/121) ([tlambert03](https://github.com/tlambert03))
- Simple workaround for pyside 6 [\#119](https://github.com/napari/superqt/pull/119) ([Czaki](https://github.com/Czaki))
- fix: Offer patch for \(unstyled\) QSliders on macos 12 and Qt \<6 [\#117](https://github.com/napari/superqt/pull/117) ([tlambert03](https://github.com/tlambert03))
## [v0.3.5](https://github.com/napari/superqt/tree/v0.3.5) (2022-08-17)
[Full Changelog](https://github.com/napari/superqt/compare/v0.3.4...v0.3.5)
**Fixed bugs:**
- fix range slider drag crash on PyQt6 [\#108](https://github.com/napari/superqt/pull/108) ([sfhbarnett](https://github.com/sfhbarnett))
- Fix float value error in pyqt configuration [\#106](https://github.com/napari/superqt/pull/106) ([mstabrin](https://github.com/mstabrin))
**Merged pull requests:**
- chore: changelog v0.3.5 [\#110](https://github.com/napari/superqt/pull/110) ([tlambert03](https://github.com/tlambert03))
## [v0.3.4](https://github.com/napari/superqt/tree/v0.3.4) (2022-07-24)
[Full Changelog](https://github.com/napari/superqt/compare/v0.3.3...v0.3.4)
**Fixed bugs:**
- fix: relax runtime typing extensions requirement [\#101](https://github.com/napari/superqt/pull/101) ([tlambert03](https://github.com/tlambert03))
- fix: catch qpixmap deprecation [\#99](https://github.com/napari/superqt/pull/99) ([tlambert03](https://github.com/tlambert03))
## [v0.3.3](https://github.com/napari/superqt/tree/v0.3.3) (2022-07-10)
[Full Changelog](https://github.com/napari/superqt/compare/v0.3.2...v0.3.3)
**Implemented enhancements:**
- Add code syntax highlight utils [\#88](https://github.com/napari/superqt/pull/88) ([Czaki](https://github.com/Czaki))
**Fixed bugs:**
- fix: fix deprecation warning on fonticon plugin discovery on python 3.10 [\#95](https://github.com/napari/superqt/pull/95) ([tlambert03](https://github.com/tlambert03))
## [v0.3.2](https://github.com/napari/superqt/tree/v0.3.2) (2022-05-03)
[Full Changelog](https://github.com/napari/superqt/compare/v0.3.1...v0.3.2)
**Implemented enhancements:**
- Add QSearchableListWidget and QSearchableComboBox widgets [\#80](https://github.com/napari/superqt/pull/80) ([Czaki](https://github.com/Czaki))
**Fixed bugs:**
- Fix crazy animation loop on Qcollapsible [\#84](https://github.com/napari/superqt/pull/84) ([tlambert03](https://github.com/tlambert03))
- Reorder label update signal [\#83](https://github.com/napari/superqt/pull/83) ([tlambert03](https://github.com/tlambert03))
- Fix height of expanded QCollapsible when child changes size [\#72](https://github.com/napari/superqt/pull/72) ([tlambert03](https://github.com/tlambert03))
**Tests & CI:**
- Fix deprecation warnings in tests [\#82](https://github.com/napari/superqt/pull/82) ([tlambert03](https://github.com/tlambert03))
**Merged pull requests:**
- Add changelog for v0.3.2 [\#86](https://github.com/napari/superqt/pull/86) ([tlambert03](https://github.com/tlambert03))
## [v0.3.1](https://github.com/napari/superqt/tree/v0.3.1) (2022-03-02)
[Full Changelog](https://github.com/napari/superqt/compare/v0.3.0...v0.3.1)
**Implemented enhancements:**
- Add `signals_blocked` util [\#69](https://github.com/napari/superqt/pull/69) ([tlambert03](https://github.com/tlambert03))
**Fixed bugs:**
- put SignalInstance in TYPE\_CHECKING clause, check min requirements [\#70](https://github.com/napari/superqt/pull/70) ([tlambert03](https://github.com/tlambert03))
**Merged pull requests:**
- Add changelog for v0.3.1 [\#71](https://github.com/napari/superqt/pull/71) ([tlambert03](https://github.com/tlambert03))
## [v0.3.0](https://github.com/napari/superqt/tree/v0.3.0) (2022-02-16)
[Full Changelog](https://github.com/napari/superqt/compare/v0.2.5-1...v0.3.0)
**Implemented enhancements:**
- Qthrottler and debouncer [\#62](https://github.com/napari/superqt/pull/62) ([tlambert03](https://github.com/tlambert03))
- add edgeLabelMode option to QLabeledSlider [\#59](https://github.com/napari/superqt/pull/59) ([tlambert03](https://github.com/tlambert03))
**Fixed bugs:**
- Fix nested threadworker not starting [\#63](https://github.com/napari/superqt/pull/63) ([tlambert03](https://github.com/tlambert03))
- Add missing signals on proxy sliders [\#54](https://github.com/napari/superqt/pull/54) ([tlambert03](https://github.com/tlambert03))
- Ugly but functional workaround for pyside6.2.1 breakages [\#51](https://github.com/napari/superqt/pull/51) ([tlambert03](https://github.com/tlambert03))
**Tests & CI:**
- add napari test to CI [\#67](https://github.com/napari/superqt/pull/67) ([tlambert03](https://github.com/tlambert03))
- add gh-release action [\#65](https://github.com/napari/superqt/pull/65) ([tlambert03](https://github.com/tlambert03))
- fix xvfb tests [\#61](https://github.com/napari/superqt/pull/61) ([tlambert03](https://github.com/tlambert03))
**Refactors:**
- Use qtpy, deprecate superqt.qtcompat, drop support for Qt \<5.12 [\#39](https://github.com/napari/superqt/pull/39) ([tlambert03](https://github.com/tlambert03))
**Merged pull requests:**
- Add changelog for v0.3.0 [\#68](https://github.com/napari/superqt/pull/68) ([tlambert03](https://github.com/tlambert03))
## [v0.2.5-1](https://github.com/napari/superqt/tree/v0.2.5-1) (2021-11-23)
[Full Changelog](https://github.com/napari/superqt/compare/v0.2.5...v0.2.5-1)
**Merged pull requests:**
- typing-extensions version pinning [\#46](https://github.com/napari/superqt/pull/46) ([AhmetCanSolak](https://github.com/AhmetCanSolak))
## [v0.2.5](https://github.com/napari/superqt/tree/v0.2.5) (2021-11-22)
[Full Changelog](https://github.com/napari/superqt/compare/v0.2.4...v0.2.5)
**Implemented enhancements:**
- add support for python 3.10 [\#42](https://github.com/napari/superqt/pull/42) ([tlambert03](https://github.com/tlambert03))
- QCollapsible for Collapsible Section Control [\#37](https://github.com/napari/superqt/pull/37) ([MosGeo](https://github.com/MosGeo))
- Threadworker [\#31](https://github.com/napari/superqt/pull/31) ([tlambert03](https://github.com/tlambert03))
- Add font icons [\#24](https://github.com/napari/superqt/pull/24) ([tlambert03](https://github.com/tlambert03))
**Fixed bugs:**
- Fix some small linting issues. [\#41](https://github.com/napari/superqt/pull/41) ([tlambert03](https://github.com/tlambert03))
- Use functools.wraps insterad of \_\_wraped\_\_ and manual proxing \_\_name\_\_ [\#29](https://github.com/napari/superqt/pull/29) ([Czaki](https://github.com/Czaki))
- Propagate function name in `ensure_main_thread` and `ensure_object_thread` [\#28](https://github.com/napari/superqt/pull/28) ([Czaki](https://github.com/Czaki))
**Tests & CI:**
- reskip test\_object\_thread\_return on ci [\#43](https://github.com/napari/superqt/pull/43) ([tlambert03](https://github.com/tlambert03))
**Refactors:**
- refactoring qtcompat [\#34](https://github.com/napari/superqt/pull/34) ([tlambert03](https://github.com/tlambert03))
**Merged pull requests:**
- Fix-manifest, move font tests [\#44](https://github.com/napari/superqt/pull/44) ([tlambert03](https://github.com/tlambert03))
- update deploy [\#33](https://github.com/napari/superqt/pull/33) ([tlambert03](https://github.com/tlambert03))
- move to src layout [\#32](https://github.com/napari/superqt/pull/32) ([tlambert03](https://github.com/tlambert03))
## [v0.2.4](https://github.com/napari/superqt/tree/v0.2.4) (2021-09-13)
[Full Changelog](https://github.com/napari/superqt/compare/v0.2.3...v0.2.4)
**Implemented enhancements:**
- Add type stubs for ensure\_thread decorator [\#23](https://github.com/napari/superqt/pull/23) ([tlambert03](https://github.com/tlambert03))
- Add `ensure_main_tread` and `ensure_object_thread` [\#22](https://github.com/napari/superqt/pull/22) ([Czaki](https://github.com/Czaki))
- Add QMessageHandler context manager [\#21](https://github.com/napari/superqt/pull/21) ([tlambert03](https://github.com/tlambert03))
**Merged pull requests:**
- add changelog for 0.2.4 [\#25](https://github.com/napari/superqt/pull/25) ([tlambert03](https://github.com/tlambert03))
## [v0.2.3](https://github.com/napari/superqt/tree/v0.2.3) (2021-08-25)
[Full Changelog](https://github.com/napari/superqt/compare/v0.2.2...v0.2.3)
**Fixed bugs:**
- Fix warnings on eliding label for 5.12, test more qt versions [\#19](https://github.com/napari/superqt/pull/19) ([tlambert03](https://github.com/tlambert03))
## [v0.2.2](https://github.com/napari/superqt/tree/v0.2.2) (2021-08-17)
[Full Changelog](https://github.com/napari/superqt/compare/v0.2.1...v0.2.2)
**Implemented enhancements:**
- Add QElidingLabel [\#16](https://github.com/napari/superqt/pull/16) ([tlambert03](https://github.com/tlambert03))
- Enum ComboBox implementation [\#13](https://github.com/napari/superqt/pull/13) ([Czaki](https://github.com/Czaki))
- fix: focus events on QLabeledSlider [\#175](https://github.com/pyapp-kit/superqt/pull/175) ([tlambert03](https://github.com/tlambert03))
- Set parent of timer in throttler [\#171](https://github.com/pyapp-kit/superqt/pull/171) ([Czaki](https://github.com/Czaki))
- fix: fix double slider label editing [\#168](https://github.com/pyapp-kit/superqt/pull/168) ([tlambert03](https://github.com/tlambert03))
**Documentation updates:**
- fix broken link [\#18](https://github.com/napari/superqt/pull/18) ([haesleinhuepf](https://github.com/haesleinhuepf))
- Fix typos [\#147](https://github.com/pyapp-kit/superqt/pull/147) ([kianmeng](https://github.com/kianmeng))
## [v0.2.1](https://github.com/napari/superqt/tree/v0.2.1) (2021-07-10)
**Tests & CI:**
[Full Changelog](https://github.com/napari/superqt/compare/v0.2.0rc1...v0.2.1)
- tests: add qtbot to test to fix windows segfault [\#165](https://github.com/pyapp-kit/superqt/pull/165) ([tlambert03](https://github.com/tlambert03))
- test: fixing tests \[wip\] [\#164](https://github.com/pyapp-kit/superqt/pull/164) ([tlambert03](https://github.com/tlambert03))
**Merged pull requests:**
- build: unpin pyside6.5 [\#178](https://github.com/pyapp-kit/superqt/pull/178) ([tlambert03](https://github.com/tlambert03))
- build: pin pyside6 to \<6.5.1 [\#169](https://github.com/pyapp-kit/superqt/pull/169) ([tlambert03](https://github.com/tlambert03))
- pin pyside6\<6.5 [\#160](https://github.com/pyapp-kit/superqt/pull/160) ([tlambert03](https://github.com/tlambert03))
- ci: \[pre-commit.ci\] autoupdate [\#146](https://github.com/pyapp-kit/superqt/pull/146) ([pre-commit-ci[bot]](https://github.com/apps/pre-commit-ci))
## [v0.4.1](https://github.com/pyapp-kit/superqt/tree/v0.4.1) (2022-12-01)
[Full Changelog](https://github.com/pyapp-kit/superqt/compare/v0.4.0...v0.4.1)
**Implemented enhancements:**
- feat: Add signal to QCollapsible [\#142](https://github.com/pyapp-kit/superqt/pull/142) ([ppwadhwa](https://github.com/ppwadhwa))
- feat: Change icon used in Collapsible widget [\#140](https://github.com/pyapp-kit/superqt/pull/140) ([ppwadhwa](https://github.com/ppwadhwa))
**Fixed bugs:**
- Fix QLabeledRangeSlider API \(fix slider proxy\) [\#10](https://github.com/napari/superqt/pull/10) ([tlambert03](https://github.com/tlambert03))
- Fix range slider with negative min range [\#9](https://github.com/napari/superqt/pull/9) ([tlambert03](https://github.com/tlambert03))
- Move QCollapsible toggle signal emit [\#144](https://github.com/pyapp-kit/superqt/pull/144) ([ppwadhwa](https://github.com/ppwadhwa))
## [v0.2.0rc1](https://github.com/napari/superqt/tree/v0.2.0rc1) (2021-06-26)
**Merged pull requests:**
[Full Changelog](https://github.com/napari/superqt/compare/v0.2.0rc0...v0.2.0rc1)
- build: use hatch for build backend, and use ruff for linting [\#139](https://github.com/pyapp-kit/superqt/pull/139) ([tlambert03](https://github.com/tlambert03))
- chore: rename napari org to pyapp-kit [\#137](https://github.com/pyapp-kit/superqt/pull/137) ([tlambert03](https://github.com/tlambert03))
## [v0.2.0rc0](https://github.com/napari/superqt/tree/v0.2.0rc0) (2021-06-26)
## [v0.4.0](https://github.com/pyapp-kit/superqt/tree/v0.4.0) (2022-11-09)
[Full Changelog](https://github.com/napari/superqt/compare/v0.2.0...v0.2.0rc0)
[Full Changelog](https://github.com/pyapp-kit/superqt/compare/v0.3.8...v0.4.0)
**Fixed bugs:**
- fix: fix quantity set value and add test [\#131](https://github.com/pyapp-kit/superqt/pull/131) ([tlambert03](https://github.com/tlambert03))
**Refactors:**
- refactor: update pyproject and ci, add py3.11 test [\#132](https://github.com/pyapp-kit/superqt/pull/132) ([tlambert03](https://github.com/tlambert03))
**Merged pull requests:**
- chore: changelog v0.4.0 [\#136](https://github.com/pyapp-kit/superqt/pull/136) ([tlambert03](https://github.com/tlambert03))
- ci\(dependabot\): bump actions/upload-artifact from 2 to 3 [\#135](https://github.com/pyapp-kit/superqt/pull/135) ([dependabot[bot]](https://github.com/apps/dependabot))
- ci\(dependabot\): bump codecov/codecov-action from 2 to 3 [\#134](https://github.com/pyapp-kit/superqt/pull/134) ([dependabot[bot]](https://github.com/apps/dependabot))
- build: unpin pyside6 [\#133](https://github.com/pyapp-kit/superqt/pull/133) ([tlambert03](https://github.com/tlambert03))
## [v0.3.8](https://github.com/pyapp-kit/superqt/tree/v0.3.8) (2022-10-10)
[Full Changelog](https://github.com/pyapp-kit/superqt/compare/v0.3.7...v0.3.8)
**Fixed bugs:**
- fix: allow submodule imports [\#128](https://github.com/pyapp-kit/superqt/pull/128) ([kne42](https://github.com/kne42))
## [v0.3.7](https://github.com/pyapp-kit/superqt/tree/v0.3.7) (2022-10-10)
[Full Changelog](https://github.com/pyapp-kit/superqt/compare/v0.3.6...v0.3.7)
**Implemented enhancements:**
- feat: add Quantity widget \(using pint\) [\#126](https://github.com/pyapp-kit/superqt/pull/126) ([tlambert03](https://github.com/tlambert03))
## [v0.3.6](https://github.com/pyapp-kit/superqt/tree/v0.3.6) (2022-10-05)
[Full Changelog](https://github.com/pyapp-kit/superqt/compare/v0.3.6rc0...v0.3.6)
**Documentation updates:**
- minor fix to readme [\#125](https://github.com/pyapp-kit/superqt/pull/125) ([tlambert03](https://github.com/tlambert03))
- Docs [\#124](https://github.com/pyapp-kit/superqt/pull/124) ([tlambert03](https://github.com/tlambert03))
## [v0.3.6rc0](https://github.com/pyapp-kit/superqt/tree/v0.3.6rc0) (2022-10-03)
[Full Changelog](https://github.com/pyapp-kit/superqt/compare/v0.3.5...v0.3.6rc0)
**Implemented enhancements:**
- feat: add editing finished signal to LabeledSliders [\#122](https://github.com/pyapp-kit/superqt/pull/122) ([tlambert03](https://github.com/tlambert03))
**Fixed bugs:**
- fix: fix missing labels after setValue [\#123](https://github.com/pyapp-kit/superqt/pull/123) ([tlambert03](https://github.com/tlambert03))
- fix: Fix TypeError on slider rangeChanged signal [\#121](https://github.com/pyapp-kit/superqt/pull/121) ([tlambert03](https://github.com/tlambert03))
- Simple workaround for pyside 6 [\#119](https://github.com/pyapp-kit/superqt/pull/119) ([Czaki](https://github.com/Czaki))
- fix: Offer patch for \(unstyled\) QSliders on macos 12 and Qt \<6 [\#117](https://github.com/pyapp-kit/superqt/pull/117) ([tlambert03](https://github.com/tlambert03))
## [v0.3.5](https://github.com/pyapp-kit/superqt/tree/v0.3.5) (2022-08-17)
[Full Changelog](https://github.com/pyapp-kit/superqt/compare/v0.3.4...v0.3.5)
**Fixed bugs:**
- fix range slider drag crash on PyQt6 [\#108](https://github.com/pyapp-kit/superqt/pull/108) ([sfhbarnett](https://github.com/sfhbarnett))
- Fix float value error in pyqt configuration [\#106](https://github.com/pyapp-kit/superqt/pull/106) ([mstabrin](https://github.com/mstabrin))
**Merged pull requests:**
- chore: changelog v0.3.5 [\#110](https://github.com/pyapp-kit/superqt/pull/110) ([tlambert03](https://github.com/tlambert03))
## [v0.3.4](https://github.com/pyapp-kit/superqt/tree/v0.3.4) (2022-07-24)
[Full Changelog](https://github.com/pyapp-kit/superqt/compare/v0.3.3...v0.3.4)
**Fixed bugs:**
- fix: relax runtime typing extensions requirement [\#101](https://github.com/pyapp-kit/superqt/pull/101) ([tlambert03](https://github.com/tlambert03))
- fix: catch qpixmap deprecation [\#99](https://github.com/pyapp-kit/superqt/pull/99) ([tlambert03](https://github.com/tlambert03))
## [v0.3.3](https://github.com/pyapp-kit/superqt/tree/v0.3.3) (2022-07-10)
[Full Changelog](https://github.com/pyapp-kit/superqt/compare/v0.3.2...v0.3.3)
**Implemented enhancements:**
- Add code syntax highlight utils [\#88](https://github.com/pyapp-kit/superqt/pull/88) ([Czaki](https://github.com/Czaki))
**Fixed bugs:**
- fix: fix deprecation warning on fonticon plugin discovery on python 3.10 [\#95](https://github.com/pyapp-kit/superqt/pull/95) ([tlambert03](https://github.com/tlambert03))
## [v0.3.2](https://github.com/pyapp-kit/superqt/tree/v0.3.2) (2022-05-03)
[Full Changelog](https://github.com/pyapp-kit/superqt/compare/v0.3.1...v0.3.2)
**Implemented enhancements:**
- Add QSearchableListWidget and QSearchableComboBox widgets [\#80](https://github.com/pyapp-kit/superqt/pull/80) ([Czaki](https://github.com/Czaki))
**Fixed bugs:**
- Fix crazy animation loop on Qcollapsible [\#84](https://github.com/pyapp-kit/superqt/pull/84) ([tlambert03](https://github.com/tlambert03))
- Reorder label update signal [\#83](https://github.com/pyapp-kit/superqt/pull/83) ([tlambert03](https://github.com/tlambert03))
- Fix height of expanded QCollapsible when child changes size [\#72](https://github.com/pyapp-kit/superqt/pull/72) ([tlambert03](https://github.com/tlambert03))
**Tests & CI:**
- Fix deprecation warnings in tests [\#82](https://github.com/pyapp-kit/superqt/pull/82) ([tlambert03](https://github.com/tlambert03))
**Merged pull requests:**
- Add changelog for v0.3.2 [\#86](https://github.com/pyapp-kit/superqt/pull/86) ([tlambert03](https://github.com/tlambert03))
## [v0.3.1](https://github.com/pyapp-kit/superqt/tree/v0.3.1) (2022-03-02)
[Full Changelog](https://github.com/pyapp-kit/superqt/compare/v0.3.0...v0.3.1)
**Implemented enhancements:**
- Add `signals_blocked` util [\#69](https://github.com/pyapp-kit/superqt/pull/69) ([tlambert03](https://github.com/tlambert03))
**Fixed bugs:**
- put SignalInstance in TYPE\_CHECKING clause, check min requirements [\#70](https://github.com/pyapp-kit/superqt/pull/70) ([tlambert03](https://github.com/tlambert03))
**Merged pull requests:**
- Add changelog for v0.3.1 [\#71](https://github.com/pyapp-kit/superqt/pull/71) ([tlambert03](https://github.com/tlambert03))
## [v0.3.0](https://github.com/pyapp-kit/superqt/tree/v0.3.0) (2022-02-16)
[Full Changelog](https://github.com/pyapp-kit/superqt/compare/v0.2.5-1...v0.3.0)
**Implemented enhancements:**
- Qthrottler and debouncer [\#62](https://github.com/pyapp-kit/superqt/pull/62) ([tlambert03](https://github.com/tlambert03))
- add edgeLabelMode option to QLabeledSlider [\#59](https://github.com/pyapp-kit/superqt/pull/59) ([tlambert03](https://github.com/tlambert03))
**Fixed bugs:**
- Fix nested threadworker not starting [\#63](https://github.com/pyapp-kit/superqt/pull/63) ([tlambert03](https://github.com/tlambert03))
- Add missing signals on proxy sliders [\#54](https://github.com/pyapp-kit/superqt/pull/54) ([tlambert03](https://github.com/tlambert03))
- Ugly but functional workaround for pyside6.2.1 breakages [\#51](https://github.com/pyapp-kit/superqt/pull/51) ([tlambert03](https://github.com/tlambert03))
**Tests & CI:**
- add napari test to CI [\#67](https://github.com/pyapp-kit/superqt/pull/67) ([tlambert03](https://github.com/tlambert03))
- add gh-release action [\#65](https://github.com/pyapp-kit/superqt/pull/65) ([tlambert03](https://github.com/tlambert03))
- fix xvfb tests [\#61](https://github.com/pyapp-kit/superqt/pull/61) ([tlambert03](https://github.com/tlambert03))
**Refactors:**
- Use qtpy, deprecate superqt.qtcompat, drop support for Qt \<5.12 [\#39](https://github.com/pyapp-kit/superqt/pull/39) ([tlambert03](https://github.com/tlambert03))
**Merged pull requests:**
- Add changelog for v0.3.0 [\#68](https://github.com/pyapp-kit/superqt/pull/68) ([tlambert03](https://github.com/tlambert03))
## [v0.2.5-1](https://github.com/pyapp-kit/superqt/tree/v0.2.5-1) (2021-11-23)
[Full Changelog](https://github.com/pyapp-kit/superqt/compare/v0.2.5...v0.2.5-1)
**Merged pull requests:**
- typing-extensions version pinning [\#46](https://github.com/pyapp-kit/superqt/pull/46) ([AhmetCanSolak](https://github.com/AhmetCanSolak))
## [v0.2.5](https://github.com/pyapp-kit/superqt/tree/v0.2.5) (2021-11-22)
[Full Changelog](https://github.com/pyapp-kit/superqt/compare/v0.2.4...v0.2.5)
**Implemented enhancements:**
- add support for python 3.10 [\#42](https://github.com/pyapp-kit/superqt/pull/42) ([tlambert03](https://github.com/tlambert03))
- QCollapsible for Collapsible Section Control [\#37](https://github.com/pyapp-kit/superqt/pull/37) ([MosGeo](https://github.com/MosGeo))
- Threadworker [\#31](https://github.com/pyapp-kit/superqt/pull/31) ([tlambert03](https://github.com/tlambert03))
- Add font icons [\#24](https://github.com/pyapp-kit/superqt/pull/24) ([tlambert03](https://github.com/tlambert03))
**Fixed bugs:**
- Fix some small linting issues. [\#41](https://github.com/pyapp-kit/superqt/pull/41) ([tlambert03](https://github.com/tlambert03))
- Use functools.wraps insterad of \_\_wraped\_\_ and manual proxing \_\_name\_\_ [\#29](https://github.com/pyapp-kit/superqt/pull/29) ([Czaki](https://github.com/Czaki))
- Propagate function name in `ensure_main_thread` and `ensure_object_thread` [\#28](https://github.com/pyapp-kit/superqt/pull/28) ([Czaki](https://github.com/Czaki))
**Tests & CI:**
- reskip test\_object\_thread\_return on ci [\#43](https://github.com/pyapp-kit/superqt/pull/43) ([tlambert03](https://github.com/tlambert03))
**Refactors:**
- refactoring qtcompat [\#34](https://github.com/pyapp-kit/superqt/pull/34) ([tlambert03](https://github.com/tlambert03))
**Merged pull requests:**
- Fix-manifest, move font tests [\#44](https://github.com/pyapp-kit/superqt/pull/44) ([tlambert03](https://github.com/tlambert03))
- update deploy [\#33](https://github.com/pyapp-kit/superqt/pull/33) ([tlambert03](https://github.com/tlambert03))
- move to src layout [\#32](https://github.com/pyapp-kit/superqt/pull/32) ([tlambert03](https://github.com/tlambert03))
## [v0.2.4](https://github.com/pyapp-kit/superqt/tree/v0.2.4) (2021-09-13)
[Full Changelog](https://github.com/pyapp-kit/superqt/compare/v0.2.3...v0.2.4)
**Implemented enhancements:**
- Add type stubs for ensure\_thread decorator [\#23](https://github.com/pyapp-kit/superqt/pull/23) ([tlambert03](https://github.com/tlambert03))
- Add `ensure_main_tread` and `ensure_object_thread` [\#22](https://github.com/pyapp-kit/superqt/pull/22) ([Czaki](https://github.com/Czaki))
- Add QMessageHandler context manager [\#21](https://github.com/pyapp-kit/superqt/pull/21) ([tlambert03](https://github.com/tlambert03))
**Merged pull requests:**
- add changelog for 0.2.4 [\#25](https://github.com/pyapp-kit/superqt/pull/25) ([tlambert03](https://github.com/tlambert03))
## [v0.2.3](https://github.com/pyapp-kit/superqt/tree/v0.2.3) (2021-08-25)
[Full Changelog](https://github.com/pyapp-kit/superqt/compare/v0.2.2...v0.2.3)
**Fixed bugs:**
- Fix warnings on eliding label for 5.12, test more qt versions [\#19](https://github.com/pyapp-kit/superqt/pull/19) ([tlambert03](https://github.com/tlambert03))
## [v0.2.2](https://github.com/pyapp-kit/superqt/tree/v0.2.2) (2021-08-17)
[Full Changelog](https://github.com/pyapp-kit/superqt/compare/v0.2.1...v0.2.2)
**Implemented enhancements:**
- Add QElidingLabel [\#16](https://github.com/pyapp-kit/superqt/pull/16) ([tlambert03](https://github.com/tlambert03))
- Enum ComboBox implementation [\#13](https://github.com/pyapp-kit/superqt/pull/13) ([Czaki](https://github.com/Czaki))
**Documentation updates:**
- fix broken link [\#18](https://github.com/pyapp-kit/superqt/pull/18) ([haesleinhuepf](https://github.com/haesleinhuepf))
## [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.0...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))

View File

@@ -48,5 +48,4 @@ All widgets should try to match the native Qt API as much as possible:
## Testing
Tests can be run in the current environment with `pytest`. Or, to run tests
against all supported python & Qt versions, run `tox`.
Tests can be run in the current environment with `pytest`.

View File

@@ -1,17 +0,0 @@
include LICENSE
include README.md
include CHANGELOG.md
include src/superqt/py.typed
recursive-include src/superqt *.py
recursive-include src/superqt *.pyi
recursive-exclude * __pycache__
recursive-exclude * *.py[co]
recursive-exclude docs *
recursive-exclude examples *
recursive-exclude tests *
exclude tox.ini
exclude CONTRIBUTING.md
exclude codecov.yml
exclude .github_changelog_generator
exclude .pre-commit-config.yaml

View File

@@ -1,11 +1,11 @@
# ![tiny](https://user-images.githubusercontent.com/1609449/120636353-8c3f3800-c43b-11eb-8732-a14dec578897.png) superqt!
[![License](https://img.shields.io/pypi/l/superqt.svg?color=green)](https://github.com/napari/superqt/raw/master/LICENSE)
[![License](https://img.shields.io/pypi/l/superqt.svg?color=green)](https://github.com/pyapp-kit/superqt/raw/master/LICENSE)
[![PyPI](https://img.shields.io/pypi/v/superqt.svg?color=green)](https://pypi.org/project/superqt)
[![Python
Version](https://img.shields.io/pypi/pyversions/superqt.svg?color=green)](https://python.org)
[![Test](https://github.com/napari/superqt/actions/workflows/test_and_deploy.yml/badge.svg)](https://github.com/napari/superqt/actions/workflows/test_and_deploy.yml)
[![codecov](https://codecov.io/gh/napari/superqt/branch/main/graph/badge.svg?token=dcsjgl1sOi)](https://codecov.io/gh/napari/superqt)
[![Test](https://github.com/pyapp-kit/superqt/actions/workflows/test_and_deploy.yml/badge.svg)](https://github.com/pyapp-kit/superqt/actions/workflows/test_and_deploy.yml)
[![codecov](https://codecov.io/gh/pyapp-kit/superqt/branch/main/graph/badge.svg?token=dcsjgl1sOi)](https://codecov.io/gh/pyapp-kit/superqt)
### "missing" widgets and components for PyQt/PySide
@@ -21,30 +21,30 @@ Components are tested on:
## Documentation
Documentation is available at https://napari.org/superqt
Documentation is available at https://pyapp-kit.github.io/superqt/
## Widgets
superqt provides a variety of widgets that are not included in the native QtWidgets module, including multihandle (range) sliders, comboboxes, and more.
See the [widgets documentation](https://napari.org/superqt/widgets) for a full list of widgets.
See the [widgets documentation](https://pyapp-kit.github.io/superqt/widgets) for a full list of widgets.
- [Range Slider](https://napari.org/superqt/widgets/qrangeslider/) (multi-handle slider)
- [Range Slider](https://pyapp-kit.github.io/superqt/widgets/qrangeslider/) (multi-handle slider)
<img src="https://raw.githubusercontent.com/napari/superqt/main/docs/images/demo_darwin10.png" alt="range sliders" width=680>
<img src="https://raw.githubusercontent.com/pyapp-kit/superqt/main/docs/images/demo_darwin10.png" alt="range sliders" width=680>
<img src="https://raw.githubusercontent.com/napari/superqt/main/docs/images/labeled_qslider.png" alt="range sliders" width=680>
<img src="https://raw.githubusercontent.com/pyapp-kit/superqt/main/docs/images/labeled_qslider.png" alt="range sliders" width=680>
<img src="https://raw.githubusercontent.com/napari/superqt/main/docs/images/labeled_range.png" alt="range sliders" width=680>
<img src="https://raw.githubusercontent.com/pyapp-kit/superqt/main/docs/images/labeled_range.png" alt="range sliders" width=680>
## Utilities
superqt includes a number of utitlities for working with Qt, including:
superqt includes a number of utilities for working with Qt, including:
- tools and decorators for working with threads in qt.
- `superqt.fonticon` for generating icons from font files (such as [Material Design Icons](https://materialdesignicons.com/) and [Font Awesome](https://fontawesome.com/))
See the [utilities documentation](https://napari.org/superqt/utilities/) for a full list of utilities.
See the [utilities documentation](https://pyapp-kit.github.io/superqt/utilities/) for a full list of utilities.
## Contributing

View File

@@ -37,9 +37,12 @@ def define_env(env: "MacrosPlugin"):
)
src = src.replace("app.exec_()", "")
exec(src)
exec(src) # noqa: S102
_grab(dest, width)
return f"![{page.title}](../{dest.parent.name}/{dest.name}){{ loading=lazy; width={width} }}\n\n"
return (
f"![{page.title}](../{dest.parent.name}/{dest.name})"
f"{{ loading=lazy; width={width} }}\n\n"
)
@env.macro
def show_members(cls: str):
@@ -101,7 +104,6 @@ def define_env(env: "MacrosPlugin"):
out += f"- `{m.name}`\n\n"
if self_members:
out += dedent(
f"""
## Methods

View File

@@ -7,7 +7,7 @@
(including native Qt sliders) to not respond properly to drag events. See:
- [https://bugreports.qt.io/browse/QTBUG-98093](https://bugreports.qt.io/browse/QTBUG-98093)
- [https://github.com/napari/superqt/issues/74](https://github.com/napari/superqt/issues/74)
- [https://github.com/pyapp-kit/superqt/issues/74](https://github.com/pyapp-kit/superqt/issues/74)
Superqt includes a workaround for this issue, but it is not perfect, and it requires using a custom stylesheet (which may interfere with your own styles). Note that you
may not see this issue if you're already using custom stylesheets.

View File

@@ -14,6 +14,7 @@ The following are QWidget subclasses:
| [`QLabeledSlider`](./qlabeledslider.md) | `QSlider` with editable `QSpinBox` that shows the current value |
| [`QLargeIntSpinBox`](./qlargeintspinbox.md) | `QSpinbox` that accepts arbitrarily large integers |
| [`QRangeSlider`](./qrangeslider.md) | Multi-handle slider |
| [`QQuantity`](./qquantity.md) | Pint-backed quantity widget (magnitude combined with unit dropdown) |
## Labels and categorical inputs

33
docs/widgets/qquantity.md Normal file
View File

@@ -0,0 +1,33 @@
# QQuantity
A widget that allows the user to edit a quantity (a magnitude associated with a unit).
!!! note
This widget requires [`pint`](https://pint.readthedocs.io):
```
pip install pint
```
or
```
pip install superqt[quantity]
```
```python
from qtpy.QtWidgets import QApplication
from superqt import QQuantity
app = QApplication([])
w = QQuantity("1m")
w.show()
app.exec()
```
{{ show_widget(150) }}
{{ show_members('superqt.QQuantity') }}

View File

@@ -110,7 +110,6 @@ class DemoWidget(QtW.QWidget):
if __name__ == "__main__":
import sys
from pathlib import Path

View File

@@ -219,7 +219,7 @@ class MainWindow(QtWidgets.QMainWindow):
self.previewGroupBox.setLayout(layout)
def createGlyphBox(self):
self.glyphGroupBox = QtWidgets.QGroupBox("Glpyhs")
self.glyphGroupBox = QtWidgets.QGroupBox("Glyphs")
self.glyphGroupBox.setMinimumSize(480, 200)
self.glyphTable = QtWidgets.QTableWidget()
self.glyphTable.setSelectionMode(QtWidgets.QAbstractItemView.NoSelection)
@@ -369,7 +369,6 @@ class MainWindow(QtWidgets.QMainWindow):
if __name__ == "__main__":
import sys
app = QtWidgets.QApplication(sys.argv)

View File

@@ -1,4 +1,4 @@
"""Example for QCollapsible"""
"""Example for QCollapsible."""
from qtpy.QtWidgets import QApplication, QLabel, QPushButton
from superqt import QCollapsible
@@ -6,6 +6,8 @@ from superqt import QCollapsible
app = QApplication([])
collapsible = QCollapsible("Advanced analysis")
collapsible.setCollapsedIcon("+")
collapsible.setExpandedIcon("-")
collapsible.addWidget(QLabel("This is the inside of the collapsible frame"))
for i in range(10):
collapsible.addWidget(QPushButton(f"Content button {i + 1}"))

9
examples/quantity.py Normal file
View File

@@ -0,0 +1,9 @@
from qtpy.QtWidgets import QApplication
from superqt import QQuantity
app = QApplication([])
w = QQuantity("1m")
w.show()
app.exec()

View File

@@ -0,0 +1,29 @@
import logging
from qtpy.QtWidgets import QApplication
from superqt import QSearchableTreeWidget
logging.basicConfig(
level=logging.DEBUG,
format="%(asctime)s : %(levelname)s : %(filename)s : %(message)s",
)
data = {
"none": None,
"str": "test",
"int": 42,
"list": [2, 3, 5],
"dict": {
"float": 0.5,
"tuple": (22, 99),
"bool": False,
},
}
app = QApplication([])
tree = QSearchableTreeWidget.fromData(data)
tree.show()
app.exec_()

View File

@@ -1,4 +1,4 @@
"""Adapted for python from the KDToolBox
"""Adapted for python from the KDToolBox.
https://github.com/KDAB/KDToolBox/tree/master/qt/KDSignalThrottler
@@ -85,12 +85,10 @@ class DrawSignalsWidget(QWidget):
self.update()
def scrollAndCut(self, v: Deque[int], cutoff: int):
x = 0
L = len(v)
for p in range(L):
v[p] += 1
if v[p] > cutoff:
x = p
break
# TODO: fix this... delete old ones

View File

@@ -1,10 +1,10 @@
site_name: superqt
site_url: https://github.com/napari/superqt
site_url: https://github.com/pyapp-kit/superqt
site_description: >-
missing widgets and components for PyQt/PySide
# Repository
repo_name: napari/superqt
repo_url: https://github.com/napari/superqt
repo_name: pyapp-kit/superqt
repo_url: https://github.com/pyapp-kit/superqt
# Copyright
copyright: Copyright &copy; 2021 - 2022 Talley Lambert
@@ -36,6 +36,9 @@ markdown_extensions:
- pymdownx.emoji:
emoji_index: !!python/name:materialx.emoji.twemoji
emoji_generator: !!python/name:materialx.emoji.to_svg
- toc:
permalink: "#"
plugins:
- search

View File

@@ -1,10 +1,184 @@
# pyproject.toml
# https://peps.python.org/pep-0517/
[build-system]
requires = ["setuptools>=45", "wheel", "setuptools_scm[toml]>=6.0"]
build-backend = "setuptools.build_meta"
requires = ["hatchling", "hatch-vcs"]
build-backend = "hatchling.build"
[tool.setuptools_scm]
write_to = "src/superqt/_version.py"
# https://peps.python.org/pep-0621/
[project]
name = "superqt"
description = "Missing widgets and components for PyQt/PySide"
readme = "README.md"
requires-python = ">=3.7"
license = { text = "BSD 3-Clause License" }
authors = [{ email = "talley.lambert@gmail.com" }, { name = "Talley Lambert" }]
keywords = [
"qt",
"pyqt",
"pyside",
"widgets",
"range slider",
"components",
"gui",
]
classifiers = [
"Development Status :: 4 - Beta",
"Environment :: X11 Applications :: Qt",
"Intended Audience :: Developers",
"License :: OSI Approved :: BSD License",
"Operating System :: OS Independent",
"Programming Language :: Python :: 3",
"Programming Language :: Python :: 3 :: Only",
"Programming Language :: Python :: 3.7",
"Programming Language :: Python :: 3.8",
"Programming Language :: Python :: 3.9",
"Programming Language :: Python :: 3.10",
"Programming Language :: Python :: 3.11",
"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",
]
# extras
# https://peps.python.org/pep-0621/#dependencies-optional-dependencies
[project.optional-dependencies]
test = ["pint", "pytest", "pytest-cov", "pytest-qt"]
dev = [
"black",
"ipython",
"ruff",
"mypy",
"pdbpp",
"pre-commit",
"pydocstyle",
"rich",
"types-Pygments",
]
docs = ["mkdocs-macros-plugin", "mkdocs-material", "mkdocstrings[python]"]
quantity = ["pint"]
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"]
pyqt5 = ["pyqt5"]
pyqt6 = ["pyqt6"]
font-fa5 = ["fonticon-fontawesome5"]
font-fa6 = ["fonticon-fontawesome6"]
font-mi6 = ["fonticon-materialdesignicons6"]
font-mi7 = ["fonticon-materialdesignicons7"]
[project.urls]
Source = "https://github.com/pyapp-kit/superqt"
Tracker = "https://github.com/pyapp-kit/superqt/issues"
Changelog = "https://github.com/pyapp-kit/superqt/blob/main/CHANGELOG.md"
[tool.hatch.version]
source = "vcs"
[tool.hatch.build.targets.sdist]
include = ["src", "tests", "CHANGELOG.md"]
# 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 = "py37"
src = ["src", "tests"]
select = [
"E", # style errors
"F", # flakes
"D", # pydocstyle
"I", # isort
"UP", # pyupgrade
"S", # bandit
"C", # flake8-comprehensions
"B", # flake8-bugbear
"A001", # flake8-builtins
"RUF", # ruff-specific rules
]
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
"C901", # Function is too complex
]
[tool.ruff.per-file-ignores]
"tests/*.py" = ["D", "S101"]
"examples/demo_widget.py" = ["E501"]
"examples/*.py" = ["B", "D"]
# https://docs.pytest.org/en/6.2.x/customize.html
[tool.pytest.ini_options]
minversion = "6.0"
testpaths = ["tests"]
filterwarnings = [
"error",
"ignore:QPixmapCache.find:DeprecationWarning:",
"ignore:SelectableGroups dict interface:DeprecationWarning",
"ignore:The distutils package is deprecated:DeprecationWarning",
]
# https://mypy.readthedocs.io/en/stable/config_file.html
[tool.mypy]
files = "src/**/*.py"
strict = true
disallow_untyped_defs = false
disallow_untyped_calls = false
disallow_any_generics = false
disallow_subclassing_any = false
show_error_codes = true
pretty = true
exclude = ['tests/**/*']
[[tool.mypy.overrides]]
module = ["superqt.qtcompat.*"]
ignore_missing_imports = true
warn_unused_ignores = false
allow_redefinition = true
# https://coverage.readthedocs.io/en/6.4/config.html
[tool.coverage.report]
exclude_lines = [
"pragma: no cover",
"if TYPE_CHECKING:",
"@overload",
"except ImportError",
]
# https://github.com/mgedmin/check-manifest#configuration
[tool.check-manifest]
ignore = ["src/superqt/_version.py", "mkdocs.yml"]
ignore = [
".github_changelog_generator",
".pre-commit-config.yaml",
"tests/**/*",
"src/superqt/_version.py",
"mkdocs.yml",
"docs/**/*",
"examples/**/*",
"CHANGELOG.md",
"CONTRIBUTING.md",
"codecov.yml",
".ruff_cache/**/*",
"setup.py",
]

119
setup.cfg
View File

@@ -1,119 +0,0 @@
[metadata]
name = superqt
description = Missing widgets for PyQt/PySide
long_description = file: README.md
long_description_content_type = text/markdown
url = https://github.com/napari/superqt
author = Talley Lambert
author_email = talley.lambert@gmail.com
license = BSD-3-Clause
license_file = LICENSE
classifiers =
Development Status :: 4 - Beta
Environment :: X11 Applications :: Qt
Intended Audience :: Developers
License :: OSI Approved :: BSD License
Operating System :: OS Independent
Programming Language :: Python
Programming Language :: Python :: 3
Programming Language :: Python :: 3 :: Only
Programming Language :: Python :: 3.7
Programming Language :: Python :: 3.8
Programming Language :: Python :: 3.9
Programming Language :: Python :: 3.10
Programming Language :: Python :: Implementation :: CPython
Topic :: Desktop Environment
Topic :: Software Development
Topic :: Software Development :: User Interfaces
Topic :: Software Development :: Widget Sets
keywords = qt, range slider, widget
project_urls =
Source = https://github.com/napari/superqt
Tracker = https://github.com/napari/superqt/issues
Changelog = https://github.com/napari/superqt/blob/master/CHANGELOG.md
[options]
packages = find:
install_requires =
packaging
pygments>=2.4.0
qtpy>=1.1.0
typing-extensions
python_requires = >=3.7
include_package_data = True
package_dir =
=src
setup_requires =
setuptools-scm
zip_safe = False
[options.packages.find]
where = src
[options.extras_require]
dev =
ipython
isort
jedi<0.18.0
mypy
pre-commit
pyside2
pytest
pytest-cov
pytest-qt
tox
tox-conda
docs =
mkdocs-macros-plugin
mkdocs-material
mkdocstrings[python]
font_fa5 =
fonticon-fontawesome5
font_mi5 =
fonticon-materialdesignicons5
pyqt5 =
pyqt5
pyqt6 =
pyqt6
pyside2 =
pyside2
pyside6 =
pyside6
testing =
pytest
pytest-cov
pytest-qt
tox
tox-conda
[options.package_data]
superqt = py.typed
[flake8]
exclude = _version.py,.eggs,examples
docstring-convention = numpy
ignore = E203,W503,E501,C901,F403,F405,D100
[pydocstyle]
convention = numpy
add_select = D402,D415,D417
ignore = D100
[isort]
profile = black
[tool:pytest]
filterwarnings =
error
ignore:QPixmapCache.find:DeprecationWarning:
ignore:SelectableGroups dict interface:DeprecationWarning
ignore:The distutils package is deprecated:DeprecationWarning
[mypy]
strict = True
files = src/superqt
[mypy-superqt.qtcompat.*]
ignore_missing_imports = True
warn_unused_ignores = False
allow_redefinition = True

29
setup.py Normal file
View File

@@ -0,0 +1,29 @@
import sys
sys.stderr.write(
"""
===============================
Unsupported installation method
===============================
superqt does not support installation with `python setup.py install`.
Please use `python -m pip install .` instead.
"""
)
sys.exit(1)
# The below code will never execute, however GitHub is particularly
# picky about where it finds Python packaging metadata.
# See: https://github.com/github/feedback/discussions/6456
#
# To be removed once GitHub catches up.
setup( # noqa: F821
name="superqt",
install_requires=[
"packaging",
"pygments>=2.4.0",
"qtpy>=1.1.0",
"typing-extensions",
],
)

View File

@@ -1,14 +1,18 @@
"""superqt is a collection of QtWidgets for python."""
"""superqt is a collection of Qt components for python."""
from typing import TYPE_CHECKING, Any
try:
from ._version import version as __version__
except ImportError:
__version__ = "unknown"
if TYPE_CHECKING:
from .spinbox._quantity import QQuantity
from ._eliding_label import QElidingLabel
from .collapsible import QCollapsible
from .combobox import QEnumComboBox, QSearchableComboBox
from .selection import QSearchableListWidget
from .elidable import QElidingLabel, QElidingLineEdit
from .selection import QSearchableListWidget, QSearchableTreeWidget
from .sliders import (
QDoubleRangeSlider,
QDoubleSlider,
@@ -25,8 +29,10 @@ __all__ = [
"ensure_main_thread",
"ensure_object_thread",
"QDoubleRangeSlider",
"QCollapsible",
"QDoubleSlider",
"QElidingLabel",
"QElidingLineEdit",
"QEnumComboBox",
"QLabeledDoubleRangeSlider",
"QLabeledDoubleSlider",
@@ -34,8 +40,17 @@ __all__ = [
"QLabeledSlider",
"QLargeIntSpinBox",
"QMessageHandler",
"QQuantity",
"QRangeSlider",
"QSearchableComboBox",
"QSearchableListWidget",
"QRangeSlider",
"QCollapsible",
"QSearchableTreeWidget",
]
def __getattr__(name: str) -> Any:
if name == "QQuantity":
from .spinbox._quantity import QQuantity
return QQuantity
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")

View File

@@ -1,110 +0,0 @@
from typing import List
from qtpy.QtCore import QPoint, QRect, QSize, Qt
from qtpy.QtGui import QFont, QFontMetrics, QResizeEvent, QTextLayout
from qtpy.QtWidgets import QLabel
class QElidingLabel(QLabel):
"""A QLabel variant that will elide text (add '') to fit width.
QElidingLabel()
QElidingLabel(parent: Optional[QWidget], f: Qt.WindowFlags = ...)
QElidingLabel(text: str, parent: Optional[QWidget] = None, f: Qt.WindowFlags = ...)
For a multiline eliding label, use `setWordWrap(True)`. In this case, text
will wrap to fit the width, and only the last line will be elided.
When `wordWrap()` is True, `sizeHint()` will return the size required to fit
the full text.
"""
def __init__(self, *args, **kwargs) -> None:
self._elide_mode = Qt.TextElideMode.ElideRight
super().__init__(*args, **kwargs)
self.setText(args[0] if args and isinstance(args[0], str) else "")
# New Public methods
def elideMode(self) -> Qt.TextElideMode:
"""The current Qt.TextElideMode."""
return self._elide_mode
def setElideMode(self, mode: Qt.TextElideMode):
"""Set the elide mode to a Qt.TextElideMode."""
self._elide_mode = Qt.TextElideMode(mode)
super().setText(self._elidedText())
@staticmethod
def wrapText(text, width, font=None) -> List[str]:
"""Returns `text`, split as it would be wrapped for `width`, given `font`.
Static method.
"""
tl = QTextLayout(text, font or QFont())
tl.beginLayout()
lines = []
while True:
ln = tl.createLine()
if not ln.isValid():
break
ln.setLineWidth(width)
start = ln.textStart()
lines.append(text[start : start + ln.textLength()])
tl.endLayout()
return lines
# Reimplemented QT methods
def text(self) -> str:
"""This property holds the label's text.
If no text has been set this will return an empty string.
"""
return self._text
def setText(self, txt: str):
"""Set the label's text.
Setting the text clears any previous content.
NOTE: we set the QLabel private text to the elided version
"""
self._text = txt
super().setText(self._elidedText())
def resizeEvent(self, ev: QResizeEvent) -> None:
ev.accept()
super().setText(self._elidedText())
def setWordWrap(self, wrap: bool) -> None:
super().setWordWrap(wrap)
super().setText(self._elidedText())
def sizeHint(self) -> QSize:
if not self.wordWrap():
return super().sizeHint()
fm = QFontMetrics(self.font())
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())
# private implementation methods
def _elidedText(self) -> str:
"""Return `self._text` elided to `width`"""
fm = QFontMetrics(self.font())
# the 2 is a magic number that prevents the ellipses from going missing
# in certain cases (?)
width = self.width() - 2
if not self.wordWrap():
return fm.elidedText(self._text, self._elide_mode, width)
# get number of lines we can fit without eliding
nlines = self.height() // fm.height() - 1
# get the last line (elided)
text = self._wrappedText()
last_line = fm.elidedText("".join(text[nlines:]), self._elide_mode, width)
# join them
return "".join(text[:nlines] + [last_line])
def _wrappedText(self) -> List[str]:
return QElidingLabel.wrapText(self._text, self.width(), self.font())

View File

@@ -1,26 +1,46 @@
"""A collapsible widget to hide and unhide child widgets"""
from typing import Optional
"""A collapsible widget to hide and unhide child widgets."""
from typing import Optional, Union
from qtpy.QtCore import QEasingCurve, QEvent, QMargins, QObject, QPropertyAnimation, Qt
from qtpy.QtCore import (
QEasingCurve,
QEvent,
QMargins,
QObject,
QPropertyAnimation,
QRect,
Qt,
Signal,
)
from qtpy.QtGui import QIcon, QPainter, QPalette, QPixmap
from qtpy.QtWidgets import QFrame, QPushButton, QVBoxLayout, QWidget
class QCollapsible(QFrame):
"""A collapsible widget to hide and unhide child widgets.
Based on [https://stackoverflow.com/a/68141638](https://stackoverflow.com/a/68141638)
A signal is emitted when the widget is expanded (True) or collapsed (False).
Based on https://stackoverflow.com/a/68141638
"""
_EXPANDED = ""
_COLLAPSED = ""
toggled = Signal(bool)
def __init__(self, title: str = "", parent: Optional[QWidget] = None):
def __init__(
self,
title: str = "",
parent: Optional[QWidget] = None,
expandedIcon: Optional[Union[QIcon, str]] = "",
collapsedIcon: Optional[Union[QIcon, str]] = "",
):
super().__init__(parent)
self._locked = False
self._is_animating = False
self._text = title
self._toggle_btn = QPushButton(self._COLLAPSED + title)
self._toggle_btn = QPushButton(title)
self._toggle_btn.setCheckable(True)
self.setCollapsedIcon(icon=collapsedIcon)
self.setExpandedIcon(icon=expandedIcon)
self._toggle_btn.setStyleSheet("text-align: left; border: none; outline: none;")
self._toggle_btn.toggled.connect(self._toggle)
@@ -44,16 +64,16 @@ class QCollapsible(QFrame):
_content.layout().setContentsMargins(QMargins(5, 0, 0, 0))
self.setContent(_content)
def setText(self, text: str):
def setText(self, text: str) -> None:
"""Set the text of the toggle button."""
current = self._toggle_btn.text()[: len(self._EXPANDED)]
current = self._toggle_btn.text()
self._toggle_btn.setText(current + text)
def text(self) -> str:
"""Return the text of the toggle button."""
return self._toggle_btn.text()[len(self._EXPANDED) :]
return self._toggle_btn.text()
def setContent(self, content: QWidget):
def setContent(self, content: QWidget) -> None:
"""Replace central widget (the widget that gets expanded/collapsed)."""
self._content = content
self.layout().addWidget(self._content)
@@ -63,56 +83,104 @@ class QCollapsible(QFrame):
"""Return the current content widget."""
return self._content
def setDuration(self, msecs: int):
def _convert_string_to_icon(self, symbol: str) -> QIcon:
"""Create a QIcon from a string."""
size = self._toggle_btn.font().pointSize()
pixmap = QPixmap(size, size)
pixmap.fill(Qt.GlobalColor.transparent)
painter = QPainter(pixmap)
color = self._toggle_btn.palette().color(QPalette.ColorRole.WindowText)
painter.setPen(color)
painter.drawText(QRect(0, 0, size, size), Qt.AlignmentFlag.AlignCenter, symbol)
painter.end()
return QIcon(pixmap)
def expandedIcon(self) -> QIcon:
"""Returns the icon used when the widget is expanded."""
return self._expanded_icon
def setExpandedIcon(self, icon: Optional[Union[QIcon, str]] = None) -> None:
"""Set the icon on the toggle button when the widget is expanded."""
if icon and isinstance(icon, QIcon):
self._expanded_icon = icon
elif icon and isinstance(icon, str):
self._expanded_icon = self._convert_string_to_icon(icon)
if self.isExpanded():
self._toggle_btn.setIcon(self._expanded_icon)
def collapsedIcon(self) -> QIcon:
"""Returns the icon used when the widget is collapsed."""
return self._collapsed_icon
def setCollapsedIcon(self, icon: Optional[Union[QIcon, str]] = None) -> None:
"""Set the icon on the toggle button when the widget is collapsed."""
if icon and isinstance(icon, QIcon):
self._collapsed_icon = icon
elif icon and isinstance(icon, str):
self._collapsed_icon = self._convert_string_to_icon(icon)
if not self.isExpanded():
self._toggle_btn.setIcon(self._collapsed_icon)
def setDuration(self, msecs: int) -> None:
"""Set duration of the collapse/expand animation."""
self._animation.setDuration(msecs)
def setEasingCurve(self, easing: QEasingCurve):
"""Set the easing curve for the collapse/expand animation"""
def setEasingCurve(self, easing: QEasingCurve) -> None:
"""Set the easing curve for the collapse/expand animation."""
self._animation.setEasingCurve(easing)
def addWidget(self, widget: QWidget):
def addWidget(self, widget: QWidget) -> None:
"""Add a widget to the central content widget's layout."""
widget.installEventFilter(self)
self._content.layout().addWidget(widget)
def removeWidget(self, widget: QWidget):
def removeWidget(self, widget: QWidget) -> None:
"""Remove widget from the central content widget's layout."""
self._content.layout().removeWidget(widget)
widget.removeEventFilter(self)
def expand(self, animate: bool = True):
"""Expand (show) the collapsible section"""
def expand(self, animate: bool = True) -> None:
"""Expand (show) the collapsible section."""
self._expand_collapse(QPropertyAnimation.Direction.Forward, animate)
def collapse(self, animate: bool = True):
"""Collapse (hide) the collapsible section"""
def collapse(self, animate: bool = True) -> None:
"""Collapse (hide) the collapsible section."""
self._expand_collapse(QPropertyAnimation.Direction.Backward, animate)
def isExpanded(self) -> bool:
"""Return whether the collapsible section is visible"""
"""Return whether the collapsible section is visible."""
return self._toggle_btn.isChecked()
def setLocked(self, locked: bool = True):
"""Set whether collapse/expand is disabled"""
def setLocked(self, locked: bool = True) -> None:
"""Set whether collapse/expand is disabled."""
self._locked = locked
self._toggle_btn.setCheckable(not locked)
def locked(self) -> bool:
"""Return True if collapse/expand is disabled"""
"""Return True if collapse/expand is disabled."""
return self._locked
def _expand_collapse(
self, direction: QPropertyAnimation.Direction, animate: bool = True
):
self,
direction: QPropertyAnimation.Direction,
animate: bool = True,
emit: bool = True,
) -> None:
"""Set values for the widget based on whether it is expanding or collapsing.
An emit flag is included so that the toggle signal is only called once (it
was being emitted a few times via eventFilter when the widget was expanding
previously).
"""
if self._locked:
return
forward = direction == QPropertyAnimation.Direction.Forward
text = self._EXPANDED if forward else self._COLLAPSED
icon = self._expanded_icon if forward else self._collapsed_icon
self._toggle_btn.setIcon(icon)
self._toggle_btn.setChecked(forward)
self._toggle_btn.setText(text + self._toggle_btn.text()[len(self._EXPANDED) :])
_content_height = self._content.sizeHint().height() + 10
if animate:
@@ -122,8 +190,10 @@ class QCollapsible(QFrame):
self._animation.start()
else:
self._content.setMaximumHeight(_content_height if forward else 0)
if emit:
self.toggled.emit(direction == QPropertyAnimation.Direction.Forward)
def _toggle(self):
def _toggle(self) -> None:
self.expand() if self.isExpanded() else self.collapse()
def eventFilter(self, a0: QObject, a1: QEvent) -> bool:
@@ -133,8 +203,10 @@ class QCollapsible(QFrame):
and self.isExpanded()
and not self._is_animating
):
self._expand_collapse(QPropertyAnimation.Direction.Forward, animate=False)
self._expand_collapse(
QPropertyAnimation.Direction.Forward, animate=False, emit=False
)
return False
def _on_animation_done(self):
def _on_animation_done(self) -> None:
self._is_animating = False

View File

@@ -11,7 +11,7 @@ NONE_STRING = "----"
def _get_name(enum_value: Enum):
"""Create human readable name if user does not provide own implementation of __str__"""
"""Create human readable name if user does not implement `__str__`."""
if (
enum_value.__str__.__module__ != "enum"
and not enum_value.__str__.__module__.startswith("shibokensupport")
@@ -24,8 +24,7 @@ def _get_name(enum_value: Enum):
class QEnumComboBox(QComboBox):
"""
ComboBox presenting options from a python Enum.
"""ComboBox presenting options from a python Enum.
If the Enum class does not implement `__str__` then a human readable name
is created from the name of the enum member, replacing underscores with spaces.
@@ -44,9 +43,7 @@ class QEnumComboBox(QComboBox):
self.currentIndexChanged.connect(self._emit_signal)
def setEnumClass(self, enum: Optional[EnumMeta], allow_none=False):
"""
Set enum class from which members value should be selected
"""
"""Set enum class from which members value should be selected."""
self.clear()
self._enum_class = enum
self._allow_none = allow_none and enum is not None
@@ -55,11 +52,11 @@ class QEnumComboBox(QComboBox):
super().addItems(list(map(_get_name, self._enum_class.__members__.values())))
def enumClass(self) -> Optional[EnumMeta]:
"""return current Enum class"""
"""Return current Enum class."""
return self._enum_class
def isOptional(self) -> bool:
"""return if current enum is with optional annotation"""
"""Return if current enum is with optional annotation."""
return self._allow_none
def clear(self):
@@ -68,7 +65,7 @@ class QEnumComboBox(QComboBox):
super().clear()
def currentEnum(self) -> Optional[EnumType]:
"""current value as Enum member"""
"""Current value as Enum member."""
if self._enum_class is not None:
if self._allow_none:
if self.currentText() == NONE_STRING:
@@ -91,7 +88,8 @@ class QEnumComboBox(QComboBox):
return
if not isinstance(value, self._enum_class):
raise TypeError(
f"setValue(self, Enum): argument 1 has unexpected type {type(value).__name__!r}"
"setValue(self, Enum): argument 1 has unexpected type "
f"{type(value).__name__!r}"
)
self.setCurrentText(_get_name(value))

View File

@@ -1,6 +1,8 @@
from typing import Optional
from qtpy import QT_VERSION
from qtpy.QtCore import Qt, Signal
from qtpy.QtWidgets import QComboBox, QCompleter
from qtpy.QtWidgets import QComboBox, QCompleter, QWidget
try:
is_qt_bellow_5_14 = tuple(int(x) for x in QT_VERSION.split(".")[:2]) < (5, 14)
@@ -9,14 +11,12 @@ except ValueError:
class QSearchableComboBox(QComboBox):
"""
ComboCox with completer for fast search in multiple options
"""
"""ComboCox with completer for fast search in multiple options."""
if is_qt_bellow_5_14:
textActivated = Signal(str) # pragma: no cover
def __init__(self, parent=None):
def __init__(self, parent: Optional[QWidget] = None):
super().__init__(parent)
self.setEditable(True)
self.completer_object = QCompleter()

View File

@@ -0,0 +1,4 @@
from ._eliding_label import QElidingLabel
from ._eliding_line_edit import QElidingLineEdit
__all__ = ["QElidingLabel", "QElidingLineEdit"]

View File

@@ -0,0 +1,78 @@
from typing import List
from qtpy.QtCore import Qt
from qtpy.QtGui import QFont, QFontMetrics, QTextLayout
class _GenericEliding:
"""A mixin to provide capabilities to elide text (could add '') to fit width."""
_elide_mode: Qt.TextElideMode = Qt.TextElideMode.ElideRight
_text: str = ""
# the 2 is a magic number that prevents the ellipses from going missing
# in certain cases (?)
_ellipses_width: int = 2
# Public methods
def elideMode(self) -> Qt.TextElideMode:
"""The current Qt.TextElideMode."""
return self._elide_mode
def setElideMode(self, mode: Qt.TextElideMode) -> None:
"""Set the elide mode to a Qt.TextElideMode."""
self._elide_mode = Qt.TextElideMode(mode)
def full_text(self) -> str:
"""The current text without eliding."""
return self._text
def setEllipsesWidth(self, width: int) -> None:
"""A width value to take into account ellipses width when eliding text.
The value is deducted from the widget width when computing the elided version
of the text.
"""
self._ellipses_width = width
@staticmethod
def wrapText(text, width, font=None) -> List[str]:
"""Returns `text`, split as it would be wrapped for `width`, given `font`.
Static method.
"""
tl = QTextLayout(text, font or QFont())
tl.beginLayout()
lines = []
while True:
ln = tl.createLine()
if not ln.isValid():
break
ln.setLineWidth(width)
start = ln.textStart()
lines.append(text[start : start + ln.textLength()])
tl.endLayout()
return lines
# private implementation methods
def _elidedText(self) -> str:
"""Return `self._text` elided to `width`."""
fm = QFontMetrics(self.font())
ellipses_width = 0
if self._elide_mode != Qt.TextElideMode.ElideNone:
ellipses_width = self._ellipses_width
width = self.width() - ellipses_width
if not getattr(self, "wordWrap", None) or not self.wordWrap():
return fm.elidedText(self._text, self._elide_mode, width)
# get number of lines we can fit without eliding
nlines = self.height() // fm.height() - 1
# get the last line (elided)
text = self._wrappedText()
last_line = fm.elidedText("".join(text[nlines:]), self._elide_mode, width)
# join them
return "".join(text[:nlines] + [last_line])
def _wrappedText(self) -> List[str]:
return _GenericEliding.wrapText(self._text, self.width(), self.font())

View File

@@ -0,0 +1,75 @@
from qtpy.QtCore import QPoint, QRect, QSize, Qt
from qtpy.QtGui import QFontMetrics, QResizeEvent
from qtpy.QtWidgets import QLabel
from ._eliding import _GenericEliding
class QElidingLabel(_GenericEliding, QLabel):
"""
A QLabel variant that will elide text (could add '') to fit width.
QElidingLabel()
QElidingLabel(parent: Optional[QWidget], f: Qt.WindowFlags = ...)
QElidingLabel(text: str, parent: Optional[QWidget] = None, f: Qt.WindowFlags = ...)
For a multiline eliding label, use `setWordWrap(True)`. In this case, text
will wrap to fit the width, and only the last line will be elided.
When `wordWrap()` is True, `sizeHint()` will return the size required to fit
the full text.
"""
def __init__(self, *args, **kwargs) -> None:
super().__init__(*args, **kwargs)
if args and isinstance(args[0], str):
self.setText(args[0])
# Reimplemented _GenericEliding methods
def setElideMode(self, mode: Qt.TextElideMode) -> None:
"""Set the elide mode to a Qt.TextElideMode."""
super().setElideMode(mode)
super().setText(self._elidedText())
def setEllipsesWidth(self, width: int) -> None:
"""A width value to take into account ellipses width when eliding text.
The value is deducted from the widget width when computing the elided version
of the text.
"""
super().setEllipsesWidth(width)
super().setText(self._elidedText())
# Reimplemented QT methods
def text(self) -> str:
"""Return the label's text.
If no text has been set this will return an empty string.
"""
return self._text
def setText(self, txt: str) -> None:
"""Set the label's text.
Setting the text clears any previous content.
NOTE: we set the QLabel private text to the elided version
"""
self._text = txt
super().setText(self._elidedText())
def resizeEvent(self, event: QResizeEvent) -> None:
event.accept()
super().setText(self._elidedText())
def setWordWrap(self, wrap: bool) -> None:
super().setWordWrap(wrap)
super().setText(self._elidedText())
def sizeHint(self) -> QSize:
if not self.wordWrap():
return super().sizeHint()
fm = QFontMetrics(self.font())
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())

View File

@@ -0,0 +1,91 @@
from qtpy.QtCore import Qt
from qtpy.QtGui import QFocusEvent, QResizeEvent
from qtpy.QtWidgets import QLineEdit
from ._eliding import _GenericEliding
class QElidingLineEdit(_GenericEliding, QLineEdit):
"""A QLineEdit variant that will elide text (could add '') to fit width.
QElidingLineEdit()
QElidingLineEdit(parent: Optional[QWidget])
QElidingLineEdit(text: str, parent: Optional[QWidget] = None)
"""
def __init__(self, *args, **kwargs) -> None:
super().__init__(*args, **kwargs)
if args and isinstance(args[0], str):
self.setText(args[0])
# The `textEdited` signal doesn't trigger the `textChanged` signal if
# text is changed with `setText`, so we connect to `textEdited` to only
# update _text when text is being edited by the user graphically.
self.textEdited.connect(self._update_text)
# Reimplemented _GenericEliding methods
def setElideMode(self, mode: Qt.TextElideMode) -> None:
"""Set the elide mode to a Qt.TextElideMode.
The text shown is updated to the elided version only if the widget is not
focused.
"""
super().setElideMode(mode)
if not self.hasFocus():
super().setText(self._elidedText())
def setEllipsesWidth(self, width: int) -> None:
"""A width value to take into account ellipses width when eliding text.
The value is deducted from the widget width when computing the elided version
of the text. The text shown is updated to the elided version only if the widget
is not focused.
"""
super().setEllipsesWidth(width)
if not self.hasFocus():
super().setText(self._elidedText())
# Reimplemented QT methods
def text(self) -> str:
"""Return the label's text being shown.
If no text has been set this will return an empty string.
"""
return self._text
def setText(self, text) -> None:
"""Set the line edit's text.
Setting the text clears any previous content.
NOTE: we set the QLineEdit private text to the elided version
"""
self._text = text
if not self.hasFocus():
super().setText(self._elidedText())
def focusInEvent(self, event: QFocusEvent) -> None:
"""Set the full text when the widget is focused."""
super().setText(self._text)
super().focusInEvent(event)
def focusOutEvent(self, event: QFocusEvent) -> None:
"""Set an elided version of the text (if needed) when the focus is out."""
super().setText(self._elidedText())
super().focusOutEvent(event)
def resizeEvent(self, event: QResizeEvent) -> None:
"""Update elided text being shown when the widget is resized."""
if not self.hasFocus():
super().setText(self._elidedText())
super().resizeEvent(event)
# private implementation methods
def _update_text(self, text: str) -> None:
"""Update only the actual text of the widget.
The actual text is the text the widget has without eliding.
"""
self._text = text

View File

@@ -14,7 +14,7 @@ __all__ = [
"spin",
]
from typing import TYPE_CHECKING, Dict, Optional, Tuple, Union
from typing import TYPE_CHECKING
from ._animations import Animation, pulse, spin
from ._iconfont import IconFont, IconFontMeta
@@ -39,20 +39,21 @@ ENTRY_POINT = _FIM.ENTRY_POINT
def icon(
glyph_key: str,
scale_factor: float = DEFAULT_SCALING_FACTOR,
color: ValidColor = None,
color: ValidColor | None = None,
opacity: float = 1,
animation: Optional[Animation] = None,
transform: Optional[QTransform] = None,
states: Dict[str, Union[IconOptionDict, IconOpts]] = {},
animation: Animation | None = None,
transform: QTransform | None = None,
states: dict[str, IconOptionDict | IconOpts] | None = None,
) -> QFontIcon:
"""Create a QIcon for `glyph_key`, with a number of optional settings
"""Create a QIcon for `glyph_key`, with a number of optional settings.
The `glyph_key` (e.g. 'fa5s.smile') represents a Font-family & style, and a glpyh.
The `glyph_key` (e.g. 'fa5s.smile') represents a Font-family & style, and a glyph.
In most cases, the key should be provided by a plugin in the environment, like:
- [fonticon-fontawesome5](https://pypi.org/project/fonticon-fontawesome5/) ('fa5s' & 'fa5r' prefixes)
- [fonticon-materialdesignicons6](https://pypi.org/project/fonticon-materialdesignicons6/) ('mdi6' prefix)
- [fonticon-fontawesome5](https://pypi.org/project/fonticon-fontawesome5/) ('fa5s' &
'fa5r' prefixes)
- [fonticon-materialdesignicons6](https://pypi.org/project/fonticon-materialdesignicons6/)
('mdi6' prefix)
...but fonts can also be added manually using [`addFont`][superqt.fonticon.addFont].
@@ -88,7 +89,7 @@ def icon(
`animation`, etc...)
Missing keys in the state dicts will be taken from the default options, provided
by the paramters above.
by the parameters above.
Returns
-------
@@ -98,7 +99,6 @@ def icon(
Examples
--------
simple example (using the string `'fa5s.smile'` assumes the `fonticon-fontawesome5`
plugin is installed)
@@ -145,11 +145,11 @@ def icon(
opacity=opacity,
animation=animation,
transform=transform,
states=states,
states=states or {},
)
def setTextIcon(widget: QWidget, glyph_key: str, size: Optional[float] = None) -> None:
def setTextIcon(widget: QWidget, glyph_key: str, size: float | None = None) -> None:
"""Set text on a widget to a specific font & glyph.
This is an alternative to setting a QIcon with a pixmap. It may be easier to
@@ -167,8 +167,8 @@ def setTextIcon(widget: QWidget, glyph_key: str, size: Optional[float] = None) -
return _QFIS.instance().setTextIcon(widget, glyph_key, size)
def font(font_prefix: str, size: Optional[int] = None) -> QFont:
"""Create QFont for `font_prefix`
def font(font_prefix: str, size: int | None = None) -> QFont:
"""Create QFont for `font_prefix`.
Parameters
----------
@@ -186,8 +186,8 @@ def font(font_prefix: str, size: Optional[int] = None) -> QFont:
def addFont(
filepath: str, prefix: str, charmap: Optional[Dict[str, str]] = None
) -> Optional[Tuple[str, str]]:
filepath: str, prefix: str, charmap: dict[str, str] | None = None
) -> tuple[str, str] | None:
"""Add OTF/TTF file at `filepath` to the registry under `prefix`.
If you'd like to later use a fontkey in the form of `prefix.some-name`, then

View File

@@ -1,4 +1,5 @@
from abc import ABC, abstractmethod
from typing import Optional
from qtpy.QtCore import QRectF, QTimer
from qtpy.QtGui import QPainter
@@ -42,5 +43,5 @@ class spin(Animation):
class pulse(spin):
"""Animation that spins an icon in slower, discrete steps."""
def __init__(self, parent_widget: QWidget = None):
def __init__(self, parent_widget: Optional[QWidget] = None):
super().__init__(parent_widget, interval=200, step=45)

View File

@@ -60,7 +60,6 @@ class IconFont(metaclass=IconFontMeta):
Examples
--------
class FA5S(IconFont):
__font_file__ = '...'
some_char = 0xfa42
@@ -73,10 +72,11 @@ class IconFont(metaclass=IconFontMeta):
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):
assert isinstance(
getattr(namespace, FONTFILE_ATTR), str
), "Not a valid font type"
return namespace # type: ignore
if not isinstance(getattr(namespace, FONTFILE_ATTR), str):
raise TypeError(
f"Invalid Font: must declare {FONTFILE_ATTR!r} attribute or classmethod"
)
return namespace
elif hasattr(namespace, "__dict__"):
ns = dict(namespace.__dict__)
else:

View File

@@ -1,4 +1,5 @@
from typing import Dict, List, Set, Tuple
import contextlib
from typing import ClassVar, Dict, List, Set, Tuple
from ._iconfont import IconFontMeta, namespace2font
@@ -9,11 +10,10 @@ except ImportError:
class FontIconManager:
ENTRY_POINT = "superqt.fonticon"
_PLUGINS: Dict[str, EntryPoint] = {}
_LOADED: Dict[str, IconFontMeta] = {}
_BLOCKED: Set[EntryPoint] = set()
ENTRY_POINT: ClassVar[str] = "superqt.fonticon"
_PLUGINS: ClassVar[Dict[str, EntryPoint]] = {}
_LOADED: ClassVar[Dict[str, IconFontMeta]] = {}
_BLOCKED: ClassVar[Set[EntryPoint]] = set()
def _discover_fonts(self) -> None:
self._PLUGINS.clear()
@@ -98,10 +98,8 @@ def loaded(load_all=False) -> Dict[str, List[str]]:
if load_all:
discover()
for x in available():
try:
with contextlib.suppress(Exception):
_manager._get_font_class(x)
except Exception:
continue
return {
key: sorted(filter(lambda x: not x.startswith("_"), cls.__dict__))
for key, cls in _manager._LOADED.items()

View File

@@ -1,10 +1,10 @@
from __future__ import annotations
import warnings
from collections import abc
from collections import abc, defaultdict
from dataclasses import dataclass
from pathlib import Path
from typing import DefaultDict, Dict, Optional, Sequence, Tuple, Type, Union, cast
from typing import ClassVar, DefaultDict, Sequence, Tuple, Union, cast
from qtpy import QT_VERSION
from qtpy.QtCore import QObject, QPoint, QRect, QSize, Qt
@@ -23,7 +23,8 @@ from qtpy.QtGui import (
from qtpy.QtWidgets import QApplication, QStyleOption, QWidget
from typing_extensions import TypedDict
from ..utils import QMessageHandler
from superqt.utils import QMessageHandler
from ._animations import Animation
@@ -52,7 +53,7 @@ ValidColor = Union[
StateOrMode = Union[QIcon.State, QIcon.Mode]
StateModeKey = Union[StateOrMode, str, Sequence[StateOrMode]]
_SM_MAP: Dict[str, StateOrMode] = {
_SM_MAP: dict[str, StateOrMode] = {
"on": QIcon.State.On,
"off": QIcon.State.Off,
"normal": QIcon.Mode.Normal,
@@ -62,8 +63,8 @@ _SM_MAP: Dict[str, StateOrMode] = {
}
def _norm_state_mode(key: StateModeKey) -> Tuple[QIcon.State, QIcon.Mode]:
"""return state/mode tuple given a variety of valid inputs.
def _norm_state_mode(key: StateModeKey) -> tuple[QIcon.State, QIcon.Mode]:
"""Return state/mode tuple given a variety of valid inputs.
Input can be either a string, or a sequence of state or mode enums.
Strings can be any combination of on, off, normal, active, selected, disabled,
@@ -73,13 +74,13 @@ def _norm_state_mode(key: StateModeKey) -> Tuple[QIcon.State, QIcon.Mode]:
if isinstance(key, str):
try:
_sm = [_SM_MAP[k.lower()] for k in key.split("_")]
except KeyError:
except KeyError as e:
raise ValueError(
f"{key!r} is not a valid state key, must be a combination of {{on, "
"off, active, disabled, selected, normal} separated by underscore"
)
) from e
else:
_sm = key if isinstance(key, abc.Sequence) else [key] # type: ignore
_sm = key if isinstance(key, abc.Sequence) else [key]
state = next((i for i in _sm if isinstance(i, QIcon.State)), QIcon.State.Off)
mode = next((i for i in _sm if isinstance(i, QIcon.Mode)), QIcon.Mode.Normal)
@@ -91,8 +92,8 @@ class IconOptionDict(TypedDict, total=False):
scale_factor: float
color: ValidColor
opacity: float
animation: Optional[Animation]
transform: Optional[QTransform]
animation: Animation | None
transform: QTransform | None
# public facing, for a nicer IDE experience than a dict
@@ -119,12 +120,12 @@ class IconOpts:
The animation to use, by default `None`
"""
glyph_key: Union[str, Unset] = _Unset
scale_factor: Union[float, Unset] = _Unset
color: Union[ValidColor, Unset] = _Unset
opacity: Union[float, Unset] = _Unset
animation: Union[Animation, Unset, None] = _Unset
transform: Union[QTransform, Unset, None] = _Unset
glyph_key: str | Unset = _Unset
scale_factor: float | Unset = _Unset
color: ValidColor | Unset = _Unset
opacity: float | Unset = _Unset
animation: Animation | Unset | None = _Unset
transform: QTransform | Unset | None = _Unset
def dict(self) -> IconOptionDict:
# not using asdict due to pickle errors on animation
@@ -140,8 +141,8 @@ class _IconOptions:
scale_factor: float = DEFAULT_SCALING_FACTOR
color: ValidColor = None
opacity: float = DEFAULT_OPACITY
animation: Optional[Animation] = None
transform: Optional[QTransform] = None
animation: Animation | None = None
transform: QTransform | None = None
def _update(self, icon_opts: IconOpts) -> _IconOptions:
return _IconOptions(**{**vars(self), **icon_opts.dict()})
@@ -156,8 +157,8 @@ class _QFontIconEngine(QIconEngine):
def __init__(self, options: _IconOptions):
super().__init__()
self._opts: DefaultDict[
QIcon.State, Dict[QIcon.Mode, Optional[_IconOptions]]
self._opts: defaultdict[
QIcon.State, dict[QIcon.Mode, _IconOptions | None]
] = DefaultDict(dict)
self._opts[QIcon.State.Off][QIcon.Mode.Normal] = options
self.update_hash()
@@ -230,7 +231,7 @@ class _QFontIconEngine(QIconEngine):
# font
font = QFont()
font.setFamily(family) # set sepeartely for Qt6
font.setFamily(family) # set separately for Qt6
font.setPixelSize(round(rect.height() * opts.scale_factor))
if style:
font.setStyleName(style)
@@ -239,7 +240,7 @@ class _QFontIconEngine(QIconEngine):
if isinstance(opts.color, tuple):
color_args = opts.color
else:
color_args = (opts.color,) if opts.color else () # type: ignore
color_args = (opts.color,) if opts.color else ()
# animation
if opts.animation is not None:
@@ -321,12 +322,12 @@ class QFontIcon(QIcon):
self,
state: QIcon.State = QIcon.State.Off,
mode: QIcon.Mode = QIcon.Mode.Normal,
glyph_key: Union[str, Unset] = _Unset,
scale_factor: Union[float, Unset] = _Unset,
color: Union[ValidColor, Unset] = _Unset,
opacity: Union[float, Unset] = _Unset,
animation: Union[Animation, Unset, None] = _Unset,
transform: Union[QTransform, Unset, None] = _Unset,
glyph_key: str | Unset = _Unset,
scale_factor: float | Unset = _Unset,
color: ValidColor | Unset = _Unset,
opacity: float | Unset = _Unset,
animation: Animation | Unset | None = _Unset,
transform: QTransform | Unset | None = _Unset,
) -> None:
"""Set icon options for a specific mode/state."""
if glyph_key is not _Unset:
@@ -344,22 +345,20 @@ class QFontIcon(QIcon):
class QFontIconStore(QObject):
# map of key -> (font_family, font_style)
_LOADED_KEYS: Dict[str, Tuple[str, Optional[str]]] = dict()
_LOADED_KEYS: ClassVar[dict[str, tuple[str, str]]] = {}
# map of (font_family, font_style) -> character (char may include key)
_CHARMAPS: Dict[Tuple[str, Optional[str]], Dict[str, str]] = dict()
_CHARMAPS: ClassVar[dict[tuple[str, str | None], dict[str, str]]] = {}
# singleton instance, use `instance()` to retrieve
__instance: Optional[QFontIconStore] = None
__instance: ClassVar[QFontIconStore | None] = None
def __init__(self, parent: Optional[QObject] = None) -> None:
def __init__(self, parent: QObject | None = None) -> None:
super().__init__(parent=parent)
# QT6 drops this
dpi = getattr(Qt.ApplicationAttribute, "AA_UseHighDpiPixmaps", None)
if dpi:
QApplication.setAttribute(dpi)
if tuple(cast(str, QT_VERSION).split(".")) < ("6", "0"):
# QT6 drops this
QApplication.setAttribute(Qt.ApplicationAttribute.AA_UseHighDpiPixmaps)
@classmethod
def instance(cls) -> QFontIconStore:
@@ -374,8 +373,8 @@ class QFontIconStore(QObject):
QFontDatabase.removeAllApplicationFonts()
@classmethod
def _key2family(cls, key: str) -> Tuple[str, Optional[str]]:
"""Return (family, style) given a font `key`"""
def _key2family(cls, key: str) -> tuple[str, str]:
"""Return (family, style) given a font `key`."""
key = key.split(".", maxsplit=1)[0]
if key not in cls._LOADED_KEYS:
from . import _plugins
@@ -383,7 +382,7 @@ class QFontIconStore(QObject):
try:
font_cls = _plugins.get_font_class(key)
result = cls.addFont(
font_cls.__font_file__, key, charmap=font_cls.__dict__
font_cls.__font_file__, key, charmap=dict(font_cls.__dict__)
)
if not result: # pragma: no cover
raise Exception("Invalid font file")
@@ -398,13 +397,15 @@ class QFontIconStore(QObject):
@classmethod
def _ensure_char(cls, char: str, family: str, style: str) -> str:
"""make sure that `char` is a glyph provided by `family` and `style`."""
"""Make sure that `char` is a glyph provided by `family` and `style`."""
if len(char) == 1 and ord(char) > 256:
return char
try:
charmap = cls._CHARMAPS[(family, style)]
except KeyError:
raise KeyError(f"No charmap registered for font '{family} ({style})'")
except KeyError as e:
raise KeyError(
f"No charmap registered for font '{family} ({style})'"
) from e
if char in charmap:
# split in case the charmap includes the key
return charmap[char].split(".", maxsplit=1)[-1]
@@ -417,8 +418,8 @@ class QFontIconStore(QObject):
raise ValueError(f"Font '{family} ({style})' has no glyph with the key {ident}")
@classmethod
def key2glyph(cls, glyph_key: str) -> tuple[str, str, Optional[str]]:
"""Return (char, family, style) given a `glyph_key`"""
def key2glyph(cls, glyph_key: str) -> tuple[str, str, str | None]:
"""Return (char, family, style) given a `glyph_key`."""
if "." not in glyph_key:
raise ValueError("Glyph key must contain a period")
font_key, char = glyph_key.split(".", maxsplit=1)
@@ -428,9 +429,9 @@ class QFontIconStore(QObject):
@classmethod
def addFont(
cls, filepath: str, prefix: str, charmap: Optional[Dict[str, str]] = None
) -> Optional[Tuple[str, str]]:
"""Add font at `filepath` to the registry under `key`.
cls, filepath: str, prefix: str, charmap: dict[str, str] | None = None
) -> tuple[str, str] | None:
r"""Add font at `filepath` to the registry under `key`.
If you'd like to later use a fontkey in the form of `key.some-name`, then
`charmap` must be provided and provide a mapping for all of the glyph names
@@ -441,7 +442,7 @@ class QFontIconStore(QObject):
----------
filepath : str
Path to an OTF or TTF file containing the fonts
key : str
prefix : str
A key that will represent this font file when used for lookup. For example,
'fa5s' for 'Font-Awesome 5 Solid'.
charmap : Dict[str, str], optional
@@ -455,8 +456,8 @@ class QFontIconStore(QObject):
something goes wrong.
"""
if prefix in cls._LOADED_KEYS:
warnings.warn(f"Prefix {prefix} already loaded")
return
warnings.warn(f"Prefix {prefix} already loaded", stacklevel=2)
return None
if not Path(filepath).exists():
raise FileNotFoundError(f"Font file doesn't exist: {filepath}")
@@ -465,28 +466,29 @@ class QFontIconStore(QObject):
fontId = QFontDatabase.addApplicationFont(str(Path(filepath).absolute()))
if fontId < 0: # pragma: no cover
warnings.warn(f"Cannot load font file: {filepath}")
warnings.warn(f"Cannot load font file: {filepath}", stacklevel=2)
return None
families = QFontDatabase.applicationFontFamilies(fontId)
if not families: # pragma: no cover
warnings.warn(f"Font file is empty!: {filepath}")
warnings.warn(f"Font file is empty!: {filepath}", stacklevel=2)
return None
family: str = families[0]
# in Qt6, everything becomes a static member
QFd: Union[QFontDatabase, Type[QFontDatabase]] = (
QFontDatabase() # type: ignore
if tuple(QT_VERSION.split(".")) < ("6", "0")
QFd: QFontDatabase | type[QFontDatabase] = (
QFontDatabase()
if tuple(cast(str, QT_VERSION).split(".")) < ("6", "0")
else QFontDatabase
)
styles = QFd.styles(family) # type: ignore
styles = QFd.styles(family)
style: str = styles[-1] if styles else ""
if not QFd.isSmoothlyScalable(family, style): # pragma: no cover
warnings.warn(
f"Registered font {family} ({style}) is not smoothly scalable. "
"Icons may not look attractive."
"Icons may not look attractive.",
stacklevel=2,
)
cls._LOADED_KEYS[prefix] = (family, style)
@@ -499,11 +501,11 @@ class QFontIconStore(QObject):
glyph_key: str,
*,
scale_factor: float = DEFAULT_SCALING_FACTOR,
color: ValidColor = None,
color: ValidColor | None = None,
opacity: float = 1,
animation: Optional[Animation] = None,
transform: Optional[QTransform] = None,
states: Dict[str, Union[IconOptionDict, IconOpts]] = {},
animation: Animation | None = None,
transform: QTransform | None = None,
states: dict[str, IconOptionDict | IconOpts] | None = None,
) -> QFontIcon:
self.key2glyph(glyph_key) # make sure it's a valid glyph_key
default_opts = _IconOptions(
@@ -515,14 +517,14 @@ class QFontIconStore(QObject):
transform=transform,
)
icon = QFontIcon(default_opts)
for kw, options in states.items():
for kw, options in (states or {}).items():
if isinstance(options, IconOpts):
options = default_opts._update(options).dict()
icon.addState(*_norm_state_mode(kw), **options)
return icon
def setTextIcon(
self, widget: QWidget, glyph_key: str, size: Optional[float] = None
self, widget: QWidget, glyph_key: str, size: float | None = None
) -> None:
"""Sets text on a widget to a specific font & glyph.
@@ -539,8 +541,8 @@ class QFontIconStore(QObject):
widget.setFont(self.font(glyph_key, int(size)))
setText(glyph)
def font(self, font_prefix: str, size: Optional[int] = None) -> QFont:
"""Create QFont for `font_prefix`"""
def font(self, font_prefix: str, size: int | None = None) -> QFont:
"""Create QFont for `font_prefix`."""
font_key, _ = font_prefix.split(".", maxsplit=1)
family, style = self._key2family(font_key)
font = QFont()
@@ -553,7 +555,7 @@ class QFontIconStore(QObject):
def _ensure_identifier(name: str) -> str:
"""Normalize string to valid identifier"""
"""Normalize string to valid identifier."""
import keyword
if not name:
@@ -570,5 +572,6 @@ def _ensure_identifier(name: str) -> str:
# replace dashes and spaces with underscores
name = name.replace("-", "_").replace(" ", "_")
assert str.isidentifier(name), f"Could not canonicalize name: {name}"
if not str.isidentifier(name):
raise ValueError(f"Could not canonicalize name: {name!r}. (not an identifier)")
return name

View File

@@ -6,13 +6,15 @@ from qtpy import * # noqa
warnings.warn(
"The superqt.qtcompat module is deprecated as of v0.3.0. "
"Please import from `qtpy` instead."
"Please import from `qtpy` instead.",
stacklevel=2,
)
# forward any requests for superqt.qtcompat.* to qtpy.*
class SuperQtImporter(abc.MetaPathFinder):
def find_spec(self, fullname: str, path, target=None): # type: ignore
"""Forward any requests for superqt.qtcompat.* to qtpy.*."""
if fullname.startswith(__name__):
return util.find_spec(fullname.replace(__name__, "qtpy"))

View File

@@ -1,3 +1,4 @@
from ._searchable_list_widget import QSearchableListWidget
from ._searchable_tree_widget import QSearchableTreeWidget
__all__ = ("QSearchableListWidget",)
__all__ = ("QSearchableListWidget", "QSearchableTreeWidget")

View File

@@ -0,0 +1,114 @@
import logging
from typing import Any, Iterable, Mapping
from qtpy.QtCore import QRegularExpression
from qtpy.QtWidgets import QLineEdit, QTreeWidget, QTreeWidgetItem, QVBoxLayout, QWidget
class QSearchableTreeWidget(QWidget):
"""A tree widget for showing a mapping that can be searched by key.
This is intended to be used with a read-only mapping and be conveniently
created using `QSearchableTreeWidget.fromData(data)`.
If the mapping changes, the easiest way to update this is by calling `setData`.
The tree can be searched by entering a regular expression pattern
into the `filter` line edit. An item is only shown if its, any of its ancestors',
or any of its descendants' keys or values match this pattern.
The regular expression follows the conventions described by the Qt docs:
https://doc.qt.io/qt-5/qregularexpression.html#details
Attributes
----------
tree : QTreeWidget
Shows the mapping as a tree of items.
filter : QLineEdit
Used to filter items in the tree by matching their key against a
regular expression.
"""
def __init__(self, parent=None):
super().__init__(parent)
self.tree: QTreeWidget = QTreeWidget(self)
self.tree.setHeaderLabels(("Key", "Value"))
self.filter: QLineEdit = QLineEdit(self)
self.filter.setClearButtonEnabled(True)
self.filter.textChanged.connect(self._updateVisibleItems)
layout = QVBoxLayout(self)
layout.addWidget(self.filter)
layout.addWidget(self.tree)
def setData(self, data: Mapping) -> None:
"""Update the mapping data shown by the tree."""
self.tree.clear()
self.filter.clear()
top_level_items = [_make_item(name=k, value=v) for k, v in data.items()]
self.tree.addTopLevelItems(top_level_items)
def _updateVisibleItems(self, pattern: str) -> None:
"""Recursively update the visibility of items based on the given pattern."""
expression = QRegularExpression(pattern)
for i in range(self.tree.topLevelItemCount()):
top_level_item = self.tree.topLevelItem(i)
_update_visible_items(top_level_item, expression)
@classmethod
def fromData(
cls, data: Mapping, *, parent: QWidget = None
) -> "QSearchableTreeWidget":
"""Make a searchable tree widget from a mapping."""
widget = cls(parent)
widget.setData(data)
return widget
def _make_item(*, name: str, value: Any) -> QTreeWidgetItem:
"""Make a tree item where the name and value are two columns.
Iterable values other than strings are recursively traversed to
add child items and build a tree. In this case, mappings use keys
as their names whereas other iterables use their enumerated index.
"""
if isinstance(value, Mapping):
item = QTreeWidgetItem([name, type(value).__name__])
for k, v in value.items():
child = _make_item(name=k, value=v)
item.addChild(child)
elif isinstance(value, Iterable) and not isinstance(value, str):
item = QTreeWidgetItem([name, type(value).__name__])
for i, v in enumerate(value):
child = _make_item(name=str(i), value=v)
item.addChild(child)
else:
item = QTreeWidgetItem([name, str(value)])
logging.debug("_make_item: %s, %s, %s", item.text(0), item.text(1), item.flags())
return item
def _update_visible_items(
item: QTreeWidgetItem, expression: QRegularExpression, ancestor_match: bool = False
) -> bool:
"""Recursively update the visibility of a tree item based on an expression.
An item is visible if any of its, any of its ancestors', or any of its descendants'
column's text matches the expression.
Returns True if the item is visible, False otherwise.
"""
match = ancestor_match or any(
expression.match(item.text(i)).hasMatch() for i in range(item.columnCount())
)
visible = match
for i in range(item.childCount()):
child = item.child(i)
descendant_visible = _update_visible_items(child, expression, match)
visible = visible or descendant_visible
item.setHidden(not visible)
logging.debug(
"_update_visible_items: %s, %s",
tuple(item.text(i) for i in range(item.columnCount())),
visible,
)
return visible

View File

@@ -1,12 +0,0 @@
from qtpy.QtWidgets import QSlider
from ._generic_range_slider import _GenericRangeSlider
from ._generic_slider import _GenericSlider
class QDoubleRangeSlider(_GenericRangeSlider): ...
class QDoubleSlider(_GenericSlider): ...
class QRangeSlider(_GenericRangeSlider): ...
class QLabeledSlider(QSlider): ...
class QLabeledDoubleSlider(QDoubleSlider): ...
class QLabeledRangeSlider(QRangeSlider): ...
class QLabeledDoubleRangeSlider(QDoubleRangeSlider): ...

View File

@@ -1,4 +1,4 @@
from typing import Generic, List, Sequence, Tuple, TypeVar, Union
from typing import List, Optional, Sequence, Tuple, TypeVar, Union
from qtpy import QtGui
from qtpy.QtCore import Property, QEvent, QPoint, QPointF, QRect, QRectF, Qt, Signal
@@ -17,7 +17,7 @@ _T = TypeVar("_T")
SC_BAR = QStyle.SubControl.SC_ScrollBarSubPage
class _GenericRangeSlider(_GenericSlider[Tuple], Generic[_T]):
class _GenericRangeSlider(_GenericSlider):
"""MultiHandle Range Slider widget.
Same API as QSlider, but `value`, `setValue`, `sliderPosition`, and
@@ -80,11 +80,11 @@ class _GenericRangeSlider(_GenericSlider[Tuple], Generic[_T]):
self._bar_is_rigid = bool(val)
def barMovesAllHandles(self) -> bool:
"""Whether clicking on the bar moves all handles (default), or just the nearest."""
"""Whether clicking on the bar moves all handles, or just the nearest."""
return self._bar_moves_all
def setBarMovesAllHandles(self, val: bool = True) -> None:
"""Whether clicking on the bar moves all handles (default), or just the nearest."""
"""Whether clicking on the bar moves all handles, or just the nearest."""
self._bar_moves_all = bool(val)
def barIsVisible(self) -> bool:
@@ -233,7 +233,9 @@ class _GenericRangeSlider(_GenericSlider[Tuple], Generic[_T]):
# SubControl Positions
def _handleRect(self, handle_index: int, opt: QStyleOptionSlider = None) -> QRect:
def _handleRect(
self, handle_index: int, opt: Optional[QStyleOptionSlider] = None
) -> QRect:
"""Return the QRect for all handles."""
opt = opt or self._styleOption
opt.sliderPosition = self._optSliderPositions[handle_index]
@@ -310,7 +312,7 @@ class _GenericRangeSlider(_GenericSlider[Tuple], Generic[_T]):
# NOTE: this is very much tied to mousepress... not a generic "get control"
def _getControlAtPos(
self, pos: QPoint, opt: QStyleOptionSlider = None
self, pos: QPoint, opt: Optional[QStyleOptionSlider] = None
) -> Tuple[QStyle.SubControl, int]:
"""Update self._pressedControl based on ev.pos()."""
opt = opt or self._styleOption

View File

@@ -1,10 +1,10 @@
"""Generic Sliders with internal python-based models
"""Generic Sliders with internal python-based models.
This module reimplements most of the logic from qslider.cpp in python:
https://code.woboq.org/qt5/qtbase/src/widgets/widgets/qslider.cpp.html
This probably looks like tremendous overkill at first (and it may be!),
since a it's possible to acheive a very reasonable "float slider" by
since a it's possible to achieve a very reasonable "float slider" by
scaling input float values to some internal integer range for the QSlider,
and converting back to float when getting `value()`. However, one still
runs into overflow limitations due to the internal integer model.
@@ -21,7 +21,7 @@ QRangeSlider.
"""
import os
import platform
from typing import Generic, TypeVar
from typing import TypeVar
from qtpy import QT_VERSION, QtGui
from qtpy.QtCore import QEvent, QPoint, QPointF, QRect, Qt, Signal
@@ -48,7 +48,7 @@ QOVERFLOW = 2**31 - 1
# whether to use the MONTEREY_SLIDER_STYLES_FIX QSS hack
# for fixing sliders on macos>=12 with QT < 6
# https://bugreports.qt.io/browse/QTBUG-98093
# https://github.com/napari/superqt/issues/74
# https://github.com/pyapp-kit/superqt/issues/74
USE_MAC_SLIDER_PATCH = (
QT_VERSION
and int(QT_VERSION.split(".")[0]) < 6
@@ -58,7 +58,7 @@ USE_MAC_SLIDER_PATCH = (
)
class _GenericSlider(QSlider, Generic[_T]):
class _GenericSlider(QSlider):
_fvalueChanged = Signal(int)
_fsliderMoved = Signal(int)
_frangeChanged = Signal(int, int)
@@ -66,7 +66,6 @@ class _GenericSlider(QSlider, Generic[_T]):
MAX_DISPLAY = 5000
def __init__(self, *args, **kwargs) -> None:
self._minimum = 0.0
self._maximum = 99.0
self._pageStep = 10.0
@@ -276,7 +275,6 @@ class _GenericSlider(QSlider, Generic[_T]):
self.update()
def wheelEvent(self, e: QtGui.QWheelEvent) -> None:
e.ignore()
vertical = bool(e.angleDelta().y())
delta = e.angleDelta().y() if vertical else e.angleDelta().x()
@@ -522,16 +520,7 @@ def _event_position(ev: QEvent) -> QPoint:
def _sliderValueFromPosition(
min: float, max: float, position: int, span: int, upsideDown: bool = False
) -> float:
"""Converts the given pixel `position` to a value.
0 maps to the `min` parameter, `span` maps to `max` and other values are
distributed evenly in-between.
By default, this function assumes that the maximum value is on the right
for horizontal items and on the bottom for vertical items. Set the
`upsideDown` parameter to True to reverse this behavior.
"""
"""Converts the given pixel `position` to a value."""
if span <= 0 or position <= 0:
return max if upsideDown else min
if position >= span:

View File

@@ -1,3 +1,4 @@
import contextlib
from enum import IntEnum
from functools import partial
from typing import Any
@@ -17,7 +18,8 @@ from qtpy.QtWidgets import (
QWidget,
)
from ..utils import signals_blocked
from superqt.utils import signals_blocked
from ._sliders import QDoubleRangeSlider, QDoubleSlider, QRangeSlider
@@ -129,6 +131,9 @@ class QLabeledSlider(_SliderProxy, QAbstractSlider):
parent, orientation = _handle_overloaded_slider_sig(args, kwargs)
super().__init__(parent)
# accept focus events
fp = self.style().styleHint(QStyle.StyleHint.SH_Button_FocusPolicy)
self.setFocusPolicy(Qt.FocusPolicy(fp))
self._slider = self._slider_class()
self._label = SliderLabel(self._slider, connect=self._setValue)
@@ -147,10 +152,7 @@ class QLabeledSlider(_SliderProxy, QAbstractSlider):
self.setOrientation(orientation)
def _setValue(self, value: float):
"""
Convert the value from float to int before
setting the slider value
"""
"""Convert the value from float to int before setting the slider value."""
self._slider.setValue(int(value))
def _rename_signals(self):
@@ -171,7 +173,7 @@ class QLabeledSlider(_SliderProxy, QAbstractSlider):
if self._edge_label_mode == EdgeLabelMode.NoLabel:
marg = (0, 0, 5, 0)
layout = QHBoxLayout()
layout = QHBoxLayout() # type: ignore
layout.addWidget(self._slider)
layout.addWidget(self._label)
self._label.setAlignment(Qt.AlignmentFlag.AlignRight)
@@ -222,6 +224,10 @@ class QLabeledDoubleSlider(QLabeledSlider):
super().__init__(*args, **kwargs)
self.setDecimals(2)
def _setValue(self, value: float):
"""Convert the value from float to int before setting the slider value."""
self._slider.setValue(value)
def _rename_signals(self):
self.valueChanged = self._fvalueChanged
self.sliderMoved = self._fsliderMoved
@@ -421,7 +427,6 @@ class QLabeledRangeSlider(_SliderProxy, QAbstractSlider):
def setOrientation(self, orientation):
"""Set orientation, value will be 'horizontal' or 'vertical'."""
self._slider.setOrientation(orientation)
if orientation == Qt.Orientation.Vertical:
layout = QVBoxLayout()
@@ -569,10 +574,8 @@ class SliderLabel(QDoubleSpinBox):
if opt == EdgeLabelMode.LabelIsRange:
self.setMinimum(-9999999)
self.setMaximum(9999999)
try:
with contextlib.suppress(Exception):
self._slider.rangeChanged.disconnect(self.setRange)
except Exception:
pass
else:
self.setMinimum(self._slider.minimum())
self.setMaximum(self._slider.maximum())

View File

@@ -260,7 +260,6 @@ def parse_color(color: str, default_attr) -> QColor | QGradient:
def update_styles_from_stylesheet(obj: _GenericRangeSlider):
qss: str = obj.styleSheet()
parent = obj.parent()

View File

@@ -27,11 +27,11 @@ class _FloatMixin:
return float(value)
class QDoubleSlider(_FloatMixin, _GenericSlider[float]):
class QDoubleSlider(_FloatMixin, _GenericSlider):
pass
class QIntSlider(_IntMixin, _GenericSlider[int]):
class QIntSlider(_IntMixin, _GenericSlider):
# mostly just an example... use QSlider instead.
valueChanged = Signal(int)

View File

@@ -1,3 +1,4 @@
import math
from enum import Enum
from qtpy.QtCore import QSize, Qt, Signal
@@ -24,7 +25,7 @@ class _AnyIntValidator(QValidator):
class QLargeIntSpinBox(QAbstractSpinBox):
"""An integer spinboxes backed by unbound python integer
"""An integer spinboxes backed by unbound python integer.
Qt's built-in ``QSpinBox`` is backed by a signed 32-bit integer.
This could become limiting, particularly in large dense segmentations.
@@ -42,6 +43,9 @@ class QLargeIntSpinBox(QAbstractSpinBox):
self._minimum: int = 0
self._maximum: int = 2**64 - 1
self._single_step: int = 1
self._step_type: QAbstractSpinBox.StepType = (
QAbstractSpinBox.StepType.DefaultStepType
)
self._pending_emit = False
validator = _AnyIntValidator(self)
self.lineEdit().setValidator(validator)
@@ -78,7 +82,13 @@ class QLargeIntSpinBox(QAbstractSpinBox):
def setSingleStep(self, step):
self._single_step = int(step)
# TODO: add prefix/suffix/stepType
def setStepType(self, stepType: QAbstractSpinBox.StepType) -> None:
self._step_type = stepType
def stepType(self) -> QAbstractSpinBox.StepType:
return self._step_type
# TODO: add prefix/suffix
# ############### QtOverrides #######################
@@ -102,13 +112,16 @@ class QLargeIntSpinBox(QAbstractSpinBox):
return super().keyPressEvent(e)
def stepBy(self, steps: int) -> None:
step = self._single_step
old = self._value
e = _EmitPolicy.EmitIfChanged
if self._pending_emit:
self._interpret(_EmitPolicy.NeverEmit)
if self._value != old:
e = _EmitPolicy.AlwaysEmit
if self._step_type == QAbstractSpinBox.StepType.AdaptiveDecimalStepType:
step = self._calculate_adaptive_decimal_step(steps)
else:
step = self._single_step
self._setValue(self._bound(self._value + (step * steps)), e)
def stepEnabled(self):
@@ -164,9 +177,12 @@ class QLargeIntSpinBox(QAbstractSpinBox):
v = int(text)
self._setValue(v, policy)
def _editor_text_changed(self, t):
def _editor_text_changed(self, t: str) -> None:
if self.keyboardTracking():
self._setValue(int(t), _EmitPolicy.EmitIfChanged)
try:
self._setValue(int(t), _EmitPolicy.EmitIfChanged)
except ValueError:
pass
self.lineEdit().setFocus()
self._pending_emit = False
else:
@@ -174,3 +190,15 @@ class QLargeIntSpinBox(QAbstractSpinBox):
def _bound(self, value):
return max(self._minimum, min(self._maximum, value))
def _calculate_adaptive_decimal_step(self, steps: int) -> int:
abs_value = abs(self._value)
if abs_value < 100:
return 1
value_negative = self._value < 0
steps_negative = steps < 0
sign_compensation = 0 if value_negative == steps_negative else 1
log = int(math.log10(abs_value - sign_compensation)) - 1
return int(math.pow(10, log))

View File

@@ -0,0 +1,234 @@
from typing import TYPE_CHECKING, Optional, Union
try:
from pint import Quantity, Unit, UnitRegistry
from pint.util import UnitsContainer
except ImportError as e:
raise ImportError(
"pint is required to use QQuantity. Install it with `pip install pint`"
) from e
from qtpy.QtCore import Signal
from qtpy.QtWidgets import QComboBox, QDoubleSpinBox, QHBoxLayout, QSizePolicy, QWidget
from superqt.utils import signals_blocked
if TYPE_CHECKING:
from decimal import Decimal
Number = Union[int, float, "Decimal"]
UREG = UnitRegistry()
NULL_OPTION = "-----"
QOVERFLOW = 2**30
SI_BASES = {
"[length]": "meter",
"[time]": "second",
"[current]": "ampere",
"[luminosity]": "candela",
"[mass]": "gram",
"[substance]": "mole",
"[temperature]": "kelvin",
}
DEFAULT_OPTIONS = {
"[length]": ["km", "m", "mm", "µm"],
"[time]": ["day", "hour", "min", "sec", "ms"],
"[current]": ["A", "mA", "µA"],
"[luminosity]": ["kcd", "cd", "mcd"],
"[mass]": ["kg", "g", "mg", "µg"],
"[substance]": ["mol", "mmol", "µmol"],
"[temperature]": ["°C", "°F", "°K"],
"radian": ["rad", "deg"],
}
class QQuantity(QWidget):
"""A combination QDoubleSpinBox and QComboBox for entering quantities.
For this widget, `value()` returns a `pint.Quantity` object, while `setValue()`
accepts either a number, `pint.Quantity`, a string that can be parsed by `pint`.
Parameters
----------
value : Union[str, pint.Quantity, Number]
The initial value to display. If a string, it will be parsed by `pint`.
units : Union[pint.util.UnitsContainer, str, pint.Quantity], optional
The units to use if `value` is a number. If a string, it will be parsed by
`pint`. If a `pint.Quantity`, the units will be extracted from it.
ureg : pint.UnitRegistry, optional
The unit registry to use. If not provided, the registry will be extracted
from `value` if it is a `pint.Quantity`, otherwise the default registry will
be used.
parent : QWidget, optional
The parent widget, by default None
"""
valueChanged = Signal(Quantity)
unitsChanged = Signal(Unit)
dimensionalityChanged = Signal(UnitsContainer)
def __init__(
self,
value: Union[str, Quantity, Number] = 0,
units: Optional[Union[UnitsContainer, str, Quantity]] = None,
ureg: Optional[UnitRegistry] = None,
parent: Optional[QWidget] = None,
) -> None:
super().__init__(parent=parent)
if ureg is None:
ureg = value._REGISTRY if isinstance(value, Quantity) else UREG
else:
if not isinstance(ureg, UnitRegistry):
raise TypeError(
f"ureg must be a pint.UnitRegistry, not {type(ureg).__name__}"
)
self._ureg = ureg
self._value: Quantity = self._ureg.Quantity(value, units=units)
# whether to preserve quantity equality when changing units or magnitude
self._preserve_quantity: bool = False
self._abbreviate_units: bool = True # TODO: implement
self._mag_spinbox = QDoubleSpinBox()
self._mag_spinbox.setDecimals(3)
self._mag_spinbox.setRange(-QOVERFLOW, QOVERFLOW - 1)
self._mag_spinbox.setValue(float(self._value.magnitude))
self._mag_spinbox.valueChanged.connect(self.setMagnitude)
self._units_combo = QComboBox()
self._units_combo.setSizePolicy(QSizePolicy.Fixed, QSizePolicy.Fixed)
self._units_combo.currentTextChanged.connect(self.setUnits)
self._update_units_combo_choices()
self.setLayout(QHBoxLayout())
self.layout().addWidget(self._mag_spinbox)
self.layout().addWidget(self._units_combo)
self.layout().setContentsMargins(6, 0, 0, 0)
def unitRegistry(self) -> UnitRegistry:
"""Return the pint UnitRegistry used by this widget."""
return self._ureg
def _update_units_combo_choices(self):
if self._value.dimensionless:
with signals_blocked(self._units_combo):
self._units_combo.clear()
self._units_combo.addItem(NULL_OPTION)
self._units_combo.addItems(
[self._format_units(x) for x in SI_BASES.values()]
)
self._units_combo.setCurrentText(NULL_OPTION)
return
units = self._value.units
dims, exp = next(iter(units.dimensionality.items()))
if exp != 1:
raise NotImplementedError("Inverse units not yet implemented")
options = [
self._format_units(self._ureg.Unit(u))
for u in DEFAULT_OPTIONS.get(dims, [])
]
current = self._format_units(units)
with signals_blocked(self._units_combo):
self._units_combo.clear()
self._units_combo.addItems(options)
if self._units_combo.findText(current) == -1:
self._units_combo.addItem(current)
self._units_combo.setCurrentText(current)
def value(self) -> Quantity:
"""Return the current value as a `pint.Quantity`."""
return self._value
def text(self) -> str:
return str(self._value)
def magnitude(self) -> Union[float, int]:
"""Return the magnitude of the current value."""
return self._value.magnitude
def units(self) -> Unit:
"""Return the current units."""
return self._value.units
def dimensionality(self) -> UnitsContainer:
"""Return the current dimensionality (cast to `str` for nice repr)."""
return self._value.dimensionality
def setDecimals(self, decimals: int) -> None:
"""Set the number of decimals to display in the spinbox."""
self._mag_spinbox.setDecimals(decimals)
if self._value is not None:
self._mag_spinbox.setValue(self._value.magnitude)
def setValue(
self,
value: Union[str, Quantity, Number],
units: Optional[Union[UnitsContainer, str, Quantity]] = None,
) -> None:
"""Set the current value (will cast to a pint Quantity)."""
if isinstance(value, Quantity):
if units is not None:
raise ValueError("Cannot specify units if value is a Quantity")
new_val = self._ureg.Quantity(value.magnitude, units=value.units)
else:
new_val = self._ureg.Quantity(value, units=units)
mag_change = new_val.magnitude != self._value.magnitude
units_change = new_val.units != self._value.units
dims_changed = new_val.dimensionality != self._value.dimensionality
self._value = new_val
if mag_change:
with signals_blocked(self._mag_spinbox):
self._mag_spinbox.setValue(float(self._value.magnitude))
if units_change:
with signals_blocked(self._units_combo):
self._units_combo.setCurrentText(self._format_units(self._value.units))
self.unitsChanged.emit(self._value.units)
if dims_changed:
self._update_units_combo_choices()
self.dimensionalityChanged.emit(self._value.dimensionality)
if mag_change or units_change:
self.valueChanged.emit(self._value)
def setMagnitude(self, magnitude: Number) -> None:
"""Set the magnitude of the current value."""
self.setValue(self._ureg.Quantity(magnitude, self._value.units))
def setUnits(self, units: Union[str, Unit, Quantity]) -> None:
"""Set the units of the current value.
If `units` is `None`, will convert to a dimensionless quantity.
Otherwise, units must be compatible with the current dimensionality.
"""
if units is None:
new_val = self._ureg.Quantity(self._value.magnitude)
elif self.isDimensionless():
new_val = self._ureg.Quantity(self._value.magnitude, units)
else:
new_val = self._value.to(units)
self.setValue(new_val)
def isDimensionless(self) -> bool:
"""Return `True` if the current value is dimensionless."""
return self._value.dimensionless
def magnitudeSpinBox(self) -> QDoubleSpinBox:
"""Return the `QSpinBox` widget used to edit the magnitude."""
return self._mag_spinbox
def unitsComboBox(self) -> QComboBox:
"""Return the `QCombBox` widget used to edit the units."""
return self._units_combo
def _format_units(self, u: Union[Unit, str]) -> str:
if isinstance(u, str):
return u
return f"{u:~}" if self._abbreviate_units else f"{u:}"

View File

@@ -6,19 +6,17 @@ from pygments.lexers import find_lexer_class, get_lexer_by_name
from pygments.util import ClassNotFound
from qtpy import QtGui
# inspired by https://github.com/Vector35/snippets/blob/master/QCodeEditor.py (MIT license) and
# inspired by https://github.com/Vector35/snippets/blob/master/QCodeEditor.py
# (MIT license) and
# https://pygments.org/docs/formatterdevelopment/#html-3-2-formatter
def get_text_char_format(style):
"""
Return a QTextCharFormat with the given attributes.
https://pygments.org/docs/formatterdevelopment/#html-3-2-formatter
"""
text_char_format = QtGui.QTextCharFormat()
text_char_format.setFontFamily("monospace")
if 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']}"))
@@ -44,7 +42,8 @@ class QFormatter(Formatter):
self._style = {name: get_text_char_format(style) for name, style in self.style}
def format(self, tokensource, outfile):
"""
"""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`.
@@ -52,12 +51,7 @@ class QFormatter(Formatter):
self.data = []
for token, value in tokensource:
self.data.extend(
[
self._style[token],
]
* len(value)
)
self.data.extend([self._style[token]] * len(value))
class CodeSyntaxHighlight(QtGui.QSyntaxHighlighter):
@@ -85,7 +79,8 @@ class CodeSyntaxHighlight(QtGui.QSyntaxHighlighter):
# dirty, dirty hack
# The core problem is that pygemnts by default use string streams,
# that will not handle QTextCharFormat, so wee need use `data` property to work around this.
# 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])

View File

@@ -1,7 +1,9 @@
# https://gist.github.com/FlorianRhiem/41a1ad9b694c14fb9ac3
from __future__ import annotations
from concurrent.futures import Future
from functools import wraps
from typing import Callable, List, Optional
from typing import TYPE_CHECKING, Callable, ClassVar, overload
from qtpy.QtCore import (
QCoreApplication,
@@ -13,10 +15,18 @@ from qtpy.QtCore import (
Slot,
)
if TYPE_CHECKING:
from typing import TypeVar
from typing_extensions import Literal, ParamSpec
P = ParamSpec("P")
R = TypeVar("R")
class CallCallable(QObject):
finished = Signal(object)
instances: List["CallCallable"] = []
instances: ClassVar[list[CallCallable]] = []
def __init__(self, callable, *args, **kwargs):
super().__init__()
@@ -32,8 +42,34 @@ class CallCallable(QObject):
self.finished.emit(res)
# fmt: off
@overload
def ensure_main_thread(
func: Optional[Callable] = None, await_return: bool = False, timeout: int = 1000
await_return: Literal[True],
timeout: int = 1000,
) -> Callable[[Callable[P, R]], Callable[P, R]]: ...
@overload
def ensure_main_thread(
func: Callable[P, R],
await_return: Literal[True],
timeout: int = 1000,
) -> Callable[P, R]: ...
@overload
def ensure_main_thread(
await_return: Literal[False] = False,
timeout: int = 1000,
) -> Callable[[Callable[P, R]], Callable[P, Future[R]]]: ...
@overload
def ensure_main_thread(
func: Callable[P, R],
await_return: Literal[False] = False,
timeout: int = 1000,
) -> Callable[P, Future[R]]: ...
# fmt: on
def ensure_main_thread(
func: Callable | None = None, await_return: bool = False, timeout: int = 1000
):
"""Decorator that ensures a function is called in the main QApplication thread.
@@ -65,13 +101,37 @@ def ensure_main_thread(
return _func
if func is None:
return _out_func
return _out_func(func)
return _out_func if func is None else _out_func(func)
# fmt: off
@overload
def ensure_object_thread(
await_return: Literal[True],
timeout: int = 1000,
) -> Callable[[Callable[P, R]], Callable[P, R]]: ...
@overload
def ensure_object_thread(
func: Callable[P, R],
await_return: Literal[True],
timeout: int = 1000,
) -> Callable[P, R]: ...
@overload
def ensure_object_thread(
await_return: Literal[False] = False,
timeout: int = 1000,
) -> Callable[[Callable[P, R]], Callable[P, Future[R]]]: ...
@overload
def ensure_object_thread(
func: Callable[P, R],
await_return: Literal[False] = False,
timeout: int = 1000,
) -> Callable[P, Future[R]]: ...
# fmt: on
def ensure_object_thread(
func: Optional[Callable] = None, await_return: bool = False, timeout: int = 1000
func: Callable | None = None, await_return: bool = False, timeout: int = 1000
):
"""Decorator that ensures a QObject method is called in the object's thread.
@@ -98,9 +158,7 @@ def ensure_object_thread(
return _func
if func is None:
return _out_func
return _out_func(func)
return _out_func if func is None else _out_func(func)
def _run_in_thread(
@@ -121,5 +179,5 @@ def _run_in_thread(
f = CallCallable(func, *args, **kwargs)
f.moveToThread(thread)
f.finished.connect(future.set_result, Qt.ConnectionType.DirectConnection)
QMetaObject.invokeMethod(f, "call", Qt.ConnectionType.QueuedConnection) # type: ignore
QMetaObject.invokeMethod(f, "call", Qt.ConnectionType.QueuedConnection) # type: ignore # noqa
return future.result(timeout=timeout / 1000) if await_return else future

View File

@@ -1,52 +0,0 @@
from concurrent.futures import Future
from typing import Callable, TypeVar, overload
from typing_extensions import Literal, ParamSpec
P = ParamSpec("P")
R = TypeVar("R")
@overload
def ensure_main_thread(
await_return: Literal[True],
timeout: int = 1000,
) -> Callable[[Callable[P, R]], Callable[P, R]]: ...
@overload
def ensure_main_thread(
func: Callable[P, R],
await_return: Literal[True],
timeout: int = 1000,
) -> Callable[P, R]: ...
@overload
def ensure_main_thread(
await_return: Literal[False] = False,
timeout: int = 1000,
) -> Callable[[Callable[P, R]], Callable[P, Future[R]]]: ...
@overload
def ensure_main_thread(
func: Callable[P, R],
await_return: Literal[False] = False,
timeout: int = 1000,
) -> Callable[P, Future[R]]: ...
@overload
def ensure_object_thread(
await_return: Literal[True],
timeout: int = 1000,
) -> Callable[[Callable[P, R]], Callable[P, R]]: ...
@overload
def ensure_object_thread(
func: Callable[P, R],
await_return: Literal[True],
timeout: int = 1000,
) -> Callable[P, R]: ...
@overload
def ensure_object_thread(
await_return: Literal[False] = False,
timeout: int = 1000,
) -> Callable[[Callable[P, R]], Callable[P, Future[R]]]: ...
@overload
def ensure_object_thread(
func: Callable[P, R],
await_return: Literal[False] = False,
timeout: int = 1000,
) -> Callable[P, Future[R]]: ...

View File

@@ -1,6 +1,8 @@
from __future__ import annotations
import logging
from contextlib import suppress
from typing import List, NamedTuple, Optional
from typing import ClassVar, NamedTuple
from qtpy.QtCore import QMessageLogContext, QtMsgType, qInstallMessageHandler
@@ -28,7 +30,6 @@ class QMessageHandler:
Examples
--------
>>> handler = QMessageHandler()
>>> handler.install() # now all Qt output will be available at mh.records
@@ -40,7 +41,7 @@ class QMessageHandler:
... ...
"""
_qt2loggertype = {
_qt2loggertype: ClassVar[dict[QtMsgType, int]] = {
QtMsgType.QtDebugMsg: logging.DEBUG,
QtMsgType.QtInfoMsg: logging.INFO,
QtMsgType.QtWarningMsg: logging.WARNING,
@@ -49,10 +50,10 @@ class QMessageHandler:
QtMsgType.QtSystemMsg: logging.CRITICAL,
}
def __init__(self, logger: Optional[logging.Logger] = None):
self.records: List[Record] = []
def __init__(self, logger: logging.Logger | None = None):
self.records: list[Record] = []
self._logger = logger
self._previous_handler: Optional[object] = "__uninstalled__"
self._previous_handler: object | None = "__uninstalled__"
def install(self):
"""Install this handler (override the current QtMessageHandler)."""
@@ -68,7 +69,7 @@ class QMessageHandler:
return f"<{n} object at {hex(id(self))} with {len(self.records)} records>"
def __enter__(self):
"""Enter a context with this handler installed"""
"""Enter a context with this handler installed."""
self.install()
return self

View File

@@ -8,15 +8,11 @@ from typing import (
TYPE_CHECKING,
Any,
Callable,
Dict,
ClassVar,
Generator,
Generic,
Optional,
Sequence,
Set,
Type,
TypeVar,
Union,
overload,
)
@@ -27,7 +23,7 @@ if TYPE_CHECKING:
class SigInst(Generic[_T]):
@staticmethod
def connect(slot: Callable[[_T], Any], type: Optional[type] = ...) -> None:
def connect(slot: Callable[[_T], Any], type: type | None = ...) -> None:
...
@staticmethod
@@ -61,7 +57,7 @@ def as_generator_function(
"""Turns a regular function (single return) into a generator function."""
@wraps(func)
def genwrapper(*args, **kwargs) -> Generator[None, None, _R]:
def genwrapper(*args: Any, **kwargs: Any) -> Generator[None, None, _R]:
yield
return func(*args, **kwargs)
@@ -69,10 +65,9 @@ def as_generator_function(
class WorkerBaseSignals(QObject):
started = Signal() # emitted when the work is started
finished = Signal() # emitted when the work is finished
_finished = Signal(object) # emitted when the work is finished ro delete
_finished = Signal(object) # emitted when the work is finished to delete
returned = Signal(object) # emitted with return value
errored = Signal(object) # emitted with error object on Exception
warned = Signal(tuple) # emitted with showwarning args on warning
@@ -93,7 +88,7 @@ class WorkerBase(QRunnable, Generic[_R]):
"""
#: A set of Workers. Add to set using `WorkerBase.start`
_worker_set: Set[WorkerBase] = set()
_worker_set: ClassVar[set[WorkerBase]] = set()
returned: SigInst[_R]
errored: SigInst[Exception]
warned: SigInst[tuple]
@@ -102,8 +97,8 @@ class WorkerBase(QRunnable, Generic[_R]):
def __init__(
self,
func: Optional[Callable[_P, _R]] = None,
SignalsClass: Type[WorkerBaseSignals] = WorkerBaseSignals,
func: Callable[_P, _R] | None = None,
SignalsClass: type[WorkerBaseSignals] = WorkerBaseSignals,
) -> None:
super().__init__()
self._abort_requested = False
@@ -148,7 +143,7 @@ class WorkerBase(QRunnable, Generic[_R]):
@property
def is_running(self) -> bool:
"""Whether the worker has been started"""
"""Whether the worker has been started."""
return self._running
def run(self) -> None:
@@ -190,6 +185,7 @@ class WorkerBase(QRunnable, Generic[_R]):
warnings.warn(
f"RuntimeError in aborted thread: {result}",
RuntimeWarning,
stacklevel=2,
)
return
else:
@@ -202,14 +198,14 @@ class WorkerBase(QRunnable, Generic[_R]):
self.finished.emit()
self._finished.emit(self)
def work(self) -> Union[Exception, _R]:
def work(self) -> Exception | _R:
"""Main method to execute the worker.
The end-user should never need to call this function.
But subclasses must implement this method (See
[`GeneratorFunction.work`][superqt.utils._qthreading.GeneratorWorker.work] for an example implementation).
Minimally, it should check `self.abort_requested` periodically and
exit if True.
[`GeneratorFunction.work`][superqt.utils._qthreading.GeneratorWorker.work] for
an example implementation). Minimally, it should check `self.abort_requested`
periodically and exit if True.
Examples
--------
@@ -267,7 +263,7 @@ class WorkerBase(QRunnable, Generic[_R]):
cls._worker_set.discard(obj)
@classmethod
def await_workers(cls, msecs: int = None) -> None:
def await_workers(cls, msecs: int | None = None) -> None:
"""Ask all workers to quit, and wait up to `msec` for quit.
Attempts to clean up all running workers by calling `worker.quit()`
@@ -363,7 +359,6 @@ class FunctionWorker(WorkerBase[_R]):
class GeneratorWorkerSignals(WorkerBaseSignals):
yielded = Signal(object) # emitted with yielded values (if generator used)
paused = Signal() # emitted when a running job has successfully paused
resumed = Signal() # emitted when a paused job has successfully resumed
@@ -397,9 +392,9 @@ class GeneratorWorker(WorkerBase, Generic[_Y, _S, _R]):
def __init__(
self,
func: Callable[_P, Generator[_Y, Optional[_S], _R]],
func: Callable[_P, Generator[_Y, _S | None, _R]],
*args,
SignalsClass: Type[WorkerBaseSignals] = GeneratorWorkerSignals,
SignalsClass: type[WorkerBaseSignals] = GeneratorWorkerSignals,
**kwargs,
):
if not inspect.isgeneratorfunction(func):
@@ -410,7 +405,7 @@ class GeneratorWorker(WorkerBase, Generic[_Y, _S, _R]):
super().__init__(SignalsClass=SignalsClass)
self._gen = func(*args, **kwargs)
self._incoming_value: Optional[_S] = None
self._incoming_value: _S | None = None
self._pause_requested = False
self._resume_requested = False
self._paused = False
@@ -419,7 +414,7 @@ class GeneratorWorker(WorkerBase, Generic[_Y, _S, _R]):
self._pause_interval = 0.01
self.pbar = None
def work(self) -> Union[Optional[_R], Exception]:
def work(self) -> _R | None | Exception:
"""Core event loop that calls the original function.
Enters a continual loop, yielding and returning from the original
@@ -445,8 +440,8 @@ class GeneratorWorker(WorkerBase, Generic[_Y, _S, _R]):
self.paused.emit()
continue
try:
input = self._next_value()
output = self._gen.send(input)
_input = self._next_value()
output = self._gen.send(_input)
self.yielded.emit(output)
except StopIteration as exc:
return exc.value
@@ -460,7 +455,7 @@ class GeneratorWorker(WorkerBase, Generic[_Y, _S, _R]):
"""Send a value into the function (if a generator was used)."""
self._incoming_value = value
def _next_value(self) -> Optional[_S]:
def _next_value(self) -> _S | None:
out = None
if self._incoming_value is not None:
out = self._incoming_value
@@ -499,9 +494,9 @@ class GeneratorWorker(WorkerBase, Generic[_Y, _S, _R]):
def create_worker(
func: Callable[_P, Generator[_Y, _S, _R]],
*args,
_start_thread: Optional[bool] = None,
_connect: Optional[Dict[str, Union[Callable, Sequence[Callable]]]] = None,
_worker_class: Union[Type[GeneratorWorker], Type[FunctionWorker], None] = None,
_start_thread: bool | None = None,
_connect: dict[str, Callable | Sequence[Callable]] | None = None,
_worker_class: type[GeneratorWorker] | type[FunctionWorker] | None = None,
_ignore_errors: bool = False,
**kwargs,
) -> GeneratorWorker[_Y, _S, _R]:
@@ -512,9 +507,9 @@ def create_worker(
def create_worker(
func: Callable[_P, _R],
*args,
_start_thread: Optional[bool] = None,
_connect: Optional[Dict[str, Union[Callable, Sequence[Callable]]]] = None,
_worker_class: Union[Type[GeneratorWorker], Type[FunctionWorker], None] = None,
_start_thread: bool | None = None,
_connect: dict[str, Callable | Sequence[Callable]] | None = None,
_worker_class: type[GeneratorWorker] | type[FunctionWorker] | None = None,
_ignore_errors: bool = False,
**kwargs,
) -> FunctionWorker[_R]:
@@ -524,12 +519,12 @@ def create_worker(
def create_worker(
func: Callable,
*args,
_start_thread: Optional[bool] = None,
_connect: Optional[Dict[str, Union[Callable, Sequence[Callable]]]] = None,
_worker_class: Union[Type[GeneratorWorker], Type[FunctionWorker], None] = None,
_start_thread: bool | None = None,
_connect: dict[str, Callable | Sequence[Callable]] | None = None,
_worker_class: type[GeneratorWorker] | type[FunctionWorker] | None = None,
_ignore_errors: bool = False,
**kwargs,
) -> Union[FunctionWorker, GeneratorWorker]:
) -> FunctionWorker | GeneratorWorker:
"""Convenience function to start a function in another thread.
By default, uses `FunctionWorker` for functions and `GeneratorWorker` for
@@ -584,7 +579,7 @@ def create_worker(
worker = create_worker(long_function, 10)
```
"""
worker: Union[FunctionWorker, GeneratorWorker]
worker: FunctionWorker | GeneratorWorker
if not _worker_class:
if inspect.isgeneratorfunction(func):
@@ -631,9 +626,9 @@ def create_worker(
@overload
def thread_worker(
function: Callable[_P, Generator[_Y, _S, _R]],
start_thread: Optional[bool] = None,
connect: Optional[Dict[str, Union[Callable, Sequence[Callable]]]] = None,
worker_class: Optional[Type[WorkerBase]] = None,
start_thread: bool | None = None,
connect: dict[str, Callable | Sequence[Callable]] | None = None,
worker_class: type[WorkerBase] | None = None,
ignore_errors: bool = False,
) -> Callable[_P, GeneratorWorker[_Y, _S, _R]]:
...
@@ -642,9 +637,9 @@ def thread_worker(
@overload
def thread_worker(
function: Callable[_P, _R],
start_thread: Optional[bool] = None,
connect: Optional[Dict[str, Union[Callable, Sequence[Callable]]]] = None,
worker_class: Optional[Type[WorkerBase]] = None,
start_thread: bool | None = None,
connect: dict[str, Callable | Sequence[Callable]] | None = None,
worker_class: type[WorkerBase] | None = None,
ignore_errors: bool = False,
) -> Callable[_P, FunctionWorker[_R]]:
...
@@ -653,25 +648,27 @@ def thread_worker(
@overload
def thread_worker(
function: Literal[None] = None,
start_thread: Optional[bool] = None,
connect: Optional[Dict[str, Union[Callable, Sequence[Callable]]]] = None,
worker_class: Optional[Type[WorkerBase]] = None,
start_thread: bool | None = None,
connect: dict[str, Callable | Sequence[Callable]] | None = None,
worker_class: type[WorkerBase] | None = None,
ignore_errors: bool = False,
) -> Callable[[Callable], Callable[_P, Union[FunctionWorker, GeneratorWorker]]]:
) -> Callable[[Callable], Callable[_P, FunctionWorker | GeneratorWorker]]:
...
def thread_worker(
function: Optional[Callable] = None,
start_thread: Optional[bool] = None,
connect: Optional[Dict[str, Union[Callable, Sequence[Callable]]]] = None,
worker_class: Optional[Type[WorkerBase]] = None,
function: Callable | None = None,
start_thread: bool | None = None,
connect: dict[str, Callable | Sequence[Callable]] | None = None,
worker_class: type[WorkerBase] | None = None,
ignore_errors: bool = False,
):
"""Decorator that runs a function in a separate thread when called.
When called, the decorated function returns a [`WorkerBase`][superqt.utils.WorkerBase]. See
[`create_worker`][superqt.utils.create_worker] for additional keyword arguments that can be used
When called, the decorated function returns a
[`WorkerBase`][superqt.utils.WorkerBase]. See
[`create_worker`][superqt.utils.create_worker] for additional keyword arguments that
can be used
when calling the function.
The returned worker will have these signals:
@@ -715,8 +712,9 @@ def thread_worker(
worker class. by default None
worker_class : Type[WorkerBase]
The [`WorkerBase`][superqt.utils.WorkerBase] to instantiate, by default
[`FunctionWorker`][superqt.utils.FunctionWorker] will be used if `func` is a regular function,
and [`GeneratorWorker`][superqt.utils.GeneratorWorker] will be used if it is a generator.
[`FunctionWorker`][superqt.utils.FunctionWorker] will be used if `func` is a
regular function, and [`GeneratorWorker`][superqt.utils.GeneratorWorker] will be
used if it is a generator.
ignore_errors : bool
If `False` (the default), errors raised in the other thread will be
reraised in the main thread (makes debugging significantly easier).
@@ -797,28 +795,14 @@ if TYPE_CHECKING:
def new_worker_qthread(
Worker: Type[WorkerProtocol],
Worker: type[WorkerProtocol],
*args,
_start_thread: bool = False,
_connect: Dict[str, Callable] = None,
_connect: dict[str, Callable] | None = None,
**kwargs,
):
"""This is a convenience function to start a worker in a `QThread`.
"""Convenience function to start a worker in a `QThread`.
In most cases, the [thread_worker][superqt.utils.thread_worker] decorator is
sufficient and preferable. But this allows the user to completely customize the
Worker object. However, they must then maintain control over the thread and clean up
appropriately.
It follows the pattern described
[here](https://www.qt.io/blog/2010/06/17/youre-doing-it-wrong) and in the [qt thread
docs](https://doc.qt.io/qt-5/qthread.html#details)
see also:
https://mayaposch.wordpress.com/2011/11/01/how-to-really-truly-use-qthreads-the-full-explanation/
A QThread object is not a thread! It should be thought of as a class to *manage* a
thread, not as the actual code or object that runs in that
thread. The QThread object is created on the main thread and lives there.
@@ -889,7 +873,6 @@ def new_worker_qthread(
)
```
"""
if _connect and not isinstance(_connect, dict):
raise TypeError("_connect parameter must be a dict")

View File

@@ -1,4 +1,4 @@
"""Adapted for python from the KDToolBox
"""Adapted for python from the KDToolBox.
https://github.com/KDAB/KDToolBox/tree/master/qt/KDSignalThrottler
@@ -62,7 +62,6 @@ class EmissionPolicy(IntFlag):
class GenericSignalThrottler(QObject):
triggered = Signal()
timeoutChanged = Signal(int)
timerTypeChanged = Signal(Qt.TimerType)
@@ -79,7 +78,7 @@ class GenericSignalThrottler(QObject):
self._emissionPolicy = emissionPolicy
self._hasPendingEmission = False
self._timer = QTimer()
self._timer = QTimer(parent=self)
self._timer.setSingleShot(True)
self._timer.setTimerType(Qt.TimerType.PreciseTimer)
self._timer.timeout.connect(self._maybeEmitTriggered)
@@ -94,10 +93,10 @@ class GenericSignalThrottler(QObject):
def timeout(self) -> int:
"""Return current timeout in milliseconds."""
return self._timer.interval() # type: ignore
return self._timer.interval()
def setTimeout(self, timeout: int) -> None:
"""Set timeout in milliseconds"""
"""Set timeout in milliseconds."""
if self._timer.interval() != timeout:
self._timer.setInterval(timeout)
self.timeoutChanged.emit(timeout)
@@ -133,8 +132,6 @@ class GenericSignalThrottler(QObject):
elif self._kind is Kind.Debouncer:
self._timer.start() # restart
assert self._timer.isActive()
def cancel(self) -> None:
"""Cancel any pending emissions."""
self._hasPendingEmission = False
@@ -230,7 +227,7 @@ def qthrottled(
@overload
def qthrottled(
func: "Literal[None]" = None,
func: Optional["Literal[None]"] = None,
timeout: int = 100,
leading: bool = True,
timer_type: Qt.TimerType = Qt.TimerType.PreciseTimer,
@@ -289,7 +286,7 @@ def qdebounced(
@overload
def qdebounced(
func: "Literal[None]" = None,
func: Optional["Literal[None]"] = None,
timeout: int = 100,
leading: bool = False,
timer_type: Qt.TimerType = Qt.TimerType.PreciseTimer,
@@ -371,10 +368,10 @@ def _make_decorator(
throttle.throttle()
return future
setattr(inner, "cancel", throttle.cancel)
setattr(inner, "flush", throttle.flush)
setattr(inner, "set_timeout", throttle.setTimeout)
setattr(inner, "triggered", throttle.triggered)
inner.cancel = throttle.cancel
inner.flush = throttle.flush
inner.set_timeout = throttle.setTimeout
inner.triggered = throttle.triggered
return inner # type: ignore
return deco(func) if func is not None else deco

View File

@@ -1,11 +1,23 @@
"""A test module for testing collapsible"""
from qtpy.QtCore import QEasingCurve
from qtpy.QtWidgets import QPushButton
from qtpy.QtCore import QEasingCurve, Qt
from qtpy.QtGui import QIcon
from qtpy.QtWidgets import QPushButton, QStyle, QWidget
from superqt import QCollapsible
def _get_builtin_icon(name: str) -> QIcon:
"""Get a built-in icon from the Qt library."""
widget = QWidget()
try:
pixmap = getattr(QStyle.StandardPixmap, f"SP_{name}")
except AttributeError:
pixmap = getattr(QStyle, f"SP_{name}")
return widget.style().standardIcon(pixmap)
def test_checked_initialization(qtbot):
"""Test simple collapsible"""
wdg1 = QCollapsible("Advanced analysis")
@@ -84,4 +96,44 @@ def test_changing_text(qtbot):
wdg = QCollapsible()
wdg.setText("Hi new text")
assert wdg.text() == "Hi new text"
assert wdg._toggle_btn.text() == QCollapsible._COLLAPSED + "Hi new text"
assert wdg._toggle_btn.text() == "Hi new text"
def test_toggle_signal(qtbot):
"""Test that signal is emitted when widget expanded/collapsed."""
wdg = QCollapsible()
with qtbot.waitSignal(wdg.toggled, timeout=500):
qtbot.mouseClick(wdg._toggle_btn, Qt.LeftButton)
with qtbot.waitSignal(wdg.toggled, timeout=500):
wdg.expand()
with qtbot.waitSignal(wdg.toggled, timeout=500):
wdg.collapse()
def test_getting_icon(qtbot):
"""Test setting string as toggle button."""
wdg = QCollapsible("test")
assert isinstance(wdg.expandedIcon(), QIcon)
assert isinstance(wdg.collapsedIcon(), QIcon)
def test_setting_icon(qtbot):
"""Test setting icon for toggle button."""
icon1 = _get_builtin_icon("ArrowRight")
icon2 = _get_builtin_icon("ArrowDown")
wdg = QCollapsible("test", expandedIcon=icon1, collapsedIcon=icon2)
assert wdg._expanded_icon == icon1
assert wdg._collapsed_icon == icon2
def test_setting_symbol_icon(qtbot):
"""Test setting string as toggle button."""
wdg = QCollapsible("test")
icon1 = wdg._convert_string_to_icon("+")
icon2 = wdg._convert_string_to_icon("-")
wdg.setCollapsedIcon(icon=icon1)
assert wdg._collapsed_icon == icon1
wdg.setExpandedIcon(icon=icon2)
assert wdg._expanded_icon == icon2

View File

@@ -31,14 +31,14 @@ def test_wrapped_eliding_label(qtbot):
qtbot.addWidget(wdg)
assert not wdg.wordWrap()
assert 630 < wdg.sizeHint().width() < 640
assert wdg._elidedText().endswith("")
assert wdg._elidedText().endswith(ELLIPSIS)
wdg.resize(QSize(200, 100))
assert wdg.text() == TEXT
assert wdg._elidedText().endswith("")
assert wdg._elidedText().endswith(ELLIPSIS)
wdg.setWordWrap(True)
assert wdg.wordWrap()
assert wdg.text() == TEXT
assert wdg._elidedText().endswith("")
assert wdg._elidedText().endswith(ELLIPSIS)
# just empirically from CI ... stupid
if platform.system() == "Linux":
assert wdg.sizeHint() in (QSize(200, 198), QSize(200, 154))

View File

@@ -0,0 +1,60 @@
from qtpy.QtCore import QSize, Qt
from qtpy.QtGui import QResizeEvent
from superqt import QElidingLineEdit
TEXT = "Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do"
ELLIPSIS = ""
def test_init_text_eliding_line_edit(qtbot):
wdg = QElidingLineEdit(TEXT)
qtbot.addWidget(wdg)
oldsize = QSize(100, 20)
wdg.resize(oldsize)
assert wdg._elidedText().endswith(ELLIPSIS)
newsize = QSize(500, 20)
wdg.resize(newsize)
wdg.resizeEvent(QResizeEvent(oldsize, newsize)) # for test coverage
assert wdg._elidedText() == TEXT
assert wdg.text() == TEXT
def test_set_text_eliding_line_edit(qtbot):
wdg = QElidingLineEdit()
qtbot.addWidget(wdg)
wdg.resize(500, 20)
wdg.setText(TEXT)
assert not wdg._elidedText().endswith(ELLIPSIS)
wdg.resize(100, 20)
assert wdg._elidedText().endswith(ELLIPSIS)
def test_set_elide_mode_eliding_line_edit(qtbot):
wdg = QElidingLineEdit()
qtbot.addWidget(wdg)
wdg.resize(500, 20)
wdg.setText(TEXT)
assert not wdg._elidedText().endswith(ELLIPSIS)
wdg.resize(100, 20)
# ellipses should be to the right
assert wdg._elidedText().endswith(ELLIPSIS)
# ellipses should be to the left
wdg.setElideMode(Qt.TextElideMode.ElideLeft)
assert wdg._elidedText().startswith(ELLIPSIS)
assert wdg.elideMode() == Qt.TextElideMode.ElideLeft
# no ellipses should be shown
wdg.setElideMode(Qt.TextElideMode.ElideNone)
assert ELLIPSIS not in wdg._elidedText()
def test_set_elipses_width_eliding_line_edit(qtbot):
wdg = QElidingLineEdit()
qtbot.addWidget(wdg)
wdg.resize(500, 20)
wdg.setText(TEXT)
assert not wdg._elidedText().endswith(ELLIPSIS)
wdg.setEllipsesWidth(int(wdg.width() / 2))
assert wdg._elidedText().endswith(ELLIPSIS)

View File

@@ -158,8 +158,8 @@ def test_names(qapp):
assert ob.check_object_thread_return_future.__doc__ == "sample docstring"
signature = inspect.signature(ob.check_object_thread_return_future)
assert len(signature.parameters) == 1
assert list(signature.parameters.values())[0].name == "a"
assert list(signature.parameters.values())[0].annotation == int
assert next(iter(signature.parameters.values())).name == "a"
assert next(iter(signature.parameters.values())).annotation == int
assert ob.check_main_thread_return.__name__ == "check_main_thread_return"

View File

@@ -12,7 +12,7 @@ FIXTURES = Path(__file__).parent / "fixtures"
@pytest.fixture
def plugin_store(qapp, monkeypatch):
_path = [str(FIXTURES)] + sys.path.copy()
_path = [str(FIXTURES), *sys.path.copy()]
store = QFontIconStore().instance()
with monkeypatch.context() as m:
m.setattr(sys, "path", _path)

View File

@@ -72,3 +72,16 @@ def test_keyboard_tracking(qtbot):
sb.lineEdit().setText("25")
assert sb._pending_emit is False
assert sgnl.args == [25]
def test_large_spinbox_step_type(qtbot):
sb = QLargeIntSpinBox()
qtbot.addWidget(sb)
sb.setMaximum(1_000_000_000)
sb.setStepType(sb.StepType.AdaptiveDecimalStepType)
sb.setValue(1_000_000)
sb.stepBy(1)
assert sb.value() == 1_100_000
sb.setStepType(sb.StepType.DefaultStepType)
sb.stepBy(1)
assert sb.value() == 1_100_001

View File

@@ -28,9 +28,9 @@ def test_message_handler_with_logger(caplog):
QtCore.qCritical("critical")
assert len(caplog.records) == 3
caplog.records[0].message == "debug"
caplog.records[0].levelno == logging.DEBUG
caplog.records[1].message == "warning"
caplog.records[1].levelno == logging.WARNING
caplog.records[2].message == "critical"
caplog.records[2].levelno == logging.CRITICAL
assert caplog.records[0].message == "debug"
assert caplog.records[0].levelno == logging.DEBUG
assert caplog.records[1].message == "warning"
assert caplog.records[1].levelno == logging.WARNING
assert caplog.records[2].message == "critical"
assert caplog.records[2].levelno == logging.CRITICAL

41
tests/test_quantity.py Normal file
View File

@@ -0,0 +1,41 @@
from pint import Quantity
from superqt import QQuantity
def test_qquantity(qtbot):
w = QQuantity(1, "m")
qtbot.addWidget(w)
assert w.value() == 1 * w.unitRegistry().meter
assert w.magnitude() == 1
assert w.units() == w.unitRegistry().meter
assert w.text() == "1 meter"
w.setUnits("cm")
assert w.value() == 100 * w.unitRegistry().centimeter
assert w.magnitude() == 100
assert w.units() == w.unitRegistry().centimeter
assert w.text() == "100.0 centimeter"
w.setMagnitude(10)
assert w.value() == 10 * w.unitRegistry().centimeter
assert w.magnitude() == 10
assert w.units() == w.unitRegistry().centimeter
assert w.text() == "10 centimeter"
w.setValue(1 * w.unitRegistry().meter)
assert w.value() == 1 * w.unitRegistry().meter
assert w.magnitude() == 1
assert w.units() == w.unitRegistry().meter
assert w.text() == "1 meter"
w.setUnits(None)
assert w.isDimensionless()
assert w.unitsComboBox().currentText() == "-----"
assert w.magnitude() == 1
def test_change_qquantity_value(qtbot):
w = QQuantity()
qtbot.addWidget(w)
assert w.value() == Quantity(0)
w.setValue(Quantity("1 meter"))
assert w.value() == Quantity("1 meter")

View File

@@ -0,0 +1,151 @@
from typing import List, Tuple
import pytest
from pytestqt.qtbot import QtBot
from qtpy.QtCore import Qt
from qtpy.QtWidgets import QTreeWidget, QTreeWidgetItem
from superqt import QSearchableTreeWidget
@pytest.fixture
def data() -> dict:
return {
"none": None,
"str": "test",
"int": 42,
"list": [2, 3, 5],
"dict": {
"float": 0.5,
"tuple": (22, 99),
"bool": False,
},
}
@pytest.fixture
def widget(qtbot: QtBot, data: dict) -> QSearchableTreeWidget:
widget = QSearchableTreeWidget.fromData(data)
qtbot.addWidget(widget)
return widget
def columns(item: QTreeWidgetItem) -> Tuple[str, str]:
return item.text(0), item.text(1)
def all_items(tree: QTreeWidget) -> List[QTreeWidgetItem]:
return tree.findItems("", Qt.MatchContains | Qt.MatchRecursive)
def shown_items(tree: QTreeWidget) -> List[QTreeWidgetItem]:
items = all_items(tree)
return [item for item in items if not item.isHidden()]
def test_init(qtbot: QtBot):
widget = QSearchableTreeWidget()
qtbot.addWidget(widget)
assert widget.tree.topLevelItemCount() == 0
def test_from_data(qtbot: QtBot, data: dict):
widget = QSearchableTreeWidget.fromData(data)
qtbot.addWidget(widget)
tree = widget.tree
assert tree.topLevelItemCount() == 5
none_item = tree.topLevelItem(0)
assert columns(none_item) == ("none", "None")
assert none_item.childCount() == 0
str_item = tree.topLevelItem(1)
assert columns(str_item) == ("str", "test")
assert str_item.childCount() == 0
int_item = tree.topLevelItem(2)
assert columns(int_item) == ("int", "42")
assert int_item.childCount() == 0
list_item = tree.topLevelItem(3)
assert columns(list_item) == ("list", "list")
assert list_item.childCount() == 3
assert columns(list_item.child(0)) == ("0", "2")
assert columns(list_item.child(1)) == ("1", "3")
assert columns(list_item.child(2)) == ("2", "5")
dict_item = tree.topLevelItem(4)
assert columns(dict_item) == ("dict", "dict")
assert dict_item.childCount() == 3
assert columns(dict_item.child(0)) == ("float", "0.5")
tuple_item = dict_item.child(1)
assert columns(tuple_item) == ("tuple", "tuple")
assert tuple_item.childCount() == 2
assert columns(tuple_item.child(0)) == ("0", "22")
assert columns(tuple_item.child(1)) == ("1", "99")
assert columns(dict_item.child(2)) == ("bool", "False")
def test_set_data(widget: QSearchableTreeWidget):
tree = widget.tree
assert tree.topLevelItemCount() != 1
widget.setData({"test": "reset"})
assert tree.topLevelItemCount() == 1
assert columns(tree.topLevelItem(0)) == ("test", "reset")
def test_search_no_match(widget: QSearchableTreeWidget):
widget.filter.setText("no match here")
items = shown_items(widget.tree)
assert len(items) == 0
def test_search_all_match(widget: QSearchableTreeWidget):
widget.filter.setText("")
tree = widget.tree
assert all_items(tree) == shown_items(tree)
def test_search_match_one_key(widget: QSearchableTreeWidget):
widget.filter.setText("int")
items = shown_items(widget.tree)
assert len(items) == 1
assert columns(items[0]) == ("int", "42")
def test_search_match_one_value(widget: QSearchableTreeWidget):
widget.filter.setText("test")
items = shown_items(widget.tree)
assert len(items) == 1
assert columns(items[0]) == ("str", "test")
def test_search_match_many_keys(widget: QSearchableTreeWidget):
widget.filter.setText("n")
items = shown_items(widget.tree)
assert len(items) == 2
assert columns(items[0]) == ("none", "None")
assert columns(items[1]) == ("int", "42")
def test_search_match_one_show_unmatched_descendants(widget: QSearchableTreeWidget):
widget.filter.setText("list")
items = shown_items(widget.tree)
assert len(items) == 4
assert columns(items[0]) == ("list", "list")
assert columns(items[1]) == ("0", "2")
assert columns(items[2]) == ("1", "3")
assert columns(items[3]) == ("2", "5")
def test_search_match_one_show_unmatched_ancestors(widget: QSearchableTreeWidget):
widget.filter.setText("tuple")
items = shown_items(widget.tree)
assert len(items) == 4
assert columns(items[0]) == ("dict", "dict")
assert columns(items[1]) == ("tuple", "tuple")
assert columns(items[2]) == ("0", "22")
assert columns(items[3]) == ("1", "99")

View File

@@ -15,15 +15,18 @@ skip_on_linux_qt6 = pytest.mark.skipif(
reason="hover events not working on linux pyqt6",
)
_PointF = QPointF()
def _mouse_event(pos=QPointF(), type_=QEvent.Type.MouseMove):
def _mouse_event(pos=_PointF, type_=QEvent.Type.MouseMove):
"""Create a mouse event of `type_` at `pos`."""
return QMouseEvent(
type_,
QPointF(pos),
Qt.MouseButton.LeftButton,
Qt.MouseButton.LeftButton,
Qt.KeyboardModifier.NoModifier,
QPointF(pos), # localPos
QPointF(), # windowPos / globalPos
Qt.MouseButton.LeftButton, # button
Qt.MouseButton.LeftButton, # buttons
Qt.KeyboardModifier.NoModifier, # modifiers
)

View File

@@ -35,7 +35,7 @@ def ds(qtbot, request):
def assert_val_type():
type_ = float
if cls in range_types:
assert all([isinstance(i, type_) for i in wdg.value()]) # sourcery skip
assert all(isinstance(i, type_) for i in wdg.value()) # sourcery skip
else:
assert isinstance(wdg.value(), type_)

View File

@@ -116,7 +116,6 @@ def test_press_move_release(gslider: _GenericSlider, qtbot):
@skip_on_linux_qt6
def test_hover(gslider: _GenericSlider):
# stub
opt = QStyleOptionSlider()
gslider.initStyleOption(opt)

View File

@@ -27,7 +27,7 @@ def test_slider_connect_works(qtbot):
def _assert_types(args: Iterable[Any], type_: type):
# sourcery skip: comprehension-to-generator
if sys.version_info >= (3, 8):
assert all([isinstance(v, type_) for v in args]), "invalid type"
assert all(isinstance(v, type_) for v in args), "invalid type"
@pytest.mark.parametrize("cls", [QLabeledDoubleSlider, QLabeledSlider])
@@ -67,12 +67,21 @@ def test_labeled_signals(cls, qtbot):
@pytest.mark.parametrize(
"cls", [QLabeledDoubleSlider, QLabeledRangeSlider, QLabeledSlider]
)
def test_editing_finished_signal(cls):
slider = cls()
def test_editing_finished_signal(cls, qtbot):
mock = Mock()
slider = cls()
qtbot.addWidget(slider)
slider.editingFinished.connect(mock)
if hasattr(slider, "_label"):
slider._label.editingFinished.emit()
else:
slider._min_label.editingFinished.emit()
mock.assert_called_once()
def test_editing_float(qtbot):
slider = QLabeledDoubleSlider()
qtbot.addWidget(slider)
slider._label.setValue(0.5)
slider._label.editingFinished.emit()
assert slider.value() == 0.5

View File

@@ -219,7 +219,7 @@ def test_wheel(cls, orientation, qtbot):
def _assert_types(args: Iterable[Any], type_: type):
# sourcery skip: comprehension-to-generator
if sys.version_info >= (3, 8):
assert all([isinstance(v, type_) for v in args]), "invalid type"
assert all(isinstance(v, type_) for v in args), "invalid type"
@pytest.mark.parametrize("cls, orientation", ALL_SLIDER_COMBOS)

View File

@@ -154,7 +154,6 @@ def test_press_move_release(sld: _GenericSlider, qtbot):
@skip_on_linux_qt6
def test_hover(sld: _GenericSlider):
_real_sld = getattr(sld, "_slider", sld)
opt = QStyleOptionSlider()
@@ -179,7 +178,6 @@ def test_hover(sld: _GenericSlider):
def test_wheel(sld: _GenericSlider, qtbot):
if type(sld) is QLabeledSlider and QT_VERSION < (5, 12):
pytest.skip()
@@ -200,7 +198,6 @@ def test_position(sld: _GenericSlider, qtbot):
def test_steps(sld: _GenericSlider, qtbot):
sld.setSingleStep(11)
assert sld.singleStep() == 11
@@ -208,7 +205,6 @@ def test_steps(sld: _GenericSlider, qtbot):
assert sld.pageStep() == 16
if type(sld) is not QLabeledSlider:
sld.setSingleStep(0.1)
assert sld.singleStep() == 0.1

View File

@@ -7,10 +7,10 @@ from qtpy.QtCore import Qt
from superqt import QRangeSlider
from superqt.sliders._generic_range_slider import SC_BAR, SC_HANDLE, SC_NONE
NOT_LINUX = platform.system() != "Linux"
NOT_PYSIDE2 = API_NAME != "PySide2"
LINUX = platform.system() == "Linux"
NOT_PYQT6 = API_NAME != "PyQt6"
skipmouse = pytest.mark.skipif(NOT_LINUX or NOT_PYSIDE2, reason="mouse tests finicky")
skipmouse = pytest.mark.skipif(LINUX or NOT_PYQT6, reason="mouse tests finicky")
@pytest.mark.parametrize("orientation", ["Horizontal", "Vertical"])

View File

@@ -98,9 +98,9 @@ def test_thread_warns(qtbot):
@qthreading.thread_worker(connect={"warned": check_warning}, start_thread=False)
def func():
yield 1
warnings.warn("hey!")
warnings.warn("hey!") # noqa: B028
yield 3
warnings.warn("hey!")
warnings.warn("hey!") # noqa: B028
return 1
wrkr = func()
@@ -236,7 +236,7 @@ def test_worker_base_attribute(qapp):
assert obj.returned is not None
assert obj.errored is not None
with pytest.raises(AttributeError):
obj.aa
_ = obj.aa
def test_abort_does_not_return(qtbot):

64
tox.ini
View File

@@ -1,64 +0,0 @@
[tox]
envlist = py{37,38,39,310}-{linux,macos,windows}-{pyqt5,pyside2,pyqt6,pyside6},py37-linux-{pyqt512,pyqt513,pyqt514}
toxworkdir=/tmp/.tox
isolated_build=True
[coverage:report]
exclude_lines =
pragma: no cover
if TYPE_CHECKING:
\.\.\.
except ImportError*
raise NotImplementedError()
omit =
superqt/_version.py
*_tests*
[gh-actions]
python =
3.6: py36
3.7: py37
3.8: py38
3.9: py39
3.10: py310
[gh-actions:env]
PLATFORM =
ubuntu-latest: linux
ubuntu-16.04: linux
ubuntu-18.04: linux
ubuntu-20.04: linux
windows-latest: windows
macos-latest: macos
macos-11.0: macos
BACKEND =
pyqt5: pyqt5
pyside2: pyside2
pyqt6: pyqt6
pyside6: pyside6
pyqt512: pyqt512
pyqt513: pyqt513
pyqt514: pyqt514
[testenv]
platform =
macos: darwin
linux: linux
windows: win32
passenv = CI GITHUB_ACTIONS DISPLAY XAUTHORITY
deps =
pyqt512: pyqt5==5.12.*
pyside512: pyside2==5.12.*
pyqt513: pyqt5==5.13.*
pyside513: pyside2==5.13.*
pyqt514: pyqt5==5.14.*
pyside514: pyside2==5.14.*
extras =
testing
pyqt5: pyqt5
pyside2: pyside2
pyqt6: pyqt6
pyside6: pyside6
commands_pre =
pyqt6,pyside6: pip install -U pytest-qt@git+https://github.com/pytest-dev/pytest-qt.git
commands = pytest --color=yes --cov=superqt --cov-report=xml -v {posargs}