mirror of
https://github.com/pyapp-kit/superqt.git
synced 2025-07-21 12:11:07 +02:00
Compare commits
84 Commits
Author | SHA1 | Date | |
---|---|---|---|
|
4bf73c37f1 | ||
|
d407af2089 | ||
|
16f9ef9d3d | ||
|
56f65ff123 | ||
|
60188de52e | ||
|
b4d3a4f9b7 | ||
|
95b1178647 | ||
|
ef87685626 | ||
|
b927159f49 | ||
|
61e7409b1c | ||
|
c9103e3dd8 | ||
|
570c261368 | ||
|
bd6899133f | ||
|
3efafd7aa8 | ||
|
0fd25aa665 | ||
|
a5740f0109 | ||
|
65a4a6e17c | ||
|
6f74c6905e | ||
|
d8211493ab | ||
|
1c80109e92 | ||
|
0b984c21e8 | ||
|
50bff8ea61 | ||
|
830fe38fb9 | ||
|
409d19e5c2 | ||
|
df2034d5dc | ||
|
bace50fbb8 | ||
|
66da7113e9 | ||
|
717b7e3d96 | ||
|
1e3cc27686 | ||
|
658995a0b4 | ||
|
60f442789f | ||
|
6993c88311 | ||
|
8525efd98c | ||
|
f676d7e171 | ||
|
599dff7d02 | ||
|
ed960f4994 | ||
|
7fcba7a485 | ||
|
619daae13f | ||
|
462eeada93 | ||
|
8457563f49 | ||
|
504adf8bd0 | ||
|
64dfb43d9e | ||
|
1da26ce7c2 | ||
|
41ea4e8907 | ||
|
39b6a0596f | ||
|
9ff01e757b | ||
|
dd9af3bfed | ||
|
7b964beb89 | ||
|
0407fdc4bd | ||
|
9119336de5 | ||
|
6318675a8c | ||
|
efa2757111 | ||
|
402d237bc4 | ||
|
dc255bdeac | ||
|
ae186df2ae | ||
|
0002d5ee37 | ||
|
f990fea78c | ||
|
1fb46854d4 | ||
|
ca4a1ecb20 | ||
|
c22b7d6f07 | ||
|
bb43cd7fad | ||
|
09c76a0bfa | ||
|
183899c4e7 | ||
|
a39b467563 | ||
|
6ce87d44a6 | ||
|
2cebc868a8 | ||
|
6abd3a21a6 | ||
|
7b2d8bfb2d | ||
|
ad2f05d908 | ||
|
3df7f49706 | ||
|
e98936e8d8 | ||
|
532d3bf89c | ||
|
16b383e783 | ||
|
38d15d1b3b | ||
|
8f09c38074 | ||
|
3c8b5bcf98 | ||
|
3ece7a27b1 | ||
|
e0bb2ea871 | ||
|
78997fe155 | ||
|
021f164419 | ||
|
7f50e69e28 | ||
|
2c747c5a4f | ||
|
b79c8e95b7 | ||
|
b393c6d039 |
2
.github/ISSUE_TEMPLATE/bug_report.md
vendored
2
.github/ISSUE_TEMPLATE/bug_report.md
vendored
@@ -2,7 +2,7 @@
|
||||
name: Bug report
|
||||
about: Create a report to help us improve
|
||||
title: ''
|
||||
labels: ''
|
||||
labels: 'bug'
|
||||
assignees: ''
|
||||
|
||||
---
|
||||
|
7
.github/ISSUE_TEMPLATE/feature.md
vendored
Normal file
7
.github/ISSUE_TEMPLATE/feature.md
vendored
Normal file
@@ -0,0 +1,7 @@
|
||||
---
|
||||
name: Feature request
|
||||
about: Request a new feature
|
||||
title: ''
|
||||
labels: 'enhancement'
|
||||
assignees: ''
|
||||
---
|
10
.github/dependabot.yml
vendored
Normal file
10
.github/dependabot.yml
vendored
Normal 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):"
|
254
.github/workflows/test_and_deploy.yml
vendored
254
.github/workflows/test_and_deploy.yml
vendored
@@ -1,216 +1,114 @@
|
||||
name: Test
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- master
|
||||
- main
|
||||
tags:
|
||||
- "v*" # Push events to matching v*, i.e. v1.0, v20.15.10
|
||||
branches: [main]
|
||||
tags: [v*]
|
||||
pull_request:
|
||||
branches:
|
||||
- master
|
||||
- main
|
||||
workflow_dispatch:
|
||||
schedule:
|
||||
- cron: "0 0 * * 0" # run weekly
|
||||
|
||||
jobs:
|
||||
test:
|
||||
name: ${{ matrix.platform }} py${{ matrix.python-version }} ${{ matrix.backend }}
|
||||
runs-on: ${{ matrix.platform }}
|
||||
timeout-minutes: 10
|
||||
name: Test
|
||||
uses: pyapp-kit/workflows/.github/workflows/test-pyrepo.yml@v1
|
||||
with:
|
||||
os: ${{ matrix.platform }}
|
||||
python-version: ${{ matrix.python-version }}
|
||||
qt: ${{ matrix.backend }}
|
||||
pip-install-pre-release: ${{ github.event_name == 'schedule' }}
|
||||
report-failures: ${{ github.event_name == 'schedule' }}
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
platform: [ubuntu-latest, windows-latest, macos-latest]
|
||||
python-version: [3.7, 3.8, 3.9]
|
||||
backend: [pyqt5, pyside2]
|
||||
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
|
||||
# lack of wheels for pyside2/py3.11
|
||||
- python-version: "3.11"
|
||||
backend: pyside2
|
||||
|
||||
include:
|
||||
# pyqt6 and pyside6 on latest platforms
|
||||
- python-version: 3.9
|
||||
platform: ubuntu-latest
|
||||
backend: pyside6
|
||||
screenshot: 1
|
||||
- python-version: 3.9
|
||||
# https://bugreports.qt.io/browse/PYSIDE-2627
|
||||
- python-version: "3.10"
|
||||
platform: macos-latest
|
||||
backend: "'pyside6!=6.6.2'"
|
||||
- python-version: "3.11"
|
||||
platform: macos-latest
|
||||
backend: "'pyside6!=6.6.2'"
|
||||
- python-version: "3.10"
|
||||
platform: windows-latest
|
||||
backend: pyside6
|
||||
screenshot: 1
|
||||
- python-version: 3.9
|
||||
platform: macos-11.0
|
||||
backend: pyside6
|
||||
screenshot: 1
|
||||
- python-version: 3.9
|
||||
platform: ubuntu-latest
|
||||
backend: pyqt6
|
||||
- python-version: 3.9
|
||||
backend: "'pyside6!=6.6.2'"
|
||||
- python-version: "3.11"
|
||||
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
|
||||
backend: "'pyside6!=6.6.2'"
|
||||
|
||||
# big sur, 3.9
|
||||
- python-version: 3.9
|
||||
platform: macos-11.0
|
||||
backend: pyside2
|
||||
- python-version: 3.9
|
||||
platform: macos-11.0
|
||||
backend: pyqt5
|
||||
|
||||
# legacy OS
|
||||
- python-version: 3.8
|
||||
platform: ubuntu-18.04
|
||||
backend: pyside2
|
||||
- python-version: "3.12"
|
||||
platform: macos-latest
|
||||
backend: pyqt6
|
||||
|
||||
# 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
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v2
|
||||
|
||||
- name: Set up Python ${{ matrix.python-version }}
|
||||
uses: actions/setup-python@v2
|
||||
with:
|
||||
python-version: ${{ matrix.python-version }}
|
||||
|
||||
- uses: tlambert03/setup-qt-libs@v1
|
||||
|
||||
- name: Linux opengl
|
||||
if: runner.os == 'Linux' && ( matrix.backend == 'pyside6' || 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
|
||||
|
||||
- name: Test with tox
|
||||
uses: GabrielBB/xvfb-action@v1
|
||||
timeout-minutes: 3
|
||||
with:
|
||||
run: python -m tox
|
||||
env:
|
||||
PLATFORM: ${{ matrix.platform }}
|
||||
BACKEND: ${{ matrix.backend }}
|
||||
|
||||
- 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
|
||||
|
||||
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
|
||||
with:
|
||||
python-version: '3.8'
|
||||
|
||||
- name: install
|
||||
run: |
|
||||
python -m pip install -U pip
|
||||
python -m pip install -e .[testing,pyqt5]
|
||||
python -m pip install qtpy==1.1.0 typing-extensions==3.10.0.0
|
||||
|
||||
- name: Test napari magicgui
|
||||
uses: GabrielBB/xvfb-action@v1
|
||||
with:
|
||||
run: python -m pytest --color=yes
|
||||
backend: "pyqt5==5.14.*"
|
||||
|
||||
test-qt-minreqs:
|
||||
uses: pyapp-kit/workflows/.github/workflows/test-pyrepo.yml@v1
|
||||
with:
|
||||
python-version: "3.8"
|
||||
qt: pyqt5
|
||||
pip-post-installs: 'qtpy==1.1.0 typing-extensions==3.7.4.3'
|
||||
|
||||
test_napari:
|
||||
name: napari tests
|
||||
uses: pyapp-kit/workflows/.github/workflows/test-dependents.yml@v1
|
||||
with:
|
||||
dependency-repo: napari/napari
|
||||
dependency-ref: ${{ matrix.napari-version }}
|
||||
dependency-extras: 'testing'
|
||||
qt: ${{ matrix.qt }}
|
||||
pytest-args: 'napari/_qt -k "not async and not qt_dims_2"'
|
||||
python-version: "3.10"
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
napari-version: ["", "v0.4.18"]
|
||||
qt: ["pyqt5", "pyside2"]
|
||||
|
||||
check-manifest:
|
||||
name: Check Manifest
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
with:
|
||||
fetch-depth: 0
|
||||
path: superqt
|
||||
|
||||
- uses: actions/checkout@v3
|
||||
with:
|
||||
repository: napari/napari
|
||||
path: napari-repo
|
||||
fetch-depth: 2
|
||||
|
||||
- uses: tlambert03/setup-qt-libs@v1
|
||||
- uses: actions/setup-python@v4
|
||||
with:
|
||||
python-version: '3.10'
|
||||
|
||||
- name: install
|
||||
run: |
|
||||
python -m pip install -U pip
|
||||
python -m pip install ./superqt
|
||||
python -m pip install ./napari-repo[testing,pyqt5]
|
||||
|
||||
- name: Test napari
|
||||
uses: GabrielBB/xvfb-action@v1
|
||||
with:
|
||||
working-directory: napari-repo
|
||||
run: python -m pytest --color=yes napari/_qt
|
||||
|
||||
check_manifest:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v2
|
||||
- uses: actions/setup-python@v2
|
||||
with:
|
||||
python-version: "3.x"
|
||||
- name: Check manifest
|
||||
run: |
|
||||
python -m pip install --upgrade pip
|
||||
pip install check-manifest
|
||||
check-manifest
|
||||
- uses: actions/checkout@v4
|
||||
- run: pipx run 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@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@v2
|
||||
uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: "3.x"
|
||||
- name: Install dependencies
|
||||
|
@@ -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
1
.gitignore
vendored
@@ -45,7 +45,6 @@ nosetests.xml
|
||||
coverage.xml
|
||||
*,cover
|
||||
.hypothesis/
|
||||
.napari_cache
|
||||
|
||||
# Translations
|
||||
*.mo
|
||||
|
@@ -1,41 +1,27 @@
|
||||
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
|
||||
- repo: https://github.com/astral-sh/ruff-pre-commit
|
||||
rev: v0.3.0
|
||||
hooks:
|
||||
- id: end-of-file-fixer
|
||||
- id: trailing-whitespace
|
||||
- repo: https://github.com/asottile/setup-cfg-fmt
|
||||
rev: v2.0.0
|
||||
- id: ruff
|
||||
args: [--fix, --unsafe-fixes]
|
||||
- id: ruff-format
|
||||
|
||||
- repo: https://github.com/abravalheri/validate-pyproject
|
||||
rev: v0.16
|
||||
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
|
||||
hooks:
|
||||
- id: black
|
||||
- id: validate-pyproject
|
||||
|
||||
- repo: https://github.com/pre-commit/mirrors-mypy
|
||||
rev: v0.981
|
||||
rev: v1.8.0
|
||||
hooks:
|
||||
- id: mypy
|
||||
exclude: examples
|
||||
stages: [manual]
|
||||
exclude: tests|examples
|
||||
additional_dependencies:
|
||||
- types-Pygments
|
||||
stages:
|
||||
- manual
|
||||
|
545
CHANGELOG.md
545
CHANGELOG.md
@@ -1,224 +1,423 @@
|
||||
# Changelog
|
||||
|
||||
## [0.3.7](https://github.com/napari/superqt/tree/0.3.7) (2022-10-10)
|
||||
## [v0.6.2](https://github.com/pyapp-kit/superqt/tree/v0.6.2) (2024-03-06)
|
||||
|
||||
[Full Changelog](https://github.com/napari/superqt/compare/v0.3.6...0.3.7)
|
||||
[Full Changelog](https://github.com/pyapp-kit/superqt/compare/v0.6.1...v0.6.2)
|
||||
|
||||
**Implemented enhancements:**
|
||||
|
||||
- feat: add Quantity widget \(using pint\) [\#126](https://github.com/napari/superqt/pull/126) ([tlambert03](https://github.com/tlambert03))
|
||||
- feat: make toggle button public in QCollapsible [\#232](https://github.com/pyapp-kit/superqt/pull/232) ([tlambert03](https://github.com/tlambert03))
|
||||
- feat: add addKey method to QIconifyIcon [\#218](https://github.com/pyapp-kit/superqt/pull/218) ([tlambert03](https://github.com/tlambert03))
|
||||
- feat: Add QIconifyIcon.name\(\) method [\#213](https://github.com/pyapp-kit/superqt/pull/213) ([tlambert03](https://github.com/tlambert03))
|
||||
|
||||
## [v0.3.6](https://github.com/napari/superqt/tree/v0.3.6) (2022-10-05)
|
||||
**Fixed bugs:**
|
||||
|
||||
[Full Changelog](https://github.com/napari/superqt/compare/v0.3.6rc0...v0.3.6)
|
||||
- fix: don't use AbstractContextManager for exceptions\_as\_dialog [\#234](https://github.com/pyapp-kit/superqt/pull/234) ([tlambert03](https://github.com/tlambert03))
|
||||
- fix: Check min max versus current value [\#221](https://github.com/pyapp-kit/superqt/pull/221) ([psobolewskiPhD](https://github.com/psobolewskiPhD))
|
||||
- fix: better default size policy for qcollapsible [\#217](https://github.com/pyapp-kit/superqt/pull/217) ([tlambert03](https://github.com/tlambert03))
|
||||
|
||||
**Merged pull requests:**
|
||||
|
||||
- style: use ruff format instead of black, update pre-commit, restrict pyside6 tests [\#235](https://github.com/pyapp-kit/superqt/pull/235) ([tlambert03](https://github.com/tlambert03))
|
||||
- ci: \[pre-commit.ci\] autoupdate [\#228](https://github.com/pyapp-kit/superqt/pull/228) ([pre-commit-ci[bot]](https://github.com/apps/pre-commit-ci))
|
||||
- ci\(dependabot\): bump actions/setup-python from 4 to 5 [\#225](https://github.com/pyapp-kit/superqt/pull/225) ([dependabot[bot]](https://github.com/apps/dependabot))
|
||||
- ci: \[pre-commit.ci\] autoupdate [\#223](https://github.com/pyapp-kit/superqt/pull/223) ([pre-commit-ci[bot]](https://github.com/apps/pre-commit-ci))
|
||||
- ci: \[pre-commit.ci\] autoupdate [\#216](https://github.com/pyapp-kit/superqt/pull/216) ([pre-commit-ci[bot]](https://github.com/apps/pre-commit-ci))
|
||||
- ci: use reusable test workflow [\#215](https://github.com/pyapp-kit/superqt/pull/215) ([tlambert03](https://github.com/tlambert03))
|
||||
- build: remove packaging dep [\#212](https://github.com/pyapp-kit/superqt/pull/212) ([tlambert03](https://github.com/tlambert03))
|
||||
|
||||
## [v0.6.1](https://github.com/pyapp-kit/superqt/tree/v0.6.1) (2023-10-10)
|
||||
|
||||
[Full Changelog](https://github.com/pyapp-kit/superqt/compare/v0.6.0...v0.6.1)
|
||||
|
||||
**Implemented enhancements:**
|
||||
|
||||
- feat: add QIcon backed by iconify [\#209](https://github.com/pyapp-kit/superqt/pull/209) ([tlambert03](https://github.com/tlambert03))
|
||||
|
||||
**Merged pull requests:**
|
||||
|
||||
- ci: test python 3.12 [\#181](https://github.com/pyapp-kit/superqt/pull/181) ([tlambert03](https://github.com/tlambert03))
|
||||
|
||||
## [v0.6.0](https://github.com/pyapp-kit/superqt/tree/v0.6.0) (2023-09-25)
|
||||
|
||||
[Full Changelog](https://github.com/pyapp-kit/superqt/compare/v0.5.4...v0.6.0)
|
||||
|
||||
**Implemented enhancements:**
|
||||
|
||||
- feat: add support for flag enum [\#207](https://github.com/pyapp-kit/superqt/pull/207) ([Czaki](https://github.com/Czaki))
|
||||
- Add restart\_timer argument to GenericSignalThrottler.flush [\#206](https://github.com/pyapp-kit/superqt/pull/206) ([Czaki](https://github.com/Czaki))
|
||||
- Add colormap combobox and utils [\#195](https://github.com/pyapp-kit/superqt/pull/195) ([tlambert03](https://github.com/tlambert03))
|
||||
- feat: add QColorComboBox for picking single colors [\#194](https://github.com/pyapp-kit/superqt/pull/194) ([tlambert03](https://github.com/tlambert03))
|
||||
|
||||
**Fixed bugs:**
|
||||
|
||||
- Fix IntEnum for python 3.11 [\#205](https://github.com/pyapp-kit/superqt/pull/205) ([Czaki](https://github.com/Czaki))
|
||||
- fix: don't reuse text in qcollapsible [\#204](https://github.com/pyapp-kit/superqt/pull/204) ([tlambert03](https://github.com/tlambert03))
|
||||
- fix: sliderMoved event on RangeSliders [\#200](https://github.com/pyapp-kit/superqt/pull/200) ([tlambert03](https://github.com/tlambert03))
|
||||
|
||||
**Documentation updates:**
|
||||
|
||||
- minor fix to readme [\#125](https://github.com/napari/superqt/pull/125) ([tlambert03](https://github.com/tlambert03))
|
||||
- Docs [\#124](https://github.com/napari/superqt/pull/124) ([tlambert03](https://github.com/tlambert03))
|
||||
|
||||
## [v0.3.6rc0](https://github.com/napari/superqt/tree/v0.3.6rc0) (2022-10-03)
|
||||
|
||||
[Full Changelog](https://github.com/napari/superqt/compare/v0.3.5...v0.3.6rc0)
|
||||
|
||||
**Implemented enhancements:**
|
||||
|
||||
- feat: add editing finished signal to LabeledSliders [\#122](https://github.com/napari/superqt/pull/122) ([tlambert03](https://github.com/tlambert03))
|
||||
|
||||
**Fixed bugs:**
|
||||
|
||||
- fix: fix missing labels after setValue [\#123](https://github.com/napari/superqt/pull/123) ([tlambert03](https://github.com/tlambert03))
|
||||
- fix: Fix TypeError on slider rangeChanged signal [\#121](https://github.com/napari/superqt/pull/121) ([tlambert03](https://github.com/tlambert03))
|
||||
- Simple workaround for pyside 6 [\#119](https://github.com/napari/superqt/pull/119) ([Czaki](https://github.com/Czaki))
|
||||
- fix: Offer patch for \(unstyled\) QSliders on macos 12 and Qt \<6 [\#117](https://github.com/napari/superqt/pull/117) ([tlambert03](https://github.com/tlambert03))
|
||||
|
||||
## [v0.3.5](https://github.com/napari/superqt/tree/v0.3.5) (2022-08-17)
|
||||
|
||||
[Full Changelog](https://github.com/napari/superqt/compare/v0.3.4...v0.3.5)
|
||||
|
||||
**Fixed bugs:**
|
||||
|
||||
- fix range slider drag crash on PyQt6 [\#108](https://github.com/napari/superqt/pull/108) ([sfhbarnett](https://github.com/sfhbarnett))
|
||||
- Fix float value error in pyqt configuration [\#106](https://github.com/napari/superqt/pull/106) ([mstabrin](https://github.com/mstabrin))
|
||||
|
||||
**Merged pull requests:**
|
||||
|
||||
- chore: changelog v0.3.5 [\#110](https://github.com/napari/superqt/pull/110) ([tlambert03](https://github.com/tlambert03))
|
||||
|
||||
## [v0.3.4](https://github.com/napari/superqt/tree/v0.3.4) (2022-07-24)
|
||||
|
||||
[Full Changelog](https://github.com/napari/superqt/compare/v0.3.3...v0.3.4)
|
||||
|
||||
**Fixed bugs:**
|
||||
|
||||
- fix: relax runtime typing extensions requirement [\#101](https://github.com/napari/superqt/pull/101) ([tlambert03](https://github.com/tlambert03))
|
||||
- fix: catch qpixmap deprecation [\#99](https://github.com/napari/superqt/pull/99) ([tlambert03](https://github.com/tlambert03))
|
||||
|
||||
## [v0.3.3](https://github.com/napari/superqt/tree/v0.3.3) (2022-07-10)
|
||||
|
||||
[Full Changelog](https://github.com/napari/superqt/compare/v0.3.2...v0.3.3)
|
||||
|
||||
**Implemented enhancements:**
|
||||
|
||||
- Add code syntax highlight utils [\#88](https://github.com/napari/superqt/pull/88) ([Czaki](https://github.com/Czaki))
|
||||
|
||||
**Fixed bugs:**
|
||||
|
||||
- fix: fix deprecation warning on fonticon plugin discovery on python 3.10 [\#95](https://github.com/napari/superqt/pull/95) ([tlambert03](https://github.com/tlambert03))
|
||||
|
||||
## [v0.3.2](https://github.com/napari/superqt/tree/v0.3.2) (2022-05-03)
|
||||
|
||||
[Full Changelog](https://github.com/napari/superqt/compare/v0.3.1...v0.3.2)
|
||||
|
||||
**Implemented enhancements:**
|
||||
|
||||
- Add QSearchableListWidget and QSearchableComboBox widgets [\#80](https://github.com/napari/superqt/pull/80) ([Czaki](https://github.com/Czaki))
|
||||
|
||||
**Fixed bugs:**
|
||||
|
||||
- Fix crazy animation loop on Qcollapsible [\#84](https://github.com/napari/superqt/pull/84) ([tlambert03](https://github.com/tlambert03))
|
||||
- Reorder label update signal [\#83](https://github.com/napari/superqt/pull/83) ([tlambert03](https://github.com/tlambert03))
|
||||
- Fix height of expanded QCollapsible when child changes size [\#72](https://github.com/napari/superqt/pull/72) ([tlambert03](https://github.com/tlambert03))
|
||||
- docs: add colormap utils and QSearchableTreeWidget to docs [\#199](https://github.com/pyapp-kit/superqt/pull/199) ([tlambert03](https://github.com/tlambert03))
|
||||
- docs: update fonticon docs [\#198](https://github.com/pyapp-kit/superqt/pull/198) ([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))
|
||||
- ci: \[pre-commit.ci\] autoupdate [\#193](https://github.com/pyapp-kit/superqt/pull/193) ([pre-commit-ci[bot]](https://github.com/apps/pre-commit-ci))
|
||||
|
||||
**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))
|
||||
- refactor: Labeled slider updates [\#197](https://github.com/pyapp-kit/superqt/pull/197) ([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))
|
||||
- ci\(dependabot\): bump actions/checkout from 3 to 4 [\#196](https://github.com/pyapp-kit/superqt/pull/196) ([dependabot[bot]](https://github.com/apps/dependabot))
|
||||
|
||||
## [v0.2.5-1](https://github.com/napari/superqt/tree/v0.2.5-1) (2021-11-23)
|
||||
## [v0.5.4](https://github.com/pyapp-kit/superqt/tree/v0.5.4) (2023-08-31)
|
||||
|
||||
[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))
|
||||
[Full Changelog](https://github.com/pyapp-kit/superqt/compare/v0.5.3...v0.5.4)
|
||||
|
||||
**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))
|
||||
- fix: fix mysterious segfault [\#192](https://github.com/pyapp-kit/superqt/pull/192) ([tlambert03](https://github.com/tlambert03))
|
||||
|
||||
**Tests & CI:**
|
||||
## [v0.5.3](https://github.com/pyapp-kit/superqt/tree/v0.5.3) (2023-08-21)
|
||||
|
||||
- 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)
|
||||
[Full Changelog](https://github.com/pyapp-kit/superqt/compare/v0.5.2...v0.5.3)
|
||||
|
||||
**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)
|
||||
- feat: add error `exceptions_as_dialog` context manager to catch and show Exceptions [\#191](https://github.com/pyapp-kit/superqt/pull/191) ([tlambert03](https://github.com/tlambert03))
|
||||
|
||||
**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))
|
||||
- fix: remove dupes/aliases in QEnumCombo [\#190](https://github.com/pyapp-kit/superqt/pull/190) ([tlambert03](https://github.com/tlambert03))
|
||||
|
||||
## [v0.2.2](https://github.com/napari/superqt/tree/v0.2.2) (2021-08-17)
|
||||
## [v0.5.2](https://github.com/pyapp-kit/superqt/tree/v0.5.2) (2023-08-18)
|
||||
|
||||
[Full Changelog](https://github.com/napari/superqt/compare/v0.2.1...v0.2.2)
|
||||
[Full Changelog](https://github.com/pyapp-kit/superqt/compare/v0.5.1...v0.5.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))
|
||||
- feat: allow throttler/debouncer as method decorator [\#188](https://github.com/pyapp-kit/superqt/pull/188) ([Czaki](https://github.com/Czaki))
|
||||
|
||||
**Fixed bugs:**
|
||||
|
||||
- fix: Add descriptive exception when fail to add instance to weakref dictionary [\#189](https://github.com/pyapp-kit/superqt/pull/189) ([Czaki](https://github.com/Czaki))
|
||||
|
||||
## [v0.5.1](https://github.com/pyapp-kit/superqt/tree/v0.5.1) (2023-08-17)
|
||||
|
||||
[Full Changelog](https://github.com/pyapp-kit/superqt/compare/v0.5.0...v0.5.1)
|
||||
|
||||
**Fixed bugs:**
|
||||
|
||||
- fix: fix parameter inspection on ensure\_thread decorators \(alternate\) [\#185](https://github.com/pyapp-kit/superqt/pull/185) ([tlambert03](https://github.com/tlambert03))
|
||||
- fix: fix callback of throttled/debounced decorated functions with mismatched args [\#184](https://github.com/pyapp-kit/superqt/pull/184) ([tlambert03](https://github.com/tlambert03))
|
||||
|
||||
**Documentation updates:**
|
||||
|
||||
- fix broken link [\#18](https://github.com/napari/superqt/pull/18) ([haesleinhuepf](https://github.com/haesleinhuepf))
|
||||
- docs: document signals blocked [\#186](https://github.com/pyapp-kit/superqt/pull/186) ([tlambert03](https://github.com/tlambert03))
|
||||
|
||||
## [v0.2.1](https://github.com/napari/superqt/tree/v0.2.1) (2021-07-10)
|
||||
**Merged pull requests:**
|
||||
|
||||
[Full Changelog](https://github.com/napari/superqt/compare/v0.2.0rc0...v0.2.1)
|
||||
- test: change wait pattern [\#187](https://github.com/pyapp-kit/superqt/pull/187) ([tlambert03](https://github.com/tlambert03))
|
||||
- build: drop python3.7, misc updates to repo [\#180](https://github.com/pyapp-kit/superqt/pull/180) ([tlambert03](https://github.com/tlambert03))
|
||||
|
||||
## [v0.5.0](https://github.com/pyapp-kit/superqt/tree/v0.5.0) (2023-08-06)
|
||||
|
||||
[Full Changelog](https://github.com/pyapp-kit/superqt/compare/v0.4.1...v0.5.0)
|
||||
|
||||
**Implemented enhancements:**
|
||||
|
||||
- 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 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))
|
||||
- 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))
|
||||
|
||||
## [v0.2.0rc0](https://github.com/napari/superqt/tree/v0.2.0rc0) (2021-06-26)
|
||||
**Documentation updates:**
|
||||
|
||||
[Full Changelog](https://github.com/napari/superqt/compare/v0.2.0...v0.2.0rc0)
|
||||
- Fix typos [\#147](https://github.com/pyapp-kit/superqt/pull/147) ([kianmeng](https://github.com/kianmeng))
|
||||
|
||||
**Tests & CI:**
|
||||
|
||||
- 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:**
|
||||
|
||||
- Move QCollapsible toggle signal emit [\#144](https://github.com/pyapp-kit/superqt/pull/144) ([ppwadhwa](https://github.com/ppwadhwa))
|
||||
|
||||
**Merged pull requests:**
|
||||
|
||||
- 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.4.0](https://github.com/pyapp-kit/superqt/tree/v0.4.0) (2022-11-09)
|
||||
|
||||
[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.0rc0...v0.2.1)
|
||||
|
||||
**Fixed bugs:**
|
||||
|
||||
- Fix QLabeledRangeSlider API \(fix slider proxy\) [\#10](https://github.com/pyapp-kit/superqt/pull/10) ([tlambert03](https://github.com/tlambert03))
|
||||
- Fix range slider with negative min range [\#9](https://github.com/pyapp-kit/superqt/pull/9) ([tlambert03](https://github.com/tlambert03))
|
||||
|
||||
## [v0.2.0rc0](https://github.com/pyapp-kit/superqt/tree/v0.2.0rc0) (2021-06-26)
|
||||
|
||||
[Full Changelog](https://github.com/pyapp-kit/superqt/compare/v0.2.0...v0.2.0rc0)
|
||||
|
||||
|
||||
|
||||
|
@@ -26,7 +26,7 @@ pytest
|
||||
|
||||
All widgets must be well-tested, and should work on:
|
||||
|
||||
- Python 3.7 and above
|
||||
- Python 3.8 and above
|
||||
- PyQt5 (5.11 and above) & PyQt6
|
||||
- PySide2 (5.11 and above) & PySide6
|
||||
- macOS, Windows, & Linux
|
||||
@@ -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`.
|
||||
|
17
MANIFEST.in
17
MANIFEST.in
@@ -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
|
24
README.md
24
README.md
@@ -1,11 +1,11 @@
|
||||
#  superqt!
|
||||
|
||||
[](https://github.com/napari/superqt/raw/master/LICENSE)
|
||||
[](https://github.com/pyapp-kit/superqt/raw/master/LICENSE)
|
||||
[](https://pypi.org/project/superqt)
|
||||
[](https://python.org)
|
||||
[](https://github.com/napari/superqt/actions/workflows/test_and_deploy.yml)
|
||||
[](https://codecov.io/gh/napari/superqt)
|
||||
[](https://github.com/pyapp-kit/superqt/actions/workflows/test_and_deploy.yml)
|
||||
[](https://codecov.io/gh/pyapp-kit/superqt)
|
||||
|
||||
### "missing" widgets and components for PyQt/PySide
|
||||
|
||||
@@ -15,36 +15,36 @@ that are not provided in the native QtWidgets module.
|
||||
Components are tested on:
|
||||
|
||||
- macOS, Windows, & Linux
|
||||
- Python 3.7 and above
|
||||
- Python 3.8 and above
|
||||
- PyQt5 (5.11 and above) & PyQt6
|
||||
- PySide2 (5.11 and above) & PySide6
|
||||
|
||||
## 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
|
||||
|
||||
|
@@ -39,7 +39,10 @@ def define_env(env: "MacrosPlugin"):
|
||||
|
||||
exec(src)
|
||||
_grab(dest, width)
|
||||
return f"{{ loading=lazy; width={width} }}\n\n"
|
||||
return (
|
||||
f""
|
||||
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
|
||||
|
@@ -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.
|
||||
|
@@ -10,7 +10,7 @@ QtWidgets module.
|
||||
Components are tested on:
|
||||
|
||||
- macOS, Windows, & Linux
|
||||
- Python 3.7 and above
|
||||
- Python 3.8 and above
|
||||
- PyQt5 (5.11 and above) & PyQt6
|
||||
- PySide2 (5.11 and above) & PySide6
|
||||
|
||||
@@ -26,4 +26,4 @@ conda install -c conda-forge superqt
|
||||
|
||||
## Usage
|
||||
|
||||
See the [Widgets](./widgets/) and [Utilities](./utilities/) pages for features offered by superqt.
|
||||
See the [Widgets](./widgets/index.md) and [Utilities](./utilities/index.md) pages for features offered by superqt.
|
||||
|
12
docs/utilities/cmap.md
Normal file
12
docs/utilities/cmap.md
Normal file
@@ -0,0 +1,12 @@
|
||||
# Colormap utilities
|
||||
|
||||
See also:
|
||||
|
||||
- [`superqt.QColormapComboBox`](../widgets/qcolormap.md)
|
||||
- [`superqt.cmap.CmapCatalogComboBox`](../widgets/colormap_catalog.md)
|
||||
|
||||
::: superqt.cmap.draw_colormap
|
||||
|
||||
::: superqt.cmap.QColormapLineEdit
|
||||
|
||||
::: superqt.cmap.QColormapItemDelegate
|
3
docs/utilities/error_dialog_contexts.md
Normal file
3
docs/utilities/error_dialog_contexts.md
Normal file
@@ -0,0 +1,3 @@
|
||||
# Error message context manager
|
||||
|
||||
::: superqt.utils.exceptions_as_dialog
|
@@ -28,21 +28,44 @@ app.exec()
|
||||
|
||||
## Font Icon plugins
|
||||
|
||||
Ready-made fonticon packs are available as plugins:
|
||||
Ready-made fonticon packs are available as plugins.
|
||||
|
||||
### [Font Awesome 5](https://fontawesome.com/v5/search)
|
||||
A great way to search across most available icons libraries from a single
|
||||
search interface is to use glyphsearch: <https://glyphsearch.com/>
|
||||
|
||||
```bash
|
||||
pip install fonticon-fontawesome5
|
||||
```
|
||||
If a font library you'd like to use is unavailable as a superqt plugin,
|
||||
please [open a feature request](https://github.com/pyapp-kit/superqt/issues/new/choose)
|
||||
|
||||
### [Font Awesome 6](https://fontawesome.com/v6/search)
|
||||
|
||||
### Font Awesome 6
|
||||
|
||||
Browse available icons at <https://fontawesome.com/v6/search>
|
||||
|
||||
```bash
|
||||
pip install fonticon-fontawesome6
|
||||
```
|
||||
|
||||
### [Material Design Icons](https://materialdesignicons.com/)
|
||||
### Font Awesome 5
|
||||
|
||||
Browse available icons at <https://fontawesome.com/v5/search>
|
||||
|
||||
```bash
|
||||
pip install fonticon-fontawesome5
|
||||
```
|
||||
|
||||
### Material Design Icons 7
|
||||
|
||||
Browse available icons at <https://materialdesignicons.com/>
|
||||
|
||||
```bash
|
||||
pip install fonticon-materialdesignicons7
|
||||
```
|
||||
|
||||
### Material Design Icons 6
|
||||
|
||||
Browse available icons at <https://materialdesignicons.com/>
|
||||
(note that the search defaults to v7, see changes from v6 in [the
|
||||
changelog](https://pictogrammers.com/docs/library/mdi/releases/changelog/))
|
||||
|
||||
```bash
|
||||
pip install fonticon-materialdesignicons6
|
||||
@@ -55,7 +78,7 @@ pip install fonticon-materialdesignicons6
|
||||
- <https://github.com/tlambert03/fonticon-feather>
|
||||
|
||||
`superqt.fonticon` is a pluggable system, and font icon packs may use the `"superqt.fonticon"`
|
||||
entry point to register themselves with superqt. See [`fonticon-cookiecutter`](https://github.com/tlambert03/fonticon-cookiecutter) for a template, or look through the following repos for examples:
|
||||
entry point to register themselves with superqt. See [`fonticon-cookiecutter`](https://github.com/tlambert03/fonticon-cookiecutter) for a template, or look through the following repos for examples:
|
||||
|
||||
- <https://github.com/tlambert03/fonticon-fontawesome6>
|
||||
- <https://github.com/tlambert03/fonticon-fontawesome5>
|
||||
@@ -64,24 +87,24 @@ entry point to register themselves with superqt. See [`fonticon-cookiecutter`](
|
||||
## API
|
||||
|
||||
::: superqt.fonticon.icon
|
||||
options:
|
||||
heading_level: 3
|
||||
options:
|
||||
heading_level: 3
|
||||
|
||||
::: superqt.fonticon.setTextIcon
|
||||
options:
|
||||
heading_level: 3
|
||||
options:
|
||||
heading_level: 3
|
||||
|
||||
::: superqt.fonticon.font
|
||||
options:
|
||||
heading_level: 3
|
||||
options:
|
||||
heading_level: 3
|
||||
|
||||
::: superqt.fonticon.IconOpts
|
||||
options:
|
||||
heading_level: 3
|
||||
options:
|
||||
heading_level: 3
|
||||
|
||||
::: superqt.fonticon.addFont
|
||||
options:
|
||||
heading_level: 3
|
||||
options:
|
||||
heading_level: 3
|
||||
|
||||
## Animations
|
||||
|
||||
@@ -89,13 +112,13 @@ the `animation` parameter to `icon()` accepts a subclass of
|
||||
`Animation` that will be
|
||||
|
||||
::: superqt.fonticon.Animation
|
||||
options:
|
||||
heading_level: 3
|
||||
options:
|
||||
heading_level: 3
|
||||
|
||||
::: superqt.fonticon.pulse
|
||||
options:
|
||||
heading_level: 3
|
||||
options:
|
||||
heading_level: 3
|
||||
|
||||
::: superqt.fonticon.spin
|
||||
options:
|
||||
heading_level: 3
|
||||
options:
|
||||
heading_level: 3
|
||||
|
@@ -29,3 +29,4 @@
|
||||
| ----------- | --------------------- |
|
||||
| [`QMessageHandler`](./qmessagehandler.md) | A context manager to intercept messages from Qt. |
|
||||
| [`CodeSyntaxHighlight`](./code_syntax_highlight.md) | A `QSyntaxHighlighter` for code syntax highlighting. |
|
||||
| [`draw_colormap`](./cmap.md) | Function that draws a colormap into any QPaintDevice. |
|
||||
|
3
docs/utilities/signal_utils.md
Normal file
3
docs/utilities/signal_utils.md
Normal file
@@ -0,0 +1,3 @@
|
||||
# Signal Utilities
|
||||
|
||||
::: superqt.utils.signals_blocked
|
35
docs/widgets/colormap_catalog.md
Normal file
35
docs/widgets/colormap_catalog.md
Normal file
@@ -0,0 +1,35 @@
|
||||
# CmapCatalogComboBox
|
||||
|
||||
Searchable `QComboBox` variant that contains the
|
||||
[entire cmap colormap catalog](https://cmap-docs.readthedocs.io/en/latest/catalog/)
|
||||
|
||||
!!! note "requires cmap"
|
||||
|
||||
This widget uses the [cmap](https://cmap-docs.readthedocs.io/) library
|
||||
to provide colormaps. You can install it with:
|
||||
|
||||
```shell
|
||||
# use the `cmap` extra to include colormap support
|
||||
pip install superqt[cmap]
|
||||
```
|
||||
|
||||
You can limit the colormaps shown by setting the `categories` or
|
||||
`interpolation` keyword arguments.
|
||||
|
||||
```python
|
||||
from qtpy.QtWidgets import QApplication
|
||||
|
||||
from superqt.cmap import CmapCatalogComboBox
|
||||
|
||||
app = QApplication([])
|
||||
|
||||
catalog_combo = CmapCatalogComboBox(interpolation="linear")
|
||||
catalog_combo.setCurrentText("viridis")
|
||||
catalog_combo.show()
|
||||
|
||||
app.exec()
|
||||
```
|
||||
|
||||
{{ show_widget(130) }}
|
||||
|
||||
{{ show_members('superqt.cmap.CmapCatalogComboBox') }}
|
@@ -24,6 +24,9 @@ The following are QWidget subclasses:
|
||||
| [`QEnumComboBox`](./qenumcombobox.md) | `QComboBox` that populates the combobox from a python `Enum` |
|
||||
| [`QSearchableComboBox`](./qsearchablecombobox.md) | `QComboBox` variant that filters available options based on text input |
|
||||
| [`QSearchableListWidget`](./qsearchablelistwidget.md) | `QListWidget` variant with search field that filters available options |
|
||||
| [`QSearchableTreeWidget`](./qsearchabletreewidget.md) | `QTreeWidget` variant with search field that filters available options |
|
||||
| [`QColorComboBox`](./qcolorcombobox.md) | `QComboBox` to select from a specified set of colors |
|
||||
| [`QColormapComboBox`](./qcolormap.md) | `QComboBox` to select from a specified set of colormaps. |
|
||||
|
||||
## Frames and containers
|
||||
|
||||
|
27
docs/widgets/qcolorcombobox.md
Normal file
27
docs/widgets/qcolorcombobox.md
Normal file
@@ -0,0 +1,27 @@
|
||||
# QColorComboBox
|
||||
|
||||
`QComboBox` designed to select from a specific set of colors.
|
||||
|
||||
```python
|
||||
from qtpy.QtWidgets import QApplication
|
||||
|
||||
from superqt import QColorComboBox
|
||||
|
||||
app = QApplication([])
|
||||
|
||||
colors = QColorComboBox()
|
||||
colors.addColors(['red', 'green', 'blue'])
|
||||
|
||||
# show an "Add Color" item that opens a QColorDialog when clicked
|
||||
colors.setUserColorsAllowed(True)
|
||||
|
||||
# emits a QColor when changed
|
||||
colors.currentColorChanged.connect(print)
|
||||
colors.show()
|
||||
|
||||
app.exec_()
|
||||
```
|
||||
|
||||
{{ show_widget(100) }}
|
||||
|
||||
{{ show_members('superqt.QColorComboBox') }}
|
67
docs/widgets/qcolormap.md
Normal file
67
docs/widgets/qcolormap.md
Normal file
@@ -0,0 +1,67 @@
|
||||
# QColormapComboBox
|
||||
|
||||
`QComboBox` variant to select from a specific set of colormaps.
|
||||
|
||||
!!! note "requires cmap"
|
||||
|
||||
This widget uses the [cmap](https://cmap-docs.readthedocs.io/) library
|
||||
to provide colormaps. You can install it with:
|
||||
|
||||
```shell
|
||||
# use the `cmap` extra to include colormap support
|
||||
pip install superqt[cmap]
|
||||
```
|
||||
|
||||
### ColorMapLike objects
|
||||
|
||||
Colormaps may be specified in a variety of ways, such as by name (string), an iterable of a color/color-like objects, or as
|
||||
a [`cmap.Colormap`][] instance. See [cmap documentation for details on
|
||||
all ColormapLike types](https://cmap-docs.readthedocs.io/en/latest/colormaps/#colormaplike-objects)
|
||||
|
||||
### Example
|
||||
|
||||
```python
|
||||
from cmap import Colormap
|
||||
from qtpy.QtWidgets import QApplication
|
||||
|
||||
from superqt import QColormapComboBox
|
||||
|
||||
app = QApplication([])
|
||||
|
||||
cmap_combo = QColormapComboBox()
|
||||
# see note above about colormap-like objects
|
||||
# as names from the cmap catalog
|
||||
cmap_combo.addColormaps(["viridis", "plasma", "magma", "gray"])
|
||||
# as a sequence of colors, linearly interpolated
|
||||
cmap_combo.addColormap(("#0f0", "slateblue", "#F3A003A0"))
|
||||
# as a `cmap.Colormap` instance with custom name:
|
||||
cmap_combo.addColormap(Colormap(("green", "white", "orange"), name="MyMap"))
|
||||
|
||||
cmap_combo.show()
|
||||
app.exec()
|
||||
```
|
||||
|
||||
{{ show_widget(200) }}
|
||||
|
||||
### Style Customization
|
||||
|
||||
Note that both the LineEdit and the dropdown can be styled to have the colormap
|
||||
on the left, or fill the entire width of the widget.
|
||||
|
||||
To make the CombBox label colormap fill the entire width of the widget:
|
||||
|
||||
```python
|
||||
from superqt.cmap import QColormapLineEdit
|
||||
cmap_combo.setLineEdit(QColormapLineEdit())
|
||||
```
|
||||
|
||||
To make the CombBox dropdown colormaps fill
|
||||
less than the entire width of the widget:
|
||||
|
||||
```python
|
||||
from superqt.cmap import QColormapItemDelegate
|
||||
delegate = QColormapItemDelegate(fractional_colormap_width=0.33)
|
||||
cmap_combo.setItemDelegate(delegate)
|
||||
```
|
||||
|
||||
{{ show_members('superqt.QColormapComboBox') }}
|
37
docs/widgets/qsearchabletreewidget.md
Normal file
37
docs/widgets/qsearchabletreewidget.md
Normal file
@@ -0,0 +1,37 @@
|
||||
# QSearchableTreeWidget
|
||||
|
||||
`QSearchableTreeWidget` combines a
|
||||
[`QTreeWidget`](https://doc.qt.io/qt-6/qtreewidget.html) and a `QLineEdit` 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`.
|
||||
|
||||
|
||||
```python
|
||||
from qtpy.QtWidgets import QApplication
|
||||
|
||||
from superqt import QSearchableTreeWidget
|
||||
|
||||
app = QApplication([])
|
||||
|
||||
data = {
|
||||
"none": None,
|
||||
"str": "test",
|
||||
"int": 42,
|
||||
"list": [2, 3, 5],
|
||||
"dict": {
|
||||
"float": 0.5,
|
||||
"tuple": (22, 99),
|
||||
"bool": False,
|
||||
},
|
||||
}
|
||||
tree = QSearchableTreeWidget.fromData(data)
|
||||
tree.show()
|
||||
|
||||
app.exec_()
|
||||
```
|
||||
|
||||
{{ show_widget() }}
|
||||
|
||||
{{ show_members('superqt.QSearchableTreeWidget') }}
|
@@ -1,4 +1,4 @@
|
||||
from PyQt5.QtGui import QColor, QPalette
|
||||
from qtpy.QtGui import QColor, QPalette
|
||||
from qtpy.QtWidgets import QApplication, QTextEdit
|
||||
|
||||
from superqt.utils import CodeSyntaxHighlight
|
||||
|
23
examples/color_combo_box.py
Normal file
23
examples/color_combo_box.py
Normal file
@@ -0,0 +1,23 @@
|
||||
from qtpy.QtGui import QColor
|
||||
from qtpy.QtWidgets import QApplication
|
||||
|
||||
from superqt import QColorComboBox
|
||||
|
||||
app = QApplication([])
|
||||
w = QColorComboBox()
|
||||
# adds an item "Add Color" that opens a QColorDialog when clicked
|
||||
w.setUserColorsAllowed(True)
|
||||
|
||||
# colors can be any argument that can be passed to QColor
|
||||
# (tuples and lists will be expanded to QColor(*color)
|
||||
COLORS = [QColor("red"), "orange", (255, 255, 0), "green", "#00F", "indigo", "violet"]
|
||||
w.addColors(COLORS)
|
||||
|
||||
# as with addColors, colors will be cast to QColor when using setColors
|
||||
w.setCurrentColor("indigo")
|
||||
|
||||
w.resize(200, 50)
|
||||
w.show()
|
||||
|
||||
w.currentColorChanged.connect(print)
|
||||
app.exec_()
|
19
examples/colormap_combo_box.py
Normal file
19
examples/colormap_combo_box.py
Normal file
@@ -0,0 +1,19 @@
|
||||
from qtpy.QtWidgets import QApplication, QVBoxLayout, QWidget
|
||||
|
||||
from superqt.cmap import CmapCatalogComboBox, QColormapComboBox
|
||||
|
||||
app = QApplication([])
|
||||
|
||||
wdg = QWidget()
|
||||
layout = QVBoxLayout(wdg)
|
||||
|
||||
catalog_combo = CmapCatalogComboBox(interpolation="linear")
|
||||
|
||||
selected_cmap_combo = QColormapComboBox(allow_user_colormaps=True)
|
||||
selected_cmap_combo.addColormaps(["viridis", "plasma", "magma", "inferno", "turbo"])
|
||||
|
||||
layout.addWidget(catalog_combo)
|
||||
layout.addWidget(selected_cmap_combo)
|
||||
|
||||
wdg.show()
|
||||
app.exec()
|
@@ -110,7 +110,6 @@ class DemoWidget(QtW.QWidget):
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
|
@@ -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)
|
||||
|
14
examples/iconify.py
Normal file
14
examples/iconify.py
Normal file
@@ -0,0 +1,14 @@
|
||||
from qtpy.QtCore import QSize
|
||||
from qtpy.QtWidgets import QApplication, QPushButton
|
||||
|
||||
from superqt import QIconifyIcon
|
||||
|
||||
app = QApplication([])
|
||||
|
||||
btn = QPushButton()
|
||||
# search https://icon-sets.iconify.design for available icon keys
|
||||
btn.setIcon(QIconifyIcon("fluent-emoji-flat:alarm-clock"))
|
||||
btn.setIconSize(QSize(60, 60))
|
||||
btn.show()
|
||||
|
||||
app.exec()
|
@@ -14,6 +14,7 @@ ORIENTATION = Qt.Orientation.Horizontal
|
||||
|
||||
w = QWidget()
|
||||
qls = QLabeledSlider(ORIENTATION)
|
||||
qls.setEdgeLabelMode(qls.EdgeLabelMode.LabelIsRange | qls.EdgeLabelMode.LabelIsValue)
|
||||
qls.valueChanged.connect(lambda e: print("qls valueChanged", e))
|
||||
qls.setRange(0, 500)
|
||||
qls.setValue(300)
|
@@ -1,4 +1,5 @@
|
||||
"""Example for QCollapsible"""
|
||||
"""Example for QCollapsible."""
|
||||
|
||||
from qtpy.QtWidgets import QApplication, QLabel, QPushButton
|
||||
|
||||
from superqt import QCollapsible
|
||||
@@ -6,6 +7,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}"))
|
||||
|
@@ -5,7 +5,6 @@ from superqt import QRangeSlider
|
||||
|
||||
app = QApplication([])
|
||||
|
||||
slider = QRangeSlider(Qt.Orientation.Horizontal)
|
||||
slider = QRangeSlider(Qt.Orientation.Horizontal)
|
||||
|
||||
slider.setValue((20, 80))
|
29
examples/searchable_tree_widget.py
Normal file
29
examples/searchable_tree_widget.py
Normal 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_()
|
@@ -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
|
||||
|
17
mkdocs.yml
17
mkdocs.yml
@@ -1,16 +1,13 @@
|
||||
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 © 2021 - 2022 Talley Lambert
|
||||
|
||||
extra_css:
|
||||
- stylesheets/extra.css
|
||||
copyright: Copyright © 2021 - 2022
|
||||
|
||||
watch:
|
||||
- src
|
||||
@@ -25,6 +22,7 @@ theme:
|
||||
# - navigation.tabs
|
||||
- search.highlight
|
||||
- search.suggest
|
||||
- content.code.copy
|
||||
|
||||
markdown_extensions:
|
||||
- admonition
|
||||
@@ -36,11 +34,13 @@ markdown_extensions:
|
||||
- pymdownx.emoji:
|
||||
emoji_index: !!python/name:materialx.emoji.twemoji
|
||||
emoji_generator: !!python/name:materialx.emoji.to_svg
|
||||
- toc:
|
||||
permalink: "#"
|
||||
|
||||
|
||||
plugins:
|
||||
- search
|
||||
- autorefs
|
||||
- mkdocstrings
|
||||
- macros:
|
||||
module_name: docs/_macros
|
||||
- mkdocstrings:
|
||||
@@ -48,6 +48,7 @@ plugins:
|
||||
python:
|
||||
import:
|
||||
- https://docs.python.org/3/objects.inv
|
||||
- https://cmap-docs.readthedocs.io/en/latest/objects.inv
|
||||
options:
|
||||
show_source: false
|
||||
docstring_style: numpy
|
||||
|
213
pyproject.toml
213
pyproject.toml
@@ -1,10 +1,211 @@
|
||||
# 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.8"
|
||||
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.8",
|
||||
"Programming Language :: Python :: 3.9",
|
||||
"Programming Language :: Python :: 3.10",
|
||||
"Programming Language :: Python :: 3.11",
|
||||
"Programming Language :: Python :: 3.12",
|
||||
"Topic :: Desktop Environment",
|
||||
"Topic :: Software Development :: User Interfaces",
|
||||
"Topic :: Software Development :: Widget Sets",
|
||||
]
|
||||
dynamic = ["version"]
|
||||
dependencies = [
|
||||
"pygments>=2.4.0",
|
||||
"qtpy>=1.1.0",
|
||||
"typing-extensions >=3.7.4.3,!=3.10.0.0",
|
||||
]
|
||||
|
||||
# extras
|
||||
# https://peps.python.org/pep-0621/#dependencies-optional-dependencies
|
||||
[project.optional-dependencies]
|
||||
test = ["pint", "pytest", "pytest-cov", "pytest-qt", "numpy", "cmap", "pyconify"]
|
||||
dev = [
|
||||
"ipython",
|
||||
"ruff",
|
||||
"mypy",
|
||||
"pdbpp",
|
||||
"pre-commit",
|
||||
"pydocstyle",
|
||||
"rich",
|
||||
"types-Pygments",
|
||||
]
|
||||
docs = ["mkdocs-macros-plugin", "mkdocs-material", "mkdocstrings[python]", "pint", "cmap"]
|
||||
quantity = ["pint"]
|
||||
cmap = ["cmap >=0.1.1"]
|
||||
pyside2 = ["pyside2"]
|
||||
# see issues surrounding usage of Generics in pyside6.5.x
|
||||
# https://github.com/pyapp-kit/superqt/pull/177
|
||||
# https://github.com/pyapp-kit/superqt/pull/164
|
||||
# https://bugreports.qt.io/browse/PYSIDE-2627
|
||||
pyside6 = ["pyside6 !=6.5.0,!=6.5.1,!=6.6.2"]
|
||||
pyqt5 = ["pyqt5"]
|
||||
pyqt6 = ["pyqt6"]
|
||||
font-fa5 = ["fonticon-fontawesome5"]
|
||||
font-fa6 = ["fonticon-fontawesome6"]
|
||||
font-mi6 = ["fonticon-materialdesignicons6"]
|
||||
font-mi7 = ["fonticon-materialdesignicons7"]
|
||||
iconify = ["pyconify >=0.1.4"]
|
||||
|
||||
[project.urls]
|
||||
Documentation = "https://pyapp-kit.github.io/superqt/"
|
||||
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"]
|
||||
|
||||
# these let you run tests across all backends easily with:
|
||||
# hatch run test:test
|
||||
[tool.hatch.envs.test]
|
||||
|
||||
[tool.hatch.envs.test.scripts]
|
||||
test = "pytest"
|
||||
|
||||
[[tool.hatch.envs.test.matrix]]
|
||||
qt = ["pyside6", "pyqt6"]
|
||||
python = ["3.11"]
|
||||
|
||||
[[tool.hatch.envs.test.matrix]]
|
||||
qt = ["pyside2", "pyqt5", "pyqt5.12"]
|
||||
python = ["3.8"]
|
||||
|
||||
[tool.hatch.envs.test.overrides]
|
||||
matrix.qt.extra-dependencies = [
|
||||
{value = "pyside2", if = ["pyside2"]},
|
||||
{value = "pyside6", if = ["pyside6"]},
|
||||
{value = "pyqt5", if = ["pyqt5"]},
|
||||
{value = "pyqt6", if = ["pyqt6"]},
|
||||
{value = "pyqt5==5.12", if = ["pyqt5.12"]},
|
||||
]
|
||||
|
||||
# https://github.com/charliermarsh/ruff
|
||||
[tool.ruff]
|
||||
line-length = 88
|
||||
target-version = "py38"
|
||||
src = ["src", "tests"]
|
||||
|
||||
# https://docs.astral.sh/ruff/rules
|
||||
[tool.ruff.lint]
|
||||
pydocstyle = { convention = "numpy" }
|
||||
select = [
|
||||
"E", # style errors
|
||||
"W", # style warnings
|
||||
"F", # flakes
|
||||
"D", # pydocstyle
|
||||
"D417", # Missing argument descriptions in Docstrings
|
||||
"I", # isort
|
||||
"UP", # pyupgrade
|
||||
"C4", # flake8-comprehensions
|
||||
"B", # flake8-bugbear
|
||||
"A001", # flake8-builtins
|
||||
"RUF", # ruff-specific rules
|
||||
"TCH", # flake8-type-checking
|
||||
"TID", # flake8-tidy-imports
|
||||
]
|
||||
ignore = [
|
||||
"D104", # Missing docstring in public package
|
||||
"D401", # First line should be in imperative mood (remove to opt in)
|
||||
]
|
||||
|
||||
[tool.ruff.lint.per-file-ignores]
|
||||
"tests/*.py" = ["D", "S101"]
|
||||
"examples/demo_widget.py" = ["E501"]
|
||||
"examples/*.py" = ["B", "D"]
|
||||
|
||||
# https://docs.astral.sh/ruff/formatter/
|
||||
[tool.ruff.format]
|
||||
docstring-code-format = true
|
||||
|
||||
# https://docs.pytest.org/en/6.2.x/customize.html
|
||||
[tool.pytest.ini_options]
|
||||
minversion = "6.0"
|
||||
testpaths = ["tests"]
|
||||
filterwarnings = [
|
||||
"error",
|
||||
"ignore: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.run]
|
||||
source = ["superqt"]
|
||||
|
||||
[tool.coverage.report]
|
||||
show_missing = true
|
||||
exclude_lines = [
|
||||
"pragma: no cover",
|
||||
"if TYPE_CHECKING:",
|
||||
"@overload",
|
||||
"except ImportError",
|
||||
"\\.\\.\\.",
|
||||
"pass"
|
||||
]
|
||||
|
||||
# https://github.com/mgedmin/check-manifest#configuration
|
||||
[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/**/*",
|
||||
]
|
||||
|
122
setup.cfg
122
setup.cfg
@@ -1,122 +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
|
||||
quantity =
|
||||
pint
|
||||
testing =
|
||||
pint
|
||||
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
|
@@ -1,18 +1,18 @@
|
||||
"""superqt is a collection of Qt components for python."""
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from importlib.metadata import PackageNotFoundError, version
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
try:
|
||||
from ._version import version as __version__
|
||||
except ImportError:
|
||||
__version__ = version("superqt")
|
||||
except PackageNotFoundError:
|
||||
__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 .combobox import QColorComboBox, QEnumComboBox, QSearchableComboBox
|
||||
from .elidable import QElidingLabel, QElidingLineEdit
|
||||
from .iconify import QIconifyIcon
|
||||
from .selection import QSearchableListWidget, QSearchableTreeWidget
|
||||
from .sliders import (
|
||||
QDoubleRangeSlider,
|
||||
QDoubleSlider,
|
||||
@@ -28,12 +28,16 @@ from .utils import QMessageHandler, ensure_main_thread, ensure_object_thread
|
||||
__all__ = [
|
||||
"ensure_main_thread",
|
||||
"ensure_object_thread",
|
||||
"QDoubleRangeSlider",
|
||||
"QCollapsible",
|
||||
"QColorComboBox",
|
||||
"QColormapComboBox",
|
||||
"QDoubleRangeSlider",
|
||||
"QDoubleSlider",
|
||||
"QElidingLabel",
|
||||
"QElidingLineEdit",
|
||||
"QEnumComboBox",
|
||||
"QLabeledDoubleRangeSlider",
|
||||
"QIconifyIcon",
|
||||
"QLabeledDoubleSlider",
|
||||
"QLabeledRangeSlider",
|
||||
"QLabeledSlider",
|
||||
@@ -43,12 +47,21 @@ __all__ = [
|
||||
"QRangeSlider",
|
||||
"QSearchableComboBox",
|
||||
"QSearchableListWidget",
|
||||
"QSearchableTreeWidget",
|
||||
]
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from .combobox import QColormapComboBox # noqa: TCH004
|
||||
from .spinbox._quantity import QQuantity # noqa: TCH004
|
||||
|
||||
def __getattr__(name):
|
||||
|
||||
def __getattr__(name: str) -> Any:
|
||||
if name == "QQuantity":
|
||||
from .spinbox._quantity import QQuantity
|
||||
|
||||
return QQuantity
|
||||
raise ImportError(f"cannot import name {name!r} from {__name__!r}")
|
||||
if name == "QColormapComboBox":
|
||||
from .cmap import QColormapComboBox
|
||||
|
||||
return QColormapComboBox
|
||||
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
|
||||
|
@@ -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())
|
23
src/superqt/cmap/__init__.py
Normal file
23
src/superqt/cmap/__init__.py
Normal file
@@ -0,0 +1,23 @@
|
||||
try:
|
||||
import cmap
|
||||
except ImportError as e:
|
||||
raise ImportError(
|
||||
"The cmap package is required to use superqt colormap utilities. "
|
||||
"Install it with `pip install cmap` or `pip install superqt[cmap]`."
|
||||
) from e
|
||||
else:
|
||||
del cmap
|
||||
|
||||
from ._catalog_combo import CmapCatalogComboBox
|
||||
from ._cmap_combo import QColormapComboBox
|
||||
from ._cmap_item_delegate import QColormapItemDelegate
|
||||
from ._cmap_line_edit import QColormapLineEdit
|
||||
from ._cmap_utils import draw_colormap
|
||||
|
||||
__all__ = [
|
||||
"QColormapItemDelegate",
|
||||
"draw_colormap",
|
||||
"QColormapLineEdit",
|
||||
"CmapCatalogComboBox",
|
||||
"QColormapComboBox",
|
||||
]
|
94
src/superqt/cmap/_catalog_combo.py
Normal file
94
src/superqt/cmap/_catalog_combo.py
Normal file
@@ -0,0 +1,94 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING, Container
|
||||
|
||||
from cmap import Colormap
|
||||
from qtpy.QtCore import Qt, Signal
|
||||
from qtpy.QtWidgets import QComboBox, QCompleter, QWidget
|
||||
|
||||
from ._cmap_item_delegate import QColormapItemDelegate
|
||||
from ._cmap_line_edit import QColormapLineEdit
|
||||
from ._cmap_utils import try_cast_colormap
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from cmap._catalog import Category, Interpolation
|
||||
from qtpy.QtGui import QKeyEvent
|
||||
|
||||
|
||||
class CmapCatalogComboBox(QComboBox):
|
||||
"""A combo box for selecting a colormap from the entire cmap catalog.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
parent : QWidget, optional
|
||||
The parent widget.
|
||||
prefer_short_names : bool, optional
|
||||
If True (default), short names (without the namespace prefix) will be
|
||||
preferred over fully qualified names. In cases where the same short name is
|
||||
used in multiple namespaces, they will *all* be referred to by their fully
|
||||
qualified (namespaced) name.
|
||||
categories : Container[Category], optional
|
||||
If provided, only return names from the given categories.
|
||||
interpolation : Interpolation, optional
|
||||
If provided, only return names that have the given interpolation method.
|
||||
"""
|
||||
|
||||
currentColormapChanged = Signal(Colormap)
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
parent: QWidget | None = None,
|
||||
*,
|
||||
categories: Container[Category] = (),
|
||||
prefer_short_names: bool = True,
|
||||
interpolation: Interpolation | None = None,
|
||||
) -> None:
|
||||
super().__init__(parent)
|
||||
|
||||
# get valid names according to preferences
|
||||
word_list = sorted(
|
||||
Colormap.catalog().unique_keys(
|
||||
prefer_short_names=prefer_short_names,
|
||||
categories=categories,
|
||||
interpolation=interpolation,
|
||||
)
|
||||
)
|
||||
|
||||
# initialize the combobox
|
||||
self.addItems(word_list)
|
||||
self.setInsertPolicy(QComboBox.InsertPolicy.NoInsert)
|
||||
self.setEditable(True)
|
||||
self.setDuplicatesEnabled(False)
|
||||
# (must come before setCompleter)
|
||||
self.setLineEdit(QColormapLineEdit(self))
|
||||
|
||||
# setup the completer
|
||||
completer = QCompleter(word_list)
|
||||
completer.setCaseSensitivity(Qt.CaseSensitivity.CaseInsensitive)
|
||||
completer.setCompletionMode(QCompleter.CompletionMode.PopupCompletion)
|
||||
completer.setFilterMode(Qt.MatchFlag.MatchContains)
|
||||
completer.setModel(self.model())
|
||||
self.setCompleter(completer)
|
||||
|
||||
# set the delegate for both the popup and the combobox
|
||||
delegate = QColormapItemDelegate()
|
||||
if popup := completer.popup():
|
||||
popup.setItemDelegate(delegate)
|
||||
self.setItemDelegate(delegate)
|
||||
|
||||
self.currentTextChanged.connect(self._on_text_changed)
|
||||
|
||||
def currentColormap(self) -> Colormap | None:
|
||||
"""Returns the currently selected Colormap or None if not yet selected."""
|
||||
return try_cast_colormap(self.currentText())
|
||||
|
||||
def keyPressEvent(self, e: QKeyEvent | None) -> None:
|
||||
if e and e.key() in (Qt.Key.Key_Enter, Qt.Key.Key_Return):
|
||||
# select the first completion when pressing enter if the popup is visible
|
||||
if (completer := self.completer()) and completer.completionCount():
|
||||
self.lineEdit().setText(completer.currentCompletion()) # type: ignore
|
||||
return super().keyPressEvent(e)
|
||||
|
||||
def _on_text_changed(self, text: str) -> None:
|
||||
if (cmap := try_cast_colormap(text)) is not None:
|
||||
self.currentColormapChanged.emit(cmap)
|
224
src/superqt/cmap/_cmap_combo.py
Normal file
224
src/superqt/cmap/_cmap_combo.py
Normal file
@@ -0,0 +1,224 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING, Any, Sequence
|
||||
|
||||
from cmap import Colormap
|
||||
from qtpy.QtCore import Qt, Signal
|
||||
from qtpy.QtWidgets import (
|
||||
QButtonGroup,
|
||||
QCheckBox,
|
||||
QComboBox,
|
||||
QDialog,
|
||||
QDialogButtonBox,
|
||||
QSizePolicy,
|
||||
QVBoxLayout,
|
||||
QWidget,
|
||||
)
|
||||
|
||||
from superqt.utils import signals_blocked
|
||||
|
||||
from ._catalog_combo import CmapCatalogComboBox
|
||||
from ._cmap_item_delegate import QColormapItemDelegate
|
||||
from ._cmap_line_edit import QColormapLineEdit
|
||||
from ._cmap_utils import try_cast_colormap
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from cmap._colormap import ColorStopsLike
|
||||
|
||||
|
||||
CMAP_ROLE = Qt.ItemDataRole.UserRole + 1
|
||||
|
||||
|
||||
class QColormapComboBox(QComboBox):
|
||||
"""A drop down menu for selecting colors.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
parent : QWidget, optional
|
||||
The parent widget.
|
||||
allow_user_colormaps : bool, optional
|
||||
Whether the user can add custom colormaps by clicking the "Add
|
||||
Colormap..." item. Default is False. Can also be set with
|
||||
`setUserAdditionsAllowed`.
|
||||
add_colormap_text: str, optional
|
||||
The text to display for the "Add Colormap..." item.
|
||||
Default is "Add Colormap...".
|
||||
"""
|
||||
|
||||
currentColormapChanged = Signal(Colormap)
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
parent: QWidget | None = None,
|
||||
*,
|
||||
allow_user_colormaps: bool = False,
|
||||
add_colormap_text: str = "Add Colormap...",
|
||||
) -> None:
|
||||
# init QComboBox
|
||||
super().__init__(parent)
|
||||
self._add_color_text: str = add_colormap_text
|
||||
self._allow_user_colors: bool = allow_user_colormaps
|
||||
self._last_cmap: Colormap | None = None
|
||||
|
||||
self.setLineEdit(_PopupColormapLineEdit(self))
|
||||
self.lineEdit().setReadOnly(True)
|
||||
self.setItemDelegate(QColormapItemDelegate(self))
|
||||
|
||||
self.currentIndexChanged.connect(self._on_index_changed)
|
||||
# there's a little bit of a potential bug here:
|
||||
# if the user clicks on the "Add Colormap..." item
|
||||
# then an indexChanged signal will be emitted, but it may not
|
||||
# actually represent a "true" change in the index if they dismiss the dialog
|
||||
self.activated.connect(self._on_activated)
|
||||
|
||||
self.setUserAdditionsAllowed(allow_user_colormaps)
|
||||
|
||||
def userAdditionsAllowed(self) -> bool:
|
||||
"""Returns whether the user can add custom colors."""
|
||||
return self._allow_user_colors
|
||||
|
||||
def setUserAdditionsAllowed(self, allow: bool) -> None:
|
||||
"""Sets whether the user can add custom colors.
|
||||
|
||||
If enabled, an "Add Colormap..." item will be added to the end of the
|
||||
list. When clicked, a dialog will be shown to allow the user to select
|
||||
a colormap from the
|
||||
[cmap catalog](https://cmap-docs.readthedocs.io/en/latest/catalog/).
|
||||
"""
|
||||
self._allow_user_colors = bool(allow)
|
||||
|
||||
idx = self.findData(self._add_color_text, Qt.ItemDataRole.DisplayRole)
|
||||
if idx < 0:
|
||||
if self._allow_user_colors:
|
||||
self.addItem(self._add_color_text)
|
||||
elif not self._allow_user_colors:
|
||||
self.removeItem(idx)
|
||||
|
||||
def clear(self) -> None:
|
||||
super().clear()
|
||||
self.setUserAdditionsAllowed(self._allow_user_colors)
|
||||
|
||||
def itemColormap(self, index: int) -> Colormap | None:
|
||||
"""Returns the color of the item at the given index."""
|
||||
return self.itemData(index, CMAP_ROLE)
|
||||
|
||||
def addColormap(self, cmap: ColorStopsLike) -> None:
|
||||
"""Adds the colormap to the QComboBox."""
|
||||
if (_cmap := try_cast_colormap(cmap)) is None:
|
||||
raise ValueError(f"Invalid colormap value: {cmap!r}")
|
||||
|
||||
for i in range(self.count()):
|
||||
if item := self.itemColormap(i):
|
||||
if item.name == _cmap.name:
|
||||
return # no duplicates # pragma: no cover
|
||||
|
||||
had_items = self.count() > int(self._allow_user_colors)
|
||||
# add the new color and set the background color of that item
|
||||
self.addItem(_cmap.name.rsplit(":", 1)[-1])
|
||||
self.setItemData(self.count() - 1, _cmap, CMAP_ROLE)
|
||||
if not had_items: # first item added
|
||||
self._on_index_changed(self.count() - 1)
|
||||
|
||||
# make sure the "Add Colormap..." item is last
|
||||
idx = self.findData(self._add_color_text, Qt.ItemDataRole.DisplayRole)
|
||||
if idx >= 0:
|
||||
with signals_blocked(self):
|
||||
self.removeItem(idx)
|
||||
self.addItem(self._add_color_text)
|
||||
|
||||
def addColormaps(self, colors: Sequence[Any]) -> None:
|
||||
"""Adds colors to the QComboBox."""
|
||||
for color in colors:
|
||||
self.addColormap(color)
|
||||
|
||||
def currentColormap(self) -> Colormap | None:
|
||||
"""Returns the currently selected Colormap or None if not yet selected."""
|
||||
return self.currentData(CMAP_ROLE)
|
||||
|
||||
def setCurrentColormap(self, color: Any) -> None:
|
||||
"""Adds the color to the QComboBox and selects it."""
|
||||
if not (cmap := try_cast_colormap(color)):
|
||||
raise ValueError(f"Invalid colormap value: {color!r}")
|
||||
|
||||
for idx in range(self.count()):
|
||||
if (item := self.itemColormap(idx)) and item.name == cmap.name:
|
||||
self.setCurrentIndex(idx)
|
||||
|
||||
def _on_activated(self, index: int) -> None:
|
||||
if self.itemText(index) != self._add_color_text:
|
||||
return
|
||||
|
||||
dlg = _CmapNameDialog(self, Qt.WindowType.Sheet)
|
||||
if dlg.exec() and (cmap := dlg.combo.currentColormap()):
|
||||
# add the color and select it, without adding duplicates
|
||||
for i in range(self.count()):
|
||||
if (item := self.itemColormap(i)) and cmap.name == item.name:
|
||||
self.setCurrentIndex(i)
|
||||
return
|
||||
self.addColormap(cmap)
|
||||
self.currentIndexChanged.emit(self.currentIndex())
|
||||
elif self._last_cmap is not None:
|
||||
# user canceled, restore previous color without emitting signal
|
||||
idx = self.findData(self._last_cmap, CMAP_ROLE)
|
||||
if idx >= 0:
|
||||
with signals_blocked(self):
|
||||
self.setCurrentIndex(idx)
|
||||
|
||||
def _on_index_changed(self, index: int) -> None:
|
||||
colormap = self.itemData(index, CMAP_ROLE)
|
||||
if isinstance(colormap, Colormap):
|
||||
self.currentColormapChanged.emit(colormap)
|
||||
self.lineEdit().setColormap(colormap)
|
||||
self._last_cmap = colormap
|
||||
|
||||
|
||||
CATEGORIES = ("sequential", "diverging", "cyclic", "qualitative", "miscellaneous")
|
||||
|
||||
|
||||
class _CmapNameDialog(QDialog):
|
||||
def __init__(self, *args: Any) -> None:
|
||||
super().__init__(*args)
|
||||
|
||||
self.combo = CmapCatalogComboBox()
|
||||
|
||||
B = QDialogButtonBox.StandardButton.Ok | QDialogButtonBox.StandardButton.Cancel
|
||||
btns = QDialogButtonBox(B)
|
||||
btns.accepted.connect(self.accept)
|
||||
btns.rejected.connect(self.reject)
|
||||
|
||||
layout = QVBoxLayout(self)
|
||||
layout.addWidget(self.combo)
|
||||
|
||||
self._btn_group = QButtonGroup(self)
|
||||
self._btn_group.setExclusive(False)
|
||||
for cat in CATEGORIES:
|
||||
box = QCheckBox(cat)
|
||||
self._btn_group.addButton(box)
|
||||
box.setChecked(True)
|
||||
box.toggled.connect(self._on_check_toggled)
|
||||
layout.addWidget(box)
|
||||
|
||||
layout.addWidget(btns)
|
||||
self.setSizePolicy(QSizePolicy.Policy.Maximum, QSizePolicy.Policy.Maximum)
|
||||
self.resize(self.sizeHint())
|
||||
|
||||
def _on_check_toggled(self) -> None:
|
||||
# get valid names according to preferences
|
||||
word_list = Colormap.catalog().unique_keys(
|
||||
prefer_short_names=True,
|
||||
categories={b.text() for b in self._btn_group.buttons() if b.isChecked()},
|
||||
)
|
||||
self.combo.clear()
|
||||
self.combo.addItems(sorted(word_list))
|
||||
|
||||
|
||||
class _PopupColormapLineEdit(QColormapLineEdit):
|
||||
def mouseReleaseEvent(self, _: Any) -> None:
|
||||
"""Show parent popup when clicked.
|
||||
|
||||
Without this, only the down arrow will show the popup. And if mousePressEvent
|
||||
is used instead, the popup will show and then immediately hide.
|
||||
"""
|
||||
parent = self.parent()
|
||||
if parent and hasattr(parent, "showPopup"):
|
||||
parent.showPopup()
|
109
src/superqt/cmap/_cmap_item_delegate.py
Normal file
109
src/superqt/cmap/_cmap_item_delegate.py
Normal file
@@ -0,0 +1,109 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING, cast
|
||||
|
||||
from qtpy.QtCore import QModelIndex, QObject, QPersistentModelIndex, QRect, QSize, Qt
|
||||
from qtpy.QtGui import QColor, QPainter
|
||||
from qtpy.QtWidgets import QStyle, QStyledItemDelegate, QStyleOptionViewItem
|
||||
|
||||
from ._cmap_utils import CMAP_ROLE, draw_colormap, pick_font_color, try_cast_colormap
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from cmap import Colormap
|
||||
|
||||
DEFAULT_SIZE = QSize(80, 22)
|
||||
DEFAULT_BORDER_COLOR = QColor(Qt.GlobalColor.transparent)
|
||||
|
||||
|
||||
class QColormapItemDelegate(QStyledItemDelegate):
|
||||
"""Delegate that draws colormaps into a QAbstractItemView item.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
parent : QObject, optional
|
||||
The parent object.
|
||||
item_size : QSize, optional
|
||||
The size hint for each item, by default QSize(80, 22).
|
||||
fractional_colormap_width : float, optional
|
||||
The fraction of the widget width to use for the colormap swatch. If the
|
||||
colormap is full width (greater than 0.75), the swatch will be drawn behind
|
||||
the text. Otherwise, the swatch will be drawn to the left of the text.
|
||||
Default is 0.33.
|
||||
padding : int, optional
|
||||
The padding (in pixels) around the edge of the item, by default 1.
|
||||
checkerboard_size : int, optional
|
||||
Size (in pixels) of the checkerboard pattern to draw behind colormaps with
|
||||
transparency, by default 4. If 0, no checkerboard is drawn.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
parent: QObject | None = None,
|
||||
*,
|
||||
item_size: QSize = DEFAULT_SIZE,
|
||||
fractional_colormap_width: float = 1,
|
||||
padding: int = 1,
|
||||
checkerboard_size: int = 4,
|
||||
) -> None:
|
||||
super().__init__(parent)
|
||||
self._item_size = item_size
|
||||
self._colormap_fraction = fractional_colormap_width
|
||||
self._padding = padding
|
||||
self._border_color: QColor | None = DEFAULT_BORDER_COLOR
|
||||
self._checkerboard_size = checkerboard_size
|
||||
|
||||
def sizeHint(
|
||||
self, option: QStyleOptionViewItem, index: QModelIndex | QPersistentModelIndex
|
||||
) -> QSize:
|
||||
return super().sizeHint(option, index).expandedTo(self._item_size)
|
||||
|
||||
def paint(
|
||||
self,
|
||||
painter: QPainter,
|
||||
option: QStyleOptionViewItem,
|
||||
index: QModelIndex | QPersistentModelIndex,
|
||||
) -> None:
|
||||
self.initStyleOption(option, index)
|
||||
rect = cast("QRect", option.rect) # type: ignore
|
||||
selected = option.state & QStyle.StateFlag.State_Selected # type: ignore
|
||||
text = index.data(Qt.ItemDataRole.DisplayRole)
|
||||
colormap: Colormap | None = index.data(CMAP_ROLE) or try_cast_colormap(text)
|
||||
|
||||
if not colormap: # pragma: no cover
|
||||
return super().paint(painter, option, index)
|
||||
|
||||
painter.save()
|
||||
rect.adjust(self._padding, self._padding, -self._padding, -self._padding)
|
||||
cmap_rect = QRect(rect)
|
||||
cmap_rect.setWidth(int(rect.width() * self._colormap_fraction))
|
||||
|
||||
lighter = 110 if selected else 100
|
||||
border = self._border_color if selected else None
|
||||
draw_colormap(
|
||||
painter,
|
||||
colormap,
|
||||
cmap_rect,
|
||||
lighter=lighter,
|
||||
border_color=border,
|
||||
checkerboard_size=self._checkerboard_size,
|
||||
)
|
||||
|
||||
# # make new rect with the remaining space
|
||||
text_rect = QRect(rect)
|
||||
|
||||
if self._colormap_fraction > 0.75:
|
||||
text_align = Qt.AlignmentFlag.AlignCenter
|
||||
alpha = 230 if selected else 140
|
||||
text_color = pick_font_color(colormap, alpha=alpha)
|
||||
else:
|
||||
text_align = Qt.AlignmentFlag.AlignLeft | Qt.AlignmentFlag.AlignVCenter
|
||||
text_color = QColor(Qt.GlobalColor.black)
|
||||
text_rect.adjust(
|
||||
cmap_rect.width() + self._padding + 4, 0, -self._padding - 2, 0
|
||||
)
|
||||
|
||||
painter.setPen(text_color)
|
||||
# cast to int works all the way back to Qt 5.12...
|
||||
# but the enum only works since Qt 5.14
|
||||
painter.drawText(text_rect, int(text_align), text)
|
||||
painter.restore()
|
133
src/superqt/cmap/_cmap_line_edit.py
Normal file
133
src/superqt/cmap/_cmap_line_edit.py
Normal file
@@ -0,0 +1,133 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from qtpy.QtCore import Qt
|
||||
from qtpy.QtGui import QIcon, QPainter, QPaintEvent, QPalette
|
||||
from qtpy.QtWidgets import QApplication, QLineEdit, QStyle, QWidget
|
||||
|
||||
from ._cmap_utils import draw_colormap, pick_font_color, try_cast_colormap
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from cmap import Colormap
|
||||
|
||||
MISSING = QStyle.StandardPixmap.SP_TitleBarContextHelpButton
|
||||
|
||||
|
||||
class QColormapLineEdit(QLineEdit):
|
||||
"""A QLineEdit that shows a colormap swatch.
|
||||
|
||||
When the current text is a valid colormap name from the `cmap` package, a swatch
|
||||
of the colormap will be shown to the left of the text (if `fractionalColormapWidth`
|
||||
is less than .75) or behind the text (for when the colormap fills the full width).
|
||||
|
||||
If the current text is not a valid colormap name, a swatch of the fallback colormap
|
||||
will be shown instead (by default, a gray colormap) if `fractionalColormapWidth` is
|
||||
less than .75.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
parent : QWidget, optional
|
||||
The parent widget.
|
||||
fractional_colormap_width : float, optional
|
||||
The fraction of the widget width to use for the colormap swatch. If the
|
||||
colormap is full width (greater than 0.75), the swatch will be drawn behind
|
||||
the text. Otherwise, the swatch will be drawn to the left of the text.
|
||||
Default is 0.33.
|
||||
fallback_cmap : Colormap | str | None, optional
|
||||
The colormap to use when the current text is not a recognized colormap.
|
||||
by default "gray".
|
||||
missing_icon : QIcon | QStyle.StandardPixmap, optional
|
||||
The icon to show when the current text is not a recognized colormap and
|
||||
`fractionalColormapWidth` is less than .75. Default is a question mark.
|
||||
checkerboard_size : int, optional
|
||||
Size (in pixels) of the checkerboard pattern to draw behind colormaps with
|
||||
transparency, by default 4. If 0, no checkerboard is drawn.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
parent: QWidget | None = None,
|
||||
*,
|
||||
fractional_colormap_width: float = 0.33,
|
||||
fallback_cmap: Colormap | str | None = "gray",
|
||||
missing_icon: QIcon | QStyle.StandardPixmap = MISSING,
|
||||
checkerboard_size: int = 4,
|
||||
) -> None:
|
||||
super().__init__(parent)
|
||||
self.setFractionalColormapWidth(fractional_colormap_width)
|
||||
self.setMissingColormap(fallback_cmap)
|
||||
self._checkerboard_size = checkerboard_size
|
||||
|
||||
if isinstance(missing_icon, QStyle.StandardPixmap):
|
||||
self._missing_icon: QIcon = self.style().standardIcon(missing_icon)
|
||||
elif isinstance(missing_icon, QIcon):
|
||||
self._missing_icon = missing_icon
|
||||
else: # pragma: no cover
|
||||
raise TypeError("missing_icon must be a QIcon or QStyle.StandardPixmap")
|
||||
|
||||
self._cmap: Colormap | None = None # current colormap
|
||||
self.textChanged.connect(self.setColormap)
|
||||
|
||||
def setFractionalColormapWidth(self, fraction: float) -> None:
|
||||
self._colormap_fraction: float = float(fraction)
|
||||
align = Qt.AlignmentFlag.AlignVCenter
|
||||
if self._cmap_is_full_width():
|
||||
align |= Qt.AlignmentFlag.AlignCenter
|
||||
else:
|
||||
align |= Qt.AlignmentFlag.AlignLeft
|
||||
self.setAlignment(align)
|
||||
|
||||
def fractionalColormapWidth(self) -> float:
|
||||
return self._colormap_fraction
|
||||
|
||||
def setMissingColormap(self, cmap: Colormap | str | None) -> None:
|
||||
self._missing_cmap: Colormap | None = try_cast_colormap(cmap)
|
||||
|
||||
def colormap(self) -> Colormap | None:
|
||||
return self._cmap
|
||||
|
||||
def setColormap(self, cmap: Colormap | str | None) -> None:
|
||||
self._cmap = try_cast_colormap(cmap)
|
||||
|
||||
# set self font color to contrast with the colormap
|
||||
if self._cmap and self._cmap_is_full_width():
|
||||
text = pick_font_color(self._cmap)
|
||||
else:
|
||||
text = QApplication.palette().color(QPalette.ColorRole.Text)
|
||||
|
||||
palette = self.palette()
|
||||
palette.setColor(QPalette.ColorRole.Text, text)
|
||||
self.setPalette(palette)
|
||||
|
||||
def _cmap_is_full_width(self):
|
||||
return self._colormap_fraction >= 0.75
|
||||
|
||||
def paintEvent(self, e: QPaintEvent) -> None:
|
||||
# don't draw the background
|
||||
# otherwise it will cover the colormap during super().paintEvent
|
||||
# FIXME: this appears to need to be reset during every paint event...
|
||||
# otherwise something is resetting it
|
||||
palette = self.palette()
|
||||
palette.setColor(palette.ColorRole.Base, Qt.GlobalColor.transparent)
|
||||
self.setPalette(palette)
|
||||
|
||||
cmap_rect = self.rect().adjusted(2, 0, 0, 0)
|
||||
cmap_rect.setWidth(int(cmap_rect.width() * self._colormap_fraction))
|
||||
|
||||
left_margin = 6
|
||||
if not self._cmap_is_full_width():
|
||||
# leave room for the colormap
|
||||
left_margin += cmap_rect.width()
|
||||
self.setTextMargins(left_margin, 2, 0, 0)
|
||||
|
||||
if self._cmap:
|
||||
draw_colormap(
|
||||
self, self._cmap, cmap_rect, checkerboard_size=self._checkerboard_size
|
||||
)
|
||||
elif not self._cmap_is_full_width():
|
||||
if self._missing_cmap:
|
||||
draw_colormap(self, self._missing_cmap, cmap_rect)
|
||||
self._missing_icon.paint(QPainter(self), cmap_rect.adjusted(4, 4, 0, -4))
|
||||
|
||||
super().paintEvent(e) # draw text (must come after draw_colormap)
|
164
src/superqt/cmap/_cmap_utils.py
Normal file
164
src/superqt/cmap/_cmap_utils.py
Normal file
@@ -0,0 +1,164 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from contextlib import suppress
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
from cmap import Colormap
|
||||
from qtpy.QtCore import QPointF, QRect, QRectF, Qt
|
||||
from qtpy.QtGui import QColor, QLinearGradient, QPaintDevice, QPainter
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from cmap._colormap import ColorStopsLike
|
||||
|
||||
CMAP_ROLE = Qt.ItemDataRole.UserRole + 1
|
||||
|
||||
|
||||
def draw_colormap(
|
||||
painter_or_device: QPainter | QPaintDevice,
|
||||
cmap: Colormap | ColorStopsLike,
|
||||
rect: QRect | QRectF | None = None,
|
||||
border_color: QColor | str | None = None,
|
||||
border_width: int = 1,
|
||||
lighter: int = 100,
|
||||
checkerboard_size: int = 4,
|
||||
) -> None:
|
||||
"""Draw a colormap onto a QPainter or QPaintDevice.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
painter_or_device : QPainter | QPaintDevice
|
||||
A `QPainter` instance or a `QPaintDevice` (e.g. a QWidget or QPixmap) onto
|
||||
which to paint the colormap.
|
||||
cmap : Colormap | Any
|
||||
`cmap.Colormap` instance, or anything that can be converted to one (such as a
|
||||
string name of a colormap in the `cmap` catalog).
|
||||
https://cmap-docs.readthedocs.io/en/latest/colormaps/#colormaplike-objects
|
||||
rect : QRect | QRectF | None, optional
|
||||
A rect onto which to draw. If `None`, the `painter.viewport()` will be
|
||||
used. by default `None`
|
||||
border_color : QColor | str | None
|
||||
If not `None`, a border of color `border_color` and width `border_width` is
|
||||
included around the edge, by default None.
|
||||
border_width : int, optional
|
||||
The width of the border to draw (provided `border_color` is not `None`),
|
||||
by default 2
|
||||
lighter : int, optional
|
||||
Percentage by which to lighten (or darken) the colors. Greater than 100
|
||||
lightens, less than 100 darkens, by default 100 (i.e. no change).
|
||||
checkerboard_size : bool, optional
|
||||
Size (in pixels) of the checkerboard pattern to draw, by default 5.
|
||||
If 0, no checkerboard is drawn.
|
||||
|
||||
Examples
|
||||
--------
|
||||
```python
|
||||
from qtpy.QtGui import QPixmap
|
||||
from qtpy.QtWidgets import QWidget
|
||||
from superqt.utils import draw_colormap
|
||||
|
||||
viridis = "viridis" # or cmap.Colormap('viridis')
|
||||
|
||||
|
||||
class W(QWidget):
|
||||
def paintEvent(self, event) -> None:
|
||||
draw_colormap(self, viridis, event.rect())
|
||||
|
||||
|
||||
# or draw onto a QPixmap
|
||||
pm = QPixmap(200, 200)
|
||||
draw_colormap(pm, viridis)
|
||||
```
|
||||
"""
|
||||
if isinstance(painter_or_device, QPainter):
|
||||
painter = painter_or_device
|
||||
elif isinstance(painter_or_device, QPaintDevice):
|
||||
painter = QPainter(painter_or_device)
|
||||
else:
|
||||
raise TypeError(
|
||||
"Expected a QPainter or QPaintDevice instance, "
|
||||
f"got {type(painter_or_device)!r} instead."
|
||||
)
|
||||
|
||||
if (cmap_ := try_cast_colormap(cmap)) is None:
|
||||
raise TypeError(
|
||||
f"Expected a Colormap instance or something that can be "
|
||||
f"converted to one, got {cmap!r} instead."
|
||||
)
|
||||
|
||||
if rect is None:
|
||||
rect = painter.viewport()
|
||||
|
||||
painter.setPen(Qt.PenStyle.NoPen)
|
||||
|
||||
if border_width and border_color is not None:
|
||||
# draw rect, and then contract it by border_width
|
||||
painter.setPen(QColor(border_color))
|
||||
painter.setBrush(Qt.BrushStyle.NoBrush)
|
||||
painter.drawRect(rect)
|
||||
rect = rect.adjusted(border_width, border_width, -border_width, -border_width)
|
||||
|
||||
if checkerboard_size:
|
||||
_draw_checkerboard(painter, rect, checkerboard_size)
|
||||
|
||||
if (
|
||||
cmap_.interpolation == "nearest"
|
||||
or getattr(cmap_.color_stops, "_interpolation", "") == "nearest"
|
||||
):
|
||||
# XXX: this is a little bit of a hack.
|
||||
# when the interpolation is nearest, the last stop is often at 1.0
|
||||
# which means that the last color is not drawn.
|
||||
# to fix this, we shrink the drawing area slightly
|
||||
# it might not work well with unenvenly-spaced stops
|
||||
# (but those are uncommon for categorical colormaps)
|
||||
width = rect.width() - rect.width() / len(cmap_.color_stops)
|
||||
for stop in cmap_.color_stops:
|
||||
painter.setBrush(QColor(stop.color.hex).lighter(lighter))
|
||||
painter.drawRect(rect.adjusted(int(stop.position * width), 0, 0, 0))
|
||||
else:
|
||||
gradient = QLinearGradient(QPointF(rect.topLeft()), QPointF(rect.topRight()))
|
||||
for stop in cmap_.color_stops:
|
||||
gradient.setColorAt(stop.position, QColor(stop.color.hex).lighter(lighter))
|
||||
painter.setBrush(gradient)
|
||||
painter.drawRect(rect)
|
||||
|
||||
|
||||
def _draw_checkerboard(
|
||||
painter: QPainter, rect: QRect | QRectF, checker_size: int
|
||||
) -> None:
|
||||
darkgray = QColor("#969696")
|
||||
lightgray = QColor("#C8C8C8")
|
||||
sz = checker_size
|
||||
h, w = rect.height(), rect.width()
|
||||
left, top = rect.left(), rect.top()
|
||||
full_rows = h // sz
|
||||
full_cols = w // sz
|
||||
for row in range(int(full_rows) + 1):
|
||||
szh = sz if row < full_rows else int(h % sz)
|
||||
for col in range(int(full_cols) + 1):
|
||||
szw = sz if col < full_cols else int(w % sz)
|
||||
color = lightgray if (row + col) % 2 == 0 else darkgray
|
||||
painter.fillRect(int(col * sz + left), int(row * sz + top), szw, szh, color)
|
||||
|
||||
|
||||
def try_cast_colormap(val: Any) -> Colormap | None:
|
||||
"""Try to cast `val` to a Colormap instance, return None if it fails."""
|
||||
if isinstance(val, Colormap):
|
||||
return val
|
||||
with suppress(Exception):
|
||||
return Colormap(val)
|
||||
return None
|
||||
|
||||
|
||||
def pick_font_color(cmap: Colormap, at_stop: float = 0.49, alpha: int = 255) -> QColor:
|
||||
"""Pick a font shade that contrasts with the given colormap at `at_stop`."""
|
||||
if _is_dark(cmap, at_stop):
|
||||
return QColor(0, 0, 0, alpha)
|
||||
else:
|
||||
return QColor(255, 255, 255, alpha)
|
||||
|
||||
|
||||
def _is_dark(cmap: Colormap, at_stop: float, threshold: float = 110) -> bool:
|
||||
"""Return True if the color at `at_stop` is dark according to `threshold`."""
|
||||
color = cmap(at_stop)
|
||||
r, g, b, a = color.rgba8
|
||||
return (r * 0.299 + g * 0.587 + b * 0.114) > threshold
|
@@ -1,33 +1,56 @@
|
||||
"""A collapsible widget to hide and unhide child widgets"""
|
||||
from typing import Optional
|
||||
"""A collapsible widget to hide and unhide child widgets."""
|
||||
|
||||
from qtpy.QtCore import QEasingCurve, QEvent, QMargins, QObject, QPropertyAnimation, Qt
|
||||
from qtpy.QtWidgets import QFrame, QPushButton, QVBoxLayout, QWidget
|
||||
from __future__ import annotations
|
||||
|
||||
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, QSizePolicy, 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: QWidget | None = None,
|
||||
expandedIcon: QIcon | str | None = "▼",
|
||||
collapsedIcon: QIcon | str | None = "▲",
|
||||
):
|
||||
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.setSizePolicy(QSizePolicy.Policy.Preferred, QSizePolicy.Policy.Maximum)
|
||||
|
||||
self._toggle_btn.setStyleSheet("text-align: left; border: none; outline: none;")
|
||||
self._toggle_btn.toggled.connect(self._toggle)
|
||||
|
||||
# frame layout
|
||||
self.setLayout(QVBoxLayout())
|
||||
self.layout().setAlignment(Qt.AlignmentFlag.AlignTop)
|
||||
self.layout().addWidget(self._toggle_btn)
|
||||
layout = QVBoxLayout(self)
|
||||
layout.setAlignment(Qt.AlignmentFlag.AlignTop)
|
||||
layout.addWidget(self._toggle_btn)
|
||||
|
||||
# Create animators
|
||||
self._animation = QPropertyAnimation(self)
|
||||
@@ -44,16 +67,19 @@ class QCollapsible(QFrame):
|
||||
_content.layout().setContentsMargins(QMargins(5, 0, 0, 0))
|
||||
self.setContent(_content)
|
||||
|
||||
def setText(self, text: str):
|
||||
def toggleButton(self) -> QPushButton:
|
||||
"""Return the toggle button."""
|
||||
return self._toggle_btn
|
||||
|
||||
def setText(self, text: str) -> None:
|
||||
"""Set the text of the toggle button."""
|
||||
current = self._toggle_btn.text()[: len(self._EXPANDED)]
|
||||
self._toggle_btn.setText(current + text)
|
||||
self._toggle_btn.setText(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 +89,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: QIcon | str | None = None) -> None:
|
||||
"""Set the icon on the toggle button when the widget is expanded."""
|
||||
if icon and isinstance(icon, QIcon):
|
||||
self._expanded_icon = icon
|
||||
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: QIcon | str | None = None) -> None:
|
||||
"""Set the icon on the toggle button when the widget is collapsed."""
|
||||
if icon and isinstance(icon, QIcon):
|
||||
self._collapsed_icon = icon
|
||||
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 | QEasingCurve.Type) -> 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 +196,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 +209,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
|
||||
|
@@ -1,4 +1,24 @@
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
from ._color_combobox import QColorComboBox
|
||||
from ._enum_combobox import QEnumComboBox
|
||||
from ._searchable_combo_box import QSearchableComboBox
|
||||
|
||||
__all__ = ("QEnumComboBox", "QSearchableComboBox")
|
||||
__all__ = (
|
||||
"QColorComboBox",
|
||||
"QColormapComboBox",
|
||||
"QEnumComboBox",
|
||||
"QSearchableComboBox",
|
||||
)
|
||||
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from superqt.cmap import QColormapComboBox # noqa: TCH004
|
||||
|
||||
|
||||
def __getattr__(name: str) -> Any: # pragma: no cover
|
||||
if name == "QColormapComboBox":
|
||||
from superqt.cmap import QColormapComboBox
|
||||
|
||||
return QColormapComboBox
|
||||
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
|
||||
|
287
src/superqt/combobox/_color_combobox.py
Normal file
287
src/superqt/combobox/_color_combobox.py
Normal file
@@ -0,0 +1,287 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import warnings
|
||||
from contextlib import suppress
|
||||
from enum import IntEnum, auto
|
||||
from typing import Any, Literal, Sequence, cast
|
||||
|
||||
from qtpy.QtCore import QModelIndex, QPersistentModelIndex, QRect, QSize, Qt, Signal
|
||||
from qtpy.QtGui import QColor, QPainter
|
||||
from qtpy.QtWidgets import (
|
||||
QAbstractItemDelegate,
|
||||
QColorDialog,
|
||||
QComboBox,
|
||||
QLineEdit,
|
||||
QStyle,
|
||||
QStyleOptionViewItem,
|
||||
QWidget,
|
||||
)
|
||||
|
||||
from superqt.utils import signals_blocked
|
||||
|
||||
_NAME_MAP = {QColor(x).name(): x for x in QColor.colorNames()}
|
||||
|
||||
COLOR_ROLE = Qt.ItemDataRole.BackgroundRole
|
||||
|
||||
|
||||
class InvalidColorPolicy(IntEnum):
|
||||
"""Policy for handling invalid colors."""
|
||||
|
||||
Ignore = auto()
|
||||
Warn = auto()
|
||||
Raise = auto()
|
||||
|
||||
|
||||
class _ColorComboLineEdit(QLineEdit):
|
||||
"""A read-only line edit that shows the parent ComboBox popup when clicked."""
|
||||
|
||||
def __init__(self, parent: QWidget | None = None) -> None:
|
||||
super().__init__(parent)
|
||||
self.setReadOnly(True)
|
||||
# hide any original text
|
||||
self.setStyleSheet("color: transparent")
|
||||
self.setText("")
|
||||
|
||||
def mouseReleaseEvent(self, _: Any) -> None:
|
||||
"""Show parent popup when clicked.
|
||||
|
||||
Without this, only the down arrow will show the popup. And if mousePressEvent
|
||||
is used instead, the popup will show and then immediately hide.
|
||||
"""
|
||||
parent = self.parent()
|
||||
if hasattr(parent, "showPopup"):
|
||||
parent.showPopup()
|
||||
|
||||
|
||||
class _ColorComboItemDelegate(QAbstractItemDelegate):
|
||||
"""Delegate that draws color squares in the ComboBox.
|
||||
|
||||
This provides more control than simply setting various data roles on the item,
|
||||
and makes for a nicer appearance. Importantly, it prevents the color from being
|
||||
obscured on hover.
|
||||
"""
|
||||
|
||||
def sizeHint(
|
||||
self, option: QStyleOptionViewItem, index: QModelIndex | QPersistentModelIndex
|
||||
) -> QSize:
|
||||
return QSize(20, 20)
|
||||
|
||||
def paint(
|
||||
self,
|
||||
painter: QPainter,
|
||||
option: QStyleOptionViewItem,
|
||||
index: QModelIndex | QPersistentModelIndex,
|
||||
) -> None:
|
||||
color: QColor | None = index.data(COLOR_ROLE)
|
||||
rect = cast("QRect", option.rect) # type: ignore
|
||||
state = cast("QStyle.StateFlag", option.state) # type: ignore
|
||||
selected = state & QStyle.StateFlag.State_Selected
|
||||
border = QColor("lightgray")
|
||||
|
||||
if not color:
|
||||
# not a color square, just draw the text
|
||||
text_color = Qt.GlobalColor.black if selected else Qt.GlobalColor.gray
|
||||
painter.setPen(text_color)
|
||||
text = index.data(Qt.ItemDataRole.DisplayRole)
|
||||
painter.drawText(rect, Qt.AlignmentFlag.AlignCenter, text)
|
||||
return
|
||||
|
||||
# slightly larger border for rect
|
||||
pen = painter.pen()
|
||||
pen.setWidth(2)
|
||||
pen.setColor(border)
|
||||
painter.setPen(pen)
|
||||
|
||||
if selected:
|
||||
# if hovering, give a slight highlight and draw the color name
|
||||
painter.setBrush(color.lighter(110))
|
||||
painter.drawRect(rect)
|
||||
# use user friendly color name if available
|
||||
name = _NAME_MAP.get(color.name(), color.name())
|
||||
painter.setPen(_pick_font_color(color))
|
||||
painter.drawText(rect, Qt.AlignmentFlag.AlignCenter, name)
|
||||
else: # not hovering
|
||||
painter.setBrush(color)
|
||||
painter.drawRect(rect)
|
||||
|
||||
|
||||
class QColorComboBox(QComboBox):
|
||||
"""A drop down menu for selecting colors.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
parent : QWidget, optional
|
||||
The parent widget.
|
||||
allow_user_colors : bool, optional
|
||||
Whether to show an "Add Color" item that opens a QColorDialog when clicked.
|
||||
Whether the user can add custom colors by clicking the "Add Color" item.
|
||||
Default is False. Can also be set with `setUserColorsAllowed`.
|
||||
add_color_text: str, optional
|
||||
The text to display for the "Add Color" item. Default is "Add Color...".
|
||||
"""
|
||||
|
||||
currentColorChanged = Signal(QColor)
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
parent: QWidget | None = None,
|
||||
*,
|
||||
allow_user_colors: bool = False,
|
||||
add_color_text: str = "Add Color...",
|
||||
) -> None:
|
||||
# init QComboBox
|
||||
super().__init__(parent)
|
||||
self._invalid_policy: InvalidColorPolicy = InvalidColorPolicy.Ignore
|
||||
self._add_color_text: str = add_color_text
|
||||
self._allow_user_colors: bool = allow_user_colors
|
||||
self._last_color: QColor = QColor()
|
||||
|
||||
self.setLineEdit(_ColorComboLineEdit(self))
|
||||
self.setItemDelegate(_ColorComboItemDelegate())
|
||||
|
||||
self.currentIndexChanged.connect(self._on_index_changed)
|
||||
self.activated.connect(self._on_activated)
|
||||
|
||||
self.setUserColorsAllowed(allow_user_colors)
|
||||
|
||||
def setInvalidColorPolicy(
|
||||
self, policy: InvalidColorPolicy | int | Literal["Raise", "Ignore", "Warn"]
|
||||
) -> None:
|
||||
"""Sets the policy for handling invalid colors."""
|
||||
if isinstance(policy, str):
|
||||
policy = InvalidColorPolicy[policy]
|
||||
elif isinstance(policy, int):
|
||||
policy = InvalidColorPolicy(policy)
|
||||
elif not isinstance(policy, InvalidColorPolicy):
|
||||
raise TypeError(f"Invalid policy type: {type(policy)!r}")
|
||||
self._invalid_policy = policy
|
||||
|
||||
def invalidColorPolicy(self) -> InvalidColorPolicy:
|
||||
"""Returns the policy for handling invalid colors."""
|
||||
return self._invalid_policy
|
||||
|
||||
InvalidColorPolicy = InvalidColorPolicy
|
||||
|
||||
def userColorsAllowed(self) -> bool:
|
||||
"""Returns whether the user can add custom colors."""
|
||||
return self._allow_user_colors
|
||||
|
||||
def setUserColorsAllowed(self, allow: bool) -> None:
|
||||
"""Sets whether the user can add custom colors."""
|
||||
self._allow_user_colors = bool(allow)
|
||||
|
||||
idx = self.findData(self._add_color_text, Qt.ItemDataRole.DisplayRole)
|
||||
if idx < 0:
|
||||
if self._allow_user_colors:
|
||||
self.addItem(self._add_color_text)
|
||||
elif not self._allow_user_colors:
|
||||
self.removeItem(idx)
|
||||
|
||||
def clear(self) -> None:
|
||||
"""Clears the QComboBox of all entries (leaves "Add colors" if enabled)."""
|
||||
super().clear()
|
||||
self.setUserColorsAllowed(self._allow_user_colors)
|
||||
|
||||
def addColor(self, color: Any) -> None:
|
||||
"""Adds the color to the QComboBox."""
|
||||
_color = _cast_color(color)
|
||||
if not _color.isValid():
|
||||
if self._invalid_policy == InvalidColorPolicy.Raise:
|
||||
raise ValueError(f"Invalid color: {color!r}")
|
||||
elif self._invalid_policy == InvalidColorPolicy.Warn:
|
||||
warnings.warn(f"Ignoring invalid color: {color!r}", stacklevel=2)
|
||||
return
|
||||
|
||||
c = self.currentColor()
|
||||
if self.findData(_color) > -1: # avoid duplicates
|
||||
return
|
||||
|
||||
# add the new color and set the background color of that item
|
||||
self.addItem("", _color)
|
||||
self.setItemData(self.count() - 1, _color, COLOR_ROLE)
|
||||
if not c or not c.isValid():
|
||||
self._on_index_changed(self.count() - 1)
|
||||
|
||||
# make sure the "Add Color" item is last
|
||||
idx = self.findData(self._add_color_text, Qt.ItemDataRole.DisplayRole)
|
||||
if idx >= 0:
|
||||
with signals_blocked(self):
|
||||
self.removeItem(idx)
|
||||
self.addItem(self._add_color_text)
|
||||
|
||||
def itemColor(self, index: int) -> QColor | None:
|
||||
"""Returns the color of the item at the given index."""
|
||||
return self.itemData(index, COLOR_ROLE)
|
||||
|
||||
def addColors(self, colors: Sequence[Any]) -> None:
|
||||
"""Adds colors to the QComboBox."""
|
||||
for color in colors:
|
||||
self.addColor(color)
|
||||
|
||||
def currentColor(self) -> QColor | None:
|
||||
"""Returns the currently selected QColor or None if not yet selected."""
|
||||
return self.currentData(COLOR_ROLE)
|
||||
|
||||
def setCurrentColor(self, color: Any) -> None:
|
||||
"""Adds the color to the QComboBox and selects it."""
|
||||
idx = self.findData(_cast_color(color), COLOR_ROLE)
|
||||
if idx >= 0:
|
||||
self.setCurrentIndex(idx)
|
||||
|
||||
def currentColorName(self) -> str | None:
|
||||
"""Returns the name of the currently selected QColor or black if None."""
|
||||
color = self.currentColor()
|
||||
return color.name() if color else "#000000"
|
||||
|
||||
def _on_activated(self, index: int) -> None:
|
||||
if self.itemText(index) != self._add_color_text:
|
||||
return
|
||||
|
||||
# show temporary text while dialog is open
|
||||
self.lineEdit().setStyleSheet("background-color: white; color: gray;")
|
||||
self.lineEdit().setText("Pick a Color ...")
|
||||
try:
|
||||
color = QColorDialog.getColor()
|
||||
finally:
|
||||
self.lineEdit().setText("")
|
||||
|
||||
if color.isValid():
|
||||
# add the color and select it
|
||||
self.addColor(color)
|
||||
elif self._last_color.isValid():
|
||||
# user canceled, restore previous color without emitting signal
|
||||
idx = self.findData(self._last_color, COLOR_ROLE)
|
||||
if idx >= 0:
|
||||
with signals_blocked(self):
|
||||
self.setCurrentIndex(idx)
|
||||
hex_ = self._last_color.name()
|
||||
self.lineEdit().setStyleSheet(f"background-color: {hex_};")
|
||||
return
|
||||
|
||||
def _on_index_changed(self, index: int) -> None:
|
||||
color = self.itemData(index, COLOR_ROLE)
|
||||
if isinstance(color, QColor):
|
||||
self.lineEdit().setStyleSheet(f"background-color: {color.name()};")
|
||||
self.currentColorChanged.emit(color)
|
||||
self._last_color = color
|
||||
|
||||
|
||||
def _cast_color(val: Any) -> QColor:
|
||||
with suppress(TypeError):
|
||||
color = QColor(val)
|
||||
if color.isValid():
|
||||
return color
|
||||
if isinstance(val, (tuple, list)):
|
||||
with suppress(TypeError):
|
||||
color = QColor(*val)
|
||||
if color.isValid():
|
||||
return color
|
||||
return QColor()
|
||||
|
||||
|
||||
def _pick_font_color(color: QColor) -> QColor:
|
||||
"""Pick a font shade that contrasts with the given color."""
|
||||
if (color.red() * 0.299 + color.green() * 0.587 + color.blue() * 0.114) > 80:
|
||||
return QColor(0, 0, 0, 128)
|
||||
else:
|
||||
return QColor(255, 255, 255, 128)
|
@@ -1,5 +1,9 @@
|
||||
from enum import Enum, EnumMeta
|
||||
from typing import Optional, TypeVar
|
||||
import sys
|
||||
from enum import Enum, EnumMeta, Flag
|
||||
from functools import reduce
|
||||
from itertools import combinations
|
||||
from operator import or_
|
||||
from typing import Optional, Tuple, TypeVar
|
||||
|
||||
from qtpy.QtCore import Signal
|
||||
from qtpy.QtWidgets import QComboBox
|
||||
@@ -11,21 +15,44 @@ NONE_STRING = "----"
|
||||
|
||||
|
||||
def _get_name(enum_value: Enum):
|
||||
"""Create human readable name if user does not provide own implementation of __str__"""
|
||||
if (
|
||||
enum_value.__str__.__module__ != "enum"
|
||||
and not enum_value.__str__.__module__.startswith("shibokensupport")
|
||||
):
|
||||
"""Create human readable name if user does not implement `__str__`."""
|
||||
str_module = getattr(enum_value.__str__, "__module__", "enum")
|
||||
if str_module != "enum" and not str_module.startswith("shibokensupport"):
|
||||
# check if function was overloaded
|
||||
name = str(enum_value)
|
||||
else:
|
||||
name = enum_value.name.replace("_", " ")
|
||||
if enum_value.name is None:
|
||||
# This is hack for python bellow 3.11
|
||||
if not isinstance(enum_value, Flag):
|
||||
raise TypeError(
|
||||
f"Expected Flag instance, got {enum_value}"
|
||||
) # pragma: no cover
|
||||
if sys.version_info >= (3, 11):
|
||||
# There is a bug in some releases of Python 3.11 (for example 3.11.3)
|
||||
# that leads to wrong evaluation of or operation on Flag members
|
||||
# and produces numeric value without proper set name property.
|
||||
return f"{enum_value.value}"
|
||||
|
||||
# Before python 3.11 there is no smart name set during
|
||||
# the creation of Flag members.
|
||||
# We needs to decompose the value to get the name.
|
||||
# It is under if condition because it uses private API.
|
||||
|
||||
from enum import _decompose
|
||||
|
||||
members, not_covered = _decompose(enum_value.__class__, enum_value.value)
|
||||
name = "|".join(m.name.replace("_", " ") for m in members[::-1])
|
||||
else:
|
||||
name = enum_value.name.replace("_", " ")
|
||||
return name
|
||||
|
||||
|
||||
def _get_name_with_value(enum_value: Enum) -> Tuple[str, Enum]:
|
||||
return _get_name(enum_value), enum_value
|
||||
|
||||
|
||||
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,22 +71,33 @@ 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
|
||||
if allow_none:
|
||||
super().addItem(NONE_STRING)
|
||||
super().addItems(list(map(_get_name, self._enum_class.__members__.values())))
|
||||
names_ = self._get_enum_member_list(enum)
|
||||
super().addItems(list(names_))
|
||||
|
||||
@staticmethod
|
||||
def _get_enum_member_list(enum: Optional[EnumMeta]):
|
||||
if issubclass(enum, Flag):
|
||||
members = list(enum.__members__.values())
|
||||
comb_list = []
|
||||
for i in range(len(members)):
|
||||
comb_list.extend(reduce(or_, x) for x in combinations(members, i + 1))
|
||||
|
||||
else:
|
||||
comb_list = list(enum.__members__.values())
|
||||
return dict(map(_get_name_with_value, comb_list))
|
||||
|
||||
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,16 +106,12 @@ 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:
|
||||
return None
|
||||
else:
|
||||
return list(self._enum_class.__members__.values())[
|
||||
self.currentIndex() - 1
|
||||
]
|
||||
return list(self._enum_class.__members__.values())[self.currentIndex()]
|
||||
return self._get_enum_member_list(self._enum_class)[self.currentText()]
|
||||
return None
|
||||
|
||||
def setCurrentEnum(self, value: Optional[EnumType]) -> None:
|
||||
@@ -91,7 +125,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))
|
||||
|
||||
|
@@ -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()
|
||||
|
4
src/superqt/elidable/__init__.py
Normal file
4
src/superqt/elidable/__init__.py
Normal file
@@ -0,0 +1,4 @@
|
||||
from ._eliding_label import QElidingLabel
|
||||
from ._eliding_line_edit import QElidingLineEdit
|
||||
|
||||
__all__ = ["QElidingLabel", "QElidingLineEdit"]
|
78
src/superqt/elidable/_eliding.py
Normal file
78
src/superqt/elidable/_eliding.py
Normal 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())
|
75
src/superqt/elidable/_eliding_label.py
Normal file
75
src/superqt/elidable/_eliding_label.py
Normal 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())
|
91
src/superqt/elidable/_eliding_line_edit.py
Normal file
91
src/superqt/elidable/_eliding_line_edit.py
Normal 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
|
@@ -10,11 +10,12 @@ __all__ = [
|
||||
"IconFontMeta",
|
||||
"IconOpts",
|
||||
"pulse",
|
||||
"QIconifyIcon",
|
||||
"setTextIcon",
|
||||
"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 +40,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 +90,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,12 +100,11 @@ def icon(
|
||||
|
||||
Examples
|
||||
--------
|
||||
|
||||
simple example (using the string `'fa5s.smile'` assumes the `fonticon-fontawesome5`
|
||||
plugin is installed)
|
||||
|
||||
>>> btn = QPushButton()
|
||||
>>> btn.setIcon(icon('fa5s.smile'))
|
||||
>>> btn.setIcon(icon("fa5s.smile"))
|
||||
|
||||
can also directly import from fonticon_fa5
|
||||
>>> from fonticon_fa5 import FA5S
|
||||
@@ -129,7 +130,7 @@ def icon(
|
||||
... "disabled": {
|
||||
... "color": "green",
|
||||
... "scale_factor": 0.8,
|
||||
... "animation": spin(btn)
|
||||
... "animation": spin(btn),
|
||||
... },
|
||||
... },
|
||||
... )
|
||||
@@ -145,11 +146,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 +168,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 +187,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
|
||||
|
@@ -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)
|
||||
|
@@ -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:
|
||||
|
@@ -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()
|
||||
|
@@ -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 TYPE_CHECKING, ClassVar, DefaultDict, Sequence, Tuple, Union, cast
|
||||
|
||||
from qtpy import QT_VERSION
|
||||
from qtpy.QtCore import QObject, QPoint, QRect, QSize, Qt
|
||||
@@ -23,8 +23,10 @@ from qtpy.QtGui import (
|
||||
from qtpy.QtWidgets import QApplication, QStyleOption, QWidget
|
||||
from typing_extensions import TypedDict
|
||||
|
||||
from ..utils import QMessageHandler
|
||||
from ._animations import Animation
|
||||
from superqt.utils import QMessageHandler
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from ._animations import Animation
|
||||
|
||||
|
||||
class Unset:
|
||||
@@ -52,7 +54,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 +64,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 +75,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 +93,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 +121,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 +142,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,9 +158,9 @@ class _QFontIconEngine(QIconEngine):
|
||||
|
||||
def __init__(self, options: _IconOptions):
|
||||
super().__init__()
|
||||
self._opts: DefaultDict[
|
||||
QIcon.State, Dict[QIcon.Mode, Optional[_IconOptions]]
|
||||
] = DefaultDict(dict)
|
||||
self._opts: defaultdict[QIcon.State, dict[QIcon.Mode, _IconOptions | None]] = (
|
||||
DefaultDict(dict)
|
||||
)
|
||||
self._opts[QIcon.State.Off][QIcon.Mode.Normal] = options
|
||||
self.update_hash()
|
||||
|
||||
@@ -230,7 +232,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 +241,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 +323,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 +346,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 +374,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 +383,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 +398,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 +419,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 +430,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 +443,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 +457,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 +467,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 +502,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 +518,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 +542,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 +556,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 +573,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
|
||||
|
126
src/superqt/iconify/__init__.py
Normal file
126
src/superqt/iconify/__init__.py
Normal file
@@ -0,0 +1,126 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from qtpy.QtCore import QSize
|
||||
from qtpy.QtGui import QIcon
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from typing import Literal
|
||||
|
||||
Flip = Literal["horizontal", "vertical", "horizontal,vertical"]
|
||||
Rotation = Literal["90", "180", "270", 90, 180, 270, "-90", 1, 2, 3]
|
||||
|
||||
try:
|
||||
from pyconify import svg_path
|
||||
except ModuleNotFoundError: # pragma: no cover
|
||||
svg_path = None
|
||||
|
||||
|
||||
class QIconifyIcon(QIcon):
|
||||
"""QIcon backed by an iconify icon.
|
||||
|
||||
Iconify includes 150,000+ icons from most major icon sets including Bootstrap,
|
||||
FontAwesome, Material Design, and many more.
|
||||
|
||||
Search availble icons at https://icon-sets.iconify.design
|
||||
Once you find one you like, use the key in the format `"prefix:name"` to create an
|
||||
icon: `QIconifyIcon("bi:bell")`.
|
||||
|
||||
This class is a thin wrapper around the
|
||||
[pyconify](https://github.com/pyapp-kit/pyconify) `svg_path` function. It pulls SVGs
|
||||
from iconify, creates a temporary SVG file and uses it as the source for a QIcon.
|
||||
SVGs are cached to disk, and persist across sessions (until `pyconify.clear_cache()`
|
||||
is called).
|
||||
|
||||
Parameters are the same as `QIconifyIcon.addKey`, which can be used to add
|
||||
additional icons for various modes and states to the same QIcon.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
*key: str
|
||||
Icon set prefix and name. May be passed as a single string in the format
|
||||
`"prefix:name"` or as two separate strings: `'prefix', 'name'`.
|
||||
color : str, optional
|
||||
Icon color. If not provided, the icon will appear black (the icon fill color
|
||||
will be set to the string "currentColor").
|
||||
flip : str, optional
|
||||
Flip icon. Must be one of "horizontal", "vertical", "horizontal,vertical"
|
||||
rotate : str | int, optional
|
||||
Rotate icon. Must be one of 0, 90, 180, 270,
|
||||
or 0, 1, 2, 3 (equivalent to 0, 90, 180, 270, respectively)
|
||||
dir : str, optional
|
||||
If 'dir' is not None, the file will be created in that directory, otherwise a
|
||||
default
|
||||
[directory](https://docs.python.org/3/library/tempfile.html#tempfile.mkstemp) is
|
||||
used.
|
||||
|
||||
Examples
|
||||
--------
|
||||
>>> from qtpy.QtWidgets import QPushButton
|
||||
>>> from superqt import QIconifyIcon
|
||||
>>> btn = QPushButton()
|
||||
>>> icon = QIconifyIcon("bi:alarm-fill", color="red", rotate=90)
|
||||
>>> btn.setIcon(icon)
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*key: str,
|
||||
color: str | None = None,
|
||||
flip: Flip | None = None,
|
||||
rotate: Rotation | None = None,
|
||||
dir: str | None = None,
|
||||
):
|
||||
if svg_path is None: # pragma: no cover
|
||||
raise ModuleNotFoundError(
|
||||
"pyconify is required to use QIconifyIcon. "
|
||||
"Please install it with `pip install pyconify` or use the "
|
||||
"`pip install superqt[iconify]` extra."
|
||||
)
|
||||
super().__init__()
|
||||
self.addKey(*key, color=color, flip=flip, rotate=rotate, dir=dir)
|
||||
|
||||
def addKey(
|
||||
self,
|
||||
*key: str,
|
||||
color: str | None = None,
|
||||
flip: Flip | None = None,
|
||||
rotate: Rotation | None = None,
|
||||
dir: str | None = None,
|
||||
size: QSize | None = None,
|
||||
mode: QIcon.Mode = QIcon.Mode.Normal,
|
||||
state: QIcon.State = QIcon.State.Off,
|
||||
) -> None:
|
||||
"""Add an icon to this QIcon.
|
||||
|
||||
This is a variant of `QIcon.addFile` that uses an iconify icon keys and
|
||||
arguments instead of a file path.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
*key: str
|
||||
Icon set prefix and name. May be passed as a single string in the format
|
||||
`"prefix:name"` or as two separate strings: `'prefix', 'name'`.
|
||||
color : str, optional
|
||||
Icon color. If not provided, the icon will appear black (the icon fill color
|
||||
will be set to the string "currentColor").
|
||||
flip : str, optional
|
||||
Flip icon. Must be one of "horizontal", "vertical", "horizontal,vertical"
|
||||
rotate : str | int, optional
|
||||
Rotate icon. Must be one of 0, 90, 180, 270, or 0, 1, 2, 3 (equivalent to 0,
|
||||
90, 180, 270, respectively)
|
||||
dir : str, optional
|
||||
If 'dir' is not None, the file will be created in that directory, otherwise
|
||||
a default
|
||||
[directory](https://docs.python.org/3/library/tempfile.html#tempfile.mkstemp)
|
||||
is used.
|
||||
size : QSize, optional
|
||||
Size specified for the icon, passed to `QIcon.addFile`.
|
||||
mode : QIcon.Mode, optional
|
||||
Mode specified for the icon, passed to `QIcon.addFile`.
|
||||
state : QIcon.State, optional
|
||||
State specified for the icon, passed to `QIcon.addFile`.
|
||||
"""
|
||||
path = svg_path(*key, color=color, flip=flip, rotate=rotate, dir=dir)
|
||||
self.addFile(str(path), size or QSize(), mode, state)
|
@@ -6,13 +6,17 @@ 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):
|
||||
"""Pseudo-importer to forward superqt.qtcompat.* to qtpy.*."""
|
||||
|
||||
def find_spec(self, fullname: str, path, target=None): # type: ignore
|
||||
"""Forward any requests for superqt.qtcompat.* to qtpy.*."""
|
||||
if fullname.startswith(__name__):
|
||||
return util.find_spec(fullname.replace(__name__, "qtpy"))
|
||||
|
||||
|
@@ -1,3 +1,4 @@
|
||||
from ._searchable_list_widget import QSearchableListWidget
|
||||
from ._searchable_tree_widget import QSearchableTreeWidget
|
||||
|
||||
__all__ = ("QSearchableListWidget",)
|
||||
__all__ = ("QSearchableListWidget", "QSearchableTreeWidget")
|
||||
|
114
src/superqt/selection/_searchable_tree_widget.py
Normal file
114
src/superqt/selection/_searchable_tree_widget.py
Normal 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
|
@@ -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): ...
|
@@ -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
|
||||
|
@@ -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.
|
||||
@@ -19,9 +19,10 @@ So that's what `_GenericSlider` is below.
|
||||
scalar (with one handle per item), and it forms the basis of
|
||||
QRangeSlider.
|
||||
"""
|
||||
|
||||
import os
|
||||
import platform
|
||||
from typing import Generic, TypeVar
|
||||
from typing import TypeVar
|
||||
|
||||
from qtpy import QT_VERSION, QtGui
|
||||
from qtpy.QtCore import QEvent, QPoint, QPointF, QRect, Qt, Signal
|
||||
@@ -48,7 +49,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 +59,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 +67,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 +276,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 +521,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:
|
||||
|
@@ -1,12 +1,16 @@
|
||||
from enum import IntEnum
|
||||
from __future__ import annotations
|
||||
|
||||
import contextlib
|
||||
from enum import IntEnum, IntFlag, auto
|
||||
from functools import partial
|
||||
from typing import Any
|
||||
from typing import Any, overload
|
||||
|
||||
from qtpy.QtCore import QPoint, QSize, Qt, Signal
|
||||
from qtpy.QtGui import QFontMetrics, QValidator
|
||||
from qtpy.QtWidgets import (
|
||||
QAbstractSlider,
|
||||
QApplication,
|
||||
QBoxLayout,
|
||||
QDoubleSpinBox,
|
||||
QHBoxLayout,
|
||||
QSlider,
|
||||
@@ -17,85 +21,89 @@ from qtpy.QtWidgets import (
|
||||
QWidget,
|
||||
)
|
||||
|
||||
from ..utils import signals_blocked
|
||||
from superqt.utils import signals_blocked
|
||||
|
||||
from ._sliders import QDoubleRangeSlider, QDoubleSlider, QRangeSlider
|
||||
|
||||
|
||||
class LabelPosition(IntEnum):
|
||||
NoLabel = 0
|
||||
LabelsAbove = 1
|
||||
LabelsBelow = 2
|
||||
LabelsRight = 1
|
||||
LabelsLeft = 2
|
||||
LabelsAbove = auto()
|
||||
LabelsBelow = auto()
|
||||
LabelsRight = LabelsAbove
|
||||
LabelsLeft = LabelsBelow
|
||||
|
||||
|
||||
class EdgeLabelMode(IntEnum):
|
||||
class EdgeLabelMode(IntFlag):
|
||||
NoLabel = 0
|
||||
LabelIsRange = 1
|
||||
LabelIsValue = 2
|
||||
LabelIsRange = auto()
|
||||
LabelIsValue = auto()
|
||||
|
||||
|
||||
class _SliderProxy:
|
||||
_slider: QSlider
|
||||
|
||||
def value(self):
|
||||
def value(self) -> int:
|
||||
return self._slider.value()
|
||||
|
||||
def setValue(self, value) -> None:
|
||||
def setValue(self, value: int) -> None:
|
||||
self._slider.setValue(value)
|
||||
|
||||
def sliderPosition(self):
|
||||
def sliderPosition(self) -> int:
|
||||
return self._slider.sliderPosition()
|
||||
|
||||
def setSliderPosition(self, pos) -> None:
|
||||
def setSliderPosition(self, pos: int) -> None:
|
||||
self._slider.setSliderPosition(pos)
|
||||
|
||||
def minimum(self):
|
||||
def minimum(self) -> int:
|
||||
return self._slider.minimum()
|
||||
|
||||
def setMinimum(self, minimum):
|
||||
def setMinimum(self, minimum: int) -> None:
|
||||
self._slider.setMinimum(minimum)
|
||||
|
||||
def maximum(self):
|
||||
def maximum(self) -> int:
|
||||
return self._slider.maximum()
|
||||
|
||||
def setMaximum(self, maximum):
|
||||
def setMaximum(self, maximum: int) -> None:
|
||||
self._slider.setMaximum(maximum)
|
||||
|
||||
def singleStep(self):
|
||||
return self._slider.singleStep()
|
||||
|
||||
def setSingleStep(self, step):
|
||||
def setSingleStep(self, step: int) -> None:
|
||||
self._slider.setSingleStep(step)
|
||||
|
||||
def pageStep(self):
|
||||
def pageStep(self) -> int:
|
||||
return self._slider.pageStep()
|
||||
|
||||
def setPageStep(self, step) -> None:
|
||||
def setPageStep(self, step: int) -> None:
|
||||
self._slider.setPageStep(step)
|
||||
|
||||
def setRange(self, min, max) -> None:
|
||||
def setRange(self, min: int, max: int) -> None:
|
||||
self._slider.setRange(min, max)
|
||||
|
||||
def tickInterval(self):
|
||||
def tickInterval(self) -> int:
|
||||
return self._slider.tickInterval()
|
||||
|
||||
def setTickInterval(self, interval) -> None:
|
||||
def setTickInterval(self, interval: int) -> None:
|
||||
self._slider.setTickInterval(interval)
|
||||
|
||||
def tickPosition(self):
|
||||
def tickPosition(self) -> QSlider.TickPosition:
|
||||
return self._slider.tickPosition()
|
||||
|
||||
def setTickPosition(self, pos) -> None:
|
||||
def setTickPosition(self, pos: QSlider.TickPosition) -> None:
|
||||
self._slider.setTickPosition(pos)
|
||||
|
||||
def __getattr__(self, name) -> Any:
|
||||
def __getattr__(self, name: Any) -> Any:
|
||||
return getattr(self._slider, name)
|
||||
|
||||
|
||||
def _handle_overloaded_slider_sig(args, kwargs):
|
||||
def _handle_overloaded_slider_sig(
|
||||
args: tuple, kwargs: dict
|
||||
) -> tuple[QWidget | None, Qt.Orientation]:
|
||||
"""Maintaining signature of QSlider.__init__."""
|
||||
parent = None
|
||||
orientation = Qt.Orientation.Vertical
|
||||
orientation = Qt.Orientation.Horizontal
|
||||
errmsg = (
|
||||
"TypeError: arguments did not match any overloaded call:\n"
|
||||
" QSlider(parent: QWidget = None)\n"
|
||||
@@ -121,43 +129,43 @@ def _handle_overloaded_slider_sig(args, kwargs):
|
||||
class QLabeledSlider(_SliderProxy, QAbstractSlider):
|
||||
editingFinished = Signal()
|
||||
|
||||
EdgeLabelMode = EdgeLabelMode
|
||||
_slider_class = QSlider
|
||||
_slider: QSlider
|
||||
|
||||
def __init__(self, *args, **kwargs) -> None:
|
||||
@overload
|
||||
def __init__(self, parent: QWidget | None = ...) -> None: ...
|
||||
|
||||
@overload
|
||||
def __init__(
|
||||
self, orientation: Qt.Orientation, parent: QWidget | None = ...
|
||||
) -> None: ...
|
||||
|
||||
def __init__(self, *args: Any, **kwargs: Any) -> None:
|
||||
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)
|
||||
self._slider = self._slider_class(parent=self)
|
||||
self._label = SliderLabel(self._slider, connect=self._setValue, parent=self)
|
||||
self._edge_label_mode: EdgeLabelMode = EdgeLabelMode.LabelIsValue
|
||||
|
||||
self._rename_signals()
|
||||
self._slider.actionTriggered.connect(self.actionTriggered.emit)
|
||||
self._slider.rangeChanged.connect(self.rangeChanged.emit)
|
||||
self._slider.rangeChanged.connect(self._on_slider_range_changed)
|
||||
self._slider.sliderMoved.connect(self.sliderMoved.emit)
|
||||
self._slider.sliderPressed.connect(self.sliderPressed.emit)
|
||||
self._slider.sliderReleased.connect(self.sliderReleased.emit)
|
||||
self._slider.valueChanged.connect(self._label.setValue)
|
||||
self._slider.valueChanged.connect(self.valueChanged.emit)
|
||||
self._slider.valueChanged.connect(self._on_slider_value_changed)
|
||||
self._label.editingFinished.connect(self.editingFinished)
|
||||
|
||||
self.setOrientation(orientation)
|
||||
|
||||
def _setValue(self, value: float):
|
||||
"""
|
||||
Convert the value from float to int before
|
||||
setting the slider value
|
||||
"""
|
||||
self._slider.setValue(int(value))
|
||||
# ------------------- public API -------------------
|
||||
|
||||
def _rename_signals(self):
|
||||
# for subclasses
|
||||
pass
|
||||
|
||||
def setOrientation(self, orientation):
|
||||
def setOrientation(self, orientation: Qt.Orientation) -> None:
|
||||
"""Set orientation, value will be 'horizontal' or 'vertical'."""
|
||||
self._slider.setOrientation(orientation)
|
||||
marg = (0, 0, 0, 0)
|
||||
@@ -171,7 +179,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)
|
||||
@@ -189,11 +197,21 @@ class QLabeledSlider(_SliderProxy, QAbstractSlider):
|
||||
return self._edge_label_mode
|
||||
|
||||
def setEdgeLabelMode(self, opt: EdgeLabelMode) -> None:
|
||||
"""Set the `EdgeLabelMode`."""
|
||||
"""Set the `EdgeLabelMode`.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
opt : EdgeLabelMode
|
||||
To show no label, use `EdgeLabelMode.NoLabel`. To show the value
|
||||
of the slider, use `EdgeLabelMode.LabelIsValue`. To show
|
||||
`value / maximum`, use
|
||||
`EdgeLabelMode.LabelIsValue | EdgeLabelMode.LabelIsRange`.
|
||||
"""
|
||||
if opt is EdgeLabelMode.LabelIsRange:
|
||||
raise ValueError(
|
||||
"mode must be one of 'EdgeLabelMode.NoLabel' or "
|
||||
"'EdgeLabelMode.LabelIsValue'."
|
||||
"'EdgeLabelMode.LabelIsValue' or"
|
||||
"'EdgeLabelMode.LabelIsValue | EdgeLabelMode.LabelIsRange'."
|
||||
)
|
||||
|
||||
self._edge_label_mode = opt
|
||||
@@ -201,15 +219,39 @@ class QLabeledSlider(_SliderProxy, QAbstractSlider):
|
||||
self._label.hide()
|
||||
w = 5 if self.orientation() == Qt.Orientation.Horizontal else 0
|
||||
self.layout().setContentsMargins(0, 0, w, 0)
|
||||
else:
|
||||
if opt & EdgeLabelMode.LabelIsValue:
|
||||
if self.isVisible():
|
||||
self._label.show()
|
||||
self._label.setMode(opt)
|
||||
self._label.setValue(self._slider.value())
|
||||
self.layout().setContentsMargins(0, 0, 0, 0)
|
||||
self._on_slider_range_changed(self.minimum(), self.maximum())
|
||||
|
||||
QApplication.processEvents()
|
||||
|
||||
# putting this after labelMode methods for the sake of mypy
|
||||
EdgeLabelMode = EdgeLabelMode
|
||||
|
||||
# --------------------- private api --------------------
|
||||
|
||||
def _on_slider_range_changed(self, min_: int, max_: int) -> None:
|
||||
slash = " / " if self._edge_label_mode & EdgeLabelMode.LabelIsValue else ""
|
||||
if self._edge_label_mode & EdgeLabelMode.LabelIsRange:
|
||||
self._label.setSuffix(f"{slash}{max_}")
|
||||
self.rangeChanged.emit(min_, max_)
|
||||
|
||||
def _on_slider_value_changed(self, v: Any) -> None:
|
||||
self._label.setValue(v)
|
||||
self.valueChanged.emit(v)
|
||||
|
||||
def _setValue(self, value: float) -> None:
|
||||
"""Convert the value from float to int before setting the slider value."""
|
||||
self._slider.setValue(int(value))
|
||||
|
||||
def _rename_signals(self) -> None:
|
||||
# for subclasses
|
||||
pass
|
||||
|
||||
|
||||
class QLabeledDoubleSlider(QLabeledSlider):
|
||||
_slider_class = QDoubleSlider
|
||||
@@ -218,11 +260,23 @@ class QLabeledDoubleSlider(QLabeledSlider):
|
||||
_fsliderMoved = Signal(float)
|
||||
_frangeChanged = Signal(float, float)
|
||||
|
||||
def __init__(self, *args, **kwargs) -> None:
|
||||
@overload
|
||||
def __init__(self, parent: QWidget | None = ...) -> None: ...
|
||||
|
||||
@overload
|
||||
def __init__(
|
||||
self, orientation: Qt.Orientation, parent: QWidget | None = ...
|
||||
) -> None: ...
|
||||
|
||||
def __init__(self, *args: Any, **kwargs: Any) -> None:
|
||||
super().__init__(*args, **kwargs)
|
||||
self.setDecimals(2)
|
||||
|
||||
def _rename_signals(self):
|
||||
def _setValue(self, value: float) -> None:
|
||||
"""Convert the value from float to int before setting the slider value."""
|
||||
self._slider.setValue(value)
|
||||
|
||||
def _rename_signals(self) -> None:
|
||||
self.valueChanged = self._fvalueChanged
|
||||
self.sliderMoved = self._fsliderMoved
|
||||
self.rangeChanged = self._frangeChanged
|
||||
@@ -230,7 +284,7 @@ class QLabeledDoubleSlider(QLabeledSlider):
|
||||
def decimals(self) -> int:
|
||||
return self._label.decimals()
|
||||
|
||||
def setDecimals(self, prec: int):
|
||||
def setDecimals(self, prec: int) -> None:
|
||||
self._label.setDecimals(prec)
|
||||
|
||||
|
||||
@@ -238,12 +292,18 @@ class QLabeledRangeSlider(_SliderProxy, QAbstractSlider):
|
||||
_valueChanged = Signal(tuple)
|
||||
editingFinished = Signal()
|
||||
|
||||
LabelPosition = LabelPosition
|
||||
EdgeLabelMode = EdgeLabelMode
|
||||
_slider_class = QRangeSlider
|
||||
_slider: QRangeSlider
|
||||
|
||||
def __init__(self, *args, **kwargs) -> None:
|
||||
@overload
|
||||
def __init__(self, parent: QWidget | None = ...) -> None: ...
|
||||
|
||||
@overload
|
||||
def __init__(
|
||||
self, orientation: Qt.Orientation, parent: QWidget | None = ...
|
||||
) -> None: ...
|
||||
|
||||
def __init__(self, *args: Any, **kwargs: Any) -> None:
|
||||
parent, orientation = _handle_overloaded_slider_sig(args, kwargs)
|
||||
super().__init__(parent)
|
||||
self._rename_signals()
|
||||
@@ -259,6 +319,7 @@ class QLabeledRangeSlider(_SliderProxy, QAbstractSlider):
|
||||
self._slider = self._slider_class()
|
||||
self._slider.valueChanged.connect(self.valueChanged.emit)
|
||||
self._slider.rangeChanged.connect(self.rangeChanged.emit)
|
||||
self.sliderMoved = self._slider._slidersMoved
|
||||
|
||||
self._min_label = SliderLabel(
|
||||
self._slider,
|
||||
@@ -281,14 +342,13 @@ class QLabeledRangeSlider(_SliderProxy, QAbstractSlider):
|
||||
self._on_range_changed(self._slider.minimum(), self._slider.maximum())
|
||||
self.setOrientation(orientation)
|
||||
|
||||
def _rename_signals(self):
|
||||
self.valueChanged = self._valueChanged
|
||||
# --------------------- public API -------------------
|
||||
|
||||
def handleLabelPosition(self) -> LabelPosition:
|
||||
"""Return where/whether labels are shown adjacent to slider handles."""
|
||||
return self._handle_label_position
|
||||
|
||||
def setHandleLabelPosition(self, opt: LabelPosition) -> LabelPosition:
|
||||
def setHandleLabelPosition(self, opt: LabelPosition) -> None:
|
||||
"""Set where/whether labels are shown adjacent to slider handles."""
|
||||
self._handle_label_position = opt
|
||||
for lbl in self._handle_labels:
|
||||
@@ -302,7 +362,7 @@ class QLabeledRangeSlider(_SliderProxy, QAbstractSlider):
|
||||
"""Return current `EdgeLabelMode`."""
|
||||
return self._edge_label_mode
|
||||
|
||||
def setEdgeLabelMode(self, opt: EdgeLabelMode):
|
||||
def setEdgeLabelMode(self, opt: EdgeLabelMode) -> None:
|
||||
"""Set `EdgeLabelMode`, controls what is shown at the min/max labels."""
|
||||
self._edge_label_mode = opt
|
||||
if not self._edge_label_mode:
|
||||
@@ -324,107 +384,14 @@ class QLabeledRangeSlider(_SliderProxy, QAbstractSlider):
|
||||
QApplication.processEvents()
|
||||
self._reposition_labels()
|
||||
|
||||
def _reposition_labels(self):
|
||||
if (
|
||||
not self._handle_labels
|
||||
or self._handle_label_position == LabelPosition.NoLabel
|
||||
):
|
||||
return
|
||||
|
||||
horizontal = self.orientation() == Qt.Orientation.Horizontal
|
||||
labels_above = self._handle_label_position == LabelPosition.LabelsAbove
|
||||
|
||||
last_edge = None
|
||||
for i, label in enumerate(self._handle_labels):
|
||||
rect = self._slider._handleRect(i)
|
||||
dx = -label.width() / 2
|
||||
dy = -label.height() / 2
|
||||
if labels_above:
|
||||
if horizontal:
|
||||
dy *= 3
|
||||
else:
|
||||
dx *= -1
|
||||
else:
|
||||
if horizontal:
|
||||
dy *= -1
|
||||
else:
|
||||
dx *= 3
|
||||
pos = self._slider.mapToParent(rect.center())
|
||||
pos += QPoint(int(dx + self.label_shift_x), int(dy + self.label_shift_y))
|
||||
if last_edge is not None:
|
||||
# prevent label overlap
|
||||
if horizontal:
|
||||
pos.setX(int(max(pos.x(), last_edge.x() + label.width() / 2 + 12)))
|
||||
else:
|
||||
pos.setY(int(min(pos.y(), last_edge.y() - label.height() / 2 - 4)))
|
||||
label.move(pos)
|
||||
last_edge = pos
|
||||
label.clearFocus()
|
||||
label.show()
|
||||
self.update()
|
||||
|
||||
def _min_label_edited(self, val):
|
||||
if self._edge_label_mode == EdgeLabelMode.LabelIsRange:
|
||||
self.setMinimum(val)
|
||||
else:
|
||||
v = list(self._slider.value())
|
||||
v[0] = val
|
||||
self.setValue(v)
|
||||
self._reposition_labels()
|
||||
|
||||
def _max_label_edited(self, val):
|
||||
if self._edge_label_mode == EdgeLabelMode.LabelIsRange:
|
||||
self.setMaximum(val)
|
||||
else:
|
||||
v = list(self._slider.value())
|
||||
v[-1] = val
|
||||
self.setValue(v)
|
||||
self._reposition_labels()
|
||||
|
||||
def _on_value_changed(self, v):
|
||||
if self._edge_label_mode == EdgeLabelMode.LabelIsValue:
|
||||
self._min_label.setValue(v[0])
|
||||
self._max_label.setValue(v[-1])
|
||||
|
||||
if len(v) != len(self._handle_labels):
|
||||
for lbl in self._handle_labels:
|
||||
lbl.setParent(None)
|
||||
lbl.deleteLater()
|
||||
self._handle_labels.clear()
|
||||
for n, val in enumerate(self._slider.value()):
|
||||
_cb = partial(self._slider.setSliderPosition, index=n)
|
||||
s = SliderLabel(self._slider, parent=self, connect=_cb)
|
||||
s.editingFinished.connect(self.editingFinished)
|
||||
s.setValue(val)
|
||||
self._handle_labels.append(s)
|
||||
else:
|
||||
for val, label in zip(v, self._handle_labels):
|
||||
label.setValue(val)
|
||||
self._reposition_labels()
|
||||
|
||||
def _on_range_changed(self, min, max):
|
||||
if (min, max) != (self._slider.minimum(), self._slider.maximum()):
|
||||
self._slider.setRange(min, max)
|
||||
for lbl in self._handle_labels:
|
||||
lbl.setRange(min, max)
|
||||
if self._edge_label_mode == EdgeLabelMode.LabelIsRange:
|
||||
self._min_label.setValue(min)
|
||||
self._max_label.setValue(max)
|
||||
self._reposition_labels()
|
||||
|
||||
# def setValue(self, value) -> None:
|
||||
# super().setValue(value)
|
||||
# self.sliderChange(QSlider.SliderValueChange)
|
||||
|
||||
def setRange(self, min, max) -> None:
|
||||
def setRange(self, min: int, max: int) -> None:
|
||||
self._on_range_changed(min, max)
|
||||
|
||||
def setOrientation(self, orientation):
|
||||
def setOrientation(self, orientation: Qt.Orientation) -> None:
|
||||
"""Set orientation, value will be 'horizontal' or 'vertical'."""
|
||||
|
||||
self._slider.setOrientation(orientation)
|
||||
if orientation == Qt.Orientation.Vertical:
|
||||
layout = QVBoxLayout()
|
||||
layout: QBoxLayout = QVBoxLayout()
|
||||
layout.setSpacing(1)
|
||||
layout.addWidget(self._max_label)
|
||||
layout.addWidget(self._slider)
|
||||
@@ -465,24 +432,132 @@ class QLabeledRangeSlider(_SliderProxy, QAbstractSlider):
|
||||
super().resizeEvent(a0)
|
||||
self._reposition_labels()
|
||||
|
||||
# putting this after methods above for the sake of mypy
|
||||
LabelPosition = LabelPosition
|
||||
EdgeLabelMode = EdgeLabelMode
|
||||
|
||||
# ------------- private methods ----------------
|
||||
def _rename_signals(self) -> None:
|
||||
self.valueChanged = self._valueChanged
|
||||
|
||||
def _reposition_labels(self) -> None:
|
||||
if (
|
||||
not self._handle_labels
|
||||
or self._handle_label_position == LabelPosition.NoLabel
|
||||
):
|
||||
return
|
||||
|
||||
horizontal = self.orientation() == Qt.Orientation.Horizontal
|
||||
labels_above = self._handle_label_position == LabelPosition.LabelsAbove
|
||||
|
||||
last_edge = None
|
||||
for i, label in enumerate(self._handle_labels):
|
||||
rect = self._slider._handleRect(i)
|
||||
dx = -label.width() / 2
|
||||
dy = -label.height() / 2
|
||||
if labels_above:
|
||||
if horizontal:
|
||||
dy *= 3
|
||||
else:
|
||||
dx *= -1
|
||||
else:
|
||||
if horizontal:
|
||||
dy *= -1
|
||||
else:
|
||||
dx *= 3
|
||||
pos = self._slider.mapToParent(rect.center())
|
||||
pos += QPoint(int(dx + self.label_shift_x), int(dy + self.label_shift_y))
|
||||
if last_edge is not None:
|
||||
# prevent label overlap
|
||||
if horizontal:
|
||||
pos.setX(int(max(pos.x(), last_edge.x() + label.width() / 2 + 12)))
|
||||
else:
|
||||
pos.setY(int(min(pos.y(), last_edge.y() - label.height() / 2 - 4)))
|
||||
label.move(pos)
|
||||
last_edge = pos
|
||||
label.clearFocus()
|
||||
label.show()
|
||||
self.update()
|
||||
|
||||
def _min_label_edited(self, val: float) -> None:
|
||||
if self._edge_label_mode == EdgeLabelMode.LabelIsRange:
|
||||
self.setMinimum(val)
|
||||
else:
|
||||
v = list(self._slider.value())
|
||||
v[0] = val
|
||||
self.setValue(v)
|
||||
self._reposition_labels()
|
||||
|
||||
def _max_label_edited(self, val: float) -> None:
|
||||
if self._edge_label_mode == EdgeLabelMode.LabelIsRange:
|
||||
self.setMaximum(val)
|
||||
else:
|
||||
v = list(self._slider.value())
|
||||
v[-1] = val
|
||||
self.setValue(v)
|
||||
self._reposition_labels()
|
||||
|
||||
def _on_value_changed(self, v: tuple[int, ...]) -> None:
|
||||
if self._edge_label_mode == EdgeLabelMode.LabelIsValue:
|
||||
self._min_label.setValue(v[0])
|
||||
self._max_label.setValue(v[-1])
|
||||
|
||||
if len(v) != len(self._handle_labels):
|
||||
for lbl in self._handle_labels:
|
||||
lbl.setParent(None)
|
||||
lbl.deleteLater()
|
||||
self._handle_labels.clear()
|
||||
for n, val in enumerate(self._slider.value()):
|
||||
_cb = partial(self._slider.setSliderPosition, index=n)
|
||||
s = SliderLabel(self._slider, parent=self, connect=_cb)
|
||||
s.editingFinished.connect(self.editingFinished)
|
||||
s.setValue(val)
|
||||
self._handle_labels.append(s)
|
||||
else:
|
||||
for val, label in zip(v, self._handle_labels):
|
||||
label.setValue(val)
|
||||
self._reposition_labels()
|
||||
|
||||
def _on_range_changed(self, min: int, max: int) -> None:
|
||||
if (min, max) != (self._slider.minimum(), self._slider.maximum()):
|
||||
self._slider.setRange(min, max)
|
||||
for lbl in self._handle_labels:
|
||||
lbl.setRange(min, max)
|
||||
if self._edge_label_mode == EdgeLabelMode.LabelIsRange:
|
||||
self._min_label.setValue(min)
|
||||
self._max_label.setValue(max)
|
||||
self._reposition_labels()
|
||||
|
||||
# def setValue(self, value) -> None:
|
||||
# super().setValue(value)
|
||||
# self.sliderChange(QSlider.SliderValueChange)
|
||||
|
||||
|
||||
class QLabeledDoubleRangeSlider(QLabeledRangeSlider):
|
||||
_slider_class = QDoubleRangeSlider
|
||||
_slider: QDoubleRangeSlider
|
||||
_frangeChanged = Signal(float, float)
|
||||
|
||||
def __init__(self, *args, **kwargs) -> None:
|
||||
@overload
|
||||
def __init__(self, parent: QWidget | None = ...) -> None: ...
|
||||
|
||||
@overload
|
||||
def __init__(
|
||||
self, orientation: Qt.Orientation, parent: QWidget | None = ...
|
||||
) -> None: ...
|
||||
|
||||
def __init__(self, *args: Any, **kwargs: Any) -> None:
|
||||
super().__init__(*args, **kwargs)
|
||||
self.setDecimals(2)
|
||||
|
||||
def _rename_signals(self):
|
||||
def _rename_signals(self) -> None:
|
||||
super()._rename_signals()
|
||||
self.rangeChanged = self._frangeChanged
|
||||
|
||||
def decimals(self) -> int:
|
||||
return self._min_label.decimals()
|
||||
|
||||
def setDecimals(self, prec: int):
|
||||
def setDecimals(self, prec: int) -> None:
|
||||
self._min_label.setDecimals(prec)
|
||||
self._max_label.setDecimals(prec)
|
||||
for lbl in self._handle_labels:
|
||||
@@ -513,28 +588,62 @@ class SliderLabel(QDoubleSpinBox):
|
||||
self.editingFinished.connect(self._silent_clear_focus)
|
||||
self._update_size()
|
||||
|
||||
def _silent_clear_focus(self):
|
||||
with signals_blocked(self):
|
||||
self.clearFocus()
|
||||
|
||||
def setDecimals(self, prec: int) -> None:
|
||||
super().setDecimals(prec)
|
||||
self._update_size()
|
||||
|
||||
def _update_size(self, *_):
|
||||
def setValue(self, val: Any) -> None:
|
||||
super().setValue(val)
|
||||
if self._mode == EdgeLabelMode.LabelIsRange:
|
||||
self._update_size()
|
||||
|
||||
def setMaximum(self, max: float) -> None:
|
||||
super().setMaximum(max)
|
||||
if self._mode == EdgeLabelMode.LabelIsValue:
|
||||
self._update_size()
|
||||
|
||||
def setMinimum(self, min: float) -> None:
|
||||
super().setMinimum(min)
|
||||
if self._mode == EdgeLabelMode.LabelIsValue:
|
||||
self._update_size()
|
||||
|
||||
def setMode(self, opt: EdgeLabelMode) -> None:
|
||||
# when the edge labels are controlling slider range,
|
||||
# we want them to have a big range, but not have a huge label
|
||||
self._mode = opt
|
||||
if opt == EdgeLabelMode.LabelIsRange:
|
||||
self.setMinimum(-9999999)
|
||||
self.setMaximum(9999999)
|
||||
with contextlib.suppress(Exception):
|
||||
self._slider.rangeChanged.disconnect(self.setRange)
|
||||
else:
|
||||
self.setMinimum(self._slider.minimum())
|
||||
self.setMaximum(self._slider.maximum())
|
||||
self._slider.rangeChanged.connect(self.setRange)
|
||||
self._update_size()
|
||||
|
||||
# --------------- private ----------------
|
||||
|
||||
def _silent_clear_focus(self) -> None:
|
||||
with signals_blocked(self):
|
||||
self.clearFocus()
|
||||
|
||||
def _update_size(self, *_: Any) -> None:
|
||||
# fontmetrics to measure the width of text
|
||||
fm = QFontMetrics(self.font())
|
||||
h = self.sizeHint().height()
|
||||
fixed_content = self.prefix() + self.suffix() + " "
|
||||
|
||||
if self._mode == EdgeLabelMode.LabelIsValue:
|
||||
if self._mode & EdgeLabelMode.LabelIsValue:
|
||||
# determine width based on min/max/specialValue
|
||||
mintext = self.textFromValue(self.minimum())[:18] + fixed_content
|
||||
maxtext = self.textFromValue(self.maximum())[:18] + fixed_content
|
||||
w = max(0, _fm_width(fm, mintext))
|
||||
w = max(w, _fm_width(fm, maxtext))
|
||||
mintext = self.textFromValue(self.minimum())[:18]
|
||||
maxtext = self.textFromValue(self.maximum())[:18]
|
||||
w = max(0, _fm_width(fm, mintext + fixed_content))
|
||||
w = max(w, _fm_width(fm, maxtext + fixed_content))
|
||||
if self.specialValueText():
|
||||
w = max(w, _fm_width(fm, self.specialValueText()))
|
||||
if self._mode & EdgeLabelMode.LabelIsRange:
|
||||
w += 8 # it seems as thought suffix() is not enough
|
||||
else:
|
||||
w = max(0, _fm_width(fm, self.textFromValue(self.value()))) + 3
|
||||
|
||||
@@ -547,46 +656,16 @@ class SliderLabel(QDoubleSpinBox):
|
||||
)
|
||||
self.setFixedSize(size)
|
||||
|
||||
def setValue(self, val: Any) -> None:
|
||||
super().setValue(val)
|
||||
if self._mode == EdgeLabelMode.LabelIsRange:
|
||||
self._update_size()
|
||||
|
||||
def setMaximum(self, max: int) -> None:
|
||||
super().setMaximum(max)
|
||||
if self._mode == EdgeLabelMode.LabelIsValue:
|
||||
self._update_size()
|
||||
|
||||
def setMinimum(self, min: int) -> None:
|
||||
super().setMinimum(min)
|
||||
if self._mode == EdgeLabelMode.LabelIsValue:
|
||||
self._update_size()
|
||||
|
||||
def setMode(self, opt: EdgeLabelMode):
|
||||
# when the edge labels are controlling slider range,
|
||||
# we want them to have a big range, but not have a huge label
|
||||
self._mode = opt
|
||||
if opt == EdgeLabelMode.LabelIsRange:
|
||||
self.setMinimum(-9999999)
|
||||
self.setMaximum(9999999)
|
||||
try:
|
||||
self._slider.rangeChanged.disconnect(self.setRange)
|
||||
except Exception:
|
||||
pass
|
||||
else:
|
||||
self.setMinimum(self._slider.minimum())
|
||||
self.setMaximum(self._slider.maximum())
|
||||
self._slider.rangeChanged.connect(self.setRange)
|
||||
self._update_size()
|
||||
|
||||
def validate(self, input: str, pos: int):
|
||||
def validate(
|
||||
self, input_: str | None, pos: int
|
||||
) -> tuple[QValidator.State, str, int]:
|
||||
# fake like an integer spinbox
|
||||
if "." in input and self.decimals() < 1:
|
||||
return QValidator.Invalid, input, len(input)
|
||||
return super().validate(input, pos)
|
||||
if input_ and "." in input_ and self.decimals() < 1:
|
||||
return QValidator.State.Invalid, input_, len(input_)
|
||||
return super().validate(input_, pos)
|
||||
|
||||
|
||||
def _fm_width(fm, text):
|
||||
def _fm_width(fm: QFontMetrics, text: str) -> int:
|
||||
if hasattr(fm, "horizontalAdvance"):
|
||||
return fm.horizontalAdvance(text)
|
||||
return fm.width(text)
|
||||
|
@@ -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()
|
||||
|
@@ -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)
|
||||
|
||||
|
@@ -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)
|
||||
@@ -61,14 +65,20 @@ class QLargeIntSpinBox(QAbstractSpinBox):
|
||||
|
||||
def setMinimum(self, min):
|
||||
self._minimum = int(min)
|
||||
if self._minimum > self._value:
|
||||
self.setValue(self._minimum)
|
||||
|
||||
def maximum(self):
|
||||
return self._maximum
|
||||
|
||||
def setMaximum(self, max):
|
||||
self._maximum = int(max)
|
||||
if self._maximum < self._value:
|
||||
self.setValue(self._maximum)
|
||||
|
||||
def setRange(self, minimum, maximum):
|
||||
if maximum < minimum:
|
||||
maximum = minimum
|
||||
self.setMinimum(minimum)
|
||||
self.setMaximum(maximum)
|
||||
|
||||
@@ -78,7 +88,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 +118,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 +183,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 +196,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))
|
||||
|
@@ -11,7 +11,7 @@ except ImportError as e:
|
||||
from qtpy.QtCore import Signal
|
||||
from qtpy.QtWidgets import QComboBox, QDoubleSpinBox, QHBoxLayout, QSizePolicy, QWidget
|
||||
|
||||
from ..utils import signals_blocked
|
||||
from superqt.utils import signals_blocked
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from decimal import Decimal
|
||||
@@ -69,8 +69,8 @@ class QQuantity(QWidget):
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
value: Union[str, Quantity, Number],
|
||||
units: Union[UnitsContainer, str, Quantity] = None,
|
||||
value: Union[str, Quantity, Number] = 0,
|
||||
units: Optional[Union[UnitsContainer, str, Quantity]] = None,
|
||||
ureg: Optional[UnitRegistry] = None,
|
||||
parent: Optional[QWidget] = None,
|
||||
) -> None:
|
||||
@@ -78,7 +78,10 @@ class QQuantity(QWidget):
|
||||
if ureg is None:
|
||||
ureg = value._REGISTRY if isinstance(value, Quantity) else UREG
|
||||
else:
|
||||
assert isinstance(ureg, UnitRegistry)
|
||||
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)
|
||||
@@ -163,10 +166,15 @@ class QQuantity(QWidget):
|
||||
def setValue(
|
||||
self,
|
||||
value: Union[str, Quantity, Number],
|
||||
units: Union[UnitsContainer, str, Quantity] = None,
|
||||
units: Optional[Union[UnitsContainer, str, Quantity]] = None,
|
||||
) -> None:
|
||||
"""Set the current value (will cast to a pint Quantity)."""
|
||||
new_val = self._ureg.Quantity(value, units=units)
|
||||
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
|
||||
|
@@ -1,8 +1,16 @@
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from superqt.cmap import draw_colormap # noqa: TCH004
|
||||
|
||||
__all__ = (
|
||||
"CodeSyntaxHighlight",
|
||||
"create_worker",
|
||||
"qimage_to_array",
|
||||
"draw_colormap",
|
||||
"ensure_main_thread",
|
||||
"ensure_object_thread",
|
||||
"exceptions_as_dialog",
|
||||
"FunctionWorker",
|
||||
"GeneratorWorker",
|
||||
"new_worker_qthread",
|
||||
@@ -18,6 +26,8 @@ __all__ = (
|
||||
|
||||
from ._code_syntax_highlight import CodeSyntaxHighlight
|
||||
from ._ensure_thread import ensure_main_thread, ensure_object_thread
|
||||
from ._errormsg_context import exceptions_as_dialog
|
||||
from ._img_utils import qimage_to_array
|
||||
from ._message_handler import QMessageHandler
|
||||
from ._misc import signals_blocked
|
||||
from ._qthreading import (
|
||||
@@ -29,3 +39,11 @@ from ._qthreading import (
|
||||
thread_worker,
|
||||
)
|
||||
from ._throttler import QSignalDebouncer, QSignalThrottler, qdebounced, qthrottled
|
||||
|
||||
|
||||
def __getattr__(name: str) -> Any: # pragma: no cover
|
||||
if name == "draw_colormap":
|
||||
from superqt.cmap import draw_colormap
|
||||
|
||||
return draw_colormap
|
||||
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
|
||||
|
@@ -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])
|
||||
|
@@ -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, Any, Callable, ClassVar, overload
|
||||
|
||||
from qtpy.QtCore import (
|
||||
QCoreApplication,
|
||||
@@ -13,12 +15,22 @@ from qtpy.QtCore import (
|
||||
Slot,
|
||||
)
|
||||
|
||||
from ._util import get_max_args
|
||||
|
||||
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):
|
||||
def __init__(self, callable: Callable, args: tuple, kwargs: dict):
|
||||
super().__init__()
|
||||
self._callable = callable
|
||||
self._args = args
|
||||
@@ -32,8 +44,32 @@ 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.
|
||||
|
||||
@@ -52,26 +88,50 @@ def ensure_main_thread(
|
||||
"""
|
||||
|
||||
def _out_func(func_):
|
||||
max_args = get_max_args(func_)
|
||||
|
||||
@wraps(func_)
|
||||
def _func(*args, **kwargs):
|
||||
def _func(*args, _max_args_=max_args, **kwargs):
|
||||
return _run_in_thread(
|
||||
func_,
|
||||
QCoreApplication.instance().thread(),
|
||||
await_return,
|
||||
timeout,
|
||||
*args,
|
||||
**kwargs,
|
||||
args[:_max_args_],
|
||||
kwargs,
|
||||
)
|
||||
|
||||
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(
|
||||
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_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: Callable | None = None, await_return: bool = False, timeout: int = 1000
|
||||
):
|
||||
"""Decorator that ensures a QObject method is called in the object's thread.
|
||||
|
||||
@@ -90,17 +150,18 @@ def ensure_object_thread(
|
||||
"""
|
||||
|
||||
def _out_func(func_):
|
||||
max_args = get_max_args(func_)
|
||||
|
||||
@wraps(func_)
|
||||
def _func(self, *args, **kwargs):
|
||||
def _func(*args, _max_args_=max_args, **kwargs):
|
||||
thread = args[0].thread() # self
|
||||
return _run_in_thread(
|
||||
func_, self.thread(), await_return, timeout, self, *args, **kwargs
|
||||
func_, thread, await_return, timeout, args[:_max_args_], kwargs
|
||||
)
|
||||
|
||||
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(
|
||||
@@ -108,9 +169,9 @@ def _run_in_thread(
|
||||
thread: QThread,
|
||||
await_return: bool,
|
||||
timeout: int,
|
||||
*args,
|
||||
**kwargs,
|
||||
):
|
||||
args: tuple,
|
||||
kwargs: dict,
|
||||
) -> Any:
|
||||
future = Future() # type: ignore
|
||||
if thread is QThread.currentThread():
|
||||
result = func(*args, **kwargs)
|
||||
@@ -118,7 +179,8 @@ def _run_in_thread(
|
||||
future.set_result(result)
|
||||
return future
|
||||
return result
|
||||
f = CallCallable(func, *args, **kwargs)
|
||||
|
||||
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
|
||||
|
@@ -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]]: ...
|
164
src/superqt/utils/_errormsg_context.py
Normal file
164
src/superqt/utils/_errormsg_context.py
Normal file
@@ -0,0 +1,164 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import traceback
|
||||
from typing import TYPE_CHECKING, cast
|
||||
|
||||
from qtpy.QtCore import Qt
|
||||
from qtpy.QtWidgets import QErrorMessage, QMessageBox, QWidget
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from types import TracebackType
|
||||
|
||||
|
||||
_DEFAULT_FLAGS = Qt.WindowType.Dialog | Qt.WindowType.MSWindowsFixedSizeDialogHint
|
||||
|
||||
|
||||
class exceptions_as_dialog:
|
||||
"""Context manager that shows a dialog when an exception is raised.
|
||||
|
||||
See examples below for common usage patterns.
|
||||
|
||||
To determine whether an exception was raised or not, check the `exception`
|
||||
attribute after the context manager has exited. If `use_error_message` is `False`
|
||||
(the default), you can also access the `dialog` attribute to get/manipulate the
|
||||
`QMessageBox` instance.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
exceptions : type[BaseException] | tuple[type[BaseException], ...], optional
|
||||
The exception(s) to catch, by default `Exception` (i.e. all exceptions).
|
||||
icon : QMessageBox.Icon, optional
|
||||
The icon to show in the QMessageBox, by default `QMessageBox.Icon.Critical`
|
||||
title : str, optional
|
||||
The title of the `QMessageBox`, by default `"An error occurred"`.
|
||||
msg_template : str, optional
|
||||
The message to show in the `QMessageBox`. The message will be formatted
|
||||
using three variables:
|
||||
|
||||
- `exc_value`: the exception instance
|
||||
- `exc_type`: the exception type
|
||||
- `tb`: the traceback as a string
|
||||
|
||||
The default template is the content of the exception: `"{exc_value}"`
|
||||
buttons : QMessageBox.StandardButton, optional
|
||||
The buttons to show in the `QMessageBox`, by default
|
||||
`QMessageBox.StandardButton.Ok`
|
||||
parent : QWidget | None, optional
|
||||
The parent widget of the `QMessageBox`, by default `None`
|
||||
use_error_message : bool | QErrorMessage, optional
|
||||
Whether to use a `QErrorMessage` instead of a `QMessageBox`. By default
|
||||
`False`. `QErrorMessage` shows a checkbox that the user can check to
|
||||
prevent seeing the message again (based on the text of the formatted
|
||||
`msg_template`.) If `True`, the global `QMessageError.qtHandler()`
|
||||
instance is used to maintain a history of dismissed messages. You may also pass
|
||||
a `QErrorMessage` instance to use a specific instance. If `use_error_message` is
|
||||
True, or if you pass your own `QErrorMessage` instance, the `parent` argument
|
||||
is ignored.
|
||||
|
||||
Attributes
|
||||
----------
|
||||
dialog : QMessageBox | None
|
||||
The `QMessageBox` instance that was created (if `use_error_message` was
|
||||
`False`). This can be used, among other things, to determine the result of
|
||||
the dialog (e.g. `dialog.result()`) or to manipulate the dialog (e.g.
|
||||
`dialog.setDetailedText("some text")`).
|
||||
exception : BaseException | None
|
||||
Will hold the exception instance if an exception was raised and caught.
|
||||
|
||||
Examples
|
||||
--------
|
||||
```python
|
||||
from qtpy.QtWidgets import QApplication
|
||||
from superqt.utils import exceptions_as_dialog
|
||||
|
||||
app = QApplication([])
|
||||
|
||||
with exceptions_as_dialog() as ctx:
|
||||
raise Exception("This will be caught and shown in a QMessageBox")
|
||||
|
||||
# you can access the exception instance here
|
||||
assert ctx.exception is not None
|
||||
|
||||
# with exceptions_as_dialog(ValueError):
|
||||
# 1 / 0 # ZeroDivisionError is not caught, so this will raise
|
||||
|
||||
with exceptions_as_dialog(msg_template="Error: {exc_value}"):
|
||||
raise Exception("This message will be inserted at 'exc_value'")
|
||||
|
||||
for _i in range(3):
|
||||
with exceptions_as_dialog(AssertionError, use_error_message=True):
|
||||
assert False, "Uncheck the checkbox to ignore this in the future"
|
||||
|
||||
# use ctx.dialog to get the result of the dialog
|
||||
btns = QMessageBox.StandardButton.Ok | QMessageBox.StandardButton.Cancel
|
||||
with exceptions_as_dialog(buttons=btns) as ctx:
|
||||
raise Exception("This will be caught and shown in a QMessageBox")
|
||||
print(ctx.dialog.result()) # prints which button was clicked
|
||||
|
||||
app.exec() # needed only for the use_error_message example to show
|
||||
```
|
||||
"""
|
||||
|
||||
dialog: QMessageBox | None
|
||||
exception: BaseException | None
|
||||
exec_result: int | None = None
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
exceptions: type[BaseException] | tuple[type[BaseException], ...] = Exception,
|
||||
icon: QMessageBox.Icon = QMessageBox.Icon.Critical,
|
||||
title: str = "An error occurred",
|
||||
msg_template: str = "{exc_value}",
|
||||
buttons: QMessageBox.StandardButton = QMessageBox.StandardButton.Ok,
|
||||
parent: QWidget | None = None,
|
||||
flags: Qt.WindowType = _DEFAULT_FLAGS,
|
||||
use_error_message: bool | QErrorMessage = False,
|
||||
):
|
||||
self.exceptions = exceptions
|
||||
self.msg_template = msg_template
|
||||
self.exception = None
|
||||
self.dialog = None
|
||||
|
||||
self._err_msg = use_error_message
|
||||
|
||||
if not use_error_message:
|
||||
# the message will be overwritten in __exit__
|
||||
self.dialog = QMessageBox(
|
||||
icon, title, "An error occurred", buttons, parent, flags
|
||||
)
|
||||
|
||||
def __enter__(self) -> exceptions_as_dialog:
|
||||
return self
|
||||
|
||||
def __exit__(
|
||||
self,
|
||||
exc_type: type[BaseException] | None,
|
||||
exc_value: BaseException | None,
|
||||
tb: TracebackType | None,
|
||||
) -> bool:
|
||||
if not (exc_value is not None and isinstance(exc_value, self.exceptions)):
|
||||
return False # let it propagate
|
||||
|
||||
# save the exception for later
|
||||
self.exception = exc_value
|
||||
|
||||
# format the message using the context variables
|
||||
if "{tb}" in self.msg_template:
|
||||
_tb = "\n".join(traceback.format_exception(exc_type, exc_value, tb))
|
||||
else:
|
||||
_tb = ""
|
||||
text = self.msg_template.format(exc_value=exc_value, exc_type=exc_type, tb=_tb)
|
||||
|
||||
# show the dialog
|
||||
if self._err_msg:
|
||||
msg = (
|
||||
self._err_msg
|
||||
if isinstance(self._err_msg, QErrorMessage)
|
||||
else QErrorMessage.qtHandler()
|
||||
)
|
||||
cast("QErrorMessage", msg).showMessage(text)
|
||||
elif self.dialog is not None: # it won't be if use_error_message=False
|
||||
self.dialog.setText(text)
|
||||
self.dialog.exec()
|
||||
|
||||
return True # swallow the exception
|
40
src/superqt/utils/_img_utils.py
Normal file
40
src/superqt/utils/_img_utils.py
Normal file
@@ -0,0 +1,40 @@
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from qtpy.QtGui import QImage
|
||||
|
||||
if TYPE_CHECKING:
|
||||
import numpy as np
|
||||
|
||||
|
||||
def qimage_to_array(img: QImage) -> "np.ndarray":
|
||||
"""Convert QImage to an array.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
img : QImage
|
||||
QImage to be converted.
|
||||
|
||||
Returns
|
||||
-------
|
||||
arr : np.ndarray
|
||||
Numpy array of type uint8 and shape (h, w, 4). Index [0, 0] is the
|
||||
upper-left corner of the rendered region.
|
||||
"""
|
||||
import numpy as np
|
||||
|
||||
# cast to ARGB32 if necessary
|
||||
if img.format() != QImage.Format.Format_ARGB32:
|
||||
img = img.convertToFormat(QImage.Format.Format_ARGB32)
|
||||
|
||||
h, w, c = img.height(), img.width(), 4
|
||||
|
||||
# pyside returns a memoryview, pyqt returns a sizeless void pointer
|
||||
b = img.constBits() # Returns a pointer to the first pixel data.
|
||||
if hasattr(b, "setsize"):
|
||||
b.setsize(h * w * c)
|
||||
|
||||
# reshape to h, w, c
|
||||
arr = np.frombuffer(b, np.uint8).reshape(h, w, c)
|
||||
|
||||
# reverse channel colors for numpy
|
||||
return arr.take([2, 1, 0, 3], axis=2)
|
@@ -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
|
||||
|
||||
@@ -37,10 +38,10 @@ class QMessageHandler:
|
||||
|
||||
>>> logger = logging.getLogger(__name__)
|
||||
>>> with QMessageHandler(logger): # re-reoute Qt messages to a python logger.
|
||||
... ...
|
||||
... ...
|
||||
"""
|
||||
|
||||
_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
|
||||
|
||||
|
@@ -7,7 +7,24 @@ if TYPE_CHECKING:
|
||||
|
||||
@contextmanager
|
||||
def signals_blocked(obj: "QObject") -> Iterator[None]:
|
||||
"""Context manager to temporarily block signals emitted by QObject: `obj`."""
|
||||
"""Context manager to temporarily block signals emitted by QObject: `obj`.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
obj : QObject
|
||||
The QObject whose signals should be blocked.
|
||||
|
||||
Examples
|
||||
--------
|
||||
```python
|
||||
from qtpy.QtWidgets import QSpinBox
|
||||
from superqt import signals_blocked
|
||||
|
||||
spinbox = QSpinBox()
|
||||
with signals_blocked(spinbox):
|
||||
spinbox.setValue(10)
|
||||
```
|
||||
"""
|
||||
previous = obj.blockSignals(True)
|
||||
try:
|
||||
yield
|
||||
|
@@ -8,15 +8,11 @@ from typing import (
|
||||
TYPE_CHECKING,
|
||||
Any,
|
||||
Callable,
|
||||
Dict,
|
||||
ClassVar,
|
||||
Generator,
|
||||
Generic,
|
||||
Optional,
|
||||
Sequence,
|
||||
Set,
|
||||
Type,
|
||||
TypeVar,
|
||||
Union,
|
||||
overload,
|
||||
)
|
||||
|
||||
@@ -27,16 +23,13 @@ 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
|
||||
def disconnect(slot: Callable[[_T], Any] = ...) -> None:
|
||||
...
|
||||
def disconnect(slot: Callable[[_T], Any] = ...) -> None: ...
|
||||
|
||||
@staticmethod
|
||||
def emit(*args: _T) -> None:
|
||||
...
|
||||
def emit(*args: _T) -> None: ...
|
||||
|
||||
from typing_extensions import Literal, ParamSpec
|
||||
|
||||
@@ -56,12 +49,12 @@ _R = TypeVar("_R")
|
||||
|
||||
|
||||
def as_generator_function(
|
||||
func: Callable[_P, _R]
|
||||
func: Callable[_P, _R],
|
||||
) -> Callable[_P, Generator[None, None, _R]]:
|
||||
"""Turns a regular function (single return) into a generator function."""
|
||||
|
||||
@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 +62,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 +85,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 +94,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 +140,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 +182,7 @@ class WorkerBase(QRunnable, Generic[_R]):
|
||||
warnings.warn(
|
||||
f"RuntimeError in aborted thread: {result}",
|
||||
RuntimeWarning,
|
||||
stacklevel=2,
|
||||
)
|
||||
return
|
||||
else:
|
||||
@@ -202,20 +195,19 @@ 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
|
||||
--------
|
||||
```python
|
||||
class MyWorker(WorkerBase):
|
||||
|
||||
def work(self):
|
||||
i = 0
|
||||
while True:
|
||||
@@ -267,7 +259,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 +355,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 +388,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 +401,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 +410,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 +436,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 +451,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,37 +490,35 @@ 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]:
|
||||
...
|
||||
) -> GeneratorWorker[_Y, _S, _R]: ...
|
||||
|
||||
|
||||
@overload
|
||||
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]:
|
||||
...
|
||||
) -> FunctionWorker[_R]: ...
|
||||
|
||||
|
||||
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
|
||||
@@ -579,12 +568,14 @@ def create_worker(
|
||||
```python
|
||||
def long_function(duration):
|
||||
import time
|
||||
|
||||
time.sleep(duration)
|
||||
|
||||
|
||||
worker = create_worker(long_function, 10)
|
||||
```
|
||||
"""
|
||||
worker: Union[FunctionWorker, GeneratorWorker]
|
||||
worker: FunctionWorker | GeneratorWorker
|
||||
|
||||
if not _worker_class:
|
||||
if inspect.isgeneratorfunction(func):
|
||||
@@ -631,47 +622,46 @@ 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]]:
|
||||
...
|
||||
) -> Callable[_P, GeneratorWorker[_Y, _S, _R]]: ...
|
||||
|
||||
|
||||
@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]]:
|
||||
...
|
||||
) -> Callable[_P, FunctionWorker[_R]]: ...
|
||||
|
||||
|
||||
@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 +705,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).
|
||||
@@ -739,7 +730,8 @@ def thread_worker(
|
||||
yield i
|
||||
|
||||
# do teardown
|
||||
return 'anything'
|
||||
return "anything"
|
||||
|
||||
|
||||
# call the function to start running in another thread.
|
||||
worker = long_function()
|
||||
@@ -792,33 +784,18 @@ if TYPE_CHECKING:
|
||||
class WorkerProtocol(QObject):
|
||||
finished: Signal
|
||||
|
||||
def work(self) -> None:
|
||||
...
|
||||
def work(self) -> None: ...
|
||||
|
||||
|
||||
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.
|
||||
|
||||
@@ -862,9 +839,7 @@ def new_worker_qthread(
|
||||
Create some QObject that has a long-running work method:
|
||||
|
||||
```python
|
||||
|
||||
class Worker(QObject):
|
||||
|
||||
finished = Signal()
|
||||
increment = Signal(int)
|
||||
|
||||
@@ -876,20 +851,21 @@ def new_worker_qthread(
|
||||
def work(self):
|
||||
# some long running task...
|
||||
import time
|
||||
|
||||
for i in range(10):
|
||||
time.sleep(1)
|
||||
self.increment.emit(i)
|
||||
self.finished.emit()
|
||||
|
||||
|
||||
worker, thread = new_worker_qthread(
|
||||
Worker,
|
||||
'argument',
|
||||
"argument",
|
||||
_start_thread=True,
|
||||
_connect={'increment': print},
|
||||
_connect={"increment": print},
|
||||
)
|
||||
```
|
||||
"""
|
||||
|
||||
if _connect and not isinstance(_connect, dict):
|
||||
raise TypeError("_connect parameter must be a dict")
|
||||
|
||||
|
@@ -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
|
||||
|
||||
@@ -26,17 +26,21 @@ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
|
||||
"""
|
||||
import sys
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from concurrent.futures import Future
|
||||
from enum import IntFlag, auto
|
||||
from functools import wraps
|
||||
from typing import TYPE_CHECKING, Callable, Generic, Optional, TypeVar, Union, overload
|
||||
from typing import TYPE_CHECKING, Callable, Generic, TypeVar, overload
|
||||
from weakref import WeakKeyDictionary
|
||||
|
||||
from qtpy.QtCore import QObject, Qt, QTimer, Signal
|
||||
|
||||
from ._util import get_max_args
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from qtpy.QtCore import SignalInstance
|
||||
from typing_extensions import Literal, ParamSpec
|
||||
from typing_extensions import ParamSpec
|
||||
|
||||
P = ParamSpec("P")
|
||||
# maintain runtime compatibility with older typing_extensions
|
||||
@@ -62,7 +66,6 @@ class EmissionPolicy(IntFlag):
|
||||
|
||||
|
||||
class GenericSignalThrottler(QObject):
|
||||
|
||||
triggered = Signal()
|
||||
timeoutChanged = Signal(int)
|
||||
timerTypeChanged = Signal(Qt.TimerType)
|
||||
@@ -71,7 +74,7 @@ class GenericSignalThrottler(QObject):
|
||||
self,
|
||||
kind: Kind,
|
||||
emissionPolicy: EmissionPolicy,
|
||||
parent: Optional[QObject] = None,
|
||||
parent: QObject | None = None,
|
||||
) -> None:
|
||||
super().__init__(parent)
|
||||
|
||||
@@ -79,7 +82,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 +97,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,24 +136,32 @@ 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
|
||||
|
||||
def flush(self) -> None:
|
||||
"""Force emission of any pending emissions."""
|
||||
self._maybeEmitTriggered()
|
||||
def flush(self, restart_timer: bool = True) -> None:
|
||||
"""
|
||||
Force emission of any pending emissions.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
restart_timer : bool
|
||||
Whether to restart the timer after flushing.
|
||||
Defaults to True.
|
||||
"""
|
||||
self._maybeEmitTriggered(restart_timer=restart_timer)
|
||||
|
||||
def _emitTriggered(self) -> None:
|
||||
self._hasPendingEmission = False
|
||||
self.triggered.emit()
|
||||
self._timer.start()
|
||||
|
||||
def _maybeEmitTriggered(self) -> None:
|
||||
def _maybeEmitTriggered(self, restart_timer=True) -> None:
|
||||
if self._hasPendingEmission:
|
||||
self._emitTriggered()
|
||||
if not restart_timer:
|
||||
self._timer.stop()
|
||||
|
||||
Kind = Kind
|
||||
EmissionPolicy = EmissionPolicy
|
||||
@@ -169,7 +180,7 @@ class QSignalThrottler(GenericSignalThrottler):
|
||||
def __init__(
|
||||
self,
|
||||
policy: EmissionPolicy = EmissionPolicy.Leading,
|
||||
parent: Optional[QObject] = None,
|
||||
parent: QObject | None = None,
|
||||
) -> None:
|
||||
super().__init__(Kind.Throttler, policy, parent)
|
||||
|
||||
@@ -184,7 +195,7 @@ class QSignalDebouncer(GenericSignalThrottler):
|
||||
def __init__(
|
||||
self,
|
||||
policy: EmissionPolicy = EmissionPolicy.Trailing,
|
||||
parent: Optional[QObject] = None,
|
||||
parent: QObject | None = None,
|
||||
) -> None:
|
||||
super().__init__(Kind.Debouncer, policy, parent)
|
||||
|
||||
@@ -192,30 +203,96 @@ class QSignalDebouncer(GenericSignalThrottler):
|
||||
# below here part is unique to superqt (not from KD)
|
||||
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from typing_extensions import Protocol
|
||||
|
||||
class ThrottledCallable(Generic[P, R], Protocol):
|
||||
triggered: "SignalInstance"
|
||||
|
||||
def cancel(self) -> None:
|
||||
...
|
||||
|
||||
def flush(self) -> None:
|
||||
...
|
||||
|
||||
def set_timeout(self, timeout: int) -> None:
|
||||
...
|
||||
|
||||
if sys.version_info < (3, 9):
|
||||
|
||||
def __call__(self, *args: "P.args", **kwargs: "P.kwargs") -> Future:
|
||||
...
|
||||
class ThrottledCallable(GenericSignalThrottler, Generic[P, R]):
|
||||
def __init__(
|
||||
self,
|
||||
func: Callable[P, R],
|
||||
kind: Kind,
|
||||
emissionPolicy: EmissionPolicy,
|
||||
parent: QObject | None = None,
|
||||
) -> None:
|
||||
super().__init__(kind, emissionPolicy, parent)
|
||||
|
||||
self._future: Future[R] = Future()
|
||||
if isinstance(func, staticmethod):
|
||||
self._func = func.__func__
|
||||
else:
|
||||
self._func = func
|
||||
|
||||
def __call__(self, *args: "P.args", **kwargs: "P.kwargs") -> Future[R]:
|
||||
...
|
||||
self.__wrapped__ = func
|
||||
|
||||
self._args: tuple = ()
|
||||
self._kwargs: dict = {}
|
||||
self.triggered.connect(self._set_future_result)
|
||||
self._name = None
|
||||
|
||||
self._obj_dkt = WeakKeyDictionary()
|
||||
|
||||
# even if we were to compile __call__ with a signature matching that of func,
|
||||
# PySide wouldn't correctly inspect the signature of the ThrottledCallable
|
||||
# instance: https://bugreports.qt.io/browse/PYSIDE-2423
|
||||
# so we do it ourselfs and limit the number of positional arguments
|
||||
# that we pass to func
|
||||
self._max_args: int | None = get_max_args(self._func)
|
||||
|
||||
def __call__(self, *args: P.args, **kwargs: P.kwargs) -> "Future[R]": # noqa
|
||||
if not self._future.done():
|
||||
self._future.cancel()
|
||||
|
||||
self._future = Future()
|
||||
self._args = args
|
||||
self._kwargs = kwargs
|
||||
|
||||
self.throttle()
|
||||
return self._future
|
||||
|
||||
def _set_future_result(self):
|
||||
result = self._func(*self._args[: self._max_args], **self._kwargs)
|
||||
self._future.set_result(result)
|
||||
|
||||
def __set_name__(self, owner, name):
|
||||
if not isinstance(self.__wrapped__, staticmethod):
|
||||
self._name = name
|
||||
|
||||
def _get_throttler(self, instance, owner, parent, obj):
|
||||
throttler = ThrottledCallable(
|
||||
self.__wrapped__.__get__(instance, owner),
|
||||
self._kind,
|
||||
self._emissionPolicy,
|
||||
parent=parent,
|
||||
)
|
||||
throttler.setTimerType(self.timerType())
|
||||
throttler.setTimeout(self.timeout())
|
||||
try:
|
||||
setattr(
|
||||
obj,
|
||||
self._name,
|
||||
throttler,
|
||||
)
|
||||
except AttributeError:
|
||||
try:
|
||||
self._obj_dkt[obj] = throttler
|
||||
except TypeError as e:
|
||||
raise TypeError(
|
||||
"To use qthrottled or qdebounced as a method decorator, "
|
||||
"objects must have `__dict__` or be weak referenceable. "
|
||||
"Please either add `__weakref__` to `__slots__` or use"
|
||||
"qthrottled/qdebounced as a function (not a decorator)."
|
||||
) from e
|
||||
return throttler
|
||||
|
||||
def __get__(self, instance, owner):
|
||||
if instance is None or not self._name:
|
||||
return self
|
||||
|
||||
if instance in self._obj_dkt:
|
||||
return self._obj_dkt[instance]
|
||||
|
||||
parent = self.parent()
|
||||
if parent is None and isinstance(instance, QObject):
|
||||
parent = instance
|
||||
|
||||
return self._get_throttler(instance, owner, parent, instance)
|
||||
|
||||
|
||||
@overload
|
||||
@@ -224,28 +301,27 @@ def qthrottled(
|
||||
timeout: int = 100,
|
||||
leading: bool = True,
|
||||
timer_type: Qt.TimerType = Qt.TimerType.PreciseTimer,
|
||||
) -> "ThrottledCallable[P, R]":
|
||||
...
|
||||
parent: QObject | None = None,
|
||||
) -> ThrottledCallable[P, R]: ...
|
||||
|
||||
|
||||
@overload
|
||||
def qthrottled(
|
||||
func: "Literal[None]" = None,
|
||||
func: None = ...,
|
||||
timeout: int = 100,
|
||||
leading: bool = True,
|
||||
timer_type: Qt.TimerType = Qt.TimerType.PreciseTimer,
|
||||
) -> Callable[[Callable[P, R]], "ThrottledCallable[P, R]"]:
|
||||
...
|
||||
parent: QObject | None = None,
|
||||
) -> Callable[[Callable[P, R]], ThrottledCallable[P, R]]: ...
|
||||
|
||||
|
||||
def qthrottled(
|
||||
func: Optional[Callable[P, R]] = None,
|
||||
func: Callable[P, R] | None = None,
|
||||
timeout: int = 100,
|
||||
leading: bool = True,
|
||||
timer_type: Qt.TimerType = Qt.TimerType.PreciseTimer,
|
||||
) -> Union[
|
||||
"ThrottledCallable[P, R]", Callable[[Callable[P, R]], "ThrottledCallable[P, R]"]
|
||||
]:
|
||||
parent: QObject | None = None,
|
||||
) -> ThrottledCallable[P, R] | Callable[[Callable[P, R]], ThrottledCallable[P, R]]:
|
||||
"""Creates a throttled function that invokes func at most once per timeout.
|
||||
|
||||
The throttled function comes with a `cancel` method to cancel delayed func
|
||||
@@ -273,8 +349,11 @@ def qthrottled(
|
||||
- `Qt.CoarseTimer`: Coarse timers try to keep accuracy within 5% of the
|
||||
desired interval
|
||||
- `Qt.VeryCoarseTimer`: Very coarse timers only keep full second accuracy
|
||||
parent: QObject or None
|
||||
Parent object for timer. If using qthrottled as function it may be usefull
|
||||
for cleaning data
|
||||
"""
|
||||
return _make_decorator(func, timeout, leading, timer_type, Kind.Throttler)
|
||||
return _make_decorator(func, timeout, leading, timer_type, Kind.Throttler, parent)
|
||||
|
||||
|
||||
@overload
|
||||
@@ -283,28 +362,27 @@ def qdebounced(
|
||||
timeout: int = 100,
|
||||
leading: bool = False,
|
||||
timer_type: Qt.TimerType = Qt.TimerType.PreciseTimer,
|
||||
) -> "ThrottledCallable[P, R]":
|
||||
...
|
||||
parent: QObject | None = None,
|
||||
) -> ThrottledCallable[P, R]: ...
|
||||
|
||||
|
||||
@overload
|
||||
def qdebounced(
|
||||
func: "Literal[None]" = None,
|
||||
func: None = ...,
|
||||
timeout: int = 100,
|
||||
leading: bool = False,
|
||||
timer_type: Qt.TimerType = Qt.TimerType.PreciseTimer,
|
||||
) -> Callable[[Callable[P, R]], "ThrottledCallable[P, R]"]:
|
||||
...
|
||||
parent: QObject | None = None,
|
||||
) -> Callable[[Callable[P, R]], ThrottledCallable[P, R]]: ...
|
||||
|
||||
|
||||
def qdebounced(
|
||||
func: Optional[Callable[P, R]] = None,
|
||||
func: Callable[P, R] | None = None,
|
||||
timeout: int = 100,
|
||||
leading: bool = False,
|
||||
timer_type: Qt.TimerType = Qt.TimerType.PreciseTimer,
|
||||
) -> Union[
|
||||
"ThrottledCallable[P, R]", Callable[[Callable[P, R]], "ThrottledCallable[P, R]"]
|
||||
]:
|
||||
parent: QObject | None = None,
|
||||
) -> ThrottledCallable[P, R] | Callable[[Callable[P, R]], ThrottledCallable[P, R]]:
|
||||
"""Creates a debounced function that delays invoking `func`.
|
||||
|
||||
`func` will not be invoked until `timeout` ms have elapsed since the last time
|
||||
@@ -335,46 +413,31 @@ def qdebounced(
|
||||
- `Qt.CoarseTimer`: Coarse timers try to keep accuracy within 5% of the
|
||||
desired interval
|
||||
- `Qt.VeryCoarseTimer`: Very coarse timers only keep full second accuracy
|
||||
parent: QObject or None
|
||||
Parent object for timer. If using qthrottled as function it may be usefull
|
||||
for cleaning data
|
||||
"""
|
||||
return _make_decorator(func, timeout, leading, timer_type, Kind.Debouncer)
|
||||
return _make_decorator(func, timeout, leading, timer_type, Kind.Debouncer, parent)
|
||||
|
||||
|
||||
def _make_decorator(
|
||||
func: Optional[Callable[P, R]],
|
||||
func: Callable[P, R] | None,
|
||||
timeout: int,
|
||||
leading: bool,
|
||||
timer_type: Qt.TimerType,
|
||||
kind: Kind,
|
||||
) -> Union[
|
||||
"ThrottledCallable[P, R]", Callable[[Callable[P, R]], "ThrottledCallable[P, R]"]
|
||||
]:
|
||||
def deco(func: Callable[P, R]) -> "ThrottledCallable[P, R]":
|
||||
parent: QObject | None = None,
|
||||
) -> ThrottledCallable[P, R] | Callable[[Callable[P, R]], ThrottledCallable[P, R]]:
|
||||
def deco(func: Callable[P, R]) -> ThrottledCallable[P, R]:
|
||||
nonlocal parent
|
||||
|
||||
instance: object | None = getattr(func, "__self__", None)
|
||||
if isinstance(instance, QObject) and parent is None:
|
||||
parent = instance
|
||||
policy = EmissionPolicy.Leading if leading else EmissionPolicy.Trailing
|
||||
throttle = GenericSignalThrottler(kind, policy)
|
||||
throttle.setTimerType(timer_type)
|
||||
throttle.setTimeout(timeout)
|
||||
last_f = None
|
||||
future: Optional[Future] = None
|
||||
|
||||
@wraps(func)
|
||||
def inner(*args: "P.args", **kwargs: "P.kwargs") -> Future:
|
||||
nonlocal last_f
|
||||
nonlocal future
|
||||
if last_f is not None:
|
||||
throttle.triggered.disconnect(last_f)
|
||||
if future is not None and not future.done():
|
||||
future.cancel()
|
||||
|
||||
future = Future()
|
||||
last_f = lambda: future.set_result(func(*args, **kwargs)) # noqa
|
||||
throttle.triggered.connect(last_f)
|
||||
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)
|
||||
return inner # type: ignore
|
||||
obj = ThrottledCallable(func, kind, policy, parent=parent)
|
||||
obj.setTimerType(timer_type)
|
||||
obj.setTimeout(timeout)
|
||||
return wraps(func)(obj)
|
||||
|
||||
return deco(func) if func is not None else deco
|
||||
|
23
src/superqt/utils/_util.py
Normal file
23
src/superqt/utils/_util.py
Normal file
@@ -0,0 +1,23 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from inspect import signature
|
||||
from typing import Callable
|
||||
|
||||
|
||||
def get_max_args(func: Callable) -> int | None:
|
||||
"""Return the maximum number of positional arguments that func can accept."""
|
||||
if not callable(func):
|
||||
raise TypeError(f"{func!r} is not callable")
|
||||
|
||||
try:
|
||||
sig = signature(func)
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
max_args = 0
|
||||
for param in sig.parameters.values():
|
||||
if param.kind == param.VAR_POSITIONAL:
|
||||
return None
|
||||
if param.kind in {param.POSITIONAL_ONLY, param.POSITIONAL_OR_KEYWORD}:
|
||||
max_args += 1
|
||||
return max_args
|
162
tests/test_cmap.py
Normal file
162
tests/test_cmap.py
Normal file
@@ -0,0 +1,162 @@
|
||||
import platform
|
||||
from unittest.mock import patch
|
||||
|
||||
import numpy as np
|
||||
import pytest
|
||||
from qtpy import API_NAME
|
||||
|
||||
try:
|
||||
from cmap import Colormap
|
||||
except ImportError:
|
||||
pytest.skip("cmap not installed", allow_module_level=True)
|
||||
|
||||
from qtpy.QtCore import QRect
|
||||
from qtpy.QtGui import QPainter, QPixmap
|
||||
from qtpy.QtWidgets import QStyleOptionViewItem, QWidget
|
||||
|
||||
from superqt import QColormapComboBox
|
||||
from superqt.cmap import (
|
||||
CmapCatalogComboBox,
|
||||
QColormapItemDelegate,
|
||||
QColormapLineEdit,
|
||||
_cmap_combo,
|
||||
draw_colormap,
|
||||
)
|
||||
from superqt.utils import qimage_to_array
|
||||
|
||||
|
||||
def test_draw_cmap(qtbot):
|
||||
# draw into a QWidget
|
||||
wdg = QWidget()
|
||||
qtbot.addWidget(wdg)
|
||||
draw_colormap(wdg, "viridis")
|
||||
# draw into any QPaintDevice
|
||||
draw_colormap(QPixmap(), "viridis")
|
||||
# pass a painter an explicit colormap and a rect
|
||||
draw_colormap(QPainter(), Colormap(("red", "yellow", "blue")), QRect())
|
||||
# test with a border
|
||||
draw_colormap(wdg, "viridis", border_color="red", border_width=2)
|
||||
|
||||
with pytest.raises(TypeError, match="Expected a QPainter or QPaintDevice instance"):
|
||||
draw_colormap(QRect(), "viridis") # type: ignore
|
||||
|
||||
with pytest.raises(TypeError, match="Expected a Colormap instance or something"):
|
||||
draw_colormap(QPainter(), "not a recognized string or cmap", QRect())
|
||||
|
||||
|
||||
def test_cmap_draw_result():
|
||||
"""Test that the image drawn actually looks correct."""
|
||||
# draw into any QPaintDevice
|
||||
w = 100
|
||||
h = 20
|
||||
pix = QPixmap(w, h)
|
||||
cmap = Colormap("viridis")
|
||||
draw_colormap(pix, cmap)
|
||||
|
||||
ary1 = cmap(np.tile(np.linspace(0, 1, w), (h, 1)), bytes=True)
|
||||
ary2 = qimage_to_array(pix.toImage())
|
||||
|
||||
# there are some subtle differences between how qimage draws and how
|
||||
# cmap draws, so we can't assert that the arrays are exactly equal.
|
||||
# they are visually indistinguishable, and numbers are close within 4 (/255) values
|
||||
# and linux, for some reason, is a bit more different``
|
||||
atol = 8 if platform.system() == "Linux" else 4
|
||||
np.testing.assert_allclose(ary1, ary2, atol=atol)
|
||||
|
||||
cmap2 = Colormap(("#230777",), name="MyMap")
|
||||
draw_colormap(pix, cmap2) # include transparency
|
||||
|
||||
|
||||
def test_catalog_combo(qtbot):
|
||||
wdg = CmapCatalogComboBox()
|
||||
qtbot.addWidget(wdg)
|
||||
wdg.show()
|
||||
|
||||
wdg.setCurrentText("viridis")
|
||||
assert wdg.currentColormap() == Colormap("viridis")
|
||||
|
||||
|
||||
def test_cmap_combo(qtbot):
|
||||
wdg = QColormapComboBox(allow_user_colormaps=True)
|
||||
qtbot.addWidget(wdg)
|
||||
wdg.show()
|
||||
assert wdg.userAdditionsAllowed()
|
||||
|
||||
with qtbot.waitSignal(wdg.currentColormapChanged):
|
||||
wdg.addColormaps([Colormap("viridis"), "magma", ("red", "blue", "green")])
|
||||
assert wdg.currentColormap().name.split(":")[-1] == "viridis"
|
||||
|
||||
with pytest.raises(ValueError, match="Invalid colormap"):
|
||||
wdg.addColormap("not a recognized string or cmap")
|
||||
|
||||
assert wdg.currentColormap().name.split(":")[-1] == "viridis"
|
||||
assert wdg.currentIndex() == 0
|
||||
assert wdg.count() == 4 # includes "Add Colormap..."
|
||||
wdg.setCurrentColormap("magma")
|
||||
assert wdg.count() == 4 # make sure we didn't duplicate
|
||||
assert wdg.currentIndex() == 1
|
||||
|
||||
if API_NAME == "PySide2":
|
||||
return # the rest fails on CI... but works locally
|
||||
|
||||
# click the Add Colormap... item
|
||||
with qtbot.waitSignal(wdg.currentColormapChanged):
|
||||
with patch.object(_cmap_combo._CmapNameDialog, "exec", return_value=True):
|
||||
wdg._on_activated(wdg.count() - 1)
|
||||
|
||||
assert wdg.count() == 5
|
||||
# this could potentially fail in the future if cmap catalog changes
|
||||
# but mocking the return value of the dialog is also annoying
|
||||
assert wdg.itemColormap(3).name.split(":")[-1] == "accent"
|
||||
|
||||
# click the Add Colormap... item, but cancel the dialog
|
||||
with patch.object(_cmap_combo._CmapNameDialog, "exec", return_value=False):
|
||||
wdg._on_activated(wdg.count() - 1)
|
||||
|
||||
|
||||
def test_cmap_item_delegate(qtbot):
|
||||
wdg = CmapCatalogComboBox()
|
||||
qtbot.addWidget(wdg)
|
||||
view = wdg.view()
|
||||
delegate = view.itemDelegate()
|
||||
assert isinstance(delegate, QColormapItemDelegate)
|
||||
|
||||
# smoke tests:
|
||||
painter = QPainter()
|
||||
option = QStyleOptionViewItem()
|
||||
index = wdg.model().index(0, 0)
|
||||
delegate._colormap_fraction = 1
|
||||
delegate.paint(painter, option, index)
|
||||
delegate._colormap_fraction = 0.33
|
||||
delegate.paint(painter, option, index)
|
||||
|
||||
assert delegate.sizeHint(option, index) == delegate._item_size
|
||||
|
||||
|
||||
def test_cmap_line_edit(qtbot, qapp):
|
||||
wdg = QColormapLineEdit()
|
||||
qtbot.addWidget(wdg)
|
||||
wdg.show()
|
||||
|
||||
wdg.setColormap("viridis")
|
||||
assert wdg.colormap() == Colormap("viridis")
|
||||
wdg.setText("magma") # also works if the name is recognized
|
||||
assert wdg.colormap() == Colormap("magma")
|
||||
qapp.processEvents()
|
||||
qtbot.wait(10) # force the paintEvent
|
||||
|
||||
wdg.setFractionalColormapWidth(1)
|
||||
assert wdg.fractionalColormapWidth() == 1
|
||||
wdg.update()
|
||||
qapp.processEvents()
|
||||
qtbot.wait(10) # force the paintEvent
|
||||
|
||||
wdg.setText("not-a-cmap")
|
||||
assert wdg.colormap() is None
|
||||
# or
|
||||
|
||||
wdg.setFractionalColormapWidth(0.3)
|
||||
wdg.setColormap(None)
|
||||
assert wdg.colormap() is None
|
||||
qapp.processEvents()
|
||||
qtbot.wait(10) # force the paintEvent
|
@@ -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
|
||||
|
86
tests/test_color_combo.py
Normal file
86
tests/test_color_combo.py
Normal file
@@ -0,0 +1,86 @@
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
from qtpy import API_NAME
|
||||
from qtpy.QtGui import QColor, QPainter
|
||||
from qtpy.QtWidgets import QStyleOptionViewItem
|
||||
|
||||
from superqt import QColorComboBox
|
||||
from superqt.combobox import _color_combobox
|
||||
|
||||
|
||||
def test_q_color_combobox(qtbot):
|
||||
wdg = QColorComboBox()
|
||||
qtbot.addWidget(wdg)
|
||||
wdg.show()
|
||||
wdg.setUserColorsAllowed(True)
|
||||
|
||||
# colors can be any argument that can be passed to QColor
|
||||
# (tuples and lists will be expanded to QColor(*color)
|
||||
COLORS = [QColor("red"), "orange", (255, 255, 0), "green", "#00F", "indigo"]
|
||||
wdg.addColors(COLORS)
|
||||
|
||||
colors = [wdg.itemColor(i) for i in range(wdg.count())]
|
||||
assert colors == [
|
||||
QColor("red"),
|
||||
QColor("orange"),
|
||||
QColor("yellow"),
|
||||
QColor("green"),
|
||||
QColor("blue"),
|
||||
QColor("indigo"),
|
||||
None, # "Add Color" item
|
||||
]
|
||||
|
||||
# as with addColors, colors will be cast to QColor when using setColors
|
||||
wdg.setCurrentColor("indigo")
|
||||
assert wdg.currentColor() == QColor("indigo")
|
||||
assert wdg.currentColorName() == "#4b0082"
|
||||
|
||||
wdg.clear()
|
||||
assert wdg.count() == 1 # "Add Color" item
|
||||
wdg.setUserColorsAllowed(False)
|
||||
assert not wdg.count()
|
||||
|
||||
wdg.setInvalidColorPolicy(wdg.InvalidColorPolicy.Ignore)
|
||||
wdg.setInvalidColorPolicy(2)
|
||||
wdg.setInvalidColorPolicy("Raise")
|
||||
with pytest.raises(TypeError):
|
||||
wdg.setInvalidColorPolicy(1.0) # type: ignore
|
||||
|
||||
with pytest.raises(ValueError):
|
||||
wdg.addColor("invalid")
|
||||
|
||||
|
||||
def test_q_color_delegate(qtbot):
|
||||
wdg = QColorComboBox()
|
||||
view = wdg.view()
|
||||
delegate = wdg.itemDelegate()
|
||||
qtbot.addWidget(wdg)
|
||||
wdg.show()
|
||||
|
||||
# smoke tests:
|
||||
painter = QPainter()
|
||||
option = QStyleOptionViewItem()
|
||||
index = wdg.model().index(0, 0)
|
||||
delegate.paint(painter, option, index)
|
||||
|
||||
wdg.addColors(["red", "orange", "yellow"])
|
||||
view.selectAll()
|
||||
index = wdg.model().index(1, 0)
|
||||
delegate.paint(painter, option, index)
|
||||
|
||||
|
||||
@pytest.mark.skipif(API_NAME == "PySide2", reason="hangs on CI")
|
||||
def test_activated(qtbot):
|
||||
wdg = QColorComboBox()
|
||||
qtbot.addWidget(wdg)
|
||||
wdg.show()
|
||||
wdg.setUserColorsAllowed(True)
|
||||
|
||||
with patch.object(_color_combobox.QColorDialog, "getColor", lambda: QColor("red")):
|
||||
wdg._on_activated(wdg.count() - 1) # "Add Color" item
|
||||
assert wdg.currentColor() == QColor("red")
|
||||
|
||||
with patch.object(_color_combobox.QColorDialog, "getColor", lambda: QColor()):
|
||||
wdg._on_activated(wdg.count() - 1) # "Add Color" item
|
||||
assert wdg.currentColor() == QColor("red")
|
@@ -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))
|
||||
|
60
tests/test_eliding_line_edit.py
Normal file
60
tests/test_eliding_line_edit.py
Normal 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)
|
@@ -1,7 +1,10 @@
|
||||
import inspect
|
||||
import os
|
||||
import threading
|
||||
import time
|
||||
from concurrent.futures import Future, TimeoutError
|
||||
from functools import wraps
|
||||
from unittest.mock import Mock
|
||||
|
||||
import pytest
|
||||
from qtpy.QtCore import QCoreApplication, QObject, QThread, Signal
|
||||
@@ -158,8 +161,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"
|
||||
|
||||
|
||||
@@ -217,3 +220,80 @@ def test_object_thread(qtbot):
|
||||
assert ob.thread() is thread
|
||||
with qtbot.waitSignal(thread.finished):
|
||||
thread.quit()
|
||||
|
||||
|
||||
@pytest.mark.parametrize("mode", ["method", "func", "wrapped"])
|
||||
@pytest.mark.parametrize("deco", [ensure_main_thread, ensure_object_thread])
|
||||
def test_ensure_thread_sig_inspection(deco, mode):
|
||||
class Emitter(QObject):
|
||||
sig = Signal(int, int, int)
|
||||
|
||||
obj = Emitter()
|
||||
mock = Mock()
|
||||
|
||||
if mode == "method":
|
||||
|
||||
class Receiver(QObject):
|
||||
@deco
|
||||
def func(self, a: int, b: int):
|
||||
mock(a, b)
|
||||
|
||||
r = Receiver()
|
||||
obj.sig.connect(r.func)
|
||||
elif deco == ensure_object_thread:
|
||||
return # not compatible with function types
|
||||
|
||||
elif mode == "wrapped":
|
||||
|
||||
def wr(fun):
|
||||
@wraps(fun)
|
||||
def wr2(*args):
|
||||
mock(*args)
|
||||
return fun(*args) * 2
|
||||
|
||||
return wr2
|
||||
|
||||
@deco
|
||||
@wr
|
||||
def wrapped_func(a, b):
|
||||
return a + b
|
||||
|
||||
obj.sig.connect(wrapped_func)
|
||||
|
||||
elif mode == "func":
|
||||
|
||||
@deco
|
||||
def func(a: int, b: int) -> None:
|
||||
mock(a, b)
|
||||
|
||||
obj.sig.connect(func)
|
||||
|
||||
# this is the crux of the test...
|
||||
# we emit 3 args, but the function only takes 2
|
||||
# this should normally work fine in Qt.
|
||||
# testing here that the decorator doesn't break it.
|
||||
obj.sig.emit(1, 2, 3)
|
||||
mock.assert_called_once_with(1, 2)
|
||||
|
||||
|
||||
def test_main_thread_function(qtbot):
|
||||
"""Testing decorator on a function rather than QObject method."""
|
||||
|
||||
mock = Mock()
|
||||
|
||||
class Emitter(QObject):
|
||||
sig = Signal(int, int, int)
|
||||
|
||||
@ensure_main_thread
|
||||
def func(x: int) -> None:
|
||||
mock(x, QThread.currentThread())
|
||||
|
||||
e = Emitter()
|
||||
e.sig.connect(func)
|
||||
|
||||
with qtbot.waitSignal(e.sig):
|
||||
thread = threading.Thread(target=e.sig.emit, args=(1, 2, 3))
|
||||
thread.start()
|
||||
thread.join()
|
||||
|
||||
mock.assert_called_once_with(1, QCoreApplication.instance().thread())
|
||||
|
@@ -1,4 +1,5 @@
|
||||
from enum import Enum
|
||||
import sys
|
||||
from enum import Enum, Flag, IntEnum, IntFlag
|
||||
|
||||
import pytest
|
||||
|
||||
@@ -11,6 +12,8 @@ class Enum1(Enum):
|
||||
b = 2
|
||||
c = 3
|
||||
|
||||
ALIAS = a
|
||||
|
||||
|
||||
class Enum2(Enum):
|
||||
d = 1
|
||||
@@ -34,6 +37,42 @@ class Enum4(Enum):
|
||||
c_3 = 3
|
||||
|
||||
|
||||
class IntEnum1(IntEnum):
|
||||
a = 1
|
||||
b = 2
|
||||
c = 5
|
||||
|
||||
|
||||
class IntFlag1(IntFlag):
|
||||
a = 1
|
||||
b = 2
|
||||
c = 4
|
||||
|
||||
|
||||
class Flag1(Flag):
|
||||
a = 1
|
||||
b = 2
|
||||
c = 4
|
||||
|
||||
|
||||
class IntFlag2(IntFlag):
|
||||
a = 1
|
||||
b = 2
|
||||
c = 3
|
||||
|
||||
|
||||
class Flag2(IntFlag):
|
||||
a = 1
|
||||
b = 2
|
||||
c = 5
|
||||
|
||||
|
||||
class FlagOrNum(IntFlag):
|
||||
a = 3
|
||||
b = 5
|
||||
c = 8
|
||||
|
||||
|
||||
def test_simple_create(qtbot):
|
||||
enum = QEnumComboBox(enum_class=Enum1)
|
||||
qtbot.addWidget(enum)
|
||||
@@ -127,3 +166,65 @@ def test_optional(qtbot):
|
||||
enum.setCurrentEnum(None)
|
||||
assert enum.currentText() == NONE_STRING
|
||||
assert enum.currentEnum() is None
|
||||
|
||||
|
||||
def test_simple_create_int_enum(qtbot):
|
||||
enum = QEnumComboBox(enum_class=IntEnum1)
|
||||
qtbot.addWidget(enum)
|
||||
assert [enum.itemText(i) for i in range(enum.count())] == ["a", "b", "c"]
|
||||
|
||||
|
||||
@pytest.mark.parametrize("enum_class", [IntFlag1, Flag1])
|
||||
def test_enum_flag_create(qtbot, enum_class):
|
||||
enum = QEnumComboBox(enum_class=enum_class)
|
||||
qtbot.addWidget(enum)
|
||||
assert [enum.itemText(i) for i in range(enum.count())] == [
|
||||
"a",
|
||||
"b",
|
||||
"c",
|
||||
"a|b",
|
||||
"a|c",
|
||||
"b|c",
|
||||
"a|b|c",
|
||||
]
|
||||
enum.setCurrentText("a|b")
|
||||
assert enum.currentEnum() == enum_class.a | enum_class.b
|
||||
|
||||
|
||||
def test_enum_flag_create_collision(qtbot):
|
||||
enum = QEnumComboBox(enum_class=IntFlag2)
|
||||
qtbot.addWidget(enum)
|
||||
assert [enum.itemText(i) for i in range(enum.count())] == ["a", "b", "c"]
|
||||
|
||||
|
||||
@pytest.mark.skipif(
|
||||
sys.version_info >= (3, 11), reason="different representation in 3.11"
|
||||
)
|
||||
def test_enum_flag_create_collision_evaluated_to_seven(qtbot):
|
||||
enum = QEnumComboBox(enum_class=FlagOrNum)
|
||||
qtbot.addWidget(enum)
|
||||
assert [enum.itemText(i) for i in range(enum.count())] == [
|
||||
"a",
|
||||
"b",
|
||||
"c",
|
||||
"a|b",
|
||||
"a|c",
|
||||
"b|c",
|
||||
"a|b|c",
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.skipif(
|
||||
sys.version_info < (3, 11), reason="StrEnum is introduced in python 3.11"
|
||||
)
|
||||
def test_create_str_enum(qtbot):
|
||||
from enum import StrEnum
|
||||
|
||||
class StrEnum1(StrEnum):
|
||||
a = "a"
|
||||
b = "b"
|
||||
c = "c"
|
||||
|
||||
enum = QEnumComboBox(enum_class=StrEnum1)
|
||||
qtbot.addWidget(enum)
|
||||
assert [enum.itemText(i) for i in range(enum.count())] == ["a", "b", "c"]
|
||||
|
@@ -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)
|
||||
|
23
tests/test_iconify.py
Normal file
23
tests/test_iconify.py
Normal file
@@ -0,0 +1,23 @@
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
import pytest
|
||||
from qtpy.QtGui import QIcon
|
||||
from qtpy.QtWidgets import QPushButton
|
||||
|
||||
from superqt import QIconifyIcon
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from pytestqt.qtbot import QtBot
|
||||
|
||||
|
||||
def test_qiconify(qtbot: "QtBot", monkeypatch: "pytest.MonkeyPatch") -> None:
|
||||
monkeypatch.setenv("PYCONIFY_CACHE", "0")
|
||||
pytest.importorskip("pyconify")
|
||||
|
||||
icon = QIconifyIcon("bi:alarm-fill", color="red", flip="vertical")
|
||||
icon.addKey("bi:alarm", color="blue", rotate=90, state=QIcon.State.On)
|
||||
|
||||
btn = QPushButton()
|
||||
qtbot.addWidget(btn)
|
||||
btn.setIcon(icon)
|
||||
btn.show()
|
@@ -22,6 +22,24 @@ def test_large_spinbox(qtbot):
|
||||
assert sb.value() == -(10**e)
|
||||
|
||||
|
||||
def test_large_spinbox_range(qtbot):
|
||||
sb = QLargeIntSpinBox()
|
||||
qtbot.addWidget(sb)
|
||||
sb.setRange(-100, 100)
|
||||
sb.setValue(50)
|
||||
|
||||
sb.setRange(-10, 10)
|
||||
assert sb.value() == 10
|
||||
|
||||
sb.setRange(100, 1000)
|
||||
assert sb.value() == 100
|
||||
|
||||
sb.setRange(50, 0)
|
||||
assert sb.minimum() == 50
|
||||
assert sb.maximum() == 50
|
||||
assert sb.value() == 50
|
||||
|
||||
|
||||
def test_large_spinbox_type(qtbot):
|
||||
sb = QLargeIntSpinBox()
|
||||
qtbot.addWidget(sb)
|
||||
@@ -72,3 +90,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
|
||||
|
@@ -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
|
||||
|
@@ -1,3 +1,5 @@
|
||||
from pint import Quantity
|
||||
|
||||
from superqt import QQuantity
|
||||
|
||||
|
||||
@@ -29,3 +31,11 @@ def test_qquantity(qtbot):
|
||||
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")
|
||||
|
151
tests/test_searchable_tree.py
Normal file
151
tests/test_searchable_tree.py
Normal 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")
|
@@ -1,4 +1,5 @@
|
||||
import inspect
|
||||
import threading
|
||||
import time
|
||||
import warnings
|
||||
from functools import partial
|
||||
@@ -98,9 +99,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 +237,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):
|
||||
@@ -280,15 +281,20 @@ def test_abort_does_not_return(qtbot):
|
||||
def test_nested_threads_start(qtbot):
|
||||
mock1 = Mock()
|
||||
mock2 = Mock()
|
||||
event = threading.Event()
|
||||
|
||||
def call_mock(_e=event):
|
||||
def nested_func():
|
||||
mock2()
|
||||
_e.set()
|
||||
|
||||
def call_mock():
|
||||
mock1()
|
||||
worker2 = qthreading.create_worker(mock2)
|
||||
worker2 = qthreading.create_worker(nested_func)
|
||||
worker2.start()
|
||||
|
||||
worker = qthreading.create_worker(call_mock)
|
||||
worker.start()
|
||||
|
||||
qtbot.wait(20)
|
||||
event.wait(timeout=2)
|
||||
mock1.assert_called_once()
|
||||
mock2.assert_called_once()
|
||||
|
@@ -1,6 +1,10 @@
|
||||
from unittest.mock import Mock
|
||||
|
||||
import pytest
|
||||
from qtpy.QtCore import QObject, Signal
|
||||
|
||||
from superqt.utils import qdebounced, qthrottled
|
||||
from superqt.utils._throttler import ThrottledCallable
|
||||
|
||||
|
||||
def test_debounced(qtbot):
|
||||
@@ -23,6 +27,136 @@ def test_debounced(qtbot):
|
||||
assert mock2.call_count == 10
|
||||
|
||||
|
||||
@pytest.mark.usefixtures("qapp")
|
||||
def test_stop_timer_simple():
|
||||
mock = Mock()
|
||||
|
||||
@qdebounced(timeout=5)
|
||||
def f1() -> str:
|
||||
mock()
|
||||
|
||||
f1()
|
||||
assert f1._timer.isActive()
|
||||
mock.assert_not_called()
|
||||
f1.flush(restart_timer=False)
|
||||
assert not f1._timer.isActive()
|
||||
mock.assert_called_once()
|
||||
|
||||
|
||||
@pytest.mark.usefixtures("qapp")
|
||||
def test_stop_timer_no_event_pending():
|
||||
mock = Mock()
|
||||
|
||||
@qdebounced(timeout=5)
|
||||
def f1() -> str:
|
||||
mock()
|
||||
|
||||
f1()
|
||||
assert f1._timer.isActive()
|
||||
mock.assert_not_called()
|
||||
f1.flush()
|
||||
assert f1._timer.isActive()
|
||||
mock.assert_called_once()
|
||||
f1.flush(restart_timer=False)
|
||||
assert not f1._timer.isActive()
|
||||
mock.assert_called_once()
|
||||
|
||||
|
||||
def test_debouncer_method(qtbot):
|
||||
class A(QObject):
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self.count = 0
|
||||
|
||||
def callback(self):
|
||||
self.count += 1
|
||||
|
||||
a = A()
|
||||
assert all(not isinstance(x, ThrottledCallable) for x in a.children())
|
||||
b = qdebounced(a.callback, timeout=4)
|
||||
assert any(isinstance(x, ThrottledCallable) for x in a.children())
|
||||
for _ in range(10):
|
||||
b()
|
||||
|
||||
qtbot.wait(5)
|
||||
|
||||
assert a.count == 1
|
||||
|
||||
|
||||
def test_debouncer_method_definition(qtbot):
|
||||
mock1 = Mock()
|
||||
mock2 = Mock()
|
||||
|
||||
class A(QObject):
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self.count = 0
|
||||
|
||||
@qdebounced(timeout=4)
|
||||
def callback(self):
|
||||
self.count += 1
|
||||
|
||||
@qdebounced(timeout=4)
|
||||
@staticmethod
|
||||
def call1():
|
||||
mock1()
|
||||
|
||||
@staticmethod
|
||||
@qdebounced(timeout=4)
|
||||
def call2():
|
||||
mock2()
|
||||
|
||||
a = A()
|
||||
assert all(not isinstance(x, ThrottledCallable) for x in a.children())
|
||||
for _ in range(10):
|
||||
a.callback(1)
|
||||
A.call1(34)
|
||||
a.call1(22)
|
||||
a.call2(22)
|
||||
A.call2(32)
|
||||
|
||||
qtbot.wait(5)
|
||||
|
||||
assert a.count == 1
|
||||
mock1.assert_called_once()
|
||||
mock2.assert_called_once()
|
||||
|
||||
|
||||
def test_class_with_slots(qtbot):
|
||||
class A:
|
||||
__slots__ = ("count", "__weakref__")
|
||||
|
||||
def __init__(self):
|
||||
self.count = 0
|
||||
|
||||
@qdebounced(timeout=4)
|
||||
def callback(self):
|
||||
self.count += 1
|
||||
|
||||
a = A()
|
||||
for _ in range(10):
|
||||
a.callback()
|
||||
|
||||
qtbot.wait(5)
|
||||
assert a.count == 1
|
||||
|
||||
|
||||
@pytest.mark.usefixtures("qapp")
|
||||
def test_class_with_slots_except():
|
||||
class A:
|
||||
__slots__ = ("count",)
|
||||
|
||||
def __init__(self):
|
||||
self.count = 0
|
||||
|
||||
@qdebounced(timeout=4)
|
||||
def callback(self):
|
||||
self.count += 1
|
||||
|
||||
with pytest.raises(TypeError, match="To use qthrottled or qdebounced"):
|
||||
A().callback()
|
||||
|
||||
|
||||
def test_throttled(qtbot):
|
||||
mock1 = Mock()
|
||||
mock2 = Mock()
|
||||
@@ -41,3 +175,29 @@ def test_throttled(qtbot):
|
||||
qtbot.wait(5)
|
||||
assert mock1.call_count == 2
|
||||
assert mock2.call_count == 10
|
||||
|
||||
|
||||
@pytest.mark.parametrize("deco", [qthrottled, qdebounced])
|
||||
def test_ensure_throttled_sig_inspection(deco, qtbot):
|
||||
mock = Mock()
|
||||
|
||||
class Emitter(QObject):
|
||||
sig = Signal(int, int, int)
|
||||
|
||||
@deco
|
||||
def func(a: int, b: int):
|
||||
"""docstring"""
|
||||
mock(a, b)
|
||||
|
||||
obj = Emitter()
|
||||
obj.sig.connect(func)
|
||||
|
||||
# this is the crux of the test...
|
||||
# we emit 3 args, but the function only takes 2
|
||||
# this should normally work fine in Qt.
|
||||
# testing here that the decorator doesn't break it.
|
||||
with qtbot.waitSignal(func.triggered, timeout=1000):
|
||||
obj.sig.emit(1, 2, 3)
|
||||
mock.assert_called_once_with(1, 2)
|
||||
assert func.__doc__ == "docstring"
|
||||
assert func.__name__ == "func"
|
||||
|
@@ -1,8 +1,14 @@
|
||||
import os
|
||||
import sys
|
||||
from unittest.mock import Mock
|
||||
|
||||
from qtpy.QtCore import QObject, Signal
|
||||
import pytest
|
||||
import qtpy
|
||||
from qtpy.QtCore import QObject, QTimer, Signal
|
||||
from qtpy.QtWidgets import QApplication, QErrorMessage, QMessageBox
|
||||
|
||||
from superqt.utils import signals_blocked
|
||||
from superqt.utils import exceptions_as_dialog, signals_blocked
|
||||
from superqt.utils._util import get_max_args
|
||||
|
||||
|
||||
def test_signal_blocker(qtbot):
|
||||
@@ -27,3 +33,109 @@ def test_signal_blocker(qtbot):
|
||||
qtbot.wait(10)
|
||||
|
||||
receiver.assert_not_called()
|
||||
|
||||
|
||||
def test_get_max_args_simple():
|
||||
def fun1():
|
||||
pass
|
||||
|
||||
assert get_max_args(fun1) == 0
|
||||
|
||||
def fun2(a):
|
||||
pass
|
||||
|
||||
assert get_max_args(fun2) == 1
|
||||
|
||||
def fun3(a, b=1):
|
||||
pass
|
||||
|
||||
assert get_max_args(fun3) == 2
|
||||
|
||||
def fun4(a, *, b=2):
|
||||
pass
|
||||
|
||||
assert get_max_args(fun4) == 1
|
||||
|
||||
def fun5(a, *b):
|
||||
pass
|
||||
|
||||
assert get_max_args(fun5) is None
|
||||
|
||||
assert get_max_args(print) is None
|
||||
|
||||
|
||||
def test_get_max_args_wrapped():
|
||||
from functools import partial, wraps
|
||||
|
||||
def fun1(a, b):
|
||||
pass
|
||||
|
||||
assert get_max_args(partial(fun1, 1)) == 1
|
||||
|
||||
def dec(fun):
|
||||
@wraps(fun)
|
||||
def wrapper(*args, **kwargs):
|
||||
return fun(*args, **kwargs)
|
||||
|
||||
return wrapper
|
||||
|
||||
assert get_max_args(dec(fun1)) == 2
|
||||
|
||||
|
||||
def test_get_max_args_methods():
|
||||
class A:
|
||||
def fun1(self):
|
||||
pass
|
||||
|
||||
def fun2(self, a):
|
||||
pass
|
||||
|
||||
def __call__(self, a, b=1):
|
||||
pass
|
||||
|
||||
assert get_max_args(A().fun1) == 0
|
||||
assert get_max_args(A().fun2) == 1
|
||||
assert get_max_args(A()) == 2
|
||||
|
||||
|
||||
MAC_CI_PYSIDE6 = bool(
|
||||
sys.platform == "darwin" and os.getenv("CI") and qtpy.API_NAME == "PySide6"
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.skipif(MAC_CI_PYSIDE6, reason="still hangs on mac ci with pyside6")
|
||||
def test_exception_context(qtbot, qapp: QApplication) -> None:
|
||||
def accept():
|
||||
for wdg in qapp.topLevelWidgets():
|
||||
if isinstance(wdg, QMessageBox):
|
||||
wdg.button(QMessageBox.StandardButton.Ok).click()
|
||||
|
||||
with exceptions_as_dialog():
|
||||
QTimer.singleShot(0, accept)
|
||||
raise Exception("This will be caught and shown in a QMessageBox")
|
||||
|
||||
with pytest.raises(ZeroDivisionError), exceptions_as_dialog(ValueError):
|
||||
1 / 0 # noqa
|
||||
|
||||
with exceptions_as_dialog(msg_template="Error: {exc_value}"):
|
||||
QTimer.singleShot(0, accept)
|
||||
raise Exception("This message will be used as 'exc_value'")
|
||||
|
||||
err = QErrorMessage()
|
||||
with exceptions_as_dialog(use_error_message=err):
|
||||
QTimer.singleShot(0, err.accept)
|
||||
raise AssertionError("Uncheck the checkbox to ignore this in the future")
|
||||
|
||||
# tb formatting smoke test, and return value checking
|
||||
exc = ValueError("Bad Val")
|
||||
with exceptions_as_dialog(
|
||||
msg_template="{tb}",
|
||||
buttons=QMessageBox.StandardButton.Ok | QMessageBox.StandardButton.Cancel,
|
||||
) as ctx:
|
||||
qtbot.addWidget(ctx.dialog)
|
||||
QTimer.singleShot(100, accept)
|
||||
raise exc
|
||||
|
||||
assert isinstance(ctx.dialog, QMessageBox)
|
||||
assert ctx.dialog.result() == QMessageBox.StandardButton.Ok
|
||||
assert ctx.exception is exc
|
||||
|
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user