Compare commits
182 Commits
Author | SHA1 | Date | |
---|---|---|---|
|
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 | ||
|
8525efd98c | ||
|
f676d7e171 | ||
|
599dff7d02 | ||
|
ed960f4994 | ||
|
7fcba7a485 | ||
|
619daae13f | ||
|
462eeada93 | ||
|
8457563f49 | ||
|
504adf8bd0 | ||
|
64dfb43d9e | ||
|
1da26ce7c2 | ||
|
41ea4e8907 | ||
|
39b6a0596f | ||
|
9ff01e757b | ||
|
dd9af3bfed | ||
|
7b964beb89 | ||
|
0407fdc4bd | ||
|
9119336de5 | ||
|
6318675a8c | ||
|
efa2757111 | ||
|
402d237bc4 | ||
|
dc255bdeac | ||
|
ae186df2ae | ||
|
0002d5ee37 | ||
|
f990fea78c | ||
|
1fb46854d4 | ||
|
ca4a1ecb20 | ||
|
c22b7d6f07 | ||
|
bb43cd7fad | ||
|
09c76a0bfa | ||
|
183899c4e7 | ||
|
a39b467563 | ||
|
6ce87d44a6 | ||
|
2cebc868a8 | ||
|
6abd3a21a6 | ||
|
7b2d8bfb2d | ||
|
ad2f05d908 | ||
|
3df7f49706 | ||
|
e98936e8d8 | ||
|
532d3bf89c | ||
|
16b383e783 | ||
|
38d15d1b3b | ||
|
8f09c38074 | ||
|
3c8b5bcf98 | ||
|
3ece7a27b1 | ||
|
e0bb2ea871 | ||
|
78997fe155 | ||
|
021f164419 | ||
|
7f50e69e28 | ||
|
2c747c5a4f | ||
|
b79c8e95b7 | ||
|
b393c6d039 | ||
|
61b8ab30ab | ||
|
abf544cf0e | ||
|
9f9dab6f3b | ||
|
97bb814451 | ||
|
d1c056886f | ||
|
a73e56bb83 | ||
|
6f71e46914 | ||
|
fbc67a745c | ||
|
77bd737e13 | ||
|
ba626e8786 | ||
|
04efa95511 | ||
|
f401d6d59c | ||
|
a3bd0d0edf | ||
|
e7e8dfc44c | ||
|
a556f16745 | ||
|
2864058974 | ||
|
463332f4fc | ||
|
f08e2d1720 | ||
|
39c10aa238 | ||
|
d5d40a35f3 | ||
|
5b92a19b82 | ||
|
a3b0f1b115 | ||
|
b1e6d55957 | ||
|
55535b7600 | ||
|
31c834053c | ||
|
69219c846d | ||
|
2edb3c287e | ||
|
218a7b4034 | ||
|
9ab24dbcf6 | ||
|
35acbbf5e6 | ||
|
0ae3350c57 | ||
|
c7f8780900 | ||
|
cc25733ce8 | ||
|
accb87021f | ||
|
ccad397838 | ||
|
68248c920c | ||
|
f8ac85aaf6 | ||
|
bd6fba96ad | ||
|
7d31812858 | ||
|
f27377ab1b | ||
|
2052fb8310 | ||
|
40d3e20bff | ||
|
f4d9881b0c | ||
|
ba1ae92bcc | ||
|
8217a1cc71 | ||
|
96de1a261a | ||
|
d8a8328793 | ||
|
2a9f47816a | ||
|
e06ab4d081 | ||
|
13e092e381 | ||
|
b2c485bcea | ||
|
d0d67da377 | ||
|
bc98f15ba1 | ||
|
49bd078012 | ||
|
d379611491 | ||
|
329eaaa9a0 | ||
|
d25f4c1cf7 | ||
|
a07ee64f8b | ||
|
bbd60eebaf | ||
|
9c55c6c657 | ||
|
3c217026af | ||
|
0681f7138a | ||
|
1e1f38d297 | ||
|
c101b29d65 | ||
|
cb1b589768 | ||
|
b0532c31c3 | ||
|
c355f8b06d | ||
|
d7afa8824c | ||
|
789b98f892 | ||
|
8001022e18 | ||
|
e1d2edb204 | ||
|
055a4fc1a7 | ||
|
5983bd1552 | ||
|
67035a0f0b | ||
|
8d76579122 | ||
|
c5658b353a | ||
|
5ab72a0c48 | ||
|
06da62811b | ||
|
bb538cda2a | ||
|
c8a40ba051 | ||
|
ac1d8403fd | ||
|
ba20665d57 | ||
|
939c5222af | ||
|
22beed7608 | ||
|
9a72d9d474 | ||
|
5202aba6a8 | ||
|
7e64be7d9d | ||
|
eeb4413678 | ||
|
f1cfe11c1a | ||
|
5a55a74670 | ||
|
27bcfc4c8e | ||
|
40b34213fb | ||
|
297838e895 | ||
|
15e3af4985 | ||
|
b12e5471a0 |
2
.github/ISSUE_TEMPLATE/bug_report.md
vendored
@@ -2,7 +2,7 @@
|
||||
name: Bug report
|
||||
about: Create a report to help us improve
|
||||
title: ''
|
||||
labels: ''
|
||||
labels: 'bug'
|
||||
assignees: ''
|
||||
|
||||
---
|
||||
|
7
.github/ISSUE_TEMPLATE/feature.md
vendored
Normal file
@@ -0,0 +1,7 @@
|
||||
---
|
||||
name: Feature request
|
||||
about: Request a new feature
|
||||
title: ''
|
||||
labels: 'enhancement'
|
||||
assignees: ''
|
||||
---
|
10
.github/dependabot.yml
vendored
Normal file
@@ -0,0 +1,10 @@
|
||||
# https://docs.github.com/github/administering-a-repository/configuration-options-for-dependency-updates
|
||||
|
||||
version: 2
|
||||
updates:
|
||||
- package-ecosystem: "github-actions"
|
||||
directory: "/"
|
||||
schedule:
|
||||
interval: "weekly"
|
||||
commit-message:
|
||||
prefix: "ci(dependabot):"
|
196
.github/workflows/test_and_deploy.yml
vendored
@@ -1,146 +1,130 @@
|
||||
name: Test
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- master
|
||||
- main
|
||||
tags:
|
||||
- "v*" # Push events to matching v*, i.e. v1.0, v20.15.10
|
||||
branches: [main]
|
||||
tags: [v*]
|
||||
pull_request:
|
||||
branches:
|
||||
- master
|
||||
- main
|
||||
workflow_dispatch:
|
||||
schedule:
|
||||
- cron: "0 0 * * 0" # run weekly
|
||||
|
||||
jobs:
|
||||
test:
|
||||
name: ${{ matrix.platform }} py${{ matrix.python-version }} ${{ matrix.backend }}
|
||||
runs-on: ${{ matrix.platform }}
|
||||
timeout-minutes: 10
|
||||
name: Test
|
||||
uses: pyapp-kit/workflows/.github/workflows/test-pyrepo.yml@v1
|
||||
with:
|
||||
os: ${{ matrix.platform }}
|
||||
python-version: ${{ matrix.python-version }}
|
||||
qt: ${{ matrix.backend }}
|
||||
pip-install-pre-release: ${{ github.event_name == 'schedule' }}
|
||||
report-failures: ${{ github.event_name == 'schedule' }}
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
platform: [ubuntu-latest, windows-latest, macos-latest]
|
||||
python-version: [3.7, 3.8, 3.9]
|
||||
backend: [pyqt5, pyside2]
|
||||
python-version: ["3.8", "3.9", "3.10", "3.11"]
|
||||
backend: [pyqt5, pyside2, pyqt6]
|
||||
exclude:
|
||||
# Abort (core dumped) on linux pyqt6, unknown reason
|
||||
- platform: ubuntu-latest
|
||||
backend: pyqt6
|
||||
# lack of wheels for pyside2/py3.11
|
||||
- python-version: "3.11"
|
||||
backend: pyside2
|
||||
|
||||
include:
|
||||
# pyqt6 and pyside6 on latest platforms
|
||||
- python-version: 3.9
|
||||
platform: ubuntu-latest
|
||||
backend: pyside6
|
||||
screenshot: 1
|
||||
- python-version: 3.9
|
||||
# https://bugreports.qt.io/browse/PYSIDE-2627
|
||||
- python-version: "3.10"
|
||||
platform: macos-latest
|
||||
backend: "'pyside6!=6.6.2'"
|
||||
- python-version: "3.11"
|
||||
platform: macos-latest
|
||||
backend: "'pyside6!=6.6.2'"
|
||||
- python-version: "3.10"
|
||||
platform: windows-latest
|
||||
backend: pyside6
|
||||
screenshot: 1
|
||||
- python-version: 3.9
|
||||
platform: macos-11.0
|
||||
backend: pyside6
|
||||
screenshot: 1
|
||||
- python-version: 3.9
|
||||
platform: ubuntu-latest
|
||||
backend: pyqt6
|
||||
- python-version: 3.9
|
||||
backend: "'pyside6!=6.6.2'"
|
||||
- python-version: "3.11"
|
||||
platform: windows-latest
|
||||
backend: pyqt6
|
||||
- python-version: 3.9
|
||||
platform: macos-11.0
|
||||
backend: "'pyside6!=6.6.2'"
|
||||
|
||||
- python-version: "3.12"
|
||||
platform: macos-latest
|
||||
backend: pyqt6
|
||||
|
||||
# big sur, 3.9
|
||||
- python-version: 3.9
|
||||
platform: macos-11.0
|
||||
backend: pyside2
|
||||
- python-version: 3.9
|
||||
platform: macos-11.0
|
||||
backend: pyqt5
|
||||
|
||||
# legacy OS
|
||||
# legacy Qt
|
||||
- python-version: 3.8
|
||||
platform: ubuntu-18.04
|
||||
backend: pyside2
|
||||
- python-version: 3.6
|
||||
platform: ubuntu-16.04
|
||||
backend: pyqt5
|
||||
- python-version: 3.6
|
||||
platform: windows-2016
|
||||
backend: pyqt5
|
||||
platform: ubuntu-latest
|
||||
backend: "pyqt5==5.12.*"
|
||||
- python-version: 3.8
|
||||
platform: ubuntu-latest
|
||||
backend: "pyqt5==5.13.*"
|
||||
- python-version: 3.8
|
||||
platform: ubuntu-latest
|
||||
backend: "pyqt5==5.14.*"
|
||||
|
||||
test-qt-minreqs:
|
||||
uses: pyapp-kit/workflows/.github/workflows/test-pyrepo.yml@v1
|
||||
with:
|
||||
python-version: "3.8"
|
||||
qt: pyqt5
|
||||
pip-post-installs: 'qtpy==1.1.0 typing-extensions==3.7.4.3'
|
||||
|
||||
test_napari:
|
||||
uses: pyapp-kit/workflows/.github/workflows/test-dependents.yml@v1
|
||||
with:
|
||||
dependency-repo: napari/napari
|
||||
dependency-ref: ${{ matrix.napari-version }}
|
||||
dependency-extras: 'testing'
|
||||
qt: ${{ matrix.qt }}
|
||||
pytest-args: 'napari/_qt -k "not async and not qt_dims_2"'
|
||||
python-version: "3.10"
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
napari-version: ["", "v0.4.18"]
|
||||
qt: ["pyqt5", "pyside2"]
|
||||
|
||||
check-manifest:
|
||||
name: Check Manifest
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v2
|
||||
|
||||
- name: Set up Python ${{ matrix.python-version }}
|
||||
uses: actions/setup-python@v2
|
||||
with:
|
||||
python-version: ${{ matrix.python-version }}
|
||||
|
||||
- name: Install Linux libraries
|
||||
if: runner.os == 'Linux'
|
||||
run: |
|
||||
sudo apt-get install -y libdbus-1-3 libxkbcommon-x11-0 libxcb-icccm4 \
|
||||
libxcb-image0 libxcb-keysyms1 libxcb-randr0 libxcb-render-util0 \
|
||||
libxcb-xinerama0 libxcb-xfixes0
|
||||
- name: Linux opengl
|
||||
if: runner.os == 'Linux' && ( matrix.backend == 'pyside6' || matrix.backend == 'pyqt6' )
|
||||
run: sudo apt-get install -y libopengl0 libegl1-mesa libxcb-xinput0
|
||||
|
||||
- name: Install dependencies
|
||||
run: |
|
||||
python -m pip install --upgrade pip
|
||||
pip install setuptools tox tox-gh-actions
|
||||
|
||||
- name: Test with tox
|
||||
run: tox
|
||||
env:
|
||||
PLATFORM: ${{ matrix.platform }}
|
||||
BACKEND: ${{ matrix.backend }}
|
||||
|
||||
- name: Coverage
|
||||
uses: codecov/codecov-action@v1
|
||||
|
||||
- name: Install for screenshots
|
||||
if: matrix.screenshot
|
||||
run: pip install . ${{ matrix.backend }}
|
||||
|
||||
- name: Screenshots
|
||||
if: runner.os == 'Linux' && matrix.screenshot
|
||||
uses: GabrielBB/xvfb-action@v1
|
||||
with:
|
||||
run: python examples/demo_widget.py
|
||||
|
||||
- name: Screenshots
|
||||
if: runner.os != 'Linux' && matrix.screenshot
|
||||
run: python examples/demo_widget.py
|
||||
|
||||
- uses: actions/upload-artifact@v2
|
||||
if: matrix.screenshot
|
||||
with:
|
||||
name: screenshots ${{ runner.os }}
|
||||
path: screenshots
|
||||
|
||||
- uses: actions/checkout@v4
|
||||
- run: pipx run check-manifest
|
||||
|
||||
deploy:
|
||||
# this will run when you have tagged a commit, starting with "v*"
|
||||
# and requires that you have put your twine API key in your
|
||||
# github secrets (see readme for details)
|
||||
needs: [test]
|
||||
needs: [test, check-manifest]
|
||||
if: ${{ github.repository == 'pyapp-kit/superqt' && contains(github.ref, 'tags') }}
|
||||
runs-on: ubuntu-latest
|
||||
if: contains(github.ref, 'tags')
|
||||
steps:
|
||||
- uses: actions/checkout@v2
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@v2
|
||||
uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: "3.x"
|
||||
- name: Install dependencies
|
||||
run: |
|
||||
python -m pip install --upgrade pip
|
||||
pip install -U setuptools setuptools_scm wheel twine
|
||||
pip install build twine
|
||||
- name: Build and publish
|
||||
env:
|
||||
TWINE_USERNAME: __token__
|
||||
TWINE_PASSWORD: ${{ secrets.TWINE_API_KEY }}
|
||||
run: |
|
||||
git tag
|
||||
python setup.py sdist bdist_wheel
|
||||
python -m build
|
||||
twine check dist/*
|
||||
twine upload dist/*
|
||||
|
||||
- uses: softprops/action-gh-release@v2
|
||||
with:
|
||||
generate_release_notes: true
|
||||
|
9
.github_changelog_generator
Normal file
@@ -0,0 +1,9 @@
|
||||
# run this with:
|
||||
# export CHANGELOG_GITHUB_TOKEN=......
|
||||
# github_changelog_generator --future-release vX.Y.Z
|
||||
user=pyapp-kit
|
||||
project=superqt
|
||||
issues=false
|
||||
since-tag=v0.2.0
|
||||
exclude-labels=duplicate,question,invalid,wontfix,hide
|
||||
add-sections={"documentation":{"prefix":"**Documentation updates:**","labels":["documentation"]},"tests":{"prefix":"**Tests & CI:**","labels":["tests"]},"refactor":{"prefix":"**Refactors:**","labels":["refactor"]}}
|
7
.gitignore
vendored
@@ -9,6 +9,7 @@ __pycache__/
|
||||
# Distribution / packaging
|
||||
.Python
|
||||
env/
|
||||
.venv/
|
||||
build/
|
||||
develop-eggs/
|
||||
dist/
|
||||
@@ -44,7 +45,6 @@ nosetests.xml
|
||||
coverage.xml
|
||||
*,cover
|
||||
.hypothesis/
|
||||
.napari_cache
|
||||
|
||||
# Translations
|
||||
*.mo
|
||||
@@ -76,6 +76,9 @@ target/
|
||||
.DS_Store
|
||||
|
||||
# written by setuptools_scm
|
||||
*/_version.py
|
||||
src/superqt/_version.py
|
||||
.vscode/settings.json
|
||||
screenshots
|
||||
|
||||
.mypy_cache
|
||||
docs/_auto_images/
|
||||
|
@@ -1,23 +1,27 @@
|
||||
ci:
|
||||
autoupdate_schedule: monthly
|
||||
autofix_commit_msg: "style: [pre-commit.ci] auto fixes [...]"
|
||||
autoupdate_commit_msg: "ci: [pre-commit.ci] autoupdate"
|
||||
|
||||
repos:
|
||||
- repo: https://github.com/pre-commit/pre-commit-hooks
|
||||
rev: v3.4.0
|
||||
- repo: https://github.com/astral-sh/ruff-pre-commit
|
||||
rev: v0.3.0
|
||||
hooks:
|
||||
- id: end-of-file-fixer
|
||||
- id: trailing-whitespace
|
||||
- repo: https://github.com/PyCQA/isort
|
||||
rev: 5.8.0
|
||||
- id: ruff
|
||||
args: [--fix, --unsafe-fixes]
|
||||
- id: ruff-format
|
||||
|
||||
- repo: https://github.com/abravalheri/validate-pyproject
|
||||
rev: v0.16
|
||||
hooks:
|
||||
- id: isort
|
||||
- repo: https://github.com/asottile/pyupgrade
|
||||
rev: v2.15.0
|
||||
- id: validate-pyproject
|
||||
|
||||
- repo: https://github.com/pre-commit/mirrors-mypy
|
||||
rev: v1.8.0
|
||||
hooks:
|
||||
- id: pyupgrade
|
||||
- repo: https://github.com/psf/black
|
||||
rev: 21.5b1
|
||||
hooks:
|
||||
- id: black
|
||||
- repo: https://github.com/PyCQA/flake8
|
||||
rev: 3.9.2
|
||||
hooks:
|
||||
- id: flake8
|
||||
pass_filenames: true
|
||||
- id: mypy
|
||||
exclude: tests|examples
|
||||
additional_dependencies:
|
||||
- types-Pygments
|
||||
stages:
|
||||
- manual
|
||||
|
432
CHANGELOG.md
Normal file
@@ -0,0 +1,432 @@
|
||||
# Changelog
|
||||
|
||||
## [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)
|
||||
|
||||
[Full Changelog](https://github.com/pyapp-kit/superqt/compare/v0.5.3...v0.5.4)
|
||||
|
||||
**Fixed bugs:**
|
||||
|
||||
- fix: fix mysterious segfault [\#192](https://github.com/pyapp-kit/superqt/pull/192) ([tlambert03](https://github.com/tlambert03))
|
||||
|
||||
## [v0.5.3](https://github.com/pyapp-kit/superqt/tree/v0.5.3) (2023-08-21)
|
||||
|
||||
[Full Changelog](https://github.com/pyapp-kit/superqt/compare/v0.5.2...v0.5.3)
|
||||
|
||||
**Implemented enhancements:**
|
||||
|
||||
- feat: add error `exceptions_as_dialog` context manager to catch and show Exceptions [\#191](https://github.com/pyapp-kit/superqt/pull/191) ([tlambert03](https://github.com/tlambert03))
|
||||
|
||||
**Fixed bugs:**
|
||||
|
||||
- fix: remove dupes/aliases in QEnumCombo [\#190](https://github.com/pyapp-kit/superqt/pull/190) ([tlambert03](https://github.com/tlambert03))
|
||||
|
||||
## [v0.5.2](https://github.com/pyapp-kit/superqt/tree/v0.5.2) (2023-08-18)
|
||||
|
||||
[Full Changelog](https://github.com/pyapp-kit/superqt/compare/v0.5.1...v0.5.2)
|
||||
|
||||
**Implemented enhancements:**
|
||||
|
||||
- feat: allow throttler/debouncer as method decorator [\#188](https://github.com/pyapp-kit/superqt/pull/188) ([Czaki](https://github.com/Czaki))
|
||||
|
||||
**Fixed bugs:**
|
||||
|
||||
- fix: Add descriptive exception when fail to add instance to weakref dictionary [\#189](https://github.com/pyapp-kit/superqt/pull/189) ([Czaki](https://github.com/Czaki))
|
||||
|
||||
## [v0.5.1](https://github.com/pyapp-kit/superqt/tree/v0.5.1) (2023-08-17)
|
||||
|
||||
[Full Changelog](https://github.com/pyapp-kit/superqt/compare/v0.5.0...v0.5.1)
|
||||
|
||||
**Fixed bugs:**
|
||||
|
||||
- fix: fix parameter inspection on ensure\_thread decorators \(alternate\) [\#185](https://github.com/pyapp-kit/superqt/pull/185) ([tlambert03](https://github.com/tlambert03))
|
||||
- fix: fix callback of throttled/debounced decorated functions with mismatched args [\#184](https://github.com/pyapp-kit/superqt/pull/184) ([tlambert03](https://github.com/tlambert03))
|
||||
|
||||
**Documentation updates:**
|
||||
|
||||
- docs: document signals blocked [\#186](https://github.com/pyapp-kit/superqt/pull/186) ([tlambert03](https://github.com/tlambert03))
|
||||
|
||||
**Merged pull requests:**
|
||||
|
||||
- test: change wait pattern [\#187](https://github.com/pyapp-kit/superqt/pull/187) ([tlambert03](https://github.com/tlambert03))
|
||||
- build: drop python3.7, misc updates to repo [\#180](https://github.com/pyapp-kit/superqt/pull/180) ([tlambert03](https://github.com/tlambert03))
|
||||
|
||||
## [v0.5.0](https://github.com/pyapp-kit/superqt/tree/v0.5.0) (2023-08-06)
|
||||
|
||||
[Full Changelog](https://github.com/pyapp-kit/superqt/compare/v0.4.1...v0.5.0)
|
||||
|
||||
**Implemented enhancements:**
|
||||
|
||||
- feat: add stepType to largeInt spinbox [\#179](https://github.com/pyapp-kit/superqt/pull/179) ([tlambert03](https://github.com/tlambert03))
|
||||
- Searchable tree widget from a mapping [\#158](https://github.com/pyapp-kit/superqt/pull/158) ([andy-sweet](https://github.com/andy-sweet))
|
||||
- Add `QElidingLineEdit` class for elidable `QLineEdit`s [\#154](https://github.com/pyapp-kit/superqt/pull/154) ([dalthviz](https://github.com/dalthviz))
|
||||
|
||||
**Fixed bugs:**
|
||||
|
||||
- fix: focus events on QLabeledSlider [\#175](https://github.com/pyapp-kit/superqt/pull/175) ([tlambert03](https://github.com/tlambert03))
|
||||
- Set parent of timer in throttler [\#171](https://github.com/pyapp-kit/superqt/pull/171) ([Czaki](https://github.com/Czaki))
|
||||
- fix: fix double slider label editing [\#168](https://github.com/pyapp-kit/superqt/pull/168) ([tlambert03](https://github.com/tlambert03))
|
||||
|
||||
**Documentation updates:**
|
||||
|
||||
- Fix typos [\#147](https://github.com/pyapp-kit/superqt/pull/147) ([kianmeng](https://github.com/kianmeng))
|
||||
|
||||
**Tests & CI:**
|
||||
|
||||
- tests: add qtbot to test to fix windows segfault [\#165](https://github.com/pyapp-kit/superqt/pull/165) ([tlambert03](https://github.com/tlambert03))
|
||||
- test: fixing tests \[wip\] [\#164](https://github.com/pyapp-kit/superqt/pull/164) ([tlambert03](https://github.com/tlambert03))
|
||||
|
||||
**Merged pull requests:**
|
||||
|
||||
- build: unpin pyside6.5 [\#178](https://github.com/pyapp-kit/superqt/pull/178) ([tlambert03](https://github.com/tlambert03))
|
||||
- build: pin pyside6 to \<6.5.1 [\#169](https://github.com/pyapp-kit/superqt/pull/169) ([tlambert03](https://github.com/tlambert03))
|
||||
- pin pyside6\<6.5 [\#160](https://github.com/pyapp-kit/superqt/pull/160) ([tlambert03](https://github.com/tlambert03))
|
||||
- ci: \[pre-commit.ci\] autoupdate [\#146](https://github.com/pyapp-kit/superqt/pull/146) ([pre-commit-ci[bot]](https://github.com/apps/pre-commit-ci))
|
||||
|
||||
## [v0.4.1](https://github.com/pyapp-kit/superqt/tree/v0.4.1) (2022-12-01)
|
||||
|
||||
[Full Changelog](https://github.com/pyapp-kit/superqt/compare/v0.4.0...v0.4.1)
|
||||
|
||||
**Implemented enhancements:**
|
||||
|
||||
- feat: Add signal to QCollapsible [\#142](https://github.com/pyapp-kit/superqt/pull/142) ([ppwadhwa](https://github.com/ppwadhwa))
|
||||
- feat: Change icon used in Collapsible widget [\#140](https://github.com/pyapp-kit/superqt/pull/140) ([ppwadhwa](https://github.com/ppwadhwa))
|
||||
|
||||
**Fixed bugs:**
|
||||
|
||||
- Move QCollapsible toggle signal emit [\#144](https://github.com/pyapp-kit/superqt/pull/144) ([ppwadhwa](https://github.com/ppwadhwa))
|
||||
|
||||
**Merged pull requests:**
|
||||
|
||||
- build: use hatch for build backend, and use ruff for linting [\#139](https://github.com/pyapp-kit/superqt/pull/139) ([tlambert03](https://github.com/tlambert03))
|
||||
- chore: rename napari org to pyapp-kit [\#137](https://github.com/pyapp-kit/superqt/pull/137) ([tlambert03](https://github.com/tlambert03))
|
||||
|
||||
## [v0.4.0](https://github.com/pyapp-kit/superqt/tree/v0.4.0) (2022-11-09)
|
||||
|
||||
[Full Changelog](https://github.com/pyapp-kit/superqt/compare/v0.3.8...v0.4.0)
|
||||
|
||||
**Fixed bugs:**
|
||||
|
||||
- fix: fix quantity set value and add test [\#131](https://github.com/pyapp-kit/superqt/pull/131) ([tlambert03](https://github.com/tlambert03))
|
||||
|
||||
**Refactors:**
|
||||
|
||||
- refactor: update pyproject and ci, add py3.11 test [\#132](https://github.com/pyapp-kit/superqt/pull/132) ([tlambert03](https://github.com/tlambert03))
|
||||
|
||||
**Merged pull requests:**
|
||||
|
||||
- chore: changelog v0.4.0 [\#136](https://github.com/pyapp-kit/superqt/pull/136) ([tlambert03](https://github.com/tlambert03))
|
||||
- ci\(dependabot\): bump actions/upload-artifact from 2 to 3 [\#135](https://github.com/pyapp-kit/superqt/pull/135) ([dependabot[bot]](https://github.com/apps/dependabot))
|
||||
- ci\(dependabot\): bump codecov/codecov-action from 2 to 3 [\#134](https://github.com/pyapp-kit/superqt/pull/134) ([dependabot[bot]](https://github.com/apps/dependabot))
|
||||
- build: unpin pyside6 [\#133](https://github.com/pyapp-kit/superqt/pull/133) ([tlambert03](https://github.com/tlambert03))
|
||||
|
||||
## [v0.3.8](https://github.com/pyapp-kit/superqt/tree/v0.3.8) (2022-10-10)
|
||||
|
||||
[Full Changelog](https://github.com/pyapp-kit/superqt/compare/v0.3.7...v0.3.8)
|
||||
|
||||
**Fixed bugs:**
|
||||
|
||||
- fix: allow submodule imports [\#128](https://github.com/pyapp-kit/superqt/pull/128) ([kne42](https://github.com/kne42))
|
||||
|
||||
## [v0.3.7](https://github.com/pyapp-kit/superqt/tree/v0.3.7) (2022-10-10)
|
||||
|
||||
[Full Changelog](https://github.com/pyapp-kit/superqt/compare/v0.3.6...v0.3.7)
|
||||
|
||||
**Implemented enhancements:**
|
||||
|
||||
- feat: add Quantity widget \(using pint\) [\#126](https://github.com/pyapp-kit/superqt/pull/126) ([tlambert03](https://github.com/tlambert03))
|
||||
|
||||
## [v0.3.6](https://github.com/pyapp-kit/superqt/tree/v0.3.6) (2022-10-05)
|
||||
|
||||
[Full Changelog](https://github.com/pyapp-kit/superqt/compare/v0.3.6rc0...v0.3.6)
|
||||
|
||||
**Documentation updates:**
|
||||
|
||||
- minor fix to readme [\#125](https://github.com/pyapp-kit/superqt/pull/125) ([tlambert03](https://github.com/tlambert03))
|
||||
- Docs [\#124](https://github.com/pyapp-kit/superqt/pull/124) ([tlambert03](https://github.com/tlambert03))
|
||||
|
||||
## [v0.3.6rc0](https://github.com/pyapp-kit/superqt/tree/v0.3.6rc0) (2022-10-03)
|
||||
|
||||
[Full Changelog](https://github.com/pyapp-kit/superqt/compare/v0.3.5...v0.3.6rc0)
|
||||
|
||||
**Implemented enhancements:**
|
||||
|
||||
- feat: add editing finished signal to LabeledSliders [\#122](https://github.com/pyapp-kit/superqt/pull/122) ([tlambert03](https://github.com/tlambert03))
|
||||
|
||||
**Fixed bugs:**
|
||||
|
||||
- fix: fix missing labels after setValue [\#123](https://github.com/pyapp-kit/superqt/pull/123) ([tlambert03](https://github.com/tlambert03))
|
||||
- fix: Fix TypeError on slider rangeChanged signal [\#121](https://github.com/pyapp-kit/superqt/pull/121) ([tlambert03](https://github.com/tlambert03))
|
||||
- Simple workaround for pyside 6 [\#119](https://github.com/pyapp-kit/superqt/pull/119) ([Czaki](https://github.com/Czaki))
|
||||
- fix: Offer patch for \(unstyled\) QSliders on macos 12 and Qt \<6 [\#117](https://github.com/pyapp-kit/superqt/pull/117) ([tlambert03](https://github.com/tlambert03))
|
||||
|
||||
## [v0.3.5](https://github.com/pyapp-kit/superqt/tree/v0.3.5) (2022-08-17)
|
||||
|
||||
[Full Changelog](https://github.com/pyapp-kit/superqt/compare/v0.3.4...v0.3.5)
|
||||
|
||||
**Fixed bugs:**
|
||||
|
||||
- fix range slider drag crash on PyQt6 [\#108](https://github.com/pyapp-kit/superqt/pull/108) ([sfhbarnett](https://github.com/sfhbarnett))
|
||||
- Fix float value error in pyqt configuration [\#106](https://github.com/pyapp-kit/superqt/pull/106) ([mstabrin](https://github.com/mstabrin))
|
||||
|
||||
**Merged pull requests:**
|
||||
|
||||
- chore: changelog v0.3.5 [\#110](https://github.com/pyapp-kit/superqt/pull/110) ([tlambert03](https://github.com/tlambert03))
|
||||
|
||||
## [v0.3.4](https://github.com/pyapp-kit/superqt/tree/v0.3.4) (2022-07-24)
|
||||
|
||||
[Full Changelog](https://github.com/pyapp-kit/superqt/compare/v0.3.3...v0.3.4)
|
||||
|
||||
**Fixed bugs:**
|
||||
|
||||
- fix: relax runtime typing extensions requirement [\#101](https://github.com/pyapp-kit/superqt/pull/101) ([tlambert03](https://github.com/tlambert03))
|
||||
- fix: catch qpixmap deprecation [\#99](https://github.com/pyapp-kit/superqt/pull/99) ([tlambert03](https://github.com/tlambert03))
|
||||
|
||||
## [v0.3.3](https://github.com/pyapp-kit/superqt/tree/v0.3.3) (2022-07-10)
|
||||
|
||||
[Full Changelog](https://github.com/pyapp-kit/superqt/compare/v0.3.2...v0.3.3)
|
||||
|
||||
**Implemented enhancements:**
|
||||
|
||||
- Add code syntax highlight utils [\#88](https://github.com/pyapp-kit/superqt/pull/88) ([Czaki](https://github.com/Czaki))
|
||||
|
||||
**Fixed bugs:**
|
||||
|
||||
- fix: fix deprecation warning on fonticon plugin discovery on python 3.10 [\#95](https://github.com/pyapp-kit/superqt/pull/95) ([tlambert03](https://github.com/tlambert03))
|
||||
|
||||
## [v0.3.2](https://github.com/pyapp-kit/superqt/tree/v0.3.2) (2022-05-03)
|
||||
|
||||
[Full Changelog](https://github.com/pyapp-kit/superqt/compare/v0.3.1...v0.3.2)
|
||||
|
||||
**Implemented enhancements:**
|
||||
|
||||
- Add QSearchableListWidget and QSearchableComboBox widgets [\#80](https://github.com/pyapp-kit/superqt/pull/80) ([Czaki](https://github.com/Czaki))
|
||||
|
||||
**Fixed bugs:**
|
||||
|
||||
- Fix crazy animation loop on Qcollapsible [\#84](https://github.com/pyapp-kit/superqt/pull/84) ([tlambert03](https://github.com/tlambert03))
|
||||
- Reorder label update signal [\#83](https://github.com/pyapp-kit/superqt/pull/83) ([tlambert03](https://github.com/tlambert03))
|
||||
- Fix height of expanded QCollapsible when child changes size [\#72](https://github.com/pyapp-kit/superqt/pull/72) ([tlambert03](https://github.com/tlambert03))
|
||||
|
||||
**Tests & CI:**
|
||||
|
||||
- Fix deprecation warnings in tests [\#82](https://github.com/pyapp-kit/superqt/pull/82) ([tlambert03](https://github.com/tlambert03))
|
||||
|
||||
**Merged pull requests:**
|
||||
|
||||
- Add changelog for v0.3.2 [\#86](https://github.com/pyapp-kit/superqt/pull/86) ([tlambert03](https://github.com/tlambert03))
|
||||
|
||||
## [v0.3.1](https://github.com/pyapp-kit/superqt/tree/v0.3.1) (2022-03-02)
|
||||
|
||||
[Full Changelog](https://github.com/pyapp-kit/superqt/compare/v0.3.0...v0.3.1)
|
||||
|
||||
**Implemented enhancements:**
|
||||
|
||||
- Add `signals_blocked` util [\#69](https://github.com/pyapp-kit/superqt/pull/69) ([tlambert03](https://github.com/tlambert03))
|
||||
|
||||
**Fixed bugs:**
|
||||
|
||||
- put SignalInstance in TYPE\_CHECKING clause, check min requirements [\#70](https://github.com/pyapp-kit/superqt/pull/70) ([tlambert03](https://github.com/tlambert03))
|
||||
|
||||
**Merged pull requests:**
|
||||
|
||||
- Add changelog for v0.3.1 [\#71](https://github.com/pyapp-kit/superqt/pull/71) ([tlambert03](https://github.com/tlambert03))
|
||||
|
||||
## [v0.3.0](https://github.com/pyapp-kit/superqt/tree/v0.3.0) (2022-02-16)
|
||||
|
||||
[Full Changelog](https://github.com/pyapp-kit/superqt/compare/v0.2.5-1...v0.3.0)
|
||||
|
||||
**Implemented enhancements:**
|
||||
|
||||
- Qthrottler and debouncer [\#62](https://github.com/pyapp-kit/superqt/pull/62) ([tlambert03](https://github.com/tlambert03))
|
||||
- add edgeLabelMode option to QLabeledSlider [\#59](https://github.com/pyapp-kit/superqt/pull/59) ([tlambert03](https://github.com/tlambert03))
|
||||
|
||||
**Fixed bugs:**
|
||||
|
||||
- Fix nested threadworker not starting [\#63](https://github.com/pyapp-kit/superqt/pull/63) ([tlambert03](https://github.com/tlambert03))
|
||||
- Add missing signals on proxy sliders [\#54](https://github.com/pyapp-kit/superqt/pull/54) ([tlambert03](https://github.com/tlambert03))
|
||||
- Ugly but functional workaround for pyside6.2.1 breakages [\#51](https://github.com/pyapp-kit/superqt/pull/51) ([tlambert03](https://github.com/tlambert03))
|
||||
|
||||
**Tests & CI:**
|
||||
|
||||
- add napari test to CI [\#67](https://github.com/pyapp-kit/superqt/pull/67) ([tlambert03](https://github.com/tlambert03))
|
||||
- add gh-release action [\#65](https://github.com/pyapp-kit/superqt/pull/65) ([tlambert03](https://github.com/tlambert03))
|
||||
- fix xvfb tests [\#61](https://github.com/pyapp-kit/superqt/pull/61) ([tlambert03](https://github.com/tlambert03))
|
||||
|
||||
**Refactors:**
|
||||
|
||||
- Use qtpy, deprecate superqt.qtcompat, drop support for Qt \<5.12 [\#39](https://github.com/pyapp-kit/superqt/pull/39) ([tlambert03](https://github.com/tlambert03))
|
||||
|
||||
**Merged pull requests:**
|
||||
|
||||
- Add changelog for v0.3.0 [\#68](https://github.com/pyapp-kit/superqt/pull/68) ([tlambert03](https://github.com/tlambert03))
|
||||
|
||||
## [v0.2.5-1](https://github.com/pyapp-kit/superqt/tree/v0.2.5-1) (2021-11-23)
|
||||
|
||||
[Full Changelog](https://github.com/pyapp-kit/superqt/compare/v0.2.5...v0.2.5-1)
|
||||
|
||||
**Merged pull requests:**
|
||||
|
||||
- typing-extensions version pinning [\#46](https://github.com/pyapp-kit/superqt/pull/46) ([AhmetCanSolak](https://github.com/AhmetCanSolak))
|
||||
|
||||
## [v0.2.5](https://github.com/pyapp-kit/superqt/tree/v0.2.5) (2021-11-22)
|
||||
|
||||
[Full Changelog](https://github.com/pyapp-kit/superqt/compare/v0.2.4...v0.2.5)
|
||||
|
||||
**Implemented enhancements:**
|
||||
|
||||
- add support for python 3.10 [\#42](https://github.com/pyapp-kit/superqt/pull/42) ([tlambert03](https://github.com/tlambert03))
|
||||
- QCollapsible for Collapsible Section Control [\#37](https://github.com/pyapp-kit/superqt/pull/37) ([MosGeo](https://github.com/MosGeo))
|
||||
- Threadworker [\#31](https://github.com/pyapp-kit/superqt/pull/31) ([tlambert03](https://github.com/tlambert03))
|
||||
- Add font icons [\#24](https://github.com/pyapp-kit/superqt/pull/24) ([tlambert03](https://github.com/tlambert03))
|
||||
|
||||
**Fixed bugs:**
|
||||
|
||||
- Fix some small linting issues. [\#41](https://github.com/pyapp-kit/superqt/pull/41) ([tlambert03](https://github.com/tlambert03))
|
||||
- Use functools.wraps insterad of \_\_wraped\_\_ and manual proxing \_\_name\_\_ [\#29](https://github.com/pyapp-kit/superqt/pull/29) ([Czaki](https://github.com/Czaki))
|
||||
- Propagate function name in `ensure_main_thread` and `ensure_object_thread` [\#28](https://github.com/pyapp-kit/superqt/pull/28) ([Czaki](https://github.com/Czaki))
|
||||
|
||||
**Tests & CI:**
|
||||
|
||||
- reskip test\_object\_thread\_return on ci [\#43](https://github.com/pyapp-kit/superqt/pull/43) ([tlambert03](https://github.com/tlambert03))
|
||||
|
||||
**Refactors:**
|
||||
|
||||
- refactoring qtcompat [\#34](https://github.com/pyapp-kit/superqt/pull/34) ([tlambert03](https://github.com/tlambert03))
|
||||
|
||||
**Merged pull requests:**
|
||||
|
||||
- Fix-manifest, move font tests [\#44](https://github.com/pyapp-kit/superqt/pull/44) ([tlambert03](https://github.com/tlambert03))
|
||||
- update deploy [\#33](https://github.com/pyapp-kit/superqt/pull/33) ([tlambert03](https://github.com/tlambert03))
|
||||
- move to src layout [\#32](https://github.com/pyapp-kit/superqt/pull/32) ([tlambert03](https://github.com/tlambert03))
|
||||
|
||||
## [v0.2.4](https://github.com/pyapp-kit/superqt/tree/v0.2.4) (2021-09-13)
|
||||
|
||||
[Full Changelog](https://github.com/pyapp-kit/superqt/compare/v0.2.3...v0.2.4)
|
||||
|
||||
**Implemented enhancements:**
|
||||
|
||||
- Add type stubs for ensure\_thread decorator [\#23](https://github.com/pyapp-kit/superqt/pull/23) ([tlambert03](https://github.com/tlambert03))
|
||||
- Add `ensure_main_tread` and `ensure_object_thread` [\#22](https://github.com/pyapp-kit/superqt/pull/22) ([Czaki](https://github.com/Czaki))
|
||||
- Add QMessageHandler context manager [\#21](https://github.com/pyapp-kit/superqt/pull/21) ([tlambert03](https://github.com/tlambert03))
|
||||
|
||||
**Merged pull requests:**
|
||||
|
||||
- add changelog for 0.2.4 [\#25](https://github.com/pyapp-kit/superqt/pull/25) ([tlambert03](https://github.com/tlambert03))
|
||||
|
||||
## [v0.2.3](https://github.com/pyapp-kit/superqt/tree/v0.2.3) (2021-08-25)
|
||||
|
||||
[Full Changelog](https://github.com/pyapp-kit/superqt/compare/v0.2.2...v0.2.3)
|
||||
|
||||
**Fixed bugs:**
|
||||
|
||||
- Fix warnings on eliding label for 5.12, test more qt versions [\#19](https://github.com/pyapp-kit/superqt/pull/19) ([tlambert03](https://github.com/tlambert03))
|
||||
|
||||
## [v0.2.2](https://github.com/pyapp-kit/superqt/tree/v0.2.2) (2021-08-17)
|
||||
|
||||
[Full Changelog](https://github.com/pyapp-kit/superqt/compare/v0.2.1...v0.2.2)
|
||||
|
||||
**Implemented enhancements:**
|
||||
|
||||
- Add QElidingLabel [\#16](https://github.com/pyapp-kit/superqt/pull/16) ([tlambert03](https://github.com/tlambert03))
|
||||
- Enum ComboBox implementation [\#13](https://github.com/pyapp-kit/superqt/pull/13) ([Czaki](https://github.com/Czaki))
|
||||
|
||||
**Documentation updates:**
|
||||
|
||||
- fix broken link [\#18](https://github.com/pyapp-kit/superqt/pull/18) ([haesleinhuepf](https://github.com/haesleinhuepf))
|
||||
|
||||
## [v0.2.1](https://github.com/pyapp-kit/superqt/tree/v0.2.1) (2021-07-10)
|
||||
|
||||
[Full Changelog](https://github.com/pyapp-kit/superqt/compare/v0.2.0...v0.2.1)
|
||||
|
||||
**Fixed bugs:**
|
||||
|
||||
- Fix QLabeledRangeSlider API \(fix slider proxy\) [\#10](https://github.com/pyapp-kit/superqt/pull/10) ([tlambert03](https://github.com/tlambert03))
|
||||
- Fix range slider with negative min range [\#9](https://github.com/pyapp-kit/superqt/pull/9) ([tlambert03](https://github.com/tlambert03))
|
||||
|
||||
|
||||
|
||||
\* *This Changelog was automatically generated by [github_changelog_generator](https://github.com/github-changelog-generator/github-changelog-generator)*
|
51
CONTRIBUTING.md
Normal file
@@ -0,0 +1,51 @@
|
||||
# Contributing to this repository
|
||||
|
||||
This repository seeks to accumulate Qt-based widgets for python (PyQt & PySide)
|
||||
that are not provided in the native QtWidgets module.
|
||||
|
||||
## Clone
|
||||
|
||||
To get started fork this repository, and clone your fork:
|
||||
|
||||
```bash
|
||||
# clone your fork
|
||||
git clone https://github.com/<your_organization>/superqt
|
||||
cd superqt
|
||||
|
||||
# install pre-commit hooks
|
||||
pre-commit install
|
||||
|
||||
# install in editable mode
|
||||
pip install -e .[dev]
|
||||
|
||||
# run tests & make sure everything is working!
|
||||
pytest
|
||||
```
|
||||
|
||||
## Targeted platforms
|
||||
|
||||
All widgets must be well-tested, and should work on:
|
||||
|
||||
- Python 3.8 and above
|
||||
- PyQt5 (5.11 and above) & PyQt6
|
||||
- PySide2 (5.11 and above) & PySide6
|
||||
- macOS, Windows, & Linux
|
||||
|
||||
|
||||
## Style Guide
|
||||
|
||||
All widgets should try to match the native Qt API as much as possible:
|
||||
|
||||
- Methods should use `camelCase` naming.
|
||||
- Getters/setters use the `attribute()/setAttribute()` pattern.
|
||||
- Private methods should use `_camelCaseNaming`.
|
||||
- `__init__` methods should be like Qt constructors, meaning they often don't
|
||||
include parameters for most of the widgets properties.
|
||||
- When possible, widgets should inherit from the most similar native widget
|
||||
available. It should strictly match the Qt API where it exists, and attempt to
|
||||
cover as much of the native API as possible; this includes properties, public
|
||||
functions, signals, and public slots.
|
||||
|
||||
## Testing
|
||||
|
||||
Tests can be run in the current environment with `pytest`.
|
2
LICENSE
@@ -12,7 +12,7 @@ modification, are permitted provided that the following conditions are met:
|
||||
this list of conditions and the following disclaimer in the documentation
|
||||
and/or other materials provided with the distribution.
|
||||
|
||||
* Neither the name of QtRangeSlider nor the names of its
|
||||
* Neither the name of superqt nor the names of its
|
||||
contributors may be used to endorse or promote products derived from
|
||||
this software without specific prior written permission.
|
||||
|
||||
|
@@ -1,5 +0,0 @@
|
||||
include LICENSE
|
||||
include README.md
|
||||
|
||||
recursive-exclude * __pycache__
|
||||
recursive-exclude * *.py[co]
|
271
README.md
@@ -1,262 +1,53 @@
|
||||
# QtRangeSlider
|
||||
#  superqt!
|
||||
|
||||
[](https://github.com/tlambert03/QtRangeSlider/raw/master/LICENSE)
|
||||
[](https://pypi.org/project/QtRangeSlider)
|
||||
[](https://github.com/pyapp-kit/superqt/raw/master/LICENSE)
|
||||
[](https://pypi.org/project/superqt)
|
||||
[](https://python.org)
|
||||
[](https://github.com/tlambert03/QtRangeSlider/actions/workflows/test_and_deploy.yml)
|
||||
[](https://codecov.io/gh/tlambert03/QtRangeSlider)
|
||||
Version](https://img.shields.io/pypi/pyversions/superqt.svg?color=green)](https://python.org)
|
||||
[](https://github.com/pyapp-kit/superqt/actions/workflows/test_and_deploy.yml)
|
||||
[](https://codecov.io/gh/pyapp-kit/superqt)
|
||||
|
||||
**The missing multi-handle range slider widget for PyQt & PySide**
|
||||
### "missing" widgets and components for PyQt/PySide
|
||||
|
||||

|
||||
This repository aims to provide high-quality community-contributed Qt widgets and components for PyQt & PySide
|
||||
that are not provided in the native QtWidgets module.
|
||||
|
||||
The goal of this package is to provide a Range Slider (a slider with 2 or more
|
||||
handles) that feels as "native" as possible. Styles should match the OS by
|
||||
default, and the slider should behave like a standard
|
||||
[`QSlider`](https://doc.qt.io/qt-5/qslider.html)... but with multiple handles!
|
||||
Components are tested on:
|
||||
|
||||
- `QRangeSlider` inherits from [`QSlider`](https://doc.qt.io/qt-5/qslider.html)
|
||||
and attempts to match the Qt API as closely as possible
|
||||
- Uses platform-specific styles (for handle, groove, & ticks) but also supports
|
||||
QSS style sheets.
|
||||
- Supports mouse wheel and keypress (soon) events
|
||||
- Supports PyQt5, PyQt6, PySide2 and PySide6
|
||||
- Supports more than 2 handles (e.g. `slider.setValue([0, 10, 60, 80])`)
|
||||
- macOS, Windows, & Linux
|
||||
- Python 3.8 and above
|
||||
- PyQt5 (5.11 and above) & PyQt6
|
||||
- PySide2 (5.11 and above) & PySide6
|
||||
|
||||
## Installation
|
||||
## Documentation
|
||||
|
||||
You can install `QtRangeSlider` via pip:
|
||||
Documentation is available at https://pyapp-kit.github.io/superqt/
|
||||
|
||||
```sh
|
||||
pip install qtrangeslider
|
||||
## Widgets
|
||||
|
||||
# NOTE: you must also install a Qt Backend.
|
||||
# PyQt5, PySide2, PyQt6, and PySide6 are supported
|
||||
# As a convenience you can install them as extras:
|
||||
pip install qtrangeslider[pyqt5]
|
||||
```
|
||||
superqt provides a variety of widgets that are not included in the native QtWidgets module, including multihandle (range) sliders, comboboxes, and more.
|
||||
|
||||
See the [widgets documentation](https://pyapp-kit.github.io/superqt/widgets) for a full list of widgets.
|
||||
|
||||
------
|
||||
- [Range Slider](https://pyapp-kit.github.io/superqt/widgets/qrangeslider/) (multi-handle slider)
|
||||
|
||||
## API
|
||||
<img src="https://raw.githubusercontent.com/pyapp-kit/superqt/main/docs/images/demo_darwin10.png" alt="range sliders" width=680>
|
||||
|
||||
To create a slider:
|
||||
<img src="https://raw.githubusercontent.com/pyapp-kit/superqt/main/docs/images/labeled_qslider.png" alt="range sliders" width=680>
|
||||
|
||||
```python
|
||||
from qtrangeslider import QRangeSlider
|
||||
<img src="https://raw.githubusercontent.com/pyapp-kit/superqt/main/docs/images/labeled_range.png" alt="range sliders" width=680>
|
||||
|
||||
# as usual:
|
||||
# you must create a QApplication before create a widget.
|
||||
range_slider = QRangeSlider()
|
||||
```
|
||||
## Utilities
|
||||
|
||||
As `QRangeSlider` inherits from `QtWidgets.QSlider`, you can use all of the
|
||||
same methods available in the [QSlider API](https://doc.qt.io/qt-5/qslider.html). The major difference is that `value` and `sliderPosition` are reimplemented as `tuples` of `int` (where the length of the tuple is equal to the number of handles in the slider.)
|
||||
superqt includes a number of utilities for working with Qt, including:
|
||||
|
||||
### `value: Tuple[int, ...]`
|
||||
- tools and decorators for working with threads in qt.
|
||||
- `superqt.fonticon` for generating icons from font files (such as [Material Design Icons](https://materialdesignicons.com/) and [Font Awesome](https://fontawesome.com/))
|
||||
|
||||
This property holds the current value of all handles in the slider.
|
||||
See the [utilities documentation](https://pyapp-kit.github.io/superqt/utilities/) for a full list of utilities.
|
||||
|
||||
The slider forces all values to be within the legal range:
|
||||
`minimum <= value <= maximum`.
|
||||
## Contributing
|
||||
|
||||
Changing the value also changes the sliderPosition.
|
||||
We welcome contributions!
|
||||
|
||||
##### Access Functions:
|
||||
|
||||
```python
|
||||
range_slider.value() -> Tuple[int, ...]
|
||||
```
|
||||
|
||||
```python
|
||||
range_slider.setValue(val: Sequence[int]) -> None
|
||||
```
|
||||
|
||||
##### Notifier Signal:
|
||||
|
||||
```python
|
||||
valueChanged(Tuple[int, ...])
|
||||
```
|
||||
|
||||
### `sliderPosition: Tuple[int, ...]`
|
||||
|
||||
This property holds the current slider positions. It is a `tuple` with length equal to the number of handles.
|
||||
|
||||
If [tracking](https://doc.qt.io/qt-5/qabstractslider.html#tracking-prop) is enabled (the default), this is identical to [`value`](#value--tupleint-).
|
||||
|
||||
##### Access Functions:
|
||||
|
||||
```python
|
||||
range_slider.sliderPosition() -> Tuple[int, ...]
|
||||
```
|
||||
|
||||
```python
|
||||
range_slider.setSliderPosition(val: Sequence[int]) -> None
|
||||
```
|
||||
|
||||
##### Notifier Signal:
|
||||
|
||||
```python
|
||||
sliderMoved(Tuple[int, ...])
|
||||
```
|
||||
|
||||
### Additional properties
|
||||
|
||||
These options are in addition to the Qt QSlider API, and control the behavior of the bar between handles.
|
||||
|
||||
| getter | setter | type | default | description |
|
||||
| -------------------- | ------------------------------------------- | ------ | ------- | ------------------------------------------------------------------------------------------------ |
|
||||
| `barIsVisible` | `setBarIsVisible` <br>`hideBar` / `showBar` | `bool` | `True` | <small>Whether the bar between handles is visible.</small> |
|
||||
| `barMovesAllHandles` | `setBarMovesAllHandles` | `bool` | `True` | <small>Whether clicking on the bar moves all handles or just the nearest</small> |
|
||||
| `barIsRigid` | `setBarIsRigid` | `bool` | `True` | <small>Whether bar length is constant or "elastic" when dragging the bar beyond min/max.</small> |
|
||||
------
|
||||
|
||||
## Examples
|
||||
|
||||
These screenshots show `QRangeSlider` (multiple handles) next to the native `QSlider`
|
||||
(single handle). With no styles applied, `QRangeSlider` will match the native OS
|
||||
style of `QSlider` – with or without tick marks. When styles have been applied
|
||||
using [Qt Style Sheets](https://doc.qt.io/qt-5/stylesheet-reference.html), then
|
||||
`QRangeSlider` will inherit any styles applied to `QSlider` (since it inherits
|
||||
from QSlider). If you'd like to style `QRangeSlider` differently than `QSlider`,
|
||||
then you can also target it directly in your style sheet. The one "special"
|
||||
property for QRangeSlider is `qproperty-barColor`, which sets the color of the
|
||||
bar between the handles.
|
||||
|
||||
> The code for these example widgets is [here](examples/demo_widget.py)
|
||||
|
||||
<details>
|
||||
|
||||
<summary><em>See style sheet used for this example</em></summary>
|
||||
|
||||
```css
|
||||
/*
|
||||
Because QRangeSlider inherits from QSlider, it will also inherit styles
|
||||
*/
|
||||
QSlider {
|
||||
min-height: 20px;
|
||||
}
|
||||
|
||||
QSlider::groove:horizontal {
|
||||
border: 0px;
|
||||
background: qlineargradient(x1:0, y1:0, x2:1, y2:1,
|
||||
stop:0 #777, stop:1 #aaa);
|
||||
height: 20px;
|
||||
border-radius: 10px;
|
||||
}
|
||||
|
||||
QSlider::handle {
|
||||
background: qradialgradient(cx:0, cy:0, radius: 1.2, fx:0.5,
|
||||
fy:0.5, stop:0 #eef, stop:1 #000);
|
||||
height: 20px;
|
||||
width: 20px;
|
||||
border-radius: 10px;
|
||||
}
|
||||
|
||||
/*
|
||||
"QSlider::sub-page" is the one exception ...
|
||||
(it styles the area to the left of the QSlider handle)
|
||||
*/
|
||||
QSlider::sub-page:horizontal {
|
||||
background: #447;
|
||||
border-top-left-radius: 10px;
|
||||
border-bottom-left-radius: 10px;
|
||||
}
|
||||
|
||||
/*
|
||||
for QRangeSlider: use "qproperty-barColor". "sub-page" will not work.
|
||||
*/
|
||||
QRangeSlider {
|
||||
qproperty-barColor: #447;
|
||||
}
|
||||
```
|
||||
|
||||
</details>
|
||||
|
||||
### macOS
|
||||
|
||||
##### Catalina
|
||||

|
||||
|
||||
##### Big Sur
|
||||

|
||||
|
||||
### Windows
|
||||
|
||||

|
||||
|
||||
### Linux
|
||||
|
||||

|
||||
|
||||
|
||||
## Labeled Sliders
|
||||
|
||||
This package also includes two "labeled" slider variants. One for `QRangeSlider`, and one for the native `QSlider`:
|
||||
|
||||
### `QLabeledRangeSlider`
|
||||
|
||||

|
||||
|
||||
```python
|
||||
from qtrangeslider import QLabeledRangeSlider
|
||||
```
|
||||
|
||||
This has the same API as `QRangeSlider` with the following additional options:
|
||||
|
||||
#### `handleLabelPosition`/`setHandleLabelPosition`
|
||||
|
||||
Where/whether labels are shown adjacent to slider handles.
|
||||
|
||||
**type:** `QLabeledRangeSlider.LabelPosition`
|
||||
|
||||
**default:** `LabelPosition.LabelsAbove`
|
||||
|
||||
*options:*
|
||||
|
||||
- `LabelPosition.NoLabel` (no labels shown adjacent to handles)
|
||||
- `LabelPosition.LabelsAbove`
|
||||
- `LabelPosition.LabelsBelow`
|
||||
- `LabelPosition.LabelsRight` (alias for `LabelPosition.LabelsAbove`)
|
||||
- `LabelPosition.LabelsLeft` (alias for `LabelPosition.LabelsBelow`)
|
||||
|
||||
|
||||
#### `edgeLabelMode`/`setEdgeLabelMode`
|
||||
|
||||
**type:** `QLabeledRangeSlider.EdgeLabelMode`
|
||||
|
||||
**default:** `EdgeLabelMode.LabelIsRange`
|
||||
|
||||
*options:*
|
||||
|
||||
- `EdgeLabelMode.NoLabel`: no labels shown at slider extremes
|
||||
- `EdgeLabelMode.LabelIsRange`: edge labels shown the min/max values
|
||||
- `EdgeLabelMode.LabelIsValue`: edge labels shown the slider range
|
||||
|
||||
|
||||
#### fine tuning position of labels:
|
||||
|
||||
If you find that you need to fine tune the position of the handle labels:
|
||||
|
||||
- `QLabeledRangeSlider.label_shift_x`: adjust horizontal label position
|
||||
- `QLabeledRangeSlider.label_shift_y`: adjust vertical label position
|
||||
|
||||
### `QLabeledSlider`
|
||||
|
||||
|
||||

|
||||
|
||||
```python
|
||||
from qtrangeslider import QLabeledSlider
|
||||
```
|
||||
|
||||
(no additional options at this point)
|
||||
|
||||
## Issues
|
||||
|
||||
If you encounter any problems, please [file an issue] along with a detailed
|
||||
description.
|
||||
|
||||
[file an issue]: https://github.com/tlambert03/QtRangeSlider/issues
|
||||
Please see the [Contributing Guide](CONTRIBUTING.md)
|
||||
|
@@ -1,14 +1,15 @@
|
||||
ignore:
|
||||
- qtrangeslider/_version.py
|
||||
- superqt/_version.py
|
||||
- '*_tests*'
|
||||
coverage:
|
||||
status:
|
||||
project:
|
||||
default:
|
||||
target: auto
|
||||
threshold: 1% # coverage can drop by up to 1% while still posting success
|
||||
threshold: 1% # PR will fail if it drops coverage on the project by >1%
|
||||
patch:
|
||||
default:
|
||||
target: auto
|
||||
threshold: 40% # coverage can drop by up to 40% while still posting success
|
||||
threshold: 40% # A given PR will fail if >40% is untested
|
||||
comment:
|
||||
require_changes: true # if true: only post the PR comment if coverage changes
|
||||
|
146
docs/_macros.py
Normal file
@@ -0,0 +1,146 @@
|
||||
import sys
|
||||
from enum import EnumMeta
|
||||
from importlib import import_module
|
||||
from pathlib import Path
|
||||
from textwrap import dedent
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from jinja2 import pass_context
|
||||
from qtpy.QtCore import QObject, Signal
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from mkdocs_macros.plugin import MacrosPlugin
|
||||
|
||||
EXAMPLES = Path(__file__).parent.parent / "examples"
|
||||
IMAGES = Path(__file__).parent / "_auto_images"
|
||||
IMAGES.mkdir(exist_ok=True, parents=True)
|
||||
|
||||
|
||||
def define_env(env: "MacrosPlugin"):
|
||||
@env.macro
|
||||
@pass_context
|
||||
def show_widget(context, width: int = 500) -> list[Path]:
|
||||
# extract all fenced code blocks starting with "python"
|
||||
page = context["page"]
|
||||
dest = IMAGES / f"{page.title}.png"
|
||||
if "build" in sys.argv:
|
||||
dest.unlink(missing_ok=True)
|
||||
|
||||
codeblocks = [
|
||||
b[6:].strip()
|
||||
for b in page.markdown.split("```")
|
||||
if b.startswith("python")
|
||||
]
|
||||
src = codeblocks[0].strip()
|
||||
src = src.replace(
|
||||
"QApplication([])", "QApplication.instance() or QApplication([])"
|
||||
)
|
||||
src = src.replace("app.exec_()", "")
|
||||
|
||||
exec(src)
|
||||
_grab(dest, width)
|
||||
return (
|
||||
f""
|
||||
f"{{ loading=lazy; width={width} }}\n\n"
|
||||
)
|
||||
|
||||
@env.macro
|
||||
def show_members(cls: str):
|
||||
# import class
|
||||
module, name = cls.rsplit(".", 1)
|
||||
_cls = getattr(import_module(module), name)
|
||||
|
||||
first_q = next(
|
||||
(
|
||||
b.__name__
|
||||
for b in _cls.__mro__
|
||||
if issubclass(b, QObject) and ".Qt" in b.__module__
|
||||
),
|
||||
None,
|
||||
)
|
||||
|
||||
inherited_members = set()
|
||||
for base in _cls.__mro__:
|
||||
if issubclass(base, QObject) and ".Qt" in base.__module__:
|
||||
inherited_members.update(
|
||||
{k for k in dir(base) if not k.startswith("_")}
|
||||
)
|
||||
|
||||
new_signals = {
|
||||
k
|
||||
for k, v in vars(_cls).items()
|
||||
if not k.startswith("_") and isinstance(v, Signal)
|
||||
}
|
||||
|
||||
self_members = {
|
||||
k
|
||||
for k in dir(_cls)
|
||||
if not k.startswith("_") and k not in inherited_members | new_signals
|
||||
}
|
||||
|
||||
enums = []
|
||||
for m in list(self_members):
|
||||
if isinstance(getattr(_cls, m), EnumMeta):
|
||||
self_members.remove(m)
|
||||
enums.append(m)
|
||||
|
||||
out = ""
|
||||
if first_q:
|
||||
url = f"https://doc.qt.io/qt-6/{first_q.lower()}.html"
|
||||
out += f"## Qt Class\n\n<a href='{url}'>`{first_q}`</a>\n\n"
|
||||
|
||||
out += ""
|
||||
|
||||
if new_signals:
|
||||
out += "## Signals\n\n"
|
||||
for sig in new_signals:
|
||||
out += f"### `{sig}`\n\n"
|
||||
|
||||
if enums:
|
||||
out += "## Enums\n\n"
|
||||
for e in enums:
|
||||
out += f"### `{_cls.__name__}.{e}`\n\n"
|
||||
for m in getattr(_cls, e):
|
||||
out += f"- `{m.name}`\n\n"
|
||||
|
||||
if self_members:
|
||||
out += dedent(
|
||||
f"""
|
||||
## Methods
|
||||
|
||||
::: {cls}
|
||||
options:
|
||||
heading_level: 3
|
||||
show_source: False
|
||||
show_inherited_members: false
|
||||
show_signature_annotations: True
|
||||
members: {sorted(self_members)}
|
||||
docstring_style: numpy
|
||||
show_bases: False
|
||||
show_root_toc_entry: False
|
||||
show_root_heading: False
|
||||
"""
|
||||
)
|
||||
|
||||
return out
|
||||
|
||||
|
||||
def _grab(dest: str | Path, width) -> list[Path]:
|
||||
"""Grab the top widgets of the application."""
|
||||
from qtpy.QtCore import QTimer
|
||||
from qtpy.QtWidgets import QApplication
|
||||
|
||||
w = QApplication.topLevelWidgets()[-1]
|
||||
w.setFixedWidth(width)
|
||||
w.activateWindow()
|
||||
w.setMinimumHeight(40)
|
||||
w.grab().save(str(dest))
|
||||
|
||||
# hack to make sure the object is truly closed and deleted
|
||||
while True:
|
||||
QTimer.singleShot(10, w.deleteLater)
|
||||
QApplication.processEvents()
|
||||
try:
|
||||
w.parent()
|
||||
except RuntimeError:
|
||||
return
|
26
docs/faq.md
Normal file
@@ -0,0 +1,26 @@
|
||||
# FAQ
|
||||
|
||||
## Sliders not dragging properly on MacOS 12+
|
||||
|
||||
??? details
|
||||
On MacOS Monterey, with Qt5, there is a bug that causes all sliders
|
||||
(including native Qt sliders) to not respond properly to drag events. See:
|
||||
|
||||
- [https://bugreports.qt.io/browse/QTBUG-98093](https://bugreports.qt.io/browse/QTBUG-98093)
|
||||
- [https://github.com/pyapp-kit/superqt/issues/74](https://github.com/pyapp-kit/superqt/issues/74)
|
||||
|
||||
Superqt includes a workaround for this issue, but it is not perfect, and it requires using a custom stylesheet (which may interfere with your own styles). Note that you
|
||||
may not see this issue if you're already using custom stylesheets.
|
||||
|
||||
To opt in to the workaround, do any of the following:
|
||||
|
||||
- set the environment variable `USE_MAC_SLIDER_PATCH=1` before importing superqt
|
||||
(note: this is safe to use even if you're targeting more than just MacOS 12, it will only be applied when needed)
|
||||
- call the `applyMacStylePatch()` method on any of the superqt slider subclasses (note, this will override your slider styles)
|
||||
- apply the stylesheet manually:
|
||||
|
||||
```python
|
||||
from superqt.sliders import MONTEREY_SLIDER_STYLES_FIX
|
||||
|
||||
slider.setStyleSheet(MONTEREY_SLIDER_STYLES_FIX)
|
||||
```
|
Before Width: | Height: | Size: 42 KiB After Width: | Height: | Size: 42 KiB |
Before Width: | Height: | Size: 45 KiB After Width: | Height: | Size: 45 KiB |
Before Width: | Height: | Size: 24 KiB After Width: | Height: | Size: 24 KiB |
Before Width: | Height: | Size: 9.0 KiB After Width: | Height: | Size: 9.0 KiB |
Before Width: | Height: | Size: 9.0 KiB After Width: | Height: | Size: 9.0 KiB |
Before Width: | Height: | Size: 12 KiB After Width: | Height: | Size: 12 KiB |
Before Width: | Height: | Size: 86 KiB After Width: | Height: | Size: 86 KiB |
29
docs/index.md
Normal file
@@ -0,0 +1,29 @@
|
||||
# superqt
|
||||
|
||||
##  "missing" widgets and components for PyQt/PySide
|
||||
|
||||
This repository aims to provide high-quality community-contributed Qt widgets
|
||||
and components for [PyQt](https://riverbankcomputing.com/software/pyqt/) &
|
||||
[PySide](https://www.qt.io/qt-for-python) that are not provided in the native
|
||||
QtWidgets module.
|
||||
|
||||
Components are tested on:
|
||||
|
||||
- macOS, Windows, & Linux
|
||||
- Python 3.8 and above
|
||||
- PyQt5 (5.11 and above) & PyQt6
|
||||
- PySide2 (5.11 and above) & PySide6
|
||||
|
||||
## Installation
|
||||
|
||||
```bash
|
||||
pip install superqt
|
||||
```
|
||||
|
||||
```bash
|
||||
conda install -c conda-forge superqt
|
||||
```
|
||||
|
||||
## Usage
|
||||
|
||||
See the [Widgets](./widgets/index.md) and [Utilities](./utilities/index.md) pages for features offered by superqt.
|
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
|
52
docs/utilities/code_syntax_highlight.md
Normal file
@@ -0,0 +1,52 @@
|
||||
# CodeSyntaxHighlight
|
||||
|
||||
A code highlighter subclass of `QSyntaxHighlighter`
|
||||
that can be used to highlight code in a QTextEdit.
|
||||
|
||||
Code lexer and available styles are from [`pygments`](https://pygments.org/) python library
|
||||
|
||||
List of available languages are available [here](https://pygments.org/languages/).
|
||||
|
||||
List of available styles are available [here](https://pygments.org/styles/).
|
||||
|
||||
## Example
|
||||
|
||||
```python
|
||||
from qtpy.QtGui import QColor, QPalette
|
||||
from qtpy.QtWidgets import QApplication, QTextEdit
|
||||
|
||||
from superqt.utils import CodeSyntaxHighlight
|
||||
|
||||
app = QApplication([])
|
||||
|
||||
text_area = QTextEdit()
|
||||
|
||||
highlight = CodeSyntaxHighlight(text_area.document(), "python", "monokai")
|
||||
|
||||
palette = text_area.palette()
|
||||
palette.setColor(QPalette.Base, QColor(highlight.background_color))
|
||||
text_area.setPalette(palette)
|
||||
text_area.setText(
|
||||
"""from argparse import ArgumentParser
|
||||
|
||||
def main():
|
||||
parser = ArgumentParser()
|
||||
parser.add_argument("name", help="Your name")
|
||||
args = parser.parse_args()
|
||||
print(f"Hello {args.name}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
"""
|
||||
)
|
||||
|
||||
text_area.show()
|
||||
text_area.resize(400, 200)
|
||||
|
||||
app.exec_()
|
||||
```
|
||||
|
||||
{{ show_widget() }}
|
||||
|
||||
{{ show_members('superqt.utils.CodeSyntaxHighlight') }}
|
3
docs/utilities/error_dialog_contexts.md
Normal file
@@ -0,0 +1,3 @@
|
||||
# Error message context manager
|
||||
|
||||
::: superqt.utils.exceptions_as_dialog
|
124
docs/utilities/fonticon.md
Normal file
@@ -0,0 +1,124 @@
|
||||
# Font icons
|
||||
|
||||
The `superqt.fonticon` module provides a set of utilities for working with font
|
||||
icons such as [Font Awesome](https://fontawesome.com/) or [Material Design
|
||||
Icons](https://materialdesignicons.com/).
|
||||
|
||||
## Basic Example
|
||||
|
||||
```python
|
||||
from fonticon_fa5 import FA5S
|
||||
|
||||
from qtpy.QtCore import QSize
|
||||
from qtpy.QtWidgets import QApplication, QPushButton
|
||||
|
||||
from superqt.fonticon import icon, pulse
|
||||
|
||||
app = QApplication([])
|
||||
|
||||
btn2 = QPushButton()
|
||||
btn2.setIcon(icon(FA5S.smile, color="blue"))
|
||||
btn2.setIconSize(QSize(225, 225))
|
||||
btn2.show()
|
||||
|
||||
app.exec()
|
||||
```
|
||||
|
||||
{{ show_widget(225) }}
|
||||
|
||||
## Font Icon plugins
|
||||
|
||||
Ready-made fonticon packs are available as plugins.
|
||||
|
||||
A great way to search across most available icons libraries from a single
|
||||
search interface is to use glyphsearch: <https://glyphsearch.com/>
|
||||
|
||||
If a font library you'd like to use is unavailable as a superqt plugin,
|
||||
please [open a feature request](https://github.com/pyapp-kit/superqt/issues/new/choose)
|
||||
|
||||
|
||||
### Font Awesome 6
|
||||
|
||||
Browse available icons at <https://fontawesome.com/v6/search>
|
||||
|
||||
```bash
|
||||
pip install fonticon-fontawesome6
|
||||
```
|
||||
|
||||
### Font Awesome 5
|
||||
|
||||
Browse available icons at <https://fontawesome.com/v5/search>
|
||||
|
||||
```bash
|
||||
pip install fonticon-fontawesome5
|
||||
```
|
||||
|
||||
### Material Design Icons 7
|
||||
|
||||
Browse available icons at <https://materialdesignicons.com/>
|
||||
|
||||
```bash
|
||||
pip install fonticon-materialdesignicons7
|
||||
```
|
||||
|
||||
### Material Design Icons 6
|
||||
|
||||
Browse available icons at <https://materialdesignicons.com/>
|
||||
(note that the search defaults to v7, see changes from v6 in [the
|
||||
changelog](https://pictogrammers.com/docs/library/mdi/releases/changelog/))
|
||||
|
||||
```bash
|
||||
pip install fonticon-materialdesignicons6
|
||||
```
|
||||
|
||||
### See also
|
||||
|
||||
- <https://github.com/tlambert03/fonticon-bootstrapicons>
|
||||
- <https://github.com/tlambert03/fonticon-linearicons>
|
||||
- <https://github.com/tlambert03/fonticon-feather>
|
||||
|
||||
`superqt.fonticon` is a pluggable system, and font icon packs may use the `"superqt.fonticon"`
|
||||
entry point to register themselves with superqt. See [`fonticon-cookiecutter`](https://github.com/tlambert03/fonticon-cookiecutter) for a template, or look through the following repos for examples:
|
||||
|
||||
- <https://github.com/tlambert03/fonticon-fontawesome6>
|
||||
- <https://github.com/tlambert03/fonticon-fontawesome5>
|
||||
- <https://github.com/tlambert03/fonticon-materialdesignicons6>
|
||||
|
||||
## API
|
||||
|
||||
::: superqt.fonticon.icon
|
||||
options:
|
||||
heading_level: 3
|
||||
|
||||
::: superqt.fonticon.setTextIcon
|
||||
options:
|
||||
heading_level: 3
|
||||
|
||||
::: superqt.fonticon.font
|
||||
options:
|
||||
heading_level: 3
|
||||
|
||||
::: superqt.fonticon.IconOpts
|
||||
options:
|
||||
heading_level: 3
|
||||
|
||||
::: superqt.fonticon.addFont
|
||||
options:
|
||||
heading_level: 3
|
||||
|
||||
## Animations
|
||||
|
||||
the `animation` parameter to `icon()` accepts a subclass of
|
||||
`Animation` that will be
|
||||
|
||||
::: superqt.fonticon.Animation
|
||||
options:
|
||||
heading_level: 3
|
||||
|
||||
::: superqt.fonticon.pulse
|
||||
options:
|
||||
heading_level: 3
|
||||
|
||||
::: superqt.fonticon.spin
|
||||
options:
|
||||
heading_level: 3
|
32
docs/utilities/index.md
Normal file
@@ -0,0 +1,32 @@
|
||||
# Utilities
|
||||
|
||||
## Font Icons
|
||||
|
||||
| Object | Description |
|
||||
| ----------- | --------------------- |
|
||||
| [`addFont`](./fonticon.md#superqt.fonticon.addFont) | Add an `OTF/TTF` file at to the font registry. |
|
||||
| [`font`](./fonticon.md#superqt.fonticon.font) | Create `QFont` for a given font-icon font family key |
|
||||
| [`icon`](./fonticon.md#superqt.fonticon.icon) | Create a `QIcon` for font-con glyph key |
|
||||
| [`setTextIcon`](./fonticon.md#superqt.fonticon.setTextIcon) | Set text on a `QWidget` to a specific font & glyph. |
|
||||
| [`IconFont`](./fonticon.md#superqt.fonticon.IconFont) | Helper class that provides a standard way to create an `IconFont`. |
|
||||
| [`IconOpts`](./fonticon.md#superqt.fonticon.IconOpts) | Options for rendering an icon |
|
||||
| [`Animation`](./fonticon.md#superqt.fonticon.Animation) | Base class for adding animations to a font-icon. |
|
||||
|
||||
## Threading tools
|
||||
|
||||
| Object | Description |
|
||||
| ----------- | --------------------- |
|
||||
| [`ensure_main_thread`](./thread_decorators.md#ensure_main_thread) | Decorator that ensures a function is called in the main `QApplication` thread. |
|
||||
| [`ensure_object_thread`](./thread_decorators.md#ensure_object_thread) | Decorator that ensures a `QObject` method is called in the object's thread. |
|
||||
| [`FunctionWorker`](./threading.md#superqt.utils.FunctionWorker) | `QRunnable` with signals that wraps a simple long-running function. |
|
||||
| [`GeneratorWorker`](./threading.md#superqt.utils.GeneratorWorker) | `QRunnable` with signals that wraps a long-running generator. |
|
||||
| [`create_worker`](./threading.md#superqt.utils.create_worker) | Create a worker to run a target function in another thread. |
|
||||
| [`thread_worker`](./threading.md#superqt.utils.thread_worker) | Decorator for `create_worker`, turn a function into a worker. |
|
||||
|
||||
## Miscellaneous
|
||||
|
||||
| Object | Description |
|
||||
| ----------- | --------------------- |
|
||||
| [`QMessageHandler`](./qmessagehandler.md) | A context manager to intercept messages from Qt. |
|
||||
| [`CodeSyntaxHighlight`](./code_syntax_highlight.md) | A `QSyntaxHighlighter` for code syntax highlighting. |
|
||||
| [`draw_colormap`](./cmap.md) | Function that draws a colormap into any QPaintDevice. |
|
8
docs/utilities/qmessagehandler.md
Normal file
@@ -0,0 +1,8 @@
|
||||
# QMessageHandler
|
||||
|
||||
::: superqt.utils.QMessageHandler
|
||||
options:
|
||||
heading_level: 3
|
||||
show_signature_annotations: True
|
||||
docstring_style: numpy
|
||||
show_bases: False
|
3
docs/utilities/signal_utils.md
Normal file
@@ -0,0 +1,3 @@
|
||||
# Signal Utilities
|
||||
|
||||
::: superqt.utils.signals_blocked
|
94
docs/utilities/thread_decorators.md
Normal file
@@ -0,0 +1,94 @@
|
||||
# Threading decorators
|
||||
|
||||
`superqt` provides two decorators that help to ensure that given function is
|
||||
running in the desired thread:
|
||||
|
||||
## `ensure_main_thread`
|
||||
|
||||
`ensure_main_thread` ensures that the decorated function/method runs in the main thread
|
||||
|
||||
## `ensure_object_thread`
|
||||
|
||||
`ensure_object_thread` ensures that a decorated bound method of a `QObject` runs
|
||||
in the thread in which the instance lives ([see qt documentation for
|
||||
details](https://doc.qt.io/qt-5/threads-qobject.html#accessing-qobject-subclasses-from-other-threads)).
|
||||
|
||||
## Usage
|
||||
|
||||
By default, functions are executed asynchronously (they return immediately with
|
||||
an instance of
|
||||
[`concurrent.futures.Future`](https://docs.python.org/3/library/concurrent.futures.html#concurrent.futures.Future)).
|
||||
|
||||
To block and wait for the result, see [Synchronous mode](#synchronous-mode)
|
||||
|
||||
```python
|
||||
from qtpy.QtCore import QObject
|
||||
from superqt import ensure_main_thread, ensure_object_thread
|
||||
|
||||
@ensure_main_thread
|
||||
def sample_function():
|
||||
print("This function will run in main thread")
|
||||
|
||||
|
||||
class SampleObject(QObject):
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self._value = 1
|
||||
|
||||
@ensure_main_thread
|
||||
def sample_method1(self):
|
||||
print("This method will run in main thread")
|
||||
|
||||
@ensure_object_thread
|
||||
def sample_method3(self):
|
||||
import time
|
||||
print("sleeping")
|
||||
time.sleep(1)
|
||||
print("This method will run in object thread")
|
||||
|
||||
@property
|
||||
def value(self):
|
||||
print("return value")
|
||||
return self._value
|
||||
|
||||
@value.setter
|
||||
@ensure_object_thread
|
||||
def value(self, value):
|
||||
print("this setter will run in object thread")
|
||||
self._value = value
|
||||
```
|
||||
|
||||
As can be seen in this example these decorators can also be used for setters.
|
||||
|
||||
These decorators should not be used as replacement of Qt Signals but rather to
|
||||
interact with Qt objects from non Qt code.
|
||||
|
||||
## Synchronous mode
|
||||
|
||||
If you'd like for the program to block and wait for the result of your function
|
||||
call, use the `await_return=True` parameter, and optionally specify a timeout.
|
||||
|
||||
!!! important
|
||||
|
||||
Using synchronous mode may significantly impact performance.
|
||||
|
||||
```python
|
||||
from superqt import ensure_main_thread
|
||||
|
||||
@ensure_main_thread
|
||||
def sample_function1():
|
||||
return 1
|
||||
|
||||
@ensure_main_thread(await_return=True)
|
||||
def sample_function2():
|
||||
return 2
|
||||
|
||||
assert sample_function1() is None
|
||||
assert sample_function2() == 2
|
||||
|
||||
# optionally, specify a timeout
|
||||
@ensure_main_thread(await_return=True, timeout=10000)
|
||||
def sample_function():
|
||||
return 1
|
||||
|
||||
```
|
36
docs/utilities/threading.md
Normal file
@@ -0,0 +1,36 @@
|
||||
# Thread workers
|
||||
|
||||
The objects in this module provide utilities for running tasks in a separate
|
||||
thread. In general (with the exception of `new_worker_qthread`), everything
|
||||
here wraps Qt's [QRunnable API](https://doc.qt.io/qt-6/qrunnable.html).
|
||||
|
||||
The highest level object is the
|
||||
[`@thread_worker`][superqt.utils.thread_worker] decorator. It was originally
|
||||
written for `napari`, and was later extracted into `superqt`. You may also be
|
||||
interested in reading the [napari
|
||||
documentation](https://napari.org/stable/guides/threading.html#threading-in-napari-with-thread-worker) on this feature,
|
||||
which provides a more in-depth/introductory usage guide.
|
||||
|
||||
For additional control, you can create your own
|
||||
[`FunctionWorker`][superqt.utils.FunctionWorker] or
|
||||
[`GeneratorWorker`][superqt.utils.GeneratorWorker] objects.
|
||||
|
||||
::: superqt.utils.WorkerBase
|
||||
|
||||
::: superqt.utils.FunctionWorker
|
||||
|
||||
::: superqt.utils.GeneratorWorker
|
||||
|
||||
## Convenience functions
|
||||
|
||||
::: superqt.utils.thread_worker
|
||||
options:
|
||||
heading_level: 3
|
||||
|
||||
::: superqt.utils.create_worker
|
||||
options:
|
||||
heading_level: 3
|
||||
|
||||
::: superqt.utils.new_worker_qthread
|
||||
options:
|
||||
heading_level: 3
|
46
docs/utilities/throttling.md
Normal file
@@ -0,0 +1,46 @@
|
||||
# Throttling & Debouncing
|
||||
|
||||
These utilities allow you to throttle or debounce a function. This is useful
|
||||
when you have a function that is called multiple times in a short period of
|
||||
time, and you want to make sure it is only "actually" called once (or at least
|
||||
no more than a certain frequency).
|
||||
|
||||
For background on throttling and debouncing, see:
|
||||
|
||||
- <https://blog.openreplay.com/forever-functional-debouncing-and-throttling-for-performance>
|
||||
- <https://css-tricks.com/debouncing-throttling-explained-examples/>
|
||||
|
||||
::: superqt.utils.qdebounced
|
||||
options:
|
||||
show_source: false
|
||||
docstring_style: numpy
|
||||
show_root_toc_entry: True
|
||||
show_root_heading: True
|
||||
|
||||
::: superqt.utils.qthrottled
|
||||
options:
|
||||
show_source: false
|
||||
docstring_style: numpy
|
||||
show_root_toc_entry: True
|
||||
show_root_heading: True
|
||||
|
||||
::: superqt.utils.QSignalDebouncer
|
||||
options:
|
||||
show_source: false
|
||||
docstring_style: numpy
|
||||
show_root_toc_entry: True
|
||||
show_root_heading: True
|
||||
|
||||
::: superqt.utils.QSignalThrottler
|
||||
options:
|
||||
show_source: false
|
||||
docstring_style: numpy
|
||||
show_root_toc_entry: True
|
||||
show_root_heading: True
|
||||
|
||||
::: superqt.utils._throttler.GenericSignalThrottler
|
||||
options:
|
||||
show_source: false
|
||||
docstring_style: numpy
|
||||
show_root_toc_entry: True
|
||||
show_root_heading: True
|
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') }}
|
35
docs/widgets/index.md
Normal file
@@ -0,0 +1,35 @@
|
||||
# Widgets
|
||||
|
||||
The following are QWidget subclasses:
|
||||
|
||||
## Sliders and Numerical Inputs
|
||||
|
||||
| Widget | Description |
|
||||
| ----------- | --------------------- |
|
||||
| [`QDoubleRangeSlider`](./qdoublerangeslider.md) | Multi-handle slider for float values |
|
||||
| [`QDoubleSlider`](./qdoubleslider.md) | Slider for float values |
|
||||
| [`QLabeledDoubleRangeSlider`](./qlabeleddoublerangeslider.md) | `QDoubleRangeSlider` variant with editable labels for each handle |
|
||||
| [`QLabeledDoubleSlider`](./qlabeleddoubleslider.md) | `QSlider` for float values with editable `QSpinBox` with the current value |
|
||||
| [`QLabeledRangeSlider`](./qlabeledrangeslider.md) | `QRangeSlider` variant, with editable labels for each handle |
|
||||
| [`QLabeledSlider`](./qlabeledslider.md) | `QSlider` with editable `QSpinBox` that shows the current value |
|
||||
| [`QLargeIntSpinBox`](./qlargeintspinbox.md) | `QSpinbox` that accepts arbitrarily large integers |
|
||||
| [`QRangeSlider`](./qrangeslider.md) | Multi-handle slider |
|
||||
| [`QQuantity`](./qquantity.md) | Pint-backed quantity widget (magnitude combined with unit dropdown) |
|
||||
|
||||
## Labels and categorical inputs
|
||||
|
||||
| Widget | Description |
|
||||
| ----------- | --------------------- |
|
||||
| [`QElidingLabel`](./qelidinglabel.md) | A `QLabel` variant that will elide text (add `…`) to fit width. |
|
||||
| [`QEnumComboBox`](./qenumcombobox.md) | `QComboBox` that populates the combobox from a python `Enum` |
|
||||
| [`QSearchableComboBox`](./qsearchablecombobox.md) | `QComboBox` variant that filters available options based on text input |
|
||||
| [`QSearchableListWidget`](./qsearchablelistwidget.md) | `QListWidget` variant with search field that filters available options |
|
||||
| [`QSearchableTreeWidget`](./qsearchabletreewidget.md) | `QTreeWidget` variant with search field that filters available options |
|
||||
| [`QColorComboBox`](./qcolorcombobox.md) | `QComboBox` to select from a specified set of colors |
|
||||
| [`QColormapComboBox`](./qcolormap.md) | `QComboBox` to select from a specified set of colormaps. |
|
||||
|
||||
## Frames and containers
|
||||
|
||||
| Widget | Description |
|
||||
| ----------- | --------------------- |
|
||||
| [`QCollapsible`](./qcollapsible.md) | A collapsible widget to hide and unhide child widgets. |
|
24
docs/widgets/qcollapsible.md
Normal file
@@ -0,0 +1,24 @@
|
||||
# QCollapsible
|
||||
|
||||
Collapsible `QFrame` that can be expanded or collapsed by clicking on the header.
|
||||
|
||||
```python
|
||||
from qtpy.QtWidgets import QApplication, QLabel, QPushButton
|
||||
|
||||
from superqt import QCollapsible
|
||||
|
||||
app = QApplication([])
|
||||
|
||||
collapsible = QCollapsible("Advanced analysis")
|
||||
collapsible.addWidget(QLabel("This is the inside of the collapsible frame"))
|
||||
for i in range(10):
|
||||
collapsible.addWidget(QPushButton(f"Content button {i + 1}"))
|
||||
|
||||
collapsible.expand(animate=False)
|
||||
collapsible.show()
|
||||
app.exec_()
|
||||
```
|
||||
|
||||
{{ show_widget(350) }}
|
||||
|
||||
{{ show_members('superqt.QCollapsible') }}
|
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
@@ -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') }}
|
23
docs/widgets/qdoublerangeslider.md
Normal file
@@ -0,0 +1,23 @@
|
||||
# QDoubleRangeSlider
|
||||
|
||||
Float variant of [`QRangeSlider`](qrangeslider.md). (see that page for more details).
|
||||
|
||||
```python
|
||||
from qtpy.QtCore import Qt
|
||||
from qtpy.QtWidgets import QApplication
|
||||
|
||||
from superqt import QDoubleRangeSlider
|
||||
|
||||
app = QApplication([])
|
||||
|
||||
slider = QDoubleRangeSlider(Qt.Orientation.Horizontal)
|
||||
slider.setRange(0, 1)
|
||||
slider.setValue((0.2, 0.8))
|
||||
slider.show()
|
||||
|
||||
app.exec_()
|
||||
```
|
||||
|
||||
{{ show_widget() }}
|
||||
|
||||
{{ show_members('superqt.QDoubleRangeSlider') }}
|
23
docs/widgets/qdoubleslider.md
Normal file
@@ -0,0 +1,23 @@
|
||||
# QDoubleSlider
|
||||
|
||||
`QSlider` variant that accepts floating point values.
|
||||
|
||||
```python
|
||||
from qtpy.QtCore import Qt
|
||||
from qtpy.QtWidgets import QApplication
|
||||
|
||||
from superqt import QDoubleSlider
|
||||
|
||||
app = QApplication([])
|
||||
|
||||
slider = QDoubleSlider(Qt.Orientation.Horizontal)
|
||||
slider.setRange(0, 1)
|
||||
slider.setValue(0.5)
|
||||
slider.show()
|
||||
|
||||
app.exec_()
|
||||
```
|
||||
|
||||
{{ show_widget() }}
|
||||
|
||||
{{ show_members('superqt.QDoubleSlider') }}
|
26
docs/widgets/qelidinglabel.md
Normal file
@@ -0,0 +1,26 @@
|
||||
# QElidingLabel
|
||||
|
||||
`QLabel` variant that will elide text (i.e. add an ellipsis)
|
||||
if it is too long to fit in the available space.
|
||||
|
||||
```python
|
||||
from qtpy.QtWidgets import QApplication
|
||||
|
||||
from superqt import QElidingLabel
|
||||
|
||||
app = QApplication([])
|
||||
|
||||
widget = QElidingLabel(
|
||||
"a skj skjfskfj sdlf sdfl sdlfk jsdf sdlkf jdsf dslfksdl sdlfk sdf sdl "
|
||||
"fjsdlf kjsdlfk laskdfsal as lsdfjdsl kfjdslf asfd dslkjfldskf sdlkfj"
|
||||
)
|
||||
widget.setWordWrap(True)
|
||||
widget.resize(300, 20)
|
||||
widget.show()
|
||||
|
||||
app.exec_()
|
||||
```
|
||||
|
||||
{{ show_widget(300) }}
|
||||
|
||||
{{ show_members('superqt.QElidingLabel') }}
|
72
docs/widgets/qenumcombobox.md
Normal file
@@ -0,0 +1,72 @@
|
||||
# QEnumComboBox
|
||||
|
||||
`QEnumComboBox` is a variant of
|
||||
[`QComboBox`](https://doc.qt.io/qt-5/qcombobox.html) that populates the items in
|
||||
the combobox based on a python `Enum` class. In addition to all the methods
|
||||
provided by `QComboBox`, this subclass adds the methods
|
||||
`enumClass`/`setEnumClass` to get/set the current `Enum` class represented by
|
||||
the combobox, and `currentEnum`/`setCurrentEnum` to get/set the current `Enum`
|
||||
member in the combobox. There is also a new signal `currentEnumChanged(enum)`
|
||||
analogous to `currentIndexChanged` and `currentTextChanged`.
|
||||
|
||||
Method like `insertItem` and `addItem` are blocked and try of its usage will end
|
||||
with `RuntimeError`
|
||||
|
||||
```python
|
||||
from enum import Enum
|
||||
|
||||
from qtpy.QtWidgets import QApplication
|
||||
from superqt import QEnumComboBox
|
||||
|
||||
|
||||
class SampleEnum(Enum):
|
||||
first = 1
|
||||
second = 2
|
||||
third = 3
|
||||
|
||||
app = QApplication([])
|
||||
|
||||
combo = QEnumComboBox()
|
||||
combo.setEnumClass(SampleEnum)
|
||||
combo.show()
|
||||
|
||||
app.exec_()
|
||||
```
|
||||
|
||||
{{ show_widget() }}
|
||||
|
||||
Another option is to use optional `enum_class` argument of constructor and change
|
||||
|
||||
```python
|
||||
# option A:
|
||||
combo = QEnumComboBox()
|
||||
combo.setEnumClass(SampleEnum)
|
||||
# option B:
|
||||
combo = QEnumComboBox(enum_class=SampleEnum)
|
||||
```
|
||||
|
||||
## Allow `None`
|
||||
|
||||
`QEnumComboBox` also allows using `Optional` type annotation:
|
||||
|
||||
```python
|
||||
from enum import Enum
|
||||
|
||||
from superqt import QEnumComboBox
|
||||
|
||||
class SampleEnum(Enum):
|
||||
first = 1
|
||||
second = 2
|
||||
third = 3
|
||||
|
||||
# as usual:
|
||||
# you must create a QApplication before create a widget.
|
||||
|
||||
combo = QEnumComboBox()
|
||||
combo.setEnumClass(SampleEnum, allow_none=True)
|
||||
```
|
||||
|
||||
In this case there is added option `----` and the `currentEnum()` method will
|
||||
return `None` when it is selected.
|
||||
|
||||
{{ show_members('superqt.QEnumComboBox') }}
|
23
docs/widgets/qlabeleddoublerangeslider.md
Normal file
@@ -0,0 +1,23 @@
|
||||
# QLabeledDoubleRangeSlider
|
||||
|
||||
Labeled Float variant of [`QRangeSlider`](qrangeslider.md). (see that page for more details).
|
||||
|
||||
```python
|
||||
from qtpy.QtCore import Qt
|
||||
from qtpy.QtWidgets import QApplication
|
||||
|
||||
from superqt import QLabeledDoubleRangeSlider
|
||||
|
||||
app = QApplication([])
|
||||
|
||||
slider = QLabeledDoubleRangeSlider(Qt.Orientation.Horizontal)
|
||||
slider.setRange(0, 1)
|
||||
slider.setValue((0.2, 0.8))
|
||||
slider.show()
|
||||
|
||||
app.exec_()
|
||||
```
|
||||
|
||||
{{ show_widget() }}
|
||||
|
||||
{{ show_members('superqt.QLabeledDoubleRangeSlider') }}
|
24
docs/widgets/qlabeleddoubleslider.md
Normal file
@@ -0,0 +1,24 @@
|
||||
# QLabeledDoubleSlider
|
||||
|
||||
[`QDoubleSlider`](./qdoubleslider.md) variant that shows an editable (SpinBox) label next to the slider.
|
||||
|
||||
|
||||
```python
|
||||
from qtpy.QtCore import Qt
|
||||
from qtpy.QtWidgets import QApplication
|
||||
|
||||
from superqt import QLabeledDoubleSlider
|
||||
|
||||
app = QApplication([])
|
||||
|
||||
slider = QLabeledDoubleSlider(Qt.Orientation.Horizontal)
|
||||
slider.setRange(0, 2.5)
|
||||
slider.setValue(1.3)
|
||||
slider.show()
|
||||
|
||||
app.exec_()
|
||||
```
|
||||
|
||||
{{ show_widget() }}
|
||||
|
||||
{{ show_members('superqt.QLabeledDoubleSlider') }}
|
29
docs/widgets/qlabeledrangeslider.md
Normal file
@@ -0,0 +1,29 @@
|
||||
# QLabeledRangeSlider
|
||||
|
||||
Labeled variant of [`QRangeSlider`](qrangeslider.md). (see that page for more details).
|
||||
|
||||
```python
|
||||
from qtpy.QtCore import Qt
|
||||
from qtpy.QtWidgets import QApplication
|
||||
|
||||
from superqt import QLabeledRangeSlider
|
||||
|
||||
app = QApplication([])
|
||||
|
||||
slider = QLabeledRangeSlider(Qt.Orientation.Horizontal)
|
||||
slider.setValue((20, 80))
|
||||
slider.show()
|
||||
|
||||
app.exec_()
|
||||
```
|
||||
|
||||
{{ show_widget() }}
|
||||
|
||||
{{ show_members('superqt.QLabeledRangeSlider') }}
|
||||
|
||||
----
|
||||
|
||||
If you find that you need to fine tune the position of the handle labels:
|
||||
|
||||
- `QLabeledRangeSlider.label_shift_x`: adjust horizontal label position
|
||||
- `QLabeledRangeSlider.label_shift_y`: adjust vertical label position
|
22
docs/widgets/qlabeledslider.md
Normal file
@@ -0,0 +1,22 @@
|
||||
# QLabeledSlider
|
||||
|
||||
`QSlider` variant that shows an editable (SpinBox) label next to the slider.
|
||||
|
||||
```python
|
||||
from qtpy.QtCore import Qt
|
||||
from qtpy.QtWidgets import QApplication
|
||||
|
||||
from superqt import QLabeledSlider
|
||||
|
||||
app = QApplication([])
|
||||
|
||||
slider = QLabeledSlider(Qt.Orientation.Horizontal)
|
||||
slider.setValue(42)
|
||||
slider.show()
|
||||
|
||||
app.exec_()
|
||||
```
|
||||
|
||||
{{ show_widget() }}
|
||||
|
||||
{{ show_members('superqt.QLabeledSlider') }}
|
23
docs/widgets/qlargeintspinbox.md
Normal file
@@ -0,0 +1,23 @@
|
||||
# QLargeIntSpinBox
|
||||
|
||||
`QSpinBox` variant that allows to enter large integers, without overflow.
|
||||
|
||||
```python
|
||||
from qtpy.QtCore import Qt
|
||||
from qtpy.QtWidgets import QApplication
|
||||
|
||||
from superqt import QLargeIntSpinBox
|
||||
|
||||
app = QApplication([])
|
||||
|
||||
slider = QLargeIntSpinBox()
|
||||
slider.setRange(0, 4.53e8)
|
||||
slider.setValue(4.53e8)
|
||||
slider.show()
|
||||
|
||||
app.exec_()
|
||||
```
|
||||
|
||||
{{ show_widget(150) }}
|
||||
|
||||
{{ show_members('superqt.QLargeIntSpinBox') }}
|
33
docs/widgets/qquantity.md
Normal file
@@ -0,0 +1,33 @@
|
||||
# QQuantity
|
||||
|
||||
A widget that allows the user to edit a quantity (a magnitude associated with a unit).
|
||||
|
||||
!!! note
|
||||
|
||||
This widget requires [`pint`](https://pint.readthedocs.io):
|
||||
|
||||
```
|
||||
pip install pint
|
||||
```
|
||||
|
||||
or
|
||||
|
||||
```
|
||||
pip install superqt[quantity]
|
||||
```
|
||||
|
||||
```python
|
||||
from qtpy.QtWidgets import QApplication
|
||||
|
||||
from superqt import QQuantity
|
||||
|
||||
app = QApplication([])
|
||||
w = QQuantity("1m")
|
||||
w.show()
|
||||
|
||||
app.exec()
|
||||
```
|
||||
|
||||
{{ show_widget(150) }}
|
||||
|
||||
{{ show_members('superqt.QQuantity') }}
|
229
docs/widgets/qrangeslider.md
Normal file
@@ -0,0 +1,229 @@
|
||||
# QRangeSlider
|
||||
|
||||
A multi-handle slider widget than can be used to
|
||||
select a range of values.
|
||||
|
||||
```python
|
||||
from qtpy.QtCore import Qt
|
||||
from qtpy.QtWidgets import QApplication
|
||||
|
||||
from superqt import QRangeSlider
|
||||
|
||||
app = QApplication([])
|
||||
|
||||
slider = QRangeSlider(Qt.Orientation.Horizontal)
|
||||
slider.setValue((20, 80))
|
||||
slider.show()
|
||||
|
||||
app.exec_()
|
||||
```
|
||||
|
||||
{{ show_widget() }}
|
||||
|
||||
- `QRangeSlider` inherits from [`QSlider`](https://doc.qt.io/qt-5/qslider.html)
|
||||
and attempts to match the Qt API as closely as possible
|
||||
- It uses platform-specific styles (for handle, groove, & ticks) but also supports
|
||||
QSS style sheets.
|
||||
- Supports mouse wheel events
|
||||
- Supports more than 2 handles (e.g. `slider.setValue([0, 10, 60, 80])`)
|
||||
|
||||
As `QRangeSlider` inherits from
|
||||
[`QtWidgets.QSlider`](https://doc.qt.io/qt-5/qslider.html), you can use all of
|
||||
the same methods available in the [QSlider
|
||||
API](https://doc.qt.io/qt-5/qslider.html). The major difference is that `value()`
|
||||
and `sliderPosition()` are reimplemented as `tuples` of `int` (where the length of
|
||||
the tuple is equal to the number of handles in the slider.)
|
||||
|
||||
These options are in addition to the Qt QSlider API, and control the behavior of the bar between handles.
|
||||
|
||||
| getter | setter | type | default | description |
|
||||
| -------------------- | ------------------------------------------- | ------ | ------- | ------------------------------------------------------------------------------------------------ |
|
||||
| `barIsVisible` | `setBarIsVisible` <br>`hideBar` / `showBar` | `bool` | `True` | <small>Whether the bar between handles is visible.</small> |
|
||||
| `barMovesAllHandles` | `setBarMovesAllHandles` | `bool` | `True` | <small>Whether clicking on the bar moves all handles or just the nearest</small> |
|
||||
| `barIsRigid` | `setBarIsRigid` | `bool` | `True` | <small>Whether bar length is constant or "elastic" when dragging the bar beyond min/max.</small> |
|
||||
|
||||
### Screenshots
|
||||
|
||||
??? title "code that generates the images below"
|
||||
|
||||
```python
|
||||
import os
|
||||
|
||||
from qtpy import QtCore
|
||||
from qtpy import QtWidgets as QtW
|
||||
|
||||
# patch for Qt 5.15 on macos >= 12
|
||||
os.environ["USE_MAC_SLIDER_PATCH"] = "1"
|
||||
|
||||
from superqt import QRangeSlider # noqa
|
||||
|
||||
QSS = """
|
||||
QSlider {
|
||||
min-height: 20px;
|
||||
}
|
||||
|
||||
QSlider::groove:horizontal {
|
||||
border: 0px;
|
||||
background: qlineargradient(x1:0, y1:0, x2:1, y2:1, stop:0 #888, stop:1 #ddd);
|
||||
height: 20px;
|
||||
border-radius: 10px;
|
||||
}
|
||||
|
||||
QSlider::handle {
|
||||
background: qradialgradient(cx:0, cy:0, radius: 1.2, fx:0.35,
|
||||
fy:0.3, stop:0 #eef, stop:1 #002);
|
||||
height: 20px;
|
||||
width: 20px;
|
||||
border-radius: 10px;
|
||||
}
|
||||
|
||||
QSlider::sub-page:horizontal {
|
||||
background: qlineargradient(x1:0, y1:0, x2:1, y2:1, stop:0 #227, stop:1 #77a);
|
||||
border-top-left-radius: 10px;
|
||||
border-bottom-left-radius: 10px;
|
||||
}
|
||||
|
||||
QRangeSlider {
|
||||
qproperty-barColor: qlineargradient(x1:0, y1:0, x2:1, y2:1, stop:0 #227, stop:1 #77a);
|
||||
}
|
||||
"""
|
||||
|
||||
Horizontal = QtCore.Qt.Orientation.Horizontal
|
||||
|
||||
|
||||
class DemoWidget(QtW.QWidget):
|
||||
def __init__(self) -> None:
|
||||
super().__init__()
|
||||
|
||||
reg_hslider = QtW.QSlider(Horizontal)
|
||||
reg_hslider.setValue(50)
|
||||
range_hslider = QRangeSlider(Horizontal)
|
||||
range_hslider.setValue((20, 80))
|
||||
multi_range_hslider = QRangeSlider(Horizontal)
|
||||
multi_range_hslider.setValue((11, 33, 66, 88))
|
||||
multi_range_hslider.setTickPosition(QtW.QSlider.TickPosition.TicksAbove)
|
||||
|
||||
styled_reg_hslider = QtW.QSlider(Horizontal)
|
||||
styled_reg_hslider.setValue(50)
|
||||
styled_reg_hslider.setStyleSheet(QSS)
|
||||
styled_range_hslider = QRangeSlider(Horizontal)
|
||||
styled_range_hslider.setValue((20, 80))
|
||||
styled_range_hslider.setStyleSheet(QSS)
|
||||
|
||||
reg_vslider = QtW.QSlider(QtCore.Qt.Orientation.Vertical)
|
||||
reg_vslider.setValue(50)
|
||||
range_vslider = QRangeSlider(QtCore.Qt.Orientation.Vertical)
|
||||
range_vslider.setValue((22, 77))
|
||||
|
||||
tick_vslider = QtW.QSlider(QtCore.Qt.Orientation.Vertical)
|
||||
tick_vslider.setValue(55)
|
||||
tick_vslider.setTickPosition(QtW.QSlider.TicksRight)
|
||||
range_tick_vslider = QRangeSlider(QtCore.Qt.Orientation.Vertical)
|
||||
range_tick_vslider.setValue((22, 77))
|
||||
range_tick_vslider.setTickPosition(QtW.QSlider.TicksLeft)
|
||||
|
||||
szp = QtW.QSizePolicy.Maximum
|
||||
left = QtW.QWidget()
|
||||
left.setLayout(QtW.QVBoxLayout())
|
||||
left.setContentsMargins(2, 2, 2, 2)
|
||||
label1 = QtW.QLabel("Regular QSlider Unstyled")
|
||||
label2 = QtW.QLabel("QRangeSliders Unstyled")
|
||||
label3 = QtW.QLabel("Styled Sliders (using same stylesheet)")
|
||||
label1.setSizePolicy(szp, szp)
|
||||
label2.setSizePolicy(szp, szp)
|
||||
label3.setSizePolicy(szp, szp)
|
||||
left.layout().addWidget(label1)
|
||||
left.layout().addWidget(reg_hslider)
|
||||
left.layout().addWidget(label2)
|
||||
left.layout().addWidget(range_hslider)
|
||||
left.layout().addWidget(multi_range_hslider)
|
||||
left.layout().addWidget(label3)
|
||||
left.layout().addWidget(styled_reg_hslider)
|
||||
left.layout().addWidget(styled_range_hslider)
|
||||
|
||||
right = QtW.QWidget()
|
||||
right.setLayout(QtW.QHBoxLayout())
|
||||
right.setContentsMargins(15, 5, 5, 0)
|
||||
right.layout().setSpacing(30)
|
||||
right.layout().addWidget(reg_vslider)
|
||||
right.layout().addWidget(range_vslider)
|
||||
right.layout().addWidget(tick_vslider)
|
||||
right.layout().addWidget(range_tick_vslider)
|
||||
|
||||
self.setLayout(QtW.QHBoxLayout())
|
||||
self.layout().addWidget(left)
|
||||
self.layout().addWidget(right)
|
||||
self.setGeometry(600, 300, 580, 300)
|
||||
self.activateWindow()
|
||||
self.show()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
dest = Path("screenshots")
|
||||
dest.mkdir(exist_ok=True)
|
||||
|
||||
app = QtW.QApplication([])
|
||||
demo = DemoWidget()
|
||||
|
||||
if "-snap" in sys.argv:
|
||||
import platform
|
||||
|
||||
QtW.QApplication.processEvents()
|
||||
demo.grab().save(str(dest / f"demo_{platform.system().lower()}.png"))
|
||||
else:
|
||||
app.exec_()
|
||||
```
|
||||
|
||||
#### macOS
|
||||
|
||||
##### Catalina
|
||||
|
||||
{ width=580; }
|
||||
|
||||
##### Big Sur
|
||||
|
||||
{ width=580; }
|
||||
|
||||
#### Windows
|
||||
|
||||

|
||||
|
||||
#### Linux
|
||||
|
||||

|
||||
|
||||
|
||||
{{ show_members('superqt.sliders._sliders._GenericRangeSlider') }}
|
||||
|
||||
## Type changes
|
||||
|
||||
Note the following changes in types compared to the `QSlider` API:
|
||||
|
||||
```python
|
||||
value() -> Tuple[int, ...]
|
||||
```
|
||||
|
||||
```python
|
||||
setValue(val: Sequence[int]) -> None
|
||||
```
|
||||
|
||||
```python
|
||||
# Signal
|
||||
valueChanged(Tuple[int, ...])
|
||||
```
|
||||
|
||||
```python
|
||||
sliderPosition() -> Tuple[int, ...]
|
||||
```
|
||||
|
||||
```python
|
||||
setSliderPosition(val: Sequence[int]) -> None
|
||||
```
|
||||
|
||||
```python
|
||||
sliderMoved(Tuple[int, ...])
|
||||
```
|
25
docs/widgets/qsearchablecombobox.md
Normal file
@@ -0,0 +1,25 @@
|
||||
# QSearchableComboBox
|
||||
|
||||
`QSearchableComboBox` is a variant of
|
||||
[`QComboBox`](https://doc.qt.io/qt-5/qcombobox.html) that allow to filter list
|
||||
of options by enter part of text. It could be drop in replacement for
|
||||
`QComboBox`.
|
||||
|
||||
|
||||
```python
|
||||
from qtpy.QtWidgets import QApplication
|
||||
|
||||
from superqt import QSearchableComboBox
|
||||
|
||||
app = QApplication([])
|
||||
|
||||
combo = QSearchableComboBox()
|
||||
combo.addItems(["foo", "bar", "baz", "foobar", "foobaz", "barbaz"])
|
||||
combo.show()
|
||||
|
||||
app.exec_()
|
||||
```
|
||||
|
||||
{{ show_widget() }}
|
||||
|
||||
{{ show_members('superqt.QSearchableComboBox') }}
|
28
docs/widgets/qsearchablelistwidget.md
Normal file
@@ -0,0 +1,28 @@
|
||||
# QSearchableListWidget
|
||||
|
||||
`QSearchableListWidget` is a variant of
|
||||
[`QListWidget`](https://doc.qt.io/qt-5/qlistwidget.html) that add text entry
|
||||
above list widget that allow to filter list of available options.
|
||||
|
||||
Due to implementation details, this widget it does not inherit directly from
|
||||
[`QListWidget`](https://doc.qt.io/qt-5/qlistwidget.html) but it does fully
|
||||
satisfy its api. The only limitation is that it cannot be used as argument of
|
||||
[`QListWidgetItem`](https://doc.qt.io/qt-5/qlistwidgetitem.html) constructor.
|
||||
|
||||
```python
|
||||
from qtpy.QtWidgets import QApplication
|
||||
|
||||
from superqt import QSearchableListWidget
|
||||
|
||||
app = QApplication([])
|
||||
|
||||
slider = QSearchableListWidget()
|
||||
slider.addItems(["foo", "bar", "baz", "foobar", "foobaz", "barbaz"])
|
||||
slider.show()
|
||||
|
||||
app.exec_()
|
||||
```
|
||||
|
||||
{{ show_widget() }}
|
||||
|
||||
{{ show_members('superqt.QSearchableListWidget') }}
|
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,12 +0,0 @@
|
||||
from qtrangeslider import QRangeSlider
|
||||
from qtrangeslider.qtcompat.QtCore import Qt
|
||||
from qtrangeslider.qtcompat.QtWidgets import QApplication
|
||||
|
||||
app = QApplication([])
|
||||
|
||||
slider = QRangeSlider(Qt.Horizontal)
|
||||
|
||||
slider.setValue((20, 80))
|
||||
slider.show()
|
||||
|
||||
app.exec_()
|
32
examples/code_highlight.py
Normal file
@@ -0,0 +1,32 @@
|
||||
from qtpy.QtGui import QColor, QPalette
|
||||
from qtpy.QtWidgets import QApplication, QTextEdit
|
||||
|
||||
from superqt.utils import CodeSyntaxHighlight
|
||||
|
||||
app = QApplication([])
|
||||
|
||||
text_area = QTextEdit()
|
||||
|
||||
highlight = CodeSyntaxHighlight(text_area.document(), "python", "monokai")
|
||||
|
||||
palette = text_area.palette()
|
||||
palette.setColor(QPalette.Base, QColor(highlight.background_color))
|
||||
text_area.setPalette(palette)
|
||||
text_area.setText(
|
||||
"""from argparse import ArgumentParser
|
||||
|
||||
def main():
|
||||
parser = ArgumentParser()
|
||||
parser.add_argument("name", help="Your name")
|
||||
args = parser.parse_args()
|
||||
print(f"Hello {args.name}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
"""
|
||||
)
|
||||
|
||||
text_area.show()
|
||||
|
||||
app.exec_()
|
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
@@ -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()
|
@@ -1,6 +1,12 @@
|
||||
from qtrangeslider import QRangeSlider
|
||||
from qtrangeslider.qtcompat import QtCore
|
||||
from qtrangeslider.qtcompat import QtWidgets as QtW
|
||||
import os
|
||||
|
||||
from qtpy import QtCore
|
||||
from qtpy import QtWidgets as QtW
|
||||
|
||||
# patch for Qt 5.15 on macos >= 12
|
||||
os.environ["USE_MAC_SLIDER_PATCH"] = "1"
|
||||
|
||||
from superqt import QRangeSlider # noqa
|
||||
|
||||
QSS = """
|
||||
QSlider {
|
||||
@@ -33,35 +39,37 @@ QRangeSlider {
|
||||
}
|
||||
"""
|
||||
|
||||
Horizontal = QtCore.Qt.Orientation.Horizontal
|
||||
|
||||
|
||||
class DemoWidget(QtW.QWidget):
|
||||
def __init__(self) -> None:
|
||||
super().__init__()
|
||||
|
||||
reg_hslider = QtW.QSlider(QtCore.Qt.Horizontal)
|
||||
reg_hslider = QtW.QSlider(Horizontal)
|
||||
reg_hslider.setValue(50)
|
||||
range_hslider = QRangeSlider(QtCore.Qt.Horizontal)
|
||||
range_hslider = QRangeSlider(Horizontal)
|
||||
range_hslider.setValue((20, 80))
|
||||
multi_range_hslider = QRangeSlider(QtCore.Qt.Horizontal)
|
||||
multi_range_hslider = QRangeSlider(Horizontal)
|
||||
multi_range_hslider.setValue((11, 33, 66, 88))
|
||||
multi_range_hslider.setTickPosition(QtW.QSlider.TicksAbove)
|
||||
multi_range_hslider.setTickPosition(QtW.QSlider.TickPosition.TicksAbove)
|
||||
|
||||
styled_reg_hslider = QtW.QSlider(QtCore.Qt.Horizontal)
|
||||
styled_reg_hslider = QtW.QSlider(Horizontal)
|
||||
styled_reg_hslider.setValue(50)
|
||||
styled_reg_hslider.setStyleSheet(QSS)
|
||||
styled_range_hslider = QRangeSlider(QtCore.Qt.Horizontal)
|
||||
styled_range_hslider = QRangeSlider(Horizontal)
|
||||
styled_range_hslider.setValue((20, 80))
|
||||
styled_range_hslider.setStyleSheet(QSS)
|
||||
|
||||
reg_vslider = QtW.QSlider(QtCore.Qt.Vertical)
|
||||
reg_vslider = QtW.QSlider(QtCore.Qt.Orientation.Vertical)
|
||||
reg_vslider.setValue(50)
|
||||
range_vslider = QRangeSlider(QtCore.Qt.Vertical)
|
||||
range_vslider = QRangeSlider(QtCore.Qt.Orientation.Vertical)
|
||||
range_vslider.setValue((22, 77))
|
||||
|
||||
tick_vslider = QtW.QSlider(QtCore.Qt.Vertical)
|
||||
tick_vslider = QtW.QSlider(QtCore.Qt.Orientation.Vertical)
|
||||
tick_vslider.setValue(55)
|
||||
tick_vslider.setTickPosition(QtW.QSlider.TicksRight)
|
||||
range_tick_vslider = QRangeSlider(QtCore.Qt.Vertical)
|
||||
range_tick_vslider = QRangeSlider(QtCore.Qt.Orientation.Vertical)
|
||||
range_tick_vslider.setValue((22, 77))
|
||||
range_tick_vslider.setTickPosition(QtW.QSlider.TicksLeft)
|
||||
|
||||
@@ -102,7 +110,6 @@ class DemoWidget(QtW.QWidget):
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
@@ -112,10 +119,10 @@ if __name__ == "__main__":
|
||||
app = QtW.QApplication([])
|
||||
demo = DemoWidget()
|
||||
|
||||
if "-x" in sys.argv:
|
||||
app.exec_()
|
||||
else:
|
||||
if "-snap" in sys.argv:
|
||||
import platform
|
||||
|
||||
QtW.QApplication.processEvents()
|
||||
demo.grab().save(str(dest / f"demo_{platform.system().lower()}.png"))
|
||||
else:
|
||||
app.exec_()
|
||||
|
14
examples/double_slider.py
Normal file
@@ -0,0 +1,14 @@
|
||||
from qtpy.QtCore import Qt
|
||||
from qtpy.QtWidgets import QApplication
|
||||
|
||||
from superqt import QDoubleSlider
|
||||
|
||||
app = QApplication([])
|
||||
|
||||
slider = QDoubleSlider(Qt.Orientation.Horizontal)
|
||||
slider.setRange(0, 1)
|
||||
slider.setValue(0.5)
|
||||
slider.resize(500, 50)
|
||||
slider.show()
|
||||
|
||||
app.exec_()
|
13
examples/eliding_label.py
Normal file
@@ -0,0 +1,13 @@
|
||||
from qtpy.QtWidgets import QApplication
|
||||
|
||||
from superqt import QElidingLabel
|
||||
|
||||
app = QApplication([])
|
||||
|
||||
widget = QElidingLabel(
|
||||
"a skj skjfskfj sdlf sdfl sdlfk jsdf sdlkf jdsf dslfksdl sdlfk sdf sdl "
|
||||
"fjsdlf kjsdlfk laskdfsal as lsdfjdsl kfjdslf asfd dslkjfldskf sdlkfj"
|
||||
)
|
||||
widget.setWordWrap(True)
|
||||
widget.show()
|
||||
app.exec_()
|
@@ -1,15 +1,15 @@
|
||||
from qtrangeslider import QRangeSlider
|
||||
from qtrangeslider._float_slider import QDoubleRangeSlider, QDoubleSlider
|
||||
from qtrangeslider.qtcompat.QtCore import Qt
|
||||
from qtrangeslider.qtcompat.QtWidgets import QApplication, QVBoxLayout, QWidget
|
||||
from qtpy.QtCore import Qt
|
||||
from qtpy.QtWidgets import QApplication, QVBoxLayout, QWidget
|
||||
|
||||
from superqt import QDoubleRangeSlider, QDoubleSlider, QRangeSlider
|
||||
|
||||
app = QApplication([])
|
||||
|
||||
w = QWidget()
|
||||
|
||||
sld1 = QDoubleSlider(Qt.Horizontal)
|
||||
sld2 = QDoubleRangeSlider(Qt.Horizontal)
|
||||
rs = QRangeSlider(Qt.Horizontal)
|
||||
sld1 = QDoubleSlider(Qt.Orientation.Horizontal)
|
||||
sld2 = QDoubleRangeSlider(Qt.Orientation.Horizontal)
|
||||
rs = QRangeSlider(Qt.Orientation.Horizontal)
|
||||
|
||||
sld1.valueChanged.connect(lambda e: print("doubslider valuechanged", e))
|
||||
|
||||
|
21
examples/fonticon1.py
Normal file
@@ -0,0 +1,21 @@
|
||||
try:
|
||||
from fonticon_fa5 import FA5S
|
||||
except ImportError as e:
|
||||
raise type(e)(
|
||||
"This example requires the fontawesome fontpack:\n\n"
|
||||
"pip install git+https://github.com/tlambert03/fonticon-fontawesome5.git"
|
||||
)
|
||||
|
||||
from qtpy.QtCore import QSize
|
||||
from qtpy.QtWidgets import QApplication, QPushButton
|
||||
|
||||
from superqt.fonticon import icon, pulse
|
||||
|
||||
app = QApplication([])
|
||||
|
||||
btn2 = QPushButton()
|
||||
btn2.setIcon(icon(FA5S.spinner, animation=pulse(btn2)))
|
||||
btn2.setIconSize(QSize(225, 225))
|
||||
btn2.show()
|
||||
|
||||
app.exec()
|
21
examples/fonticon2.py
Normal file
@@ -0,0 +1,21 @@
|
||||
try:
|
||||
from fonticon_fa5 import FA5S
|
||||
except ImportError as e:
|
||||
raise type(e)(
|
||||
"This example requires the fontawesome fontpack:\n\n"
|
||||
"pip install git+https://github.com/tlambert03/fonticon-fontawesome5.git"
|
||||
)
|
||||
|
||||
from qtpy.QtWidgets import QApplication, QPushButton
|
||||
|
||||
from superqt.fonticon import setTextIcon
|
||||
|
||||
app = QApplication([])
|
||||
|
||||
|
||||
btn4 = QPushButton()
|
||||
btn4.resize(275, 275)
|
||||
setTextIcon(btn4, FA5S.hamburger)
|
||||
btn4.show()
|
||||
|
||||
app.exec()
|
41
examples/fonticon3.py
Normal file
@@ -0,0 +1,41 @@
|
||||
try:
|
||||
from fonticon_fa5 import FA5S
|
||||
except ImportError as e:
|
||||
raise type(e)(
|
||||
"This example requires the fontawesome fontpack:\n\n"
|
||||
"pip install git+https://github.com/tlambert03/fonticon-fontawesome5.git"
|
||||
)
|
||||
|
||||
from qtpy.QtCore import QSize
|
||||
from qtpy.QtWidgets import QApplication, QPushButton
|
||||
|
||||
from superqt.fonticon import IconOpts, icon, pulse, spin
|
||||
|
||||
app = QApplication([])
|
||||
|
||||
btn = QPushButton()
|
||||
btn.setIcon(
|
||||
icon(
|
||||
FA5S.smile,
|
||||
color="blue",
|
||||
states={
|
||||
"active": IconOpts(
|
||||
glyph_key=FA5S.spinner,
|
||||
color="red",
|
||||
scale_factor=0.5,
|
||||
animation=pulse(btn),
|
||||
),
|
||||
"disabled": {"color": "green", "scale_factor": 0.8, "animation": spin(btn)},
|
||||
},
|
||||
)
|
||||
)
|
||||
btn.setIconSize(QSize(256, 256))
|
||||
btn.show()
|
||||
|
||||
|
||||
@btn.clicked.connect
|
||||
def toggle_state():
|
||||
btn.setChecked(not btn.isChecked())
|
||||
|
||||
|
||||
app.exec()
|
13
examples/generic.py
Normal file
@@ -0,0 +1,13 @@
|
||||
from qtpy.QtCore import Qt
|
||||
from qtpy.QtWidgets import QApplication
|
||||
|
||||
from superqt import QDoubleSlider
|
||||
|
||||
app = QApplication([])
|
||||
|
||||
sld = QDoubleSlider(Qt.Orientation.Horizontal)
|
||||
sld.setRange(0, 1)
|
||||
sld.setValue(0.5)
|
||||
sld.show()
|
||||
|
||||
app.exec_()
|
377
examples/icon_explorer.py
Normal file
@@ -0,0 +1,377 @@
|
||||
from qtpy import QtCore, QtGui, QtWidgets
|
||||
from qtpy.QtCore import Qt
|
||||
|
||||
from superqt.fonticon._plugins import loaded
|
||||
|
||||
P = loaded(load_all=True)
|
||||
if not P:
|
||||
print("you have no font packs loaded!")
|
||||
|
||||
|
||||
class GlyphDelegate(QtWidgets.QItemDelegate):
|
||||
def createEditor(self, parent, option, index):
|
||||
if index.column() < 2:
|
||||
edit = QtWidgets.QLineEdit(parent)
|
||||
edit.editingFinished.connect(self.emitCommitData)
|
||||
return edit
|
||||
comboBox = QtWidgets.QComboBox(parent)
|
||||
if index.column() == 2:
|
||||
comboBox.addItem("Normal")
|
||||
comboBox.addItem("Active")
|
||||
comboBox.addItem("Disabled")
|
||||
comboBox.addItem("Selected")
|
||||
elif index.column() == 3:
|
||||
comboBox.addItem("Off")
|
||||
comboBox.addItem("On")
|
||||
|
||||
comboBox.activated.connect(self.emitCommitData)
|
||||
return comboBox
|
||||
|
||||
def setEditorData(self, editor, index):
|
||||
if index.column() < 2:
|
||||
editor.setText(index.model().data(index))
|
||||
return
|
||||
comboBox = editor
|
||||
if comboBox:
|
||||
pos = comboBox.findText(
|
||||
index.model().data(index), Qt.MatchFlag.MatchExactly
|
||||
)
|
||||
comboBox.setCurrentIndex(pos)
|
||||
|
||||
def setModelData(self, editor, model, index):
|
||||
if editor:
|
||||
text = editor.text() if index.column() < 2 else editor.currentText()
|
||||
model.setData(index, text)
|
||||
|
||||
def emitCommitData(self):
|
||||
self.commitData.emit(self.sender())
|
||||
|
||||
|
||||
class IconPreviewArea(QtWidgets.QWidget):
|
||||
def __init__(self, parent=None):
|
||||
super().__init__(parent)
|
||||
|
||||
mainLayout = QtWidgets.QGridLayout()
|
||||
self.setLayout(mainLayout)
|
||||
|
||||
self.icon = QtGui.QIcon()
|
||||
self.size = QtCore.QSize()
|
||||
self.stateLabels = []
|
||||
self.modeLabels = []
|
||||
self.pixmapLabels = []
|
||||
|
||||
self.stateLabels.append(self.createHeaderLabel("Off"))
|
||||
self.stateLabels.append(self.createHeaderLabel("On"))
|
||||
self.modeLabels.append(self.createHeaderLabel("Normal"))
|
||||
self.modeLabels.append(self.createHeaderLabel("Active"))
|
||||
self.modeLabels.append(self.createHeaderLabel("Disabled"))
|
||||
self.modeLabels.append(self.createHeaderLabel("Selected"))
|
||||
|
||||
for j, label in enumerate(self.stateLabels):
|
||||
mainLayout.addWidget(label, j + 1, 0)
|
||||
|
||||
for i, label in enumerate(self.modeLabels):
|
||||
mainLayout.addWidget(label, 0, i + 1)
|
||||
|
||||
self.pixmapLabels.append([])
|
||||
for j in range(len(self.stateLabels)):
|
||||
self.pixmapLabels[i].append(self.createPixmapLabel())
|
||||
mainLayout.addWidget(self.pixmapLabels[i][j], j + 1, i + 1)
|
||||
|
||||
def setIcon(self, icon):
|
||||
self.icon = icon
|
||||
self.updatePixmapLabels()
|
||||
|
||||
def setSize(self, size):
|
||||
if size != self.size:
|
||||
self.size = size
|
||||
self.updatePixmapLabels()
|
||||
|
||||
def createHeaderLabel(self, text):
|
||||
label = QtWidgets.QLabel("<b>%s</b>" % text)
|
||||
label.setAlignment(Qt.AlignmentFlag.AlignCenter)
|
||||
return label
|
||||
|
||||
def createPixmapLabel(self):
|
||||
label = QtWidgets.QLabel()
|
||||
label.setEnabled(False)
|
||||
label.setAlignment(Qt.AlignmentFlag.AlignCenter)
|
||||
label.setFrameShape(QtWidgets.QFrame.Box)
|
||||
label.setSizePolicy(
|
||||
QtWidgets.QSizePolicy.Expanding, QtWidgets.QSizePolicy.Expanding
|
||||
)
|
||||
label.setBackgroundRole(QtGui.QPalette.Base)
|
||||
label.setAutoFillBackground(True)
|
||||
label.setMinimumSize(132, 132)
|
||||
return label
|
||||
|
||||
def updatePixmapLabels(self):
|
||||
for i in range(len(self.modeLabels)):
|
||||
if i == 0:
|
||||
mode = QtGui.QIcon.Mode.Normal
|
||||
elif i == 1:
|
||||
mode = QtGui.QIcon.Mode.Active
|
||||
elif i == 2:
|
||||
mode = QtGui.QIcon.Mode.Disabled
|
||||
else:
|
||||
mode = QtGui.QIcon.Mode.Selected
|
||||
|
||||
for j in range(len(self.stateLabels)):
|
||||
state = {True: QtGui.QIcon.State.Off, False: QtGui.QIcon.State.On}[
|
||||
j == 0
|
||||
]
|
||||
pixmap = self.icon.pixmap(self.size, mode, state)
|
||||
self.pixmapLabels[i][j].setPixmap(pixmap)
|
||||
self.pixmapLabels[i][j].setEnabled(not pixmap.isNull())
|
||||
|
||||
|
||||
class MainWindow(QtWidgets.QMainWindow):
|
||||
def __init__(self, parent=None):
|
||||
super().__init__(parent)
|
||||
|
||||
self.centralWidget = QtWidgets.QWidget()
|
||||
self.setCentralWidget(self.centralWidget)
|
||||
|
||||
self.createPreviewGroupBox()
|
||||
self.createGlyphBox()
|
||||
self.createIconSizeGroupBox()
|
||||
|
||||
mainLayout = QtWidgets.QGridLayout()
|
||||
mainLayout.addWidget(self.previewGroupBox, 0, 0, 1, 2)
|
||||
mainLayout.addWidget(self.glyphGroupBox, 1, 0)
|
||||
mainLayout.addWidget(self.iconSizeGroupBox, 1, 1)
|
||||
self.centralWidget.setLayout(mainLayout)
|
||||
|
||||
self.setWindowTitle("Icons")
|
||||
self.otherRadioButton.click()
|
||||
|
||||
self.resize(self.minimumSizeHint())
|
||||
|
||||
def changeSize(self):
|
||||
if self.otherRadioButton.isChecked():
|
||||
extent = self.otherSpinBox.value()
|
||||
else:
|
||||
if self.smallRadioButton.isChecked():
|
||||
metric = QtWidgets.QStyle.PixelMetric.PM_SmallIconSize
|
||||
elif self.largeRadioButton.isChecked():
|
||||
metric = QtWidgets.QStyle.PixelMetric.PM_LargeIconSize
|
||||
elif self.toolBarRadioButton.isChecked():
|
||||
metric = QtWidgets.QStyle.PixelMetric.PM_ToolBarIconSize
|
||||
elif self.listViewRadioButton.isChecked():
|
||||
metric = QtWidgets.QStyle.PixelMetric.PM_ListViewIconSize
|
||||
elif self.iconViewRadioButton.isChecked():
|
||||
metric = QtWidgets.QStyle.PixelMetric.PM_IconViewIconSize
|
||||
else:
|
||||
metric = QtWidgets.QStyle.PixelMetric.PM_TabBarIconSize
|
||||
|
||||
extent = QtWidgets.QApplication.style().pixelMetric(metric)
|
||||
|
||||
self.previewArea.setSize(QtCore.QSize(extent, extent))
|
||||
self.otherSpinBox.setEnabled(self.otherRadioButton.isChecked())
|
||||
|
||||
def changeIcon(self):
|
||||
from superqt import fonticon
|
||||
|
||||
icon = None
|
||||
for row in range(self.glyphTable.rowCount()):
|
||||
item0 = self.glyphTable.item(row, 0)
|
||||
item1 = self.glyphTable.item(row, 1)
|
||||
item2 = self.glyphTable.item(row, 2)
|
||||
item3 = self.glyphTable.item(row, 3)
|
||||
|
||||
if item0.checkState() != Qt.CheckState.Checked:
|
||||
continue
|
||||
key = item0.text()
|
||||
if not key:
|
||||
continue
|
||||
|
||||
if item2.text() == "Normal":
|
||||
mode = QtGui.QIcon.Mode.Normal
|
||||
elif item2.text() == "Active":
|
||||
mode = QtGui.QIcon.Mode.Active
|
||||
elif item2.text() == "Disabled":
|
||||
mode = QtGui.QIcon.Mode.Disabled
|
||||
else:
|
||||
mode = QtGui.QIcon.Mode.Selected
|
||||
|
||||
color = item1.text() or None
|
||||
state = (
|
||||
QtGui.QIcon.State.On if item3.text() == "On" else QtGui.QIcon.State.Off
|
||||
)
|
||||
try:
|
||||
if icon is None:
|
||||
icon = fonticon.icon(key, color=color)
|
||||
else:
|
||||
icon.addState(state, mode, glyph_key=key, color=color)
|
||||
except Exception as e:
|
||||
print(e)
|
||||
continue
|
||||
if icon:
|
||||
self.previewArea.setIcon(icon)
|
||||
|
||||
def createPreviewGroupBox(self):
|
||||
self.previewGroupBox = QtWidgets.QGroupBox("Preview")
|
||||
|
||||
self.previewArea = IconPreviewArea()
|
||||
|
||||
layout = QtWidgets.QVBoxLayout()
|
||||
layout.addWidget(self.previewArea)
|
||||
self.previewGroupBox.setLayout(layout)
|
||||
|
||||
def createGlyphBox(self):
|
||||
self.glyphGroupBox = QtWidgets.QGroupBox("Glyphs")
|
||||
self.glyphGroupBox.setMinimumSize(480, 200)
|
||||
self.glyphTable = QtWidgets.QTableWidget()
|
||||
self.glyphTable.setSelectionMode(QtWidgets.QAbstractItemView.NoSelection)
|
||||
self.glyphTable.setItemDelegate(GlyphDelegate(self))
|
||||
|
||||
self.glyphTable.horizontalHeader().setDefaultSectionSize(100)
|
||||
self.glyphTable.setColumnCount(4)
|
||||
self.glyphTable.setHorizontalHeaderLabels(("Glyph", "Color", "Mode", "State"))
|
||||
self.glyphTable.horizontalHeader().setSectionResizeMode(
|
||||
0, QtWidgets.QHeaderView.Stretch
|
||||
)
|
||||
self.glyphTable.horizontalHeader().setSectionResizeMode(
|
||||
1, QtWidgets.QHeaderView.Fixed
|
||||
)
|
||||
self.glyphTable.horizontalHeader().setSectionResizeMode(
|
||||
2, QtWidgets.QHeaderView.Fixed
|
||||
)
|
||||
self.glyphTable.horizontalHeader().setSectionResizeMode(
|
||||
3, QtWidgets.QHeaderView.Fixed
|
||||
)
|
||||
self.glyphTable.verticalHeader().hide()
|
||||
|
||||
self.glyphTable.itemChanged.connect(self.changeIcon)
|
||||
|
||||
layout = QtWidgets.QVBoxLayout()
|
||||
layout.addWidget(self.glyphTable)
|
||||
self.glyphGroupBox.setLayout(layout)
|
||||
self.changeIcon()
|
||||
|
||||
p0 = list(P)[-1]
|
||||
key = f"{p0}.{list(P[p0])[1]}"
|
||||
for _ in range(4):
|
||||
row = self.glyphTable.rowCount()
|
||||
self.glyphTable.setRowCount(row + 1)
|
||||
|
||||
item0 = QtWidgets.QTableWidgetItem()
|
||||
item1 = QtWidgets.QTableWidgetItem()
|
||||
|
||||
if _ == 0:
|
||||
item0.setText(key)
|
||||
# item0.setFlags(item0.flags() & ~Qt.ItemFlag.ItemIsEditable)
|
||||
|
||||
item2 = QtWidgets.QTableWidgetItem("Normal")
|
||||
item3 = QtWidgets.QTableWidgetItem("Off")
|
||||
|
||||
self.glyphTable.setItem(row, 0, item0)
|
||||
self.glyphTable.setItem(row, 1, item1)
|
||||
self.glyphTable.setItem(row, 2, item2)
|
||||
self.glyphTable.setItem(row, 3, item3)
|
||||
self.glyphTable.openPersistentEditor(item2)
|
||||
self.glyphTable.openPersistentEditor(item3)
|
||||
|
||||
item0.setCheckState(Qt.CheckState.Checked)
|
||||
|
||||
def createIconSizeGroupBox(self):
|
||||
self.iconSizeGroupBox = QtWidgets.QGroupBox("Icon Size")
|
||||
|
||||
self.smallRadioButton = QtWidgets.QRadioButton()
|
||||
self.largeRadioButton = QtWidgets.QRadioButton()
|
||||
self.toolBarRadioButton = QtWidgets.QRadioButton()
|
||||
self.listViewRadioButton = QtWidgets.QRadioButton()
|
||||
self.iconViewRadioButton = QtWidgets.QRadioButton()
|
||||
self.tabBarRadioButton = QtWidgets.QRadioButton()
|
||||
self.otherRadioButton = QtWidgets.QRadioButton("Other:")
|
||||
|
||||
self.otherSpinBox = QtWidgets.QSpinBox()
|
||||
self.otherSpinBox.setRange(8, 128)
|
||||
self.otherSpinBox.setValue(64)
|
||||
|
||||
self.smallRadioButton.toggled.connect(self.changeSize)
|
||||
self.largeRadioButton.toggled.connect(self.changeSize)
|
||||
self.toolBarRadioButton.toggled.connect(self.changeSize)
|
||||
self.listViewRadioButton.toggled.connect(self.changeSize)
|
||||
self.iconViewRadioButton.toggled.connect(self.changeSize)
|
||||
self.tabBarRadioButton.toggled.connect(self.changeSize)
|
||||
self.otherRadioButton.toggled.connect(self.changeSize)
|
||||
self.otherSpinBox.valueChanged.connect(self.changeSize)
|
||||
|
||||
otherSizeLayout = QtWidgets.QHBoxLayout()
|
||||
otherSizeLayout.addWidget(self.otherRadioButton)
|
||||
otherSizeLayout.addWidget(self.otherSpinBox)
|
||||
otherSizeLayout.addStretch()
|
||||
|
||||
layout = QtWidgets.QGridLayout()
|
||||
layout.addWidget(self.smallRadioButton, 0, 0)
|
||||
layout.addWidget(self.largeRadioButton, 1, 0)
|
||||
layout.addWidget(self.toolBarRadioButton, 2, 0)
|
||||
layout.addWidget(self.listViewRadioButton, 0, 1)
|
||||
layout.addWidget(self.iconViewRadioButton, 1, 1)
|
||||
layout.addWidget(self.tabBarRadioButton, 2, 1)
|
||||
layout.addLayout(otherSizeLayout, 3, 0, 1, 2)
|
||||
layout.setRowStretch(4, 1)
|
||||
self.iconSizeGroupBox.setLayout(layout)
|
||||
self.changeStyle()
|
||||
|
||||
def changeStyle(self, style=None):
|
||||
style = style or QtWidgets.QApplication.style().objectName()
|
||||
style = QtWidgets.QStyleFactory.create(style)
|
||||
if not style:
|
||||
return
|
||||
|
||||
QtWidgets.QApplication.setStyle(style)
|
||||
|
||||
self.setButtonText(
|
||||
self.smallRadioButton,
|
||||
"Small (%d x %d)",
|
||||
style,
|
||||
QtWidgets.QStyle.PixelMetric.PM_SmallIconSize,
|
||||
)
|
||||
self.setButtonText(
|
||||
self.largeRadioButton,
|
||||
"Large (%d x %d)",
|
||||
style,
|
||||
QtWidgets.QStyle.PixelMetric.PM_LargeIconSize,
|
||||
)
|
||||
self.setButtonText(
|
||||
self.toolBarRadioButton,
|
||||
"Toolbars (%d x %d)",
|
||||
style,
|
||||
QtWidgets.QStyle.PixelMetric.PM_ToolBarIconSize,
|
||||
)
|
||||
self.setButtonText(
|
||||
self.listViewRadioButton,
|
||||
"List views (%d x %d)",
|
||||
style,
|
||||
QtWidgets.QStyle.PixelMetric.PM_ListViewIconSize,
|
||||
)
|
||||
self.setButtonText(
|
||||
self.iconViewRadioButton,
|
||||
"Icon views (%d x %d)",
|
||||
style,
|
||||
QtWidgets.QStyle.PixelMetric.PM_IconViewIconSize,
|
||||
)
|
||||
self.setButtonText(
|
||||
self.tabBarRadioButton,
|
||||
"Tab bars (%d x %d)",
|
||||
style,
|
||||
QtWidgets.QStyle.PixelMetric.PM_TabBarIconSize,
|
||||
)
|
||||
|
||||
self.changeSize()
|
||||
|
||||
@staticmethod
|
||||
def setButtonText(button, label, style, metric):
|
||||
metric_value = style.pixelMetric(metric)
|
||||
button.setText(label % (metric_value, metric_value))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
import sys
|
||||
|
||||
app = QtWidgets.QApplication(sys.argv)
|
||||
mainWin = MainWindow()
|
||||
mainWin.show()
|
||||
sys.exit(app.exec_())
|
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()
|
@@ -1,23 +1,20 @@
|
||||
from qtrangeslider._labeled import (
|
||||
from qtpy.QtCore import Qt
|
||||
from qtpy.QtWidgets import QApplication, QHBoxLayout, QVBoxLayout, QWidget
|
||||
|
||||
from superqt import (
|
||||
QLabeledDoubleRangeSlider,
|
||||
QLabeledDoubleSlider,
|
||||
QLabeledRangeSlider,
|
||||
QLabeledSlider,
|
||||
)
|
||||
from qtrangeslider.qtcompat.QtCore import Qt
|
||||
from qtrangeslider.qtcompat.QtWidgets import (
|
||||
QApplication,
|
||||
QHBoxLayout,
|
||||
QVBoxLayout,
|
||||
QWidget,
|
||||
)
|
||||
|
||||
app = QApplication([])
|
||||
|
||||
ORIENTATION = Qt.Horizontal
|
||||
ORIENTATION = Qt.Orientation.Horizontal
|
||||
|
||||
w = QWidget()
|
||||
qls = QLabeledSlider(ORIENTATION)
|
||||
qls.setEdgeLabelMode(qls.EdgeLabelMode.LabelIsRange | qls.EdgeLabelMode.LabelIsValue)
|
||||
qls.valueChanged.connect(lambda e: print("qls valueChanged", e))
|
||||
qls.setRange(0, 500)
|
||||
qls.setValue(300)
|
||||
@@ -30,16 +27,19 @@ qlds.setValue(0.5)
|
||||
qlds.setSingleStep(0.1)
|
||||
|
||||
qlrs = QLabeledRangeSlider(ORIENTATION)
|
||||
qlrs.valueChanged.connect(lambda e: print("qlrs valueChanged", e))
|
||||
qlrs.valueChanged.connect(lambda e: print("QLabeledRangeSlider valueChanged", e))
|
||||
qlrs.setValue((20, 60))
|
||||
|
||||
qldrs = QLabeledDoubleRangeSlider(ORIENTATION)
|
||||
qldrs.valueChanged.connect(lambda e: print("qlrs valueChanged", e))
|
||||
qldrs.setRange(0, 1)
|
||||
qldrs.setSingleStep(0.01)
|
||||
qldrs.setValue((0.2, 0.7))
|
||||
|
||||
|
||||
w.setLayout(QVBoxLayout() if ORIENTATION == Qt.Horizontal else QHBoxLayout())
|
||||
w.setLayout(
|
||||
QVBoxLayout() if ORIENTATION == Qt.Orientation.Horizontal else QHBoxLayout()
|
||||
)
|
||||
w.layout().addWidget(qls)
|
||||
w.layout().addWidget(qlds)
|
||||
w.layout().addWidget(qlrs)
|
@@ -1,5 +1,6 @@
|
||||
from qtrangeslider import QRangeSlider
|
||||
from qtrangeslider.qtcompat.QtWidgets import QApplication
|
||||
from qtpy.QtWidgets import QApplication
|
||||
|
||||
from superqt import QRangeSlider
|
||||
|
||||
app = QApplication([])
|
||||
|
||||
|
18
examples/qcollapsible.py
Normal file
@@ -0,0 +1,18 @@
|
||||
"""Example for QCollapsible."""
|
||||
|
||||
from qtpy.QtWidgets import QApplication, QLabel, QPushButton
|
||||
|
||||
from superqt import QCollapsible
|
||||
|
||||
app = QApplication([])
|
||||
|
||||
collapsible = QCollapsible("Advanced analysis")
|
||||
collapsible.setCollapsedIcon("+")
|
||||
collapsible.setExpandedIcon("-")
|
||||
collapsible.addWidget(QLabel("This is the inside of the collapsible frame"))
|
||||
for i in range(10):
|
||||
collapsible.addWidget(QPushButton(f"Content button {i + 1}"))
|
||||
|
||||
collapsible.expand(animate=False)
|
||||
collapsible.show()
|
||||
app.exec_()
|
9
examples/quantity.py
Normal file
@@ -0,0 +1,9 @@
|
||||
from qtpy.QtWidgets import QApplication
|
||||
|
||||
from superqt import QQuantity
|
||||
|
||||
app = QApplication([])
|
||||
w = QQuantity("1m")
|
||||
w.show()
|
||||
|
||||
app.exec()
|
13
examples/range_slider.py
Normal file
@@ -0,0 +1,13 @@
|
||||
from qtpy.QtCore import Qt
|
||||
from qtpy.QtWidgets import QApplication
|
||||
|
||||
from superqt import QRangeSlider
|
||||
|
||||
app = QApplication([])
|
||||
|
||||
slider = QRangeSlider(Qt.Orientation.Horizontal)
|
||||
|
||||
slider.setValue((20, 80))
|
||||
slider.show()
|
||||
|
||||
app.exec_()
|
11
examples/searchable_combo_box.py
Normal file
@@ -0,0 +1,11 @@
|
||||
from qtpy.QtWidgets import QApplication
|
||||
|
||||
from superqt import QSearchableComboBox
|
||||
|
||||
app = QApplication([])
|
||||
|
||||
slider = QSearchableComboBox()
|
||||
slider.addItems(["foo", "bar", "baz", "foobar", "foobaz", "barbaz"])
|
||||
slider.show()
|
||||
|
||||
app.exec_()
|
11
examples/searchable_list_widget.py
Normal file
@@ -0,0 +1,11 @@
|
||||
from qtpy.QtWidgets import QApplication
|
||||
|
||||
from superqt import QSearchableListWidget
|
||||
|
||||
app = QApplication([])
|
||||
|
||||
slider = QSearchableListWidget()
|
||||
slider.addItems(["foo", "bar", "baz", "foobar", "foobaz", "barbaz"])
|
||||
slider.show()
|
||||
|
||||
app.exec_()
|
29
examples/searchable_tree_widget.py
Normal file
@@ -0,0 +1,29 @@
|
||||
import logging
|
||||
|
||||
from qtpy.QtWidgets import QApplication
|
||||
|
||||
from superqt import QSearchableTreeWidget
|
||||
|
||||
logging.basicConfig(
|
||||
level=logging.DEBUG,
|
||||
format="%(asctime)s : %(levelname)s : %(filename)s : %(message)s",
|
||||
)
|
||||
|
||||
data = {
|
||||
"none": None,
|
||||
"str": "test",
|
||||
"int": 42,
|
||||
"list": [2, 3, 5],
|
||||
"dict": {
|
||||
"float": 0.5,
|
||||
"tuple": (22, 99),
|
||||
"bool": False,
|
||||
},
|
||||
}
|
||||
|
||||
app = QApplication([])
|
||||
|
||||
tree = QSearchableTreeWidget.fromData(data)
|
||||
tree.show()
|
||||
|
||||
app.exec_()
|
29
examples/throttle_mouse_event.py
Normal file
@@ -0,0 +1,29 @@
|
||||
from qtpy.QtCore import Signal
|
||||
from qtpy.QtWidgets import QApplication, QWidget
|
||||
|
||||
from superqt.utils import qthrottled
|
||||
|
||||
|
||||
class Demo(QWidget):
|
||||
positionChanged = Signal(int, int)
|
||||
|
||||
def __init__(self) -> None:
|
||||
super().__init__()
|
||||
self.setMouseTracking(True)
|
||||
self.positionChanged.connect(self._show_location)
|
||||
|
||||
@qthrottled(timeout=400) # call this no more than once every 400ms
|
||||
def _show_location(self, x, y):
|
||||
print("Throttled event at", x, y)
|
||||
|
||||
def mouseMoveEvent(self, event):
|
||||
print("real move event at", event.x(), event.y())
|
||||
self.positionChanged.emit(event.x(), event.y())
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
app = QApplication([])
|
||||
w = Demo()
|
||||
w.resize(600, 600)
|
||||
w.show()
|
||||
app.exec_()
|
280
examples/throttler_demo.py
Normal file
@@ -0,0 +1,280 @@
|
||||
"""Adapted for python from the KDToolBox.
|
||||
|
||||
https://github.com/KDAB/KDToolBox/tree/master/qt/KDSignalThrottler
|
||||
|
||||
MIT License
|
||||
|
||||
Copyright (C) 2019-2022 Klarälvdalens Datakonsult AB, a KDAB Group company,
|
||||
info@kdab.com
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
|
||||
"""
|
||||
|
||||
from typing import Deque
|
||||
|
||||
from qtpy.QtCore import QRect, QSize, Qt, QTimer, Signal
|
||||
from qtpy.QtGui import QPainter, QPen
|
||||
from qtpy.QtWidgets import (
|
||||
QApplication,
|
||||
QCheckBox,
|
||||
QComboBox,
|
||||
QFormLayout,
|
||||
QGroupBox,
|
||||
QHBoxLayout,
|
||||
QLabel,
|
||||
QPushButton,
|
||||
QSizePolicy,
|
||||
QSpinBox,
|
||||
QVBoxLayout,
|
||||
QWidget,
|
||||
)
|
||||
|
||||
from superqt.utils._throttler import (
|
||||
GenericSignalThrottler,
|
||||
QSignalDebouncer,
|
||||
QSignalThrottler,
|
||||
)
|
||||
|
||||
|
||||
class DrawSignalsWidget(QWidget):
|
||||
def __init__(self, parent=None):
|
||||
super().__init__(parent)
|
||||
self.setFocusPolicy(Qt.FocusPolicy.NoFocus)
|
||||
self.setSizePolicy(QSizePolicy.MinimumExpanding, QSizePolicy.MinimumExpanding)
|
||||
self.setAttribute(Qt.WA_OpaquePaintEvent)
|
||||
|
||||
self._scrollTimer = QTimer(self)
|
||||
self._scrollTimer.setInterval(10)
|
||||
self._scrollTimer.timeout.connect(self._scroll)
|
||||
self._scrollTimer.start()
|
||||
|
||||
self._signalActivations: Deque[int] = Deque()
|
||||
self._throttledSignalActivations: Deque[int] = Deque()
|
||||
|
||||
def sizeHint(self):
|
||||
return QSize(400, 200)
|
||||
|
||||
def addSignalActivation(self):
|
||||
self._signalActivations.appendleft(0)
|
||||
|
||||
def addThrottledSignalActivation(self):
|
||||
self._throttledSignalActivations.appendleft(0)
|
||||
|
||||
def _scroll(self):
|
||||
cutoff = self.width()
|
||||
self.scrollAndCut(self._signalActivations, cutoff)
|
||||
self.scrollAndCut(self._throttledSignalActivations, cutoff)
|
||||
|
||||
self.update()
|
||||
|
||||
def scrollAndCut(self, v: Deque[int], cutoff: int):
|
||||
L = len(v)
|
||||
for p in range(L):
|
||||
v[p] += 1
|
||||
if v[p] > cutoff:
|
||||
break
|
||||
|
||||
# TODO: fix this... delete old ones
|
||||
|
||||
def paintEvent(self, event):
|
||||
p = QPainter(self)
|
||||
p.fillRect(self.rect(), Qt.white)
|
||||
|
||||
h = self.height()
|
||||
h2 = h // 2
|
||||
w = self.width()
|
||||
|
||||
self._drawSignals(p, self._signalActivations, Qt.red, 0, h2)
|
||||
self._drawSignals(p, self._throttledSignalActivations, Qt.blue, h2, h)
|
||||
|
||||
p.drawText(
|
||||
QRect(0, 0, w, h2),
|
||||
Qt.AlignmentFlag.AlignRight | Qt.AlignmentFlag.AlignVCenter,
|
||||
"Source signal",
|
||||
)
|
||||
p.drawText(
|
||||
QRect(0, h2, w, h2),
|
||||
Qt.AlignmentFlag.AlignRight | Qt.AlignmentFlag.AlignVCenter,
|
||||
"Throttled signal",
|
||||
)
|
||||
|
||||
p.save()
|
||||
pen = QPen()
|
||||
pen.setWidthF(2.0)
|
||||
p.drawLine(0, h2, w, h2)
|
||||
p.restore()
|
||||
|
||||
def _drawSignals(self, p: QPainter, v: Deque[int], color, yStart, yEnd):
|
||||
p.save()
|
||||
pen = QPen()
|
||||
pen.setWidthF(2.0)
|
||||
pen.setColor(color)
|
||||
p.setPen(pen)
|
||||
|
||||
for i in v:
|
||||
p.drawLine(i, yStart, i, yEnd)
|
||||
p.restore()
|
||||
|
||||
|
||||
class DemoWidget(QWidget):
|
||||
signalToBeThrottled = Signal()
|
||||
_throttler: GenericSignalThrottler
|
||||
|
||||
def __init__(self, parent=None) -> None:
|
||||
super().__init__(parent)
|
||||
self._createUi()
|
||||
self._throttler = None
|
||||
|
||||
self._throttlerKindComboBox.currentIndexChanged.connect(self._createThrottler)
|
||||
self._createThrottler()
|
||||
|
||||
self._throttlerTimeoutSpinBox.valueChanged.connect(self.setThrottlerTimeout)
|
||||
self.setThrottlerTimeout()
|
||||
|
||||
self._mainButton.clicked.connect(self.signalToBeThrottled)
|
||||
|
||||
self._autoTriggerTimer = QTimer(self)
|
||||
self._autoTriggerTimer.setTimerType(Qt.TimerType.PreciseTimer)
|
||||
self._autoTriggerCheckBox.clicked.connect(self._startOrStopAutoTriggerTimer)
|
||||
self._startOrStopAutoTriggerTimer()
|
||||
|
||||
self._autoTriggerIntervalSpinBox.valueChanged.connect(
|
||||
self._setAutoTriggerTimeout
|
||||
)
|
||||
self._setAutoTriggerTimeout()
|
||||
|
||||
self._autoTriggerTimer.timeout.connect(self.signalToBeThrottled)
|
||||
self.signalToBeThrottled.connect(self._drawSignalsWidget.addSignalActivation)
|
||||
|
||||
def _createThrottler(self) -> None:
|
||||
if self._throttler is not None:
|
||||
self._throttler.deleteLater()
|
||||
del self._throttler
|
||||
|
||||
if self._throttlerKindComboBox.currentIndex() < 2:
|
||||
cls = QSignalThrottler
|
||||
else:
|
||||
cls = QSignalDebouncer
|
||||
if self._throttlerKindComboBox.currentIndex() % 2:
|
||||
policy = QSignalThrottler.EmissionPolicy.Leading
|
||||
else:
|
||||
policy = QSignalThrottler.EmissionPolicy.Trailing
|
||||
|
||||
self._throttler: GenericSignalThrottler = cls(policy, self)
|
||||
|
||||
self._throttler.setTimerType(Qt.TimerType.PreciseTimer)
|
||||
self.signalToBeThrottled.connect(self._throttler.throttle)
|
||||
self._throttler.triggered.connect(
|
||||
self._drawSignalsWidget.addThrottledSignalActivation
|
||||
)
|
||||
|
||||
self.setThrottlerTimeout()
|
||||
|
||||
def setThrottlerTimeout(self):
|
||||
self._throttler.setTimeout(self._throttlerTimeoutSpinBox.value())
|
||||
|
||||
def _startOrStopAutoTriggerTimer(self):
|
||||
shouldStart = self._autoTriggerCheckBox.isChecked()
|
||||
if shouldStart:
|
||||
self._autoTriggerTimer.start()
|
||||
else:
|
||||
self._autoTriggerTimer.stop()
|
||||
|
||||
self._autoTriggerIntervalSpinBox.setEnabled(shouldStart)
|
||||
self._autoTriggerLabel.setEnabled(shouldStart)
|
||||
|
||||
def _setAutoTriggerTimeout(self):
|
||||
timeout = self._autoTriggerIntervalSpinBox.value()
|
||||
self._autoTriggerTimer.setInterval(timeout)
|
||||
|
||||
def _createUi(self):
|
||||
helpLabel = QLabel(self)
|
||||
helpLabel.setWordWrap(True)
|
||||
helpLabel.setText(
|
||||
"<h2>SignalThrottler example</h2>"
|
||||
"<p>This example demonstrates the differences between "
|
||||
"the different kinds of signal throttlers and debouncers."
|
||||
)
|
||||
|
||||
throttlerKindGroupBox = QGroupBox("Throttler configuration", self)
|
||||
|
||||
self._throttlerKindComboBox = QComboBox(throttlerKindGroupBox)
|
||||
self._throttlerKindComboBox.addItems(
|
||||
(
|
||||
"Throttler, trailing",
|
||||
"Throttler, leading",
|
||||
"Debouncer, trailing",
|
||||
"Debouncer, leading",
|
||||
)
|
||||
)
|
||||
|
||||
self._throttlerTimeoutSpinBox = QSpinBox(throttlerKindGroupBox)
|
||||
self._throttlerTimeoutSpinBox.setRange(1, 5000)
|
||||
self._throttlerTimeoutSpinBox.setValue(500)
|
||||
self._throttlerTimeoutSpinBox.setSuffix(" ms")
|
||||
|
||||
layout = QFormLayout(throttlerKindGroupBox)
|
||||
layout.addRow("Kind of throttler:", self._throttlerKindComboBox)
|
||||
layout.addRow("Timeout:", self._throttlerTimeoutSpinBox)
|
||||
throttlerKindGroupBox.setLayout(layout)
|
||||
|
||||
buttonGroupBox = QGroupBox("Throttler activation")
|
||||
self._mainButton = QPushButton(("Press me!"), buttonGroupBox)
|
||||
|
||||
self._autoTriggerCheckBox = QCheckBox("Trigger automatically")
|
||||
|
||||
autoTriggerLayout = QHBoxLayout()
|
||||
self._autoTriggerLabel = QLabel("Interval", buttonGroupBox)
|
||||
self._autoTriggerIntervalSpinBox = QSpinBox(buttonGroupBox)
|
||||
self._autoTriggerIntervalSpinBox.setRange(1, 5000)
|
||||
self._autoTriggerIntervalSpinBox.setValue(100)
|
||||
self._autoTriggerIntervalSpinBox.setSuffix(" ms")
|
||||
|
||||
autoTriggerLayout.setContentsMargins(0, 0, 0, 0)
|
||||
autoTriggerLayout.addWidget(self._autoTriggerLabel)
|
||||
autoTriggerLayout.addWidget(self._autoTriggerIntervalSpinBox)
|
||||
|
||||
layout = QVBoxLayout(buttonGroupBox)
|
||||
layout.addWidget(self._mainButton)
|
||||
layout.addWidget(self._autoTriggerCheckBox)
|
||||
layout.addLayout(autoTriggerLayout)
|
||||
buttonGroupBox.setLayout(layout)
|
||||
|
||||
resultGroupBox = QGroupBox("Result")
|
||||
self._drawSignalsWidget = DrawSignalsWidget(resultGroupBox)
|
||||
layout = QVBoxLayout(resultGroupBox)
|
||||
layout.addWidget(self._drawSignalsWidget)
|
||||
resultGroupBox.setLayout(layout)
|
||||
|
||||
layout = QVBoxLayout(self)
|
||||
layout.addWidget(helpLabel)
|
||||
layout.addWidget(throttlerKindGroupBox)
|
||||
layout.addWidget(buttonGroupBox)
|
||||
layout.addWidget(resultGroupBox)
|
||||
|
||||
self.setLayout(layout)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
app = QApplication([__name__])
|
||||
w = DemoWidget()
|
||||
w.resize(600, 600)
|
||||
w.show()
|
||||
app.exec_()
|
56
mkdocs.yml
Normal file
@@ -0,0 +1,56 @@
|
||||
site_name: superqt
|
||||
site_url: https://github.com/pyapp-kit/superqt
|
||||
site_description: >-
|
||||
missing widgets and components for PyQt/PySide
|
||||
# Repository
|
||||
repo_name: pyapp-kit/superqt
|
||||
repo_url: https://github.com/pyapp-kit/superqt
|
||||
|
||||
# Copyright
|
||||
copyright: Copyright © 2021 - 2022
|
||||
|
||||
watch:
|
||||
- src
|
||||
|
||||
theme:
|
||||
name: material
|
||||
features:
|
||||
- navigation.instant
|
||||
- navigation.indexes
|
||||
- navigation.expand
|
||||
# - navigation.tracking
|
||||
# - navigation.tabs
|
||||
- search.highlight
|
||||
- search.suggest
|
||||
- content.code.copy
|
||||
|
||||
markdown_extensions:
|
||||
- admonition
|
||||
- pymdownx.details
|
||||
- pymdownx.superfences
|
||||
- tables
|
||||
- attr_list
|
||||
- md_in_html
|
||||
- pymdownx.emoji:
|
||||
emoji_index: !!python/name:materialx.emoji.twemoji
|
||||
emoji_generator: !!python/name:materialx.emoji.to_svg
|
||||
- toc:
|
||||
permalink: "#"
|
||||
|
||||
|
||||
plugins:
|
||||
- search
|
||||
- autorefs
|
||||
- macros:
|
||||
module_name: docs/_macros
|
||||
- mkdocstrings:
|
||||
handlers:
|
||||
python:
|
||||
import:
|
||||
- https://docs.python.org/3/objects.inv
|
||||
- https://cmap-docs.readthedocs.io/en/latest/objects.inv
|
||||
options:
|
||||
show_source: false
|
||||
docstring_style: numpy
|
||||
show_root_toc_entry: True
|
||||
show_root_heading: True
|
212
pyproject.toml
@@ -1,3 +1,211 @@
|
||||
# pyproject.toml
|
||||
# https://peps.python.org/pep-0517/
|
||||
[build-system]
|
||||
requires = ["setuptools>=45", "wheel", "setuptools_scm[toml]>=6.0"]
|
||||
requires = ["hatchling", "hatch-vcs"]
|
||||
build-backend = "hatchling.build"
|
||||
|
||||
# https://peps.python.org/pep-0621/
|
||||
[project]
|
||||
name = "superqt"
|
||||
description = "Missing widgets and components for PyQt/PySide"
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.8"
|
||||
license = { text = "BSD 3-Clause License" }
|
||||
authors = [{ email = "talley.lambert@gmail.com", name = "Talley Lambert" }]
|
||||
keywords = [
|
||||
"qt",
|
||||
"pyqt",
|
||||
"pyside",
|
||||
"widgets",
|
||||
"range slider",
|
||||
"components",
|
||||
"gui",
|
||||
]
|
||||
classifiers = [
|
||||
"Development Status :: 4 - Beta",
|
||||
"Environment :: X11 Applications :: Qt",
|
||||
"Intended Audience :: Developers",
|
||||
"License :: OSI Approved :: BSD License",
|
||||
"Operating System :: OS Independent",
|
||||
"Programming Language :: Python :: 3",
|
||||
"Programming Language :: Python :: 3 :: Only",
|
||||
"Programming Language :: Python :: 3.8",
|
||||
"Programming Language :: Python :: 3.9",
|
||||
"Programming Language :: Python :: 3.10",
|
||||
"Programming Language :: Python :: 3.11",
|
||||
"Programming Language :: Python :: 3.12",
|
||||
"Topic :: Desktop Environment",
|
||||
"Topic :: Software Development :: User Interfaces",
|
||||
"Topic :: Software Development :: Widget Sets",
|
||||
]
|
||||
dynamic = ["version"]
|
||||
dependencies = [
|
||||
"pygments>=2.4.0",
|
||||
"qtpy>=1.1.0",
|
||||
"typing-extensions >=3.7.4.3,!=3.10.0.0",
|
||||
]
|
||||
|
||||
# extras
|
||||
# https://peps.python.org/pep-0621/#dependencies-optional-dependencies
|
||||
[project.optional-dependencies]
|
||||
test = ["pint", "pytest", "pytest-cov", "pytest-qt", "numpy", "cmap", "pyconify"]
|
||||
dev = [
|
||||
"ipython",
|
||||
"ruff",
|
||||
"mypy",
|
||||
"pdbpp",
|
||||
"pre-commit",
|
||||
"pydocstyle",
|
||||
"rich",
|
||||
"types-Pygments",
|
||||
]
|
||||
docs = ["mkdocs-macros-plugin", "mkdocs-material", "mkdocstrings[python]", "pint", "cmap"]
|
||||
quantity = ["pint"]
|
||||
cmap = ["cmap >=0.1.1"]
|
||||
pyside2 = ["pyside2"]
|
||||
# see issues surrounding usage of Generics in pyside6.5.x
|
||||
# https://github.com/pyapp-kit/superqt/pull/177
|
||||
# https://github.com/pyapp-kit/superqt/pull/164
|
||||
# https://bugreports.qt.io/browse/PYSIDE-2627
|
||||
pyside6 = ["pyside6 !=6.5.0,!=6.5.1,!=6.6.2"]
|
||||
pyqt5 = ["pyqt5"]
|
||||
pyqt6 = ["pyqt6"]
|
||||
font-fa5 = ["fonticon-fontawesome5"]
|
||||
font-fa6 = ["fonticon-fontawesome6"]
|
||||
font-mi6 = ["fonticon-materialdesignicons6"]
|
||||
font-mi7 = ["fonticon-materialdesignicons7"]
|
||||
iconify = ["pyconify >=0.1.4"]
|
||||
|
||||
[project.urls]
|
||||
Documentation = "https://pyapp-kit.github.io/superqt/"
|
||||
Source = "https://github.com/pyapp-kit/superqt"
|
||||
Tracker = "https://github.com/pyapp-kit/superqt/issues"
|
||||
Changelog = "https://github.com/pyapp-kit/superqt/blob/main/CHANGELOG.md"
|
||||
|
||||
[tool.hatch.version]
|
||||
source = "vcs"
|
||||
|
||||
[tool.hatch.build.targets.sdist]
|
||||
include = ["src", "tests", "CHANGELOG.md"]
|
||||
|
||||
# these let you run tests across all backends easily with:
|
||||
# hatch run test:test
|
||||
[tool.hatch.envs.test]
|
||||
|
||||
[tool.hatch.envs.test.scripts]
|
||||
test = "pytest"
|
||||
|
||||
[[tool.hatch.envs.test.matrix]]
|
||||
qt = ["pyside6", "pyqt6"]
|
||||
python = ["3.11"]
|
||||
|
||||
[[tool.hatch.envs.test.matrix]]
|
||||
qt = ["pyside2", "pyqt5", "pyqt5.12"]
|
||||
python = ["3.8"]
|
||||
|
||||
[tool.hatch.envs.test.overrides]
|
||||
matrix.qt.extra-dependencies = [
|
||||
{value = "pyside2", if = ["pyside2"]},
|
||||
{value = "pyside6", if = ["pyside6"]},
|
||||
{value = "pyqt5", if = ["pyqt5"]},
|
||||
{value = "pyqt6", if = ["pyqt6"]},
|
||||
{value = "pyqt5==5.12", if = ["pyqt5.12"]},
|
||||
]
|
||||
|
||||
# https://github.com/charliermarsh/ruff
|
||||
[tool.ruff]
|
||||
line-length = 88
|
||||
target-version = "py38"
|
||||
src = ["src", "tests"]
|
||||
|
||||
# https://docs.astral.sh/ruff/rules
|
||||
[tool.ruff.lint]
|
||||
pydocstyle = { convention = "numpy" }
|
||||
select = [
|
||||
"E", # style errors
|
||||
"W", # style warnings
|
||||
"F", # flakes
|
||||
"D", # pydocstyle
|
||||
"D417", # Missing argument descriptions in Docstrings
|
||||
"I", # isort
|
||||
"UP", # pyupgrade
|
||||
"C4", # flake8-comprehensions
|
||||
"B", # flake8-bugbear
|
||||
"A001", # flake8-builtins
|
||||
"RUF", # ruff-specific rules
|
||||
"TCH", # flake8-type-checking
|
||||
"TID", # flake8-tidy-imports
|
||||
]
|
||||
ignore = [
|
||||
"D104", # Missing docstring in public package
|
||||
"D401", # First line should be in imperative mood (remove to opt in)
|
||||
]
|
||||
|
||||
[tool.ruff.lint.per-file-ignores]
|
||||
"tests/*.py" = ["D", "S101"]
|
||||
"examples/demo_widget.py" = ["E501"]
|
||||
"examples/*.py" = ["B", "D"]
|
||||
|
||||
# https://docs.astral.sh/ruff/formatter/
|
||||
[tool.ruff.format]
|
||||
docstring-code-format = true
|
||||
|
||||
# https://docs.pytest.org/en/6.2.x/customize.html
|
||||
[tool.pytest.ini_options]
|
||||
minversion = "6.0"
|
||||
testpaths = ["tests"]
|
||||
filterwarnings = [
|
||||
"error",
|
||||
"ignore:QPixmapCache.find:DeprecationWarning:",
|
||||
"ignore:SelectableGroups dict interface:DeprecationWarning",
|
||||
"ignore:The distutils package is deprecated:DeprecationWarning",
|
||||
]
|
||||
|
||||
# https://mypy.readthedocs.io/en/stable/config_file.html
|
||||
[tool.mypy]
|
||||
files = "src/**/*.py"
|
||||
strict = true
|
||||
disallow_untyped_defs = false
|
||||
disallow_untyped_calls = false
|
||||
disallow_any_generics = false
|
||||
disallow_subclassing_any = false
|
||||
show_error_codes = true
|
||||
pretty = true
|
||||
exclude = ['tests/**/*']
|
||||
|
||||
|
||||
[[tool.mypy.overrides]]
|
||||
module = ["superqt.qtcompat.*"]
|
||||
ignore_missing_imports = true
|
||||
warn_unused_ignores = false
|
||||
allow_redefinition = true
|
||||
|
||||
# https://coverage.readthedocs.io/en/6.4/config.html
|
||||
[tool.coverage.run]
|
||||
source = ["superqt"]
|
||||
|
||||
[tool.coverage.report]
|
||||
show_missing = true
|
||||
exclude_lines = [
|
||||
"pragma: no cover",
|
||||
"if TYPE_CHECKING:",
|
||||
"@overload",
|
||||
"except ImportError",
|
||||
"\\.\\.\\.",
|
||||
"pass"
|
||||
]
|
||||
|
||||
# https://github.com/mgedmin/check-manifest#configuration
|
||||
[tool.check-manifest]
|
||||
ignore = [
|
||||
".github_changelog_generator",
|
||||
".pre-commit-config.yaml",
|
||||
"tests/**/*",
|
||||
"src/superqt/_version.py",
|
||||
"mkdocs.yml",
|
||||
"docs/**/*",
|
||||
"examples/**/*",
|
||||
"CHANGELOG.md",
|
||||
"CONTRIBUTING.md",
|
||||
"codecov.yml",
|
||||
".ruff_cache/**/*",
|
||||
]
|
||||
|
@@ -1,96 +0,0 @@
|
||||
import math
|
||||
from typing import Tuple
|
||||
|
||||
from ._hooked import _HookedSlider
|
||||
from ._qrangeslider import QRangeSlider
|
||||
from .qtcompat.QtCore import Signal
|
||||
|
||||
|
||||
class QDoubleSlider(_HookedSlider):
|
||||
|
||||
valueChanged = Signal(float)
|
||||
rangeChanged = Signal(float, float)
|
||||
sliderMoved = Signal(float)
|
||||
_multiplier = 1
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
self._multiplier = 10 ** 2
|
||||
self.setMinimum(0)
|
||||
self.setMaximum(99)
|
||||
self.setSingleStep(1)
|
||||
self.setPageStep(10)
|
||||
super().sliderMoved.connect(
|
||||
lambda e: self.sliderMoved.emit(self._post_get_hook(e))
|
||||
)
|
||||
|
||||
def decimals(self) -> int:
|
||||
"""This property holds the precision of the slider, in decimals."""
|
||||
return int(math.log10(self._multiplier))
|
||||
|
||||
def setDecimals(self, prec: int):
|
||||
"""This property holds the precision of the slider, in decimals
|
||||
|
||||
Sets how many decimals the slider uses for displaying and interpreting doubles.
|
||||
"""
|
||||
previous = self._multiplier
|
||||
self._multiplier = 10 ** int(prec)
|
||||
ratio = self._multiplier / previous
|
||||
|
||||
if ratio != 1:
|
||||
self.blockSignals(True)
|
||||
try:
|
||||
newmin = self.minimum() * ratio
|
||||
newmax = self.maximum() * ratio
|
||||
newval = self._scale_value(ratio)
|
||||
newstep = self.singleStep() * ratio
|
||||
newpage = self.pageStep() * ratio
|
||||
self.setRange(newmin, newmax)
|
||||
self.setValue(newval)
|
||||
self.setSingleStep(newstep)
|
||||
self.setPageStep(newpage)
|
||||
except OverflowError as err:
|
||||
self._multiplier = previous
|
||||
raise OverflowError(
|
||||
f"Cannot use {prec} decimals with a range of {newmin}-"
|
||||
f"{newmax}. If you need this feature, please open a feature"
|
||||
" request at github."
|
||||
) from err
|
||||
self.blockSignals(False)
|
||||
|
||||
def _scale_value(self, p):
|
||||
# for subclasses
|
||||
return self.value() * p
|
||||
|
||||
def _post_get_hook(self, value: int) -> float:
|
||||
return float(value / self._multiplier)
|
||||
|
||||
def _pre_set_hook(self, value: float) -> int:
|
||||
return int(value * self._multiplier)
|
||||
|
||||
def sliderChange(self, change) -> None:
|
||||
if change == self.SliderValueChange:
|
||||
self.valueChanged.emit(self.value())
|
||||
if change == self.SliderRangeChange:
|
||||
self.rangeChanged.emit(self.minimum(), self.maximum())
|
||||
return super().sliderChange(self.SliderChange(change))
|
||||
|
||||
|
||||
class QDoubleRangeSlider(QRangeSlider, QDoubleSlider):
|
||||
rangeChanged = Signal(float, float)
|
||||
|
||||
def value(self) -> Tuple[float, ...]:
|
||||
"""Get current value of the widget as a tuple of integers."""
|
||||
return tuple(float(i) for i in self._value)
|
||||
|
||||
def _min_max_bound(self, val: int) -> float:
|
||||
return round(super()._min_max_bound(val), self.decimals())
|
||||
|
||||
def _scale_value(self, p):
|
||||
# This function is called during setDecimals...
|
||||
# but because QRangeSlider has a private nonQt `_value`
|
||||
# we don't actually need to scale
|
||||
return self._value
|
||||
|
||||
def setDecimals(self, prec: int):
|
||||
return super().setDecimals(prec)
|
@@ -1,42 +0,0 @@
|
||||
from .qtcompat.QtWidgets import QSlider
|
||||
|
||||
|
||||
class _HookedSlider(QSlider):
|
||||
def _post_get_hook(self, value):
|
||||
return value
|
||||
|
||||
def _pre_set_hook(self, value):
|
||||
return value
|
||||
|
||||
def value(self):
|
||||
return self._post_get_hook(super().value())
|
||||
|
||||
def setValue(self, value) -> None:
|
||||
super().setValue(self._pre_set_hook(value))
|
||||
|
||||
def minimum(self):
|
||||
return self._post_get_hook(super().minimum())
|
||||
|
||||
def setMinimum(self, minimum):
|
||||
super().setMinimum(self._pre_set_hook(minimum))
|
||||
|
||||
def maximum(self):
|
||||
return self._post_get_hook(super().maximum())
|
||||
|
||||
def setMaximum(self, maximum):
|
||||
super().setMaximum(self._pre_set_hook(maximum))
|
||||
|
||||
def singleStep(self):
|
||||
return self._post_get_hook(super().singleStep())
|
||||
|
||||
def setSingleStep(self, step):
|
||||
super().setSingleStep(self._pre_set_hook(step))
|
||||
|
||||
def pageStep(self):
|
||||
return self._post_get_hook(super().pageStep())
|
||||
|
||||
def setPageStep(self, step) -> None:
|
||||
super().setPageStep(self._pre_set_hook(step))
|
||||
|
||||
def setRange(self, min: float, max: float) -> None:
|
||||
super().setRange(self._pre_set_hook(min), self._pre_set_hook(max))
|
@@ -1,475 +0,0 @@
|
||||
from enum import IntEnum
|
||||
from functools import partial
|
||||
|
||||
from ._float_slider import QDoubleRangeSlider, QDoubleSlider
|
||||
from ._qrangeslider import QRangeSlider
|
||||
from .qtcompat.QtCore import QPoint, QSize, Qt, Signal
|
||||
from .qtcompat.QtGui import QFontMetrics, QValidator
|
||||
from .qtcompat.QtWidgets import (
|
||||
QAbstractSlider,
|
||||
QApplication,
|
||||
QDoubleSpinBox,
|
||||
QHBoxLayout,
|
||||
QSlider,
|
||||
QSpinBox,
|
||||
QStyle,
|
||||
QStyleOptionSpinBox,
|
||||
QVBoxLayout,
|
||||
QWidget,
|
||||
)
|
||||
|
||||
|
||||
class LabelPosition(IntEnum):
|
||||
NoLabel = 0
|
||||
LabelsAbove = 1
|
||||
LabelsBelow = 2
|
||||
LabelsRight = 1
|
||||
LabelsLeft = 2
|
||||
|
||||
|
||||
class EdgeLabelMode(IntEnum):
|
||||
NoLabel = 0
|
||||
LabelIsRange = 1
|
||||
LabelIsValue = 2
|
||||
|
||||
|
||||
class SliderProxy:
|
||||
_slider: QAbstractSlider
|
||||
|
||||
def value(self):
|
||||
return self._slider.value()
|
||||
|
||||
def setValue(self, value) -> None:
|
||||
self._slider.setValue(value)
|
||||
|
||||
def minimum(self):
|
||||
return self._slider.minimum()
|
||||
|
||||
def setMinimum(self, minimum):
|
||||
self._slider.setMinimum(minimum)
|
||||
|
||||
def maximum(self):
|
||||
return self._slider.maximum()
|
||||
|
||||
def setMaximum(self, maximum):
|
||||
self._slider.setMaximum(maximum)
|
||||
|
||||
def singleStep(self):
|
||||
return self._slider.singleStep()
|
||||
|
||||
def setSingleStep(self, step):
|
||||
self._slider.setSingleStep(step)
|
||||
|
||||
def pageStep(self):
|
||||
return self._slider.pageStep()
|
||||
|
||||
def setPageStep(self, step) -> None:
|
||||
self._slider.setPageStep(step)
|
||||
|
||||
def setRange(self, min, max) -> None:
|
||||
self._slider.setRange(min, max)
|
||||
|
||||
|
||||
def _handle_overloaded_slider_sig(args, kwargs):
|
||||
parent = None
|
||||
orientation = Qt.Vertical
|
||||
errmsg = (
|
||||
"TypeError: arguments did not match any overloaded call:\n"
|
||||
" QSlider(parent: QWidget = None)\n"
|
||||
" QSlider(Qt.Orientation, parent: QWidget = None)"
|
||||
)
|
||||
if len(args) > 2:
|
||||
raise TypeError(errmsg)
|
||||
elif len(args) == 2:
|
||||
if kwargs:
|
||||
raise TypeError(errmsg)
|
||||
orientation, parent = args
|
||||
elif args:
|
||||
if isinstance(args[0], QWidget):
|
||||
if kwargs:
|
||||
raise TypeError(errmsg)
|
||||
parent = args[0]
|
||||
else:
|
||||
orientation = args[0]
|
||||
parent = kwargs.get("parent", parent)
|
||||
return parent, orientation
|
||||
|
||||
|
||||
class QLabeledSlider(SliderProxy, QAbstractSlider):
|
||||
_slider_class = QSlider
|
||||
_slider: QSlider
|
||||
|
||||
def __init__(self, *args, **kwargs) -> None:
|
||||
parent, orientation = _handle_overloaded_slider_sig(args, kwargs)
|
||||
|
||||
super().__init__(parent)
|
||||
|
||||
self._slider = self._slider_class()
|
||||
self._label = SliderLabel(self._slider, connect=self._slider.setValue)
|
||||
|
||||
self._slider.rangeChanged.connect(self.rangeChanged.emit)
|
||||
self._slider.valueChanged.connect(self.valueChanged.emit)
|
||||
self._slider.valueChanged.connect(self._label.setValue)
|
||||
|
||||
self.setOrientation(orientation)
|
||||
|
||||
def setOrientation(self, orientation):
|
||||
"""Set orientation, value will be 'horizontal' or 'vertical'."""
|
||||
self._slider.setOrientation(orientation)
|
||||
if orientation == Qt.Vertical:
|
||||
layout = QVBoxLayout()
|
||||
layout.addWidget(self._slider, alignment=Qt.AlignHCenter)
|
||||
layout.addWidget(self._label, alignment=Qt.AlignHCenter)
|
||||
self._label.setAlignment(Qt.AlignCenter)
|
||||
layout.setSpacing(1)
|
||||
else:
|
||||
layout = QHBoxLayout()
|
||||
layout.addWidget(self._slider)
|
||||
layout.addWidget(self._label)
|
||||
self._label.setAlignment(Qt.AlignRight)
|
||||
layout.setSpacing(6)
|
||||
|
||||
old_layout = self.layout()
|
||||
if old_layout is not None:
|
||||
QWidget().setLayout(old_layout)
|
||||
|
||||
layout.setContentsMargins(0, 0, 0, 0)
|
||||
self.setLayout(layout)
|
||||
|
||||
|
||||
class QLabeledDoubleSlider(QLabeledSlider):
|
||||
_slider_class = QDoubleSlider
|
||||
_slider: QDoubleSlider
|
||||
valueChanged = Signal(float)
|
||||
rangeChanged = Signal(float, float)
|
||||
|
||||
def __init__(self, *args, **kwargs) -> None:
|
||||
super().__init__(*args, **kwargs)
|
||||
self.setDecimals(2)
|
||||
|
||||
def decimals(self) -> int:
|
||||
return self._slider.decimals()
|
||||
|
||||
def setDecimals(self, prec: int):
|
||||
self._slider.setDecimals(prec)
|
||||
self._label.setDecimals(prec)
|
||||
|
||||
|
||||
class QLabeledRangeSlider(SliderProxy, QAbstractSlider):
|
||||
valueChanged = Signal(tuple)
|
||||
LabelPosition = LabelPosition
|
||||
EdgeLabelMode = EdgeLabelMode
|
||||
_slider_class = QRangeSlider
|
||||
_slider: QRangeSlider
|
||||
|
||||
def __init__(self, *args, **kwargs) -> None:
|
||||
parent, orientation = _handle_overloaded_slider_sig(args, kwargs)
|
||||
super().__init__(parent)
|
||||
self.setAttribute(Qt.WA_ShowWithoutActivating)
|
||||
self._handle_labels = []
|
||||
self._handle_label_position: LabelPosition = LabelPosition.LabelsAbove
|
||||
|
||||
# for fine tuning label position
|
||||
self.label_shift_x = 0
|
||||
self.label_shift_y = 0
|
||||
|
||||
self._slider = self._slider_class()
|
||||
self._slider.valueChanged.connect(self.valueChanged.emit)
|
||||
self._slider.rangeChanged.connect(self.rangeChanged.emit)
|
||||
|
||||
self._min_label = SliderLabel(
|
||||
self._slider, alignment=Qt.AlignLeft, connect=self._min_label_edited
|
||||
)
|
||||
self._max_label = SliderLabel(
|
||||
self._slider, alignment=Qt.AlignRight, connect=self._max_label_edited
|
||||
)
|
||||
self.setEdgeLabelMode(EdgeLabelMode.LabelIsRange)
|
||||
|
||||
self._slider.valueChanged.connect(self._on_value_changed)
|
||||
self._slider.rangeChanged.connect(self._on_range_changed)
|
||||
|
||||
self._on_value_changed(self._slider.value())
|
||||
self._on_range_changed(self._slider.minimum(), self._slider.maximum())
|
||||
self.setOrientation(orientation)
|
||||
|
||||
def handleLabelPosition(self) -> LabelPosition:
|
||||
return self._handle_label_position
|
||||
|
||||
def setHandleLabelPosition(self, opt: LabelPosition) -> LabelPosition:
|
||||
self._handle_label_position = opt
|
||||
for lbl in self._handle_labels:
|
||||
if not opt:
|
||||
lbl.hide()
|
||||
else:
|
||||
lbl.show()
|
||||
self.setOrientation(self.orientation())
|
||||
|
||||
def edgeLabelMode(self) -> EdgeLabelMode:
|
||||
return self._edge_label_mode
|
||||
|
||||
def setEdgeLabelMode(self, opt: EdgeLabelMode):
|
||||
self._edge_label_mode = opt
|
||||
if not self._edge_label_mode:
|
||||
self._min_label.hide()
|
||||
self._max_label.hide()
|
||||
else:
|
||||
if self.isVisible():
|
||||
self._min_label.show()
|
||||
self._max_label.show()
|
||||
self._min_label.setMode(opt)
|
||||
self._max_label.setMode(opt)
|
||||
if opt == EdgeLabelMode.LabelIsValue:
|
||||
v0, *_, v1 = self._slider.value()
|
||||
self._min_label.setValue(v0)
|
||||
self._max_label.setValue(v1)
|
||||
elif opt == EdgeLabelMode.LabelIsRange:
|
||||
self._min_label.setValue(self._slider.minimum())
|
||||
self._max_label.setValue(self._slider.maximum())
|
||||
QApplication.processEvents()
|
||||
self._reposition_labels()
|
||||
|
||||
def _reposition_labels(self):
|
||||
if not self._handle_labels:
|
||||
return
|
||||
|
||||
horizontal = self.orientation() == Qt.Horizontal
|
||||
labels_above = self._handle_label_position == LabelPosition.LabelsAbove
|
||||
|
||||
last_edge = None
|
||||
for label, rect in zip(self._handle_labels, self._slider._handleRects()):
|
||||
dx = -label.width() / 2
|
||||
dy = -label.height() / 2
|
||||
if labels_above:
|
||||
if horizontal:
|
||||
dy *= 3
|
||||
else:
|
||||
dx *= -1
|
||||
else:
|
||||
if horizontal:
|
||||
dy *= -1
|
||||
else:
|
||||
dx *= 3
|
||||
pos = self._slider.mapToParent(rect.center())
|
||||
pos += QPoint(int(dx + self.label_shift_x), int(dy + self.label_shift_y))
|
||||
if last_edge is not None:
|
||||
# prevent label overlap
|
||||
if horizontal:
|
||||
pos.setX(int(max(pos.x(), last_edge.x() + label.width() / 2 + 12)))
|
||||
else:
|
||||
pos.setY(int(min(pos.y(), last_edge.y() - label.height() / 2 - 4)))
|
||||
label.move(pos)
|
||||
last_edge = pos
|
||||
label.clearFocus()
|
||||
|
||||
def _min_label_edited(self, val):
|
||||
if self._edge_label_mode == EdgeLabelMode.LabelIsRange:
|
||||
self.setMinimum(val)
|
||||
else:
|
||||
v = list(self._slider.value())
|
||||
v[0] = val
|
||||
self.setValue(v)
|
||||
self._reposition_labels()
|
||||
|
||||
def _max_label_edited(self, val):
|
||||
if self._edge_label_mode == EdgeLabelMode.LabelIsRange:
|
||||
self.setMaximum(val)
|
||||
else:
|
||||
v = list(self._slider.value())
|
||||
v[-1] = val
|
||||
self.setValue(v)
|
||||
self._reposition_labels()
|
||||
|
||||
def _on_value_changed(self, v):
|
||||
if self._edge_label_mode == EdgeLabelMode.LabelIsValue:
|
||||
self._min_label.setValue(v[0])
|
||||
self._max_label.setValue(v[-1])
|
||||
|
||||
if len(v) != len(self._handle_labels):
|
||||
for lbl in self._handle_labels:
|
||||
lbl.setParent(None)
|
||||
lbl.deleteLater()
|
||||
self._handle_labels.clear()
|
||||
for n, val in enumerate(self._slider.value()):
|
||||
_cb = partial(self._slider._setSliderPositionAt, n)
|
||||
s = SliderLabel(self._slider, parent=self, connect=_cb)
|
||||
s.setValue(val)
|
||||
self._handle_labels.append(s)
|
||||
else:
|
||||
for val, label in zip(v, self._handle_labels):
|
||||
label.setValue(val)
|
||||
self._reposition_labels()
|
||||
|
||||
def _on_range_changed(self, min, max):
|
||||
self._slider.setRange(min, max)
|
||||
for lbl in self._handle_labels:
|
||||
lbl.setRange(min, max)
|
||||
if self._edge_label_mode == EdgeLabelMode.LabelIsRange:
|
||||
self._min_label.setValue(min)
|
||||
self._max_label.setValue(max)
|
||||
self._reposition_labels()
|
||||
|
||||
# def setValue(self, value) -> None:
|
||||
# super().setValue(value)
|
||||
# self.sliderChange(QSlider.SliderValueChange)
|
||||
|
||||
def setRange(self, min, max) -> None:
|
||||
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.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.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):
|
||||
_slider_class = QDoubleRangeSlider
|
||||
_slider: QDoubleRangeSlider
|
||||
rangeChanged = Signal(float, float)
|
||||
|
||||
def __init__(self, *args, **kwargs) -> None:
|
||||
super().__init__(*args, **kwargs)
|
||||
self.setDecimals(2)
|
||||
|
||||
def decimals(self) -> int:
|
||||
return self._slider.decimals()
|
||||
|
||||
def setDecimals(self, prec: int):
|
||||
self._slider.setDecimals(prec)
|
||||
self._min_label.setDecimals(prec)
|
||||
self._max_label.setDecimals(prec)
|
||||
for lbl in self._handle_labels:
|
||||
lbl.setDecimals(prec)
|
||||
|
||||
|
||||
class SliderLabel(QDoubleSpinBox):
|
||||
def __init__(
|
||||
self, slider: QSlider, parent=None, alignment=Qt.AlignCenter, connect=None
|
||||
) -> None:
|
||||
super().__init__(parent=parent)
|
||||
self._slider = slider
|
||||
self.setFocusPolicy(Qt.ClickFocus)
|
||||
self.setMode(EdgeLabelMode.LabelIsValue)
|
||||
self.setDecimals(0)
|
||||
|
||||
self.setRange(slider.minimum(), slider.maximum())
|
||||
slider.rangeChanged.connect(self._update_size)
|
||||
self.setAlignment(alignment)
|
||||
self.setButtonSymbols(QSpinBox.NoButtons)
|
||||
self.setStyleSheet("background:transparent; border: 0;")
|
||||
if connect is not None:
|
||||
self.editingFinished.connect(lambda: connect(self.value()))
|
||||
self.editingFinished.connect(self.clearFocus)
|
||||
self._update_size()
|
||||
|
||||
def setDecimals(self, prec: int) -> None:
|
||||
super().setDecimals(prec)
|
||||
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.CT_SpinBox, opt, QSize(w, h), self)
|
||||
self.setFixedSize(size)
|
||||
|
||||
def setValue(self, val):
|
||||
super().setValue(val)
|
||||
if self._mode == EdgeLabelMode.LabelIsRange:
|
||||
self._update_size()
|
||||
|
||||
def setMaximum(self, max: int) -> None:
|
||||
super().setMaximum(max)
|
||||
if self._mode == EdgeLabelMode.LabelIsValue:
|
||||
self._update_size()
|
||||
|
||||
def setMinimum(self, min: int) -> None:
|
||||
super().setMinimum(min)
|
||||
if self._mode == EdgeLabelMode.LabelIsValue:
|
||||
self._update_size()
|
||||
|
||||
def setMode(self, opt: EdgeLabelMode):
|
||||
# when the edge labels are controlling slider range,
|
||||
# we want them to have a big range, but not have a huge label
|
||||
self._mode = opt
|
||||
if opt == EdgeLabelMode.LabelIsRange:
|
||||
self.setMinimum(-9999999)
|
||||
self.setMaximum(9999999)
|
||||
try:
|
||||
self._slider.rangeChanged.disconnect(self.setRange)
|
||||
except Exception:
|
||||
pass
|
||||
else:
|
||||
self.setMinimum(self._slider.minimum())
|
||||
self.setMaximum(self._slider.maximum())
|
||||
self._slider.rangeChanged.connect(self.setRange)
|
||||
self._update_size()
|
||||
|
||||
def validate(self, input: str, pos: int):
|
||||
# fake like an integer spinbox
|
||||
if "." in input and self.decimals() < 1:
|
||||
return QValidator.Invalid, input, len(input)
|
||||
return super().validate(input, pos)
|
||||
|
||||
|
||||
def _fm_width(fm, text):
|
||||
if hasattr(fm, "horizontalAdvance"):
|
||||
return fm.horizontalAdvance(text)
|
||||
return fm.width(text)
|
@@ -1,585 +0,0 @@
|
||||
import textwrap
|
||||
from collections import abc
|
||||
from typing import List, Sequence, Tuple
|
||||
|
||||
from ._hooked import _HookedSlider
|
||||
from ._style import RangeSliderStyle, update_styles_from_stylesheet
|
||||
from .qtcompat import QtGui
|
||||
from .qtcompat.QtCore import (
|
||||
Property,
|
||||
QEvent,
|
||||
QPoint,
|
||||
QPointF,
|
||||
QRect,
|
||||
QRectF,
|
||||
Qt,
|
||||
Signal,
|
||||
)
|
||||
from .qtcompat.QtWidgets import (
|
||||
QApplication,
|
||||
QSlider,
|
||||
QStyle,
|
||||
QStyleOptionSlider,
|
||||
QStylePainter,
|
||||
)
|
||||
|
||||
ControlType = Tuple[str, int]
|
||||
|
||||
|
||||
class QRangeSlider(_HookedSlider, QSlider):
|
||||
"""MultiHandle Range Slider widget.
|
||||
|
||||
Same API as QSlider, but `value`, `setValue`, `sliderPosition`, and
|
||||
`setSliderPosition` are all sequences of integers.
|
||||
|
||||
The `valueChanged` and `sliderMoved` signals also both emit a tuple of
|
||||
integers.
|
||||
"""
|
||||
|
||||
# Emitted when the slider value has changed, with the new slider values
|
||||
valueChanged = Signal(tuple)
|
||||
|
||||
# Emitted when sliderDown is true and the slider moves
|
||||
# This usually happens when the user is dragging the slider
|
||||
# The value is the positions of *all* handles.
|
||||
sliderMoved = Signal(tuple)
|
||||
|
||||
_NULL_CTRL = ("None", -1)
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
# list of values
|
||||
self._value: List[int] = [20, 80]
|
||||
|
||||
# list of current positions of each handle. same length as _value
|
||||
# If tracking is enabled (the default) this will be identical to _value
|
||||
self._position: List[int] = [20, 80]
|
||||
self._pressedControl: ControlType = self._NULL_CTRL
|
||||
self._hoverControl: ControlType = self._NULL_CTRL
|
||||
|
||||
# whether bar length is constant when dragging the bar
|
||||
# if False, the bar can shorten when dragged beyond min/max
|
||||
self._bar_is_rigid = True
|
||||
# whether clicking on the bar moves all handles, or just the nearest handle
|
||||
self._bar_moves_all = True
|
||||
self._should_draw_bar = True
|
||||
|
||||
# for keyboard nav
|
||||
self._repeatMultiplier = 1 # TODO
|
||||
# for wheel nav
|
||||
self._offset_accum = 0
|
||||
# fraction of total range to scroll when holding Ctrl while scrolling
|
||||
self._control_fraction = 0.04
|
||||
|
||||
# color
|
||||
self._style = RangeSliderStyle()
|
||||
self.setStyleSheet("")
|
||||
update_styles_from_stylesheet(self)
|
||||
|
||||
# ############### Public API #######################
|
||||
|
||||
def setStyleSheet(self, styleSheet: str) -> None:
|
||||
# sub-page styles render on top of the lower sliders and don't work here.
|
||||
override = f"""
|
||||
\n{type(self).__name__}::sub-page:horizontal {{background: none}}
|
||||
\n{type(self).__name__}::sub-page:vertical {{background: none}}
|
||||
"""
|
||||
return super().setStyleSheet(styleSheet + override)
|
||||
|
||||
def value(self) -> Tuple[int, ...]:
|
||||
"""Get current value of the widget as a tuple of integers."""
|
||||
return tuple(self._value)
|
||||
|
||||
def setValue(self, val: Sequence[int]) -> None:
|
||||
"""Set current value of the widget with a sequence of integers.
|
||||
|
||||
The number of handles will be equal to the length of the sequence
|
||||
"""
|
||||
if not (isinstance(val, abc.Sequence) and len(val) >= 2):
|
||||
raise ValueError("value must be iterable of len >= 2")
|
||||
val = [self._min_max_bound(v) for v in val]
|
||||
if self._value == val and self._position == val:
|
||||
return
|
||||
self._value[:] = val[:]
|
||||
if self._position != val:
|
||||
self._position = val
|
||||
if self.isSliderDown():
|
||||
self.sliderMoved.emit(tuple(self._position))
|
||||
|
||||
self.sliderChange(QSlider.SliderValueChange)
|
||||
self.valueChanged.emit(self.value())
|
||||
|
||||
def sliderPosition(self) -> Tuple[int, ...]:
|
||||
"""Get current value of the widget as a tuple of integers.
|
||||
|
||||
If tracking is enabled (the default) this will be identical to value().
|
||||
"""
|
||||
return tuple(self._position)
|
||||
|
||||
def setSliderPosition(self, val: Sequence[int]) -> None:
|
||||
"""Set current position of the handles with a sequence of integers.
|
||||
|
||||
The sequence must have the same length as `value()`.
|
||||
"""
|
||||
if len(val) != len(self.value()):
|
||||
raise ValueError(
|
||||
f"'sliderPosition' must have length of 'value()' ({len(self.value())})"
|
||||
)
|
||||
|
||||
for i, v in enumerate(val):
|
||||
self._setSliderPositionAt(i, v, _update=False)
|
||||
self._updateSliderMove()
|
||||
|
||||
def barIsRigid(self) -> bool:
|
||||
"""Whether bar length is constant when dragging the bar.
|
||||
|
||||
If False, the bar can shorten when dragged beyond min/max. Default is True.
|
||||
"""
|
||||
return self._bar_is_rigid
|
||||
|
||||
def setBarIsRigid(self, val: bool = True) -> None:
|
||||
"""Whether bar length is constant when dragging the bar.
|
||||
|
||||
If False, the bar can shorten when dragged beyond min/max. Default is True.
|
||||
"""
|
||||
self._bar_is_rigid = bool(val)
|
||||
|
||||
def barMovesAllHandles(self) -> bool:
|
||||
"""Whether clicking on the bar moves all handles (default), or just the nearest."""
|
||||
return self._bar_moves_all
|
||||
|
||||
def setBarMovesAllHandles(self, val: bool = True) -> None:
|
||||
"""Whether clicking on the bar moves all handles (default), or just the nearest."""
|
||||
self._bar_moves_all = bool(val)
|
||||
|
||||
def barIsVisible(self) -> bool:
|
||||
"""Whether to show the bar between the first and last handle."""
|
||||
return self._should_draw_bar
|
||||
|
||||
def setBarVisible(self, val: bool = True) -> None:
|
||||
"""Whether to show the bar between the first and last handle."""
|
||||
self._should_draw_bar = bool(val)
|
||||
|
||||
def hideBar(self) -> None:
|
||||
self.setBarVisible(False)
|
||||
|
||||
def showBar(self) -> None:
|
||||
self.setBarVisible(True)
|
||||
|
||||
# ############### Implementation Details #######################
|
||||
|
||||
def _setSliderPositionAt(self, index: int, pos: int, _update=True) -> None:
|
||||
pos = self._min_max_bound(pos)
|
||||
# prevent sliders from moving beyond their neighbors
|
||||
pos = self._neighbor_bound(pos, index, self._position)
|
||||
if pos == self._position[index]:
|
||||
return
|
||||
|
||||
self._position[index] = pos
|
||||
if _update:
|
||||
self._updateSliderMove()
|
||||
|
||||
def _updateSliderMove(self):
|
||||
if not self.hasTracking():
|
||||
self.update()
|
||||
if self.isSliderDown():
|
||||
self.sliderMoved.emit(tuple(self._position))
|
||||
if self.hasTracking():
|
||||
self.triggerAction(QSlider.SliderMove)
|
||||
|
||||
def _offsetAllPositions(self, offset: int, ref=None) -> None:
|
||||
if ref is None:
|
||||
ref = self._position
|
||||
if self._bar_is_rigid:
|
||||
# NOTE: This assumes monotonically increasing slider positions
|
||||
if offset > 0 and ref[-1] + offset > self.maximum():
|
||||
offset = self.maximum() - ref[-1]
|
||||
elif ref[0] + offset < self.minimum():
|
||||
offset = self.minimum() - ref[0]
|
||||
self.setSliderPosition([i + offset for i in ref])
|
||||
|
||||
def _spreadAllPositions(self, shrink=False, gain=1.1, ref=None) -> None:
|
||||
if ref is None:
|
||||
ref = self._position
|
||||
# if self._bar_is_rigid: # TODO
|
||||
|
||||
if shrink:
|
||||
gain = 1 / gain
|
||||
center = abs(ref[-1] + ref[0]) / 2
|
||||
self.setSliderPosition([((i - center) * gain) + center for i in ref])
|
||||
|
||||
def _getStyleOption(self) -> QStyleOptionSlider:
|
||||
opt = QStyleOptionSlider()
|
||||
self.initStyleOption(opt)
|
||||
opt.sliderValue = 0
|
||||
opt.sliderPosition = 0
|
||||
return opt
|
||||
|
||||
def _getBarColor(self):
|
||||
return self._style.brush(self._getStyleOption())
|
||||
|
||||
def _setBarColor(self, color):
|
||||
self._style.brush_active = color
|
||||
|
||||
barColor = Property(QtGui.QBrush, _getBarColor, _setBarColor)
|
||||
|
||||
def _drawBar(self, painter: QStylePainter, opt: QStyleOptionSlider):
|
||||
|
||||
brush = self._style.brush(opt)
|
||||
|
||||
r_bar = self._barRect(opt)
|
||||
if isinstance(brush, QtGui.QGradient):
|
||||
brush.setStart(r_bar.topLeft())
|
||||
brush.setFinalStop(r_bar.bottomRight())
|
||||
|
||||
painter.setPen(self._style.pen(opt))
|
||||
painter.setBrush(brush)
|
||||
painter.drawRect(r_bar)
|
||||
|
||||
def paintEvent(self, a0: QtGui.QPaintEvent) -> None:
|
||||
"""Paint the slider."""
|
||||
# initialize painter and options
|
||||
painter = QStylePainter(self)
|
||||
opt = self._getStyleOption()
|
||||
|
||||
# draw groove and ticks
|
||||
opt.subControls = QStyle.SC_SliderGroove | QStyle.SC_SliderTickmarks
|
||||
painter.drawComplexControl(QStyle.CC_Slider, opt)
|
||||
|
||||
if self._should_draw_bar:
|
||||
self._drawBar(painter, opt)
|
||||
|
||||
# draw handles
|
||||
opt.subControls = QStyle.SC_SliderHandle
|
||||
hidx = -1
|
||||
pidx = -1
|
||||
if self._pressedControl[0] == "handle":
|
||||
pidx = self._pressedControl[1]
|
||||
elif self._hoverControl[0] == "handle":
|
||||
hidx = self._hoverControl[1]
|
||||
for idx, pos in enumerate(self._position):
|
||||
opt.sliderPosition = self._pre_set_hook(pos)
|
||||
|
||||
if idx == pidx: # make pressed handles appear sunken
|
||||
opt.state |= QStyle.State_Sunken
|
||||
else:
|
||||
opt.state = opt.state & ~QStyle.State_Sunken
|
||||
if idx == hidx:
|
||||
opt.activeSubControls = QStyle.SC_SliderHandle
|
||||
else:
|
||||
opt.activeSubControls = QStyle.SC_None
|
||||
painter.drawComplexControl(QStyle.CC_Slider, opt)
|
||||
|
||||
def event(self, ev: QEvent) -> bool:
|
||||
if ev.type() == QEvent.WindowActivate:
|
||||
self.update()
|
||||
if ev.type() == QEvent.StyleChange:
|
||||
update_styles_from_stylesheet(self)
|
||||
if ev.type() in (QEvent.HoverEnter, QEvent.HoverLeave, QEvent.HoverMove):
|
||||
old_hover = self._hoverControl
|
||||
self._hoverControl = self._getControlAtPos(ev.pos())
|
||||
if self._hoverControl != old_hover:
|
||||
self.update() # TODO: restrict to the rect of old_hover
|
||||
return super().event(ev)
|
||||
|
||||
def mousePressEvent(self, ev: QtGui.QMouseEvent) -> None:
|
||||
if self.minimum() == self.maximum() or ev.buttons() ^ ev.button():
|
||||
ev.ignore()
|
||||
return
|
||||
|
||||
ev.accept()
|
||||
# FIXME: why not working on other styles?
|
||||
# set_buttons = self.style().styleHint(QStyle.SH_Slider_AbsoluteSetButtons)
|
||||
set_buttons = Qt.LeftButton | Qt.MiddleButton
|
||||
|
||||
# If the mouse button used is allowed to set the value
|
||||
if ev.buttons() & set_buttons == ev.button():
|
||||
opt = self._getStyleOption()
|
||||
|
||||
self._pressedControl = self._getControlAtPos(ev.pos(), opt, True)
|
||||
|
||||
if self._pressedControl[0] == "handle":
|
||||
offset = self._handle_offset(opt)
|
||||
new_pos = self._pixelPosToRangeValue(self._pick(ev.pos() - offset))
|
||||
self._setSliderPositionAt(self._pressedControl[1], new_pos)
|
||||
self.triggerAction(QSlider.SliderMove)
|
||||
self.setRepeatAction(QSlider.SliderNoAction)
|
||||
self.update()
|
||||
|
||||
if self._pressedControl[0] == "handle":
|
||||
self.setRepeatAction(QSlider.SliderNoAction) # why again?
|
||||
sr = self._handleRects(opt, self._pressedControl[1])
|
||||
self._clickOffset = self._pick(ev.pos() - sr.topLeft())
|
||||
self.update()
|
||||
self.setSliderDown(True)
|
||||
elif self._pressedControl[0] == "bar":
|
||||
self.setRepeatAction(QSlider.SliderNoAction) # why again?
|
||||
self._clickOffset = self._pixelPosToRangeValue(self._pick(ev.pos()))
|
||||
self._sldPosAtPress = tuple(self._position)
|
||||
self.update()
|
||||
self.setSliderDown(True)
|
||||
|
||||
def mouseMoveEvent(self, ev: QtGui.QMouseEvent) -> None:
|
||||
# TODO: add pixelMetric(QStyle::PM_MaximumDragDistance, &opt, this);
|
||||
if self._pressedControl[0] == "handle":
|
||||
ev.accept()
|
||||
new = self._pixelPosToRangeValue(self._pick(ev.pos()) - self._clickOffset)
|
||||
self._setSliderPositionAt(self._pressedControl[1], new)
|
||||
elif self._pressedControl[0] == "bar":
|
||||
ev.accept()
|
||||
|
||||
delta = self._clickOffset - self._pixelPosToRangeValue(self._pick(ev.pos()))
|
||||
self._offsetAllPositions(-delta, self._sldPosAtPress)
|
||||
else:
|
||||
ev.ignore()
|
||||
return
|
||||
|
||||
def mouseReleaseEvent(self, ev: QtGui.QMouseEvent) -> None:
|
||||
if self._pressedControl[0] == "None" or ev.buttons():
|
||||
ev.ignore()
|
||||
return
|
||||
ev.accept()
|
||||
old_pressed = self._pressedControl
|
||||
self._pressedControl = self._NULL_CTRL
|
||||
self.setRepeatAction(QSlider.SliderNoAction)
|
||||
if old_pressed[0] in ("handle", "bar"):
|
||||
self.setSliderDown(False)
|
||||
self.update() # TODO: restrict to the rect of old_pressed
|
||||
|
||||
def triggerAction(self, action: QSlider.SliderAction) -> None:
|
||||
super().triggerAction(action) # TODO: probably need to override.
|
||||
self.setValue(self._position)
|
||||
|
||||
def setRange(self, min: int, max: int) -> None:
|
||||
super().setRange(min, max)
|
||||
self.setValue(self._value) # re-bound
|
||||
|
||||
def _handleRects(
|
||||
self, opt: QStyleOptionSlider = None, handle_index: int = None
|
||||
) -> QRect:
|
||||
"""Return the QRect for all handles."""
|
||||
if opt is None:
|
||||
opt = self._getStyleOption()
|
||||
|
||||
style = self.style().proxy()
|
||||
|
||||
if handle_index is not None: # get specific handle rect
|
||||
opt.sliderPosition = self._pre_set_hook(self._position[handle_index])
|
||||
return style.subControlRect(
|
||||
QStyle.CC_Slider, opt, QStyle.SC_SliderHandle, self
|
||||
)
|
||||
else:
|
||||
rects = []
|
||||
for p in self._position:
|
||||
opt.sliderPosition = self._pre_set_hook(p)
|
||||
r = style.subControlRect(
|
||||
QStyle.CC_Slider, opt, QStyle.SC_SliderHandle, self
|
||||
)
|
||||
rects.append(r)
|
||||
return rects
|
||||
|
||||
def _grooveRect(self, opt: QStyleOptionSlider) -> QRect:
|
||||
"""Return the QRect for the slider groove."""
|
||||
style = self.style().proxy()
|
||||
return style.subControlRect(QStyle.CC_Slider, opt, QStyle.SC_SliderGroove, self)
|
||||
|
||||
def _barRect(self, opt: QStyleOptionSlider, r_groove: QRect = None) -> QRect:
|
||||
"""Return the QRect for the bar between the outer handles."""
|
||||
if r_groove is None:
|
||||
r_groove = self._grooveRect(opt)
|
||||
r_bar = QRectF(r_groove)
|
||||
hdl_low, *_, hdl_high = self._handleRects(opt)
|
||||
|
||||
thickness = self._style.thickness(opt)
|
||||
offset = self._style.offset(opt)
|
||||
|
||||
if opt.orientation == Qt.Horizontal:
|
||||
r_bar.setTop(r_bar.center().y() - thickness / 2 + offset)
|
||||
r_bar.setHeight(thickness)
|
||||
r_bar.setLeft(hdl_low.center().x())
|
||||
r_bar.setRight(hdl_high.center().x())
|
||||
else:
|
||||
r_bar.setLeft(r_bar.center().x() - thickness / 2 + offset)
|
||||
r_bar.setWidth(thickness)
|
||||
r_bar.setBottom(hdl_low.center().y())
|
||||
r_bar.setTop(hdl_high.center().y())
|
||||
|
||||
return r_bar
|
||||
|
||||
def _getControlAtPos(
|
||||
self, pos: QPoint, opt: QStyleOptionSlider = None, closest_handle=False
|
||||
) -> ControlType:
|
||||
"""Update self._pressedControl based on ev.pos()."""
|
||||
if not opt:
|
||||
opt = self._getStyleOption()
|
||||
|
||||
event_position = self._pick(pos)
|
||||
bar_idx = 0
|
||||
hdl_idx = 0
|
||||
dist = float("inf")
|
||||
|
||||
if isinstance(pos, QPointF):
|
||||
pos = QPoint(pos.x(), pos.y())
|
||||
# TODO: this should be reversed, to prefer higher value handles
|
||||
for i, hdl in enumerate(self._handleRects(opt)):
|
||||
if hdl.contains(pos):
|
||||
return ("handle", i) # TODO: use enum for 'handle'
|
||||
hdl_center = self._pick(hdl.center())
|
||||
abs_dist = abs(event_position - hdl_center)
|
||||
if abs_dist < dist:
|
||||
dist = abs_dist
|
||||
hdl_idx = i
|
||||
if event_position > hdl_center:
|
||||
bar_idx += 1
|
||||
else:
|
||||
if closest_handle:
|
||||
if bar_idx == 0:
|
||||
# the click was below the minimum slider
|
||||
return ("handle", 0)
|
||||
elif bar_idx == len(self._position):
|
||||
# the click was above the maximum slider
|
||||
return ("handle", len(self._position) - 1)
|
||||
if self._bar_moves_all:
|
||||
# the click was in an internal segment
|
||||
return ("bar", bar_idx)
|
||||
elif closest_handle:
|
||||
return ("handle", hdl_idx)
|
||||
|
||||
return self._NULL_CTRL
|
||||
|
||||
def _handle_offset(self, opt: QStyleOptionSlider) -> QPoint:
|
||||
# to take half of the slider off for the setSliderPosition call we use the
|
||||
# center - topLeft
|
||||
handle_rect = self._handleRects(opt, 0)
|
||||
return handle_rect.center() - handle_rect.topLeft()
|
||||
|
||||
# from QSliderPrivate::pixelPosToRangeValue
|
||||
def _pixelPosToRangeValue(self, pos: int, opt: QStyleOptionSlider = None) -> int:
|
||||
if not opt:
|
||||
opt = self._getStyleOption()
|
||||
groove_rect = self._grooveRect(opt)
|
||||
handle_rect = self._handleRects(opt, 0)
|
||||
if self.orientation() == Qt.Horizontal:
|
||||
sliderLength = handle_rect.width()
|
||||
sliderMin = groove_rect.x()
|
||||
sliderMax = groove_rect.right() - sliderLength + 1
|
||||
else:
|
||||
sliderLength = handle_rect.height()
|
||||
sliderMin = groove_rect.y()
|
||||
sliderMax = groove_rect.bottom() - sliderLength + 1
|
||||
v = QStyle.sliderValueFromPosition(
|
||||
opt.minimum,
|
||||
opt.maximum,
|
||||
pos - sliderMin,
|
||||
sliderMax - sliderMin,
|
||||
opt.upsideDown,
|
||||
)
|
||||
return self._post_get_hook(v)
|
||||
|
||||
def _pick(self, pt: QPoint) -> int:
|
||||
return pt.x() if self.orientation() == Qt.Horizontal else pt.y()
|
||||
|
||||
def _min_max_bound(self, val: int) -> int:
|
||||
return _bound(self.minimum(), self.maximum(), val)
|
||||
|
||||
def _neighbor_bound(self, val: int, index: int, _lst: List[int]) -> int:
|
||||
# make sure we don't go lower than any preceding index:
|
||||
min_dist = self._post_get_hook(self.singleStep())
|
||||
if index > 0:
|
||||
val = max(_lst[index - 1] + min_dist, val)
|
||||
# make sure we don't go higher than any following index:
|
||||
if index < (len(_lst) - 1):
|
||||
val = min(_lst[index + 1] - min_dist, val)
|
||||
return val
|
||||
|
||||
def wheelEvent(self, e: QtGui.QWheelEvent) -> None:
|
||||
e.ignore()
|
||||
vertical = bool(e.angleDelta().y())
|
||||
delta = e.angleDelta().y() if vertical else e.angleDelta().x()
|
||||
if e.inverted():
|
||||
delta *= -1
|
||||
|
||||
orientation = Qt.Vertical if vertical else Qt.Horizontal
|
||||
if self._scrollByDelta(orientation, e.modifiers(), delta):
|
||||
e.accept()
|
||||
|
||||
def _scrollByDelta(self, orientation, modifiers, delta: int) -> bool:
|
||||
steps_to_scroll = 0
|
||||
pg_step = self.pageStep()
|
||||
|
||||
# in Qt scrolling to the right gives negative values.
|
||||
if orientation == Qt.Horizontal:
|
||||
delta *= -1
|
||||
offset = delta / 120
|
||||
if modifiers & Qt.ShiftModifier:
|
||||
# Scroll one page regardless of delta:
|
||||
steps_to_scroll = _bound(-pg_step, pg_step, int(offset * pg_step))
|
||||
self._offset_accum = 0
|
||||
elif modifiers & Qt.ControlModifier:
|
||||
# Scroll one page regardless of delta:
|
||||
_range = self._pre_set_hook(self.maximum()) - self._pre_set_hook(
|
||||
self.minimum()
|
||||
)
|
||||
steps_to_scroll = offset * _range * self._control_fraction
|
||||
self._offset_accum = 0
|
||||
else:
|
||||
# Calculate how many lines to scroll. Depending on what delta is (and
|
||||
# offset), we might end up with a fraction (e.g. scroll 1.3 lines). We can
|
||||
# only scroll whole lines, so we keep the reminder until next event.
|
||||
wheel_scroll_lines = QApplication.wheelScrollLines()
|
||||
steps_to_scrollF = wheel_scroll_lines * offset * self._effectiveSingleStep()
|
||||
|
||||
# Check if wheel changed direction since last event:
|
||||
if self._offset_accum != 0 and (offset / self._offset_accum) < 0:
|
||||
self._offset_accum = 0
|
||||
|
||||
self._offset_accum += steps_to_scrollF
|
||||
|
||||
# Don't scroll more than one page in any case:
|
||||
steps_to_scroll = _bound(-pg_step, pg_step, int(self._offset_accum))
|
||||
|
||||
self._offset_accum -= int(self._offset_accum)
|
||||
|
||||
if steps_to_scroll == 0:
|
||||
# We moved less than a line, but might still have accumulated partial
|
||||
# scroll, unless we already are at one of the ends.
|
||||
effective_offset = self._offset_accum
|
||||
if self.invertedControls():
|
||||
effective_offset *= -1
|
||||
if effective_offset > 0 and max(self._value) < self.maximum():
|
||||
return True
|
||||
if effective_offset < 0 and min(self._value) < self.minimum():
|
||||
return True
|
||||
self._offset_accum = 0
|
||||
return False
|
||||
|
||||
if self.invertedControls():
|
||||
steps_to_scroll *= -1
|
||||
|
||||
_prev_value = self.value()
|
||||
|
||||
if modifiers & Qt.AltModifier:
|
||||
self._spreadAllPositions(shrink=steps_to_scroll < 0)
|
||||
else:
|
||||
self._offsetAllPositions(self._post_get_hook(steps_to_scroll))
|
||||
self.triggerAction(QSlider.SliderMove)
|
||||
|
||||
if _prev_value == self.value():
|
||||
self._offset_accum = 0
|
||||
return False
|
||||
return True
|
||||
|
||||
def _effectiveSingleStep(self) -> int:
|
||||
return self.singleStep() * self._repeatMultiplier
|
||||
|
||||
def keyPressEvent(self, ev: QtGui.QKeyEvent) -> None:
|
||||
return # TODO
|
||||
|
||||
|
||||
def _bound(min_: int, max_: int, value: int) -> int:
|
||||
"""Return value bounded by min_ and max_."""
|
||||
return max(min_, min(max_, value))
|
||||
|
||||
|
||||
QRangeSlider.__doc__ += "\n" + textwrap.indent(QSlider.__doc__, " ")
|
@@ -1,119 +0,0 @@
|
||||
import platform
|
||||
|
||||
import pytest
|
||||
|
||||
from qtrangeslider import QRangeSlider
|
||||
from qtrangeslider.qtcompat import API_NAME
|
||||
from qtrangeslider.qtcompat.QtCore import Qt
|
||||
|
||||
NOT_LINUX = platform.system() != "Linux"
|
||||
NOT_PYSIDE2 = API_NAME != "PySide2"
|
||||
|
||||
skipmouse = pytest.mark.skipif(NOT_LINUX or NOT_PYSIDE2, reason="mouse tests finicky")
|
||||
|
||||
|
||||
@pytest.mark.parametrize("orientation", ["Horizontal", "Vertical"])
|
||||
def test_basic(qtbot, orientation):
|
||||
rs = QRangeSlider(getattr(Qt, orientation))
|
||||
qtbot.addWidget(rs)
|
||||
|
||||
|
||||
@skipmouse
|
||||
def test_drag_handles(qtbot):
|
||||
rs = QRangeSlider(Qt.Horizontal)
|
||||
qtbot.addWidget(rs)
|
||||
rs.setRange(0, 99)
|
||||
rs.setValue((20, 80))
|
||||
rs.setMouseTracking(True)
|
||||
rs.show()
|
||||
|
||||
# press the left handle
|
||||
opt = rs._getStyleOption()
|
||||
pos = rs._handleRects(opt, 0).center()
|
||||
with qtbot.waitSignal(rs.sliderPressed):
|
||||
qtbot.mousePress(rs, Qt.LeftButton, pos=pos)
|
||||
assert rs._pressedControl == ("handle", 0)
|
||||
|
||||
# drag the left handle
|
||||
with qtbot.waitSignals([rs.sliderMoved] * 13): # couple less signals
|
||||
for _ in range(15):
|
||||
pos.setX(pos.x() + 2)
|
||||
qtbot.mouseMove(rs, pos)
|
||||
|
||||
with qtbot.waitSignal(rs.sliderReleased):
|
||||
qtbot.mouseRelease(rs, Qt.LeftButton)
|
||||
|
||||
# check the values
|
||||
assert rs.value()[0] > 30
|
||||
assert rs._pressedControl == rs._NULL_CTRL
|
||||
|
||||
# press the right handle
|
||||
pos = rs._handleRects(opt, 1).center()
|
||||
with qtbot.waitSignal(rs.sliderPressed):
|
||||
qtbot.mousePress(rs, Qt.LeftButton, pos=pos)
|
||||
assert rs._pressedControl == ("handle", 1)
|
||||
|
||||
# drag the right handle
|
||||
with qtbot.waitSignals([rs.sliderMoved] * 13): # couple less signals
|
||||
for _ in range(15):
|
||||
pos.setX(pos.x() - 2)
|
||||
qtbot.mouseMove(rs, pos)
|
||||
with qtbot.waitSignal(rs.sliderReleased):
|
||||
qtbot.mouseRelease(rs, Qt.LeftButton)
|
||||
|
||||
# check the values
|
||||
assert rs.value()[1] < 70
|
||||
assert rs._pressedControl == rs._NULL_CTRL
|
||||
|
||||
|
||||
@skipmouse
|
||||
def test_drag_handles_beyond_edge(qtbot):
|
||||
rs = QRangeSlider(Qt.Horizontal)
|
||||
qtbot.addWidget(rs)
|
||||
rs.setRange(0, 99)
|
||||
rs.setValue((20, 80))
|
||||
rs.setMouseTracking(True)
|
||||
rs.show()
|
||||
|
||||
# press the right handle
|
||||
opt = rs._getStyleOption()
|
||||
pos = rs._handleRects(opt, 1).center()
|
||||
with qtbot.waitSignal(rs.sliderPressed):
|
||||
qtbot.mousePress(rs, Qt.LeftButton, pos=pos)
|
||||
assert rs._pressedControl == ("handle", 1)
|
||||
|
||||
# drag the handle off the right edge and make sure the value gets to the max
|
||||
for _ in range(5):
|
||||
pos.setX(pos.x() + 20)
|
||||
qtbot.mouseMove(rs, pos)
|
||||
|
||||
with qtbot.waitSignal(rs.sliderReleased):
|
||||
qtbot.mouseRelease(rs, Qt.LeftButton)
|
||||
|
||||
assert rs.value()[1] == 99
|
||||
|
||||
|
||||
@skipmouse
|
||||
def test_bar_drag_beyond_edge(qtbot):
|
||||
rs = QRangeSlider(Qt.Horizontal)
|
||||
qtbot.addWidget(rs)
|
||||
rs.setRange(0, 99)
|
||||
rs.setValue((20, 80))
|
||||
rs.setMouseTracking(True)
|
||||
rs.show()
|
||||
|
||||
# press the right handle
|
||||
pos = rs.rect().center()
|
||||
with qtbot.waitSignal(rs.sliderPressed):
|
||||
qtbot.mousePress(rs, Qt.LeftButton, pos=pos)
|
||||
assert rs._pressedControl == ("bar", 1)
|
||||
|
||||
# drag the handle off the right edge and make sure the value gets to the max
|
||||
for _ in range(15):
|
||||
pos.setX(pos.x() + 10)
|
||||
qtbot.mouseMove(rs, pos)
|
||||
|
||||
with qtbot.waitSignal(rs.sliderReleased):
|
||||
qtbot.mouseRelease(rs, Qt.LeftButton)
|
||||
|
||||
assert rs.value()[1] == 99
|
@@ -1,57 +0,0 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
#
|
||||
# Copyright © 2014-2015 Colin Duquesnoy
|
||||
# Copyright © 2009- The Spyder Development Team
|
||||
#
|
||||
# Licensed under the terms of the MIT License
|
||||
# (see LICENSE.txt for details)
|
||||
|
||||
"""
|
||||
Modified from qtpy.QtCore.
|
||||
Provides QtCore classes and functions.
|
||||
"""
|
||||
|
||||
from . import PYQT5, PYQT6, PYSIDE2, PYSIDE6, PythonQtError
|
||||
|
||||
if PYQT5:
|
||||
from PyQt5.QtCore import QT_VERSION_STR as __version__
|
||||
from PyQt5.QtCore import *
|
||||
from PyQt5.QtCore import pyqtProperty as Property # noqa
|
||||
from PyQt5.QtCore import pyqtSignal as Signal # noqa
|
||||
from PyQt5.QtCore import pyqtSlot as Slot # noqa
|
||||
|
||||
# Those are imported from `import *`
|
||||
del pyqtSignal, pyqtBoundSignal, pyqtSlot, pyqtProperty, QT_VERSION_STR
|
||||
elif PYQT6:
|
||||
from PyQt6.QtCore import QT_VERSION_STR as __version__
|
||||
from PyQt6.QtCore import *
|
||||
from PyQt6.QtCore import pyqtProperty as Property # noqa
|
||||
from PyQt6.QtCore import pyqtSignal as Signal # noqa
|
||||
from PyQt6.QtCore import pyqtSlot as Slot # noqa
|
||||
|
||||
# backwards compat with PyQt5
|
||||
# namespace moves:
|
||||
for cls in (QEvent, Qt):
|
||||
for attr in dir(cls):
|
||||
if not attr[0].isupper():
|
||||
continue
|
||||
ns = getattr(cls, attr)
|
||||
for name, val in vars(ns).items():
|
||||
if not name.startswith("_"):
|
||||
setattr(cls, name, val)
|
||||
|
||||
# Those are imported from `import *`
|
||||
del pyqtSignal, pyqtBoundSignal, pyqtSlot, pyqtProperty, QT_VERSION_STR
|
||||
elif PYSIDE2:
|
||||
import PySide2.QtCore
|
||||
from PySide2.QtCore import * # noqa
|
||||
|
||||
__version__ = PySide2.QtCore.__version__
|
||||
elif PYSIDE6:
|
||||
import PySide6.QtCore
|
||||
from PySide6.QtCore import * # noqa
|
||||
|
||||
__version__ = PySide6.QtCore.__version__
|
||||
|
||||
else:
|
||||
raise PythonQtError("No Qt bindings could be found")
|
@@ -1,43 +0,0 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
#
|
||||
# Copyright © 2014-2015 Colin Duquesnoy
|
||||
# Copyright © 2009- The Spyder Development Team
|
||||
#
|
||||
# Licensed under the terms of the MIT License
|
||||
# (see LICENSE.txt for details)
|
||||
|
||||
"""
|
||||
Modified from qtpy.QtGui
|
||||
Provides QtGui classes and functions.
|
||||
"""
|
||||
|
||||
from . import PYQT5, PYQT6, PYSIDE2, PYSIDE6, PythonQtError
|
||||
|
||||
if PYQT5:
|
||||
from PyQt5.QtGui import *
|
||||
elif PYSIDE2:
|
||||
from PySide2.QtGui import *
|
||||
elif PYQT6:
|
||||
from PyQt6.QtGui import *
|
||||
|
||||
# backwards compat with PyQt5
|
||||
# namespace moves:
|
||||
for cls in (QPalette,):
|
||||
for attr in dir(cls):
|
||||
if not attr[0].isupper():
|
||||
continue
|
||||
ns = getattr(cls, attr)
|
||||
for name, val in vars(ns).items():
|
||||
if not name.startswith("_"):
|
||||
setattr(cls, name, val)
|
||||
|
||||
def pos(self, *a):
|
||||
_pos = self.position(*a)
|
||||
return _pos.toPoint()
|
||||
|
||||
QMouseEvent.pos = pos
|
||||
|
||||
elif PYSIDE6:
|
||||
from PySide6.QtGui import * # noqa
|
||||
else:
|
||||
raise PythonQtError("No Qt bindings could be found")
|
@@ -1,43 +0,0 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
#
|
||||
# Copyright © 2014-2015 Colin Duquesnoy
|
||||
# Copyright © 2009- The Spyder Developmet Team
|
||||
#
|
||||
# Licensed under the terms of the MIT License
|
||||
# (see LICENSE.txt for details)
|
||||
|
||||
"""
|
||||
Modified from qtpy.QtWidgets
|
||||
Provides widget classes and functions.
|
||||
"""
|
||||
|
||||
from . import PYQT5, PYQT6, PYSIDE2, PYSIDE6, PythonQtError
|
||||
|
||||
if PYQT5:
|
||||
from PyQt5.QtWidgets import *
|
||||
elif PYSIDE2:
|
||||
from PySide2.QtWidgets import *
|
||||
elif PYQT6:
|
||||
from PyQt6.QtWidgets import *
|
||||
|
||||
# backwards compat with PyQt5
|
||||
# namespace moves:
|
||||
for cls in (QStyle, QSlider, QSizePolicy, QSpinBox):
|
||||
for attr in dir(cls):
|
||||
if not attr[0].isupper():
|
||||
continue
|
||||
ns = getattr(cls, attr)
|
||||
for name, val in vars(ns).items():
|
||||
if not name.startswith("_"):
|
||||
setattr(cls, name, val)
|
||||
|
||||
def exec_(self):
|
||||
self.exec()
|
||||
|
||||
QApplication.exec_ = exec_
|
||||
|
||||
elif PYSIDE6:
|
||||
from PySide6.QtWidgets import * # noqa
|
||||
|
||||
else:
|
||||
raise PythonQtError("No Qt bindings could be found")
|
@@ -1,167 +0,0 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
#
|
||||
# Copyright © 2009- The Spyder Development Team
|
||||
# Copyright © 2014-2015 Colin Duquesnoy
|
||||
#
|
||||
# Licensed under the terms of the MIT License
|
||||
# (see LICENSE.txt for details)
|
||||
|
||||
"""
|
||||
This file is borrowed from qtpy and modified to support PySide6/PyQt6 (drops PyQt4)
|
||||
"""
|
||||
|
||||
import os
|
||||
import platform
|
||||
import sys
|
||||
import warnings
|
||||
from distutils.version import LooseVersion
|
||||
|
||||
|
||||
class PythonQtError(RuntimeError):
|
||||
"""Error raise if no bindings could be selected."""
|
||||
|
||||
|
||||
class PythonQtWarning(Warning):
|
||||
"""Warning if some features are not implemented in a binding."""
|
||||
|
||||
|
||||
# Qt API environment variable name
|
||||
QT_API = "QT_API"
|
||||
|
||||
# Names of the expected PyQt5 api
|
||||
PYQT5_API = ["pyqt5"]
|
||||
|
||||
# Names of the expected PyQt6 api
|
||||
PYQT6_API = ["pyqt6"]
|
||||
|
||||
# Names of the expected PySide2 api
|
||||
PYSIDE2_API = ["pyside2"]
|
||||
|
||||
# Names of the expected PySide6 api
|
||||
PYSIDE6_API = ["pyside6"]
|
||||
|
||||
# Detecting if a binding was specified by the user
|
||||
binding_specified = QT_API in os.environ
|
||||
|
||||
# Setting a default value for QT_API
|
||||
os.environ.setdefault(QT_API, "pyqt5")
|
||||
|
||||
API = os.environ[QT_API].lower()
|
||||
initial_api = API
|
||||
assert API in (PYQT5_API + PYQT6_API + PYSIDE2_API + PYSIDE6_API)
|
||||
|
||||
PYQT5 = True
|
||||
PYSIDE2 = PYQT6 = PYSIDE6 = False
|
||||
|
||||
# When `FORCE_QT_API` is set, we disregard
|
||||
# any previously imported python bindings.
|
||||
if os.environ.get("FORCE_QT_API") is not None:
|
||||
if "PyQt5" in sys.modules:
|
||||
API = initial_api if initial_api in PYQT5_API else "pyqt5"
|
||||
elif "PySide2" in sys.modules:
|
||||
API = initial_api if initial_api in PYSIDE2_API else "pyside2"
|
||||
elif "PyQt6" in sys.modules:
|
||||
API = initial_api if initial_api in PYQT6_API else "pyqt6"
|
||||
elif "PySide6" in sys.modules:
|
||||
API = initial_api if initial_api in PYSIDE6_API else "pyside6"
|
||||
|
||||
|
||||
if API in PYQT5_API:
|
||||
try:
|
||||
from PyQt5.QtCore import PYQT_VERSION_STR as PYQT_VERSION # noqa
|
||||
from PyQt5.QtCore import QT_VERSION_STR as QT_VERSION # noqa
|
||||
|
||||
PYSIDE_VERSION = None # noqa
|
||||
|
||||
if sys.platform == "darwin":
|
||||
macos_version = LooseVersion(platform.mac_ver()[0])
|
||||
if macos_version < LooseVersion("10.10"):
|
||||
if LooseVersion(QT_VERSION) >= LooseVersion("5.9"):
|
||||
raise PythonQtError(
|
||||
"Qt 5.9 or higher only works in "
|
||||
"macOS 10.10 or higher. Your "
|
||||
"program will fail in this "
|
||||
"system."
|
||||
)
|
||||
elif macos_version < LooseVersion("10.11"):
|
||||
if LooseVersion(QT_VERSION) >= LooseVersion("5.11"):
|
||||
raise PythonQtError(
|
||||
"Qt 5.11 or higher only works in "
|
||||
"macOS 10.11 or higher. Your "
|
||||
"program will fail in this "
|
||||
"system."
|
||||
)
|
||||
|
||||
del macos_version
|
||||
except ImportError:
|
||||
API = os.environ["QT_API"] = "pyside2"
|
||||
|
||||
if API in PYSIDE2_API:
|
||||
try:
|
||||
from PySide2 import __version__ as PYSIDE_VERSION # noqa
|
||||
from PySide2.QtCore import __version__ as QT_VERSION # noqa
|
||||
|
||||
PYQT_VERSION = None # noqa
|
||||
PYQT5 = False
|
||||
PYSIDE2 = True
|
||||
|
||||
if sys.platform == "darwin":
|
||||
macos_version = LooseVersion(platform.mac_ver()[0])
|
||||
if macos_version < LooseVersion("10.11"):
|
||||
if LooseVersion(QT_VERSION) >= LooseVersion("5.11"):
|
||||
raise PythonQtError(
|
||||
"Qt 5.11 or higher only works in "
|
||||
"macOS 10.11 or higher. Your "
|
||||
"program will fail in this "
|
||||
"system."
|
||||
)
|
||||
|
||||
del macos_version
|
||||
except ImportError:
|
||||
API = os.environ["QT_API"] = "pyqt6"
|
||||
|
||||
if API in PYQT6_API:
|
||||
try:
|
||||
from PyQt6.QtCore import PYQT_VERSION_STR as PYQT_VERSION # noqa
|
||||
from PyQt6.QtCore import QT_VERSION_STR as QT_VERSION # noqa
|
||||
|
||||
PYSIDE_VERSION = None # noqa
|
||||
PYQT5 = False
|
||||
PYQT6 = True
|
||||
|
||||
except ImportError:
|
||||
API = os.environ["QT_API"] = "pyside6"
|
||||
|
||||
if API in PYSIDE6_API:
|
||||
try:
|
||||
from PySide6 import __version__ as PYSIDE_VERSION # noqa
|
||||
from PySide6.QtCore import __version__ as QT_VERSION # noqa
|
||||
|
||||
PYQT_VERSION = None # noqa
|
||||
PYQT5 = False
|
||||
PYSIDE6 = True
|
||||
|
||||
except ImportError:
|
||||
API = None
|
||||
|
||||
if API is None:
|
||||
raise PythonQtError(
|
||||
"No Qt bindings could be found.\nYou must install one of the following packages "
|
||||
"to use QtRangeSlider: PyQt5, PyQt6, PySide2, or PySide6"
|
||||
)
|
||||
|
||||
# If a correct API name is passed to QT_API and it could not be found,
|
||||
# switches to another and informs through the warning
|
||||
if API != initial_api and binding_specified:
|
||||
warnings.warn(
|
||||
'Selected binding "{}" could not be found, '
|
||||
'using "{}"'.format(initial_api, API),
|
||||
RuntimeWarning,
|
||||
)
|
||||
|
||||
API_NAME = {
|
||||
"pyqt5": "PyQt5",
|
||||
"pyqt6": "PyQt6",
|
||||
"pyside2": "PySide2",
|
||||
"pyside6": "PySide6",
|
||||
}[API]
|
68
setup.cfg
@@ -1,68 +0,0 @@
|
||||
[metadata]
|
||||
name = QtRangeSlider
|
||||
url = https://github.com/tlambert03/QtRangeSlider
|
||||
license = BSD-3
|
||||
license_file = LICENSE
|
||||
description = Multi-handle range slider widget for PyQt/PySide
|
||||
long_description = file: README.md, CHANGELOG.md
|
||||
long_description_content_type = text/markdown
|
||||
author = Talley Lambert
|
||||
author_email = talley.lambert@gmail.com
|
||||
keywords = qt, range slider, widget
|
||||
project_urls =
|
||||
Source = https://github.com/tlambert03/QtRangeSlider
|
||||
Tracker = https://github.com/tlambert03/QtRangeSlider/issues
|
||||
Changelog = https://github.com/tlambert03/QtRangeSlider/blob/master/CHANGELOG.md
|
||||
classifiers =
|
||||
Development Status :: 4 - Beta
|
||||
Environment :: X11 Applications :: Qt
|
||||
Intended Audience :: Developers
|
||||
License :: OSI Approved :: BSD License
|
||||
Operating System :: OS Independent
|
||||
Programming Language :: Python
|
||||
Programming Language :: Python :: 3 :: Only
|
||||
Programming Language :: Python :: 3.6
|
||||
Programming Language :: Python :: 3.7
|
||||
Programming Language :: Python :: 3.8
|
||||
Programming Language :: Python :: 3.9
|
||||
Topic :: Desktop Environment
|
||||
Topic :: Software Development
|
||||
Topic :: Software Development :: User Interfaces
|
||||
Topic :: Software Development :: Widget Sets
|
||||
|
||||
[options]
|
||||
zip_safe = False
|
||||
packages = find:
|
||||
python_requires = >=3.6
|
||||
setup_requires = setuptools_scm
|
||||
|
||||
[options.extras_require]
|
||||
pyside2 = pyside2
|
||||
pyqt5 = pyqt5
|
||||
pyside6 = pyside6
|
||||
pyqt6 = pyqt6
|
||||
testing =
|
||||
tox
|
||||
tox-conda
|
||||
pytest
|
||||
pytest-qt
|
||||
pytest-cov
|
||||
dev =
|
||||
ipython
|
||||
jedi<0.18.0
|
||||
isort
|
||||
mypy
|
||||
pre-commit
|
||||
%(testing)s
|
||||
%(pyqt5)s
|
||||
|
||||
[flake8]
|
||||
exclude = _version.py,.eggs,examples
|
||||
docstring-convention = numpy
|
||||
ignore = E203,W503,E501,C901,F403,F405
|
||||
|
||||
[isort]
|
||||
profile=black
|
||||
|
||||
[tool:pytest]
|
||||
addopts = -W error
|
10
setup.py
@@ -1,10 +0,0 @@
|
||||
"""
|
||||
PEP 517 doesn’t support editable installs
|
||||
so this file is currently here to support "pip install -e ."
|
||||
"""
|
||||
from setuptools import setup
|
||||
|
||||
setup(
|
||||
use_scm_version={"write_to": "qtrangeslider/_version.py"},
|
||||
setup_requires=["setuptools_scm"],
|
||||
)
|
67
src/superqt/__init__.py
Normal file
@@ -0,0 +1,67 @@
|
||||
"""superqt is a collection of Qt components for python."""
|
||||
|
||||
from importlib.metadata import PackageNotFoundError, version
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
try:
|
||||
__version__ = version("superqt")
|
||||
except PackageNotFoundError:
|
||||
__version__ = "unknown"
|
||||
|
||||
from .collapsible import QCollapsible
|
||||
from .combobox import QColorComboBox, QEnumComboBox, QSearchableComboBox
|
||||
from .elidable import QElidingLabel, QElidingLineEdit
|
||||
from .iconify import QIconifyIcon
|
||||
from .selection import QSearchableListWidget, QSearchableTreeWidget
|
||||
from .sliders import (
|
||||
QDoubleRangeSlider,
|
||||
QDoubleSlider,
|
||||
QLabeledDoubleRangeSlider,
|
||||
QLabeledDoubleSlider,
|
||||
QLabeledRangeSlider,
|
||||
QLabeledSlider,
|
||||
QRangeSlider,
|
||||
)
|
||||
from .spinbox import QLargeIntSpinBox
|
||||
from .utils import QMessageHandler, ensure_main_thread, ensure_object_thread
|
||||
|
||||
__all__ = [
|
||||
"ensure_main_thread",
|
||||
"ensure_object_thread",
|
||||
"QCollapsible",
|
||||
"QColorComboBox",
|
||||
"QColormapComboBox",
|
||||
"QDoubleRangeSlider",
|
||||
"QDoubleSlider",
|
||||
"QElidingLabel",
|
||||
"QElidingLineEdit",
|
||||
"QEnumComboBox",
|
||||
"QLabeledDoubleRangeSlider",
|
||||
"QIconifyIcon",
|
||||
"QLabeledDoubleSlider",
|
||||
"QLabeledRangeSlider",
|
||||
"QLabeledSlider",
|
||||
"QLargeIntSpinBox",
|
||||
"QMessageHandler",
|
||||
"QQuantity",
|
||||
"QRangeSlider",
|
||||
"QSearchableComboBox",
|
||||
"QSearchableListWidget",
|
||||
"QSearchableTreeWidget",
|
||||
]
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from .combobox import QColormapComboBox # noqa: TCH004
|
||||
from .spinbox._quantity import QQuantity # noqa: TCH004
|
||||
|
||||
|
||||
def __getattr__(name: str) -> Any:
|
||||
if name == "QQuantity":
|
||||
from .spinbox._quantity import QQuantity
|
||||
|
||||
return QQuantity
|
||||
if name == "QColormapComboBox":
|
||||
from .cmap import QColormapComboBox
|
||||
|
||||
return QColormapComboBox
|
||||
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
|
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
@@ -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
@@ -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
@@ -0,0 +1,109 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING, cast
|
||||
|
||||
from qtpy.QtCore import QModelIndex, QObject, QPersistentModelIndex, QRect, QSize, Qt
|
||||
from qtpy.QtGui import QColor, QPainter
|
||||
from qtpy.QtWidgets import QStyle, QStyledItemDelegate, QStyleOptionViewItem
|
||||
|
||||
from ._cmap_utils import CMAP_ROLE, draw_colormap, pick_font_color, try_cast_colormap
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from cmap import Colormap
|
||||
|
||||
DEFAULT_SIZE = QSize(80, 22)
|
||||
DEFAULT_BORDER_COLOR = QColor(Qt.GlobalColor.transparent)
|
||||
|
||||
|
||||
class QColormapItemDelegate(QStyledItemDelegate):
|
||||
"""Delegate that draws colormaps into a QAbstractItemView item.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
parent : QObject, optional
|
||||
The parent object.
|
||||
item_size : QSize, optional
|
||||
The size hint for each item, by default QSize(80, 22).
|
||||
fractional_colormap_width : float, optional
|
||||
The fraction of the widget width to use for the colormap swatch. If the
|
||||
colormap is full width (greater than 0.75), the swatch will be drawn behind
|
||||
the text. Otherwise, the swatch will be drawn to the left of the text.
|
||||
Default is 0.33.
|
||||
padding : int, optional
|
||||
The padding (in pixels) around the edge of the item, by default 1.
|
||||
checkerboard_size : int, optional
|
||||
Size (in pixels) of the checkerboard pattern to draw behind colormaps with
|
||||
transparency, by default 4. If 0, no checkerboard is drawn.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
parent: QObject | None = None,
|
||||
*,
|
||||
item_size: QSize = DEFAULT_SIZE,
|
||||
fractional_colormap_width: float = 1,
|
||||
padding: int = 1,
|
||||
checkerboard_size: int = 4,
|
||||
) -> None:
|
||||
super().__init__(parent)
|
||||
self._item_size = item_size
|
||||
self._colormap_fraction = fractional_colormap_width
|
||||
self._padding = padding
|
||||
self._border_color: QColor | None = DEFAULT_BORDER_COLOR
|
||||
self._checkerboard_size = checkerboard_size
|
||||
|
||||
def sizeHint(
|
||||
self, option: QStyleOptionViewItem, index: QModelIndex | QPersistentModelIndex
|
||||
) -> QSize:
|
||||
return super().sizeHint(option, index).expandedTo(self._item_size)
|
||||
|
||||
def paint(
|
||||
self,
|
||||
painter: QPainter,
|
||||
option: QStyleOptionViewItem,
|
||||
index: QModelIndex | QPersistentModelIndex,
|
||||
) -> None:
|
||||
self.initStyleOption(option, index)
|
||||
rect = cast("QRect", option.rect) # type: ignore
|
||||
selected = option.state & QStyle.StateFlag.State_Selected # type: ignore
|
||||
text = index.data(Qt.ItemDataRole.DisplayRole)
|
||||
colormap: Colormap | None = index.data(CMAP_ROLE) or try_cast_colormap(text)
|
||||
|
||||
if not colormap: # pragma: no cover
|
||||
return super().paint(painter, option, index)
|
||||
|
||||
painter.save()
|
||||
rect.adjust(self._padding, self._padding, -self._padding, -self._padding)
|
||||
cmap_rect = QRect(rect)
|
||||
cmap_rect.setWidth(int(rect.width() * self._colormap_fraction))
|
||||
|
||||
lighter = 110 if selected else 100
|
||||
border = self._border_color if selected else None
|
||||
draw_colormap(
|
||||
painter,
|
||||
colormap,
|
||||
cmap_rect,
|
||||
lighter=lighter,
|
||||
border_color=border,
|
||||
checkerboard_size=self._checkerboard_size,
|
||||
)
|
||||
|
||||
# # make new rect with the remaining space
|
||||
text_rect = QRect(rect)
|
||||
|
||||
if self._colormap_fraction > 0.75:
|
||||
text_align = Qt.AlignmentFlag.AlignCenter
|
||||
alpha = 230 if selected else 140
|
||||
text_color = pick_font_color(colormap, alpha=alpha)
|
||||
else:
|
||||
text_align = Qt.AlignmentFlag.AlignLeft | Qt.AlignmentFlag.AlignVCenter
|
||||
text_color = QColor(Qt.GlobalColor.black)
|
||||
text_rect.adjust(
|
||||
cmap_rect.width() + self._padding + 4, 0, -self._padding - 2, 0
|
||||
)
|
||||
|
||||
painter.setPen(text_color)
|
||||
# cast to int works all the way back to Qt 5.12...
|
||||
# but the enum only works since Qt 5.14
|
||||
painter.drawText(text_rect, int(text_align), text)
|
||||
painter.restore()
|
133
src/superqt/cmap/_cmap_line_edit.py
Normal file
@@ -0,0 +1,133 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from qtpy.QtCore import Qt
|
||||
from qtpy.QtGui import QIcon, QPainter, QPaintEvent, QPalette
|
||||
from qtpy.QtWidgets import QApplication, QLineEdit, QStyle, QWidget
|
||||
|
||||
from ._cmap_utils import draw_colormap, pick_font_color, try_cast_colormap
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from cmap import Colormap
|
||||
|
||||
MISSING = QStyle.StandardPixmap.SP_TitleBarContextHelpButton
|
||||
|
||||
|
||||
class QColormapLineEdit(QLineEdit):
|
||||
"""A QLineEdit that shows a colormap swatch.
|
||||
|
||||
When the current text is a valid colormap name from the `cmap` package, a swatch
|
||||
of the colormap will be shown to the left of the text (if `fractionalColormapWidth`
|
||||
is less than .75) or behind the text (for when the colormap fills the full width).
|
||||
|
||||
If the current text is not a valid colormap name, a swatch of the fallback colormap
|
||||
will be shown instead (by default, a gray colormap) if `fractionalColormapWidth` is
|
||||
less than .75.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
parent : QWidget, optional
|
||||
The parent widget.
|
||||
fractional_colormap_width : float, optional
|
||||
The fraction of the widget width to use for the colormap swatch. If the
|
||||
colormap is full width (greater than 0.75), the swatch will be drawn behind
|
||||
the text. Otherwise, the swatch will be drawn to the left of the text.
|
||||
Default is 0.33.
|
||||
fallback_cmap : Colormap | str | None, optional
|
||||
The colormap to use when the current text is not a recognized colormap.
|
||||
by default "gray".
|
||||
missing_icon : QIcon | QStyle.StandardPixmap, optional
|
||||
The icon to show when the current text is not a recognized colormap and
|
||||
`fractionalColormapWidth` is less than .75. Default is a question mark.
|
||||
checkerboard_size : int, optional
|
||||
Size (in pixels) of the checkerboard pattern to draw behind colormaps with
|
||||
transparency, by default 4. If 0, no checkerboard is drawn.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
parent: QWidget | None = None,
|
||||
*,
|
||||
fractional_colormap_width: float = 0.33,
|
||||
fallback_cmap: Colormap | str | None = "gray",
|
||||
missing_icon: QIcon | QStyle.StandardPixmap = MISSING,
|
||||
checkerboard_size: int = 4,
|
||||
) -> None:
|
||||
super().__init__(parent)
|
||||
self.setFractionalColormapWidth(fractional_colormap_width)
|
||||
self.setMissingColormap(fallback_cmap)
|
||||
self._checkerboard_size = checkerboard_size
|
||||
|
||||
if isinstance(missing_icon, QStyle.StandardPixmap):
|
||||
self._missing_icon: QIcon = self.style().standardIcon(missing_icon)
|
||||
elif isinstance(missing_icon, QIcon):
|
||||
self._missing_icon = missing_icon
|
||||
else: # pragma: no cover
|
||||
raise TypeError("missing_icon must be a QIcon or QStyle.StandardPixmap")
|
||||
|
||||
self._cmap: Colormap | None = None # current colormap
|
||||
self.textChanged.connect(self.setColormap)
|
||||
|
||||
def setFractionalColormapWidth(self, fraction: float) -> None:
|
||||
self._colormap_fraction: float = float(fraction)
|
||||
align = Qt.AlignmentFlag.AlignVCenter
|
||||
if self._cmap_is_full_width():
|
||||
align |= Qt.AlignmentFlag.AlignCenter
|
||||
else:
|
||||
align |= Qt.AlignmentFlag.AlignLeft
|
||||
self.setAlignment(align)
|
||||
|
||||
def fractionalColormapWidth(self) -> float:
|
||||
return self._colormap_fraction
|
||||
|
||||
def setMissingColormap(self, cmap: Colormap | str | None) -> None:
|
||||
self._missing_cmap: Colormap | None = try_cast_colormap(cmap)
|
||||
|
||||
def colormap(self) -> Colormap | None:
|
||||
return self._cmap
|
||||
|
||||
def setColormap(self, cmap: Colormap | str | None) -> None:
|
||||
self._cmap = try_cast_colormap(cmap)
|
||||
|
||||
# set self font color to contrast with the colormap
|
||||
if self._cmap and self._cmap_is_full_width():
|
||||
text = pick_font_color(self._cmap)
|
||||
else:
|
||||
text = QApplication.palette().color(QPalette.ColorRole.Text)
|
||||
|
||||
palette = self.palette()
|
||||
palette.setColor(QPalette.ColorRole.Text, text)
|
||||
self.setPalette(palette)
|
||||
|
||||
def _cmap_is_full_width(self):
|
||||
return self._colormap_fraction >= 0.75
|
||||
|
||||
def paintEvent(self, e: QPaintEvent) -> None:
|
||||
# don't draw the background
|
||||
# otherwise it will cover the colormap during super().paintEvent
|
||||
# FIXME: this appears to need to be reset during every paint event...
|
||||
# otherwise something is resetting it
|
||||
palette = self.palette()
|
||||
palette.setColor(palette.ColorRole.Base, Qt.GlobalColor.transparent)
|
||||
self.setPalette(palette)
|
||||
|
||||
cmap_rect = self.rect().adjusted(2, 0, 0, 0)
|
||||
cmap_rect.setWidth(int(cmap_rect.width() * self._colormap_fraction))
|
||||
|
||||
left_margin = 6
|
||||
if not self._cmap_is_full_width():
|
||||
# leave room for the colormap
|
||||
left_margin += cmap_rect.width()
|
||||
self.setTextMargins(left_margin, 2, 0, 0)
|
||||
|
||||
if self._cmap:
|
||||
draw_colormap(
|
||||
self, self._cmap, cmap_rect, checkerboard_size=self._checkerboard_size
|
||||
)
|
||||
elif not self._cmap_is_full_width():
|
||||
if self._missing_cmap:
|
||||
draw_colormap(self, self._missing_cmap, cmap_rect)
|
||||
self._missing_icon.paint(QPainter(self), cmap_rect.adjusted(4, 4, 0, -4))
|
||||
|
||||
super().paintEvent(e) # draw text (must come after draw_colormap)
|
164
src/superqt/cmap/_cmap_utils.py
Normal file
@@ -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
|
3
src/superqt/collapsible/__init__.py
Normal file
@@ -0,0 +1,3 @@
|
||||
from ._collapsible import QCollapsible
|
||||
|
||||
__all__ = ["QCollapsible"]
|
218
src/superqt/collapsible/_collapsible.py
Normal file
@@ -0,0 +1,218 @@
|
||||
"""A collapsible widget to hide and unhide child widgets."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from qtpy.QtCore import (
|
||||
QEasingCurve,
|
||||
QEvent,
|
||||
QMargins,
|
||||
QObject,
|
||||
QPropertyAnimation,
|
||||
QRect,
|
||||
Qt,
|
||||
Signal,
|
||||
)
|
||||
from qtpy.QtGui import QIcon, QPainter, QPalette, QPixmap
|
||||
from qtpy.QtWidgets import QFrame, QPushButton, QSizePolicy, QVBoxLayout, QWidget
|
||||
|
||||
|
||||
class QCollapsible(QFrame):
|
||||
"""A collapsible widget to hide and unhide child widgets.
|
||||
|
||||
A signal is emitted when the widget is expanded (True) or collapsed (False).
|
||||
|
||||
Based on https://stackoverflow.com/a/68141638
|
||||
"""
|
||||
|
||||
toggled = Signal(bool)
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
title: str = "",
|
||||
parent: QWidget | None = None,
|
||||
expandedIcon: QIcon | str | None = "▼",
|
||||
collapsedIcon: QIcon | str | None = "▲",
|
||||
):
|
||||
super().__init__(parent)
|
||||
self._locked = False
|
||||
self._is_animating = False
|
||||
self._text = title
|
||||
|
||||
self._toggle_btn = QPushButton(title)
|
||||
self._toggle_btn.setCheckable(True)
|
||||
self.setCollapsedIcon(icon=collapsedIcon)
|
||||
self.setExpandedIcon(icon=expandedIcon)
|
||||
self.setSizePolicy(QSizePolicy.Policy.Preferred, QSizePolicy.Policy.Maximum)
|
||||
|
||||
self._toggle_btn.setStyleSheet("text-align: left; border: none; outline: none;")
|
||||
self._toggle_btn.toggled.connect(self._toggle)
|
||||
|
||||
# frame layout
|
||||
layout = QVBoxLayout(self)
|
||||
layout.setAlignment(Qt.AlignmentFlag.AlignTop)
|
||||
layout.addWidget(self._toggle_btn)
|
||||
|
||||
# Create animators
|
||||
self._animation = QPropertyAnimation(self)
|
||||
self._animation.setPropertyName(b"maximumHeight")
|
||||
self._animation.setStartValue(0)
|
||||
self._animation.finished.connect(self._on_animation_done)
|
||||
self.setDuration(300)
|
||||
self.setEasingCurve(QEasingCurve.Type.InOutCubic)
|
||||
|
||||
# default content widget
|
||||
_content = QWidget()
|
||||
_content.setLayout(QVBoxLayout())
|
||||
_content.setMaximumHeight(0)
|
||||
_content.layout().setContentsMargins(QMargins(5, 0, 0, 0))
|
||||
self.setContent(_content)
|
||||
|
||||
def toggleButton(self) -> QPushButton:
|
||||
"""Return the toggle button."""
|
||||
return self._toggle_btn
|
||||
|
||||
def setText(self, text: str) -> None:
|
||||
"""Set the text of the toggle button."""
|
||||
self._toggle_btn.setText(text)
|
||||
|
||||
def text(self) -> str:
|
||||
"""Return the text of the toggle button."""
|
||||
return self._toggle_btn.text()
|
||||
|
||||
def setContent(self, content: QWidget) -> None:
|
||||
"""Replace central widget (the widget that gets expanded/collapsed)."""
|
||||
self._content = content
|
||||
self.layout().addWidget(self._content)
|
||||
self._animation.setTargetObject(content)
|
||||
|
||||
def content(self) -> QWidget:
|
||||
"""Return the current content widget."""
|
||||
return self._content
|
||||
|
||||
def _convert_string_to_icon(self, symbol: str) -> QIcon:
|
||||
"""Create a QIcon from a string."""
|
||||
size = self._toggle_btn.font().pointSize()
|
||||
pixmap = QPixmap(size, size)
|
||||
pixmap.fill(Qt.GlobalColor.transparent)
|
||||
painter = QPainter(pixmap)
|
||||
color = self._toggle_btn.palette().color(QPalette.ColorRole.WindowText)
|
||||
painter.setPen(color)
|
||||
painter.drawText(QRect(0, 0, size, size), Qt.AlignmentFlag.AlignCenter, symbol)
|
||||
painter.end()
|
||||
return QIcon(pixmap)
|
||||
|
||||
def expandedIcon(self) -> QIcon:
|
||||
"""Returns the icon used when the widget is expanded."""
|
||||
return self._expanded_icon
|
||||
|
||||
def setExpandedIcon(self, icon: QIcon | str | None = None) -> None:
|
||||
"""Set the icon on the toggle button when the widget is expanded."""
|
||||
if icon and isinstance(icon, QIcon):
|
||||
self._expanded_icon = icon
|
||||
elif icon and isinstance(icon, str):
|
||||
self._expanded_icon = self._convert_string_to_icon(icon)
|
||||
|
||||
if self.isExpanded():
|
||||
self._toggle_btn.setIcon(self._expanded_icon)
|
||||
|
||||
def collapsedIcon(self) -> QIcon:
|
||||
"""Returns the icon used when the widget is collapsed."""
|
||||
return self._collapsed_icon
|
||||
|
||||
def setCollapsedIcon(self, icon: QIcon | str | None = None) -> None:
|
||||
"""Set the icon on the toggle button when the widget is collapsed."""
|
||||
if icon and isinstance(icon, QIcon):
|
||||
self._collapsed_icon = icon
|
||||
elif icon and isinstance(icon, str):
|
||||
self._collapsed_icon = self._convert_string_to_icon(icon)
|
||||
|
||||
if not self.isExpanded():
|
||||
self._toggle_btn.setIcon(self._collapsed_icon)
|
||||
|
||||
def setDuration(self, msecs: int) -> None:
|
||||
"""Set duration of the collapse/expand animation."""
|
||||
self._animation.setDuration(msecs)
|
||||
|
||||
def setEasingCurve(self, easing: QEasingCurve | QEasingCurve.Type) -> None:
|
||||
"""Set the easing curve for the collapse/expand animation."""
|
||||
self._animation.setEasingCurve(easing)
|
||||
|
||||
def addWidget(self, widget: QWidget) -> None:
|
||||
"""Add a widget to the central content widget's layout."""
|
||||
widget.installEventFilter(self)
|
||||
self._content.layout().addWidget(widget)
|
||||
|
||||
def removeWidget(self, widget: QWidget) -> None:
|
||||
"""Remove widget from the central content widget's layout."""
|
||||
self._content.layout().removeWidget(widget)
|
||||
widget.removeEventFilter(self)
|
||||
|
||||
def expand(self, animate: bool = True) -> None:
|
||||
"""Expand (show) the collapsible section."""
|
||||
self._expand_collapse(QPropertyAnimation.Direction.Forward, animate)
|
||||
|
||||
def collapse(self, animate: bool = True) -> None:
|
||||
"""Collapse (hide) the collapsible section."""
|
||||
self._expand_collapse(QPropertyAnimation.Direction.Backward, animate)
|
||||
|
||||
def isExpanded(self) -> bool:
|
||||
"""Return whether the collapsible section is visible."""
|
||||
return self._toggle_btn.isChecked()
|
||||
|
||||
def setLocked(self, locked: bool = True) -> None:
|
||||
"""Set whether collapse/expand is disabled."""
|
||||
self._locked = locked
|
||||
self._toggle_btn.setCheckable(not locked)
|
||||
|
||||
def locked(self) -> bool:
|
||||
"""Return True if collapse/expand is disabled."""
|
||||
return self._locked
|
||||
|
||||
def _expand_collapse(
|
||||
self,
|
||||
direction: QPropertyAnimation.Direction,
|
||||
animate: bool = True,
|
||||
emit: bool = True,
|
||||
) -> None:
|
||||
"""Set values for the widget based on whether it is expanding or collapsing.
|
||||
|
||||
An emit flag is included so that the toggle signal is only called once (it
|
||||
was being emitted a few times via eventFilter when the widget was expanding
|
||||
previously).
|
||||
"""
|
||||
if self._locked:
|
||||
return
|
||||
|
||||
forward = direction == QPropertyAnimation.Direction.Forward
|
||||
icon = self._expanded_icon if forward else self._collapsed_icon
|
||||
self._toggle_btn.setIcon(icon)
|
||||
self._toggle_btn.setChecked(forward)
|
||||
|
||||
_content_height = self._content.sizeHint().height() + 10
|
||||
if animate:
|
||||
self._animation.setDirection(direction)
|
||||
self._animation.setEndValue(_content_height)
|
||||
self._is_animating = True
|
||||
self._animation.start()
|
||||
else:
|
||||
self._content.setMaximumHeight(_content_height if forward else 0)
|
||||
if emit:
|
||||
self.toggled.emit(direction == QPropertyAnimation.Direction.Forward)
|
||||
|
||||
def _toggle(self) -> None:
|
||||
self.expand() if self.isExpanded() else self.collapse()
|
||||
|
||||
def eventFilter(self, a0: QObject, a1: QEvent) -> bool:
|
||||
"""If a child widget resizes, we need to update our expanded height."""
|
||||
if (
|
||||
a1.type() == QEvent.Type.Resize
|
||||
and self.isExpanded()
|
||||
and not self._is_animating
|
||||
):
|
||||
self._expand_collapse(
|
||||
QPropertyAnimation.Direction.Forward, animate=False, emit=False
|
||||
)
|
||||
return False
|
||||
|
||||
def _on_animation_done(self) -> None:
|
||||
self._is_animating = False
|
24
src/superqt/combobox/__init__.py
Normal file
@@ -0,0 +1,24 @@
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
from ._color_combobox import QColorComboBox
|
||||
from ._enum_combobox import QEnumComboBox
|
||||
from ._searchable_combo_box import QSearchableComboBox
|
||||
|
||||
__all__ = (
|
||||
"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
@@ -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)
|