mirror of
https://github.com/pyapp-kit/superqt.git
synced 2025-07-23 05:01:07 +02:00
Compare commits
51 Commits
Author | SHA1 | Date | |
---|---|---|---|
|
17ad1079a8 | ||
|
6bb050c499 | ||
|
1f4d9081b9 | ||
|
7b1aefd119 | ||
|
0ec5cd3a2f | ||
|
8f62b0b00d | ||
|
4a0aaca2e9 | ||
|
2d49e77c3d | ||
|
ba495a5e72 | ||
|
12f10be8da | ||
|
9ca0bbf858 | ||
|
0ab6758972 | ||
|
d2bc3d898c | ||
|
1bb1a58a73 | ||
|
1288250597 | ||
|
34a776e8d0 | ||
|
146644e105 | ||
|
e7873ad93d | ||
|
0396d465e2 | ||
|
4bf73c37f1 | ||
|
d407af2089 | ||
|
16f9ef9d3d | ||
|
56f65ff123 | ||
|
60188de52e | ||
|
b4d3a4f9b7 | ||
|
95b1178647 | ||
|
ef87685626 | ||
|
b927159f49 | ||
|
61e7409b1c | ||
|
c9103e3dd8 | ||
|
570c261368 | ||
|
bd6899133f | ||
|
3efafd7aa8 | ||
|
0fd25aa665 | ||
|
a5740f0109 | ||
|
65a4a6e17c | ||
|
6f74c6905e | ||
|
d8211493ab | ||
|
1c80109e92 | ||
|
0b984c21e8 | ||
|
50bff8ea61 | ||
|
830fe38fb9 | ||
|
409d19e5c2 | ||
|
df2034d5dc | ||
|
bace50fbb8 | ||
|
66da7113e9 | ||
|
717b7e3d96 | ||
|
1e3cc27686 | ||
|
658995a0b4 | ||
|
60f442789f | ||
|
6993c88311 |
2
.github/ISSUE_TEMPLATE/bug_report.md
vendored
2
.github/ISSUE_TEMPLATE/bug_report.md
vendored
@@ -2,7 +2,7 @@
|
|||||||
name: Bug report
|
name: Bug report
|
||||||
about: Create a report to help us improve
|
about: Create a report to help us improve
|
||||||
title: ''
|
title: ''
|
||||||
labels: ''
|
labels: 'bug'
|
||||||
assignees: ''
|
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: ''
|
||||||
|
---
|
144
.github/workflows/test_and_deploy.yml
vendored
144
.github/workflows/test_and_deploy.yml
vendored
@@ -6,23 +6,27 @@ concurrency:
|
|||||||
|
|
||||||
on:
|
on:
|
||||||
push:
|
push:
|
||||||
branches:
|
branches: [main]
|
||||||
- main
|
tags: [v*]
|
||||||
tags:
|
|
||||||
- "v*" # Push events to matching v*, i.e. v1.0, v20.15.10
|
|
||||||
pull_request:
|
pull_request:
|
||||||
branches:
|
|
||||||
- main
|
|
||||||
workflow_dispatch:
|
workflow_dispatch:
|
||||||
|
schedule:
|
||||||
|
- cron: "0 0 * * 0" # run weekly
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
test:
|
test:
|
||||||
name: ${{ matrix.platform }} py${{ matrix.python-version }} ${{ matrix.backend }}
|
name: Test
|
||||||
runs-on: ${{ matrix.platform }}
|
uses: pyapp-kit/workflows/.github/workflows/test-pyrepo.yml@v2
|
||||||
|
with:
|
||||||
|
os: ${{ matrix.platform }}
|
||||||
|
python-version: ${{ matrix.python-version }}
|
||||||
|
qt: ${{ matrix.backend }}
|
||||||
|
pip-install-pre-release: ${{ github.event_name == 'schedule' }}
|
||||||
|
coverage-upload: artifact
|
||||||
strategy:
|
strategy:
|
||||||
fail-fast: false
|
fail-fast: false
|
||||||
matrix:
|
matrix:
|
||||||
platform: [ubuntu-latest, windows-latest, macos-latest]
|
platform: [ubuntu-latest, windows-latest, macos-13]
|
||||||
python-version: ["3.8", "3.9", "3.10", "3.11"]
|
python-version: ["3.8", "3.9", "3.10", "3.11"]
|
||||||
backend: [pyqt5, pyside2, pyqt6]
|
backend: [pyqt5, pyside2, pyqt6]
|
||||||
exclude:
|
exclude:
|
||||||
@@ -32,7 +36,6 @@ jobs:
|
|||||||
# lack of wheels for pyside2/py3.11
|
# lack of wheels for pyside2/py3.11
|
||||||
- python-version: "3.11"
|
- python-version: "3.11"
|
||||||
backend: pyside2
|
backend: pyside2
|
||||||
|
|
||||||
include:
|
include:
|
||||||
- python-version: "3.10"
|
- python-version: "3.10"
|
||||||
platform: macos-latest
|
platform: macos-latest
|
||||||
@@ -46,7 +49,9 @@ jobs:
|
|||||||
- python-version: "3.11"
|
- python-version: "3.11"
|
||||||
platform: windows-latest
|
platform: windows-latest
|
||||||
backend: pyside6
|
backend: pyside6
|
||||||
|
- python-version: "3.12"
|
||||||
|
platform: macos-latest
|
||||||
|
backend: pyqt6
|
||||||
# legacy Qt
|
# legacy Qt
|
||||||
- python-version: 3.8
|
- python-version: 3.8
|
||||||
platform: ubuntu-latest
|
platform: ubuntu-latest
|
||||||
@@ -58,96 +63,43 @@ jobs:
|
|||||||
platform: ubuntu-latest
|
platform: ubuntu-latest
|
||||||
backend: "pyqt5==5.14.*"
|
backend: "pyqt5==5.14.*"
|
||||||
|
|
||||||
steps:
|
test-qt-minreqs:
|
||||||
- uses: actions/checkout@v3
|
uses: pyapp-kit/workflows/.github/workflows/test-pyrepo.yml@v2
|
||||||
|
with:
|
||||||
|
python-version: "3.8"
|
||||||
|
qt: pyqt5
|
||||||
|
pip-post-installs: "qtpy==1.1.0 typing-extensions==3.7.4.3"
|
||||||
|
pip-install-flags: -e
|
||||||
|
coverage-upload: artifact
|
||||||
|
|
||||||
- name: Set up Python ${{ matrix.python-version }}
|
upload_coverage:
|
||||||
uses: actions/setup-python@v4
|
if: always()
|
||||||
with:
|
needs: [test, test-qt-minreqs]
|
||||||
python-version: ${{ matrix.python-version }}
|
uses: pyapp-kit/workflows/.github/workflows/upload-coverage.yml@v2
|
||||||
|
secrets: inherit
|
||||||
- uses: tlambert03/setup-qt-libs@v1.4
|
|
||||||
|
|
||||||
- name: Linux opengl
|
|
||||||
if: runner.os == 'Linux' && ( startsWith(matrix.backend, 'pyside6') || startsWith(matrix.backend, 'pyqt6') )
|
|
||||||
run: sudo apt-get install -y libopengl0 libegl1-mesa libxcb-xinput0
|
|
||||||
|
|
||||||
- name: Install dependencies
|
|
||||||
run: |
|
|
||||||
python -m pip install --upgrade pip
|
|
||||||
python -m pip install -e .[test]
|
|
||||||
python -m pip install ${{ matrix.backend }}
|
|
||||||
|
|
||||||
- name: Test
|
|
||||||
uses: aganders3/headless-gui@v1.2
|
|
||||||
with:
|
|
||||||
run: python -m pytest --color=yes --cov=superqt --cov-report=xml
|
|
||||||
|
|
||||||
- name: Coverage
|
|
||||||
uses: codecov/codecov-action@v3
|
|
||||||
|
|
||||||
test_old_qtpy:
|
|
||||||
name: qtpy minreq
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
steps:
|
|
||||||
- uses: actions/checkout@v3
|
|
||||||
- uses: tlambert03/setup-qt-libs@v1.4
|
|
||||||
- uses: actions/setup-python@v4
|
|
||||||
with:
|
|
||||||
python-version: "3.8"
|
|
||||||
|
|
||||||
- name: install
|
|
||||||
run: |
|
|
||||||
python -m pip install -U pip
|
|
||||||
python -m pip install -e .[test,pyqt5]
|
|
||||||
python -m pip install qtpy==1.1.0 typing-extensions==3.7.4.3
|
|
||||||
|
|
||||||
- name: Test
|
|
||||||
uses: aganders3/headless-gui@v1.2
|
|
||||||
with:
|
|
||||||
run: python -m pytest --color=yes
|
|
||||||
|
|
||||||
test_napari:
|
test_napari:
|
||||||
name: napari tests
|
uses: pyapp-kit/workflows/.github/workflows/test-dependents.yml@v2
|
||||||
runs-on: ubuntu-latest
|
with:
|
||||||
steps:
|
dependency-repo: napari/napari
|
||||||
- uses: actions/checkout@v3
|
dependency-ref: ${{ matrix.napari-version }}
|
||||||
with:
|
dependency-extras: "testing"
|
||||||
fetch-depth: 0
|
qt: ${{ matrix.qt }}
|
||||||
path: superqt
|
pytest-args: 'napari/_qt -k "not async and not qt_dims_2 and not qt_viewer_console_focus and not keybinding_editor"'
|
||||||
|
python-version: "3.10"
|
||||||
- uses: actions/checkout@v3
|
post-install-cmd: "pip install lxml_html_clean"
|
||||||
with:
|
strategy:
|
||||||
repository: napari/napari
|
fail-fast: false
|
||||||
path: napari-repo
|
matrix:
|
||||||
fetch-depth: 2
|
napari-version: ["", "v0.4.19.post1"]
|
||||||
|
qt: ["pyqt5", "pyside2"]
|
||||||
- uses: tlambert03/setup-qt-libs@v1
|
|
||||||
- uses: actions/setup-python@v4
|
|
||||||
with:
|
|
||||||
python-version: "3.10"
|
|
||||||
|
|
||||||
- name: install
|
|
||||||
run: |
|
|
||||||
python -m pip install -U pip
|
|
||||||
python -m pip install ./superqt
|
|
||||||
python -m pip install ./napari-repo[testing,pyqt5]
|
|
||||||
|
|
||||||
- name: Test napari
|
|
||||||
uses: aganders3/headless-gui@v1.2
|
|
||||||
with:
|
|
||||||
working-directory: napari-repo
|
|
||||||
run: python -m pytest --color=yes napari/_qt
|
|
||||||
|
|
||||||
check-manifest:
|
check-manifest:
|
||||||
name: Check Manifest
|
name: Check Manifest
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v3
|
- uses: actions/checkout@v4
|
||||||
- uses: actions/setup-python@v4
|
- run: pipx run check-manifest
|
||||||
with:
|
|
||||||
python-version: "3.x"
|
|
||||||
- run: pip install check-manifest && check-manifest
|
|
||||||
|
|
||||||
deploy:
|
deploy:
|
||||||
# this will run when you have tagged a commit, starting with "v*"
|
# this will run when you have tagged a commit, starting with "v*"
|
||||||
@@ -157,11 +109,11 @@ jobs:
|
|||||||
if: ${{ github.repository == 'pyapp-kit/superqt' && contains(github.ref, 'tags') }}
|
if: ${{ github.repository == 'pyapp-kit/superqt' && contains(github.ref, 'tags') }}
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v3
|
- uses: actions/checkout@v4
|
||||||
with:
|
with:
|
||||||
fetch-depth: 0
|
fetch-depth: 0
|
||||||
- name: Set up Python
|
- name: Set up Python
|
||||||
uses: actions/setup-python@v4
|
uses: actions/setup-python@v5
|
||||||
with:
|
with:
|
||||||
python-version: "3.x"
|
python-version: "3.x"
|
||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
@@ -178,6 +130,6 @@ jobs:
|
|||||||
twine check dist/*
|
twine check dist/*
|
||||||
twine upload dist/*
|
twine upload dist/*
|
||||||
|
|
||||||
- uses: softprops/action-gh-release@v1
|
- uses: softprops/action-gh-release@v2
|
||||||
with:
|
with:
|
||||||
generate_release_notes: true
|
generate_release_notes: true
|
||||||
|
@@ -4,31 +4,20 @@ ci:
|
|||||||
autoupdate_commit_msg: "ci: [pre-commit.ci] autoupdate"
|
autoupdate_commit_msg: "ci: [pre-commit.ci] autoupdate"
|
||||||
|
|
||||||
repos:
|
repos:
|
||||||
- repo: https://github.com/pre-commit/pre-commit-hooks
|
|
||||||
rev: v4.4.0
|
|
||||||
hooks:
|
|
||||||
- id: check-docstring-first
|
|
||||||
- id: end-of-file-fixer
|
|
||||||
- id: trailing-whitespace
|
|
||||||
|
|
||||||
- repo: https://github.com/psf/black
|
|
||||||
rev: 23.7.0
|
|
||||||
hooks:
|
|
||||||
- id: black
|
|
||||||
|
|
||||||
- repo: https://github.com/astral-sh/ruff-pre-commit
|
- repo: https://github.com/astral-sh/ruff-pre-commit
|
||||||
rev: v0.0.281
|
rev: v0.4.7
|
||||||
hooks:
|
hooks:
|
||||||
- id: ruff
|
- id: ruff
|
||||||
args: ["--fix"]
|
args: [--fix, --unsafe-fixes]
|
||||||
|
- id: ruff-format
|
||||||
|
|
||||||
- repo: https://github.com/abravalheri/validate-pyproject
|
- repo: https://github.com/abravalheri/validate-pyproject
|
||||||
rev: v0.13
|
rev: v0.18
|
||||||
hooks:
|
hooks:
|
||||||
- id: validate-pyproject
|
- id: validate-pyproject
|
||||||
|
|
||||||
- repo: https://github.com/pre-commit/mirrors-mypy
|
- repo: https://github.com/pre-commit/mirrors-mypy
|
||||||
rev: v1.4.1
|
rev: v1.10.0
|
||||||
hooks:
|
hooks:
|
||||||
- id: mypy
|
- id: mypy
|
||||||
exclude: tests|examples
|
exclude: tests|examples
|
||||||
|
128
CHANGELOG.md
128
CHANGELOG.md
@@ -1,5 +1,133 @@
|
|||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
|
## [v0.6.7](https://github.com/pyapp-kit/superqt/tree/v0.6.7) (2024-06-07)
|
||||||
|
|
||||||
|
[Full Changelog](https://github.com/pyapp-kit/superqt/compare/v0.6.6...v0.6.7)
|
||||||
|
|
||||||
|
**Fixed bugs:**
|
||||||
|
|
||||||
|
- fix: prevent qthrottled and qdebounced from holding strong references with bound methods [\#247](https://github.com/pyapp-kit/superqt/pull/247) ([tlambert03](https://github.com/tlambert03))
|
||||||
|
|
||||||
|
**Merged pull requests:**
|
||||||
|
|
||||||
|
- Prevent computing full document content highlight per block and only compute current block content for performance [\#246](https://github.com/pyapp-kit/superqt/pull/246) ([dalthviz](https://github.com/dalthviz))
|
||||||
|
|
||||||
|
## [v0.6.6](https://github.com/pyapp-kit/superqt/tree/v0.6.6) (2024-05-12)
|
||||||
|
|
||||||
|
[Full Changelog](https://github.com/pyapp-kit/superqt/compare/v0.6.5...v0.6.6)
|
||||||
|
|
||||||
|
**Refactors:**
|
||||||
|
|
||||||
|
- perf: improve paint time for QColormapLineEdit [\#245](https://github.com/pyapp-kit/superqt/pull/245) ([tlambert03](https://github.com/tlambert03))
|
||||||
|
|
||||||
|
## [v0.6.5](https://github.com/pyapp-kit/superqt/tree/v0.6.5) (2024-05-06)
|
||||||
|
|
||||||
|
[Full Changelog](https://github.com/pyapp-kit/superqt/compare/v0.6.4...v0.6.5)
|
||||||
|
|
||||||
|
**Implemented enhancements:**
|
||||||
|
|
||||||
|
- fix: fix a number of issues with Labeled and Range Sliders, add LabelsOnHandle mode. [\#242](https://github.com/pyapp-kit/superqt/pull/242) ([tlambert03](https://github.com/tlambert03))
|
||||||
|
|
||||||
|
**Tests & CI:**
|
||||||
|
|
||||||
|
- ci: trying to fix tests on various platforms [\#243](https://github.com/pyapp-kit/superqt/pull/243) ([tlambert03](https://github.com/tlambert03))
|
||||||
|
|
||||||
|
## [v0.6.4](https://github.com/pyapp-kit/superqt/tree/v0.6.4) (2024-04-25)
|
||||||
|
|
||||||
|
[Full Changelog](https://github.com/pyapp-kit/superqt/compare/v0.6.3...v0.6.4)
|
||||||
|
|
||||||
|
**Fixed bugs:**
|
||||||
|
|
||||||
|
- fix: fix inverted appearance [\#240](https://github.com/pyapp-kit/superqt/pull/240) ([tlambert03](https://github.com/tlambert03))
|
||||||
|
|
||||||
|
**Merged pull requests:**
|
||||||
|
|
||||||
|
- ci: \[pre-commit.ci\] autoupdate [\#238](https://github.com/pyapp-kit/superqt/pull/238) ([pre-commit-ci[bot]](https://github.com/apps/pre-commit-ci))
|
||||||
|
|
||||||
|
## [v0.6.3](https://github.com/pyapp-kit/superqt/tree/v0.6.3) (2024-03-27)
|
||||||
|
|
||||||
|
[Full Changelog](https://github.com/pyapp-kit/superqt/compare/v0.6.2...v0.6.3)
|
||||||
|
|
||||||
|
**Fixed bugs:**
|
||||||
|
|
||||||
|
- fix: fix sliderReleased, sliderPressed signals, and setTracking [\#237](https://github.com/pyapp-kit/superqt/pull/237) ([tlambert03](https://github.com/tlambert03))
|
||||||
|
|
||||||
|
**Merged pull requests:**
|
||||||
|
|
||||||
|
- ci\(dependabot\): bump softprops/action-gh-release from 1 to 2 [\#236](https://github.com/pyapp-kit/superqt/pull/236) ([dependabot[bot]](https://github.com/apps/dependabot))
|
||||||
|
|
||||||
|
## [v0.6.2](https://github.com/pyapp-kit/superqt/tree/v0.6.2) (2024-03-06)
|
||||||
|
|
||||||
|
[Full Changelog](https://github.com/pyapp-kit/superqt/compare/v0.6.1...v0.6.2)
|
||||||
|
|
||||||
|
**Implemented enhancements:**
|
||||||
|
|
||||||
|
- feat: make toggle button public in QCollapsible [\#232](https://github.com/pyapp-kit/superqt/pull/232) ([tlambert03](https://github.com/tlambert03))
|
||||||
|
- feat: add addKey method to QIconifyIcon [\#218](https://github.com/pyapp-kit/superqt/pull/218) ([tlambert03](https://github.com/tlambert03))
|
||||||
|
- feat: Add QIconifyIcon.name\(\) method [\#213](https://github.com/pyapp-kit/superqt/pull/213) ([tlambert03](https://github.com/tlambert03))
|
||||||
|
|
||||||
|
**Fixed bugs:**
|
||||||
|
|
||||||
|
- fix: don't use AbstractContextManager for exceptions\_as\_dialog [\#234](https://github.com/pyapp-kit/superqt/pull/234) ([tlambert03](https://github.com/tlambert03))
|
||||||
|
- fix: Check min max versus current value [\#221](https://github.com/pyapp-kit/superqt/pull/221) ([psobolewskiPhD](https://github.com/psobolewskiPhD))
|
||||||
|
- fix: better default size policy for qcollapsible [\#217](https://github.com/pyapp-kit/superqt/pull/217) ([tlambert03](https://github.com/tlambert03))
|
||||||
|
|
||||||
|
**Merged pull requests:**
|
||||||
|
|
||||||
|
- style: use ruff format instead of black, update pre-commit, restrict pyside6 tests [\#235](https://github.com/pyapp-kit/superqt/pull/235) ([tlambert03](https://github.com/tlambert03))
|
||||||
|
- ci: \[pre-commit.ci\] autoupdate [\#228](https://github.com/pyapp-kit/superqt/pull/228) ([pre-commit-ci[bot]](https://github.com/apps/pre-commit-ci))
|
||||||
|
- ci\(dependabot\): bump actions/setup-python from 4 to 5 [\#225](https://github.com/pyapp-kit/superqt/pull/225) ([dependabot[bot]](https://github.com/apps/dependabot))
|
||||||
|
- ci: \[pre-commit.ci\] autoupdate [\#223](https://github.com/pyapp-kit/superqt/pull/223) ([pre-commit-ci[bot]](https://github.com/apps/pre-commit-ci))
|
||||||
|
- ci: \[pre-commit.ci\] autoupdate [\#216](https://github.com/pyapp-kit/superqt/pull/216) ([pre-commit-ci[bot]](https://github.com/apps/pre-commit-ci))
|
||||||
|
- ci: use reusable test workflow [\#215](https://github.com/pyapp-kit/superqt/pull/215) ([tlambert03](https://github.com/tlambert03))
|
||||||
|
- build: remove packaging dep [\#212](https://github.com/pyapp-kit/superqt/pull/212) ([tlambert03](https://github.com/tlambert03))
|
||||||
|
|
||||||
|
## [v0.6.1](https://github.com/pyapp-kit/superqt/tree/v0.6.1) (2023-10-10)
|
||||||
|
|
||||||
|
[Full Changelog](https://github.com/pyapp-kit/superqt/compare/v0.6.0...v0.6.1)
|
||||||
|
|
||||||
|
**Implemented enhancements:**
|
||||||
|
|
||||||
|
- feat: add QIcon backed by iconify [\#209](https://github.com/pyapp-kit/superqt/pull/209) ([tlambert03](https://github.com/tlambert03))
|
||||||
|
|
||||||
|
**Merged pull requests:**
|
||||||
|
|
||||||
|
- ci: test python 3.12 [\#181](https://github.com/pyapp-kit/superqt/pull/181) ([tlambert03](https://github.com/tlambert03))
|
||||||
|
|
||||||
|
## [v0.6.0](https://github.com/pyapp-kit/superqt/tree/v0.6.0) (2023-09-25)
|
||||||
|
|
||||||
|
[Full Changelog](https://github.com/pyapp-kit/superqt/compare/v0.5.4...v0.6.0)
|
||||||
|
|
||||||
|
**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:**
|
||||||
|
|
||||||
|
- 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:**
|
||||||
|
|
||||||
|
- 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:**
|
||||||
|
|
||||||
|
- refactor: Labeled slider updates [\#197](https://github.com/pyapp-kit/superqt/pull/197) ([tlambert03](https://github.com/tlambert03))
|
||||||
|
|
||||||
|
**Merged pull requests:**
|
||||||
|
|
||||||
|
- 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.5.4](https://github.com/pyapp-kit/superqt/tree/v0.5.4) (2023-08-31)
|
## [v0.5.4](https://github.com/pyapp-kit/superqt/tree/v0.5.4) (2023-08-31)
|
||||||
|
|
||||||
[Full Changelog](https://github.com/pyapp-kit/superqt/compare/v0.5.3...v0.5.4)
|
[Full Changelog](https://github.com/pyapp-kit/superqt/compare/v0.5.3...v0.5.4)
|
||||||
|
@@ -37,7 +37,7 @@ def define_env(env: "MacrosPlugin"):
|
|||||||
)
|
)
|
||||||
src = src.replace("app.exec_()", "")
|
src = src.replace("app.exec_()", "")
|
||||||
|
|
||||||
exec(src) # noqa: S102
|
exec(src)
|
||||||
_grab(dest, width)
|
_grab(dest, width)
|
||||||
return (
|
return (
|
||||||
f""
|
f""
|
||||||
|
@@ -26,4 +26,4 @@ conda install -c conda-forge superqt
|
|||||||
|
|
||||||
## Usage
|
## 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
|
@@ -28,21 +28,44 @@ app.exec()
|
|||||||
|
|
||||||
## Font Icon plugins
|
## 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
|
If a font library you'd like to use is unavailable as a superqt plugin,
|
||||||
pip install fonticon-fontawesome5
|
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
|
```bash
|
||||||
pip install fonticon-fontawesome6
|
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
|
```bash
|
||||||
pip install fonticon-materialdesignicons6
|
pip install fonticon-materialdesignicons6
|
||||||
@@ -55,7 +78,7 @@ pip install fonticon-materialdesignicons6
|
|||||||
- <https://github.com/tlambert03/fonticon-feather>
|
- <https://github.com/tlambert03/fonticon-feather>
|
||||||
|
|
||||||
`superqt.fonticon` is a pluggable system, and font icon packs may use the `"superqt.fonticon"`
|
`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-fontawesome6>
|
||||||
- <https://github.com/tlambert03/fonticon-fontawesome5>
|
- <https://github.com/tlambert03/fonticon-fontawesome5>
|
||||||
@@ -64,24 +87,24 @@ entry point to register themselves with superqt. See [`fonticon-cookiecutter`](
|
|||||||
## API
|
## API
|
||||||
|
|
||||||
::: superqt.fonticon.icon
|
::: superqt.fonticon.icon
|
||||||
options:
|
options:
|
||||||
heading_level: 3
|
heading_level: 3
|
||||||
|
|
||||||
::: superqt.fonticon.setTextIcon
|
::: superqt.fonticon.setTextIcon
|
||||||
options:
|
options:
|
||||||
heading_level: 3
|
heading_level: 3
|
||||||
|
|
||||||
::: superqt.fonticon.font
|
::: superqt.fonticon.font
|
||||||
options:
|
options:
|
||||||
heading_level: 3
|
heading_level: 3
|
||||||
|
|
||||||
::: superqt.fonticon.IconOpts
|
::: superqt.fonticon.IconOpts
|
||||||
options:
|
options:
|
||||||
heading_level: 3
|
heading_level: 3
|
||||||
|
|
||||||
::: superqt.fonticon.addFont
|
::: superqt.fonticon.addFont
|
||||||
options:
|
options:
|
||||||
heading_level: 3
|
heading_level: 3
|
||||||
|
|
||||||
## Animations
|
## Animations
|
||||||
|
|
||||||
@@ -89,13 +112,13 @@ the `animation` parameter to `icon()` accepts a subclass of
|
|||||||
`Animation` that will be
|
`Animation` that will be
|
||||||
|
|
||||||
::: superqt.fonticon.Animation
|
::: superqt.fonticon.Animation
|
||||||
options:
|
options:
|
||||||
heading_level: 3
|
heading_level: 3
|
||||||
|
|
||||||
::: superqt.fonticon.pulse
|
::: superqt.fonticon.pulse
|
||||||
options:
|
options:
|
||||||
heading_level: 3
|
heading_level: 3
|
||||||
|
|
||||||
::: superqt.fonticon.spin
|
::: superqt.fonticon.spin
|
||||||
options:
|
options:
|
||||||
heading_level: 3
|
heading_level: 3
|
||||||
|
@@ -29,3 +29,4 @@
|
|||||||
| ----------- | --------------------- |
|
| ----------- | --------------------- |
|
||||||
| [`QMessageHandler`](./qmessagehandler.md) | A context manager to intercept messages from Qt. |
|
| [`QMessageHandler`](./qmessagehandler.md) | A context manager to intercept messages from Qt. |
|
||||||
| [`CodeSyntaxHighlight`](./code_syntax_highlight.md) | A `QSyntaxHighlighter` for code syntax highlighting. |
|
| [`CodeSyntaxHighlight`](./code_syntax_highlight.md) | A `QSyntaxHighlighter` for code syntax highlighting. |
|
||||||
|
| [`draw_colormap`](./cmap.md) | Function that draws a colormap into any QPaintDevice. |
|
||||||
|
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` |
|
| [`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 |
|
| [`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 |
|
| [`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
|
## 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 qtpy.QtWidgets import QApplication, QTextEdit
|
||||||
|
|
||||||
from superqt.utils import CodeSyntaxHighlight
|
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()
|
@@ -88,7 +88,7 @@ class IconPreviewArea(QtWidgets.QWidget):
|
|||||||
self.updatePixmapLabels()
|
self.updatePixmapLabels()
|
||||||
|
|
||||||
def createHeaderLabel(self, text):
|
def createHeaderLabel(self, text):
|
||||||
label = QtWidgets.QLabel("<b>%s</b>" % text)
|
label = QtWidgets.QLabel(f"<b>{text}</b>")
|
||||||
label.setAlignment(Qt.AlignmentFlag.AlignCenter)
|
label.setAlignment(Qt.AlignmentFlag.AlignCenter)
|
||||||
return label
|
return label
|
||||||
|
|
||||||
|
14
examples/iconify.py
Normal file
14
examples/iconify.py
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
from qtpy.QtCore import QSize
|
||||||
|
from qtpy.QtWidgets import QApplication, QPushButton
|
||||||
|
|
||||||
|
from superqt import QIconifyIcon
|
||||||
|
|
||||||
|
app = QApplication([])
|
||||||
|
|
||||||
|
btn = QPushButton()
|
||||||
|
# search https://icon-sets.iconify.design for available icon keys
|
||||||
|
btn.setIcon(QIconifyIcon("fluent-emoji-flat:alarm-clock"))
|
||||||
|
btn.setIconSize(QSize(60, 60))
|
||||||
|
btn.show()
|
||||||
|
|
||||||
|
app.exec()
|
@@ -14,6 +14,7 @@ ORIENTATION = Qt.Orientation.Horizontal
|
|||||||
|
|
||||||
w = QWidget()
|
w = QWidget()
|
||||||
qls = QLabeledSlider(ORIENTATION)
|
qls = QLabeledSlider(ORIENTATION)
|
||||||
|
qls.setEdgeLabelMode(qls.EdgeLabelMode.LabelIsRange | qls.EdgeLabelMode.LabelIsValue)
|
||||||
qls.valueChanged.connect(lambda e: print("qls valueChanged", e))
|
qls.valueChanged.connect(lambda e: print("qls valueChanged", e))
|
||||||
qls.setRange(0, 500)
|
qls.setRange(0, 500)
|
||||||
qls.setValue(300)
|
qls.setValue(300)
|
@@ -1,4 +1,5 @@
|
|||||||
"""Example for QCollapsible."""
|
"""Example for QCollapsible."""
|
||||||
|
|
||||||
from qtpy.QtWidgets import QApplication, QLabel, QPushButton
|
from qtpy.QtWidgets import QApplication, QLabel, QPushButton
|
||||||
|
|
||||||
from superqt import QCollapsible
|
from superqt import QCollapsible
|
||||||
|
@@ -5,7 +5,6 @@ from superqt import QRangeSlider
|
|||||||
|
|
||||||
app = QApplication([])
|
app = QApplication([])
|
||||||
|
|
||||||
slider = QRangeSlider(Qt.Orientation.Horizontal)
|
|
||||||
slider = QRangeSlider(Qt.Orientation.Horizontal)
|
slider = QRangeSlider(Qt.Orientation.Horizontal)
|
||||||
|
|
||||||
slider.setValue((20, 80))
|
slider.setValue((20, 80))
|
@@ -7,10 +7,7 @@ repo_name: pyapp-kit/superqt
|
|||||||
repo_url: https://github.com/pyapp-kit/superqt
|
repo_url: https://github.com/pyapp-kit/superqt
|
||||||
|
|
||||||
# Copyright
|
# Copyright
|
||||||
copyright: Copyright © 2021 - 2022 Talley Lambert
|
copyright: Copyright © 2021 - 2022
|
||||||
|
|
||||||
extra_css:
|
|
||||||
- stylesheets/extra.css
|
|
||||||
|
|
||||||
watch:
|
watch:
|
||||||
- src
|
- src
|
||||||
@@ -44,7 +41,6 @@ markdown_extensions:
|
|||||||
plugins:
|
plugins:
|
||||||
- search
|
- search
|
||||||
- autorefs
|
- autorefs
|
||||||
- mkdocstrings
|
|
||||||
- macros:
|
- macros:
|
||||||
module_name: docs/_macros
|
module_name: docs/_macros
|
||||||
- mkdocstrings:
|
- mkdocstrings:
|
||||||
@@ -52,6 +48,7 @@ plugins:
|
|||||||
python:
|
python:
|
||||||
import:
|
import:
|
||||||
- https://docs.python.org/3/objects.inv
|
- https://docs.python.org/3/objects.inv
|
||||||
|
- https://cmap-docs.readthedocs.io/en/latest/objects.inv
|
||||||
options:
|
options:
|
||||||
show_source: false
|
show_source: false
|
||||||
docstring_style: numpy
|
docstring_style: numpy
|
||||||
|
@@ -32,13 +32,13 @@ classifiers = [
|
|||||||
"Programming Language :: Python :: 3.9",
|
"Programming Language :: Python :: 3.9",
|
||||||
"Programming Language :: Python :: 3.10",
|
"Programming Language :: Python :: 3.10",
|
||||||
"Programming Language :: Python :: 3.11",
|
"Programming Language :: Python :: 3.11",
|
||||||
|
"Programming Language :: Python :: 3.12",
|
||||||
"Topic :: Desktop Environment",
|
"Topic :: Desktop Environment",
|
||||||
"Topic :: Software Development :: User Interfaces",
|
"Topic :: Software Development :: User Interfaces",
|
||||||
"Topic :: Software Development :: Widget Sets",
|
"Topic :: Software Development :: Widget Sets",
|
||||||
]
|
]
|
||||||
dynamic = ["version"]
|
dynamic = ["version"]
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"packaging",
|
|
||||||
"pygments>=2.4.0",
|
"pygments>=2.4.0",
|
||||||
"qtpy>=1.1.0",
|
"qtpy>=1.1.0",
|
||||||
"typing-extensions >=3.7.4.3,!=3.10.0.0",
|
"typing-extensions >=3.7.4.3,!=3.10.0.0",
|
||||||
@@ -47,9 +47,8 @@ dependencies = [
|
|||||||
# extras
|
# extras
|
||||||
# https://peps.python.org/pep-0621/#dependencies-optional-dependencies
|
# https://peps.python.org/pep-0621/#dependencies-optional-dependencies
|
||||||
[project.optional-dependencies]
|
[project.optional-dependencies]
|
||||||
test = ["pint", "pytest", "pytest-cov", "pytest-qt"]
|
test = ["pint", "pytest", "pytest-cov", "pytest-qt", "numpy", "cmap", "pyconify"]
|
||||||
dev = [
|
dev = [
|
||||||
"black",
|
|
||||||
"ipython",
|
"ipython",
|
||||||
"ruff",
|
"ruff",
|
||||||
"mypy",
|
"mypy",
|
||||||
@@ -59,21 +58,25 @@ dev = [
|
|||||||
"rich",
|
"rich",
|
||||||
"types-Pygments",
|
"types-Pygments",
|
||||||
]
|
]
|
||||||
docs = ["mkdocs-macros-plugin", "mkdocs-material", "mkdocstrings[python]"]
|
docs = ["mkdocs-macros-plugin", "mkdocs-material", "mkdocstrings[python]", "pint", "cmap"]
|
||||||
quantity = ["pint"]
|
quantity = ["pint"]
|
||||||
|
cmap = ["cmap >=0.1.1"]
|
||||||
pyside2 = ["pyside2"]
|
pyside2 = ["pyside2"]
|
||||||
# see issues surrounding usage of Generics in pyside6.5.x
|
# 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/177
|
||||||
# https://github.com/pyapp-kit/superqt/pull/164
|
# https://github.com/pyapp-kit/superqt/pull/164
|
||||||
pyside6 = ["pyside6 !=6.5.0,!=6.5.1"]
|
# https://bugreports.qt.io/browse/PYSIDE-2627
|
||||||
|
pyside6 = ["pyside6 !=6.5.0,!=6.5.1,!=6.6.2"]
|
||||||
pyqt5 = ["pyqt5"]
|
pyqt5 = ["pyqt5"]
|
||||||
pyqt6 = ["pyqt6"]
|
pyqt6 = ["pyqt6<6.7"]
|
||||||
font-fa5 = ["fonticon-fontawesome5"]
|
font-fa5 = ["fonticon-fontawesome5"]
|
||||||
font-fa6 = ["fonticon-fontawesome6"]
|
font-fa6 = ["fonticon-fontawesome6"]
|
||||||
font-mi6 = ["fonticon-materialdesignicons6"]
|
font-mi6 = ["fonticon-materialdesignicons6"]
|
||||||
font-mi7 = ["fonticon-materialdesignicons7"]
|
font-mi7 = ["fonticon-materialdesignicons7"]
|
||||||
|
iconify = ["pyconify >=0.1.4"]
|
||||||
|
|
||||||
[project.urls]
|
[project.urls]
|
||||||
|
Documentation = "https://pyapp-kit.github.io/superqt/"
|
||||||
Source = "https://github.com/pyapp-kit/superqt"
|
Source = "https://github.com/pyapp-kit/superqt"
|
||||||
Tracker = "https://github.com/pyapp-kit/superqt/issues"
|
Tracker = "https://github.com/pyapp-kit/superqt/issues"
|
||||||
Changelog = "https://github.com/pyapp-kit/superqt/blob/main/CHANGELOG.md"
|
Changelog = "https://github.com/pyapp-kit/superqt/blob/main/CHANGELOG.md"
|
||||||
@@ -84,55 +87,75 @@ source = "vcs"
|
|||||||
[tool.hatch.build.targets.sdist]
|
[tool.hatch.build.targets.sdist]
|
||||||
include = ["src", "tests", "CHANGELOG.md"]
|
include = ["src", "tests", "CHANGELOG.md"]
|
||||||
|
|
||||||
# https://pycqa.github.io/isort/docs/configuration/options.html
|
# these let you run tests across all backends easily with:
|
||||||
[tool.isort]
|
# hatch run test:test
|
||||||
profile = "black"
|
[tool.hatch.envs.test]
|
||||||
src_paths = ["src/superqt", "tests"]
|
|
||||||
|
[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
|
# https://github.com/charliermarsh/ruff
|
||||||
[tool.ruff]
|
[tool.ruff]
|
||||||
line-length = 88
|
line-length = 88
|
||||||
target-version = "py38"
|
target-version = "py38"
|
||||||
src = ["src", "tests"]
|
src = ["src", "tests"]
|
||||||
|
|
||||||
|
# https://docs.astral.sh/ruff/rules
|
||||||
|
[tool.ruff.lint]
|
||||||
|
pydocstyle = { convention = "numpy" }
|
||||||
select = [
|
select = [
|
||||||
"E", # style errors
|
"E", # style errors
|
||||||
|
"W", # style warnings
|
||||||
"F", # flakes
|
"F", # flakes
|
||||||
"W", # flakes
|
|
||||||
"D", # pydocstyle
|
"D", # pydocstyle
|
||||||
|
"D417", # Missing argument descriptions in Docstrings
|
||||||
"I", # isort
|
"I", # isort
|
||||||
"UP", # pyupgrade
|
"UP", # pyupgrade
|
||||||
"S", # bandit
|
|
||||||
"C4", # flake8-comprehensions
|
"C4", # flake8-comprehensions
|
||||||
"B", # flake8-bugbear
|
"B", # flake8-bugbear
|
||||||
"A001", # flake8-builtins
|
"A001", # flake8-builtins
|
||||||
"RUF", # ruff-specific rules
|
"RUF", # ruff-specific rules
|
||||||
"TID", # tidy imports
|
"TCH", # flake8-type-checking
|
||||||
|
"TID", # flake8-tidy-imports
|
||||||
]
|
]
|
||||||
ignore = [
|
ignore = [
|
||||||
"D100", # Missing docstring in public module
|
|
||||||
"D101", # Missing docstring in public class
|
|
||||||
"D104", # Missing docstring in public package
|
"D104", # Missing docstring in public package
|
||||||
"D107", # Missing docstring in __init__
|
"D401", # First line should be in imperative mood (remove to opt in)
|
||||||
"D203", # 1 blank line required before class docstring
|
|
||||||
"D212", # Multi-line docstring summary should start at the first line
|
|
||||||
"D213", # Multi-line docstring summary should start at the second line
|
|
||||||
"D401", # First line should be in imperative mood
|
|
||||||
"D413", # Missing blank line after last section
|
|
||||||
"D416", # Section name should end with a colon
|
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[tool.ruff.lint.per-file-ignores]
|
||||||
[tool.ruff.per-file-ignores]
|
|
||||||
"tests/*.py" = ["D", "S101"]
|
"tests/*.py" = ["D", "S101"]
|
||||||
"examples/demo_widget.py" = ["E501"]
|
"examples/demo_widget.py" = ["E501"]
|
||||||
"examples/*.py" = ["B", "D"]
|
"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
|
# https://docs.pytest.org/en/6.2.x/customize.html
|
||||||
[tool.pytest.ini_options]
|
[tool.pytest.ini_options]
|
||||||
minversion = "6.0"
|
minversion = "6.0"
|
||||||
testpaths = ["tests"]
|
testpaths = ["tests"]
|
||||||
filterwarnings = [
|
filterwarnings = [
|
||||||
"error",
|
"error",
|
||||||
|
"ignore:Failed to disconnect::pytestqt",
|
||||||
"ignore:QPixmapCache.find:DeprecationWarning:",
|
"ignore:QPixmapCache.find:DeprecationWarning:",
|
||||||
"ignore:SelectableGroups dict interface:DeprecationWarning",
|
"ignore:SelectableGroups dict interface:DeprecationWarning",
|
||||||
"ignore:The distutils package is deprecated:DeprecationWarning",
|
"ignore:The distutils package is deprecated:DeprecationWarning",
|
||||||
@@ -158,12 +181,18 @@ warn_unused_ignores = false
|
|||||||
allow_redefinition = true
|
allow_redefinition = true
|
||||||
|
|
||||||
# https://coverage.readthedocs.io/en/6.4/config.html
|
# https://coverage.readthedocs.io/en/6.4/config.html
|
||||||
|
[tool.coverage.run]
|
||||||
|
source = ["superqt"]
|
||||||
|
|
||||||
[tool.coverage.report]
|
[tool.coverage.report]
|
||||||
|
show_missing = true
|
||||||
exclude_lines = [
|
exclude_lines = [
|
||||||
"pragma: no cover",
|
"pragma: no cover",
|
||||||
"if TYPE_CHECKING:",
|
"if TYPE_CHECKING:",
|
||||||
"@overload",
|
"@overload",
|
||||||
"except ImportError",
|
"except ImportError",
|
||||||
|
"\\.\\.\\.",
|
||||||
|
"pass",
|
||||||
]
|
]
|
||||||
|
|
||||||
# https://github.com/mgedmin/check-manifest#configuration
|
# https://github.com/mgedmin/check-manifest#configuration
|
||||||
|
@@ -1,4 +1,5 @@
|
|||||||
"""superqt is a collection of Qt components for python."""
|
"""superqt is a collection of Qt components for python."""
|
||||||
|
|
||||||
from importlib.metadata import PackageNotFoundError, version
|
from importlib.metadata import PackageNotFoundError, version
|
||||||
from typing import TYPE_CHECKING, Any
|
from typing import TYPE_CHECKING, Any
|
||||||
|
|
||||||
@@ -7,12 +8,10 @@ try:
|
|||||||
except PackageNotFoundError:
|
except PackageNotFoundError:
|
||||||
__version__ = "unknown"
|
__version__ = "unknown"
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
|
||||||
from .spinbox._quantity import QQuantity
|
|
||||||
|
|
||||||
from .collapsible import QCollapsible
|
from .collapsible import QCollapsible
|
||||||
from .combobox import QEnumComboBox, QSearchableComboBox
|
from .combobox import QColorComboBox, QEnumComboBox, QSearchableComboBox
|
||||||
from .elidable import QElidingLabel, QElidingLineEdit
|
from .elidable import QElidingLabel, QElidingLineEdit
|
||||||
|
from .iconify import QIconifyIcon
|
||||||
from .selection import QSearchableListWidget, QSearchableTreeWidget
|
from .selection import QSearchableListWidget, QSearchableTreeWidget
|
||||||
from .sliders import (
|
from .sliders import (
|
||||||
QDoubleRangeSlider,
|
QDoubleRangeSlider,
|
||||||
@@ -29,13 +28,16 @@ from .utils import QMessageHandler, ensure_main_thread, ensure_object_thread
|
|||||||
__all__ = [
|
__all__ = [
|
||||||
"ensure_main_thread",
|
"ensure_main_thread",
|
||||||
"ensure_object_thread",
|
"ensure_object_thread",
|
||||||
"QDoubleRangeSlider",
|
|
||||||
"QCollapsible",
|
"QCollapsible",
|
||||||
|
"QColorComboBox",
|
||||||
|
"QColormapComboBox",
|
||||||
|
"QDoubleRangeSlider",
|
||||||
"QDoubleSlider",
|
"QDoubleSlider",
|
||||||
"QElidingLabel",
|
"QElidingLabel",
|
||||||
"QElidingLineEdit",
|
"QElidingLineEdit",
|
||||||
"QEnumComboBox",
|
"QEnumComboBox",
|
||||||
"QLabeledDoubleRangeSlider",
|
"QLabeledDoubleRangeSlider",
|
||||||
|
"QIconifyIcon",
|
||||||
"QLabeledDoubleSlider",
|
"QLabeledDoubleSlider",
|
||||||
"QLabeledRangeSlider",
|
"QLabeledRangeSlider",
|
||||||
"QLabeledSlider",
|
"QLabeledSlider",
|
||||||
@@ -48,10 +50,18 @@ __all__ = [
|
|||||||
"QSearchableTreeWidget",
|
"QSearchableTreeWidget",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from .combobox import QColormapComboBox # noqa: TCH004
|
||||||
|
from .spinbox._quantity import QQuantity # noqa: TCH004
|
||||||
|
|
||||||
|
|
||||||
def __getattr__(name: str) -> Any:
|
def __getattr__(name: str) -> Any:
|
||||||
if name == "QQuantity":
|
if name == "QQuantity":
|
||||||
from .spinbox._quantity import QQuantity
|
from .spinbox._quantity import QQuantity
|
||||||
|
|
||||||
return QQuantity
|
return QQuantity
|
||||||
|
if name == "QColormapComboBox":
|
||||||
|
from .cmap import QColormapComboBox
|
||||||
|
|
||||||
|
return QColormapComboBox
|
||||||
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
|
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
|
||||||
|
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()
|
138
src/superqt/cmap/_cmap_line_edit.py
Normal file
138
src/superqt/cmap/_cmap_line_edit.py
Normal file
@@ -0,0 +1,138 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import TYPE_CHECKING, Any
|
||||||
|
|
||||||
|
from qtpy.QtCore import QRect, Qt
|
||||||
|
from qtpy.QtGui import QIcon, QPainter, QPaintEvent, QPalette
|
||||||
|
from qtpy.QtWidgets import QApplication, QLineEdit, QStyle, QWidget
|
||||||
|
|
||||||
|
from ._cmap_utils import draw_colormap, pick_font_color, try_cast_colormap
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from cmap import Colormap
|
||||||
|
|
||||||
|
MISSING = QStyle.StandardPixmap.SP_TitleBarContextHelpButton
|
||||||
|
|
||||||
|
|
||||||
|
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 _cmap_rect(self) -> QRect:
|
||||||
|
cmap_rect = self.rect().adjusted(2, 0, 0, 0)
|
||||||
|
cmap_rect.setWidth(int(cmap_rect.width() * self._colormap_fraction))
|
||||||
|
return cmap_rect
|
||||||
|
|
||||||
|
def resizeEvent(self, e: Any) -> None:
|
||||||
|
left_margin = 6
|
||||||
|
if not self._cmap_is_full_width():
|
||||||
|
# leave room for the colormap
|
||||||
|
left_margin += self._cmap_rect().width()
|
||||||
|
self.setTextMargins(left_margin, 2, 0, 0)
|
||||||
|
super().resizeEvent(e)
|
||||||
|
|
||||||
|
def paintEvent(self, e: QPaintEvent) -> None:
|
||||||
|
# don't draw the background
|
||||||
|
# otherwise it will cover the colormap during super().paintEvent
|
||||||
|
# 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._cmap_rect()
|
||||||
|
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,5 +1,6 @@
|
|||||||
"""A collapsible widget to hide and unhide child widgets."""
|
"""A collapsible widget to hide and unhide child widgets."""
|
||||||
from typing import Optional, Union
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
from qtpy.QtCore import (
|
from qtpy.QtCore import (
|
||||||
QEasingCurve,
|
QEasingCurve,
|
||||||
@@ -12,7 +13,7 @@ from qtpy.QtCore import (
|
|||||||
Signal,
|
Signal,
|
||||||
)
|
)
|
||||||
from qtpy.QtGui import QIcon, QPainter, QPalette, QPixmap
|
from qtpy.QtGui import QIcon, QPainter, QPalette, QPixmap
|
||||||
from qtpy.QtWidgets import QFrame, QPushButton, QVBoxLayout, QWidget
|
from qtpy.QtWidgets import QFrame, QPushButton, QSizePolicy, QVBoxLayout, QWidget
|
||||||
|
|
||||||
|
|
||||||
class QCollapsible(QFrame):
|
class QCollapsible(QFrame):
|
||||||
@@ -28,9 +29,9 @@ class QCollapsible(QFrame):
|
|||||||
def __init__(
|
def __init__(
|
||||||
self,
|
self,
|
||||||
title: str = "",
|
title: str = "",
|
||||||
parent: Optional[QWidget] = None,
|
parent: QWidget | None = None,
|
||||||
expandedIcon: Optional[Union[QIcon, str]] = "▼",
|
expandedIcon: QIcon | str | None = "▼",
|
||||||
collapsedIcon: Optional[Union[QIcon, str]] = "▲",
|
collapsedIcon: QIcon | str | None = "▲",
|
||||||
):
|
):
|
||||||
super().__init__(parent)
|
super().__init__(parent)
|
||||||
self._locked = False
|
self._locked = False
|
||||||
@@ -41,13 +42,15 @@ class QCollapsible(QFrame):
|
|||||||
self._toggle_btn.setCheckable(True)
|
self._toggle_btn.setCheckable(True)
|
||||||
self.setCollapsedIcon(icon=collapsedIcon)
|
self.setCollapsedIcon(icon=collapsedIcon)
|
||||||
self.setExpandedIcon(icon=expandedIcon)
|
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.setStyleSheet("text-align: left; border: none; outline: none;")
|
||||||
self._toggle_btn.toggled.connect(self._toggle)
|
self._toggle_btn.toggled.connect(self._toggle)
|
||||||
|
|
||||||
# frame layout
|
# frame layout
|
||||||
self.setLayout(QVBoxLayout())
|
layout = QVBoxLayout(self)
|
||||||
self.layout().setAlignment(Qt.AlignmentFlag.AlignTop)
|
layout.setAlignment(Qt.AlignmentFlag.AlignTop)
|
||||||
self.layout().addWidget(self._toggle_btn)
|
layout.addWidget(self._toggle_btn)
|
||||||
|
|
||||||
# Create animators
|
# Create animators
|
||||||
self._animation = QPropertyAnimation(self)
|
self._animation = QPropertyAnimation(self)
|
||||||
@@ -64,10 +67,13 @@ class QCollapsible(QFrame):
|
|||||||
_content.layout().setContentsMargins(QMargins(5, 0, 0, 0))
|
_content.layout().setContentsMargins(QMargins(5, 0, 0, 0))
|
||||||
self.setContent(_content)
|
self.setContent(_content)
|
||||||
|
|
||||||
|
def toggleButton(self) -> QPushButton:
|
||||||
|
"""Return the toggle button."""
|
||||||
|
return self._toggle_btn
|
||||||
|
|
||||||
def setText(self, text: str) -> None:
|
def setText(self, text: str) -> None:
|
||||||
"""Set the text of the toggle button."""
|
"""Set the text of the toggle button."""
|
||||||
current = self._toggle_btn.text()
|
self._toggle_btn.setText(text)
|
||||||
self._toggle_btn.setText(current + text)
|
|
||||||
|
|
||||||
def text(self) -> str:
|
def text(self) -> str:
|
||||||
"""Return the text of the toggle button."""
|
"""Return the text of the toggle button."""
|
||||||
@@ -99,7 +105,7 @@ class QCollapsible(QFrame):
|
|||||||
"""Returns the icon used when the widget is expanded."""
|
"""Returns the icon used when the widget is expanded."""
|
||||||
return self._expanded_icon
|
return self._expanded_icon
|
||||||
|
|
||||||
def setExpandedIcon(self, icon: Optional[Union[QIcon, str]] = None) -> None:
|
def setExpandedIcon(self, icon: QIcon | str | None = None) -> None:
|
||||||
"""Set the icon on the toggle button when the widget is expanded."""
|
"""Set the icon on the toggle button when the widget is expanded."""
|
||||||
if icon and isinstance(icon, QIcon):
|
if icon and isinstance(icon, QIcon):
|
||||||
self._expanded_icon = icon
|
self._expanded_icon = icon
|
||||||
@@ -113,7 +119,7 @@ class QCollapsible(QFrame):
|
|||||||
"""Returns the icon used when the widget is collapsed."""
|
"""Returns the icon used when the widget is collapsed."""
|
||||||
return self._collapsed_icon
|
return self._collapsed_icon
|
||||||
|
|
||||||
def setCollapsedIcon(self, icon: Optional[Union[QIcon, str]] = None) -> None:
|
def setCollapsedIcon(self, icon: QIcon | str | None = None) -> None:
|
||||||
"""Set the icon on the toggle button when the widget is collapsed."""
|
"""Set the icon on the toggle button when the widget is collapsed."""
|
||||||
if icon and isinstance(icon, QIcon):
|
if icon and isinstance(icon, QIcon):
|
||||||
self._collapsed_icon = icon
|
self._collapsed_icon = icon
|
||||||
@@ -127,7 +133,7 @@ class QCollapsible(QFrame):
|
|||||||
"""Set duration of the collapse/expand animation."""
|
"""Set duration of the collapse/expand animation."""
|
||||||
self._animation.setDuration(msecs)
|
self._animation.setDuration(msecs)
|
||||||
|
|
||||||
def setEasingCurve(self, easing: QEasingCurve) -> None:
|
def setEasingCurve(self, easing: QEasingCurve | QEasingCurve.Type) -> None:
|
||||||
"""Set the easing curve for the collapse/expand animation."""
|
"""Set the easing curve for the collapse/expand animation."""
|
||||||
self._animation.setEasingCurve(easing)
|
self._animation.setEasingCurve(easing)
|
||||||
|
|
||||||
|
@@ -1,4 +1,24 @@
|
|||||||
|
from typing import TYPE_CHECKING, Any
|
||||||
|
|
||||||
|
from ._color_combobox import QColorComboBox
|
||||||
from ._enum_combobox import QEnumComboBox
|
from ._enum_combobox import QEnumComboBox
|
||||||
from ._searchable_combo_box import QSearchableComboBox
|
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
|
import sys
|
||||||
from typing import Optional, TypeVar
|
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.QtCore import Signal
|
||||||
from qtpy.QtWidgets import QComboBox
|
from qtpy.QtWidgets import QComboBox
|
||||||
@@ -12,17 +16,41 @@ NONE_STRING = "----"
|
|||||||
|
|
||||||
def _get_name(enum_value: Enum):
|
def _get_name(enum_value: Enum):
|
||||||
"""Create human readable name if user does not implement `__str__`."""
|
"""Create human readable name if user does not implement `__str__`."""
|
||||||
if (
|
str_module = getattr(enum_value.__str__, "__module__", "enum")
|
||||||
enum_value.__str__.__module__ != "enum"
|
if str_module != "enum" and not str_module.startswith("shibokensupport"):
|
||||||
and not enum_value.__str__.__module__.startswith("shibokensupport")
|
|
||||||
):
|
|
||||||
# check if function was overloaded
|
# check if function was overloaded
|
||||||
name = str(enum_value)
|
name = str(enum_value)
|
||||||
else:
|
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
|
return name
|
||||||
|
|
||||||
|
|
||||||
|
def _get_name_with_value(enum_value: Enum) -> Tuple[str, Enum]:
|
||||||
|
return _get_name(enum_value), enum_value
|
||||||
|
|
||||||
|
|
||||||
class QEnumComboBox(QComboBox):
|
class QEnumComboBox(QComboBox):
|
||||||
"""ComboBox presenting options from a python Enum.
|
"""ComboBox presenting options from a python Enum.
|
||||||
|
|
||||||
@@ -49,9 +77,20 @@ class QEnumComboBox(QComboBox):
|
|||||||
self._allow_none = allow_none and enum is not None
|
self._allow_none = allow_none and enum is not None
|
||||||
if allow_none:
|
if allow_none:
|
||||||
super().addItem(NONE_STRING)
|
super().addItem(NONE_STRING)
|
||||||
names = map(_get_name, self._enum_class.__members__.values())
|
names_ = self._get_enum_member_list(enum)
|
||||||
_names = dict.fromkeys(names) # remove duplicates/aliases, keep order
|
super().addItems(list(names_))
|
||||||
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]:
|
def enumClass(self) -> Optional[EnumMeta]:
|
||||||
"""Return current Enum class."""
|
"""Return current Enum class."""
|
||||||
@@ -72,11 +111,7 @@ class QEnumComboBox(QComboBox):
|
|||||||
if self._allow_none:
|
if self._allow_none:
|
||||||
if self.currentText() == NONE_STRING:
|
if self.currentText() == NONE_STRING:
|
||||||
return None
|
return None
|
||||||
else:
|
return self._get_enum_member_list(self._enum_class)[self.currentText()]
|
||||||
return list(self._enum_class.__members__.values())[
|
|
||||||
self.currentIndex() - 1
|
|
||||||
]
|
|
||||||
return list(self._enum_class.__members__.values())[self.currentIndex()]
|
|
||||||
return None
|
return None
|
||||||
|
|
||||||
def setCurrentEnum(self, value: Optional[EnumType]) -> None:
|
def setCurrentEnum(self, value: Optional[EnumType]) -> None:
|
||||||
|
@@ -10,6 +10,7 @@ __all__ = [
|
|||||||
"IconFontMeta",
|
"IconFontMeta",
|
||||||
"IconOpts",
|
"IconOpts",
|
||||||
"pulse",
|
"pulse",
|
||||||
|
"QIconifyIcon",
|
||||||
"setTextIcon",
|
"setTextIcon",
|
||||||
"spin",
|
"spin",
|
||||||
]
|
]
|
||||||
@@ -103,7 +104,7 @@ def icon(
|
|||||||
plugin is installed)
|
plugin is installed)
|
||||||
|
|
||||||
>>> btn = QPushButton()
|
>>> btn = QPushButton()
|
||||||
>>> btn.setIcon(icon('fa5s.smile'))
|
>>> btn.setIcon(icon("fa5s.smile"))
|
||||||
|
|
||||||
can also directly import from fonticon_fa5
|
can also directly import from fonticon_fa5
|
||||||
>>> from fonticon_fa5 import FA5S
|
>>> from fonticon_fa5 import FA5S
|
||||||
@@ -129,7 +130,7 @@ def icon(
|
|||||||
... "disabled": {
|
... "disabled": {
|
||||||
... "color": "green",
|
... "color": "green",
|
||||||
... "scale_factor": 0.8,
|
... "scale_factor": 0.8,
|
||||||
... "animation": spin(btn)
|
... "animation": spin(btn),
|
||||||
... },
|
... },
|
||||||
... },
|
... },
|
||||||
... )
|
... )
|
||||||
|
@@ -4,7 +4,7 @@ import warnings
|
|||||||
from collections import abc, defaultdict
|
from collections import abc, defaultdict
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import ClassVar, DefaultDict, Sequence, Tuple, Union, cast
|
from typing import TYPE_CHECKING, ClassVar, DefaultDict, Sequence, Tuple, Union, cast
|
||||||
|
|
||||||
from qtpy import QT_VERSION
|
from qtpy import QT_VERSION
|
||||||
from qtpy.QtCore import QObject, QPoint, QRect, QSize, Qt
|
from qtpy.QtCore import QObject, QPoint, QRect, QSize, Qt
|
||||||
@@ -25,7 +25,8 @@ from typing_extensions import TypedDict
|
|||||||
|
|
||||||
from superqt.utils import QMessageHandler
|
from superqt.utils import QMessageHandler
|
||||||
|
|
||||||
from ._animations import Animation
|
if TYPE_CHECKING:
|
||||||
|
from ._animations import Animation
|
||||||
|
|
||||||
|
|
||||||
class Unset:
|
class Unset:
|
||||||
@@ -157,9 +158,9 @@ class _QFontIconEngine(QIconEngine):
|
|||||||
|
|
||||||
def __init__(self, options: _IconOptions):
|
def __init__(self, options: _IconOptions):
|
||||||
super().__init__()
|
super().__init__()
|
||||||
self._opts: defaultdict[
|
self._opts: defaultdict[QIcon.State, dict[QIcon.Mode, _IconOptions | None]] = (
|
||||||
QIcon.State, dict[QIcon.Mode, _IconOptions | None]
|
DefaultDict(dict)
|
||||||
] = DefaultDict(dict)
|
)
|
||||||
self._opts[QIcon.State.Off][QIcon.Mode.Normal] = options
|
self._opts[QIcon.State.Off][QIcon.Mode.Normal] = options
|
||||||
self.update_hash()
|
self.update_hash()
|
||||||
|
|
||||||
|
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)
|
@@ -13,6 +13,8 @@ warnings.warn(
|
|||||||
|
|
||||||
# forward any requests for superqt.qtcompat.* to qtpy.*
|
# forward any requests for superqt.qtcompat.* to qtpy.*
|
||||||
class SuperQtImporter(abc.MetaPathFinder):
|
class SuperQtImporter(abc.MetaPathFinder):
|
||||||
|
"""Pseudo-importer to forward superqt.qtcompat.* to qtpy.*."""
|
||||||
|
|
||||||
def find_spec(self, fullname: str, path, target=None): # type: ignore
|
def find_spec(self, fullname: str, path, target=None): # type: ignore
|
||||||
"""Forward any requests for superqt.qtcompat.* to qtpy.*."""
|
"""Forward any requests for superqt.qtcompat.* to qtpy.*."""
|
||||||
if fullname.startswith(__name__):
|
if fullname.startswith(__name__):
|
||||||
|
@@ -103,7 +103,7 @@ class _GenericRangeSlider(_GenericSlider):
|
|||||||
"""Show the bar between the first and last handle."""
|
"""Show the bar between the first and last handle."""
|
||||||
self.setBarVisible(True)
|
self.setBarVisible(True)
|
||||||
|
|
||||||
def applyMacStylePatch(self) -> str:
|
def applyMacStylePatch(self) -> None:
|
||||||
"""Apply a QSS patch to fix sliders on macos>=12 with QT < 6.
|
"""Apply a QSS patch to fix sliders on macos>=12 with QT < 6.
|
||||||
|
|
||||||
see [FAQ](../faq.md#sliders-not-dragging-properly-on-macos-12) for more details.
|
see [FAQ](../faq.md#sliders-not-dragging-properly-on-macos-12) for more details.
|
||||||
@@ -124,11 +124,27 @@ class _GenericRangeSlider(_GenericSlider):
|
|||||||
"""
|
"""
|
||||||
return tuple(float(i) for i in self._position)
|
return tuple(float(i) for i in self._position)
|
||||||
|
|
||||||
def setSliderPosition(self, pos: Union[float, Sequence[float]], index=None) -> None:
|
def setSliderPosition( # type: ignore
|
||||||
|
self,
|
||||||
|
pos: Union[float, Sequence[float]],
|
||||||
|
index: Optional[int] = None,
|
||||||
|
*,
|
||||||
|
reversed: bool = False,
|
||||||
|
) -> None:
|
||||||
"""Set current position of the handles with a sequence of integers.
|
"""Set current position of the handles with a sequence of integers.
|
||||||
|
|
||||||
If `pos` is a sequence, it must have the same length as `value()`.
|
Parameters
|
||||||
If it is a scalar, index will be
|
----------
|
||||||
|
pos : Union[float, Sequence[float]]
|
||||||
|
The new position of the slider handle(s). If a sequence, it must have the
|
||||||
|
same length as `value()`. If it is a scalar, index will be used to set the
|
||||||
|
position of the handle at that index.
|
||||||
|
index : int | None
|
||||||
|
The index of the handle to set the position of. If None, the "pressedIndex"
|
||||||
|
will be used.
|
||||||
|
reversed : bool
|
||||||
|
Order in which to set the positions. Can be useful when setting multiple
|
||||||
|
positions, to avoid intermediate overlapping values.
|
||||||
"""
|
"""
|
||||||
if isinstance(pos, (list, tuple)):
|
if isinstance(pos, (list, tuple)):
|
||||||
val_len = len(self.value())
|
val_len = len(self.value())
|
||||||
@@ -139,6 +155,9 @@ class _GenericRangeSlider(_GenericSlider):
|
|||||||
else:
|
else:
|
||||||
pairs = [(self._pressedIndex if index is None else index, pos)]
|
pairs = [(self._pressedIndex if index is None else index, pos)]
|
||||||
|
|
||||||
|
if reversed:
|
||||||
|
pairs = pairs[::-1]
|
||||||
|
|
||||||
for idx, position in pairs:
|
for idx, position in pairs:
|
||||||
self._position[idx] = self._bound(position, idx)
|
self._position[idx] = self._bound(position, idx)
|
||||||
|
|
||||||
@@ -222,7 +241,7 @@ class _GenericRangeSlider(_GenericSlider):
|
|||||||
offset = self.maximum() - ref[-1]
|
offset = self.maximum() - ref[-1]
|
||||||
elif ref[0] + offset < self.minimum():
|
elif ref[0] + offset < self.minimum():
|
||||||
offset = self.minimum() - ref[0]
|
offset = self.minimum() - ref[0]
|
||||||
self.setSliderPosition([i + offset for i in ref])
|
self.setSliderPosition([i + offset for i in ref], reversed=offset > 0)
|
||||||
|
|
||||||
def _fixStyleOption(self, option):
|
def _fixStyleOption(self, option):
|
||||||
pass
|
pass
|
||||||
|
@@ -19,6 +19,7 @@ So that's what `_GenericSlider` is below.
|
|||||||
scalar (with one handle per item), and it forms the basis of
|
scalar (with one handle per item), and it forms the basis of
|
||||||
QRangeSlider.
|
QRangeSlider.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import os
|
import os
|
||||||
import platform
|
import platform
|
||||||
from typing import TypeVar
|
from typing import TypeVar
|
||||||
@@ -73,6 +74,7 @@ class _GenericSlider(QSlider):
|
|||||||
self._position: _T = 0.0
|
self._position: _T = 0.0
|
||||||
self._singleStep = 1.0
|
self._singleStep = 1.0
|
||||||
self._offsetAccumulated = 0.0
|
self._offsetAccumulated = 0.0
|
||||||
|
self._inverted_appearance = False
|
||||||
self._blocktracking = False
|
self._blocktracking = False
|
||||||
self._tickInterval = 0.0
|
self._tickInterval = 0.0
|
||||||
self._pressedControl = SC_NONE
|
self._pressedControl = SC_NONE
|
||||||
@@ -97,7 +99,7 @@ class _GenericSlider(QSlider):
|
|||||||
if USE_MAC_SLIDER_PATCH:
|
if USE_MAC_SLIDER_PATCH:
|
||||||
self.applyMacStylePatch()
|
self.applyMacStylePatch()
|
||||||
|
|
||||||
def applyMacStylePatch(self) -> str:
|
def applyMacStylePatch(self) -> None:
|
||||||
"""Apply a QSS patch to fix sliders on macos>=12 with QT < 6.
|
"""Apply a QSS patch to fix sliders on macos>=12 with QT < 6.
|
||||||
|
|
||||||
see [FAQ](../faq.md#sliders-not-dragging-properly-on-macos-12) for more details.
|
see [FAQ](../faq.md#sliders-not-dragging-properly-on-macos-12) for more details.
|
||||||
@@ -173,6 +175,13 @@ class _GenericSlider(QSlider):
|
|||||||
self._tickInterval = max(0.0, ts)
|
self._tickInterval = max(0.0, ts)
|
||||||
self.update()
|
self.update()
|
||||||
|
|
||||||
|
def invertedAppearance(self) -> bool:
|
||||||
|
return self._inverted_appearance
|
||||||
|
|
||||||
|
def setInvertedAppearance(self, inverted: bool) -> None:
|
||||||
|
self._inverted_appearance = inverted
|
||||||
|
self.update()
|
||||||
|
|
||||||
def triggerAction(self, action: QSlider.SliderAction) -> None:
|
def triggerAction(self, action: QSlider.SliderAction) -> None:
|
||||||
self._blocktracking = True
|
self._blocktracking = True
|
||||||
# other actions here
|
# other actions here
|
||||||
@@ -192,9 +201,8 @@ class _GenericSlider(QSlider):
|
|||||||
if self.orientation() == Qt.Orientation.Horizontal
|
if self.orientation() == Qt.Orientation.Horizontal
|
||||||
else not self.invertedAppearance()
|
else not self.invertedAppearance()
|
||||||
)
|
)
|
||||||
option.direction = (
|
# we use the upsideDown option instead
|
||||||
Qt.LayoutDirection.LeftToRight
|
option.direction = Qt.LayoutDirection.LeftToRight
|
||||||
) # we use the upsideDown option instead
|
|
||||||
# option.sliderValue = self._value # type: ignore
|
# option.sliderValue = self._value # type: ignore
|
||||||
# option.singleStep = self._singleStep # type: ignore
|
# option.singleStep = self._singleStep # type: ignore
|
||||||
if self.orientation() == Qt.Orientation.Horizontal:
|
if self.orientation() == Qt.Orientation.Horizontal:
|
||||||
@@ -334,8 +342,12 @@ class _GenericSlider(QSlider):
|
|||||||
option.sliderValue = self._to_qinteger_space(self._value - self._minimum)
|
option.sliderValue = self._to_qinteger_space(self._value - self._minimum)
|
||||||
|
|
||||||
def _to_qinteger_space(self, val, _max=None):
|
def _to_qinteger_space(self, val, _max=None):
|
||||||
|
"""Converts a value to the internal integer space."""
|
||||||
_max = _max or self.MAX_DISPLAY
|
_max = _max or self.MAX_DISPLAY
|
||||||
return int(min(QOVERFLOW, val / (self._maximum - self._minimum) * _max))
|
range_ = self._maximum - self._minimum
|
||||||
|
if range_ == 0:
|
||||||
|
return self._minimum
|
||||||
|
return int(min(QOVERFLOW, val / range_ * _max))
|
||||||
|
|
||||||
def _pick(self, pt: QPoint) -> int:
|
def _pick(self, pt: QPoint) -> int:
|
||||||
return pt.x() if self.orientation() == Qt.Orientation.Horizontal else pt.y()
|
return pt.x() if self.orientation() == Qt.Orientation.Horizontal else pt.y()
|
||||||
|
@@ -1,13 +1,16 @@
|
|||||||
import contextlib
|
from __future__ import annotations
|
||||||
from enum import IntEnum
|
|
||||||
from functools import partial
|
|
||||||
from typing import Any
|
|
||||||
|
|
||||||
from qtpy.QtCore import QPoint, QSize, Qt, Signal
|
import contextlib
|
||||||
|
from enum import IntEnum, IntFlag, auto
|
||||||
|
from functools import partial
|
||||||
|
from typing import Any, Iterable, overload
|
||||||
|
|
||||||
|
from qtpy import QtGui
|
||||||
|
from qtpy.QtCore import Property, QPoint, QSize, Qt, Signal
|
||||||
from qtpy.QtGui import QFontMetrics, QValidator
|
from qtpy.QtGui import QFontMetrics, QValidator
|
||||||
from qtpy.QtWidgets import (
|
from qtpy.QtWidgets import (
|
||||||
QAbstractSlider,
|
QAbstractSlider,
|
||||||
QApplication,
|
QBoxLayout,
|
||||||
QDoubleSpinBox,
|
QDoubleSpinBox,
|
||||||
QHBoxLayout,
|
QHBoxLayout,
|
||||||
QSlider,
|
QSlider,
|
||||||
@@ -25,79 +28,113 @@ from ._sliders import QDoubleRangeSlider, QDoubleSlider, QRangeSlider
|
|||||||
|
|
||||||
class LabelPosition(IntEnum):
|
class LabelPosition(IntEnum):
|
||||||
NoLabel = 0
|
NoLabel = 0
|
||||||
LabelsAbove = 1
|
LabelsAbove = auto()
|
||||||
LabelsBelow = 2
|
LabelsBelow = auto()
|
||||||
LabelsRight = 1
|
LabelsRight = LabelsAbove
|
||||||
LabelsLeft = 2
|
LabelsLeft = LabelsBelow
|
||||||
|
LabelsOnHandle = auto()
|
||||||
|
|
||||||
|
|
||||||
class EdgeLabelMode(IntEnum):
|
class EdgeLabelMode(IntFlag):
|
||||||
NoLabel = 0
|
NoLabel = 0
|
||||||
LabelIsRange = 1
|
LabelIsRange = auto()
|
||||||
LabelIsValue = 2
|
LabelIsValue = auto()
|
||||||
|
|
||||||
|
|
||||||
class _SliderProxy:
|
class _SliderProxy:
|
||||||
_slider: QSlider
|
_slider: QSlider
|
||||||
|
|
||||||
def value(self):
|
def value(self) -> Any:
|
||||||
return self._slider.value()
|
return self._slider.value()
|
||||||
|
|
||||||
def setValue(self, value) -> None:
|
def setValue(self, value: Any) -> None:
|
||||||
self._slider.setValue(value)
|
self._slider.setValue(value)
|
||||||
|
|
||||||
def sliderPosition(self):
|
def sliderPosition(self) -> int:
|
||||||
return self._slider.sliderPosition()
|
return self._slider.sliderPosition()
|
||||||
|
|
||||||
def setSliderPosition(self, pos) -> None:
|
def setSliderPosition(self, pos: int) -> None:
|
||||||
self._slider.setSliderPosition(pos)
|
self._slider.setSliderPosition(pos)
|
||||||
|
|
||||||
def minimum(self):
|
def minimum(self) -> int:
|
||||||
return self._slider.minimum()
|
return self._slider.minimum()
|
||||||
|
|
||||||
def setMinimum(self, minimum):
|
def setMinimum(self, minimum: int) -> None:
|
||||||
self._slider.setMinimum(minimum)
|
self._slider.setMinimum(minimum)
|
||||||
|
|
||||||
def maximum(self):
|
def maximum(self) -> int:
|
||||||
return self._slider.maximum()
|
return self._slider.maximum()
|
||||||
|
|
||||||
def setMaximum(self, maximum):
|
def setMaximum(self, maximum: int) -> None:
|
||||||
self._slider.setMaximum(maximum)
|
self._slider.setMaximum(maximum)
|
||||||
|
|
||||||
def singleStep(self):
|
def singleStep(self):
|
||||||
return self._slider.singleStep()
|
return self._slider.singleStep()
|
||||||
|
|
||||||
def setSingleStep(self, step):
|
def setSingleStep(self, step: int) -> None:
|
||||||
self._slider.setSingleStep(step)
|
self._slider.setSingleStep(step)
|
||||||
|
|
||||||
def pageStep(self):
|
def pageStep(self) -> int:
|
||||||
return self._slider.pageStep()
|
return self._slider.pageStep()
|
||||||
|
|
||||||
def setPageStep(self, step) -> None:
|
def setPageStep(self, step: int) -> None:
|
||||||
self._slider.setPageStep(step)
|
self._slider.setPageStep(step)
|
||||||
|
|
||||||
def setRange(self, min, max) -> None:
|
def setRange(self, min: int, max: int) -> None:
|
||||||
self._slider.setRange(min, max)
|
self._slider.setRange(min, max)
|
||||||
|
|
||||||
def tickInterval(self):
|
def tickInterval(self) -> int:
|
||||||
return self._slider.tickInterval()
|
return self._slider.tickInterval()
|
||||||
|
|
||||||
def setTickInterval(self, interval) -> None:
|
def setTickInterval(self, interval: int) -> None:
|
||||||
self._slider.setTickInterval(interval)
|
self._slider.setTickInterval(interval)
|
||||||
|
|
||||||
def tickPosition(self):
|
def tickPosition(self) -> QSlider.TickPosition:
|
||||||
return self._slider.tickPosition()
|
return self._slider.tickPosition()
|
||||||
|
|
||||||
def setTickPosition(self, pos) -> None:
|
def setTickPosition(self, pos: QSlider.TickPosition) -> None:
|
||||||
self._slider.setTickPosition(pos)
|
self._slider.setTickPosition(pos)
|
||||||
|
|
||||||
def __getattr__(self, name) -> Any:
|
def triggerAction(self, action: QAbstractSlider.SliderAction) -> None:
|
||||||
|
return self._slider.triggerAction(action)
|
||||||
|
|
||||||
|
def invertedControls(self) -> bool:
|
||||||
|
return self._slider.invertedControls()
|
||||||
|
|
||||||
|
def setInvertedControls(self, a0: bool) -> None:
|
||||||
|
return self._slider.setInvertedControls(a0)
|
||||||
|
|
||||||
|
def invertedAppearance(self) -> bool:
|
||||||
|
return self._slider.invertedAppearance()
|
||||||
|
|
||||||
|
def setInvertedAppearance(self, a0: bool) -> None:
|
||||||
|
return self._slider.setInvertedAppearance(a0)
|
||||||
|
|
||||||
|
def isSliderDown(self) -> bool:
|
||||||
|
return self._slider.isSliderDown()
|
||||||
|
|
||||||
|
def setSliderDown(self, a0: bool) -> None:
|
||||||
|
return self._slider.setSliderDown(a0)
|
||||||
|
|
||||||
|
def hasTracking(self) -> bool:
|
||||||
|
return self._slider.hasTracking()
|
||||||
|
|
||||||
|
def setTracking(self, enable: bool) -> None:
|
||||||
|
return self._slider.setTracking(enable)
|
||||||
|
|
||||||
|
def orientation(self) -> Qt.Orientation:
|
||||||
|
return self._slider.orientation()
|
||||||
|
|
||||||
|
def __getattr__(self, name: Any) -> Any:
|
||||||
return getattr(self._slider, name)
|
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
|
parent = None
|
||||||
orientation = Qt.Orientation.Vertical
|
orientation = Qt.Orientation.Horizontal
|
||||||
errmsg = (
|
errmsg = (
|
||||||
"TypeError: arguments did not match any overloaded call:\n"
|
"TypeError: arguments did not match any overloaded call:\n"
|
||||||
" QSlider(parent: QWidget = None)\n"
|
" QSlider(parent: QWidget = None)\n"
|
||||||
@@ -122,12 +159,22 @@ def _handle_overloaded_slider_sig(args, kwargs):
|
|||||||
|
|
||||||
class QLabeledSlider(_SliderProxy, QAbstractSlider):
|
class QLabeledSlider(_SliderProxy, QAbstractSlider):
|
||||||
editingFinished = Signal()
|
editingFinished = Signal()
|
||||||
|
_ivalueChanged = Signal(int)
|
||||||
|
_isliderMoved = Signal(int)
|
||||||
|
_irangeChanged = Signal(int, int)
|
||||||
|
|
||||||
EdgeLabelMode = EdgeLabelMode
|
|
||||||
_slider_class = QSlider
|
_slider_class = QSlider
|
||||||
_slider: 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)
|
parent, orientation = _handle_overloaded_slider_sig(args, kwargs)
|
||||||
|
|
||||||
super().__init__(parent)
|
super().__init__(parent)
|
||||||
@@ -141,7 +188,7 @@ class QLabeledSlider(_SliderProxy, QAbstractSlider):
|
|||||||
|
|
||||||
self._rename_signals()
|
self._rename_signals()
|
||||||
self._slider.actionTriggered.connect(self.actionTriggered.emit)
|
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.sliderMoved.connect(self.sliderMoved.emit)
|
||||||
self._slider.sliderPressed.connect(self.sliderPressed.emit)
|
self._slider.sliderPressed.connect(self.sliderPressed.emit)
|
||||||
self._slider.sliderReleased.connect(self.sliderReleased.emit)
|
self._slider.sliderReleased.connect(self.sliderReleased.emit)
|
||||||
@@ -150,19 +197,9 @@ class QLabeledSlider(_SliderProxy, QAbstractSlider):
|
|||||||
|
|
||||||
self.setOrientation(orientation)
|
self.setOrientation(orientation)
|
||||||
|
|
||||||
def _on_slider_value_changed(self, v):
|
# ------------------- public API -------------------
|
||||||
self._label.setValue(v)
|
|
||||||
self.valueChanged.emit(v)
|
|
||||||
|
|
||||||
def _setValue(self, value: float):
|
def setOrientation(self, orientation: Qt.Orientation) -> None:
|
||||||
"""Convert the value from float to int before setting the slider value."""
|
|
||||||
self._slider.setValue(int(value))
|
|
||||||
|
|
||||||
def _rename_signals(self):
|
|
||||||
# for subclasses
|
|
||||||
pass
|
|
||||||
|
|
||||||
def setOrientation(self, orientation):
|
|
||||||
"""Set orientation, value will be 'horizontal' or 'vertical'."""
|
"""Set orientation, value will be 'horizontal' or 'vertical'."""
|
||||||
self._slider.setOrientation(orientation)
|
self._slider.setOrientation(orientation)
|
||||||
marg = (0, 0, 0, 0)
|
marg = (0, 0, 0, 0)
|
||||||
@@ -194,11 +231,21 @@ class QLabeledSlider(_SliderProxy, QAbstractSlider):
|
|||||||
return self._edge_label_mode
|
return self._edge_label_mode
|
||||||
|
|
||||||
def setEdgeLabelMode(self, opt: EdgeLabelMode) -> None:
|
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:
|
if opt is EdgeLabelMode.LabelIsRange:
|
||||||
raise ValueError(
|
raise ValueError(
|
||||||
"mode must be one of 'EdgeLabelMode.NoLabel' or "
|
"mode must be one of 'EdgeLabelMode.NoLabel' or "
|
||||||
"'EdgeLabelMode.LabelIsValue'."
|
"'EdgeLabelMode.LabelIsValue' or"
|
||||||
|
"'EdgeLabelMode.LabelIsValue | EdgeLabelMode.LabelIsRange'."
|
||||||
)
|
)
|
||||||
|
|
||||||
self._edge_label_mode = opt
|
self._edge_label_mode = opt
|
||||||
@@ -206,14 +253,37 @@ class QLabeledSlider(_SliderProxy, QAbstractSlider):
|
|||||||
self._label.hide()
|
self._label.hide()
|
||||||
w = 5 if self.orientation() == Qt.Orientation.Horizontal else 0
|
w = 5 if self.orientation() == Qt.Orientation.Horizontal else 0
|
||||||
self.layout().setContentsMargins(0, 0, w, 0)
|
self.layout().setContentsMargins(0, 0, w, 0)
|
||||||
else:
|
if opt & EdgeLabelMode.LabelIsValue:
|
||||||
if self.isVisible():
|
if self.isVisible():
|
||||||
self._label.show()
|
self._label.show()
|
||||||
self._label.setMode(opt)
|
self._label.setMode(opt)
|
||||||
self._label.setValue(self._slider.value())
|
self._label.setValue(self._slider.value())
|
||||||
self.layout().setContentsMargins(0, 0, 0, 0)
|
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:
|
||||||
|
self.valueChanged = self._ivalueChanged
|
||||||
|
self.sliderMoved = self._isliderMoved
|
||||||
|
self.rangeChanged = self._irangeChanged
|
||||||
|
|
||||||
|
|
||||||
class QLabeledDoubleSlider(QLabeledSlider):
|
class QLabeledDoubleSlider(QLabeledSlider):
|
||||||
@@ -223,15 +293,23 @@ class QLabeledDoubleSlider(QLabeledSlider):
|
|||||||
_fsliderMoved = Signal(float)
|
_fsliderMoved = Signal(float)
|
||||||
_frangeChanged = Signal(float, 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)
|
super().__init__(*args, **kwargs)
|
||||||
self.setDecimals(2)
|
self.setDecimals(2)
|
||||||
|
|
||||||
def _setValue(self, value: float):
|
def _setValue(self, value: float) -> None:
|
||||||
"""Convert the value from float to int before setting the slider value."""
|
"""Convert the value from float to int before setting the slider value."""
|
||||||
self._slider.setValue(value)
|
self._slider.setValue(value)
|
||||||
|
|
||||||
def _rename_signals(self):
|
def _rename_signals(self) -> None:
|
||||||
self.valueChanged = self._fvalueChanged
|
self.valueChanged = self._fvalueChanged
|
||||||
self.sliderMoved = self._fsliderMoved
|
self.sliderMoved = self._fsliderMoved
|
||||||
self.rangeChanged = self._frangeChanged
|
self.rangeChanged = self._frangeChanged
|
||||||
@@ -239,26 +317,34 @@ class QLabeledDoubleSlider(QLabeledSlider):
|
|||||||
def decimals(self) -> int:
|
def decimals(self) -> int:
|
||||||
return self._label.decimals()
|
return self._label.decimals()
|
||||||
|
|
||||||
def setDecimals(self, prec: int):
|
def setDecimals(self, prec: int) -> None:
|
||||||
self._label.setDecimals(prec)
|
self._label.setDecimals(prec)
|
||||||
|
|
||||||
|
|
||||||
class QLabeledRangeSlider(_SliderProxy, QAbstractSlider):
|
class QLabeledRangeSlider(_SliderProxy, QAbstractSlider):
|
||||||
_valueChanged = Signal(tuple)
|
_valueChanged = Signal(tuple)
|
||||||
|
_sliderPressed = Signal()
|
||||||
|
_sliderReleased = Signal()
|
||||||
editingFinished = Signal()
|
editingFinished = Signal()
|
||||||
|
|
||||||
LabelPosition = LabelPosition
|
|
||||||
EdgeLabelMode = EdgeLabelMode
|
|
||||||
_slider_class = QRangeSlider
|
_slider_class = QRangeSlider
|
||||||
_slider: 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)
|
parent, orientation = _handle_overloaded_slider_sig(args, kwargs)
|
||||||
super().__init__(parent)
|
super().__init__(parent)
|
||||||
self._rename_signals()
|
self._rename_signals()
|
||||||
|
|
||||||
self.setAttribute(Qt.WidgetAttribute.WA_ShowWithoutActivating)
|
self.setAttribute(Qt.WidgetAttribute.WA_ShowWithoutActivating)
|
||||||
self._handle_labels = []
|
self._handle_labels: list[SliderLabel] = []
|
||||||
self._handle_label_position: LabelPosition = LabelPosition.LabelsAbove
|
self._handle_label_position: LabelPosition = LabelPosition.LabelsAbove
|
||||||
|
|
||||||
# for fine tuning label position
|
# for fine tuning label position
|
||||||
@@ -267,7 +353,10 @@ class QLabeledRangeSlider(_SliderProxy, QAbstractSlider):
|
|||||||
|
|
||||||
self._slider = self._slider_class()
|
self._slider = self._slider_class()
|
||||||
self._slider.valueChanged.connect(self.valueChanged.emit)
|
self._slider.valueChanged.connect(self.valueChanged.emit)
|
||||||
|
self._slider.sliderPressed.connect(self.sliderPressed.emit)
|
||||||
|
self._slider.sliderReleased.connect(self.sliderReleased.emit)
|
||||||
self._slider.rangeChanged.connect(self.rangeChanged.emit)
|
self._slider.rangeChanged.connect(self.rangeChanged.emit)
|
||||||
|
self.sliderMoved = self._slider._slidersMoved
|
||||||
|
|
||||||
self._min_label = SliderLabel(
|
self._min_label = SliderLabel(
|
||||||
self._slider,
|
self._slider,
|
||||||
@@ -290,28 +379,27 @@ class QLabeledRangeSlider(_SliderProxy, QAbstractSlider):
|
|||||||
self._on_range_changed(self._slider.minimum(), self._slider.maximum())
|
self._on_range_changed(self._slider.minimum(), self._slider.maximum())
|
||||||
self.setOrientation(orientation)
|
self.setOrientation(orientation)
|
||||||
|
|
||||||
def _rename_signals(self):
|
# --------------------- public API -------------------
|
||||||
self.valueChanged = self._valueChanged
|
|
||||||
|
|
||||||
def handleLabelPosition(self) -> LabelPosition:
|
def handleLabelPosition(self) -> LabelPosition:
|
||||||
"""Return where/whether labels are shown adjacent to slider handles."""
|
"""Return where/whether labels are shown adjacent to slider handles."""
|
||||||
return self._handle_label_position
|
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."""
|
"""Set where/whether labels are shown adjacent to slider handles."""
|
||||||
self._handle_label_position = opt
|
self._handle_label_position = opt
|
||||||
for lbl in self._handle_labels:
|
for lbl in self._handle_labels:
|
||||||
if not opt:
|
lbl.setVisible(bool(opt))
|
||||||
lbl.hide()
|
trans = opt == LabelPosition.LabelsOnHandle
|
||||||
else:
|
# TODO: make double clickable to edit
|
||||||
lbl.show()
|
lbl.setAttribute(Qt.WidgetAttribute.WA_TransparentForMouseEvents, trans)
|
||||||
self.setOrientation(self.orientation())
|
self.setOrientation(self.orientation())
|
||||||
|
|
||||||
def edgeLabelMode(self) -> EdgeLabelMode:
|
def edgeLabelMode(self) -> EdgeLabelMode:
|
||||||
"""Return current `EdgeLabelMode`."""
|
"""Return current `EdgeLabelMode`."""
|
||||||
return self._edge_label_mode
|
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."""
|
"""Set `EdgeLabelMode`, controls what is shown at the min/max labels."""
|
||||||
self._edge_label_mode = opt
|
self._edge_label_mode = opt
|
||||||
if not self._edge_label_mode:
|
if not self._edge_label_mode:
|
||||||
@@ -330,10 +418,82 @@ class QLabeledRangeSlider(_SliderProxy, QAbstractSlider):
|
|||||||
elif opt == EdgeLabelMode.LabelIsRange:
|
elif opt == EdgeLabelMode.LabelIsRange:
|
||||||
self._min_label.setValue(self._slider.minimum())
|
self._min_label.setValue(self._slider.minimum())
|
||||||
self._max_label.setValue(self._slider.maximum())
|
self._max_label.setValue(self._slider.maximum())
|
||||||
QApplication.processEvents()
|
|
||||||
self._reposition_labels()
|
self._reposition_labels()
|
||||||
|
|
||||||
def _reposition_labels(self):
|
def setRange(self, min: int, max: int) -> None:
|
||||||
|
self._on_range_changed(min, max)
|
||||||
|
|
||||||
|
def _add_labels(self, layout: QBoxLayout, inverted: bool = False) -> None:
|
||||||
|
if inverted:
|
||||||
|
first, second = self._max_label, self._min_label
|
||||||
|
else:
|
||||||
|
first, second = self._min_label, self._max_label
|
||||||
|
layout.addWidget(first)
|
||||||
|
layout.addWidget(self._slider)
|
||||||
|
layout.addWidget(second)
|
||||||
|
|
||||||
|
def setOrientation(self, orientation: Qt.Orientation) -> None:
|
||||||
|
"""Set orientation, value will be 'horizontal' or 'vertical'."""
|
||||||
|
self._slider.setOrientation(orientation)
|
||||||
|
inverted = self._slider.invertedAppearance()
|
||||||
|
marg = (0, 0, 0, 0)
|
||||||
|
if orientation == Qt.Orientation.Vertical:
|
||||||
|
layout: QBoxLayout = QVBoxLayout()
|
||||||
|
layout.setSpacing(1)
|
||||||
|
self._add_labels(layout, inverted=not inverted)
|
||||||
|
# TODO: set margins based on label width
|
||||||
|
if self._handle_label_position == LabelPosition.LabelsLeft:
|
||||||
|
marg = (30, 0, 0, 0)
|
||||||
|
elif self._handle_label_position == LabelPosition.LabelsRight:
|
||||||
|
marg = (0, 0, 20, 0)
|
||||||
|
layout.setAlignment(Qt.AlignmentFlag.AlignCenter)
|
||||||
|
else:
|
||||||
|
layout = QHBoxLayout()
|
||||||
|
layout.setSpacing(7)
|
||||||
|
if self._handle_label_position == LabelPosition.LabelsBelow:
|
||||||
|
marg = (0, 0, 0, 25)
|
||||||
|
elif self._handle_label_position == LabelPosition.LabelsAbove:
|
||||||
|
marg = (0, 25, 0, 0)
|
||||||
|
self._add_labels(layout, inverted=inverted)
|
||||||
|
|
||||||
|
# remove old layout
|
||||||
|
old_layout = self.layout()
|
||||||
|
if old_layout is not None:
|
||||||
|
QWidget().setLayout(old_layout)
|
||||||
|
|
||||||
|
self.setLayout(layout)
|
||||||
|
layout.setContentsMargins(*marg)
|
||||||
|
super().setOrientation(orientation)
|
||||||
|
self._reposition_labels()
|
||||||
|
|
||||||
|
def setInvertedAppearance(self, a0: bool) -> None:
|
||||||
|
self._slider.setInvertedAppearance(a0)
|
||||||
|
self.setOrientation(self._slider.orientation())
|
||||||
|
|
||||||
|
def resizeEvent(self, a0: Any) -> None:
|
||||||
|
super().resizeEvent(a0)
|
||||||
|
self._reposition_labels()
|
||||||
|
|
||||||
|
# putting this after methods above for the sake of mypy
|
||||||
|
LabelPosition = LabelPosition
|
||||||
|
EdgeLabelMode = EdgeLabelMode
|
||||||
|
|
||||||
|
def _getBarColor(self) -> QtGui.QBrush:
|
||||||
|
return self._slider._style.brush(self._slider._styleOption)
|
||||||
|
|
||||||
|
def _setBarColor(self, color: str) -> None:
|
||||||
|
self._slider._style.brush_active = color
|
||||||
|
|
||||||
|
barColor = Property(QtGui.QBrush, _getBarColor, _setBarColor)
|
||||||
|
"""The color of the bar between the first and last handle."""
|
||||||
|
|
||||||
|
# ------------- private methods ----------------
|
||||||
|
def _rename_signals(self) -> None:
|
||||||
|
self.valueChanged = self._valueChanged
|
||||||
|
self.sliderReleased = self._sliderReleased
|
||||||
|
self.sliderPressed = self._sliderPressed
|
||||||
|
|
||||||
|
def _reposition_labels(self) -> None:
|
||||||
if (
|
if (
|
||||||
not self._handle_labels
|
not self._handle_labels
|
||||||
or self._handle_label_position == LabelPosition.NoLabel
|
or self._handle_label_position == LabelPosition.NoLabel
|
||||||
@@ -342,17 +502,26 @@ class QLabeledRangeSlider(_SliderProxy, QAbstractSlider):
|
|||||||
|
|
||||||
horizontal = self.orientation() == Qt.Orientation.Horizontal
|
horizontal = self.orientation() == Qt.Orientation.Horizontal
|
||||||
labels_above = self._handle_label_position == LabelPosition.LabelsAbove
|
labels_above = self._handle_label_position == LabelPosition.LabelsAbove
|
||||||
|
labels_on_handle = self._handle_label_position == LabelPosition.LabelsOnHandle
|
||||||
|
|
||||||
last_edge = None
|
last_edge = None
|
||||||
for i, label in enumerate(self._handle_labels):
|
labels: Iterable[tuple[int, SliderLabel]] = enumerate(self._handle_labels)
|
||||||
|
if self._slider.invertedAppearance():
|
||||||
|
labels = reversed(list(labels))
|
||||||
|
for i, label in labels:
|
||||||
rect = self._slider._handleRect(i)
|
rect = self._slider._handleRect(i)
|
||||||
dx = -label.width() / 2
|
dx = (-label.width() / 2) + 2
|
||||||
dy = -label.height() / 2
|
dy = -label.height() / 2
|
||||||
if labels_above:
|
if labels_above: # or on the right
|
||||||
if horizontal:
|
if horizontal:
|
||||||
dy *= 3
|
dy *= 3
|
||||||
else:
|
else:
|
||||||
dx *= -1
|
dx *= -1
|
||||||
|
elif labels_on_handle:
|
||||||
|
if horizontal:
|
||||||
|
dy += 0.5
|
||||||
|
else:
|
||||||
|
dx += 0.5
|
||||||
else:
|
else:
|
||||||
if horizontal:
|
if horizontal:
|
||||||
dy *= -1
|
dy *= -1
|
||||||
@@ -369,10 +538,11 @@ class QLabeledRangeSlider(_SliderProxy, QAbstractSlider):
|
|||||||
label.move(pos)
|
label.move(pos)
|
||||||
last_edge = pos
|
last_edge = pos
|
||||||
label.clearFocus()
|
label.clearFocus()
|
||||||
|
label.raise_()
|
||||||
label.show()
|
label.show()
|
||||||
self.update()
|
self.update()
|
||||||
|
|
||||||
def _min_label_edited(self, val):
|
def _min_label_edited(self, val: float) -> None:
|
||||||
if self._edge_label_mode == EdgeLabelMode.LabelIsRange:
|
if self._edge_label_mode == EdgeLabelMode.LabelIsRange:
|
||||||
self.setMinimum(val)
|
self.setMinimum(val)
|
||||||
else:
|
else:
|
||||||
@@ -381,7 +551,7 @@ class QLabeledRangeSlider(_SliderProxy, QAbstractSlider):
|
|||||||
self.setValue(v)
|
self.setValue(v)
|
||||||
self._reposition_labels()
|
self._reposition_labels()
|
||||||
|
|
||||||
def _max_label_edited(self, val):
|
def _max_label_edited(self, val: float) -> None:
|
||||||
if self._edge_label_mode == EdgeLabelMode.LabelIsRange:
|
if self._edge_label_mode == EdgeLabelMode.LabelIsRange:
|
||||||
self.setMaximum(val)
|
self.setMaximum(val)
|
||||||
else:
|
else:
|
||||||
@@ -390,7 +560,7 @@ class QLabeledRangeSlider(_SliderProxy, QAbstractSlider):
|
|||||||
self.setValue(v)
|
self.setValue(v)
|
||||||
self._reposition_labels()
|
self._reposition_labels()
|
||||||
|
|
||||||
def _on_value_changed(self, v):
|
def _on_value_changed(self, v: tuple[int, ...]) -> None:
|
||||||
if self._edge_label_mode == EdgeLabelMode.LabelIsValue:
|
if self._edge_label_mode == EdgeLabelMode.LabelIsValue:
|
||||||
self._min_label.setValue(v[0])
|
self._min_label.setValue(v[0])
|
||||||
self._max_label.setValue(v[-1])
|
self._max_label.setValue(v[-1])
|
||||||
@@ -411,7 +581,7 @@ class QLabeledRangeSlider(_SliderProxy, QAbstractSlider):
|
|||||||
label.setValue(val)
|
label.setValue(val)
|
||||||
self._reposition_labels()
|
self._reposition_labels()
|
||||||
|
|
||||||
def _on_range_changed(self, min, max):
|
def _on_range_changed(self, min: int, max: int) -> None:
|
||||||
if (min, max) != (self._slider.minimum(), self._slider.maximum()):
|
if (min, max) != (self._slider.minimum(), self._slider.maximum()):
|
||||||
self._slider.setRange(min, max)
|
self._slider.setRange(min, max)
|
||||||
for lbl in self._handle_labels:
|
for lbl in self._handle_labels:
|
||||||
@@ -425,77 +595,46 @@ class QLabeledRangeSlider(_SliderProxy, QAbstractSlider):
|
|||||||
# super().setValue(value)
|
# super().setValue(value)
|
||||||
# self.sliderChange(QSlider.SliderValueChange)
|
# self.sliderChange(QSlider.SliderValueChange)
|
||||||
|
|
||||||
def setRange(self, min, max) -> None:
|
|
||||||
self._on_range_changed(min, max)
|
|
||||||
|
|
||||||
def setOrientation(self, orientation):
|
|
||||||
"""Set orientation, value will be 'horizontal' or 'vertical'."""
|
|
||||||
self._slider.setOrientation(orientation)
|
|
||||||
if orientation == Qt.Orientation.Vertical:
|
|
||||||
layout = QVBoxLayout()
|
|
||||||
layout.setSpacing(1)
|
|
||||||
layout.addWidget(self._max_label)
|
|
||||||
layout.addWidget(self._slider)
|
|
||||||
layout.addWidget(self._min_label)
|
|
||||||
# TODO: set margins based on label width
|
|
||||||
if self._handle_label_position == LabelPosition.LabelsLeft:
|
|
||||||
marg = (30, 0, 0, 0)
|
|
||||||
elif self._handle_label_position == LabelPosition.NoLabel:
|
|
||||||
marg = (0, 0, 0, 0)
|
|
||||||
else:
|
|
||||||
marg = (0, 0, 20, 0)
|
|
||||||
layout.setAlignment(Qt.AlignmentFlag.AlignCenter)
|
|
||||||
else:
|
|
||||||
layout = QHBoxLayout()
|
|
||||||
layout.setSpacing(7)
|
|
||||||
if self._handle_label_position == LabelPosition.LabelsBelow:
|
|
||||||
marg = (0, 0, 0, 25)
|
|
||||||
elif self._handle_label_position == LabelPosition.NoLabel:
|
|
||||||
marg = (0, 0, 0, 0)
|
|
||||||
else:
|
|
||||||
marg = (0, 25, 0, 0)
|
|
||||||
layout.addWidget(self._min_label)
|
|
||||||
layout.addWidget(self._slider)
|
|
||||||
layout.addWidget(self._max_label)
|
|
||||||
|
|
||||||
# remove old layout
|
|
||||||
old_layout = self.layout()
|
|
||||||
if old_layout is not None:
|
|
||||||
QWidget().setLayout(old_layout)
|
|
||||||
|
|
||||||
self.setLayout(layout)
|
|
||||||
layout.setContentsMargins(*marg)
|
|
||||||
super().setOrientation(orientation)
|
|
||||||
QApplication.processEvents()
|
|
||||||
self._reposition_labels()
|
|
||||||
|
|
||||||
def resizeEvent(self, a0) -> None:
|
|
||||||
super().resizeEvent(a0)
|
|
||||||
self._reposition_labels()
|
|
||||||
|
|
||||||
|
|
||||||
class QLabeledDoubleRangeSlider(QLabeledRangeSlider):
|
class QLabeledDoubleRangeSlider(QLabeledRangeSlider):
|
||||||
_slider_class = QDoubleRangeSlider
|
_slider_class = QDoubleRangeSlider
|
||||||
_slider: QDoubleRangeSlider
|
_slider: QDoubleRangeSlider
|
||||||
_frangeChanged = Signal(float, 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)
|
super().__init__(*args, **kwargs)
|
||||||
self.setDecimals(2)
|
self.setDecimals(2)
|
||||||
|
|
||||||
def _rename_signals(self):
|
def _rename_signals(self) -> None:
|
||||||
super()._rename_signals()
|
super()._rename_signals()
|
||||||
self.rangeChanged = self._frangeChanged
|
self.rangeChanged = self._frangeChanged
|
||||||
|
|
||||||
def decimals(self) -> int:
|
def decimals(self) -> int:
|
||||||
return self._min_label.decimals()
|
return self._min_label.decimals()
|
||||||
|
|
||||||
def setDecimals(self, prec: int):
|
def setDecimals(self, prec: int) -> None:
|
||||||
self._min_label.setDecimals(prec)
|
self._min_label.setDecimals(prec)
|
||||||
self._max_label.setDecimals(prec)
|
self._max_label.setDecimals(prec)
|
||||||
for lbl in self._handle_labels:
|
for lbl in self._handle_labels:
|
||||||
lbl.setDecimals(prec)
|
lbl.setDecimals(prec)
|
||||||
|
|
||||||
|
def _getBarColor(self) -> QtGui.QBrush:
|
||||||
|
return self._slider._style.brush(self._slider._styleOption)
|
||||||
|
|
||||||
|
def _setBarColor(self, color: str) -> None:
|
||||||
|
self._slider._style.brush_active = color
|
||||||
|
|
||||||
|
barColor = Property(QtGui.QBrush, _getBarColor, _setBarColor)
|
||||||
|
"""The color of the bar between the first and last handle."""
|
||||||
|
|
||||||
|
|
||||||
class SliderLabel(QDoubleSpinBox):
|
class SliderLabel(QDoubleSpinBox):
|
||||||
def __init__(
|
def __init__(
|
||||||
@@ -521,56 +660,26 @@ class SliderLabel(QDoubleSpinBox):
|
|||||||
self.editingFinished.connect(self._silent_clear_focus)
|
self.editingFinished.connect(self._silent_clear_focus)
|
||||||
self._update_size()
|
self._update_size()
|
||||||
|
|
||||||
def _silent_clear_focus(self):
|
|
||||||
with signals_blocked(self):
|
|
||||||
self.clearFocus()
|
|
||||||
|
|
||||||
def setDecimals(self, prec: int) -> None:
|
def setDecimals(self, prec: int) -> None:
|
||||||
super().setDecimals(prec)
|
super().setDecimals(prec)
|
||||||
self._update_size()
|
self._update_size()
|
||||||
|
|
||||||
def _update_size(self, *_):
|
|
||||||
# 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:
|
|
||||||
# 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))
|
|
||||||
if self.specialValueText():
|
|
||||||
w = max(w, _fm_width(fm, self.specialValueText()))
|
|
||||||
else:
|
|
||||||
w = max(0, _fm_width(fm, self.textFromValue(self.value()))) + 3
|
|
||||||
|
|
||||||
w += 3 # cursor blinking space
|
|
||||||
# get the final size hint
|
|
||||||
opt = QStyleOptionSpinBox()
|
|
||||||
self.initStyleOption(opt)
|
|
||||||
size = self.style().sizeFromContents(
|
|
||||||
QStyle.ContentsType.CT_SpinBox, opt, QSize(w, h), self
|
|
||||||
)
|
|
||||||
self.setFixedSize(size)
|
|
||||||
|
|
||||||
def setValue(self, val: Any) -> None:
|
def setValue(self, val: Any) -> None:
|
||||||
super().setValue(val)
|
super().setValue(val)
|
||||||
if self._mode == EdgeLabelMode.LabelIsRange:
|
if self._mode == EdgeLabelMode.LabelIsRange:
|
||||||
self._update_size()
|
self._update_size()
|
||||||
|
|
||||||
def setMaximum(self, max: int) -> None:
|
def setMaximum(self, max: float) -> None:
|
||||||
super().setMaximum(max)
|
super().setMaximum(max)
|
||||||
if self._mode == EdgeLabelMode.LabelIsValue:
|
if self._mode == EdgeLabelMode.LabelIsValue:
|
||||||
self._update_size()
|
self._update_size()
|
||||||
|
|
||||||
def setMinimum(self, min: int) -> None:
|
def setMinimum(self, min: float) -> None:
|
||||||
super().setMinimum(min)
|
super().setMinimum(min)
|
||||||
if self._mode == EdgeLabelMode.LabelIsValue:
|
if self._mode == EdgeLabelMode.LabelIsValue:
|
||||||
self._update_size()
|
self._update_size()
|
||||||
|
|
||||||
def setMode(self, opt: EdgeLabelMode):
|
def setMode(self, opt: EdgeLabelMode) -> None:
|
||||||
# when the edge labels are controlling slider range,
|
# when the edge labels are controlling slider range,
|
||||||
# we want them to have a big range, but not have a huge label
|
# we want them to have a big range, but not have a huge label
|
||||||
self._mode = opt
|
self._mode = opt
|
||||||
@@ -585,14 +694,50 @@ class SliderLabel(QDoubleSpinBox):
|
|||||||
self._slider.rangeChanged.connect(self.setRange)
|
self._slider.rangeChanged.connect(self.setRange)
|
||||||
self._update_size()
|
self._update_size()
|
||||||
|
|
||||||
def validate(self, input: str, pos: int):
|
# --------------- 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:
|
||||||
|
# determine width based on min/max/specialValue
|
||||||
|
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
|
||||||
|
|
||||||
|
w += 3 # cursor blinking space
|
||||||
|
# get the final size hint
|
||||||
|
opt = QStyleOptionSpinBox()
|
||||||
|
self.initStyleOption(opt)
|
||||||
|
size = self.style().sizeFromContents(
|
||||||
|
QStyle.ContentsType.CT_SpinBox, opt, QSize(w, h), self
|
||||||
|
)
|
||||||
|
self.setFixedSize(size)
|
||||||
|
|
||||||
|
def validate(
|
||||||
|
self, input_: str | None, pos: int
|
||||||
|
) -> tuple[QValidator.State, str, int]:
|
||||||
# fake like an integer spinbox
|
# fake like an integer spinbox
|
||||||
if "." in input and self.decimals() < 1:
|
if input_ and "." in input_ and self.decimals() < 1:
|
||||||
return QValidator.Invalid, input, len(input)
|
return QValidator.State.Invalid, input_, len(input_)
|
||||||
return super().validate(input, pos)
|
return super().validate(input_, pos)
|
||||||
|
|
||||||
|
|
||||||
def _fm_width(fm, text):
|
def _fm_width(fm: QFontMetrics, text: str) -> int:
|
||||||
if hasattr(fm, "horizontalAdvance"):
|
if hasattr(fm, "horizontalAdvance"):
|
||||||
return fm.horizontalAdvance(text)
|
return fm.horizontalAdvance(text)
|
||||||
return fm.width(text)
|
return fm.width(text)
|
||||||
|
@@ -5,7 +5,6 @@ import re
|
|||||||
from dataclasses import dataclass, replace
|
from dataclasses import dataclass, replace
|
||||||
from typing import TYPE_CHECKING
|
from typing import TYPE_CHECKING
|
||||||
|
|
||||||
from qtpy import QT_VERSION
|
|
||||||
from qtpy.QtCore import Qt
|
from qtpy.QtCore import Qt
|
||||||
from qtpy.QtGui import (
|
from qtpy.QtGui import (
|
||||||
QBrush,
|
QBrush,
|
||||||
@@ -140,8 +139,9 @@ CATALINA_STYLE = replace(
|
|||||||
tick_offset=4,
|
tick_offset=4,
|
||||||
)
|
)
|
||||||
|
|
||||||
if QT_VERSION and int(QT_VERSION.split(".")[0]) == 6:
|
# I can no longer reproduce the cases in which this was necessary
|
||||||
CATALINA_STYLE = replace(CATALINA_STYLE, tick_offset=2)
|
# if QT_VERSION and int(QT_VERSION.split(".")[0]) == 6:
|
||||||
|
# CATALINA_STYLE = replace(CATALINA_STYLE, tick_offset=2)
|
||||||
|
|
||||||
BIG_SUR_STYLE = replace(
|
BIG_SUR_STYLE = replace(
|
||||||
CATALINA_STYLE,
|
CATALINA_STYLE,
|
||||||
@@ -155,8 +155,9 @@ BIG_SUR_STYLE = replace(
|
|||||||
tick_bar_alpha=0.2,
|
tick_bar_alpha=0.2,
|
||||||
)
|
)
|
||||||
|
|
||||||
if QT_VERSION and int(QT_VERSION.split(".")[0]) == 6:
|
# I can no longer reproduce the cases in which this was necessary
|
||||||
BIG_SUR_STYLE = replace(BIG_SUR_STYLE, tick_offset=-3)
|
# if QT_VERSION and int(QT_VERSION.split(".")[0]) == 6:
|
||||||
|
# BIG_SUR_STYLE = replace(BIG_SUR_STYLE, tick_offset=-3)
|
||||||
|
|
||||||
WINDOWS_STYLE = replace(
|
WINDOWS_STYLE = replace(
|
||||||
BASE_STYLE,
|
BASE_STYLE,
|
||||||
@@ -229,7 +230,7 @@ rgba_pattern = re.compile(
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def parse_color(color: str, default_attr) -> QColor | QGradient:
|
def parse_color(color: str, default_attr: str) -> QColor | QGradient:
|
||||||
qc = QColor(color)
|
qc = QColor(color)
|
||||||
if qc.isValid():
|
if qc.isValid():
|
||||||
return qc
|
return qc
|
||||||
@@ -241,6 +242,7 @@ def parse_color(color: str, default_attr) -> QColor | QGradient:
|
|||||||
|
|
||||||
# try linear gradient:
|
# try linear gradient:
|
||||||
match = qlineargrad_pattern.search(color)
|
match = qlineargrad_pattern.search(color)
|
||||||
|
grad: QGradient
|
||||||
if match:
|
if match:
|
||||||
grad = QLinearGradient(*(float(i) for i in match.groups()[:4]))
|
grad = QLinearGradient(*(float(i) for i in match.groups()[:4]))
|
||||||
grad.setColorAt(0, QColor(match.groupdict()["stop0"]))
|
grad.setColorAt(0, QColor(match.groupdict()["stop0"]))
|
||||||
@@ -259,11 +261,11 @@ def parse_color(color: str, default_attr) -> QColor | QGradient:
|
|||||||
return QColor(getattr(SYSTEM_STYLE, default_attr))
|
return QColor(getattr(SYSTEM_STYLE, default_attr))
|
||||||
|
|
||||||
|
|
||||||
def update_styles_from_stylesheet(obj: _GenericRangeSlider):
|
def update_styles_from_stylesheet(obj: _GenericRangeSlider) -> None:
|
||||||
qss: str = obj.styleSheet()
|
qss: str = obj.styleSheet()
|
||||||
|
|
||||||
parent = obj.parent()
|
parent = obj.parent()
|
||||||
while parent is not None:
|
while parent and hasattr(parent, "styleSheet"):
|
||||||
qss = parent.styleSheet() + qss
|
qss = parent.styleSheet() + qss
|
||||||
parent = parent.parent()
|
parent = parent.parent()
|
||||||
qss = QApplication.instance().styleSheet() + qss
|
qss = QApplication.instance().styleSheet() + qss
|
||||||
|
@@ -65,14 +65,20 @@ class QLargeIntSpinBox(QAbstractSpinBox):
|
|||||||
|
|
||||||
def setMinimum(self, min):
|
def setMinimum(self, min):
|
||||||
self._minimum = int(min)
|
self._minimum = int(min)
|
||||||
|
if self._minimum > self._value:
|
||||||
|
self.setValue(self._minimum)
|
||||||
|
|
||||||
def maximum(self):
|
def maximum(self):
|
||||||
return self._maximum
|
return self._maximum
|
||||||
|
|
||||||
def setMaximum(self, max):
|
def setMaximum(self, max):
|
||||||
self._maximum = int(max)
|
self._maximum = int(max)
|
||||||
|
if self._maximum < self._value:
|
||||||
|
self.setValue(self._maximum)
|
||||||
|
|
||||||
def setRange(self, minimum, maximum):
|
def setRange(self, minimum, maximum):
|
||||||
|
if maximum < minimum:
|
||||||
|
maximum = minimum
|
||||||
self.setMinimum(minimum)
|
self.setMinimum(minimum)
|
||||||
self.setMaximum(maximum)
|
self.setMaximum(maximum)
|
||||||
|
|
||||||
|
@@ -1,8 +1,16 @@
|
|||||||
|
from typing import TYPE_CHECKING, Any
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from superqt.cmap import draw_colormap # noqa: TCH004
|
||||||
|
|
||||||
__all__ = (
|
__all__ = (
|
||||||
"CodeSyntaxHighlight",
|
"CodeSyntaxHighlight",
|
||||||
"create_worker",
|
"create_worker",
|
||||||
|
"qimage_to_array",
|
||||||
|
"draw_colormap",
|
||||||
"ensure_main_thread",
|
"ensure_main_thread",
|
||||||
"ensure_object_thread",
|
"ensure_object_thread",
|
||||||
|
"exceptions_as_dialog",
|
||||||
"FunctionWorker",
|
"FunctionWorker",
|
||||||
"GeneratorWorker",
|
"GeneratorWorker",
|
||||||
"new_worker_qthread",
|
"new_worker_qthread",
|
||||||
@@ -14,12 +22,12 @@ __all__ = (
|
|||||||
"signals_blocked",
|
"signals_blocked",
|
||||||
"thread_worker",
|
"thread_worker",
|
||||||
"WorkerBase",
|
"WorkerBase",
|
||||||
"exceptions_as_dialog",
|
|
||||||
)
|
)
|
||||||
|
|
||||||
from ._code_syntax_highlight import CodeSyntaxHighlight
|
from ._code_syntax_highlight import CodeSyntaxHighlight
|
||||||
from ._ensure_thread import ensure_main_thread, ensure_object_thread
|
from ._ensure_thread import ensure_main_thread, ensure_object_thread
|
||||||
from ._errormsg_context import exceptions_as_dialog
|
from ._errormsg_context import exceptions_as_dialog
|
||||||
|
from ._img_utils import qimage_to_array
|
||||||
from ._message_handler import QMessageHandler
|
from ._message_handler import QMessageHandler
|
||||||
from ._misc import signals_blocked
|
from ._misc import signals_blocked
|
||||||
from ._qthreading import (
|
from ._qthreading import (
|
||||||
@@ -31,3 +39,11 @@ from ._qthreading import (
|
|||||||
thread_worker,
|
thread_worker,
|
||||||
)
|
)
|
||||||
from ._throttler import QSignalDebouncer, QSignalThrottler, qdebounced, qthrottled
|
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}")
|
||||||
|
@@ -1,5 +1,3 @@
|
|||||||
from itertools import takewhile
|
|
||||||
|
|
||||||
from pygments import highlight
|
from pygments import highlight
|
||||||
from pygments.formatter import Formatter
|
from pygments.formatter import Formatter
|
||||||
from pygments.lexers import find_lexer_class, get_lexer_by_name
|
from pygments.lexers import find_lexer_class, get_lexer_by_name
|
||||||
@@ -68,21 +66,10 @@ class CodeSyntaxHighlight(QtGui.QSyntaxHighlighter):
|
|||||||
return self.formatter.style.background_color
|
return self.formatter.style.background_color
|
||||||
|
|
||||||
def highlightBlock(self, text):
|
def highlightBlock(self, text):
|
||||||
cb = self.currentBlock()
|
|
||||||
p = cb.position()
|
|
||||||
text_ = self.document().toPlainText() + "\n"
|
|
||||||
highlight(text_, self.lexer, self.formatter)
|
|
||||||
|
|
||||||
enters = sum(1 for _ in takewhile(lambda x: x == "\n", text_))
|
|
||||||
# pygments lexer ignore leading empty lines, so we need to do correction
|
|
||||||
# here calculating the number of empty lines.
|
|
||||||
|
|
||||||
# dirty, dirty hack
|
# dirty, dirty hack
|
||||||
# The core problem is that pygemnts by default use string streams,
|
# The core problem is that pygemnts by default use string streams,
|
||||||
# that will not handle QTextCharFormat, so we need use `data` property to
|
# that will not handle QTextCharFormat, so we need use `data` property to
|
||||||
# work around this.
|
# work around this.
|
||||||
|
highlight(text, self.lexer, self.formatter)
|
||||||
for i in range(len(text)):
|
for i in range(len(text)):
|
||||||
try:
|
self.setFormat(i, 1, self.formatter.data[i])
|
||||||
self.setFormat(i, 1, self.formatter.data[p + i - enters])
|
|
||||||
except IndexError: # pragma: no cover
|
|
||||||
pass
|
|
||||||
|
@@ -2,6 +2,7 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from concurrent.futures import Future
|
from concurrent.futures import Future
|
||||||
|
from contextlib import suppress
|
||||||
from functools import wraps
|
from functools import wraps
|
||||||
from typing import TYPE_CHECKING, Any, Callable, ClassVar, overload
|
from typing import TYPE_CHECKING, Any, Callable, ClassVar, overload
|
||||||
|
|
||||||
@@ -41,7 +42,8 @@ class CallCallable(QObject):
|
|||||||
def call(self):
|
def call(self):
|
||||||
CallCallable.instances.remove(self)
|
CallCallable.instances.remove(self)
|
||||||
res = self._callable(*self._args, **self._kwargs)
|
res = self._callable(*self._args, **self._kwargs)
|
||||||
self.finished.emit(res)
|
with suppress(RuntimeError):
|
||||||
|
self.finished.emit(res)
|
||||||
|
|
||||||
|
|
||||||
# fmt: off
|
# fmt: off
|
||||||
@@ -68,8 +70,6 @@ def ensure_main_thread(
|
|||||||
timeout: int = 1000,
|
timeout: int = 1000,
|
||||||
) -> Callable[P, Future[R]]: ...
|
) -> Callable[P, Future[R]]: ...
|
||||||
# fmt: on
|
# fmt: on
|
||||||
|
|
||||||
|
|
||||||
def ensure_main_thread(
|
def ensure_main_thread(
|
||||||
func: Callable | None = None, await_return: bool = False, timeout: int = 1000
|
func: Callable | None = None, await_return: bool = False, timeout: int = 1000
|
||||||
):
|
):
|
||||||
@@ -132,8 +132,6 @@ def ensure_object_thread(
|
|||||||
timeout: int = 1000,
|
timeout: int = 1000,
|
||||||
) -> Callable[P, Future[R]]: ...
|
) -> Callable[P, Future[R]]: ...
|
||||||
# fmt: on
|
# fmt: on
|
||||||
|
|
||||||
|
|
||||||
def ensure_object_thread(
|
def ensure_object_thread(
|
||||||
func: Callable | None = None, await_return: bool = False, timeout: int = 1000
|
func: Callable | None = None, await_return: bool = False, timeout: int = 1000
|
||||||
):
|
):
|
||||||
@@ -187,5 +185,5 @@ def _run_in_thread(
|
|||||||
f = CallCallable(func, args, kwargs)
|
f = CallCallable(func, args, kwargs)
|
||||||
f.moveToThread(thread)
|
f.moveToThread(thread)
|
||||||
f.finished.connect(future.set_result, Qt.ConnectionType.DirectConnection)
|
f.finished.connect(future.set_result, Qt.ConnectionType.DirectConnection)
|
||||||
QMetaObject.invokeMethod(f, "call", Qt.ConnectionType.QueuedConnection) # type: ignore # noqa
|
QMetaObject.invokeMethod(f, "call", Qt.ConnectionType.QueuedConnection) # type: ignore
|
||||||
return future.result(timeout=timeout / 1000) if await_return else future
|
return future.result(timeout=timeout / 1000) if await_return else future
|
||||||
|
@@ -1,7 +1,6 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import traceback
|
import traceback
|
||||||
from contextlib import AbstractContextManager
|
|
||||||
from typing import TYPE_CHECKING, cast
|
from typing import TYPE_CHECKING, cast
|
||||||
|
|
||||||
from qtpy.QtCore import Qt
|
from qtpy.QtCore import Qt
|
||||||
@@ -14,7 +13,7 @@ if TYPE_CHECKING:
|
|||||||
_DEFAULT_FLAGS = Qt.WindowType.Dialog | Qt.WindowType.MSWindowsFixedSizeDialogHint
|
_DEFAULT_FLAGS = Qt.WindowType.Dialog | Qt.WindowType.MSWindowsFixedSizeDialogHint
|
||||||
|
|
||||||
|
|
||||||
class exceptions_as_dialog(AbstractContextManager):
|
class exceptions_as_dialog:
|
||||||
"""Context manager that shows a dialog when an exception is raised.
|
"""Context manager that shows a dialog when an exception is raised.
|
||||||
|
|
||||||
See examples below for common usage patterns.
|
See examples below for common usage patterns.
|
||||||
@@ -66,8 +65,8 @@ class exceptions_as_dialog(AbstractContextManager):
|
|||||||
exception : BaseException | None
|
exception : BaseException | None
|
||||||
Will hold the exception instance if an exception was raised and caught.
|
Will hold the exception instance if an exception was raised and caught.
|
||||||
|
|
||||||
Examplez
|
Examples
|
||||||
-------
|
--------
|
||||||
```python
|
```python
|
||||||
from qtpy.QtWidgets import QApplication
|
from qtpy.QtWidgets import QApplication
|
||||||
from superqt.utils import exceptions_as_dialog
|
from superqt.utils import exceptions_as_dialog
|
||||||
|
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)
|
@@ -38,7 +38,7 @@ class QMessageHandler:
|
|||||||
|
|
||||||
>>> logger = logging.getLogger(__name__)
|
>>> logger = logging.getLogger(__name__)
|
||||||
>>> with QMessageHandler(logger): # re-reoute Qt messages to a python logger.
|
>>> with QMessageHandler(logger): # re-reoute Qt messages to a python logger.
|
||||||
... ...
|
... ...
|
||||||
"""
|
"""
|
||||||
|
|
||||||
_qt2loggertype: ClassVar[dict[QtMsgType, int]] = {
|
_qt2loggertype: ClassVar[dict[QtMsgType, int]] = {
|
||||||
|
@@ -23,16 +23,13 @@ if TYPE_CHECKING:
|
|||||||
|
|
||||||
class SigInst(Generic[_T]):
|
class SigInst(Generic[_T]):
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def connect(slot: Callable[[_T], Any], type: type | None = ...) -> None:
|
def connect(slot: Callable[[_T], Any], type: type | None = ...) -> None: ...
|
||||||
...
|
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def disconnect(slot: Callable[[_T], Any] = ...) -> None:
|
def disconnect(slot: Callable[[_T], Any] = ...) -> None: ...
|
||||||
...
|
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def emit(*args: _T) -> None:
|
def emit(*args: _T) -> None: ...
|
||||||
...
|
|
||||||
|
|
||||||
from typing_extensions import Literal, ParamSpec
|
from typing_extensions import Literal, ParamSpec
|
||||||
|
|
||||||
@@ -52,7 +49,7 @@ _R = TypeVar("_R")
|
|||||||
|
|
||||||
|
|
||||||
def as_generator_function(
|
def as_generator_function(
|
||||||
func: Callable[_P, _R]
|
func: Callable[_P, _R],
|
||||||
) -> Callable[_P, Generator[None, None, _R]]:
|
) -> Callable[_P, Generator[None, None, _R]]:
|
||||||
"""Turns a regular function (single return) into a generator function."""
|
"""Turns a regular function (single return) into a generator function."""
|
||||||
|
|
||||||
@@ -211,7 +208,6 @@ class WorkerBase(QRunnable, Generic[_R]):
|
|||||||
--------
|
--------
|
||||||
```python
|
```python
|
||||||
class MyWorker(WorkerBase):
|
class MyWorker(WorkerBase):
|
||||||
|
|
||||||
def work(self):
|
def work(self):
|
||||||
i = 0
|
i = 0
|
||||||
while True:
|
while True:
|
||||||
@@ -499,8 +495,7 @@ def create_worker(
|
|||||||
_worker_class: type[GeneratorWorker] | type[FunctionWorker] | None = None,
|
_worker_class: type[GeneratorWorker] | type[FunctionWorker] | None = None,
|
||||||
_ignore_errors: bool = False,
|
_ignore_errors: bool = False,
|
||||||
**kwargs,
|
**kwargs,
|
||||||
) -> GeneratorWorker[_Y, _S, _R]:
|
) -> GeneratorWorker[_Y, _S, _R]: ...
|
||||||
...
|
|
||||||
|
|
||||||
|
|
||||||
@overload
|
@overload
|
||||||
@@ -512,8 +507,7 @@ def create_worker(
|
|||||||
_worker_class: type[GeneratorWorker] | type[FunctionWorker] | None = None,
|
_worker_class: type[GeneratorWorker] | type[FunctionWorker] | None = None,
|
||||||
_ignore_errors: bool = False,
|
_ignore_errors: bool = False,
|
||||||
**kwargs,
|
**kwargs,
|
||||||
) -> FunctionWorker[_R]:
|
) -> FunctionWorker[_R]: ...
|
||||||
...
|
|
||||||
|
|
||||||
|
|
||||||
def create_worker(
|
def create_worker(
|
||||||
@@ -574,8 +568,10 @@ def create_worker(
|
|||||||
```python
|
```python
|
||||||
def long_function(duration):
|
def long_function(duration):
|
||||||
import time
|
import time
|
||||||
|
|
||||||
time.sleep(duration)
|
time.sleep(duration)
|
||||||
|
|
||||||
|
|
||||||
worker = create_worker(long_function, 10)
|
worker = create_worker(long_function, 10)
|
||||||
```
|
```
|
||||||
"""
|
"""
|
||||||
@@ -630,8 +626,7 @@ def thread_worker(
|
|||||||
connect: dict[str, Callable | Sequence[Callable]] | None = None,
|
connect: dict[str, Callable | Sequence[Callable]] | None = None,
|
||||||
worker_class: type[WorkerBase] | None = None,
|
worker_class: type[WorkerBase] | None = None,
|
||||||
ignore_errors: bool = False,
|
ignore_errors: bool = False,
|
||||||
) -> Callable[_P, GeneratorWorker[_Y, _S, _R]]:
|
) -> Callable[_P, GeneratorWorker[_Y, _S, _R]]: ...
|
||||||
...
|
|
||||||
|
|
||||||
|
|
||||||
@overload
|
@overload
|
||||||
@@ -641,8 +636,7 @@ def thread_worker(
|
|||||||
connect: dict[str, Callable | Sequence[Callable]] | None = None,
|
connect: dict[str, Callable | Sequence[Callable]] | None = None,
|
||||||
worker_class: type[WorkerBase] | None = None,
|
worker_class: type[WorkerBase] | None = None,
|
||||||
ignore_errors: bool = False,
|
ignore_errors: bool = False,
|
||||||
) -> Callable[_P, FunctionWorker[_R]]:
|
) -> Callable[_P, FunctionWorker[_R]]: ...
|
||||||
...
|
|
||||||
|
|
||||||
|
|
||||||
@overload
|
@overload
|
||||||
@@ -652,8 +646,7 @@ def thread_worker(
|
|||||||
connect: dict[str, Callable | Sequence[Callable]] | None = None,
|
connect: dict[str, Callable | Sequence[Callable]] | None = None,
|
||||||
worker_class: type[WorkerBase] | None = None,
|
worker_class: type[WorkerBase] | None = None,
|
||||||
ignore_errors: bool = False,
|
ignore_errors: bool = False,
|
||||||
) -> Callable[[Callable], Callable[_P, FunctionWorker | GeneratorWorker]]:
|
) -> Callable[[Callable], Callable[_P, FunctionWorker | GeneratorWorker]]: ...
|
||||||
...
|
|
||||||
|
|
||||||
|
|
||||||
def thread_worker(
|
def thread_worker(
|
||||||
@@ -737,7 +730,8 @@ def thread_worker(
|
|||||||
yield i
|
yield i
|
||||||
|
|
||||||
# do teardown
|
# do teardown
|
||||||
return 'anything'
|
return "anything"
|
||||||
|
|
||||||
|
|
||||||
# call the function to start running in another thread.
|
# call the function to start running in another thread.
|
||||||
worker = long_function()
|
worker = long_function()
|
||||||
@@ -790,8 +784,7 @@ if TYPE_CHECKING:
|
|||||||
class WorkerProtocol(QObject):
|
class WorkerProtocol(QObject):
|
||||||
finished: Signal
|
finished: Signal
|
||||||
|
|
||||||
def work(self) -> None:
|
def work(self) -> None: ...
|
||||||
...
|
|
||||||
|
|
||||||
|
|
||||||
def new_worker_qthread(
|
def new_worker_qthread(
|
||||||
@@ -846,9 +839,7 @@ def new_worker_qthread(
|
|||||||
Create some QObject that has a long-running work method:
|
Create some QObject that has a long-running work method:
|
||||||
|
|
||||||
```python
|
```python
|
||||||
|
|
||||||
class Worker(QObject):
|
class Worker(QObject):
|
||||||
|
|
||||||
finished = Signal()
|
finished = Signal()
|
||||||
increment = Signal(int)
|
increment = Signal(int)
|
||||||
|
|
||||||
@@ -860,16 +851,18 @@ def new_worker_qthread(
|
|||||||
def work(self):
|
def work(self):
|
||||||
# some long running task...
|
# some long running task...
|
||||||
import time
|
import time
|
||||||
|
|
||||||
for i in range(10):
|
for i in range(10):
|
||||||
time.sleep(1)
|
time.sleep(1)
|
||||||
self.increment.emit(i)
|
self.increment.emit(i)
|
||||||
self.finished.emit()
|
self.finished.emit()
|
||||||
|
|
||||||
|
|
||||||
worker, thread = new_worker_qthread(
|
worker, thread = new_worker_qthread(
|
||||||
Worker,
|
Worker,
|
||||||
'argument',
|
"argument",
|
||||||
_start_thread=True,
|
_start_thread=True,
|
||||||
_connect={'increment': print},
|
_connect={"increment": print},
|
||||||
)
|
)
|
||||||
```
|
```
|
||||||
"""
|
"""
|
||||||
|
@@ -26,13 +26,18 @@ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|||||||
SOFTWARE.
|
SOFTWARE.
|
||||||
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import warnings
|
||||||
from concurrent.futures import Future
|
from concurrent.futures import Future
|
||||||
|
from contextlib import suppress
|
||||||
from enum import IntFlag, auto
|
from enum import IntFlag, auto
|
||||||
from functools import wraps
|
from functools import wraps
|
||||||
from typing import TYPE_CHECKING, Callable, Generic, TypeVar, overload
|
from inspect import signature
|
||||||
from weakref import WeakKeyDictionary
|
from types import MethodType
|
||||||
|
from typing import TYPE_CHECKING, Any, Callable, Generic, TypeVar, overload
|
||||||
|
from weakref import WeakKeyDictionary, WeakMethod
|
||||||
|
|
||||||
from qtpy.QtCore import QObject, Qt, QTimer, Signal
|
from qtpy.QtCore import QObject, Qt, QTimer, Signal
|
||||||
|
|
||||||
@@ -52,6 +57,12 @@ else:
|
|||||||
P = TypeVar("P")
|
P = TypeVar("P")
|
||||||
|
|
||||||
R = TypeVar("R")
|
R = TypeVar("R")
|
||||||
|
REF_ERROR = (
|
||||||
|
"To use qthrottled or qdebounced as a method decorator, "
|
||||||
|
"objects must have `__dict__` or be weak referenceable. "
|
||||||
|
"Please either add `__weakref__` to `__slots__` or use"
|
||||||
|
"qthrottled/qdebounced as a function (not a decorator)."
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class Kind(IntFlag):
|
class Kind(IntFlag):
|
||||||
@@ -139,18 +150,28 @@ class GenericSignalThrottler(QObject):
|
|||||||
"""Cancel any pending emissions."""
|
"""Cancel any pending emissions."""
|
||||||
self._hasPendingEmission = False
|
self._hasPendingEmission = False
|
||||||
|
|
||||||
def flush(self) -> None:
|
def flush(self, restart_timer: bool = True) -> None:
|
||||||
"""Force emission of any pending emissions."""
|
"""
|
||||||
self._maybeEmitTriggered()
|
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:
|
def _emitTriggered(self) -> None:
|
||||||
self._hasPendingEmission = False
|
self._hasPendingEmission = False
|
||||||
self.triggered.emit()
|
self.triggered.emit()
|
||||||
self._timer.start()
|
self._timer.start()
|
||||||
|
|
||||||
def _maybeEmitTriggered(self) -> None:
|
def _maybeEmitTriggered(self, restart_timer: bool = True) -> None:
|
||||||
if self._hasPendingEmission:
|
if self._hasPendingEmission:
|
||||||
self._emitTriggered()
|
self._emitTriggered()
|
||||||
|
if not restart_timer:
|
||||||
|
self._timer.stop()
|
||||||
|
|
||||||
Kind = Kind
|
Kind = Kind
|
||||||
EmissionPolicy = EmissionPolicy
|
EmissionPolicy = EmissionPolicy
|
||||||
@@ -192,6 +213,26 @@ class QSignalDebouncer(GenericSignalThrottler):
|
|||||||
# below here part is unique to superqt (not from KD)
|
# below here part is unique to superqt (not from KD)
|
||||||
|
|
||||||
|
|
||||||
|
def _weak_func(func: Callable[P, R]) -> Callable[P, R]:
|
||||||
|
if isinstance(func, MethodType):
|
||||||
|
# this is a bound method, we need to avoid strong references
|
||||||
|
try:
|
||||||
|
weak_method = WeakMethod(func)
|
||||||
|
except TypeError as e:
|
||||||
|
raise TypeError(REF_ERROR) from e
|
||||||
|
|
||||||
|
def weak_func(*args, **kwargs):
|
||||||
|
if method := weak_method():
|
||||||
|
return method(*args, **kwargs)
|
||||||
|
warnings.warn(
|
||||||
|
"Method has been garbage collected", RuntimeWarning, stacklevel=2
|
||||||
|
)
|
||||||
|
|
||||||
|
return weak_func
|
||||||
|
|
||||||
|
return func
|
||||||
|
|
||||||
|
|
||||||
class ThrottledCallable(GenericSignalThrottler, Generic[P, R]):
|
class ThrottledCallable(GenericSignalThrottler, Generic[P, R]):
|
||||||
def __init__(
|
def __init__(
|
||||||
self,
|
self,
|
||||||
@@ -203,26 +244,32 @@ class ThrottledCallable(GenericSignalThrottler, Generic[P, R]):
|
|||||||
super().__init__(kind, emissionPolicy, parent)
|
super().__init__(kind, emissionPolicy, parent)
|
||||||
|
|
||||||
self._future: Future[R] = Future()
|
self._future: Future[R] = Future()
|
||||||
if isinstance(func, staticmethod):
|
|
||||||
self._func = func.__func__
|
|
||||||
else:
|
|
||||||
self._func = func
|
|
||||||
|
|
||||||
self.__wrapped__ = func
|
self._is_static_method: bool = False
|
||||||
|
if isinstance(func, staticmethod):
|
||||||
|
self._is_static_method = True
|
||||||
|
func = func.__func__
|
||||||
|
|
||||||
|
max_args = get_max_args(func)
|
||||||
|
with suppress(TypeError, ValueError):
|
||||||
|
self.__signature__ = signature(func)
|
||||||
|
|
||||||
|
self._func = _weak_func(func)
|
||||||
|
self.__wrapped__ = self._func
|
||||||
|
|
||||||
self._args: tuple = ()
|
self._args: tuple = ()
|
||||||
self._kwargs: dict = {}
|
self._kwargs: dict = {}
|
||||||
self.triggered.connect(self._set_future_result)
|
self.triggered.connect(self._set_future_result)
|
||||||
self._name = None
|
self._name = None
|
||||||
|
|
||||||
self._obj_dkt = WeakKeyDictionary()
|
self._obj_dkt: WeakKeyDictionary[Any, ThrottledCallable] = WeakKeyDictionary()
|
||||||
|
|
||||||
# even if we were to compile __call__ with a signature matching that of func,
|
# even if we were to compile __call__ with a signature matching that of func,
|
||||||
# PySide wouldn't correctly inspect the signature of the ThrottledCallable
|
# PySide wouldn't correctly inspect the signature of the ThrottledCallable
|
||||||
# instance: https://bugreports.qt.io/browse/PYSIDE-2423
|
# instance: https://bugreports.qt.io/browse/PYSIDE-2423
|
||||||
# so we do it ourselfs and limit the number of positional arguments
|
# so we do it ourselfs and limit the number of positional arguments
|
||||||
# that we pass to func
|
# that we pass to func
|
||||||
self._max_args: int | None = get_max_args(self._func)
|
self._max_args: int | None = max_args
|
||||||
|
|
||||||
def __call__(self, *args: P.args, **kwargs: P.kwargs) -> "Future[R]": # noqa
|
def __call__(self, *args: P.args, **kwargs: P.kwargs) -> "Future[R]": # noqa
|
||||||
if not self._future.done():
|
if not self._future.done():
|
||||||
@@ -240,12 +287,18 @@ class ThrottledCallable(GenericSignalThrottler, Generic[P, R]):
|
|||||||
self._future.set_result(result)
|
self._future.set_result(result)
|
||||||
|
|
||||||
def __set_name__(self, owner, name):
|
def __set_name__(self, owner, name):
|
||||||
if not isinstance(self.__wrapped__, staticmethod):
|
if not self._is_static_method:
|
||||||
self._name = name
|
self._name = name
|
||||||
|
|
||||||
def _get_throttler(self, instance, owner, parent, obj):
|
def _get_throttler(self, instance, owner, parent, obj, name):
|
||||||
|
try:
|
||||||
|
bound_method = self._func.__get__(instance, owner)
|
||||||
|
except Exception as e: # pragma: no cover
|
||||||
|
raise RuntimeError(
|
||||||
|
f"Failed to bind function {self._func!r} to object {instance!r}"
|
||||||
|
) from e
|
||||||
throttler = ThrottledCallable(
|
throttler = ThrottledCallable(
|
||||||
self.__wrapped__.__get__(instance, owner),
|
bound_method,
|
||||||
self._kind,
|
self._kind,
|
||||||
self._emissionPolicy,
|
self._emissionPolicy,
|
||||||
parent=parent,
|
parent=parent,
|
||||||
@@ -253,21 +306,12 @@ class ThrottledCallable(GenericSignalThrottler, Generic[P, R]):
|
|||||||
throttler.setTimerType(self.timerType())
|
throttler.setTimerType(self.timerType())
|
||||||
throttler.setTimeout(self.timeout())
|
throttler.setTimeout(self.timeout())
|
||||||
try:
|
try:
|
||||||
setattr(
|
setattr(obj, name, throttler)
|
||||||
obj,
|
|
||||||
self._name,
|
|
||||||
throttler,
|
|
||||||
)
|
|
||||||
except AttributeError:
|
except AttributeError:
|
||||||
try:
|
try:
|
||||||
self._obj_dkt[obj] = throttler
|
self._obj_dkt[obj] = throttler
|
||||||
except TypeError as e:
|
except TypeError as e:
|
||||||
raise TypeError(
|
raise TypeError(REF_ERROR) from e
|
||||||
"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
|
return throttler
|
||||||
|
|
||||||
def __get__(self, instance, owner):
|
def __get__(self, instance, owner):
|
||||||
@@ -281,7 +325,7 @@ class ThrottledCallable(GenericSignalThrottler, Generic[P, R]):
|
|||||||
if parent is None and isinstance(instance, QObject):
|
if parent is None and isinstance(instance, QObject):
|
||||||
parent = instance
|
parent = instance
|
||||||
|
|
||||||
return self._get_throttler(instance, owner, parent, instance)
|
return self._get_throttler(instance, owner, parent, instance, self._name)
|
||||||
|
|
||||||
|
|
||||||
@overload
|
@overload
|
||||||
@@ -291,8 +335,7 @@ def qthrottled(
|
|||||||
leading: bool = True,
|
leading: bool = True,
|
||||||
timer_type: Qt.TimerType = Qt.TimerType.PreciseTimer,
|
timer_type: Qt.TimerType = Qt.TimerType.PreciseTimer,
|
||||||
parent: QObject | None = None,
|
parent: QObject | None = None,
|
||||||
) -> ThrottledCallable[P, R]:
|
) -> ThrottledCallable[P, R]: ...
|
||||||
...
|
|
||||||
|
|
||||||
|
|
||||||
@overload
|
@overload
|
||||||
@@ -302,8 +345,7 @@ def qthrottled(
|
|||||||
leading: bool = True,
|
leading: bool = True,
|
||||||
timer_type: Qt.TimerType = Qt.TimerType.PreciseTimer,
|
timer_type: Qt.TimerType = Qt.TimerType.PreciseTimer,
|
||||||
parent: QObject | None = None,
|
parent: QObject | None = None,
|
||||||
) -> Callable[[Callable[P, R]], ThrottledCallable[P, R]]:
|
) -> Callable[[Callable[P, R]], ThrottledCallable[P, R]]: ...
|
||||||
...
|
|
||||||
|
|
||||||
|
|
||||||
def qthrottled(
|
def qthrottled(
|
||||||
@@ -354,8 +396,7 @@ def qdebounced(
|
|||||||
leading: bool = False,
|
leading: bool = False,
|
||||||
timer_type: Qt.TimerType = Qt.TimerType.PreciseTimer,
|
timer_type: Qt.TimerType = Qt.TimerType.PreciseTimer,
|
||||||
parent: QObject | None = None,
|
parent: QObject | None = None,
|
||||||
) -> ThrottledCallable[P, R]:
|
) -> ThrottledCallable[P, R]: ...
|
||||||
...
|
|
||||||
|
|
||||||
|
|
||||||
@overload
|
@overload
|
||||||
@@ -365,8 +406,7 @@ def qdebounced(
|
|||||||
leading: bool = False,
|
leading: bool = False,
|
||||||
timer_type: Qt.TimerType = Qt.TimerType.PreciseTimer,
|
timer_type: Qt.TimerType = Qt.TimerType.PreciseTimer,
|
||||||
parent: QObject | None = None,
|
parent: QObject | None = None,
|
||||||
) -> Callable[[Callable[P, R]], ThrottledCallable[P, R]]:
|
) -> Callable[[Callable[P, R]], ThrottledCallable[P, R]]: ...
|
||||||
...
|
|
||||||
|
|
||||||
|
|
||||||
def qdebounced(
|
def qdebounced(
|
||||||
@@ -431,6 +471,11 @@ def _make_decorator(
|
|||||||
obj = ThrottledCallable(func, kind, policy, parent=parent)
|
obj = ThrottledCallable(func, kind, policy, parent=parent)
|
||||||
obj.setTimerType(timer_type)
|
obj.setTimerType(timer_type)
|
||||||
obj.setTimeout(timeout)
|
obj.setTimeout(timeout)
|
||||||
|
|
||||||
|
if instance is not None:
|
||||||
|
# this is a bound method, we need to avoid strong references,
|
||||||
|
# and functools.wraps will prevent garbage collection on bound methods
|
||||||
|
return obj
|
||||||
return wraps(func)(obj)
|
return wraps(func)(obj)
|
||||||
|
|
||||||
return deco(func) if func is not None else deco
|
return deco(func) if func is not None else deco
|
||||||
|
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
|
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")
|
@@ -1,4 +1,5 @@
|
|||||||
from enum import Enum
|
import sys
|
||||||
|
from enum import Enum, Flag, IntEnum, IntFlag
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
@@ -36,6 +37,42 @@ class Enum4(Enum):
|
|||||||
c_3 = 3
|
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):
|
def test_simple_create(qtbot):
|
||||||
enum = QEnumComboBox(enum_class=Enum1)
|
enum = QEnumComboBox(enum_class=Enum1)
|
||||||
qtbot.addWidget(enum)
|
qtbot.addWidget(enum)
|
||||||
@@ -129,3 +166,65 @@ def test_optional(qtbot):
|
|||||||
enum.setCurrentEnum(None)
|
enum.setCurrentEnum(None)
|
||||||
assert enum.currentText() == NONE_STRING
|
assert enum.currentText() == NONE_STRING
|
||||||
assert enum.currentEnum() is None
|
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"]
|
||||||
|
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)
|
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):
|
def test_large_spinbox_type(qtbot):
|
||||||
sb = QLargeIntSpinBox()
|
sb = QLargeIntSpinBox()
|
||||||
qtbot.addWidget(sb)
|
qtbot.addWidget(sb)
|
||||||
|
@@ -1,3 +1,5 @@
|
|||||||
|
import gc
|
||||||
|
import weakref
|
||||||
from unittest.mock import Mock
|
from unittest.mock import Mock
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
@@ -27,6 +29,41 @@ def test_debounced(qtbot):
|
|||||||
assert mock2.call_count == 10
|
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):
|
def test_debouncer_method(qtbot):
|
||||||
class A(QObject):
|
class A(QObject):
|
||||||
def __init__(self):
|
def __init__(self):
|
||||||
@@ -81,7 +118,6 @@ def test_debouncer_method_definition(qtbot):
|
|||||||
A.call2(32)
|
A.call2(32)
|
||||||
|
|
||||||
qtbot.wait(5)
|
qtbot.wait(5)
|
||||||
|
|
||||||
assert a.count == 1
|
assert a.count == 1
|
||||||
mock1.assert_called_once()
|
mock1.assert_called_once()
|
||||||
mock2.assert_called_once()
|
mock2.assert_called_once()
|
||||||
@@ -166,3 +202,36 @@ def test_ensure_throttled_sig_inspection(deco, qtbot):
|
|||||||
mock.assert_called_once_with(1, 2)
|
mock.assert_called_once_with(1, 2)
|
||||||
assert func.__doc__ == "docstring"
|
assert func.__doc__ == "docstring"
|
||||||
assert func.__name__ == "func"
|
assert func.__name__ == "func"
|
||||||
|
|
||||||
|
|
||||||
|
def test_qthrottled_does_not_prevent_gc(qtbot):
|
||||||
|
mock = Mock()
|
||||||
|
|
||||||
|
class Thing:
|
||||||
|
@qdebounced(timeout=1)
|
||||||
|
def dmethod(self) -> None:
|
||||||
|
mock()
|
||||||
|
|
||||||
|
@qthrottled(timeout=1)
|
||||||
|
def tmethod(self, x: int = 1) -> None:
|
||||||
|
mock()
|
||||||
|
|
||||||
|
thing = Thing()
|
||||||
|
thing_ref = weakref.ref(thing)
|
||||||
|
assert thing_ref() is not None
|
||||||
|
thing.dmethod()
|
||||||
|
qtbot.waitUntil(thing.dmethod._future.done, timeout=2000)
|
||||||
|
assert mock.call_count == 1
|
||||||
|
thing.tmethod()
|
||||||
|
qtbot.waitUntil(thing.tmethod._future.done, timeout=2000)
|
||||||
|
assert mock.call_count == 2
|
||||||
|
|
||||||
|
wm = thing.tmethod
|
||||||
|
assert isinstance(wm, ThrottledCallable)
|
||||||
|
del thing
|
||||||
|
gc.collect()
|
||||||
|
assert thing_ref() is None
|
||||||
|
|
||||||
|
with pytest.warns(RuntimeWarning, match="Method has been garbage collected"):
|
||||||
|
wm()
|
||||||
|
wm._set_future_result()
|
||||||
|
Reference in New Issue
Block a user